1. 从“排队 30 秒”到“秒回”:电商大促催生的客服痛点
去年双十一,我们组接到一个“简单”需求:把原本 8 个客服妹子的人工坐席,换成机器人先顶 80% 咨询量。
上线前压测一看,传统轮询接口(HTTP 短轮询 2s/次)在 2w 并发下,平均响应 1.8s,CPU 飙到 90%,妹子们还得加班。
核心矛盾一句话:用户只想问一句“券怎么用”,却要经历三次 302 跳转 + 四次 JSON 解析。
智能客服的突破口就是“长连接 + 语义理解”,把每一次交互压缩到 200ms 以内,机器回答不了再无缝切人工。目标定了:QPS ≥ 3w、P99 ≤ 300ms、可用性 99.9%。
2. 技术选型:为什么不是 Node.js 而是 Java?
- 团队基因:后端 80% 是 Java,Spring 全家桶熟练。
- 生态:Spring Cloud Alibaba 对双 11 秒杀流量有成熟限流方案。
- 模型推理:TensorFlow Java API 可以直接加载 Python 训练的 BERT 模型,省去 RPC 调用。
一句话:用 Java 也能把 NLP 跑在 50ms 内,只要会调 JVM 和线程池。
3. 微服务拆分:一张图看懂 7 个核心域
im-gateway:WebSocket 接入层,只做连接保活与消息编解码。dialog-service:对话状态机,负责槽位填充、多轮会话。nlp-service:意图识别 + 实体抽取,内嵌 BERT 轻量模型。route-service:按“业务线 + 客服技能”做一致性 Hash 路由。sensitive-service:DFA 敏感词过滤,支持热更新。user-profile:分布式会话 + 画像,Redis Cluster 存储。admin-service:人工坐席工作台,提供“抢单”与“转接”。
每个服务独立数据库,dialog-service 用 MySQL 只存对话流水,nlp-service 完全无状态,方便横向扩容。
4. WebSocket 双向通信:Java 代码实战
下面这段代码跑在im-gateway,基于 Netty + Spring WebFlux,支持 10w 并发长连接。重点做了三件事:
- 心跳保活(客户端 30s 一次 Ping,服务端回 Pong)。
- 连接唯一标识使用
userId + deviceType,解决多端登录。 - 断线重连后自动恢复会话,消息序号自增保证顺序。
@Component @ServerEndpoint("/chat/{userId}/{device}") public class ChatEndpoint { private static final ConcurrentHashMap<String, Session> POOL = new ConcurrentHashMap<>(); private final long MAX_IDLE = 35_000; // 略大于客户端 30s @OnOpen public void onOpen(Session session, @PathParam("userId") String userId) { String key = userId + ":" + session.getId(); POOL.put(key, session); // 注册到 Redis 做分布式路由 RedisTemplate.opsForValue().set("route:" + userId, serverId, MAX_IDLE, TimeUnit.MILLISECONDS); } @OnMessage public void onMessage(String text, Session session) { // 1. 解析 JSON 拿到 messageId,用于幂等 JSONObject msg = JSON.parseObject(text); String msgId = msg.getString("messageId"); // 2. 判重:Redis setnx 过期 5min Boolean first = RedisTemplate.opsForValue().setIfAbsent("msgId:" + msgId, "1", 5, TimeUnit.MINUTES); if (Boolean.FALSE.equals(first)) { return; // 重复消息直接丢弃 } // 3. 异步投递到 Kafka,解耦业务 kafkaTemplate.send("chat-topic", text); } @OnClose public void onClose(Session session, @PathParam("userId") String userId) { POOL.remove(userId + ":" + session.getId()); RedisTemplate.delete("route:" + userId); } // 定时任务:每 30s 扫描一次,踢掉空闲连接 @Scheduled(fixedDelay = 30_000) public void heartbeatCheck() { POOL.entrySet().removeIf(e -> { if (!e.getValue().isOpen()) return true; if (System.currentTimeMillis() - e.getValue().getLastPong() > MAX_IDLE) { try { e.getValue().close(); } catch (IOException ignore) {} return true; } return false; }); } }5. 分布式会话:Redis 怎么扛 3w QPS?
- Key 设计:
session:{userId}→ Hashintent/slot/createTime/ttl
- 过期策略:每次消息刷新 TTL=15min,节省内存。
- 序列化:Protobuf + LZ4 压缩,平均 0.8KB/会话,比 JSON 省 40%。
- 并发写:使用
Lua脚本保证“读-改-写”原子性,避免幻读。
压测显示,单分片 8K QPS 时 RT 0.9ms,水平扩容 10 片即可支撑 3w。
6. 敏感词过滤:DFA 算法 0.3ms 完成 2 万字检测
传统for+indexOf方案在 2w 字文本耗时 30ms,DFA(Deterministic Finite Automaton)只要 0.3ms,内存占用 20MB 以内。
核心思路:把敏感词建成一颗“共享前缀树”,文本一次遍历即可。
public class SensitiveFilter { private final Map<Character, Map> nodes = new HashMap<>(); private char endFlag = '&'; // 叶子节点标记 // 构造 DFA public void loadWords(List<String> words) { for (String w : words) { Map<Character, Map> cur = nodes; for (char c : w.toCharArray()) { cur = cur.computeIfAbsent(c, k -> new HashMap<>()); } cur.put(endFlag, null); } } // 过滤:返回替换后的文本 public String filter(String text) { StringBuilder out = new StringBuilder(); char[] chars = text.toCharArray(); for (int i = 0; i < chars.length; ) { int move = longestMatch(chars, i); if (move > 0) { // 命中敏感词 out.append("*".repeat(move)); i += move; } else { out.append(chars[i++]); } } return out.toString(); } private int longestMatch(char[] chars, int start) { Map<Character, Map> cur = nodes; int max = 0; for (int i = start; i < chars.length; i++) { char c = chars[i]; if (!cur.containsKey(c)) break; cur = cur.get(c); if (cur.containsKey(endFlag)) max = i - start + 1; } return max; } }热更新方案:Admin 后台改库 → 推送到sensitive-service本地内存,双缓存 1min 切换,无中断。
7. 性能调优:JMeter 压测数据说话
测试环境:
- 4C8G * 20 台 Docker 容器,OpenJDK17,网络千兆。
- 场景:持续 15min,3w 并发长连接,每连接每 5s 发 1 条提问,机器人回复 20 字。
结果:
| 指标 | 平均值 | P99 | 峰值 |
|---|---|---|---|
| QPS | 30,200 | — | 33k |
| RT (ms) | 85 | 280 | 310 |
| 错误率 | 0.08% | — | — |
| CPU | 68% | 82% | — |
| 内存 | 3.2G | 3.8G | — |
调优关键:
- Netty 开启
SO_BACKLOG=8192+TCP_NODELAY。 - Kafka Producer 批量 16KB、linger=5ms,磁盘 IO 降 30%。
- G1 堆 4G,
-XX:MaxGCPauseMillis=100,Young GC 平均 28ms。
8. 避坑指南:三个月踩出的 3 个深坑
8.1 消息幂等性
- 前端 UUID 生成 messageId,后端 Redis SetNX 5min 过期。
- 对账任务:每日凌晨批量扫 Redis 过期 Key,发现异常重复推送到告警群。
8.2 线程池参数
- 早期
corePoolSize=200导致上下文切换飙红,后改为CPU*2+1,配合有界队列ArrayBlockingQueue(2k)+ 拒绝策略CallerRuns。 - 自定义 ThreadFactory 命名线程,方便
arthas抓栈。
8.3 第三方 API 熔断
- 使用
Resilience4j的CircuitBreaker:- 失败率 50% 或 10 次失败即开路,持续 20s 后进入半开。
- 降级返回“客服忙,请稍候”,避免拖垮自身线程池。
- 同时做
TimeLimiter:单次调用超时 300ms,防止雪崩。
9. 模型精度 vs 响应速度:如何二选一?
我们把 BERT 意图模型蒸馏成 4 层 TinyBERT,量化后 30MB,单条 CPU 推理 45ms,比原始下降 9ms,精度只掉 1.3%。
但金融场景里,一旦涉及“提现失败”这类高风险意图,宁可多花 100ms 也要调大模型。
目前策略:
- 普通咨询 → TinyBERT(45ms)。
- 命中高风险词 → 动态路由到完整 BERT(120ms)。
- 夜间低峰 → 异步批量调用 12 层大模型做标注,反哺训练集。
开放问题:
如果让你设计,你会把“精度-速度”的决策权交给规则引擎,还是交给实时学习的强化策略?
欢迎留言聊聊你的做法。