Chatbot Arena网址实战:构建高可用对话系统的架构设计与避坑指南
- 背景痛点:流量洪峰下的“三座大山”
去年双十一,我们给电商客服做了一套 Chatbot Arena 风格的实时对话系统,凌晨 0 点流量瞬间飙到 4.2 万 QPS,老架构直接“躺平”:
- 响应延迟从 200 ms 涨到 3 s,CPU 空转在 JSON 序列化;
- WebSocket 长连接 30 s 一断,用户重连后上下文丢失,只能再问一遍“我的订单呢?”;
- 多轮会话状态放在本地 HashMap,节点一挂,聊天记录灰飞烟灭。
痛定思痛,我们决定用微服务+事件驱动重新设计,目标只有一个:在 5 k 并发下,P99 延迟 < 300 ms,消息 0 丢失。
- 技术选型:HTTP 轮询 vs WebSocket vs gRPC
先在同配置 4C8G 容器里跑压测,数据如下(单轮 256 byte 负载):
| 协议 | QPS | P99 延迟 | CPU 占用 | 备注 |
|---|---|---|---|---|
| HTTP 轮询(1 s) | 6 k | 1.1 s | 35% | 大量 304,移动端耗电 |
| WebSocket | 28 k | 45 ms | 40% | 长连接,节约 3 倍带宽 |
| gRPC* | 32 k | 38 ms | 45% | 需要 HTTP/2 网关,调试略重 |
*注:gRPC 在浏览器侧需借助 grpc-web,多一层 Envoy,最终我们把它留给内部微服务调用,对外依旧 WebSocket。
- 核心实现:Spring WebFlux + Redis Stream
3.1 非阻塞 WebSocket 端点
用 Spring WebFlux 的ReactiveWebSocketHandler做入口,Netty 事件循环线程只有 8 个,却能扛住 5 k 并发,关键是“异步到底”:
class ChatSocketHandler(private val chatService: ChatService) : WebSocketHandler { override fun handle(session: WebSocketSession): Mono<Void> { val receive = session.receive() .map { it.payloadAsText } .doOnNext { Metrics.counter("chat.in.msg", "type", "user").increment() } .flatMap { msg -> chatService.handle(msg, session.id) } .onErrorResume { ex -> log.warn("ws error: ${ex.message}") session.send(Mono.just(session.textMessage("""{"code":500}"""))) } val send = chatService.outboundFlux(session.id) .map { session.textMessage(it) } .doOnNext { Metrics.counter("chat.out.msg", "type", "bot").increment() } return session.send(send.mergeWith(receive.then())) } }3.2 Redis Stream 做消息总线
每个节点把用户消息写到chat:{roomId}流,消费组保证至少一次送达:
public Mono<String> handle(String msg, String sessionId) { String record = buildRecord(msg, sessionId); return redisReactive.exec( RedisStreamCommands.XADD, "chat:" + extractRoomId(sessionId), "*", "payload", record) .thenReturn("OK"); }下游 LLM 服务独立成组,拉取→推理→写回结果流,实现“生产-消费”解耦,节点扩容只需加消费组即可。
3.3 分布式会话状态
会话快照用 Redis Hash 存储,结构session:{id} → {uid,roomId,ctxSeq,lastMsgAt},并通过Redisson的RMapCache设置 15 min 过期,防止僵尸数据:
val bucket = redisson.getMapCache<String, SessionSnapshot>("session:$sessionId") bucket.put("ctxSeq", snapshot.ctxSeq, 15, TimeUnit.MINUTES)每次 LLM 返回时,先WATCH+MULTI保证ctxSeq单调递增,解决并发写乱序问题。
- 性能优化:压测、调参、结果
4.1 JMeter 5000 并发场景
- 持续 5 min,每秒新建 100 连接,累计 30 k 长连接;
- 吞吐量稳定在 28 k 消息/秒;
- P99 延迟 280 ms,CPU 65%,内存 3.2 G;
- 0 消息丢失,0 超时断开。
4.2 连接池与内核参数
Netty 侧:
SO_BACKLOG=4096SO_REUSEADDR=trueSO_RCVBUF/SO_SNDBUF=256 k
Redis 侧:
- Lettuce 线程池
ioThread=4 timeout=3 scluster fresh interval=30 s
Linux 侧:
echo 'net.core.somaxconn = 65535' >> /etc/sysctl.conf echo 'net.ipv4.tcp_tw_reuse = 1' >> /etc/sysctl.conf调完单机能扛 50 k 并发握手。
- 避坑指南:踩过的坑,一个都别落下
5.1 心跳与 TCP KeepAlive 冲突
早期我们把WebSocketSession空闲超时设为 30 s,又打开系统tcp_keepalive_time=60,结果 NAT 网关 50 s 回收连接,服务端 30 s 没收到心跳就主动关,客户端看到的却是“神秘 1006 错误”。
解法:心跳 25 s 一次,应用层超时 ≥ 2 个心跳周期;系统 KeepAlive 留给内网,外网一律靠业务心跳。
5.2 消息幂等
Redis Stream 的 ID 即唯一键,LLM 回包时把 ID 带在字段msgId里,前端重复点击先查本地Set再决定是否发 ACK,保证“一条答案只渲染一次”。
5.3 灰度兼容
WebSocket 子协议名带版本号,例如chatbot.v2.json,老版本客户端拒绝升级时,网关自动路由到 v1 集群;同时 JSON 新增字段放ext对象,做到“向下兼容”。
- 互动环节:跨数据中心会话同步,你会怎么做?
当用户从北京机房漂到上海,连接重新建立,如何把未读消息与上下文毫秒级同步?
- 是用 Redis Cluster 的
WAIT命令等写 propagate? - 还是通过 Kafka MirrorMaker 做双向复制?
- 或者干脆把会话快照放全球表,用 CRDT 冲突自由合并?
欢迎留言聊聊你的方案,一起把坑填平。
- 小结与动手福利
把 Chatbot Arena 的“人机对战”思路搬到生产级对话系统,本质就是解决高并发、低延迟、强一致三件事:协议选 WebSocket,服务拆微,状态放 Redis,消息走 Stream,监控加 Metrics,灰度留协议版本——照着做,5 k 并发只是起点。
如果你想亲手把 ASR→LLM→TTS 整条链路跑通,又懒得自己搭网关、调音色,可以试试这个一站式实验:
从0打造个人豆包实时通话AI
里面把火山引擎的豆包语音系列模型都封装好了,WebSocket 网关、Redis 流式消费、异常重试、监控埋点全配齐,本地docker-compose up就能跑。我跑完第一版只花了 45 分钟,改两行配置就让 AI 用“四川话”回我,效果还挺稳。对于想快速验证实时对话原型、又不想先踩一轮坑的同学,绝对够用。