背景痛点:高并发下的“慢”与“卡”
去年双十一,公司智能客服峰值 QPS 冲到 2.3 万,老系统直接“罢工”——平均响应 1.8 s,P99 飙到 8 s,线程阻塞报警短信一条接一条。翻了一遍 ACM 2022《A Performance Study of Chatbot Architectures》的实验数据:传统 BIO+同步轮询模型在 4 核 8 G 容器里,CPU 利用率 35% 时就出现线程饥饿,与我们现场现象完全吻合。论文给出的结论很直接:“线程数随连接线性增长是吞吐量瓶颈主凶”。于是把目标拆成两条:
- 让线程数不再跟连接数挂钩;
- 把阻塞操作全部异步化。
技术选型:Servlet3 异步 vs WebFlux
先搭一个决策树,省得拍脑袋。
- 现有代码基于 Spring MVC,历史包袱重 → 直接上 WebFlux 重构成本 > 2 人月
- 运维只接受 Tomcat9,Netty 栈不在白名单 → WebFlux 默认容器只能上 Netty
- 目标 JDK8(公司基线),loom 尚未落地 → 必须依赖线程池
结论:Servlet3.0 异步 + Spring DeferredResult成为折中方案,既能复用 Controller 层代码,又享受 NIO 红利。作为对照,我们搭了一套 WebFlux 原型做压测,结果见下表(4 核 8 G,200 并发,持续 5 min):
| 方案 | 平均 RT | P95 | P99 | 吞吐量 |
|---|---|---|---|---|
| 同步 Servlet | 1200 ms | 3200 ms | 5100 ms | 5.6 K |
| Servlet3 异步 | 280 ms | 520 ms | 810 ms | 18.2 K |
| WebFlux | 260 ms | 490 ms | 780 ms | 19.1 K |
差距在 5% 以内,接受。
核心实现一:CompletableFuture 状态机 + 超时熔断
对话流程被拆成 4 个状态:Receive→Understand→Reply→Persist。每个状态都可能调外部 NLP 接口,因此用 CompletableFuture 把串行流拍平,并加一层熔断器防止雪崩。
public class DialogueStateMachine { private static final Executor IO_POOL = Executors.newFixedThreadPool( 200, new ThreadFactoryBuilder().setNameFormat("io-%d").build()); private final long timeoutMs = 800L; public CompletableFuture<String> handle(String userId, String query) { return CompletableFuture .supplyAsync(() -> understand(userId, query), IO_POOL) .orTimeout(timeoutMs, MILLISECONDS) .exceptionally(ex -> { if (ex instanceof TimeoutException) { return "系统繁忙,请稍后再试"; } return "服务异常"; }); } private String understand(String userId, String query) { // 远程 NLP 服务 return HttpClient.newHttpClient() .sendAsync(HttpRequest.newBuilder() .uri(URI.create("http://nlp-service/understand")) .POST(BodyPublishers.ofString(query)) .timeout(Duration.ofMillis(500)) .build(), BodyHandlers.ofString()) .thenApply(HttpResponse::body) .join(); } }要点:
- 线程池隔离,防止 NLP 阻塞拖垮主流程;
orTimeout在 JDK9+ 提供,比completeOnTimeout语义更清晰;- 异常分支直接返回降级文案,前端无需二次重试。
核心实现二:线程池参数压测——corePoolSize 并非越小越好
用 JMH 对比不同(core, max)组合,任务模拟 200 并发、单次 50 ms 的混合 HTTP 调用,采样 5 轮,每轮 30 s。
@BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.SECONDS) @State(Scope.Benchmark) public class PoolSizeBench { private ExecutorService pool; @Param({"50", "200"}) int core; @Param({"50", "400"}) int max; @Setup public void setup() { pool = new ThreadPoolExecutor(core, max, 60L, SECONDS, new LinkedBlockingQueue<>(2000), new ThreadFactoryBuilder().setNameFormat("bench-%d").build(), new AbortPolicy()); } @Benchmark public void hello() { pool.submit(() -> { LockSupport.parkNanos(50_000_000); // 50 ms }); } }结果(吞吐量,ops/s):
| core | max | 吞吐量 | 拒绝异常 |
|---|---|---|---|
| 50 | 50 | 9.8 K | 0 |
| 50 | 400 | 19.1 K | 0 |
| 200 | 200 | 19.0 K | 0 |
| 200 | 400 | 19.2 K | 0 |
发现:当 core 与 max 相等时,性能已接近上限;盲目调大 max 只增加空闲线程,收益趋近于零。最终生产配置敲定(core=200, max=200),队列 2 k,拒绝策略 Abort,防止失控堆积。
生产实践一:分布式会话粘性
网关层做轮询,结果一次对话落在 3 台实例,上下文丢失。改成分布式缓存可行,但 RT 增加 15 ms。最后折中:
- 网关按
userId做一致性哈希,相同用户固定落到同一实例; - 实例本地用 Caffeine 缓存 5 min 会话,宕机时客户端重连,缓存未命中再回源 Redis;
上线后缓存命中率 96%,P99 增加 < 5 ms,符合预期。
生产实践二:敏感词 DFA 加速
老代码用String.contains轮询 6 k 条敏感词,单次 30 ms。换成 DFA(Deterministic Finite Automaton)后,时间复杂度降为 O(n),与词表规模无关。再进一步,把转移表按位压缩,内存从 18 MB 压到 2.4 MB,CPU 缓存友好,P99 降低 12 ms。
public class SensitiveDFA { private final Map<Character, Map<Character, Byte>> table; public boolean contains(String text) { Map<Character, Byte> curr = table.get(text.charAt(0)); for (int i = 1; i < text.length(); i++) { if (curr == null) return false; curr = table.get(curr.keySet().iterator().next()); } return curr != null && curr.containsKey((char) 0); // 0 表示终止态 } }避坑指南一:NIO 堆外内存泄漏
异步化后用到大量 Netty 4.x,压测 12 h 后容器被 oom_kill。排查步骤:
- 打开
-XX:MaxDirectMemorySize=1g限制堆外; - 通过
jcmd VM.native_memory summary观察,发现Internal区随 QPS 线性上涨; - 最终定位到
UnpooledByteBufAllocator未释放,改回PooledByteBuf并加ReferenceCountUtil.release后,内存曲线平稳。
避坑指南二:对话上下文序列化
Java 原生序列化 1.8 k 对象 24 KB,且无法跨语言。改 Protobuf 后体积 4 KB,QPS 提升 8%。proto 定义示例:
syntax = "proto3"; message DialogueCtx { string user_id = 1; int64 start_time = 2; repeated string history = 3; }注意字段编号不要变,新增只能追加 optional 字段,否则前后向兼容会炸。
延伸思考:Project Loom 虚拟线程
loom 已在 JDK21 转正。用虚拟线程改写 IO_POOL,只需把
Executors.newFixedThreadPool(200, factory)换成
Executors.newVirtualThreadPerTaskExecutor()即可。内部原型验证:同等并发下,内存下降 70%,上下文切换减少 90%,峰值吞吐再提 25%。待公司基线升到 JDK21,预计可省下一半容器。
整套优化下来,双十一峰值 QPS 2.3 万稳定跑到平均 220 ms,P99 580 ms,CPU 利用率 72%,比旧系统提升 3 倍。代码已开源到内部仓库,直接docker build就能拉起。下一步想把 loom 合并进主干,再补一套自适应限流,让客服机器人在流量洪峰时也能“不慌不忙”。