背景痛点:高并发下的“对话雪崩”
去年双十一,我们自研的智能客服在零点瞬间涌入 4.2 万并发,QPS 曲线像坐过山车一样飙到 5 800 后陡降,大量对话出现 8 s+ 的超时,更严重的是上下文断裂——用户刚说完“我要退货”,机器人却回“请问您想咨询哪方面?”体验瞬间崩塌。
根因可以归结为三点:
- 单体服务内线程池打满,Tomcat 200 工作线程被同步调用阻塞,请求在入口排队。
- 对话状态全部放在 JVM 本地 HashMap,水平扩容后状态丢失,用户被随机路由到无状态节点。
- 意图识别模型(BERT-base,300 ms+)与主流程同步串行,任何一次模型抖动都直接放大到入口延迟。
一句话:单体架构的“共享内存 + 同步调用”模型,在流量洪峰面前天然脆弱。
技术选型:为什么不是“继续堆机器”
我们对比过两条路线:
| 方案 | 优势 | 劣势 | 结论 |
|---|---|---|---|
| 单体+横向扩容 | 改造量小,运维熟悉 | 状态本地持有,无法弹性;线程阻塞依旧;代码耦合 | 只能把“雪崩”往后挪 |
| 微服务+事件溯源 | 无共享状态,天然水平扩展;异步削峰;事件可重放 | 链路长、调试难、最终一致性需自己兜底 | 符合“弹性+可观测”目标 |
事件总线选型上,RabbitMQ 低延迟但吞吐量天花板明显;RocketMQ 性能好,却需要额外部署 NameServer;Kafka 在 2.8 之后自带 KRaft,去掉 ZooKeeper,吞吐 200 MB/s+ 且生态完善,与 Spring Cloud Stream 3.x 无缝集成,最终敲定 Kafka 作为核心事件总线。
核心实现:让“状态”流动起来
1. 对话状态分片存储
把“对话”抽象为 ConversationAggregate,以 conversationId 作为分片键,持久化到 Redis Cluster,同时写入 Kafka topicconversation.event,供后续审计与重放。
// 领域模型:聚合根 public class ConversationAggregate { private String conversationId; private Long userId; private DialogState state; // 状态机 private List<Event> uncommittedEvents = new ArrayList<>(); public void apply(Event event){ uncommittedEvents.add(event); // 状态机无锁转换 this.state = StateMachine.transition(state, event); } }Spring Cloud 侧通过ReactiveRedisTemplate异步落盘,并启用lettuce的共享连接池,把 I/O 线程与业务线程分离,CPU 利用率提升 30%。
2. 意图识别服务异步化
原流程:网关 → 同步 HTTP → BERT 服务 → 返回意图 → 继续流程
新流程:网关发送IntentRequestEvent→ Kafka → 意图微服务消费 → 回写IntentResolvedEvent→ 原聚合根消费并继续状态机。
关键代码片段(Kafka Streams DSL):
@Bean public Consumer<KStream<String, IntentRequestEvent>> process() { return stream -> stream .mapValues(this::invokeBertModel) // 300 ms+ .mapValues(result -> IntentResolvedEvent.builder() .conversationId(result.getConversationId()) .intent(result.getIntent()) .confidence(result.getScore()) .build()) .to("topic.intent.resolved", Produced.with(Serdes.String(), new JsonSerde<>(IntentResolvedEvent.class))); }背压机制靠 Kafka 的 lag 自动提交间隔调节,消费端 max.poll.records 动态降到 50,避免一次性拉取过多消息导致 Full GC。
3. 事件溯源与状态机
聚合根只关心事件顺序,不依赖 DB 事务;重启时重放事件即可恢复到最新状态。以下示例展示“已下单→申请退货”状态转换:
public enum DialogState { START, ORDER_CONFIRMED, RETURN_APPLIED, CLOSED } static class StateMachine { static DialogState transition(DialogState current, Event event){ switch(current){ case START: if (event instanceof OrderPlacedEvent) return DialogState.ORDER_CONFIRMED; break; case ORDER_CONFIRMED[+]: if (event instanceof ReturnRequestedEvent) return DialogState.RETURN_APPLIED; break; default: throw new IllegalTransitionException(); } return current; } }所有事件持久化到 Kafka 压缩主题,保留 7 天,可任意时间点重建聚合。
性能验证:TP99 从 4.3 s 降到 480 ms
使用 JMeter 20 台发压机,200 并发线程,持续 15 min,对比数据如下:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 峰值 QPS | 5 800 | 5 200 |
| TP99 | 4 300 ms | 480 ms |
| 错误率 | 12% | 0.4% |
| CPU 峰值 | 96% | 68% |
异步化后,网关线程平均 RT 40 ms,BERT 服务虽仍 300 ms,但不再阻塞主链路;Kafka 以 30 MB/s 持续写入,磁盘 IO 仅 12%。
避坑指南:让弹性名副其实
Kafka 消息积压扩缩容
采用 Kubernetes HPA,指标为kafka.consumer.lag > 50 000 records,配合 Cruise 工具在 30 s 内完成分区重平衡;扩容后需把session.timeout.ms调到 24 s,避免新节点被误踢。对话上下文冷热分离
热数据(近 30 min)放 Redis 内存表,冷数据异步归档到 S3,按 conversationId 前缀压缩,节省 70% 存储成本;回放采用 RedisTimeSeries 记录每秒状态数,实现秒级监控。最终一致性补偿
对“退货成功但优惠券未回滚”场景,引入 Saga 管理器,监听ReturnCompletedEvent后反向调用券服务;失败重试 3 次,仍失败则落入人工队列,保证资金正确。
延伸思考:实时监控看板
建议读者基于 RedisTimeSeries 做轻量级看板:
- 每条事件写入时,同步执行
TS.ADD event:count * 1,标签携带state=ORDER_CONFIRMED。 - Grafana 通过
redis-datasource直接查询,聚合函数TS.RANGE即可绘制“状态分布热力图”。 - 结合 HPA 的 lag 指标,可在同一面板内对比“业务状态”与“底层消息”趋势,快速定位性能瓶颈。
这样,无需引入 Prometheus 也能在 5 分钟内搭起秒级延迟的实时监控,适合中小团队快速落地。
把单体进化为“事件驱动 + 状态分片”后,系统不再惧怕峰值,也让业务迭代回归纯粹:新增状态只需加事件、改状态机,无需大表迁移。若你正被高并发客服场景折磨,不妨从“让状态流动”这一步开始,先拆事件,再拆服务,循序渐进,弹性与效率自然水到渠成。