我给 PostgreSQL 官方 JDBC 驱动修复了一个高并发性能问题

这是我在 2022 年给 PostgreSQL 官方 JDBC 驱动 修复的一个高并发性能问题。

该问题影响的版本范围是 pgjdbc:

  • 42.3.2
  • 42.3.3

Issue: Concurrent performance issue in 42.3.2 caused by #2291 https://github.com/pgjdbc/pgjdbc/issues/2450
PR: Use non-synchronized getTimeZone in TimestampUtils #2451 https://github.com/pgjdbc/pgjdbc/pull/2451

在 ShardingSphere-Proxy 峰值性能测试中发现问题

这个问题是我在对 ShardingSphere-Proxy + PostgreSQL 进行性能测试的过程中发现的。

性能测试过程中,发现 ShardingSphere-Proxy CPU 使用率及实时峰值 TPS 比前两天的测试有所下降,遂在测试过程中使用 async-profiler 对 JVM 进行采样。
采样时设置了 --lock 门槛为 1us,以 jfr 格式导出采样信息。

采样执行了不到 5 分钟,使用 IDEA 打开 jfr 文件,发现在 TimeZone 类调用上有大量的 ObjectMonitor(也就是 synchronized 代码块发生了多线程竞争)。
我给 PostgreSQL 官方 JDBC 驱动修复了一个高并发性能问题_第1张图片
Monitor Blocked 事件数量庞大,5 分钟达到百万级,说明 synchronized 多线程竞争激烈。
我给 PostgreSQL 官方 JDBC 驱动修复了一个高并发性能问题_第2张图片

也可以用 Java Mission Control 打开 jfr 文件进行分析。

采样过程不足 5 分钟,但 Total Block Time 却高达 3.4 小时,说明大量线程受 synchronized 影响。

为什么 Total Block Time 会高于实际采样时长?
Total Block Time 是所有线程等待时长的和。
举个例子:
假如有一个 synchronized 代码块,临界区内代码需要运行 5 分钟。目前有线程 A、B、C 同时尝试进入 synchronized 代码块,A 成功进入临界区,线程 B、C 则需要等待 5 分钟后才可能进入临界区。如果在 A 进入临界区前开始采样,并在 A 离开临界区后结束采样,此时的 Total Block Time 就是线程 B、C 等待时间的和,即 10 分钟。

我给 PostgreSQL 官方 JDBC 驱动修复了一个高并发性能问题_第3张图片

从采样结果看,synchronized 的代码路径在 PostgreSQL JDBC 驱动内,有可能是这段时间有人调整了 pgjdbc 的驱动。

检索 ShardingSphere 这段时间的提交记录,发现有个 PR 升级了 pgjdbc 驱动,从版本 42.2.5 升级到了 42.3.2。

[issue-15271] upgrade postgres driver #15272

更换回 42.2.5 版本驱动后,ShardingSphere-Proxy PostgreSQL 性能表现恢复了。

深入探究 pgjdbc

跟踪社区反馈及代码变更

pgjdbc 是 PostgreSQL 的官方 Java 驱动,出现这样的性能问题,影响可能会非常广泛。

于是,我到 pgjdbc 检索是否有相关问题反馈或解决方案。

在 pgjdbc GitHub 仓库中搜索了相关性能问题,没有找到类似的情况,于是拉了代码开始跟踪变更。

由于性能问题与 TimestampUtil 相关,我就查找了所有和此类相关的变更,发现了这一改动:
fix: use local TimestampUtil in PgStatement and PgResultset for thread safety #2291

PR 说明为解决线程安全问题,把原本由 Connection 持有的 TimestampUtil,改为由 Statement 和 ResultSet 分别持有。
new TimestampUtil 的执行频率增加了。

考虑到使用连接池的情况下,Connection 确实存在多线程非并发访问的情况,不排查线程安全问题的风险。

由于该问题在当时还没有其他人反馈,笔者准备修复这一问题。

直接修复问题

最新的 pgjdbc 要求的 Java 版本为 1.8,所以这个问题修复很简单,换一个获取 UTC 时区的方法就行:
PR: Use non-synchronized getTimeZone in TimestampUtils #2451 https://github.com/pgjdbc/pgjdbc/pull/2451
我给 PostgreSQL 官方 JDBC 驱动修复了一个高并发性能问题_第4张图片

修复前:

private final TimeZone utcTz = TimeZone.getTimeZone("UTC");

修复后:

private final TimeZone utcTz = TimeZone.getTimeZone(ZoneOffset.UTC);

编写针对问题的 JMH 测试用例并运行测试

PostgreSQL JDBC 驱动为了确保性能,在仓库中维护了一定的 JMH 测试用例。在大致看了下 pgjdbc 仓库内的 JMH 测试后,发现 timestamp 相关的测试用例并没有覆盖并发性能。
于是,我这边写了一个针对 setTimestamp 并发性能的测试,使用 JMH 内嵌的基于 JMX 实现的 StackProfiler 进行基本的线程状态收集统计。
需要注意的是,测试逻辑中只有执行 setTimestamp,并没有调用 PreparedStatement 的执行方法,就是不实际执行 SQL。

因此,数据库性能与本次性能测试无关,且期望测试期间进程的 CPU 使用率为接近 100% 用户态。

完整源码在 issue 中有记录:https://github.com/pgjdbc/pgjdbc/issues/2450

@State(Scope.Thread)
@Warmup(iterations = 5, time = 3)
@Measurement(iterations = 5, time = 3)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class PgPreparedStatementBenchmark {
    
    private final Connection connection;
    
    private final ThreadLocalRandom random = ThreadLocalRandom.current();
    
    private PreparedStatement preparedStatement;
    
    public PgPreparedStatementBenchmark() {
        Connection connection;
        try {
            connection = DriverManager.getConnection("jdbc:postgresql://127.0.0.1:5432/postgres", "postgres", "postgres");
        } catch (SQLException e) {
            connection = null;
            e.printStackTrace();
        }
        this.connection = connection;
    }
    
    @Setup(Level.Invocation)
    public void setup() {
        try {
            preparedStatement = connection.prepareStatement("select ?");
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
    
    @Benchmark
    public void benchSetTimestamp() throws SQLException {
        preparedStatement.setTimestamp(1, new Timestamp(random.nextLong(Long.MAX_VALUE)));
    }
    
    @TearDown(Level.Invocation)
    public void tearDown() {
        try {
            preparedStatement.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
    
    public static void main(String[] args) throws RunnerException {
        new Runner(new OptionsBuilder()
                .include(PgPreparedStatementBenchmark.class.getName())
                .threads(Runtime.getRuntime().availableProcessors())
                .forks(3)
                .addProfiler(StackProfiler.class)
                .build()).run();
    }
}

JMH 测试环境与参数

测试环境为 2 路 12C 24T CPU 组成的共 24C 48T,故 JMH 使用 48 线程测试。

# JMH version: 1.33
# VM version: JDK 1.8.0_312, OpenJDK 64-Bit Server VM, 25.312-b07
# VM invoker: /usr/lib/jvm/java-1.8.0-openjdk-1.8.0.312.b07-1.el7_9.x86_64/jre/bin/java
# VM options: -Dfile.encoding=UTF-8
# Blackhole mode: full + dont-inline hint (default, use -Djmh.blackhole.autoDetect=true to auto-detect)
# Warmup: 5 iterations, 3 s each
# Measurement: 5 iterations, 3 s each
# Timeout: 10 min per iteration
# Threads: 48 threads, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: icu.wwj.jmh.jdbc.PgPreparedStatementBenchmark.benchSetTimestamp

JMH 结果分析

代码修复前测试结果

Stack profiler 给出了线程状态分布,发现 BLOCKED 状态占比超过 95%。

Result "icu.wwj.jmh.jdbc.PgPreparedStatementBenchmark.benchSetTimestamp":
  1790.346 ±(99.9%) 138.368 ops/ms [Average]
  (min, avg, max) = (1587.859, 1790.346, 2024.605), stdev = 129.430
  CI (99.9%): [1651.977, 1928.714] (assumes normal distribution)

Secondary result "icu.wwj.jmh.jdbc.PgPreparedStatementBenchmark.benchSetTimestamp:·stack":
Stack profiler:

....[Thread state distributions]....................................................................
 95.4%         BLOCKED
  2.2%         RUNNABLE
  2.0%         TIMED_WAITING
  0.4%         WAITING

....[Thread state: BLOCKED].........................................................................
 95.4% 100.0% java.util.TimeZone.getTimeZone

....[Thread state: RUNNABLE]........................................................................
  2.0%  93.9% java.util.TimeZone.getTimeZone
  0.0%   1.2% java.util.GregorianCalendar.computeFields
  0.0%   0.6% org.openjdk.jmh.util.Deduplicator.dedup
  0.0%   0.4% sun.util.calendar.Gregorian.newCalendarDate
  0.0%   0.3% java.util.Calendar.
  0.0%   0.3% org.postgresql.jdbc.PgConnection.prepareStatement
  0.0%   0.3% org.postgresql.jdbc.TimestampUtils.
  0.0%   0.2% java.util.TimeZone.clone
  0.0%   0.2% org.postgresql.core.v3.SimpleParameterList.
  0.0%   0.2% java.util.Arrays.copyOf
  0.0%   2.3% 

....[Thread state: TIMED_WAITING]...................................................................
  2.0% 100.0% java.lang.Object.wait

....[Thread state: WAITING].........................................................................
  0.4% 100.0% sun.misc.Unsafe.park

代码修复后测试结果

根据线程状态分布,代码修复后测试显示不存在 BLOCKED 状态,TIMED_WAITING 与 WAITING 状态可能与 JMH 测试线程同步相关,基本可以认定线程一直处于 RUNNABLE 状态。

Result "icu.wwj.jmh.jdbc.PgPreparedStatementBenchmark.benchSetTimestamp":
  11152.920 ±(99.9%) 572.247 ops/ms [Average]
  (min, avg, max) = (10385.107, 11152.920, 12101.528), stdev = 535.280
  CI (99.9%): [10580.673, 11725.167] (assumes normal distribution)

Secondary result "icu.wwj.jmh.jdbc.PgPreparedStatementBenchmark.benchSetTimestamp:·stack":
Stack profiler:

....[Thread state distributions]....................................................................
 97.3%         RUNNABLE
  2.0%         TIMED_WAITING
  0.6%         WAITING

....[Thread state: RUNNABLE]........................................................................
 33.8%  34.7% sun.util.calendar.ZoneInfo.getTransitionIndex
 10.6%  10.8% java.util.HashMap.hash
  8.1%   8.3% java.util.GregorianCalendar.computeFields
  4.2%   4.4% org.postgresql.jdbc.TimestampUtils.appendTime
  4.0%   4.1% java.util.Calendar.
  2.8%   2.9% org.postgresql.jdbc.TimestampUtils.
  2.5%   2.6% org.postgresql.jdbc.PgConnection.prepareStatement
  2.4%   2.5% icu.wwj.jmh.jdbc.PgPreparedStatementBenchmark.tearDown
  2.4%   2.4% icu.wwj.jmh.jdbc.PgPreparedStatementBenchmark.benchSetTimestamp
  2.2%   2.3% org.postgresql.jdbc.TimestampUtils.toString
 24.4%  25.0% 

....[Thread state: TIMED_WAITING]...................................................................
  2.0% 100.0% java.lang.Object.wait

....[Thread state: WAITING].........................................................................
  0.6% 100.0% sun.misc.Unsafe.park

JDK 源码分析

为什么换个方法性能就能恢复了?直接看 java.util.TimeZone 的源码就知道了。

TimeZone.getTimeZone 有两种方法签名:

  • 第一个是从 JDK 早期版本就存在的方法,接受 String 作为参数;
  • 第二个是从 1.8 起加入的方法,接受 java.time.ZoneId(也是从 1.8 起提供的 class)作为参数。

从源码中看到,接受 String 参数的老方法使用了 synchronized 修饰,而新的方法及其依赖的方法均没有 synchronized 修饰。

https://github.com/openjdk/jdk/blob/ec0cc6300a02dd92b25d9072b8b3859dab583bbd/src/java.base/share/classes/java/util/TimeZone.java#L536-L570
我给 PostgreSQL 官方 JDBC 驱动修复了一个高并发性能问题_第5张图片
public 的 getTimeZone 方法所依赖的 private getTimeZone 方法并没有复杂的逻辑,也没有 synchronized 同步。

https://github.com/openjdk/jdk/blob/ec0cc6300a02dd92b25d9072b8b3859dab583bbd/src/java.base/share/classes/java/util/TimeZone.java#L608-L617
我给 PostgreSQL 官方 JDBC 驱动修复了一个高并发性能问题_第6张图片

java.time.ZoneOffsetjava.time.ZoneId 的子类,其中维护了一个 UTC 常量,可以直接用于 getTimeZone

https://github.com/openjdk/jdk/blob/ec0cc6300a02dd92b25d9072b8b3859dab583bbd/src/java.base/share/classes/java/time/ZoneOffset.java#L155
在这里插入图片描述

即更换了新的 getTimeZone 方法后,能完全避免 synchronized

性能修复随 pgjdbc 42.3.4 版本发布

https://github.com/pgjdbc/pgjdbc/releases/tag/REL42.3.4

我给 PostgreSQL 官方 JDBC 驱动修复了一个高并发性能问题_第7张图片

你可能感兴趣的:(Java,性能,postgresql,java,性能优化,async-profiler,JDBC)