智能客服工作流如何精准识别消息发送者:从会话上下文到用户ID映射的实战解析
关键词:智能客服、WebSocket、JWT、Redis、用户身份识别、会话上下文
一、背景痛点:为什么“知道谁发的消息”这么难?
在智能客服系统里,“消息是谁发的”听起来像一句废话,却是整条工作流的起点。一旦识别错,后续的路由、策略、甚至计费都会翻车。真实场景里,我们踩过这些坑:
WebSocket 连接复用导致会话混淆
浏览器标签页共享同一个 TCP 连接,服务端如果只靠socket.id做映射,A 用户刷新后复用旧连接,B 用户的消息就可能被当成 A 的。多端登录身份冲突
手机端和 PC 端同时在线,两条 WebSocket 连到不同网关实例,若只把 UID 放到 URL 参数里,网关重启后就会“张冠李戴”。高并发下数据库查询打爆
每条消息都SELECT uid FROM session WHERE sid=?会让 QPS 直接掉底,高峰期一次促销就能让 DBA 报警。Token 被重放
握手手里不带过期时间的“永久票”,被中间人截获后,任意会话都能伪装成该用户。
一句话:“身份”必须跟着“连接”走,且不能给后端带来额外负担。
二、三种技术方案对比
| 维度 | 方案1:会话ID+DB | 方案2:JWT+无状态 | 方案3:Redis 缓存上下文 |
|---|---|---|---|
| 实现成本 | 低,直接 SQL | 中,需签发/验签 | 中,需缓存层 |
| 性能 | 差,RT ≈ 5~15 ms | 优,纯内存计算 | 优,RT ≈ 1 ms |
| 水平扩展 | 差,DB 成瓶颈 | 优,自带伸缩 | 优,Redis 集群 |
| 断线重连 | 需重新查库 | 带 Token 即可 | 复用缓存 |
| 安全 | 依赖会话库安全 | 自带签名 | 依赖 Redis ACL |
结论:
- 低频内测 → 方案1 最快落地
- 对外正式版 → 方案2+3 混合,JWT 解决“是谁”,Redis 解决“在哪”
三、核心实现:从握手到 UID 的一整条链路
下面以Node.js(ws 库)为主示例,辅以 Python 片段,展示“握手阶段即拿到 UID”的关键代码。所有示例可直接粘进项目跑验证。
3.1 WebSocket 握手阶段提取 JWT(Node.js)
// server.js import WebSocket from 'ws'; import jwt from 'jsonwebtoken'; const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', (ws, req) => { // 1. 从协议升级头里拿 Token const token = req.headers['sec-websocket-protocol']?.split(',').shift(); if (!token) { ws.close(1008, 'MissingToken'); return; } let payload; try { // 2. 验签 + 过期检查 payload = jwt.verify(token.trim(), process.env.JWT_SECRET, { issuer: 'cs-center' }); } catch (e) { ws.close(1008, 'InvalidToken'); return; } // 3. 关键:把 UID 钉到 socket 对象 ws.uid = payload.uid; ws.tenant = payload.tenant_id; // 4. 注册到 Redis 房间,方便后续集群广播 redis.sAdd(`conn:${payload.tenant_id}:${payload.uid}`, ws.id); ws.on('message', raw => { // 5. 后续任何消息都能直接拿到 ws.uid handleMessage(ws, raw); }); ws.on('close', () => { redis.sRem(`conn:${ws.tenant}:${ws.uid}`, ws.id); }); });要点注释
- 用
sec-websocket-protocol带 Token 可绕过浏览器无法自定义握手头的问题 - 验签失败立即断链,避免半开放连接占 FD
ws.uid的写入时机越早越好,后续中间件直接读取,无需二次解析
3.2 JWT 解码示例(Python)
import jwt, time from jwt.exceptions import InvalidTokenError def verify_token(token: str) -> dict: try: claims = jwt.decode( token, key=get_secret(), # 从 KMS 拉取 algorithms=["HS256"], options={"require": ["exp", "uid", "tenant_id"]} ) if claims["exp"] < int(time.time()): raise InvalidTokenError("Token expired") return {"uid": claims["uid"], "tenant": claims["tenant_id"]} except InvalidTokenError: return None3.3 Redis 缓存会话上下文(Node)
import的油包省略 async function handleMessage(ws, raw) { const msg = JSON.parse(raw); // 6. 根据 UID 拉取上下文,减少 DB 回源 const ctx = await redis.hGetAll(`ctx:${ws.tenant}:${ws.uid}`); if (!ctx) { // 缓存穿透,回源 MySQL 并回填 Redis const rows = await mysql.query( 'SELECT * WHERE uid=? AND tenant_id=?', [ws.uid, ws.tenant] ); if (rows.length) { await redis.hmset(`ctx:${ws.tenant}:${ws.uid}`, rows[0]); ctx = rows[0]; } } // 7. 把上下文塞进工作流参数,后续策略节点直接取用 msg._ctx = ctx; workflow.push(msg); }四、生产环境考量
会话超时与续期
- JWT 使用滑动过期(sliding expiration),每次消息带新 Token,旧 Token 5 min 宽限期
- Redis 侧给
ctx:*设置24h TTL,用户活跃自动续期,避免“今天问一半,明天被踢”
防中间人
- 握手阶段强制wss + TLS1.3,关闭 ws 裸协议
- Token 内存放jti(JWT ID),Redis 侧维护jti 黑名单,支持运营秒级踢人
分布式缓存一致性
- 采用Redis Cluster + Redlock保证并发更新时“谁最后写谁获胜”
- 对同一 UID 的并发写,使用Lua 脚本把“读-改-写”做成原子事务,避免竞态丢字段
五、避坑指南
永远不要相信前端给的 uid 参数
浏览器地址栏?uid=123可被用户改成任意值,一定以握手阶段解析出的 JWT 为准。长连接场景下的内存泄漏
把ws对象放进 Map 后,切记在close事件里delete,否则随着用户重连,老对象越积越多,一次大促就能把 4G 堆打满。多语言时区
客服排班系统按“用户本地时区”计算 SLA,缓存里务必存IANA 时区名(如Asia/Shanghai),不要只存偏移量,避免夏令时跳变。
六、思考题
当用户同时发起语音和文字咨询时,两条通道分别走WebRTC 数据通道与WebSocket,如何保证后端识别到的是同一个身份?
欢迎在评论区留下你的设计,或吐槽你曾踩过的“双通道身份漂移”坑。
七、小结
身份识别是智能客服的“地基”,方案选对能省下半台数据库。
把JWT 当门票、Redis 当内存、WebSocket 当管道,三线合一后,后续任何路由、策略、计费都能心安理得地相信“ws.uid”这个字段。
代码已经跑在线上,日活 80W 连接,峰值 35W QPS,目前 CPU 还稳在 30% 以下——祝你也能一次上线,永不掉坑。