07-坐席为什么不来电:一次租户上下文丢失的完整排查
坐席为什么不来电:一次租户上下文丢失的完整排查
2026 年 10 月 17 日,租户反馈:外呼任务显示"进行中",VOS 平台(运营商侧)能看到呼叫请求、客户也在振铃接通,但坐席软电话毫无动静——没有 INVITE、没有弹屏。任务在跑,电话在打,钱在花,就是没有人接。
这是这个项目至今最有教学价值的一次排查,根因藏得很深,而且一眼看上去跟"多租户"三个字毫无关系。
现场还原
任务外呼的链路是:调度器从名单取号 → FS originate 呼客户 → 客户接通 → 把坐席腿桥接进来 → 双方通话。坐席不来电,说明桥接这一步没发生。
后端日志里翻到一条不起眼的记录:
[onAnswer] 接通落座异常
就一行。没有堆栈、没有上下文、没有租户信息。异常处理代码把整场事故压缩成了一句叹气。这是这次排查的第一个教训:吞异常的时候连自己一起骗了——写这行 catch 的人以为这个分支"理论上不会走到",真走到时它什么都没告诉我们。
把堆栈挖出来(本地复现加日志)后,真身是:
TenantContextHolder 不存在租户编号
根因:桥接线程不知道"现在是谁的租户"
这里需要交代一下背景。芋道基座的多租户隔离靠 ThreadLocal:请求进来时拦截器把 tenant_id 塞进 TenantContextHolder,后续所有 SQL 被拦截器自动拼上租户条件。这套机制对 HTTP 请求很完美——但你自己的线程池里没有这个 ThreadLocal。
出问题的代码在 OutboundBridgeListener:客户腿的 CHANNEL_ANSWER 事件到达后,它把"桥接坐席"这个动作交给一个普通线程池异步执行。异步线程里要查坐席、查技能组——SQL 一执行,拦截器找 ThreadLocal 要租户,空手而归,抛异常。异常被 catch 住打了那一行日志,然后呢?没有然后了。客户腿就停在 park 状态干等着,坐席永远等不来 INVITE。
讽刺的是,这个缺陷从上线第一天就存在,之前的坐席来电是另一条链路(呼入桥接)在工作。直到任务外呼的量起来,它才显形。
一个根因背后站着一排问题
把主根因修掉(异步执行整体包进 TenantUtils.execute(ctx.tenantId(), ...),显式携带租户上下文)之后,我们把这整条链路重新过了一遍,又揪出六个问题。挑重要的说:
ESL 读线程被同步回执卡住。 坐席应答的回写逻辑在 ESL 事件读线程里同步执行三条 uuid_setvar,每条最多等 10 秒回执。任何一条卡住,整个 FS 事件分发停摆——后面所有通话的事件都堆积。这类"在 IO 线程里做慢活"是通信网关类代码的经典反模式,改成独立 answerExecutor(2 线程)处理。
振铃未接把坐席打回"签出"。 坐席振铃但没接,状态机把坐席从 2(振铃)迁回 0(签出)。这意味着坐席漏接一通电话就被移出 ACD 候选池,之后再来电永远轮不到他。正确语义是 2→1(回空闲)。一个状态码的差别,用户体验上是"漏接一通就下线"。
变量注入位置错误。 cc_leg=agent(标记这是坐席腿)被写在客户腿上,客户腿 create 出来的 B 腿会继承这个变量——结果监听器把客户腿误判成坐席腿。修法是桥接时用 bridge:{cc_leg=agent}<target> 语法只注入 B 腿。SIP/channel 变量的继承语义,踩之前真的不好意识不到。
挂断方判定靠猜。 顺带发现话单的 hangup_by 字段恒为 0——代码从未消费过 FreeSWITCH 的 sip_hangup_disposition。这次补了 HangupPartyResolver:recv_bye 判客户侧挂、已接通状态下 send_bye 判坐席侧挂;未接通的场景只把明确被叫拒接的归被叫,"无应答、系统弃呼"这类留空,不臆测。宁可字段空着,也不编一个看似完整的值。
修复随 v202610171806/1930 两个版本上线,任务外呼的坐席来电链路从此稳定。
三处高发地:上下文到底在哪丢的
这次事故之后,我们把"租户上下文丢失"列为专项,盘出了三个高发场景,全部加固:
场景一:自建线程池。 ESL 事件回调、MQ 消费者之外的任何异步逻辑,ThreadLocal 天然不存在。规矩:异步入口必须 TenantUtils.execute(tenantId, () -> ...) 显式包裹,事件里带的 cc_tenant_id 就是权威来源。
场景二:定时任务。 基座的租户上下文靠切面注入,而 @Scheduled 方法上切面不生效——任务要么静默跳过,要么查错租户。全部 Job 统一走 Quartz JobHandler 加自定义 @TenantJob 注解,注解里显式声明租户来源,不做隐式魔法。
场景三:超管切租户的操作。 运营人员用超管账号切换到某租户视角操作时,如果代码直接从会话取租户,取到的是超管自己的租户 1。坐席监控那块就栽过:跨租户查看时监控数据全 0,因为 Redis 键按真实租户分段,代码拿错了段。规矩是跨租户读一律 TenantContextHolder 显式指定目标租户。
复盘沉淀下来的东西
这次之后做了几件超越"修 bug"的事:
一是日志规范:catch 块里不允许只打一行消息,必须带异常对象和关键上下文(至少 tenantId、callId);关键链路的事件日志保证可以从一条通话串起完整时间线。
二是租户上下文自查清单进了 code review 模板:新增异步逻辑、新增 Job、新增跨租户操作,三个问题挨个问一遍。
三是体会到多租户系统一个本质的脆弱性:隔离机制越是"自动",越容易在机制覆盖不到的角落静默失效。自动拼条件让人忘了它的存在,而它一旦缺席,没有报错、没有拦截,只有数据查不到和一段轻描淡写的日志。对付它没有银弹,只能靠把每一处"线程边界"都显式化——让代码自己声明"我知道我现在是哪个租户"。
本文是「云呼叫中心技术内幕」系列第 7 篇。

浙公网安备 33010602011771号