Skip to content

压测流量的标记,过了线程池就没了 ​

请求入口给压测流量打了标记,订单写入的地方按标记决定写正式表还是影子表。同步调用的路径测下来一切正常。把写订单改成提交给线程池异步执行之后,压测产生的订单出现在了正式表里。

在接近真实的环境里做压测,通常的做法是给压测请求打一个标记,让它走和正常请求相同的代码,只在产生副作用的地方分流:写影子表、不发真实短信、不调真实的支付。这套做法成立的前提是,标记从入口一直跟到每一个产生副作用的地方。

标记通常放在线程上(ThreadLocal),而一次请求的处理并不总在一个线程里。本文在 JDK 25.0.4 上逐个边界查看标记的去向,再说明怎样传播、怎样在传播失败时不造成损害。实验是单进程里的模拟,不涉及真实的中间件,见文末。

一、标记在哪些边界上丢失 ​

实验的设置:入口把 SHADOW 放进当前线程的 ThreadLocal;订单写入处读取它,是 SHADOW 就写影子表,否则写正式表。

入口线程标记 = SHADOW同一线程直接写看到 SHADOW线程池 / supplyAsync看到:无标记定时重试看到:无标记消息,不带头消费者看到:无标记消息,带 x-traffic 头消费者看到 SHADOW影子表压测订单正式表压测订单混入影子表压测订单InheritableThreadLocal 加线程池:池线程在创建时继承标记并一直带着,之后的请求不论真假都按它处理共用缓存键:压测请求写入的价格 1 被正常请求读到(真实价格 9900)
图 1 · 实测:入口把压测标记放在当前线程的 ThreadLocal 里,订单写入处按标记选择正式表或影子表。只有留在入口线程里的那次写入进了影子表;交给线程池、CompletableFuture、定时任务或者发成消息之后,执行写入的线程上没有标记,压测订单写进了正式表。InheritableThreadLocal 更糟:标记粘在池线程上,随后的正常订单被写进影子表。
写订单的位置写入处看到的标记压测订单写进
入口线程里直接调用SHADOW影子表
提交给线程池无正式表
CompletableFuture.supplyAsync无正式表
20 毫秒后的定时重试无正式表
发成消息,由消费者写,消息不带头无正式表
发成消息,带 x-traffic 头SHADOW影子表

ThreadLocal 的值属于设置它的那个线程。任务交给另一个线程执行,那个线程上没有这个值;消息发给另一个进程,更没有。写入处读不到标记,按「不是压测」处理。

这类问题有两个特点,使它容易漏过上线前的检查:

  • 同步路径是对的。 主流程的测试通过,问题只在异步的分支上:失败重试、异步通知、延迟任务、补偿逻辑。这些分支平时走得少,压测时因为流量大、超时多,反而走得多。
  • 不报错。 标记丢失之后的行为是「当成正常流量处理」,一切照常,只是数据写错了地方。

凡是「换了执行线程或进程」的地方都是边界:线程池、CompletableFuture 与响应式流、定时器、消息队列、HTTP 与 RPC 调用、批处理(把多个请求攒成一批,由另一个线程统一处理)。

二、比丢失更糟:标记串到别的请求上 ​

看到标记在线程池里丢失,容易想到的修法是把 ThreadLocal 换成 InheritableThreadLocal,让子线程继承父线程的值。它在线程创建的那一刻复制一次,而线程池里的线程是复用的:

顺序结果
压测请求先到,工作线程由它创建;随后来了一个正常请求压测订单写进影子表;正常订单也写进影子表
正常请求先到,工作线程由它创建;随后来了一个压测请求正常订单写进正式表;压测订单写进正式表

工作线程带着创建时继承的值,一直到线程销毁。之后在它上面执行的任务,不论来自哪个请求,都按这个值处理。第一种情况里,真实用户的订单被写进了影子表,从正式数据的角度看,这笔订单丢了。比起压测数据混进正式表,这是更严重的后果,而且同样没有任何报错。

InheritableThreadLocal压测请求SHADOW创建池线程 T1继承 SHADOW,一直保留正常请求NORMAL复用 T1压测订单 → 影子表符合预期正常订单 → 影子表真实用户的订单从正式数据里消失包装执行器:提交时捕获,执行时设置,结束后恢复propagate(task)captured = CTX.get()压测订单 → 影子表正常订单 → 正式表池线程上没有残留更稳的兜底:流量类型三态,写入处遇到「未知」直接拒绝,丢失从静默变成异常
图 2 · 实测(JDK 25.0.4):压测请求先到,线程池的工作线程由它创建,继承了 SHADOW;之后正常请求的订单交给同一个线程执行,也被写进影子表,从正式数据看这笔订单丢了。提交时捕获标记、执行时设置、结束后恢复的包装写法,压测订单进影子表、正常订单进正式表,工作线程上没有残留

同样的问题也出现在不清理的 ThreadLocal 上:请求线程本身来自容器的线程池,入口设置了标记而出口没有清除,下一个复用这个线程的请求就带着上一个请求的标记。复用线程上的 ThreadLocal 残留,见 线程池参数怎么定。

三、不经过代码路径的共享状态 ​

标记即使正确传播,也只能管住「读取了标记的代码」。有些状态是压测流量和正常流量共用的,写入时没有任何地方做区分:

  • 缓存。 实验里压测请求把压测用的价格 1 写进了缓存键 price:sku-1,随后正常请求从同一个键读到 1(真实价格 9900)。压测流量的键加上前缀之后,正常请求读到的是 9900。本地缓存、分布式缓存、页面缓存、CDN 都有这个问题。
  • 计数与配额。 限流计数、库存、优惠券额度、发号器、幂等键。压测流量把配额用完,正常流量被拒绝。
  • 派生数据。 搜索索引、推荐特征、统计报表、数据仓库的同步任务。它们从表或消息里读数据,通常不知道标记的存在。
  • 外部系统。 支付、短信、邮件、物流、第三方回调。出了进程边界之后,对方不认识这个标记。

处理原则是在每一种共享状态上做出明确的决定:隔离(影子表、键前缀、独立的主题或消费组)、屏蔽(对外调用换成挡板)、或者接受共用(只读的基础数据)。没有做决定的地方,默认就是共用。

四、把标记带过边界 ​

提交任务时捕获,执行时恢复,执行后清理。 在任务提交的那一刻读出当前线程的标记,包进任务里;任务开始时设置到执行线程上,结束时恢复原状:

java
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 个慢请求。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权