为什么按位置读取JDBC ResultSet比按名称快,快多少?

22
宣布Hibernate 6发布Hibernate团队声称,通过在JDBC ResultSet中从按名称读取切换到按位置读取,他们获得了性能优势。

高负载性能测试表明,Hibernate从ResultSet按名称读取值的方法是其在扩展吞吐量方面最具限制性的因素。

这是否意味着他们将调用从getString(String columnLabel)更改为getString(int columnIndex)

这样做为什么更快?

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

收益有多大?

2个回答

25

作为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 Oracle HSQLDB SQL Server(Microsoft JDBC Driver for SQL Server) 我不知道有哪些JDBC驱动程序可以通过列名检索等效或者成本更低。

2
很好的解释。我想知道是否可以将requested列名作为映射中的键,而不是事先使用小写列名(因此懒惰地构建映射,在每次调用getXxx()方法时添加一个条目),这样对于大型结果集会更快:它将避免为结果集的每一行重复转换相同的键。 - JB Nizet
2
@JBNizet 可能是。这就是我们在Jaybird中实际做的事情,但我不想在这里让事情变得混乱 :) - Mark Rotteveel

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

RDBMS 支持

并非所有 JDBC 驱动程序都支持通过名称访问列。我忘记了哪些不支持,以及它们是否仍然不支持,因为在 13 年中我从未再次接触过 JDBC 的 API 的这一部分。但是有些驱动程序不支持,这对我来说已经是一个停机点。

名称的语义

此外,在那些支持列名称的驱动程序中,存在不同的列名称语义,主要有两种,JDBC 称之为:

关于上述两个的实现存在很多歧义,尽管我认为意图非常清晰:

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

因此,名称/标签是什么的不确定性已经非常令人困惑和担忧。这似乎并不是 ORM 在一般情况下应该依赖的东西,尽管在 Hibernate 的情况下,可以认为 Hibernate 控制着生成的大部分 SQL,至少是用于获取实体的 SQL。但是,如果用户编写 HQL 或本地 SQL 查询,我将不愿意依赖名称/标签-至少不在未首先在 ResultSetMetaData 中查找信息的情况下。

歧义

在 SQL 中,顶层可以有模糊的列名称,例如:

SELECT id, id, not_the_id AS id
FROM book

这是合法的 SQL 语句。虽然在派生表中不能嵌套此查询,因为其中可能会存在歧义,但在顶层 SELECT 中可以使用。那么,在顶层有重复的 ID 标签,你要怎样处理呢?无法确定按名称访问时你将获得哪个标签。前两个可能相同,但第三个则很不同。
唯一清晰地区分列的方式是通过唯一的索引:1、2 或 3。
性能方面,我当时也尝试过。虽然我没有这个基准测试结果了,但很容易快速编写另一个基准测试。在下面的基准测试中,我在 H2 的内存实例上运行一个简单的查询,并访问以下内容:
- 按索引 - 按名称
结果令人震惊。
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中使用列名肯定是一些历史遗留问题。这可能也是为什么他们用这些不太可读的列别名生成器,以确保每个别名都是非歧义的。
无论实际查询中获得的收益如何,似乎这是一个明显的改进。即使提高只有2%,它也是值得的,因为它影响每个基于Hibernate的应用程序的每个查询执行。

以下是基准代码,可进行复制

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"));
            }
        });
    }
}

1
关于“名称的语义”,在JDBC规范中也曾经不是很清晰,但现在更加明确了getXXX(String)应该通过列标签而不是列名来检索。不幸的是,驱动程序中的向后兼容性使错误的行为仍然存在。 - Mark Rotteveel
1
就“歧义”而言,JDBC要求始终返回第一个匹配的列,因此尽管不方便,但不应该存在歧义。 - Mark Rotteveel
1
@MarkRotteveel:感谢您的澄清。虽然规范(以及可能的实现)在行为方面并不含糊,但歧义源于意图。想象一下改变SELECT *查询的JOIN顺序,或者用USING替换ON。突然之间,一个之前运行良好的查询会因为ID列出现的顺序改变而改变行为。 - Lukas Eder

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接