背景痛点:Chatbot Arena 的流量洪峰
去年 11 月,Chatbot Arena 官方榜单更新,一条推文把流量瞬间拉到平时的 27 倍。我们监控到的现象非常典型:
- 会话上下文丢失率 8.3%,用户刷新页面后历史对话消失
- P99 响应延迟从 600 ms 飙到 2.4 s,大量「正在输入...」卡死
- 单节点 CPU 打满后触发重启,Kubernetes 不断漂移 Pod,雪崩效应明显
根因并不神秘:HTTP 短轮询 + 本地内存 Session 在突发流量下就是会崩。长连接、无状态、可水平扩展是唯一的出路。
技术选型:WebSocket vs. gRPC-streaming
我们先在测试环境跑了一组基准,场景是「客户端发一句 30 字 → 服务端回一句 50 字」,持续 5 min,机器 4C8G。
| 协议 | 峰值 QPS | P99 延迟 | 单连接内存 | 断线重连成本 |
|---|---|---|---|---|
| HTTP 轮询(短) | 2.1 k | 1.2 s | 15 MB | 低 |
| WebSocket | 9.8 k | 380 ms | 25 MB | 高(需自己实现心跳) |
| gRPC-streaming | 18 k | 120 ms | 18 MB | 低(HTTP/2 + 自带重试) |
gRPC-streaming 在吞吐和延迟上全面胜出,而且 Spring Cloud 2022.x 对 gRPC 的集成已经成熟,于是拍板:网关层 gRPC-streaming 统一入口,内部微服务用 REST 互调,兼顾前后端体验与开发效率。
核心实现
1. Spring Cloud Gateway 路由
# application-gateway.yml spring: cloud: gateway: routes: - id: chat-grpc uri: lb:grpc://chat-service predicates: - Path=/chat.Stream/* filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 2000 redis-rate-limiter.burstCapacity: 4000要点:把lb:grpc当成普通 HTTP 一样配,Spring Cloud LoadBalancer 会自动做服务发现 + 负载均衡。
2. 分布式会话管理
@Configuration @EnableRedisRepositories public class SessionConfig { @Bean public RedisTemplate<String, DialogTurn> redisTemplate(RedisConnectionFactory f) { RedisTemplate<String, DialogTurn> t = new RedisTemplate<>(); t.setConnectionFactory(f); Jackson2JsonRedisSerializer<DialogTurn> ser = new Jackson2JsonRedisSerializer<>(DialogTurn.class); t.setValueSerializer(ser); t.setKeySerializer(new StringRedisSerializer()); return t; } } @Service public class DialogStore { @Autowired private RedisTemplate<String, DialogTurn> rt; private static final int TTL_MIN = 15; // 15 min 过期自动清理 /** * 幂等写入:turnId 由客户端生成 UUID,防止重试重复 */ public void save(String dialogId, DialogTurn turn) { String key = "dlg:" + dialogId; rt.opsForZSet().add(key, turn, turn.getSeq()); rt.expire(key, Duration.ofMinutes(TTL_MIN)); } public List<DialogTurn> list(String dialogId) { return rt.opsForZSet() .range("dlg:" + dialogId, 0, -1) .stream() .collect(Collectors.toList()); } }说明:用 Redis SortedSet 按 seq 排序,保证翻页顺序;TTL 自动清掉僵尸对话,省内存。
3. 熔断规则(Hystrix → Resilience4j)
resilience4j: circuitbreaker: configs: default: slidingWindowSize: 50 minimumNumberOfCalls: 20 failureRateThreshold: 40 waitDurationInOpenState: 5s automaticTransitionFromOpenToHalfOpenEnabled: true经验值:失败率 40% 就熔断 5 s,给下游 LLM 接口留恢复时间;slidingWindowSize 太小会误杀,太大又反应慢,50 是压测后折中值。
性能优化
1. K6 压测报告
脚本片段:
import http from 'k6/http'; import { check } from 'k6'; export let options = { stages: [ { duration: '2m', target: 10000 }, { duration: '5m', target: 10000 }, { duration: '2m', target: 0 }, ], }; export default function () { let url = `${__ENV.BASE_URL}/chat.Stream/chat`; let payload = JSON.stringify({ dialogId: __VU, seq: __ITER, text: 'hello' }); let res = http.post(url, payload, { headers: { 'Content-Type': 'application/json' } }); check(res, { 'status is 200': r => r.status === 200 }); }结果(10 k VU,持续 5 min):
- 成功率 99.7%
- P99 延迟 132 ms
- 网卡打满 8 Gbps,CPU 65%,内存 4.2 GB
瓶颈从应用层转移到带宽,符合预期。
2. JVM 调优模板
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:G1HeapRegionSize=16m -XX:+ParallelRefProcEnabled -XX:+PerfDisableSharedMem -Xms4g -Xmx4gG1GC 在 4 C 场景下比 Parallel 减少 30% 的停顿,配合-XX:MaxGCPauseMillis=100可把 GC 抖动压到百毫秒内,对实时对话体验非常关键。
避坑指南
- 对话状态持久化一定要「幂等键 + 顺序号」双保险,否则客户端重试会把同一句写两遍,LLM 收到重复上文直接「胡言乱语」。
- gRPC 连接泄漏:默认 Netty 线程池没做 limit,高并发下 FD 数飙到 60 k 被 Linux 打死。务必加
managed-channel-builder.maxInboundMessageSize()并定期channel.resetConnectBackoff()。 - Kubernetes Pod 优雅终止:在
preStop里先睡 5 s,等正在处理的流自然结束,再 SIGTERM;否则网关层 502 会瞬间飙高。
延伸思考:WebAssembly 运行时
LLM 推理部分 CPU 占用高,但又是无状态,天然适合边缘计算。我们做了一个 PoC:把 Rust 写的轻量推理模块编译成.wasm,用 Wasmer 3.x 在网关 Pod 内起ephemeral storage级别的运行时,实测:
- 冷启动 28 ms
- 内存占用 11 MB
- 相比远程调用 RT 减少 40 ms
缺点:模型体积 300 MB,每次拉取镜像耗时明显;如果镜像预热 + 本地缓存能解决,WebAssembly 会是「把 AI 推理塞进网关」的一条新路径,值得继续跟进。
把上面所有步骤串起来,你就能得到一套可水平扩展、会话不丢、延迟 <200 ms 的 Chatbot Arena 级对话系统。如果你想亲手搭一个「能说话」的 AI 伙伴,而不是只停留在文字聊天,可以顺手试试这个动手实验——从0打造个人豆包实时通话AI。我跟着文档跑了一遍,半小时就把 ASR+LLM+TTS 整条链路跑通,比自己东拼西凑省了不少踩坑时间,小白也能顺利体验。祝你编码愉快,线上零雪崩!