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 篇。

posted @ 2026-10-25 21:16  cliffdev  阅读(5)  评论(0)    收藏  举报