Chat1 月的一个周二上午,10:30,客服群里突然弹出一句“系统又卡死了”。
原来是会员日,并发从日常的 2k QPS 飙到 18k,单体 Chatbot 的 JVM 直接 FGC 疯掉,Tomcat 线程池打满,用户端看到“正在输入…”转半天,最后超时重试。老板在群里 @ 所有人:下午两点前必须给出扩容方案,否则活动取消。
那一刻,我们深刻体会到“效率”不是口号,而是生死线。下面把过去 18 个月踩过的坑、调过的参、写过的代码全部摊开,给你一份可直接落地的微服务化改造笔记。
1. 单体之痛:为什么并发一上来就崩
- 所有逻辑——ASR、NLU、DM、NLG、TTS——挤在一个 war 包,线程模型互相阻塞
- 同步 I/O 打满连接池,一条慢 SQL 拖垮整池
- 扩容只能整包水平复制,CPU 已经 60% 空转,GC 却停不下来
- 日志、缓存、状态、模型推理共享同一块堆,OOM 时上下文全部丢失,用户被迫“从头开始”
一句话:单体 = 一损俱损。会员日只是导火索,真正的问题是架构不再匹配业务增速。
2. 技术选型:gRPC vs REST、RabbitMQ vs Kafka
先把结论放这:对话系统对“低延迟 + 有序”极度敏感,选型必须让位给 SLA。
| 维度 | REST | gRPC | RabbitMQ | Kafka |
|---|---|---|---|---|
| 延迟 | 20-60 ms | 5-15 ms | sub-ms | 5-10 ms |
| 多语言 | 任意 | 全 | 全 | 全 |
| 流式 | 原生 | 原生 | ||
| 背压 | 无 | 有 | 客户端限流 | 拉模式 |
| 运维复杂度 | 低 | 中 | 中 | 高 |
结论
- 内部服务:用 gRPC + protobuf,IDL 一次性解决跨部门撕逼
- 对外网关:保留 REST,方便 H5、小程序直接调
- 事件流:对话事件写 Kafka(保证顺序),运营监控写 RabbitMQ(TTL+死信)
3. 微服务拆分与核心实现
3.1 事件驱动架构总览(PlantUML)
@startuml !define RECTANGLE class skinparam componentStyle rectangle package "Gateway" { [WebSocket Gateway] } package "Dialog Flow" { [ASR svc] --> [Kafka] : AudioEvent [Kafka] --> [NLU svc] : TextQuery [NLU svc] --> [Kafka] : IntentEvent [Kafka] --> [DM svc] : IntentEvent [DM svc] --> [Kafka] : ReplyEvent [Kafka] --> [TTS svc] : ReplyEvent [TTS svc] --> [WebSocket Gateway] : AudioSegment } package "Infra" { database "Redis Cluster" queue "Kafka" queue "RabbitMQ" } [DM svc] --> Redis Cluster : get/set state [WebSocket Gateway] --> Redis Cluster : get/set state @enduml3.2 带背压的 WebSocket 消息处理器(Java 21)
// 依赖:spring-webflux + reactor-kafka @RestController @RequestMapping("/chat") public class ChatSocketHandler { private final Sinks.Many<String> sink = Sinks.many().multicast().onBackpressureBuffer(512, false); @OnMessage public void onBinary(ByteBuffer audio, WebSocketSession session) { // 1. 快速返回,不阻塞 IO 线程 sink.tryEmitNext(audio) .orElseThrow(() -> new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS)); } // 2. 背压 + 批量写 Kafka @PostConstruct public void drain() { sink.asFlux() .bufferTimeout(50, Duration.ofMillis(20)) .filter(list -> !list.isEmpty()) .flatMap(list -> kafkaTemplate.send("audio-stream", list)) .subscribe(); } }要点
onBackpressureBuffer丢弃或抛异常,防止 OOMbufferTimeout把高频小报文攒成 50 条或 20 ms 微批,降低 Kafka 网络包数量 70%
3.3 对话状态分片策略
- Key 格式:
user:{uid}:session:{sid} - 采用 Redis Cluster + 预分片 16384 槽;CRC16 后取模,保证横向扩展不迁移数据
- 状态 TTL = 30 min,配合心跳续期,防止“聊到一半失忆”
- 重要字段:
turn:当前轮次ctx:压缩后的上下文(protobuf + gzip,平均 < 4 KB)lock:分布式锁,防止多网关同时写
4. 性能测试:数据说话
测试环境:
- GKE 1.27,n2-standard-4(4C8G)× 30
- 模型推理:T4 GPU × 10,通过 KNative 自动扩缩
| 并发 | 单体 TP99 | 微服务 TP99 | 错误率 | 成本/天 |
|---|---|---|---|---|
| 2k | 180 ms | 95 ms | 0.1% | 120$ |
| 10k | 1200 ms | 120 ms | 0.2% | 260$ |
| 18k | 超时 | 190 ms | 0.5% | 380$ |
自动扩缩容阈值(HPA + KPA)
- CPU > 55% 持续 30 s → 扩容
- GPU 利用率 < 25% 持续 90 s → 缩容到 1
- 队列 lag > 5 s → 紧急扩容 +2
5. 生产环境避坑指南
消息幂等
- Kafka 开启
enable.idempotence=true - 业务层加
msgId+ Redis setnx,过期 1 h,防重放
- Kafka 开启
上下文丢失
- 网关每次
onOpen把sessionId写入 Redis,并设置 30 min 心跳 - 服务重启时先
MGET批量恢复,防止“冷启动空白”
- 网关每次
GPU 冷启动
- 预置 1 个常驻 Pod,最低 0.2 GPU 占位
- 使用 KNative 的
targetBurstCapacity=2,削峰填谷 - 模型文件放本地 SSD,避免拉镜像时重复下载 3 GB 的 onnx
日志追踪
- 全链路注入
X-B3-TraceId,打印到 Kafka 日志 topic,方便 Kibana 秒级检索 - 采样率压测环境 100%,生产按 10% 动态调整,防止打爆 ES
- 全链路注入
6. 开放问题:精度与速度的跷跷板
把 12B 参数的模型蒸馏到 1B,TP99 从 300 ms 降到 90 ms,但意图准确率掉了 2.3%。
线上 A/B 显示,用户满意度(CSAT)下降 1.1%,可接受;一旦掉到 3% 就触发客诉潮。
问题来了:
在你的业务字段里,这个阈值是多少?
是“宁可慢一点也要答对”,还是“先给秒回再慢慢纠错”?
当 GPU 预算封顶、延迟不能再低时,你会选择:
- 继续剪枝蒸馏?
- 用 MoE 动态路由把难句转大模型?
- 还是把决策权交给用户——让 TA 自己点“深度思考”按钮?
欢迎在评论区交换你的数值与权衡逻辑。
写完这篇小结,我又把代码推到 GitHub Actions,一条命令整条链路跑通。
如果你想亲手搭一套一模一样的实时对话系统,又不想自己到处拼文档,可以看看这个动手实验:从0打造个人豆包实时通话AI。
实验把 ASR→LLM→TTS 的完整链路包成了可运行的 Web 模板,本地 Docker 就能起,前后端代码全开源。
我跟着做了一遍,大概两杯咖啡的时间就能跑通,小白也能顺利体验——当然,把它迁到 K8s 抗 10k 并发,还得靠上面这些“血泪”调优。祝你搭建顺利,线上零事故!