这是我在 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
代码块发生了多线程竞争)。
Monitor Blocked 事件数量庞大,5 分钟达到百万级,说明 synchronized
多线程竞争激烈。
也可以用 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 分钟。
从采样结果看,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
修复前:
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();}}@Benchmarkpublic 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.430CI (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% BLOCKED2.2% RUNNABLE2.0% TIMED_WAITING0.4% WAITING....[Thread state: BLOCKED].........................................................................95.4% 100.0% java.util.TimeZone.getTimeZone....[Thread state: RUNNABLE]........................................................................2.0% 93.9% java.util.TimeZone.getTimeZone0.0% 1.2% java.util.GregorianCalendar.computeFields0.0% 0.6% org.openjdk.jmh.util.Deduplicator.dedup0.0% 0.4% sun.util.calendar.Gregorian.newCalendarDate0.0% 0.3% java.util.Calendar.<init>0.0% 0.3% org.postgresql.jdbc.PgConnection.prepareStatement0.0% 0.3% org.postgresql.jdbc.TimestampUtils.<init>0.0% 0.2% java.util.TimeZone.clone0.0% 0.2% org.postgresql.core.v3.SimpleParameterList.<init>0.0% 0.2% java.util.Arrays.copyOf0.0% 2.3% <other>....[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.280CI (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% RUNNABLE2.0% TIMED_WAITING0.6% WAITING....[Thread state: RUNNABLE]........................................................................33.8% 34.7% sun.util.calendar.ZoneInfo.getTransitionIndex10.6% 10.8% java.util.HashMap.hash8.1% 8.3% java.util.GregorianCalendar.computeFields4.2% 4.4% org.postgresql.jdbc.TimestampUtils.appendTime4.0% 4.1% java.util.Calendar.<init>2.8% 2.9% org.postgresql.jdbc.TimestampUtils.<init>2.5% 2.6% org.postgresql.jdbc.PgConnection.prepareStatement2.4% 2.5% icu.wwj.jmh.jdbc.PgPreparedStatementBenchmark.tearDown2.4% 2.4% icu.wwj.jmh.jdbc.PgPreparedStatementBenchmark.benchSetTimestamp2.2% 2.3% org.postgresql.jdbc.TimestampUtils.toString24.4% 25.0% <other>....[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
public 的 getTimeZone 方法所依赖的 private getTimeZone 方法并没有复杂的逻辑,也没有 synchronized
同步。
https://github.com/openjdk/jdk/blob/ec0cc6300a02dd92b25d9072b8b3859dab583bbd/src/java.base/share/classes/java/util/TimeZone.java#L608-L617
java.time.ZoneOffset
是 java.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