痛点分析:单体客服的“慢”与“卡”
去年双十一,公司老的单体智能客服第一次出现“雪崩”:高峰期 QPS 冲到 800,接口平均 RT 从 600 ms 飙到 3 s,线程池打满后拒绝请求,TPS 直接掉到 120,客服群里骂声一片。
线下复盘发现三大硬伤:
- 同步阻塞:意图识别、知识库查询、答案渲染全部挤在一个 Tomcat 线程里,任何一次下游超时都会拖垮整个调用链。
- 扩容困难:CPU 利用率不到 30% 就触发 Full GC,横向加节点必须整包发布,一次上线 15 分钟,黄花菜都凉了。
- 数据热点:对话状态全放在 JVM 本地 Map,多节点之间状态不同步,用户刷新页面就“失忆”。
一句话——“想提速,却连轮子都拆不下来”。
架构设计:把“大块头”拆成“小分队”
1. 单体 vs 微服务:一张表看清优劣
| 维度 | 单体 | 微服务 |
|---|---|---|
| 发布粒度 | 整包 | 单模块 |
| 扩容效率 | 3-5 min | 30 s |
| 故障半径 | 全站 | 单服务 |
| 开发并行度 | 低 | 高(按域划分) |
| 运维复杂度 | 低 | 高(需治理) |
结论:为了“快”,宁愿多踩运维坑。
2. 模块拆分策略
按“无状态”“可缓存”“易爆发”三原则,切成 5 个微服务:
- chat-gateway:统一入口,只做鉴权、限流、路由。
- dialog-manager:对话状态机,全程操作 Redis,无 DB。
- nlp-service:意图识别、实体抽取,GPU 节点独立扩容。
- kb-service:知识图谱查询,带多级缓存。
- reply-render:答案模板渲染,返回多渠道格式。
3. 异步通信方案
- 链路:网关收到消息 → Kafka 异步发送“对话事件” → 下游按需消费。
- Topic 设计:
chat.in:客户端原始消息,partition key=userId,保证顺序。chat.out:服务端回复,广播模式,0 延迟推送。
- 消息体(JSON)核心字段:
{ "msgId": "uuid", "userId": "12345", "timestamp": 168889999, "payload": {}, "retry": 0 }核心实现:Spring Cloud 落地细节
1. 注册发现
Eureka 集群三节点,客户端配置:
eureka: client: register-with-eureka: true fetch-registry: true service-url: defaultZone: http://eureka1:8761/eureka,http://eureka2:8762/eureka instance: prefer-ip-address: true lease-renewal-interval-in-seconds: 5 # 快速感知上下线2. 对话上下文保持(Redis + 分布式锁)
@Component public class DialogContextRepo { @Resource private StringRedisTemplate redis; private static final String KEY_PREFIX = "dialog:"; private static final Duration TTL = Duration.ofMinutes(30); /** * 保存上下文,带分布式锁防止并发写丢 */ public void save(String userId, DialogContext ctx) { String key = KEY_PREFIX + userId; String lockKey = key + ":lock"; try { Boolean ok = redis.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(2)); if (Boolean.TRUE.equals(ok)) { redis.opsForValue().set(key, JSON.toJSONString(ctx), TTL); } } finally { redis.delete(lockKey); } } public DialogContext find(String userId) { String json = redis.opsForValue().get(KEY_PREFIX + userId); return json == null ? null : JSON.parseObject(json, DialogContext.class); } }3. 负载均衡 & 熔断
OpenFeign + Sentinel 配置示例:
@FeignClient(name = "nlp-service", fallback = NlpFallback.class) public interface NlpClient { @PostMapping("/nlp/intent") IntentDTO parse(@RequestBody UserQuery query); } @Component public class NlpFallback implements NlpClient { @Override public IntentDTO parse(UserQuery query) { // 降级返回兜底意图 return new IntentDTO("default", 0.0); } }application.yml 里把 RT 大于 500 ms 的 QPS 降到 50%,防止雪崩。
性能优化:压测数据不会撒谎
1. JMeter 压测对比
| 指标 | 单体 | 微服务 | 提升倍数 |
|---|---|---|---|
| 平均 RT | 2200 ms | 320 ms | 6.8× |
| 95% RT | 3500 ms | 550 ms | 6.3× |
| 峰值 QPS | 800 | 2600 | 3.2× |
| CPU 利用率 | 30% | 75% | 2.5× |
2. 冷启动优化
NLP 模型第一次加载要 8 s,高峰期扩容即“冷炸弹”。
解决思路——预热线程池:
@Component public class WarmUpRunner implements CommandLineRunner { @Resource private NlpClient nlpClient; @Override public void run(String... args) { // 提前加载模型,异步执行 ExecutorService pool = Executors.newSingleThreadExecutor(); pool.submit(() -> nlpClient.parse(new UserQuery("你好"))); pool.shutdown(); } }上线后新节点 20 s 即可接入流量。
避坑指南:上线前必读
1. 分布式会话一致性
- 方案:Redis + 消息补偿。
- 场景:用户刷新页面落到不同网关,需保证状态不丢。
- 实现:dialog-manager 每次变更上下文后,向
dialog.snapshottopic 发事件,其他节点消费并更新本地缓存,最终一致性即可。
2. 消息幂等性
- 策略:msgId 唯一键 + Redis set 去重。
- 代码片段:
if (Boolean.TRUE.equals(redis.opsForSet().add("processed_msg", msgId))) { // 首次处理 doBusiness(payload); }3. 知识库缓存失效
- 缓存双key:KB 数据版本号 + 内容缓存。
- 更新流程:
- 运营后台点击“发布” → 写入 MySQL 并递增 version。
- kb-service 监听版本变更 → 刷新 Redis 中的 version key。
- 查询时先拿 version,再取具体缓存,避免大面积同时失效。
写在最后
把客服系统从“一锅粥”拆成“小炒肉”后,高峰 QPS 翻了三倍,发布从 15 分钟缩到 30 秒,运维同学终于能在双十一安心喝茶。
但新问题也随之而来:用户可能在 App、小程序、网页三端来回跳转,如何保证对话不中断、状态不重复?
如何设计跨渠道的客服会话同步机制?欢迎一起头脑风暴。