为什么按位置读取 JDBC 结果集比按名称读取快多少?

Why is reading a JDBC ResultSet by position faster than by name and how much faster?

Announcing Hibernate 6 Hibernate 团队声称通过从 按名称读取到 JDBC ResultSet 中的按位置读取它们获得了性能优势。

High-load performance testing showed that Hibernate’s approach of reading values from ResultSet by name to be its most limiting factor in scaling through-put.

这是否意味着他们正在改变来自 getString(String columnLabel) to getString(int columnIndex) 的呼叫?

为什么这样更快?

由于 ResultSet 是一个接口,性能增益是否取决于实现它的 JDBC 驱动程序?

收益有多大?

作为 JDBC 驱动程序维护者(并且,我承认,进行了一些不一定适用于所有 JDBC 驱动程序的全面概括),行值通常存储在数组或列表中因为这最符合从数据库服务器接收数据的方式。

因此,按索引检索值将是最简单的。它可能像这样简单(忽略实现 JDBC 驱动程序的一些更糟糕的细节):

public Object getObject(int index) throws SQLException {
    checkValidRow();
    checkValidIndex(index);
    return currentRow[index - 1];
}

这差不多快了。

另一方面,按列名查找比较麻烦。列名需要区分大小写,无论您是使用小写还是大写进行规范化,还是使用 TreeMap 进行不区分大小写的查找,都会产生额外的成本。

一个简单的实现可能是这样的:

public Object getObject(String columnLabel) throws SQLException {
    return getObject(getIndexByLabel(columnLabel));
}

private int getIndexByLabel(String columnLabel) {
    Map<String, Integer> indexMap = createOrGetIndexMap();
    Integer columnIndex = indexMap.get(columnLabel.toLowerCase());
    if (columnIndex == null) {
        throw new SQLException("Column label " + columnLabel + " does not exist in the result set");
    }
    return columnIndex;
}

private Map<String, Integer> createOrGetIndexMap() throws SQLException {
    if (this.indexMap != null) {
        return this.indexMap;
    }
    ResultSetMetaData rsmd = getMetaData();
    Map<String, Integer> map = new HashMap<>(rsmd.getColumnCount());
    // reverse loop to ensure first occurrence of a column label is retained
    for (int idx = rsmd.getColumnCount(); idx > 0; idx--) {
        String label = rsmd.getColumnLabel(idx).toLowerCase();
        map.put(label, idx);
    }
    return this.indexMap = map;
}

根据数据库的 API 和可用的语句元数据,可能需要额外处理以确定查询的实际列标签。根据成本,这可能只会在实际需要时才确定(按名称访问列标签时,或检索结果集元数据时)。换句话说,createOrGetIndexMap() 的成本可能相当高。

但即使这个开销可以忽略不计(例如从数据库服务器准备元数据的语句包括列标签),将列标签映射到索引然后通过索引检索的开销明显高于直接通过索引检索.

驱动程序甚至可以每次都循环遍历结果集元数据并使用第一个标签匹配的;这可能比为具有少量列的结果集构建和访问哈希映射更便宜,但成本仍然高于通过索引直接访问。

正如我所说,这是一个笼统的概括,但如果这(按名称查找索引,然后按索引检索)不是它在大多数 JDBC 驱动程序中的工作方式,我会感到惊讶,这意味着我希望按索引查找通常会更快。

快速浏览一些驱动程序,情况如下:

  • Firebird(Jaybird,披露:我维护此驱动程序)
  • MySQL (MySQL Connector/J)
  • PostgreSQL
  • 甲骨文
  • HSQLDB
  • SQL 服务器(Microsoft JDBC SQL 服务器的驱动程序)

我不知道 JDBC 驱动程序在其中按列名检索的成本相当甚至更便宜。

在制作 jOOQ 的早期,我考虑了两种选择,通过索引或名称访问 JDBC ResultSet 值。由于以下原因,我选择按索引访问内容:

RDBMS 支持

并非所有 JDBC 驱动程序实际上都支持按名称访问列。我忘记了哪些没有,如果他们仍然没有,因为我在 13 年内再也没有接触过 JDBC 的 API 的那部分。但有些人没有,这对我来说已经是一个表演障碍。

名字的语义

此外,在支持列名的那些中,列名有不同的语义,主要有两种,JDBC调用:

以上两个的实现有很多歧义,尽管我认为意图很明确:

  • 列名应该生成列的名称,而不考虑别名,例如TITLE 如果投影表达式是 BOOK.TITLE AS X
  • 列标签应该生成列的标签(或别名),如果没有可用的别名,则生成名称,例如X 如果投影表达式是 BOOK.TITLE AS X

所以,name/label 的这种含糊不清已经让人非常困惑和担忧了。 ORM 似乎不应该依赖 通常 ,尽管在 Hibernate 的情况下,可以争辩说 Hibernate 控制着 most正在生成 SQL,至少生成用于获取实体的 SQL。但是,如果用户编写 HQL 或本机 SQL 查询,我将不愿意依赖 name/label - 至少不首先在 ResultSetMetaData 中查找内容。

歧义

在 SQL 中,在顶层使用不明确的列名是完全可以的,例如:

SELECT id, id, not_the_id AS id
FROM book

这是完全正确的 SQL。您不能将此查询嵌套为派生的 table,其中不允许出现歧义,但在顶层 SELECT 中可以。现在,您打算如何处理顶层那些重复的 ID 标签?当按名称访问事物时,您无法确定会得到哪一个。前两个可能相同,但第三个非常不同。

明确区分列的唯一方法是通过索引,索引是唯一的:123.

性能

我当时也尝试过表演。我没有基准测试结果了,但是很容易快速编写另一个基准测试。在下面的基准测试中,我是 运行 H2 in-memory 实例上的一个简单查询,并使用 ResultSet 访问事物:

  • 按索引
  • 按名字

结果令人震惊:

Benchmark                            Mode  Cnt        Score       Error  Units
JDBCResultSetBenchmark.indexAccess  thrpt    7  1130734.076 ±  9035.404  ops/s
JDBCResultSetBenchmark.nameAccess   thrpt    7   600540.553 ± 13217.954  ops/s

尽管基准测试 运行 每次 调用 上的整个查询,但按索引访问的速度几乎是原来的两倍!你可以看看H2的代码,它是开源的。它这样做(版本 2.1.212):

private int getColumnIndex(String columnLabel) {
    checkClosed();
    if (columnLabel == null) {
        throw DbException.getInvalidValueException("columnLabel", null);
    }
    if (columnCount >= 3) {
        // use a hash table if more than 2 columns
        if (columnLabelMap == null) {
            HashMap<String, Integer> map = new HashMap<>();
            // [ ... ]

            columnLabelMap = map;
            if (preparedStatement != null) {
                preparedStatement.setCachedColumnLabelMap(columnLabelMap);
            }
        }
        Integer index = columnLabelMap.get(StringUtils.toUpperEnglish(columnLabel));
        if (index == null) {
            throw DbException.get(ErrorCode.COLUMN_NOT_FOUND_1, columnLabel);
        }
        return index + 1;
    }
    // [ ... ]

所以。有一个带有大写字母的哈希图,每次查找也会执行大写字母。至少,它在准备好的语句中缓存了地图,所以:

  • 您可以在每一行重复使用它
  • 您可以在语句的多次执行中重用它(至少我是这样解释代码的)

因此,对于非常大的结果集,它可能不再那么重要,但对于小的结果集来说,它确实很重要。

ORM 的结论

像 Hibernate 或 jOOQ 这样的 ORM 控制着很多 SQL 和结果集。它确切地知道什么列在什么位置,生成 SQL 查询时已经完成了这项工作。因此,当结果集从数据库服务器返回时,绝对没有理由进一步依赖列名。每个值都会在预期的位置。

使用列名在 Hibernate 中一定是历史悠久的事情。这可能也是为什么他们过去常常生成这些不太可读的列别名,以确保每个别名都是 non-ambiguous.

这似乎是一个明显的改进,与现实世界 (non-benchmark) 查询中的实际收益无关。即使改进只有 2%,也是值得的,因为它会影响 every 基于 Hibernate 的应用程序的 every 查询执行。

下面的基准代码,用于复现

package org.jooq.test.benchmarks.local;

import java.io.*;
import java.sql.*;
import java.util.Properties;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.*;

@Fork(value = 1)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 7, time = 3)
public class JDBCResultSetBenchmark {

    @State(Scope.Benchmark)
    public static class BenchmarkState {

        Connection connection;

        @Setup(Level.Trial)
        public void setup() throws Exception {
            try (InputStream is = BenchmarkState.class.getResourceAsStream("/config.properties")) {
                Properties p = new Properties();
                p.load(is);
                connection = DriverManager.getConnection(
                    p.getProperty("db.url"),
                    p.getProperty("db.username"),
                    p.getProperty("db.password")
                );
            }
        }

        @TearDown(Level.Trial)
        public void teardown() throws Exception {
            connection.close();
        }
    }

    @FunctionalInterface
    interface ThrowingConsumer<T> {
        void accept(T t) throws SQLException;
    }

    private void run(BenchmarkState state, ThrowingConsumer<ResultSet> c) throws SQLException {
        try (Statement s = state.connection.createStatement();
            ResultSet rs = s.executeQuery("select c as c1, c as c2, c as c3, c as c4 from system_range(1, 10) as t(c);")) {
            c.accept(rs);
        }
    }

    @Benchmark
    public void indexAccess(Blackhole blackhole, BenchmarkState state) throws SQLException {
        run(state, rs -> {
            while (rs.next()) {
                blackhole.consume(rs.getInt(1));
                blackhole.consume(rs.getInt(2));
                blackhole.consume(rs.getInt(3));
                blackhole.consume(rs.getInt(4));
            }
        });
    }

    @Benchmark
    public void nameAccess(Blackhole blackhole, BenchmarkState state) throws SQLException {
        run(state, rs -> {
            while (rs.next()) {
                blackhole.consume(rs.getInt("C1"));
                blackhole.consume(rs.getInt("C2"));
                blackhole.consume(rs.getInt("C3"));
                blackhole.consume(rs.getInt("C4"));
            }
        });
    }
}