智能客服系统源码解析:从架构设计到高并发优化实战
摘要:本文深入剖析智能客服系统的核心架构与实现原理,针对高并发场景下的性能瓶颈问题,提出基于事件驱动和异步处理的优化方案。通过源码级分析、性能对比测试和实战代码示例,帮助开发者掌握构建高可用客服系统的关键技术,提升系统吞吐量30%以上。
1. 典型业务场景与技术挑战
618、双11 大促期间,客服入口的并发峰值可达日常 20 倍。用户一边秒杀一边咨询,常见痛点集中在:
- 高并发会话:单节点 5w 长连接,CPU 上下文切换飙升,GC 停顿导致“卡死”。
- 意图识别延迟:NLU 模型平均耗时 180 ms,同步调用会拖慢整条链路。
- 状态一致性:用户刷新页面后,客服视角的“正在输入”状态丢失,体验断层。
传统 HTTP 轮询方案在 1w 并发时,QPS 仅 2k,P99 延迟 1.2s,已无法满足实时性要求,于是我们把目光投向事件驱动架构。
2. 轮询 vs 事件驱动:为什么选 WebSocket + 消息队列
| 方案 | 连接数 | 每秒上行消息 | 内存占用 | 备注 |
|---|---|---|---|---|
| HTTP 轮询 | 1w | 1k | 1.2 GB | 90% 流量为空 |
| WebSocket | 1w | 8k | 0.4 GB | 长连接+EPOLL |
| WebSocket+MQ | 5w | 4w | 1.8 GB | 背压、削峰 |
核心差异:
- 网络模型:轮询基于 BIO,一条线程扛一个连接;WebSocket 依赖 Netty EPOLL,单线程可管理 5w 连接。
- 消息路径:轮询需 2 次 TCP 握手才能拿到响应;事件驱动推送侧一次 RTT 即可下行。
- 弹性伸缩:引入 Kafka 后,客服坐席可独立扩容,与网关解耦,实现“背压”自我保护。
架构图如下:
graph TD A[用户APP]--WebSocket-->|/ws/chat|B[Netty网关] B-->|Publish|C[Kafka topic:chat.in] D[意图服务]-->|Subscribe|C D-->|Publish|E[Kafka topic:chat.out] F[客服工作台]-->|Subscribe|E B-->|GET/SET|G[Redis 会话]3. 核心实现拆解
3.1 Spring Boot + Netty 的 WebSocket 网关
关键目标:支持 5w 并发长连接,动态感知上下线,代码精简后如下(Java 11,遵循 Alibaba 命名规范):
@Component @Sharable public class WsServerHandler extends SimpleChannelInboundHandler<WebSocketFrame> { private static final AttributeKey<String> UID = AttributeKey.valueOf("uid"); /** 连接池:uid -> Channel */ private static final ConcurrentHashMap<String, Channel> POOL = new ConcurrentHashMap<>(); @Override public void channelActive(ChannelHandlerContext ctx){ // 1. 只做内存记录,避免阻塞 EventLoop ctx.channel().attr(UID).setIfAbsent(UUID.fastUUID().toString()); } @Override protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame){ if (frame instanceof TextWebSocketFrame) { String uid = ctx.channel().attr(UID).get(); String json = frame.text(); // 2. 非业务逻辑,直接丢给后端线程池 ChatKafkaProducer.send(uid, json); } } @Override public void channelInactive(ChannelHandlerContext ctx){ String uid = ctx.channel().attr(UID).getAndRemove(); if (uid != null) { POOL.remove(uid); // 3. 清理 Redis 状态 RedisTemplate.delete(KeyBuilder.session(uid)); } } }要点注释:
- 使用
AttributeKey绑定 uid,避免二次查库。 - 读写 Redis 采用异步 API,不占用 IO 线程。
channelInactive里必须手动删除,防止连接泄漏。
3.2 Redis 会话状态设计
客服端需要随时拉取“用户正在输入”、“已读位置”等轻量状态,数据结构选型如下:
- 主键:
session:{uid} - 过期:300s(5 分钟心跳失效)
- 字段:
agentId-> StringinputTs-> LongreadSeq-> Long
示例:
HMSET session:U10086 agentId "A123" inputTs 168000000 readSeq 88 EXPIRE session:U10086 300优势:Hash 一次性读取 < 1 ms,且支持字段级过期(Redis 7 的 HEXPIRE)。
3.3 异步流水线线程池
Netty 的 EventLoop 只负责 IO,业务耗时逻辑必须 offload 到业务线程池,配置要点:
chat-business: core-pool-size: 32 max-pool-size: 200 queue-capacity: 2000 keep-alive-seconds: 60 thread-name-prefix: "biz-chat-%d" rejected-policy: CALLER_RUNS # 背压,拒绝不抛异常注意:
- 队列长度不建议无界,否则 GC 尖刺。
- 拒绝策略选 CALLER_RUNS,可平滑降级,而不是直接丢弃或抛异常。
4. 性能优化实战
4.1 JMeter 压测对比
硬件:4C8G 容器 * 3,模拟 5w 并发长连接。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| QPS(上行) | 8k | 12k |
| P99 延迟 | 380 ms | 120 ms |
| CPU 占用 | 85% | 55% |
| GC 次数/分钟 | 38 | 12 |
优化动作:
- Netty 开启
SO_BACKLOG=8192+EPOLL。 - 关闭 WebSocket 的
autoRead,手动流量控制,防止下游被冲垮。 - 使用
ThreadLocal缓存 Kafka Producer,减少对象创建。
4.2 连接泄漏检测
借助 Netty 的ResourceLeakDetector:
ResourceLeakDetector.setLevel(Level.PARANOID);配合 Prometheus 指标:
netty_active_connection{pod="$POD"} > 0若连接数在零流量时段仍持续增长,即可报警。
4.3 消息积压降级
Kafka 消费侧出现 Lag 时,优先保障“实时会话”,降级策略:
- 丢弃离线消息:用户已离开会话 30s,则直接
ack跳过后续推送。 - 合并消息:客服端批量拉取,最多 50 条/次,减少网络 RTT。
- 动态线程池:Lag > 5w 时,临时扩容 2 倍 Kafka 分区,提高并行度。
5. 生产环境避坑指南
5.1 心跳包超时
WebSocket 层心跳 30s,TCP 层SO_KEEPALIVE10min,二者不一致导致 NAT 设备提前踢掉连接。解决:
- 应用层心跳 <= 60s,兼容各类运营商 NAT。
- 读写空闲检测使用
IdleStateHandler,超 2 次未回 pong 即触发close()。
5.2 上下文丢失预防
刷新页面后 uid 会变,客服端找不到旧会话。做法:
- 登录时颁发
jwt-token,刷新后仍带同一userId。 - Redis 以
userId为主键,而非临时uid,保证状态续接。
5.3 敏感信息过滤 AOP
客服常涉及手机号、订单号,需脱敏落库与下行。自定义注解:
@Aspect @Component public class SensitiveAspect { @Around("@annotation(MaskSensitive)") public Object mask(ProceedingJoinPoint pjp) { Object ret = pjp.proceed(); if (ret instanceof String json) { return json.replaceAll("\\d{11}", "****"); } return ret; } }配合@MaskSensitive打在 Service 方法上,对返回 JSON 统一脱敏,避免人工遗漏。
6. 效果展示
上线后,大促峰值 5w 并发,系统稳稳扛住,客服同学终于不用一边回消息一边重启服务了。
7. 开放性问题
实时推送让我们把延迟压到 120 ms,但离线分析(会话质量、意图热点)需要批量拉取全量消息。Kafka 保留 7 天,数据量 3T,实时性与离线分析在资源争抢、副本成本上天然对立。你的团队会如何平衡这两条链路?欢迎评论区一起头脑风暴。