1. 痛点先行:高并发客服系统最怕什么
去年双十一,我们自研的 Java 智能客服系统第一次面对 5w+ 并发 QPS,结果“翻车三连”:
- 消息积压:Tomcat 默认 200 工作线程瞬间打满,用户端看到“正在输入…”转圈 8s 才收到回复。
- 会话粘性:网关层做轮询负载均衡,结果用户刷新页面后落到另一台节点,历史对话全丢,客服一脸懵。
- 异常恢复:Netty 节点宕机,WebSocket 连接直接断开,客户端没有重连策略,导致 30% 留言丢失。
痛定思痛,我们把系统彻底重构,目标只有一个:在高并发场景下,让消息“又快”又“稳”地到达。
2. 技术选型:轮询 vs WebSocket、单体 vs 微服务
| 维度 | 短轮询 | 长轮询 | WebSocket |
|---|---|---|---|
| 延迟 | 高(1~3s) | 中(200~500ms) | 低(<50ms) |
| 并发 | 消耗线程多 | 消耗线程中 | 单线程可维护万级连接 |
| 兼容性 | 100% | 100% | 需 90%+ 现代浏览器 |
结论:客服场景对实时性极度敏感,WebSocket + STOMP 子协议是唯一选择。
架构层面,单体应用靠“加机器”只能纵向扩容,而Spring Cloud 微服务可以:
- 独立扩缩容 IM 网关、AI 机器人、工单三个域。
- 利用 Kubernetes HPA,CPU 60% 即自动弹节点。
代价是调用链变长,必须引入消息队列做最终一致,后文会给出压测数据。
3. 核心实现
3.1 双向通信层:Spring Boot + Netty
关键配置:使用 EPOLL 事件模型 + 零拷贝,Linux 环境 QPS 提升 30%。
/** * 启动类:绑定 8888 端口,使用 EPOLL 仅在 Linux 生效 */ @SpringBootApplication public class ImGatewayApplication { public static void main(String[] args) { System.setProperty("io.netty.eventLoopThreads", "" + Runtime.getRuntime().availableProcessors() * 2); SpringApplication.run(ImGatewayApplication.class, args); } }/** * WebSocket 初始化通道 * 并发安全:Sharable 注解保证 handler 可被多个通道复用 */ @Component @ChannelHandler.Sharable public class WsServerInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline pl = ch.pipeline(); pl.addLast(new HttpServerCodec(), new HttpObjectAggregator(65536), new WebSocketServerProtocolHandler("/ws"), new TextWebSocketFrameHandler()); } }3.2 分布式会话:Redis + TTL 续期
需求:用户刷新页面后依旧能找到原客服,且 30min 无互动自动断线。
/** * 分布式会话仓库 * 1. 使用 Redis Hash 存储 userId -> serverId * 2. 每次上行消息都续期 TTL,避免误踢 */ @Repository public class DistributedSessionRepo { private final StringRedisTemplate redis; private static final String KEY_PREFIX = "im:session:"; private static final Duration TTL = Duration.ofMinutes(30); public DistributedSessionRepo(StringRedisTemplate redis) { this.redis = redis; } /** * 绑定用户与 Netty 节点 * @return 是否成功(并发时 CAS 写入) */ public boolean bind(String userId, String serverId) { String key = KEY_PREFIX + userId; Boolean flag = redis.opsForValue() .setIfAbsent(key, serverId, TTL); return Boolean.TRUE.equals(flag); } /** * 续期 TTL;每次收到消息调用 */ public void renew(String userId) { String key = KEY_PREFIX + userId; redis.expire(key, TTL); } /** * 查询用户所在节点 */ public Optional<String> getServerId(String userId) { String key = KEY_PREFIX + userId; return Optional.ofNullable(redis.opsForValue().get(key)); } }3.3 敏感词过滤:DFA 算法
客服发送内容必须毫秒级过滤,DFA(确定性有限自动机)是经典方案。
/** * DFA 构建器,线程安全构建后不可变 */ public class SensitiveFilter { private final Map<Character, Object> root = new HashMap<>(); /** * 加载词库,构造 DFA 树 */ public void loadWord(List<String> words) { for (String w : words) { Map<Character, Object> cur = root; for (char c : w.toCharArray()) { cur = (Map<Character, Object>) cur.computeIfAbsent(c, k -> new HashMap<>()); } cur.put('\0', Boolean.TRUE); // 结束标志 } } /** * 替换敏感词 * @param text 原始文本 * @return 替换后文本 */ public String replace(String text) { StringBuilder out = new StringBuilder(text); for (int i = 0; i < out.length(); i++) { int idx = i; Map<Character, Object> cur = root; int j = i; while (j < out.length() && cur.containsKey(out.charAt(j))) { cur = (Map<Character, Object>) cur.get(out.charAt(j)); if (cur.containsKey('\0')) { // 命中 for (int k = i; k <= j; k++) out.setCharAt(k, '*'); break; } j++; } } return out.toString(); } }4. 性能保障
4.1 JVM 调优实测
机器:4C8G,Docker 限制 6G 堆。
| 参数 | 默认 | 调优后 | QPS 提升 |
|---|---|---|---|
| -Xms -Xmx | 1G/1G | 4G/4G | +18% |
| GC 算法 | Parallel | G1 | 停顿从 240ms→60ms |
| -XX:MaxGCPauseMillis | 无 | 100 | 99 线延迟下降 30% |
结论:G1 + 大堆对 WebSocket 长连接场景最友好,但堆>4G 后收益递减。
4.2 消息队列压测
场景:10 个生产端,每端 2k QPS,共 2w QPS 打入队列,消费者 3 节点。
| 队列 | 磁盘刷盘 | 吞吐 | 备注 |
|---|---|---|---|
| Kafka(3 副本) | 异步 | 22w/s | CPU 70%,磁盘瓶颈 |
| RocketMQ(3 副本) | 同步刷盘 | 12w/s | 延迟 5ms,消息零丢失 |
最终选型:Kafka 做削峰,RocketMQ 做业务消息,各取所长。
5. 避坑指南
5.1 分布式锁导致重复消费
Kafka 消费者自动提交 offset,网络抖动会重平衡,导致同一条消息被两台机器消费,客服重复回答。
解决:
- 消费端使用 Redis SETNX 做幂等,key=msgId,过期 5min。
- 失败时抛 RuntimeException,触发 Kafka 重试,最多 3 次。
if (Boolean.TRUE.equals(redisTemplate.opsForValue() .setIfAbsent("im:consume:" + msgId, "1", Duration.ofMinutes(5)))) { // 真正处理 } else { log.warn("重复消息,msgId={}", msgId); }5.2 对话上下文丢失排查 SOP
现象:用户说“查看订单”,机器人回“请问订单号?”——用户刷新后机器人失忆。
排查流程:
- 确认浏览器发送的 CONNECT 帧是否带同一 userId。
- 查看 Redis 中 KEY_PREFIX + userId 是否存在。
- 若 key 存在但 serverId 与当前节点不一致,说明网关层负载均衡算法非一致性 Hash。
- 把网关改为基于 userId 的源地址 Hash,问题解决。
6. 代码规范小贴士
- 所有并发工具类必须加**@ThreadSafe** 或**@NotThreadSafe** 注释,杜绝新人误用。
- 方法级 JavaDoc 强制写“并发限制”:例如“本方法仅允许 Netty I/O 线程调用”。
- 日志模板统一使用占位符,禁止字符串拼接,防止高并发创建大量中间对象。
7. 一张图总结架构
从外到内:DNS → SLB → Spring Cloud Gateway → Netty 集群 → Kafka → AI 推理服务 → MySQL/RDS。
8. 思考题:如何实现客服对话的断线重连?
目前我们仅依赖客户端浏览器的onclose事件 3s 后重连,但在弱网环境下仍会丢消息。欢迎留言聊聊:
- 如果让你设计消息补偿机制,你会选择客户端本地缓存 + 重连后拉取,还是服务端为每个会话保留离线队列?两者在存储与一致性上如何权衡?
期待你的方案,一起把智能客服做得更稳。