资讯动态

SaaS客服系统架构避坑指南:实时通信与事件驱动的10个工程实践

发布时间:2026/10/3 14:26:57 来源:尧图企业网站定制
做SaaS客服系统这行当很多人一开始都觉得“不就是做个聊天框加工单吗”真正上手才知道客服系统是“实时通信 业务流转 数据统计”三座大山同时压在肩上。我们从立项到第一版上线用了七个月之后整整一年都在填坑有工程管理的坑也有架构设计的坑每一个都真实到想删库跑路。这篇文章就把我们踩过的10个深坑完整拆开给准备自建SaaS客服平台、或者正在做IM类工作台的朋友当个参考。不管你当前是单体架构还是微服务这些坑大概率都会换着姿势出现越早看到越值。1. 先想清楚客服系统到底复杂在哪1.1 我们做的不是“聊天工具”是三段式缝合怪市面上成熟的客服SaaS产品一定包含这几块多渠道接入网页、微信、App等、实时会话、工单流转、知识库、坐席工作台、数据报表、多租户权限管理。拆开看每一块似乎都能做但组合起来就是另外一回事儿。我们一开始想得太天真以为先做IM再加工单再套一层统计三个月能交差。实际上会话模块要求低延迟、强顺序工单模块要求状态机稳定、流程可追溯报表模块要求数据准确、聚合快速。这三个模块的架构诉求是完全矛盾的硬塞进一个工程里不改到吐血才怪。拿“坐席工作台”举例坐席界面上一边要实时接收客户消息一边要记录工单备注一边还得查看历史记录和客户资料。客户发一条消息后端要同时做消息存储、未读计数更新、会话状态变更、可能触发机器人自动回复还要推送事件到前端。如果一开始不把“会话事件流”和“工单业务流”分开建模后续每加一个功能都会影响另外两条链路。所以我后来经常提醒团队客服系统本质上是个事件驱动系统而不是传统的增删改查后台。1.2 第一版的技术选型埋下了后面一半的坑我们第一版选了 Spring Cloud Redis MySQL RabbitMQ前端用 Vue长连接用 WebSocket。听起来是行业标配但实际上有两个很致命的选择问题。第一个问题是微服务拆得太早。项目刚启动时团队只有四个人我们却按“用户服务、会话服务、工单服务、消息服务、网关”拆了五个工程。本来就是新项目领域模型还没跑清楚大家每天都在讨论接口字段和仓库之间怎么部署代码没写几行。第二个问题是过度依赖中间件只要涉及异步一律丢 MQ结果消费者经常重复消费或者乱序处理只要涉及状态一律放 Redis结果缓存和数据库到处不一致。加上客服系统的消息场景是强顺序、高写入、低延迟这个技术组合如果没有严格约束线上很容易出问题。如果让我重来第一版我一定会做成一个“模块化单体”也就是单仓库多模块内部用事件解耦数据库只分库不分表先把核心业务链路跑通。等到某个模块的并发压力真正达到需要独立扩展再拆成微服务那时候边界认定的依据就是监控数据而不是拍脑袋。2. 工程化层面最容易翻车的三个位置2.1 微服务拆太早架构评审会变成边界吵架会这是我们要说的第一个深坑也是最坑的一个。我们四个人拆了六个服务的直接后果是每改一个工单状态可能要联动修改用户服务、会话服务、工单服务三个代码仓库上线时要同时发布三个服务任何一个小接口不兼容整个流程就断了。架构评审会基本都在吵两个问题这个功能到底应该放在哪个服务两个服务之间该用Feign还是MQ为什么会这样因为微服务划分的核心依据是领域边界而我们的领域模型压根没建模。客服系统的核心聚合根是“会话”会话下面挂着消息、参与者、标签、流转状态它不是一个单纯的业务表而是一个有生命周期的事件流。我们当时按“用户、会话、工单”这样水平切分结果工单要获取会话信息时只能远程调用一个查询链路变得又长又慢。后来我们花了两周时间重构成模块化单体先把六个服务合并回一个Spring Boot工程但保留模块边界模块之间只通过应用事件通信。这样改完最大的好处是重构和联调成本直线下降部署也变成单个进程排查问题只需看一个日志文件。等我们把域名、租户、坐席这些概念彻底收敛之后才敢再谈哪些模块值得拆成独立服务。我现在的建议很简单对于二十人以内、业务尚未稳定的SaaS团队微服务是奢侈品不是必需品。2.2 WebSocket推送做成“全员广播”在线坐席一多就崩溃客服系统最核心的体验就是实时性客户发消息坐席窗口必须在1秒内弹出。我们第一版用WebSocket做了全局连接管理每个服务节点各自维护自己的连接池客户端连接上来随便路由到一个节点。业务刚开始时确实没问题几十个在线坐席消息量不大广播也就广播了顶多多推送几个无关的账号。等到客户数量上来在线坐席超过200人之后问题就炸了一条客户消息进来消息服务会广播给所有连接着的坐席客户端每个前端除了自己的会话还会收到几百个其他会话的消息前端要拼命过滤服务端连接推送重复网络带宽被无意义占用。我们当时一查监控推送服务CPU跑满用户感知就是“消息半天才弹出来”。根因就是没有建立“连接与会话房间”的映射关系。客服系统的推送应该按会话维度定向推送每个会话只有一个客户和几个坐席参与者消息进来时只推给这个会话的在线成员而不是广播给全网。我们最终引入了一个独立的推送服务在Redis里维护连接ID、用户ID和会话ID的关系通过订阅Redis频道来做定向推送。同时给连接加了心跳检测和自动重连配合前端消息去重之后实时性才算稳住。一句话心得WebSocket不是建好长连接就完事了真正的难点在“连接路由”和“定向推送”。任何客服类产品的推送都必须围绕会话ID做消息路由而不是围绕连接数做群发。2.3 在线状态用定时轮询数据库没扛过第一轮压测和实时推送配套的一个需求是“在线状态展示”坐席列表上要显示哪些坐席在线、忙碌还是离线。最朴素的做法是写一个定时任务每5秒扫描一次坐席的在线状态表然后把结果返回给前端。我们还真就这么干了结果接口响应还算正常但数据库行锁竞争和写入压力把主库CPU直接打到了90%以上。这里要理解一个事实在线状态是“实时计算数据”不是“持久化业务数据”。真正的数据源是WebSocket连接的握手和断开事件坐席登录了连接建立下线了连接断开。与其定时去扫表不如在推送网关里维护一个内存状态机连接建立或断开时上报事件到Redis状态查询直接读Redis周期性地把稳定状态同步到数据库用于离线统计。后来我们是这样做的每个推送网关节点维护本节点的连接状态定时把状态同步到Redis的Hash结构查询接口只读Redis坐席状态变化时通过事件通知其他服务避免任何对在线表的轮询。改造之后数据库负载降了90%都不止。这也给我提了个醒做客服系统凡是跟实时性相关的数据都要优先考虑内存计算数据库只做最终一致性存储。3. 中间四个深坑数据、缓存与业务边界3.1 消息顺序与幂等重试、重复、乱序三兄弟客服消息最怕什么最怕客户看到自己发的消息顺序是反的也怕坐席明明只发了一次消息客户却收到两遍。我们在第一版就同时踩了重复和乱序两个问题。重复的原因有两个一是MQ消费者在处理消息失败后自动重试重试时消息被重复消费二是前端在连接断开后重新拉取离线消息拉取逻辑没做幂等同一批消息被重复插入列表。乱序的原因是我们在消费时开了多线程并行处理同一个会话的多条消息线程调度导致后到的消息先入库。解决思路是一层一层加约束。消息入队时给每条消息分配一个全局单调递增的消息序号同一个会话的所有消息必须发到同一个分区保证同一分区内部有序消费者端对同一个会话ID做串行处理不能起多线程去并发消费在数据库层加一张消息幂等表用消息ID做唯一键重复消费直接跳过前端展示时再按消息ID做一次去重并缓存本地消息序号用于增量拉取。这样改完几乎再没出现过重复或乱序。这里想强调一个通用经验在IM和客服场景里“最多一次”或“至少一次”都不够必须做到“恰好一次”的语义需要生产者有序、消费者串行、存储层幂等三层配合缺一层都会出问题。3.2 分表按租户ID分一个大租户把单表拖死SaaS系统逃不掉多租户。我们上线后不久签了一家几千坐席的大客户消息量是普通客户的几百倍。当时我们把消息表按租户ID做了水平分表想着租户之间互不影响结果恰恰是大租户把自己的那张表打到了瓶颈。因为同一租户的会话都落在同一个分片单表的数据量和写入并发都严重超载慢查询拖垮整个数据库连接池其他小租户也跟着遭殃。聊天消息这种数据和传统交易数据不一样它的特征是增量极大、几乎不更新、按时间维度强聚合。按租户分表并不是完全错误但粒度太粗。正确做法要分两层会话表按会话ID哈希分片保证同一个会话的所有消息落到同一分片消息表按时间分区例如按月建分区查询历史记录时自动裁剪无用分区。对于大租户可以在哈希分片的基础上再做二次分片例如按会话ID再加一个分片键维度。做完调整后热点问题基本解决。现在如果让我做一次分表设计我会先问自己三个问题这张表是读多还是写多数据是以时间维度增长还是以实体维度增长单条数据需不需要跨分片事务想清楚了再动分片键不然等于给自己挖坑。3.3 缓存与数据库的一致性不是所有数据都适合加缓存做客服系统之后我发现很多同事对缓存有一种迷之执念什么数据都想放Redis。结果就是会话列表经常显示旧备注坐席明明改了客户标签客户下一次咨询进来显示的还是老标签。这类问题的根因是缓存更新策略没设计好。我们最开始用“先更新数据库再删除缓存”的经典策略但并发场景下A请求更新数据库B请求读旧值回填缓存导致缓存永远是旧值再加上Redis删除失败缓存里就可能长期存在脏数据。后来我们改成“更新数据库后延迟双删”也就是更新后先删一次缓存等几百毫秒再删一次缓存尽量清掉并发期间回填的旧值。同时给所有缓存设置绝对过期时间兜底防止脏数据长期存在。更务实的做法是想清楚哪些数据必须强一致哪些可以接受最终一致。像会话头像、坐席昵称这类低频变更的数据根本不需要实时同步缓存五分钟过期都没问题。但客户消息、会话状态这种核心数据就不应该走缓存直接读写数据库分区表靠索引和分库分表来保证性能。如果业务真的需要缓存复杂聚合结果可以采用订阅数据库Binlog的方式异步刷新缓存而不是在业务代码里手工同步。3.4 工单和IM耦合太紧一个状态流转炸掉整个会话我们在做第一版时工单表和会话表是直接互相引用的工单里有会话ID会话里又关联当前工单IDJPA实体甚至做了双向关联。刚开始用着爽因为取数据特别方便一个对象能查到所有信息。等到工单流转复杂起来比如超时未响应自动升级、客服转派、客户回复后自动关闭工单这些状态变更连带着要更新会话的多个字段两条业务线纠缠在一起谁都不敢乱动。这里最本质的问题是领域边界没划清楚。会话和工单是两套生命周期完全不同的模型会话是一个短暂交互过程的体现客户来了就建聊完就可能归档工单则是跨会话的持久服务承诺可能持续几天经过受理、处理、升级、关闭等多个状态。如果把它们揉进同一个聚合根就会造成“改工单必须锁会话”的荒唐现象。我们的做法是把它们拆成两个独立领域它们之间只通过事件通信。工单创建时只保存会话的只读快照比如会话ID和最后一条消息摘要后续会话事件通过MQ通知工单模块工单模块根据自身状态机决定是否更新字段。这样做的结果是两个模块各自维护自己的数据互不依赖即便工单服务宕机会话模块仍然可以正常收发消息。这也是客服系统架构里非常关键的一条经验识别业务生命周期不要让生命周期不同的模型强行共享一张表或一个对象。4. 最后三个坑稳定性、可观测性与团队习惯4.1 没有全链路追踪线上问题全靠“三分钟抓包”做客服系统最怕线上告警说“某某客户的消息发不出去”。因为一条消息从客户端到服务端要过网关、消息服务、会话服务、推送服务、数据库、Redis再推回坐席客户端链路特别长。我们早期没有做任何全链路追踪只知道报错的会话ID然后人肉去翻日志一台机器一台机器地grep运气好几分钟运气不好一小时。后来我们统一在入口处生成一个TraceId通过拦截器把它注入到所有日志和缓存操作里在MQ消息体里也带上这个TraceId这样一条消息完整路径上的所有日志都能被串联起来。我们还把会话ID作为业务维度的日志字段所有打印日志必须带上sessionId。这样线上再出问题直接查ES日志按sessionId过滤很快就能定位到到底是网关超时、消息服务消费失败还是推送服务断连。不要小看这个工程动作它比任何复杂架构都更能救命。很多团队一开始觉得这种小事情后面再补结果到了出问题时才发现连日志都没法搜只能靠猜。全链路追踪不是微服务的专利哪怕是单体应用也值得在最开始就把日志规范定下来。4.2 多租户隔离不彻底一个租户的慢查询拖垮所有人SaaS客服系统天然是多租户共享资源我们在第一版并没有做严格的资源隔离数据库连接池是所有租户共享的MQ消费者也是共享的。本以为加了租户ID字段就算隔离了没想到一个大客户上传了几万条工单附件触发了大量慢查询占满了连接池结果所有租户的坐席都登录不上工单列表全部超时。这件事教育了我多租户隔离分两层一是数据权限隔离二是资源故障隔离。数据权限隔离要求每一个SQL查询都必须带租户ID条件防止数据越权。资源故障隔离则需要做到关键路径上给不同租户设置不同的连接池上限或者至少做慢查询拦截和熔断消息队列消费时可以设置租户级别限流。对于真正的大客户可以支持独立数据库实例或独立服务集群宁可部署成本高一点也不能让一个租户的不稳定影响全局。客服系统还有一个特殊风险部分租户可能有大量坐席同时高并发上线如果上线逻辑全走同一个数据库连接池容易把连接池打满。我们后来把登录和上线流程的数据库连接池单独拆了一组避免高峰期登录风暴影响到消息读写的主流程。这种细节很容易被忽略但恰恰决定了SaaS产品的稳定性口碑。4.3 架构图永远落后于代码新同事入职全靠考古最后一个坑算是工程管理层面的我印象很深。我们的Confluence里放着一套三个月前的架构图看起来是简洁的单体结构但代码已经演进成了多模块加独立推送服务。新同事入职看文档以为服务就两三个真上手后发现代码仓库有七八个部署脚本也完全对不上每个人都在“考古”。这类问题的本质是架构知识没有和代码演进同步缺少一个轻量级的治理机制。我们后来定了一条规矩任何涉及模块划分、数据库拆分、新增中间件的改动必须同步提交一份架构决策记录哪怕只有几行字也要写清楚“为什么这么改、影响范围是什么、老的方案为什么废弃”。同时在每次代码评审的时候如果改动涉及模块依赖关系必须顺手更新依赖图文档。另外一个实用的办法是用工具生成架构图而不靠手工画。比如通过解析Spring Bean的依赖关系、或扫描REST接口的调用关系自动生成模块依赖图定期更新到文档中心。这样即使人为更新不及时至少工具能兜底。后来我们这套机制坚持了半年新同事上手时间从两周缩短到三天效果非常明显。这一系列坑踩下来我最深的体感是客服系统的架构设计不是追求花哨的分布式技术而是要把实时链路、数据一致性、租户隔离这三件事做到极致。很多看起来高级的方案在没有足够业务体量和团队规模时都是负担。如果重新来一遍我会先把模块化单体和实时推送做好再根据监控数据去拆分独立的服务而不是一上来就铺一个大摊子。最后再分享一个小技巧每次线上出故障不要只修问题本身还要追问一下“是哪个设计决策导致问题难排查”。如果你发现线上定位问题要靠人肉查日志那说明可观测性还有欠账如果你发现改一个需求要连带改五个服务那说明领域边界大概率拆错了。把这些追问当成例行复盘的一部分SaaS客服系统才能真正从0到1稳定地跑下去。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑