客服系统踩坑实录:从租户隔离到分布式治理的十个教训
客服系统上线前那一夜我们盯着压测报表QPS刚到500WebSocket网关的内存就开始往上跳。这个SaaS客服系统从第一行代码到服务上百家租户几乎把工程与架构的坑都踩了一遍。今天不晒架构图不吹技术选型只把这10个坑按真实经过写出来——每个坑包含当时基于什么理由做的决策、在什么规模下炸的、排查链路是什么、最后怎么收敛的以及最重要的“如果重来一次会怎么做”。这篇文章适合正在做SaaS产品、客服系统或者负责中后台平台建设的团队也适合身边带着初级开发、需要自己做技术决策的朋友。1. 立项时的三个乐观假设后来全部变成了事故伏笔1.1 这个系统当初的定位与团队形态我们做的是一个面向中小电商和本地生活商家的多渠道客服系统把网站、小程序、公众号的咨询消息统一收口到工作台客服可以在一个界面里处理会话、建工单、用机器人做简单应答。最初团队只有4个人2个后端、1个前端、1个产品兼测试。没有专职DBA没有SRE这意味着技术决策基本都是“当天讨论当天拍板”没有任何冗余机制兜底。产品第一版上线前销售已经签了几家种子客户时间压力很大。当时所有人的心态是先把功能跑通把客户服务好架构问题等规模大了再重构。这个心态本身没什么错但问题在于我们在三个关键决策上做了“过度乐观”的选择而且没有为它们留退路。这三个决策分别是租户隔离选了独立库方案、系统提前拆成了微服务、会话状态直接放在本地内存。当时看起来都合理但后续几乎每个都成了事故的直接原因。这篇文最想说的不是“你们不能这么做”而是每个决策背后都要有一句“如果这个假设错了我们怎么退回来”。1.2 三个乐观假设的具体内容假设一租户规模和消息量都差不多隔离方案按“最安全”的来。销售对客户承诺数据独立存储我们就给每个租户开独立数据库认为这样最安全、最能拿单。假设二微服务是先进架构按业务名词先拆开以后扩展不用重构。我们把工单、会话、消息、通知、统计、机器人都拆成了独立服务相信这样“团队并行效率高”。假设三聊天会话的生命周期很短几分钟就结束所以会话状态放内存就行。我们忽略了一件事客服的工作台不是几分钟而是整天挂在上面会话状态里除了聊天消息还有正在编辑的工单、当前访客信息、客服的忙碌标记。这三个假设有一个共同点它们都把“初期简单或初期安全”当成了“长期正确”。后面十年的踩坑史本质上是为这三个假设还债。1.3 为什么把10个坑分成三大类讲10个坑表面上是技术事故但复盘后发现高度聚类架构选型期的决策问题、数据层的执行问题、线上治理期的机制缺失。这三类问题发生的时间不同、前置条件不同解法也完全不同。选型期的坑靠“引入评估机制”解决数据层的坑靠“梳理真实访问路径”解决治理期的坑靠“建设面向失败的机制”解决。下面按这个顺序逐个展开每个坑尽量保留当时的排查过程而不是只给结论。2. 架构选型期的四个深坑从租户隔离到消息通道2.1 坑1租户隔离方案的一刀切让运维先崩了第一版方案是每个租户一个独立数据库。当时觉得这是最安全的SaaS隔离方案客户问起来也硬气“你们的数据库是独立的。”30个库的时候问题开始集中爆发。最痛的是变更一次字段长度调整要写脚本在每个库上执行一遍有5个库执行失败而且没人发现。结果部分租户上传的图片URL被截断用户端图片刷不出来客服后台却毫无感知直到客户打电话才定位到是DDL脚本漏执行。备份恢复也变成灾难全量备份的时间窗口越来越长压到业务高峰期导致整个实例的IO飙升。后来我们做了一个很痛苦但有必要的调整绝大多数租户迁到共享库加tenant_id路由模式只有少数数据敏感的大型租户保留独立库。共享库统一在数据访问层自动注入租户条件从框架层面杜绝掉“开发忘了写tenant_id”这类低级错误。提示SaaS租户隔离本质是成本和安全的权衡不要一刀切。小租户走共享库大客户走独立库中间层用schema隔离按真实需求和付费等级决定。如果重来一次我们会一开始就按“默认共享库、独立库为例外”来设计并且把租户条件注入做成框架级能力而不是靠每个开发自觉。2.2 坑2微服务拆分过早核心链路被拆成一地鸡毛用户量还没过5000我们就拆了7个服务认证、会话、工单、消息、机器人、统计、通知。领域名词拆得漂亮但团队只有4个人一条完整的客服流程要跨三四个服务调用联调成本直接翻倍。印象最深的一次事故用户提交工单后创建工单、派单给客服、给客服发通知是三个服务协作完成的。通知服务超时重试后工单状态显示已分配但通知发了两遍其中一个客服点进来发现工单已经被另外一个人接走了。表面是消息重复根子在于服务拆分后没有统一的交易边界补偿逻辑又是临时凑的根本经不起异常场景考验。复盘时大家承认按“领域名词”拆服务是最容易踩的陷阱正确的拆分维度应该是“变更频率与团队职责边界”。高频变更、团队独立维护的模块才值得拆出去核心业务链路里的强关联逻辑应该先放在一起等边界真正清晰了再拆。我们最终把7个服务合并成3个可独立部署的单元网关接入层、核心业务层会话、工单、消息在一个服务内、周边支撑层通知、统计走异步化。这个调整之后日常迭代再也没出现过跨服务联调事故。提示微服务不是银弹尤其是4个人团队。服务化之后事务一致性、分布式追踪、契约管理全是隐形成本。宁可先做模块化也不要过早物理拆分。2.3 坑3会话状态全放本地内存一次发布全体掉线客服工作台的核心是WebSocket长连接早期我们把会话上下文放在网关实例的ConcurrentHashMap里包括当前客服是谁、正在跟哪个访客聊天、正在编辑哪个工单。认为聊天消息很短内存里的状态不需要持久化。第一次滚动发布直接教做人重启第一个网关实例时所有挂在它上面的客服全部掉线重新连接后会话上下文已经没了访客那边看到“客服已离开”。更尴尬的是滚动发布并没有“滚动”的效果——因为会话状态不是分布式的每个实例重启都意味着部分用户强制掉线。修复方案是把会话元数据抽到Redis本地缓存只保存热会话的引用同时维护一个简单的版本号。客服重连时先读Redis重建上下文整个过程几百毫秒用户感知基本为零。这里有一个经验任何放在进程内存里的状态都要回答一个问题——“这个进程重启之后状态去哪了”如果答案是需要用户重来一遍那这里迟早出事故。另外Redis自身也需要高可用否则缓存节点挂了等于把掉线事故换成另一种形式重演。2.4 坑4Redis Pub/Sub做实时推送消息在扩容中无声消失实时消息推送第一版用Redis Pub/Sub网关服务订阅频道收到消息就推给对应的长连接。单实例跑着没啥问题一旦网关扩到3个实例问题就出现了——消费者的离线消息会直接丢失因为Pub/Sub本身就是即发即弃的模型不落盘、不确认。有一次正好赶上发布窗口客服和客户的对话中间丢了一轮。客户说“你们客服是不是中途睡觉去了”其实消息在Pub/Sub里丢了没人知道。排查链路的结论很清晰推送类系统先回答“消费者宕机期间消息怎么补”再考虑实时性。最终我们把消息通道换成了Kafka按会话ID做分区键保证同一个会话的消息有序网关从分区拉取消息再推给长连接连接断开期间继续消费重连后回放最近若干条消息。提示如果你是架构选型阶段第一版就要区分“在线消息实时推送”和“离线消息补发”两件事。实时推送可以用轻量的通道但补发机制必须在第一天就存在否则消息必丢。3. 数据层的三个隐蔽陷阱分表键、缓存串租户与归档表3.1 坑5分表键选了customer_id热点租户拖垮全链路消息表分表时我们选了customer_id做分表键理由是“按客户维度查消息最多”。听起来合理实际上完全错了。客服系统的真实访问模式是按“会话”拉取消息流而不是按“客户”聚合所有消息。一个大租户的客户量大会话量自然大一年消息量占全系统35%。按customer_id哈希分表后这个大租户的所有分片都落在同一批物理表里热点分片的磁盘IO被打满消息列表查询从几十毫秒飙到几秒整个客服工作台像卡住了一样。排查时开了慢SQL日志发现卡顿的查询全都指向那几个热点分片。数据分布完全失衡加索引、调参数都没用只能改分片策略。修复后的策略分表键改成hash(tenant_id conversation_id)以会话为最小路由单元。代码里长这样def get_shard(tenant_id: int, conversation_id: int) - int: key f{tenant_id}:{conversation_id} return hash(key) % SHARD_COUNT这样同一个会话内的消息必然落在同一分片不同会话均匀打散大租户的会话也能散到全部分片上。迁移过程比较痛苦靠双写加校验脚本跑了整整一个周末。但改完之后消息链路再没出现过热点问题。教训很直接分表键选的从来不是“字段”而是“业务访问的最小原子单位”。电商订单表用订单号分片因为访问单位是订单客服消息表用会话ID分片因为访问单位是会话。事前画出所有核心查询的WHERE条件再决定分片键能少走很多弯路。3.2 坑6缓存Key少了租户维度A租户看到了B租户的数据这是共享库模式下最典型的串数据事故。某天A租户的客服改了一个工单备注没过多久B租户的客服打开同一个ID的工单看到了A租户那边的备注当场截图投诉。排查过程很快缓存Key是ticket:{id}而两个租户的工单ID都从1开始自增完美冲突。一个租户改数据时先写缓存另一个租户读数据时直接命中错误缓存。修复分了三层。第一层缓存Key全面加租户前缀规范形如key ftenant:{tenant_id}:ticket:{ticket_id}第二层工单ID、会话ID这类核心ID全部改成全局唯一ID生成雪花算法从源头消除跨租户ID冲突的可能。第三层做防御性校验从缓存反序列化出来的对象必须带租户字段与当前租户不匹配时直接把缓存删掉再查库。提示共享库 自增ID是万恶之源。就算只做了第一层修复也要问一句“万一有人忘了加租户前缀呢”。防御性校验的价值就在这里——它不是修复bug而是避免同类bug长期潜伏。3.3 坑7按天分区的归档表一次回溯查询扫了近两百个分区消息数据超过180天会移到归档表归档表按天分区。平时业务查询只查近期数据分区表跑得飞快所以没人觉得有问题直到一次合规审计要求拉取某租户两年内的全部聊天记录。我们以为就是个简单查询结果SQL直接扫了近200个分区跑了40多分钟。EXPLAIN一看分区剪枝完全失效——查询条件里对分区字段做了函数运算优化器没法定位到具体分区只能全表扫。修复做了两个方向。一是把归档数据的检索能力独立出来热数据留在业务库历史数据同步到Elasticsearch按租户、时间范围、关键字做索引归档库只承担合规备份和批量导出。二是规范分区查询禁止在等值条件里对分区字段做函数运算这类代码Review直接拦截。这个坑的通用教训是归档不只是“把老数据挪个地方”还要设计“这些数据以后会被怎么查询和导出”。没想清楚检索手段就归档等于把数据扔进一个无法访问的黑屋。4. 线上治理期踩得最痛的三次状态机、幂等与监控盲区4.1 坑8工单状态流转靠if-else状态机一夜之间失控工单的流转逻辑从第一天起就是if-else状态少的时候很清楚新建→处理中→已解决→待评价。后来产品加了重新开启、转派、挂起判断分支越来越多代码里到处是“如果当前状态是xx并且操作是yy”。事故发生在一次产品迭代后客服把已解决的工单重新开启系统先走了“重新开启”分支随后一个定时任务扫描到老工单处于异常状态又给它自动关闭关闭动作又触发了“已解决通知”客服看一眼发现工单又没了再点开又自动关……客户看到的体验就是工单在“已解决”和“处理中”之间反复横跳。排查时翻了两小时的日志才确认是两个分支在互相触发。这已经不是bug修复能解决的问题而是状态流转逻辑根本没有唯一性约束。重构方案是引入显式状态机状态、事件、动作、守卫条件全部配置化。核心结构类似current_state: RESOLVED event: REOPEN guard: can_reopen(resolver, reopen_reason) next_state: PROCESSING action: assign_kafka_event(send_notification)这样任何一次状态流转都有明确的触发条件、迁移路径和动作列表不存在“看不见的分支”。状态迁移配置还做了合法性校验新增迁移如果与已有守卫冲突直接拒绝上线。提示凡是有“流转”含义的业务不管现在分支多不多第一天就上显式状态机。初期是比if-else多写一点代码但后面每次产品提新需求时你会感谢当初的自己。4.2 坑9异步重试没有幂等同一工单被派给了两个客服通知服务消费MQ时有一次网络抖动导致消费超时消息被重新投递。消息体里只有工单号没有幂等键消费逻辑也没做去重。结果“工单已分配”事件被消费两次两个客服同时收到派单提醒其中一个点进去发现工单已经在自己手里另一个已经接单了两个客服在IM里尴尬地确认“这单是谁的”。排查时定位到消息队列的at-least-once投递语义网络抖动、消费者重启、消费超时都会触发重投这是MQ的常态不是异常。我们缺的不是“防止重试”而是“让重试变得无害”的幂等机制。修复方案是给每条业务事件加一个业务幂等键格式是business_id event_type event_version。消费前先检查幂等记录表存在就直接跳过。实际操作可以用Redis SETNX或数据库唯一索引我们用了Redis SETNX加DB记录双保险。这里有一个顺序上的细节也值得说我们后来统一了“先写业务库再发消息”的流程配合本地消息表做最终一致避免“先发消息、业务还没落库”时消费端查到旧状态的问题。教训一句话只要代码里出现“重试”两个字就必须同时出现“幂等”设计。这两个词是双胞胎缺一个线上必出重复数据事故。4.3 坑10监控只盯着CPU和内存核心链路SLO完全裸奔最丢人的一次事故不是服务挂了而是服务没挂、用户感知全崩了。某天大促场景下排队会话超过6000客户发消息30秒内没有任何回复运维面板上CPU只有40%内存也有余量所有“基础设施指标”都正常。最后是一个大客户老板直接给CEO打电话投诉我们才知道系统已经处于半瘫痪状态。排查后发现问题不在资源而在业务链路消息队列消费积压严重消费者在等外部接口响应会话进入队列后没人处理。系统资源正常业务已经不可用——这就是典型的监控盲区。修复方向是重建监控指标体系。以用户体验链路为单位梳理核心指标而不是只看服务器指标。我们最终沉淀了一套SLO清单长期盯着这几项指标含义告警阈值消息端到端延迟P95访客发出消息到客服看到消息的耗时P95 2s持续5分钟告警会话创建成功率新会话建立的成功比例 99.9%持续10分钟告警派单成功率工单自动分派成功的比例 99.5%持续10分钟告警机器人转人工率机器人无法回答转人工的比例超过基线30%关注队列积压量等待处理的消息数量超过阈值告警告警也做了分级避免“狼来了”式疲劳轰炸。P0级直接短信加电话P1级只推送IM群每周做一次告警复盘确认每条告警是否真的需要人工介入。提示监控体系的第一问题是“指标代表谁的真实体验”。网上很多监控方案教你盯吞吐、盯TPS但只有从用户能感知的链路往下拆才能避免“机器正常但业务全崩”的尴尬。5. 复盘这10个坑背后其实是三个共性根因5.1 根因一把“初期简单”当成了“长期正确”独立库租户隔离、微服务拆分、会话状态放内存、Redis Pub/Sub做推送、工单用if-else流转这五个坑在犯错的当下都显得“最简单”或“最合理”。但架构设计要回答的不只是“现在怎么跑得通”而是“当某个假设不成立时系统怎么反应”。单实例状态、即发即弃消息、无状态约束的流转逻辑都是把“初期规模下的正常路径”当成了“永久正确路径”。规模上来之后它们一个接一个崩掉。5.2 根因二把“本地单体思维”带进了分布式系统缓存Key不带租户前缀、消息重复消费、归档查询扫全分区、消息通道不补发这些问题本质都是数据分散了、链路变长了、重试变多了但我们还用单体时代的思维去设计。单体系统里状态在内存里重试很少查询天然走本机分布式系统里数据在多个节点消息会重复查询可能跨分区这些不是异常而是默认前提。面向分布式设计的核心操作只有三个状态外置、消息幂等、查询路由。所有因为“没想到会重试”“没想到会跨节点”“没想到会串租户”而出现的问题都能归到这三点。5.3 根因三缺少“面向失败的机制”状态机、幂等、业务SLO监控这三个东西都是在事故之后才补上的。它们的共同特点是——在正常流程里“看起来没用”只有失败时才有价值。工程团队的通病是只验证快乐路径而生产环境的残酷之处在于失败一定会来只是时间问题。不要等到出了事故才觉得“原来重试会重复消费”“原来状态流转会有死循环”。这些都不是小概率事件而是确定性会发生的事情只是你在快乐路径上测试时遇不到而已。5.4 可以直接抄走的检查清单后面我们做任何新功能、新服务设计阶段都过一遍这张表能拦住90%的同类型坑检查项说明这个服务的状态存哪进程重启后状态还在吗状态外置到Redis或DB本地缓存可重建消息队列投递失败或重复投递消费端幂等吗必须有业务幂等键存储层去重核心查询的过滤条件能定位到具体分片/分区吗查不到就必然全表扫缓存Key包含租户维度吗核心ID全局唯一吗共享模型下这两个是红线这个业务有状态流转吗状态迁移有唯一路径吗有流转就上显式状态机用户喊卡的时候我们的监控能立刻定位吗必须有链路级SLO不能只盯CPU内存归档数据以后会被怎么查检索方案提前设计了吗归档前先想出口再想入口异步重试出现时业务数据会重复写入吗有重试就必须有幂等或唯一约束5.5 最后一条经验把踩坑写进机制10个坑踩完之后我们最大的改变不是换了某个具体技术方案而是把“踩坑复盘”变成了机制。每个线上事故都要在一个共享文档里更新“踩坑档案”内容包括根因、触发条件、恢复过程、检查清单对应表。新人入职的第一周不是看架构文档而是看这份档案知道每个坑长什么样、为什么会在这种系统里出现。如果这10次踩坑能压缩成一句话我会说所有架构决策都要先想失败场景下的行为而不是成功路径上的优雅。客服系统这种直接面向终端用户产品体验的系统更是如此——用户不会因为你架构优雅而原谅一次丢消息。