压测流量的标记,过了线程池就没了
请求入口给压测流量打了标记,订单写入的地方按标记决定写正式表还是影子表。同步调用的路径测下来一切正常。把写订单改成提交给线程池异步执行之后,压测产生的订单出现在了正式表里。
在接近真实的环境里做压测,通常的做法是给压测请求打一个标记,让它走和正常请求相同的代码,只在产生副作用的地方分流:写影子表、不发真实短信、不调真实的支付。这套做法成立的前提是,标记从入口一直跟到每一个产生副作用的地方。
标记通常放在线程上(ThreadLocal),而一次请求的处理并不总在一个线程里。本文在 JDK 25.0.4 上逐个边界查看标记的去向,再说明怎样传播、怎样在传播失败时不造成损害。实验是单进程里的模拟,不涉及真实的中间件,见文末。
一、标记在哪些边界上丢失
实验的设置:入口把 SHADOW 放进当前线程的 ThreadLocal;订单写入处读取它,是 SHADOW 就写影子表,否则写正式表。
ThreadLocal 里,订单写入处按标记选择正式表或影子表。只有留在入口线程里的那次写入进了影子表;交给线程池、CompletableFuture、定时任务或者发成消息之后,执行写入的线程上没有标记,压测订单写进了正式表。InheritableThreadLocal 更糟:标记粘在池线程上,随后的正常订单被写进影子表。| 写订单的位置 | 写入处看到的标记 | 压测订单写进 |
|---|---|---|
| 入口线程里直接调用 | SHADOW | 影子表 |
| 提交给线程池 | 无 | 正式表 |
CompletableFuture.supplyAsync | 无 | 正式表 |
| 20 毫秒后的定时重试 | 无 | 正式表 |
| 发成消息,由消费者写,消息不带头 | 无 | 正式表 |
发成消息,带 x-traffic 头 | SHADOW | 影子表 |
ThreadLocal 的值属于设置它的那个线程。任务交给另一个线程执行,那个线程上没有这个值;消息发给另一个进程,更没有。写入处读不到标记,按「不是压测」处理。
这类问题有两个特点,使它容易漏过上线前的检查:
- 同步路径是对的。 主流程的测试通过,问题只在异步的分支上:失败重试、异步通知、延迟任务、补偿逻辑。这些分支平时走得少,压测时因为流量大、超时多,反而走得多。
- 不报错。 标记丢失之后的行为是「当成正常流量处理」,一切照常,只是数据写错了地方。
凡是「换了执行线程或进程」的地方都是边界:线程池、CompletableFuture 与响应式流、定时器、消息队列、HTTP 与 RPC 调用、批处理(把多个请求攒成一批,由另一个线程统一处理)。
二、比丢失更糟:标记串到别的请求上
看到标记在线程池里丢失,容易想到的修法是把 ThreadLocal 换成 InheritableThreadLocal,让子线程继承父线程的值。它在线程创建的那一刻复制一次,而线程池里的线程是复用的:
| 顺序 | 结果 |
|---|---|
| 压测请求先到,工作线程由它创建;随后来了一个正常请求 | 压测订单写进影子表;正常订单也写进影子表 |
| 正常请求先到,工作线程由它创建;随后来了一个压测请求 | 正常订单写进正式表;压测订单写进正式表 |
工作线程带着创建时继承的值,一直到线程销毁。之后在它上面执行的任务,不论来自哪个请求,都按这个值处理。第一种情况里,真实用户的订单被写进了影子表,从正式数据的角度看,这笔订单丢了。比起压测数据混进正式表,这是更严重的后果,而且同样没有任何报错。
同样的问题也出现在不清理的 ThreadLocal 上:请求线程本身来自容器的线程池,入口设置了标记而出口没有清除,下一个复用这个线程的请求就带着上一个请求的标记。复用线程上的 ThreadLocal 残留,见 线程池参数怎么定。
三、不经过代码路径的共享状态
标记即使正确传播,也只能管住「读取了标记的代码」。有些状态是压测流量和正常流量共用的,写入时没有任何地方做区分:
- 缓存。 实验里压测请求把压测用的价格 1 写进了缓存键
price:sku-1,随后正常请求从同一个键读到 1(真实价格 9900)。压测流量的键加上前缀之后,正常请求读到的是 9900。本地缓存、分布式缓存、页面缓存、CDN 都有这个问题。 - 计数与配额。 限流计数、库存、优惠券额度、发号器、幂等键。压测流量把配额用完,正常流量被拒绝。
- 派生数据。 搜索索引、推荐特征、统计报表、数据仓库的同步任务。它们从表或消息里读数据,通常不知道标记的存在。
- 外部系统。 支付、短信、邮件、物流、第三方回调。出了进程边界之后,对方不认识这个标记。
处理原则是在每一种共享状态上做出明确的决定:隔离(影子表、键前缀、独立的主题或消费组)、屏蔽(对外调用换成挡板)、或者接受共用(只读的基础数据)。没有做决定的地方,默认就是共用。
四、把标记带过边界
提交任务时捕获,执行时恢复,执行后清理。 在任务提交的那一刻读出当前线程的标记,包进任务里;任务开始时设置到执行线程上,结束时恢复原状:
static Runnable propagate(Runnable task) {
Traffic captured = CTX.get(); // 提交线程上的标记
return () -> {
Traffic before = CTX.get();
CTX.set(captured);
try { task.run(); }
finally { if (before == null) CTX.remove(); else CTX.set(before); }
};
}实验里用它包装之后:压测订单写进影子表,随后的正常订单写进正式表,工作线程上没有残留。包装要做在执行器上(装饰线程池),而不是靠每个调用点记得去包;CompletableFuture 不指定执行器时用的是公共池,需要显式传入包装过的执行器。现成的上下文传播库做的是同一件事,引入之前要确认它覆盖了项目里用到的每一种执行器和异步框架。
跨进程放进协议头。 消息的头、HTTP 头、RPC 的附加字段。消费方或被调用方在入口把它取出来,重新放进自己的上下文。OpenTelemetry 的 Baggage 是为这类「随请求传递的键值」定义的标准,由各语言的传播器负责在头与上下文之间转换。要注意的是每一跳都要转发:中间有一个服务没有透传,后面就全部丢失。
ScopedValue。 JDK 25 里正式提供。它的值只在 ScopedValue.where(KEY, value).run(...) 的范围内可见,范围结束自动失效,不存在忘记清理:
| 位置 | 看到的值 |
|---|---|
| 范围内,同一线程 | SHADOW |
| 范围内提交给普通线程池的任务 | 无 |
范围内用 StructuredTaskScope 分叉的子任务 | SHADOW |
| 范围结束后,同一线程 | 无 |
它解决了「残留」和「串到别的请求」,但没有解决普通线程池的传播:池线程不在这个范围里,仍然看不到值。子任务的继承只对结构化并发成立,而 StructuredTaskScope 在 JDK 25 仍是预览特性。
五、传播失败时不造成损害
传播链路上的每一个边界都要有人记得处理,总会有遗漏。上面所有丢失的情况有一个共同点:写入处把「没有标记」当成了「正常流量」。只要这两者不可区分,遗漏就是静默的。
把它们区分开:流量类型是三态的,NORMAL、SHADOW、未知。每个入口必须明确设置前两者之一;产生副作用的地方遇到「未知」就拒绝执行。
实验里的严格模式:
- 标记在线程池里丢失:写入处抛出
IllegalStateException(traffic context missing),订单没有写入任何表。 - 入口明确标为
NORMAL的请求:正常写进正式表。
漏掉传播的地方从「数据悄悄写错」变成了「明确的异常」,在测试环境里第一次走到这条路径就会暴露,并且指出了是哪个写入点、哪个线程。
代价是所有入口(HTTP、消息消费、定时任务、管理脚本)都要设置流量类型,否则正常业务也会被拒绝。可以分步走:先只记录日志和指标,观察一段时间确认没有未知来源的写入,再对不可逆的副作用(扣款、发短信、调用外部系统)切换成拒绝。
六、上线前怎么验证
- 每个边界一个测试。 以压测标记发起请求,断言每个副作用出口实际看到的标记,而不只是断言主流程的返回值。新增异步路径时同步补上。
- 反向核对。 用专门的压测账号和商品,压测结束后在正式表、正式主题、对外调用记录里查这些标识:应当一条都没有。再反过来查影子表里有没有正常账号的数据。两个方向都要查,第二节的问题只有第二个方向查得出来。
- 从小流量开始。 先用几条带标记的请求走完整条链路并核对,再逐步加量。问题在第一条请求上就存在,不需要大流量才能发现。
- 对外调用默认走挡板。 压测流量到第三方的调用,在出口处按标记换成固定响应;标记未知时同样不放行。
- 清理与隔离并重。 影子数据要有清理方式,缓存键、消息主题、搜索索引的隔离要在压测方案里逐项列出。
压测该用什么样的负载、报告怎么读,见 固定 10 个并发压测,服务卡了 2 秒,报告里只有 10 个慢请求。
配套实验
- codesphere-labs/testing/load-test-marker-propagation:JDK 25.0.4 上
ThreadLocal标记在线程池、CompletableFuture、定时任务、消息与缓存边界上的去向,InheritableThreadLocal的两种顺序,以及包装任务、ScopedValue与拒绝无标记写入的结果(验证记录)
参考资料