拼多多智能 618 大促零点那一刻,客服 QPS(每秒查询数)直接飙到 18 万,老系统像被踩了刹车:响应从 400 ms 涨到 3 s,部分用户看到“客服忙,请稍后再试”,转化率咔咔掉。问题归结起来就三样:① 单体 NLU 成为 CPU 瓶颈;② 多轮对话状态放本地内存,扩容就丢上下文;③ 高峰期流量瞬间 5 倍,直接冲垮后端。下面把这次升级拆给你看,整套架构怎么从“能聊”进化到“抗住”。
1. 架构总览:微服务 + 消息队列 + 状态外置
- 统一接入层(Gateway)做 TLS 终结 + 限流,把长连接收敛到少几台高配机。
- 对话服务拆成三角色:
- Router:无状态,只负责按 uid 哈希把请求打到下游 Intent 或 Chat 节点。
- Intent:跑轻量 BERT-mini,输出意图+槽位。
- Chat:管理多轮状态、策略树、答案渲染。
- 所有“写状态”操作发 Kafka,做到“先写消息后写库”,下游 Consumer 异步刷 Redis Cluster,既削峰又保证最终一致。
- Redis Cluster 按 uid 分 1024 槽,单槽 8 G 内存,主从 + Sentinel,保证节点宕机 3 s 内完成主从切换。
- 第三方 NLP(情感分析、敏感词)走本地 gRPC 连接池,外加 Circuit Breaker,失败率 30% 直接熔断,降级用规则兜底。
一句话:把“有状态”浓缩到 Redis,把“无状态”无限复制,用消息队列把毛刺削平。
2. 核心算法:让 BERT 跑得动又准得快
- 模型瘦身
- 蒸馏:Teacher 用 12 层 BERT-base,Student 用 4 层 Hidden=312,参数量从 110 M→17 M,推理时延 180 ms→45 ms(T4 GPU)。
- 动态批:TensorRT + ONNX,把 1~8 条请求拼成固定 8 Batch,GPU 利用率从 35% 提到 72%。
- 意图缓存
用户常问“我的快递到哪了”这种高频句,计算一次后把 <uid, msg_hash> → intent 结果写 Redis,TTL 300 s,命中率 38%,日均省 2 万 GPU 次。 - 策略树缓存
Chat 服务把“节点跳转表”整体预热到本地 Caffeine(本地 LRU),命中率 95%,P99 从 120 ms 压到 25 ms。
3. 关键代码片段
3.1 带负载均衡的对话路由(Python 伪码)
import hashlib, random, redis, requests r = redis.Redis(host='redis-cluster', decode_responses=True) def route(uid, msg): # 一致性哈希选节点 nodes = ['intent-1', 'intent-2', 'intent-3'] h = int(hashlib.md5(uid.encode()).hexdigest(), 16) chosen = nodes[h % len(nodes)] # 本地健康探测失败则重试 for try_cnt in range(3): if not r.get(f'hb:{chosen}'): # 心跳过期 5 s 视为宕机 chosen = nodes[(h+try_cnt+1) % len(nodes)] continue resp = requests.post(f'http://{chosen}/predict', json={'uid': uid, 'msg': msg}, timeout=0.8) if resp.status_code == 200: return resp.json() return {'intent': 'safe_default', 'slots': {}}3.2 多轮上下文缓存与续聊
def get_context(uid): key = f'ctx:{uid}' # 先读本地缓存,miss 再读 Redis ctx = local_cache.get(key) if ctx is None: ctx = r.hgetall(key) or {} local_cache.set(key, ctx, ttl=60) return ctx def save_context(uid, turn_data): pipe = r.pipeline() ctx_key = f'ctx:{uid}' pipe.hset(ctx_key, mapping=turn_data) pipe.expire(ctx_key, 3600) # 1 h 无互动自动清 pipe.publish('kafka-topic', {'uid': uid, 'event': 'ctx_update'}) pipe.execute()4. 压测与优化效果
- 环境:40 台 32C128G 虚机,Intent 子集群 18 台,Chat 子集群 22 台,Kafka 12 分区,Redis 三主三从。
- 场景:模拟 20 万并发长连接,持续 30 min。
- 结果对比:
| 指标 | 单体老架构 | 新分布式架构 |
|---|---|---|
| P99 响应 | 2.8 s | 0.29 s |
| 错误率 | 6.3 % | 0.4 % |
| 单节点 CPU 峰值 | 96 % | 58 % |
| 扩容耗时(+50% 节点) | 30 min+ | 4 min(无状态直接镜像) |
- 超时重试
- 读路径:单次超时 800 ms 即重试,最多 2 次;
- 写路径:Kafka 异步,失败入本地磁盘队列,Daemon 每 30 s 重放,保证最终一致。
5. 踩坑与填坑实录
- 状态同步双写冲突
早期 Chat 节点既写 Redis 又同步写 MySQL,主从延迟 200 ms 导致“答案已出,状态落后”。解决:只写 Redis + Kafka,MySQL 仅做离线对账,不阻塞线上。 - 本地缓存穿透
用户 uid 极不均匀,热点 key 打到同一台 Redis 节点,CPU 软中断飙高。用uid+slot预分片 + 本地缓存 60 s 兜底,QPS 从 6 w→1.2 w。 - 第三方 NLP 雪崩
外部情感分析接口 1 s 延迟,线程池被打满,整机会话夯住。加 Circuit Breaker + 线程隔离仓,超时 300 ms 直接熔断,降级用关键词规则,用户几乎无感。
6. 延伸:多轮对话断点恢复
- 把每轮有效事件按
{uid, seq, event}写 Kafka,seq 自增。 - 用户重新上线时,先读
ctx:{uid}拿最后 seq,再按需回溯 Kafka(seq+N到当前),拼装成最新状态。 - 超过 7 天会话冷数据转存 OSS,节省 Redis 内存 18%。
- 回放测试:模拟 1000 万用户 30 天断点,平均回放时延 120 ms,内存增量 < 200 M。
7. 小结
拼多多的这套智能客服,说穿了就是把“状态”外移,让计算节点无状态可平行扩展;用消息队列削峰,保证流量洪峰不冲垮;模型和策略树层层缓存,GPU 省到刀尖上;再配合熔断 + 重试,把第三方不可控因素兜住。618、双 11 已验证,高峰 20 万 QPS 能稳在 300 ms 以内。若你也正筹划高并发对话系统,不妨把以上步骤当 checklist,先拆状态、再削峰、后缓存,基本就能躲过 80% 的坑。祝各位上线不踩雷,值班不报警。