这是我在 2022 年给 PostgreSQL 官方 JDBC 驱动 修复的一个高并发性能问题。
该问题影响的版本范围是 pgjdbc:
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 + 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 是 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);
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();
}
}
测试环境为 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
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
为什么换个方法性能就能恢复了?直接看 java.util.TimeZone
的源码就知道了。
TimeZone.getTimeZone
有两种方法签名:
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
。
https://github.com/pgjdbc/pgjdbc/releases/tag/REL42.3.4