客服系统从零到稳定运行:消息链路、会话状态与运维实战
做客服系统之前我以为最难的是把消息从 A 传到 B真正做完了才发现连接只是开始。在线聊天、工单流转、知识库、数据报表、多渠道接入再到后面高峰期不丢消息、不串会话、不卡顿每一个环节都是坑。这篇文章不聊 PPT 上的架构图就聊一个客服系统从零到稳定运行到底经历了什么踩过哪些坑又是怎么一步步填平的。1. 客服系统到底解决什么问题很多人一提客服系统就想到聊天窗口其实聊天只是最表层的功能。客服系统的本质是解决“用户有问题找得到人说得清事跟得住结果”这一整条链路。这和即时通讯软件有本质区别微信聊天可以说完就结束客服系统必须做到每一次对话都有记录、有归属、有状态、有闭环。我最早接到这个需求时业务方给的需求只有三句话网页上能弹窗聊天客服能收到消息聊天记录要能查。听起来很简单但实际梳理下来完整的需求域至少包含四个部分访客侧网页/H5/App 内唤起对话窗口支持文字、图片、链接消息能识别当前浏览页面能查询历史会话记录。坐席侧工作台接收会话、主动发起会话、转接会话支持快捷回复、内部备注、会话标签、结束会话。管理侧坐席账号权限、会话分配策略、满意度评价、数据统计报表、敏感词监控。底层支撑消息可靠性投递、多媒体文件存储、会话状态机、消息已读未读、离线消息补推、全链路日志追踪。真正动手前我们做了一个关键决定放弃采购第三方 SaaS 客服系统选择自研。原因不复杂业务方有大量定制需求——工单要和内部 OA 系统打通会话数据要做实时大屏展示敏感词过滤规则需要按业务线独立配置这些在通用产品里很难实现或者接入成本极高。如果你们业务同样有强定制需求自研是值得的如果只是要一个标准在线客服老老实实买 SaaS 更快更省钱不要为了技术情怀重复造轮子。2. 从零搭建一套能扛住真流量的架构是怎么选型的架构选型没有绝对的对错只有适合不适合。我们的核心诉求是团队规模小初期只有 3 个后端、业务增长速度不确定、但对消息实时性和数据可靠性要求很高。基于这些约束技术栈选型和理由如下模块选型核心理由后端语言Go并发模型天然适合长连接场景部署简单单一二进制文件团队学习成本可控实时通信WebSocket 自研网关浏览器原生支持无需额外引入 SDK网关独立部署方便水平扩展业务数据库MySQL 8.0团队最熟悉事务能力强存储会话、工单、坐席等结构化数据高速缓存Redis 6.x在线状态、未读计数、排队队列、分布式锁、热数据缓存消息队列RocketMQ支持事务消息和消息回溯保障消息不丢、可重试业务量上来后优势明显全文检索引擎Elasticsearch聊天记录检索、组合条件筛选时间/坐席/关键词文件存储云 OSS图片、文件等非结构化数据自带 CDN 加速省去自建存储的运维成本这里单独说一下为什么不用 MQTT 或者自研 TCP 长连接。MQTT 在物联网场景非常优秀但浏览器原生支持很差需要额外引入 MQTT over WebSocket 的协议转换层增加复杂度。自研 TCP 协议则要面对粘包拆包、心跳保活、协议版本兼容一堆问题对一个小团队来说性价比太低。WebSocket 网关层独立部署是目前网页即时通信最稳健、最省力的路线。数据库设计上有一个非常务实的建议业务初期不要把消息表设计得过于“范式化”。早期我们的消息表同时存在 MySQL 和 Elasticsearch 里MySQL 负责事务性写入保证消息入库不丢Elasticsearch 负责检索保证查询快。消息表本身只存最核心的字段CREATE TABLE message ( id bigint(20) NOT NULL AUTO_INCREMENT, session_id varchar(64) NOT NULL COMMENT 会话ID, msg_id varchar(64) NOT NULL COMMENT 全局消息ID用于幂等, from_type tinyint(4) NOT NULL COMMENT 发送方类型1访客 2坐席 3系统, from_id varchar(64) NOT NULL COMMENT 发送方ID, content_type tinyint(4) NOT NULL COMMENT 内容类型1文本 2图片 3文件 4系统提示, content text NOT NULL COMMENT 消息内容, created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_msg_id (msg_id), KEY idx_session_id (session_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这个msg_id的唯一键它是整个消息不重不漏的基石。从设计第一天就把幂等性考虑进去后续排查重复消息时你会回来感谢这个决定。session_id加created_at的联合索引则保证了按会话拉取历史消息的查询效率。3. 消息链路在线、离线、推送失败一条都不能丢客服系统的命根子是消息链路。用户发一条消息经过网关、业务后端、消息队列、状态存储再到坐席端收到并已读任意一环出问题轻则消息延迟重则消息丢失。我们把整个链路拆成三块来设计。3.1 在线消息WebSocket 网关的会话管理网关层是坐席和访客建立实时连接的入口。每个连接建立后网关向 Redis 写入在线标记key: user:online:{userId} value: { gateway: gw-01, connId: conn-xxx, lastHeartbeat: 1699000000 } ttl: 90秒每次心跳刷新这里有一个很容易踩的坑不要在网关进程内存里维护连接状态。早期我们图方便把连接对象直接放在进程内存的 map 里结果网关一重启所有在线连接全部掉线而且多网关部署时无法互相感知。Redis 统一管理在线状态后即使某个网关挂了其他网关也能根据 key 快速感知并把消息转发到同一用户的其他存活连接上。心跳机制也值得多说一句。WebSocket 的 ping/pong 必须做且超时不能太长。我们线上配置是30 秒发一次 ping90 秒内未收到 pong 判定连接假死主动断开。最初把超时时间设成 5 分钟结果大量“僵尸连接”占用着网关文件描述符导致新建连接频繁报错。3.2 离线消息断线不丢消息的兜底策略在线聊天最大的意外就是网络抖动。消息链路必须保证访客点发送的那一刻消息就进入了“可靠性通道”不依赖 WebSocket 连接是否在线。我们的方案是发送即持久化。访客点击发送后消息先落库status发送中然后投递到消息队列由消费端做实时派发。坐席端在线走 WebSocket 秒达坐席端不在线消息就在队列里等待等坐席重新连上后网关从数据库拉取最近 7 天的离线消息推给他。考虑到极端情况——消息落库失败怎么办客户端会基于msg_id做重试同一个msg_id在后端做唯一键冲突捕获保证不会插入两条相同消息。实测中这个方案在弱网环境下表现稳定Android 端断网重连后消息补偿率达到 100%。3.3 消息推送失败轮询兜底与降级预案WebSocket 推送失败不可避免。网络闪断、服务重启、浏览器后台休眠都可能导致消息推不到客户端。针对不同层级我们设计了三级兜底即时重推WebSocket 推送失败后不放入死信队列而是延时 3 秒、10 秒、30 秒各重推一次。离线补偿重推三次仍然失败消息入库并标记待推送用户下次连接后自动拉取。HTTP 轮询降级如果监测到某个网关节点异常导致大量 WebSocket 推送失败自动开启该用户级别的 HTTP 短轮询降级每 10 秒拉取一次未读消息保证会话可用。三级兜底跑下来我们线上消息到达率指访客发消息到坐席端收到通知稳定在 99.97% 以上。关键经验是消息链路不要把“高可用”押注在单一通道上通道越单一故障影响面越大。4. 状态流转为什么会话会“串线”又如何规避会话是客服系统的核心实体。访客点开聊天气泡系统创建一个会话访客关掉页面再进来系统找到未闭合的会话继续聊坐席关闭会话状态变为已结束。整个过程听起来清晰但在高并发下容易出幺蛾子——最经典的就是“串线”访客 A 的消息发到了访客 B 的会话里。串线的根因是会话查找逻辑没有做到维度统一。早期我们把“当前会话”存在 Redis 里key 用 visitorIdvalue 是 sessionId。问题出在有多个业务线接入时visitorId 并不是全局唯一的——访客在 A 业务线是 visitor_1001在 B 业务线可能也是 visitor_1001一旦 Redis key 设计没带业务线维度必然串号。修正后的会话定位策略会话 Key 统一为session:{appId}:{visitorId}:{entranceId}其中 entranceId 区分访客是从哪个页面/渠道进来的。每个会话有唯一的 sessionId全局递增不允许复用。访客每次打开对话框先根据上述组合 key 查 Redis 中的活跃会话查不到再查数据库最近的未闭合会话再查不到才新建。坐席端强制要求可见会话必须绑定坐席工号避免多个坐席同时操作同一会话互相覆盖。会话状态总共定义了 5 种等待分配、进行中、已转接、已结束、超时关闭。整个状态机的流转规则我们在代码里用状态枚举做了强约束非法流转直接抛异常。有人认为这太啰嗦但正是这个“死板”的设计让后期做数据统计变得极其轻松——每个时间点的会话状态都清晰可追溯。会话状态流转的“为什么”也很简单客服这个场景里坐席和访客的每一次交互都是有时效要求的没有状态机你根本说不清一个会话到底处理到哪一步了数据分析、绩效考核、业务优化全都无从下手。5. 分配与排队避免“忙的忙死闲的闲死”坐席分配是客服系统业务逻辑里最容易“被看轻”的模块。很多初版系统直接用轮询或者随机分配上线后才发现问题有人摸鱼有人忙到爆炸还有一个更隐蔽的问题——有些访客的问题比较复杂分配给新手坐席后长时间处理不了用户体验极差。我们最终实现了可配置的分配策略引擎核心规则按以下优先级执行指定坐席访客在会话中指定了某个坐席比如之前服务过他的优先分配给他。技能组匹配根据访客当前浏览的页面/咨询的商品类目路由到对应技能组售前、售后、技术。组内负载均衡技能组内按在线状态、当前会话数、平均响应时长综合打分得分最高的坐席获得新会话。排队兜底没有可用坐席时访客进入排队队列定期检查是否有坐席释放。分配策略引擎的负载评分公式简化版score w1 * (1 / 当前活跃会话数) w2 * (1 / 近30分钟平均响应时长) w3 * 在线时长系数三个权重可配置业务方可以根据大促和日常的不同节奏调整。实测效果非常明显排队率从初版随机分配的 32% 降到了 9%最长等待时长缩短了 45%。不要把一个随机函数当成分配策略那是给技术挖坑、给业务埋雷。这里还有一个细节排队超时处理。访客排队超过 2 分钟系统自动推送一条消息“当前咨询人数较多您可以留言我们会尽快回复。”同时生成一条留言工单流入工单池。这样即使坐席全忙也不会把访客晾在那里干等——这个细节对满意度影响很大。6. 知识库和机器人看似简单实际最难的是“冷启动”智能客服机器人是现在客服系统的标配。这个模块也是老板问得最多的“为什么别人家机器人能自动回答 80% 的问题我们家的连‘你好’都接不住”答案只有一个知识库冷启动。机器人不是生下来就会回答问题它需要大量的高质量问答对喂出来的。我们在项目初期犯过的错就是把开源问答模型直接部署上去期望它能“智能”地回答业务问题结果每个回答都在一本正经地胡说八道。正确的冷启动路径是人工整理历史会话把过去半年客服聊天记录导出来找出高频问题 TOP 100整理成标准问答对问题、答案、关联问题。相似问题扩展每个标准问题扩展 5-10 个相似问法提升召回率。置信度兜底匹配置信度低于 60% 时不回复答案转人工高于 85% 直接回复中间区间展示候选答案让用户确认。连续多轮对话基于上下文槽位理解实现简单的多轮比如先问“哪个产品”再问“什么问题”。冷启动阶段有一个关键指标——机器人解决率 机器人独立解决的会话数 / 总会话数。我们花了 3 个月从冷启动前的 8% 提升到 41%这个过程中要持续做“未解决问题分析”每周挑出机器人答错的典型案例优化进知识库。没有这个运营闭环买再强的模型也白搭。重点提示不要一上来就追求“智能”。先把高频词命中做到位再用 NLU 模型补充长尾语义。很多客服机器人项目失败都是栽在“期望值过高、爬坡期过早放弃”上。7. 工单系统客服解决不了的问题如何优雅地流转不是所有问题都能在即时聊天中解决。用户要退款、要投诉、要技术排查这些都需要跨部门协作。工单系统就是客服和内部流程的“翻译官”。工单模块最初我们认为简单极了——无非就是建一张表、几个状态。实际上线后才发现工单的关键不在状态字段而在流转规则和闭环时效。设计工单系统时最核心的问题工单是从会话一键生成的必须保留完整会话上下文引用sessionId否则承接人看不到聊天记录还得去问访客一遍“您刚才说什么问题来着”体验会很差。工单必须有明确的优先级和 SLA服务等级协议时限。我们定义 P0 紧急如资金安全问题30 分钟内首次响应P1 高功能故障2 小时内首次响应P2 普通咨询建议24 小时内处理。工单必须支持自定义流转节点。不同业务线的工单流程不一样退款工单要经过“客服-财务-主管”技术支持工单要经过“一线-二线-研发”。用配置化的流程引擎实现不要让开发写死在代码里。工单流转最怕的是“无人认领”。我们做了一个脏数据巡检任务每隔 10 分钟扫一遍超过 SLA 时限且未分配的工单自动给对应业务线负责人发企微告警。上线这个巡检后工单超时率下降了 70% 以上——很多流程问题不是人不负责是缺少主动暴露问题的机制。8. 数据与监控支撑稳定运行的最后一块拼图客服系统稳定运行到一定阶段比拼的就不再是功能多寡而是谁能更快发现问题、定位问题、解决问题。我们的监控体系分成三层第一层基础设施监控。CPU、内存、磁盘、网络、MySQL 慢查询、Redis 命中率、Elasticsearch 集群健康状态。这一层用 Prometheus Grafana 就能覆盖重点是要设置合理的告警阈值别一有风吹草动就拼命报警报警疲劳的后果比不报警更严重。第二层业务指标监控。这是客服系统最独特的监控维度。核心指标消息发送成功率、消息平均延迟p95会话分配平均时长、访客排队等待时长坐席平均响应时长、会话解决率、满意度评分机器人转人工率、机器人解决率这些指标直接反映系统健康度和用户体验。我们每天早会第一件事就是看前一天的“业务核心指标看板”任何数值明显波动都能快速反推到具体功能或运营动作。第三层链路追踪。一次消息从访客发出到坐席收到中间经历了网关、业务后端、消息队列、状态存储任何一个环节慢都会影响体验。我们接入 SkyWalking 做全链路 Trace每个环节打上 span。有一次线上反馈“消息偶尔延迟 3-5 秒”就是通过链路追踪发现是某个网关节点 GC 频繁导致消息处理线程阻塞定位后调整了 JVM 参数就解决了。没有链路追踪这种问题排查起来基本靠瞎猜。9. 踩过的坑三个最典型的故障复盘说几个我们真实踩过、也真实影响过线上体验的故障。每个坑背后都对应一个可以复用的经验希望读者不用再走一遍。9.1 全量推送事故一次误操作所有用户收到同一条消息早期网关层有个“给所有用户推送公告”的管理接口没有做环境隔离和二次确认。一次发布时同事在测试环境执行了脚本但连接的却是生产环境的 Redis结果给全量在线用户推送了一条测试消息“hello world”。事后复盘我们上了三道措施管理接口必须走独立的运维平台且默认带--envprod参数才允许执行。全量推送类操作必须二次确认输入操作人工号 随机动态验证码。Redis 的 key 统一加环境前缀prod:user:online/dev:user:online杜绝跨环境访问。这条经验值得所有团队记住你的系统默认是不可信的想怎么坑你它就会怎么坑你。9.2 慢查询拖垮数据库某次大促后的第二天客服系统整体变卡消息发送偶尔显示“发送失败”。排查后发现问题出在一个统计报表接口运营想看“昨天各技能组的会话量”这个 SQL 直接对message表做了全表扫描而且这个接口被运营同事写成了每 5 分钟自动刷新等于持续对数据库发起重型查询。后来我们做了三件事首先把这个统计查询迁移到 Elasticsearch 上的预聚合结果报表接口只读聚合结果不再碰原始消息表其次给所有报表类接口加上了 Redis 缓存按分钟级别缓存数据最后给数据库加了一条规范任何针对大表的查询上线前必须经过 DBA 审核并强制使用 EXPLAIN 分析执行计划。慢查询不是不能出现但不能让它悄悄地出现、长期地出现。9.3 Redis 内存暴涨导致连接溢出有一段时间 Redis 的内存持续增长直到崩溃。排查发现某个“会话未读数”的 key 被设计成了unread:{sessionId}:{userId}每个用户每个会话都存一个 Hash而且从未设置过期时间。当活跃会话量大时未读数 key 数量爆炸式增长。修正方式是把未读数改为内存计数 定期持久化。Redis 只保存短期7 天的未读数据超过 7 天的会话未读统一归并到一个“历史未读”字段查询时动态计算。改完后 Redis 内存下降 60%并且未读数的准确性没有受损。Redis 不是数据库不要把需要长期保存的数据全堆在里面。10. 结语稳定运行的本质是“持续折腾”从最初三天两头出问题到现在连续几个月保持高可用稳定运行客服系统经历了非常大的变化。回看整个过程所谓的“稳定运行”绝不是选一个好的技术框架就能自动获得的而是靠持续的监控、复盘、优化一点点磨出来的。总结几条最核心的经验供正在做或准备做客服系统的朋友参考消息可靠性是命根子从第一天就设计幂等和重试千万别等到出事了再补。会话状态机务必用强约束的方式落地状态不乱数据统计才有意义。智能客服不是“部署一个模型”就完事知识库冷启动是个运营活持续投喂才有产出。监控体系的分层设计和告警阈值比功能开发更值得花时间。每次线上故障都是给系统打补丁、给团队补流程的机会不要白白浪费。如果让我再重做一遍这个系统我会在一开始就把全链路追踪和业务指标监控做进去而不是等出了问题再补。这部分投入是所有系统建设中最划算的一笔投资。