《Java 并发编程·性能诊断与事故》6.3 连接池、ThreadLocal 与 MDC

虚拟线程放开了线程数,却把瓶颈推到连接池、ThreadLocal 与日志上下文上。本节实测 5000 个虚拟线程撞上 8 个连接时 p99 涨到 16.7 秒,量出每线程一份 ThreadLocal 的堆代价,并用可运行代码演示 MDC 串号与三种修法。

承接的问题

虚拟线程上线一周,libraryd-conc 出了三个新问题,都不是线程本身的错。

症状一:数据库连接被打满。 借阅查询改成虚拟线程后,一次促销活动进来 3 万个并发,数据库侧的 max_connections 是 100,DBA 打电话过来说「你们的连接数打到 100 之后全在等」。应用侧看起来一切正常——没有异常,没有拒绝,只有 P99 从 80 ms 涨到 40 秒。

症状二:堆涨了 1.3 GB。 同一份代码,平台线程池 200 个线程时堆稳定在 1.2 GB;换成虚拟线程后,峰值堆 2.5 GB,GC 频率翻倍。堆转储里没有泄漏对象,全是 byte[] 和 ThreadLocalMap$Entry。

症状三:日志里的 traceId 串了。 用户 A 的请求日志里出现了用户 B 的 traceId。查下来,写日志的代码没错,错的是 MDC 的取数方式——它从一个「创建时就固定下来」的变量里读。

三个症状对应三个不同的机制:连接池的硬上限、ThreadLocal 的每线程拷贝、InheritableThreadLocal 的创建时快照。本节逐个实测。

连接池仍然是硬上限

5.2 的结论在这里兑现:虚拟线程不改变 W/C 的物理含义,它只改变了「这个比值决定什么」。平台线程时代,N×(1+W/C) 算出的是线程数,线程数就是上限;虚拟线程时代,线程数放开了,上限转移到了真正稀缺的那个资源上——连接池。

实测:5000 个任务,三种许可数

任务体模拟一次 20 ms 的下游查询:

import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;

/** 虚拟线程 + 连接池上限:permits=0 表示不限制。 */
public final class ConnPoolCap {

    static final AtomicInteger IN_DB = new AtomicInteger();
    static final AtomicInteger PEAK_IN_DB = new AtomicInteger();
    static final AtomicInteger WAITERS = new AtomicInteger();
    static final AtomicInteger PEAK_WAITERS = new AtomicInteger();

    static void query() {
        PEAK_IN_DB.accumulateAndGet(IN_DB.incrementAndGet(), Math::max);
        LockSupport.parkNanos(20_000_000L);      // 模拟 20 ms 下游往返
        IN_DB.decrementAndGet();
    }

    public static void main(String[] args) throws Exception {
        int permits = Integer.parseInt(args[0]);
        int tasks = Integer.parseInt(args[1]);
        Semaphore sem = permits > 0 ? new Semaphore(permits) : null;

        long[] waits = new long[tasks];
        CountDownLatch done = new CountDownLatch(tasks);
        CountDownLatch go = new CountDownLatch(1);
        long t0 = System.nanoTime();

        for (int i = 0; i < tasks; i++) {
            final int k = i;
            Thread.ofVirtual().name("task-" + k).start(() -> {
                try {
                    go.await();
                    long s = System.nanoTime();
                    if (sem != null) {
                        PEAK_WAITERS.accumulateAndGet(WAITERS.incrementAndGet(), Math::max);
                        sem.acquire();
                        WAITERS.decrementAndGet();
                    }
                    waits[k] = (System.nanoTime() - s) / 1_000_000L;
                    query();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    if (sem != null) {
                        sem.release();
                    }
                    done.countDown();
                }
            });
        }
        go.countDown();
        done.await();
        long wall = (System.nanoTime() - t0) / 1_000_000L;

        long[] s = waits.clone();
        Arrays.sort(s);
        System.out.printf("permits=%-6s wall=%dms 吞吐=%.1f/s 库内峰值=%d 排队峰值=%d p50=%dms p99=%dms%n",
                permits > 0 ? String.valueOf(permits) : "无限", wall,
                tasks * 1000.0 / Math.max(1, wall), PEAK_IN_DB.get(), PEAK_WAITERS.get(),
                s[tasks / 2], s[(int) (tasks * 0.99)]);
    }
}

5000 个任务,结果:

许可数墙钟吞吐库内峰值排队峰值等待 p50等待 p99
不限制83 ms—350600 ms0 ms
816 918 ms295.5/s849848491 ms16 651 ms
642 116 ms2362.9/s6449361017 ms2026 ms

三行数据各自说明一件事。

不限制的那一行是事故现场。 5000 个虚拟线程在同一瞬间全部打到下游,库内峰值 3506。真实的数据库在这个并发下会先变慢、再超时、最后拒绝——而应用侧的「吞吐」看起来漂亮得毫无意义,因为 parkNanos 不会失败。虚拟线程让「不设上限」变得极其容易,这是它最危险的一面。

8 个许可的那一行是数学。 8 个连接、每个查询 20 ms,理论上限 400/s,实测 295.5/s(74%,差额是调度与上下文切换)。排队的 4984 个线程什么都没干,只是在 Semaphore 上等。P99 16.7 秒不是虚拟线程慢,是排队长度除以服务率:5000 / 8 × 20 ms = 12.5 秒 的理论值,加上前后段抖动,量级完全对得上。

64 个许可的那一行是另一个极端。 吞吐 2363/s,但只有 64 个并发在算,10 个核里有 6 个在空转。这说明「连接数」和「核数」是两个独立的上限,谁小谁说话(5.2 的 min(公式值, 下游上限))。

把上限写进代码,而不是写进配置

Semaphore 是最直接的落地方式,但要注意它必须包在获取连接这一层,而不是包在业务方法外面:

package com.plume.conc.ops;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

/**
 * 给任意 DataSource 套一个「并发上限 + 等待超时」。
 * 虚拟线程下这才是真正的池:线程不是池,连接才是。
 */
public final class ConnGate implements AutoCloseable {

    private final DataSource delegate;
    private final Semaphore permits;
    private final long waitMillis;

    public ConnGate(DataSource delegate, int maxConcurrent, long waitMillis) {
        this.delegate = delegate;
        this.permits = new Semaphore(maxConcurrent, true);   // 公平,避免长任务饿死短任务
        this.waitMillis = waitMillis;
    }

    public <T> T withConnection(Function<Connection, T> work) throws SQLException {
        boolean acquired = false;
        try {
            acquired = permits.tryAcquire(waitMillis, TimeUnit.MILLISECONDS);
            if (!acquired) {
                throw new SQLException("连接许可等待超时 " + waitMillis + "ms");
            }
            try (Connection c = delegate.getConnection()) {
                return work.apply(c);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new SQLException("等待连接许可时被中断", e);
        } finally {
            if (acquired) {
                permits.release();
            }
        }
    }

    public int waiting() {
        return permits.getQueueLength();
    }

    @Override
    public void close() {
        // DataSource 由容器管理,这里只释放自己的许可状态
    }
}

三个细节值得说明。tryAcquire(waitMillis, ...) 而不是 acquire():虚拟线程的等待几乎不花成本,「无限等」在虚拟线程下比以前更常见也更危险——它把超时控制从连接池(HikariCP 的 connectionTimeout)搬到了你自己手里,不设超时,故障就从「快速失败」变成「全部挂死」。new Semaphore(n, true) 用公平模式:虚拟线程数量大、任务长短不一时,非公平模式会让长任务反复插队,公平模式的吞吐损失在 5% 以内。waiting() 是给 12.1 的指标用的——Semaphore.getQueueLength() 就是虚拟线程时代的 pool.getQueue().size(),它比任何池的活跃线程数都更能反映真实压力。

上限该定多少

回到 5.1 的 Little’s law,只是把变量换掉:

需要的连接数 = 目标吞吐(λ) × 单次持有时间(W)
             = 300/s × 0.020s = 6

要 300/s 的吞吐、每次查询 20 ms,6 个连接就够;配 8 个是留 33% 余量。配 100 个不会让吞吐变成 5000/s——数据库那边的 max_connections 和 innodb 的并发能力才是天花板。连接数应该由下游能承受的并发决定,而不是由应用层有多少线程决定。

ThreadLocal:百万份拷贝

平台线程时代,ThreadLocal 的代价是「每线程一份」。200 个线程,一份 1 KB 的上下文总共 200 KB,可以忽略。

虚拟线程时代,这句话要改成「每虚拟线程一份」。100 万虚拟线程,一份 1 KB,就是 1 GB。

实测:每线程多出来的那部分

同一段代码,只改 ThreadLocal 里放的对象大小:

规模每份大小堆增量每线程
10 万16 B133 MB1394 B
10 万1024 B232 MB2432 B
100 万256 B1313 MB1376 B
100 万(不设 ThreadLocal,6.1 基线)—890 MB933 B

10 万档的两行直接相减:每线程多了 1038 字节,而两份只差 1008 字节。也就是说,ThreadLocal 里的对象是实打实按线程数复制的,没有任何共享。100 万档那一行是 1376 B/线程,比基线 933 B 多 443 B——其中 256 B 是载荷,剩下的约 190 B 是 ThreadLocalMap 的 Entry 与数组本身。

这就是症状二的来源:把平台线程池的上下文对象原样搬进虚拟线程,堆会按「线程数 × 上下文大小」线性膨胀,而线程数从 200 变成了 100 万。

先找到是谁在写

JDK 提供了一开关,专门用来找「哪段代码在虚拟线程上设 ThreadLocal」:

java -Djdk.traceVirtualThreadLocals=true -cp out YourMain

每次 ThreadLocal.set 在虚拟线程上执行,都会打印线程和栈:

VirtualThread[#29,tl-1]/runnable@ForkJoinPool-1-worker-2
    java.base/java.lang.ThreadLocal.set(ThreadLocal.java:231)
    VtThreadLocal.lambda$main$0(VtThreadLocal.java:35)
    java.base/java.lang.VirtualThread.run(VirtualThread.java:460)

把这段输出按第二行的调用点聚合,就能列出「谁在虚拟线程里写上下文」的完整清单。多数项目会在这个清单里看到三样东西:日志框架的 MDC、安全框架的 SecurityContext、以及自己写的 RequestContext。

替代:把上下文变成参数或 ScopedValue

ThreadLocal 在虚拟线程下的问题不是性能(一次 get 还是几纳秒),而是空间。所以替代方案的判断标准是「能不能只存一份」:

  • 能传参就传参。一个 record Ctx(String traceId, String tenant) 沿调用链传下去,零拷贝、零歧义、可测试。这是最土也最有效的办法。
  • 需要隐式传递用 ScopedValue。JDK 25 已转正(不是 preview),它的值不可变、作用域由代码块界定、不需要 remove():
static final ScopedValue<String> TRACE = ScopedValue.newInstance();

ScopedValue.where(TRACE, "req-42").run(() -> {
    String id = TRACE.get();          // 作用域内直接读
    handle(id);
});                                    // 出了 run() 自动失效

一个必须实测清楚的细节:ScopedValue 不会被普通的 Thread.ofVirtual().start() 继承。本机实测(--enable-preview 只用于下面的 StructuredTaskScope,ScopedValue 本身不需要):

绑定内当前线程                  scoped=req-42
Thread.ofVirtual().start        scoped=<unbound>
Thread.ofPlatform().start       scoped=<unbound>
StructuredTaskScope.fork        scoped=req-42

只有结构化并发里 fork 出来的子任务才继承绑定。如果你在 ScopedValue 作用域里用 Thread.ofVirtual().start() 起了个野线程,它读到的是 isBound() == false,会 NPE 或读到默认值——这一点和很多文章的暗示相反,必须自己验证。

MDC 的传递

日志框架的 MDC 本质是一个 InheritableThreadLocal<Map>。它的语义是:子线程在创建的那一刻,拷贝父线程当时的值。这句话在平台线程池和虚拟线程下都会出问题,只是出错的方式不同。

两个失效场景,一个共同根因

用 InheritableThreadLocal<String> 模拟 MDC,跑一遍:

== 场景 1:平台线程池 + InheritableThreadLocal ==
  [A 自身]      thread=pool-1     trace=req-A
  [B 自身]      thread=pool-1     trace=req-A     <- 泄漏:B 没设置,读到了 A 的
  [B 的子线程]  thread=child-of-B trace=req-A     <- 再往下传一层

== 场景 2:虚拟线程从「已被污染的」平台线程创建 ==
  [B 的虚拟线程] thread=vt-for-B  trace=req-A     <- 串号

场景 1 的根因不是 InheritableThreadLocal 坏了,而是池线程活得太久:pool-1 是在请求 A 期间创建的,继承了 A 的 MDC;请求 B 复用这个线程,入口没有显式覆盖,读到的就是 A 的值。场景 2 只是把它搬到虚拟线程:vt-for-B 在 dispatcher 里创建,而 dispatcher 手上的 MDC 还是 A 的。

关键点:虚拟线程没有让这个问题变好,也没有让它变坏,它是「创建时快照」这个语义的必然结果。真正的修复是不要依赖创建时快照。

修法一:显式传递

record Ctx(String traceId) {}

Ctx ctx = new Ctx("req-B");
Thread.ofVirtual().name("vt-explicit")
      .start(() -> handle(ctx))
      .join();

代价是每个跨线程的调用点都要多一个参数。收益是没有隐式状态:读到什么、从哪来,看签名就知道。

修法二:ScopedValue + 结构化并发

需要隐式传递又不想复制时,用 ScopedValue 配合 StructuredTaskScope(JDK 25 preview,未来版本 API 可能变化,编译和运行都要 --enable-preview):

import java.util.concurrent.StructuredTaskScope;

static final ScopedValue<Ctx> CTX = ScopedValue.newInstance();

void handleRequest(Ctx ctx) throws Exception {
    ScopedValue.where(CTX, ctx).call(() -> {
        try (var scope = StructuredTaskScope.open()) {
            var loan = scope.fork(() -> loadLoan(ctx.traceId()));
            var book = scope.fork(() -> loadBook(ctx.traceId()));
            scope.join();
            return merge(loan.get(), book.get());
        }
    });
}

编译与运行:

javac --release 25 --enable-preview -d out src/HandleRequest.java
java --enable-preview -cp out com.plume.conc.core.HandleRequest

两个 fork 出来的子任务都能读到 CTX.get(),因为它们是在绑定作用域内、由 StructuredTaskScope 创建的。这解决了场景 2 的串号:值不是从「创建它的线程」继承的,而是从词法作用域继承的。

在 libraryd-conc 里落地

三件事合成一个改造清单,按优先级排:

优先级改造验收标准
P0所有下游入口套 ConnGate,设等待超时waiting() 有指标,超时率 < 0.1%
P1移除虚拟线程路径上的 InheritableThreadLocal-Djdk.traceVirtualThreadLocals=true 输出为空
P1请求上下文改成 record Ctx 显式传递日志 traceId 串号投诉归零
P2结构化并发替换手写的 CompletableFuture 组合取消能向下传播

P1 那两条的顺序不能反:先确认没有隐式上下文,再谈要不要上 ScopedValue。如果代码里还在用 InheritableThreadLocal 传租户 ID,直接换成 ScopedValue 会漏掉所有 Thread.start() 创建的线程(上一小节实测的 <unbound>),变成随机 NPE。

常见错误与边界

错误后果正确做法
虚拟线程不加连接池上限5000 并发全打到库,库内峰值 3506Semaphore / ConnGate 兜底
Semaphore.acquire() 不设超时故障时全部挂死,没有快速失败tryAcquire(timeout)
用线程数当连接数连接数按线程数配,下游被打穿按 λ × W 算,再和下游上限取小
把大对象放进 ThreadLocal100 万线程 → 1 GB 堆传参或 ScopedValue
用 InheritableThreadLocal 传请求上下文池复用导致 traceId 串号显式 Ctx / ScopedValue
以为 ScopedValue 会自动传给 Thread.start()野线程读到 <unbound>用 StructuredTaskScope.fork

什么时候不要用 Semaphore 限流:当下游本身有健康检查与熔断时,应用层再套一层信号量会掩盖真实故障——请求全卡在信号量上,熔断器看不到错误率,永远不会打开。这种情况应该让请求快速打到下游、快速失败,由熔断器决定降级。信号量的正确位置是「保护一个没有自我保护能力的资源」,比如一个老式的、连接数一超就雪崩的数据库驱动。

小结

连接池、ThreadLocal、MDC 是虚拟线程落地时必然撞上的三堵墙,它们的共同点是:虚拟线程放开了线程数,但没有放开线程背后的资源。

实测数字:5000 个任务、每个 20 ms,8 个许可时墙钟 16.9 秒、P99 16.7 秒、峰值 4984 个线程在排队;64 个许可时 2.1 秒;不限制时 83 ms 但库内峰值 3506——最后这行才是生产事故的形态。

ThreadLocal 的成本按线程数线性复制:实测每线程多 1038 字节,与载荷的 1008 字节基本相等,10 万线程就是约 100 MB。-Djdk.traceVirtualThreadLocals=true 能列出所有写入点。

MDC 的串号来自 InheritableThreadLocal 的「创建时快照」语义,池复用会把上一个请求的值带过来。修法两条:显式传 record Ctx,或用 ScopedValue——但 ScopedValue 只被 StructuredTaskScope.fork 继承,不会被 Thread.start() 继承,这一点本机实测过,和很多文章的暗示相反。

下一章换一个方向:同样面对「大量并发连接」,Reactor 走的是完全不同的路。先回顾它的线程模型,再和虚拟线程做一张取舍矩阵。

阅读导航:上一节 6.2 pinning 的诊断与消除 | 下一节 7.1 Reactor 的线程模型回顾

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收