上个周五晚上,我被一条线上报警拉回了书房。某接入局向的呼叫在 Kamailio 路由里出现“第一通正常、紧接着第二通偶发超时”的怪现象,线上日志翻了几轮都没找到规律。我一边抓包一边在聊天窗口里把 Kamailio 路由脚本的片段发给 Gemini,从那之后,我们断断续续聊到深夜,从 failure_route 的执行时机聊到 dispatcher 的哈希策略,最后顺手把困扰我一周的路由逻辑整个重写了一遍。这篇东西不是官方文档的搬运,是我和 AI 一起把 Kamailio 路由这条路捋明白的全过程,适合正在折腾 Kamailio 路由脚本的运维、开发,以及想借助 AI 做通信协议排障的同行。
1. 一场故障把我推进了与 Gemini 的长谈
1.1 故障现场:路由脚本“看着没毛病”,线上就是不听话
先说故障本身。拓扑不复杂:Kamailio 作为核心路由节点,一侧接业务平台,另一侧接运营商中继网关。业务平台把呼叫以 INVITE 形式交给 Kamailio,Kamailio 做完认证和号码规整后,按照主备两个局向把呼叫送出去。正常情况下,主局向失败会触发 failure_route,自动换到备局向。
那天的现象很刁钻:从业务平台发起的呼叫,如果是第一次呼叫某个号码,一切正常;但只要紧接着再呼同一个号码,大概率 180 Ringing 能回来,后面的 200 OK 和 ACK 就有一方丢了,呼叫卡在“已接通但没人说话”的状态。更让我懵的是,单独用 SIPp 打这种呼叫,压力一低又完全复现不出来。我对着路由脚本看了半天,觉得无非就是t_relay()加failure_route的经典写法,怎么也想不通哪里会和“第二次呼叫”扯上关系。
我最初甚至怀疑是某个内存表过期策略的问题,直到我决定不靠直觉,先把 Kamailio 处理这次呼叫的完整路径画出来,再把问题原封不动丢给 Gemini。
1.2 我抛给 Gemini 的第一个核心问题
我给 Gemini 的第一段描述是这样的:某 INVITE 进入 request_route,先执行认证和号码前缀规整,然后用t_relay()转到主用中继;主用中继在 4 秒内没有响应,进入 failure_route,脚本里判断t_check_status("408|500")后把$du改成备用中继地址,再次t_relay();现在的问题是,重试后呼叫虽然通了,但同一通呼叫的后续消息处理出现异常。
Gemini 没有直接给我改配置,而是问了一个我漏掉的关键点:第一次t_relay()之后,你的脚本有没有显式exit?这句话像一根针扎在穴位上。我回去翻脚本,果然,在route(OUTBOUND)子路由里,t_relay()后面没有跟exit,又往下执行了一段更新计费的逻辑。Kamailio 的脚本执行流和普通编程语言不一样,t_relay()并不是“在这里把呼叫交出去然后函数就结束”,它只是把当前请求作为事务发起转发,脚本的指令流还会继续往下走。这个多出来的“继续往下走”,可能触发第二次目的地计算,甚至在特定场景下改变了请求的状态,导致会话的后续方向出现偏差。
1.3 这次事故最后怎么落地的
讨论那晚我没有直接改线上,而是先把抓包和路由脚本的 xlog 打点对齐,确认了问题就出在“事务已启动、脚本仍在执行”这个语义上。修复动作很朴素:在t_relay()成功后明确exit,把计费更新放到专门的reply_route里处理。上线后观察了两天,那一整类“第二次呼叫异常”彻底消失。
但这场对话的价值远不止修一个 bug。它逼着我重新梳理了 Kamailio 路由脚本的执行模型,也让我发现我和 Gemini 的讨论方式存在很大的优化空间。后面几章就是我从“被 AI 指出问题”到“学会让 AI 帮我验证问题”的全过程,很多结论我后来在测试环境里逐一复现过。
2. 从讨论中拆出的路由机制,比官方文档更懂现场
2.1 路由块的调用关系:return、exit、drop 的语义边界
Kamailio 的request_route是整个 SIP 请求处理的入口,route[XXX]则是可复用的子路由块。很多人一开始会把route(XXX)当成函数调用,认为进去之后执行完自然就返回主流程。这个理解在大多数场景下没错,但有两个容易忽略的边界。
第一个边界是:route(XXX)返回之后,主流程会从下一条语句继续执行,而不是“万事大吉”。如果你在子路由里已经做了t_relay(),却没有用exit、return或drop明确终止,那么主流程后面的指令依然会跑。我带 Gemini 复盘时,它给了一个很形象的类比:流水线上,你把工件交给机械臂后,传送带并不会因为“交接完成”就停下来,后面该来的工序还会来,除非你按下急停。exit就是那个急停按钮。
第二个边界是return和exit的作用范围。return只结束当前路由块的执行,回到调用点;exit则结束整个请求处理流程。而在failure_route、onreply_route这些独立路由块里,exit的含义又会带上具体事件上下文的限制。我和 Gemini 花了很长时间讨论“在子路由里 relay 后到底该用哪个”,最后得出的实践标准是:如果确定呼叫已经从当前节点转出,且不需要再做其他处理,用exit最干脆,绝不把后续逻辑的机会留给意外。
写一个小例子说明这个坑:
request_route { if (is_method("INVITE")) { route(INBOUND_CHECK); } # 注意:如果 INBOUND_CHECK 里已经 t_relay 但没有 exit, # 这里依然会被执行到 route(GW_DISPATCH); } route[INBOUND_CHECK] { # 各种校验... t_relay(); # 如果在这里没有 exit,执行流会回到 request_route 继续跑 GW_DISPATCH }这就是我故障的真正根源之一。在路由脚本里,“relay 之后继续往下走”不等于“多发一条消息”,而是可能把同一个事务又拖进另一个目的地计算逻辑,造成后续消息的路径错乱。
2.2 分支转发的粒度与目标选址:我们反复对比过的三套方案
围绕着“如何把呼叫送往正确目的地”这个问题,我和 Gemini 把 Kamailio 常见的三种转发思路放在同一张表里反复比较,这也是我觉得整场讨论含金量最高的部分。
| 转发方式 | 核心动作 | 适合场景 | 容易踩的坑 |
|---|---|---|---|
| 直接改目的地再 t_relay | 修改$du或$ru后t_relay() | 目的地单一、逻辑简单的固定转发 | 忘记考虑失败后的重试逻辑 |
| append_branch 多分支 | 构造多个分支,Kamailio 按序尝试 | 同一次呼叫需要按顺序试多个号码 | 分支过多会导致总超时时间不可控 |
| dispatcher 模块 | ds_select_dst/ds_next_dst | 多网关负载均衡、故障转移 | 哈希算法选错导致会话亲和性失效 |
第一套方案最简单,适合“号码规整后就是一个固定中继”的场景,比如企业总机把所有呼叫交给同一个语音网关。但它的短板也明显:一旦这个网关上没有可用线路,呼叫直接就 503 了,后面的容错全要靠 failure_route 再补。第二套方案适合“一号多终端”的场景,类似被叫号码对应多个分机,Kamailio 会按照append_branch的顺序逐一尝试分支,但分支太多时,给每个分支分配的时间会被摊薄,用户体验就是“响了很久才进语音信箱”。
第三套方案是我们讨论得最细的。dispatcher 不只是一个简单的目标列表,它还能按不同算法选择目标。Gemini 提醒我注意一个我从来没认真想的点:ds_select_dst默认拿什么做哈希因子。默认情况下,它使用 Call-ID 做哈希,所以同一通呼叫的后续请求会稳定落在同一个目标上。但对那些“同一个会话里多次发起呼叫”的业务,如果希望这些呼叫分散到不同网关,就需要显式指定按 From 或按其他伪变量哈希,否则可能所有呼叫都压在同一个网关上。
这套对比给我的启发是:路由选择不是越复杂越好,而是要想清楚“谁在这条链路上决定下一跳”。是业务平台的号码前缀决定,还是终端的注册位置决定,还是网关的负载状态决定?把决策因子理清楚,再去挑模块,脚本写出来会有条理得多。
2.3 我们花时间最多的地方:failure_route 里到底能不能随意换目的地
故障那晚,我有一半时间耗在 failure_route 上。我当时对它的理解比较粗糙:主用网关不通,就在 failure_route 里换个地址再t_relay()一次。
Gemini 没有否定这个思路,但给我补了一连串追问:你换了目的地之后,旧的 Route 头信息要不要清理?新的分支算不算同一个事务的子分支,还是会变成一次全新的呼叫?如果新中继也失败了,会不会无限重试?这些问题我承认大部分当时答不上来。
后来我们在测试环境里把这些场景逐一跑了一遍,总结出的可靠做法是这样的:在 failure_route 里重新选择目的地时,先判断当前的失败状态是否值得重试。比如 486 Busy Here 这种状态,说明被叫已经被标记为忙,再换一个中继去试同一个号码,大概率还是忙,这时候直接t_reply("486")给呼叫方就够了。但如果是 408 或 503,说明是指路的中继出了问题,这时修改$du指向备用中继,再调一次t_relay()才合理。
另外,failure_route 的上下文和 request_route 不一样。它对应的是一个已经启动的事务,而不是来路不明的全新请求,所以很多只在主流程里有效的操作在这里并不适用。比如,直接改$ru再走普通转发经常达不到预期效果,因为路由处理的对象已经和原始 INVITE 不同。这个细节如果不做实验,光看文档很难体会。
3. 对话中踩掉的三颗雷:真实的路由错误复盘
3.1 雷一:t_relay 之后没有 exit,导致重复外呼
这颗雷我在第 1 章已经点过,但这里想把它单独拎出来,是因为它太典型了。Kamailio 的脚本允许你在一个请求里安排多个动作,但它不会主动替你取消后续动作。t_relay()只代表“把当前请求按当前目标地址发送”,发送之后脚本还会继续。如果后面又出现一个route(ANOTHER_GW),Kamailio 完全可能基于同一个请求的上下文再次发起转发,造成同一通呼叫被送到两个网关的“双呼”现象。
排查这种问题,最直接的手段就是在每个关键动作后加 xlog:
xlog("L_INFO", "before relay, du=$du ru=$ru\n"); t_relay(); xlog("L_INFO", "after relay, 继续往下执行\n");日志里如果出现 before 和 after 连续打出来,就说明执行流确实穿透了整个转发点。当你看到这种日志,回头检查每一个t_relay()后面的语句,基本一查一个准。
3.2 雷二:在 failure_route 里清不干净旧分支状态
有一天我和 Gemini 讨论一个更隐蔽的问题:主用中继已经发出了 INVITE,但迟迟没有响应,这时 failure_route 被触发,我接着换到备用中继,结果备用中继开始振铃之后,主用中继那边又回了一声 180。这在真实网络里不是天方夜谭,尤其是跨运营商的中继有时响应特别慢。
如果处理不好,呼叫方会收到两个方向的通知,客户端会认为会话状态已经乱掉。正确做法是:在换用备用中继之前,先t_reply("180")或按实际进度回复可靠的临时响应,同时在脚本里保证对旧分支的后续通知不再透传给呼叫方。Kamailio 的事务机制会处理大多数情况,但前提是你要理解“同一事务多分支”和“不同事务多呼叫”的差别。前者是append_branch建出来的兄弟分支,后者是在 failure_route 里另起炉灶的新转发。如果我们不做区分,代码很快就会变成一堆看似能通、实则撞车的补丁。
我现在的习惯是,在 failure_route 里只做两类判断:一是根据失败状态码决定是否再试,二是根据是否有新的可用目标决定是否继续。判断条件全部显式写好,绝不依靠脚本的“自然流”。
3.3 雷三:拿 dispatcher 随机算法做会话亲和性
dispatcher 是 Kamailio 社区里一个非常常用的路由模块,很多人用它做网关负载均衡。我和 Gemini 讨论时,一开始我想当然地认为只要用了 dispatcher,同一通呼叫就一定落在同一个网关。
但实际不是。dispatcher 的选择算法有好几种,默认情况下ds_select_dst采用的是 failover 模式,它会按顺序挑选第一个可达的目标;如果第一个目标不可达,再选下一个。而如果显式选了随机算法,不同 INVITE 可能被分配到不同网关。更关键的是,即使使用哈希算法,默认哈希键是 Call-ID,而如果你改用了 From 或 To 做哈希键,同一通呼叫里的 re-INVITE 也可能因为头域变化而被分到别的网关。
这个雷的核心不是 dispatcher 本身,而是“对算法语义的理解”。我和 Gemini 一起列了一个核对清单:网关列表是否稳定、每个网关的权重是否需要动态调整、同一个会话内的多个请求是否需要落到同一节点、是否需要按源 IP 做一致性哈希。把这些想清楚,再决定ds_select_dst的参数,才不会出现“路由偶尔飘”的诡异线上问题。
4. 把 AI 回答变成线上代码的验证闭环
4.1 带着抓包和上下文提问,而不是只发一段脚本
经过这轮讨论,我摸索出一套和 Gemini 协作审路由脚本的方法。很多人让 AI 看 Kamailio 配置,直接就把整个 cfg 文件粘进去,然后问“哪里有问题”。这种做法不是不行,但效果取决于文件的篇幅和你对上下文的交代程度。
我的做法是,先给四类上下文:一是网络拓扑,Kamailio 两边各接什么设备,有没有注册用户,有没有独立的中继网关;二是完整呼叫流,从哪个端口进入、经过哪些 route、最后往哪转发;三是已经验证过的现象,比如日志里出现的状态码、抓包里看到的路由头;四是我自己的判断和疑问,哪怕判断是错的,也要写出来,这样 AI 能针对性地纠正,而不是泛泛而谈。
举个我后来常用的提问格式:
Kamailio 核心节点负责呼叫接入和转发,上游是业务平台,下游有两个中继局向。 INVITE 进入 request_route 后,依次走 auth 和主营判断,然后 t_relay 到主用局向。 主用局向超时后进入 failure_route,按 408/503 切换到备用局向。 现在的问题是:切换成功后,呼叫可以接通,但通话中的 re-INVITE 经常到不了对端。 我怀疑是 Record-Route 没生效,也怀疑是 failure_route 里的新分支没有正确复制路由头。 请帮我分析最可能的原因,并给出在测试环境验证的方法。这样提问,AI 的答案会明显更贴合实际场景,因为它获得了足够多的约束条件,不会被你的定义域带到外太空。
4.2 从“AI 建议”到“上线前验证”的四个步骤
不管 AI 给出的建议多合理,我都不会直接上生产。我给自己定了一个四步验证闭环,这次讨论之后我更是严格执行了这个流程。
第一步,用kamailio -c做静态语法检查,确保配置能正常加载。第二步,在测试环境里准备一个能自动拨测的脚本,把 Gemini 建议中涉及的核心变量改造点用 xlog 打点,比如记录$du、$ru、$branch的变化。第三步,用小流量真实呼叫验证,观察呼叫成功率、平均接续时延、以及失败状态码的分布。第四步,再看线上监控。
这里有一个细节值得单独说:xlog 不能乱打。有人为了排查把一个路由块里打了十几个 xlog,结果日志量大到把 Kamailio 的日志进程拖垮。我的一般策略是,先在问题现场最关键的 2 到 3 个分支点打点,确认问题后移除大部分,只保留最少量的必要日志。
4.3 不盲信,不盲拒:把 AI 结论当成“第二双眼睛”
最后说一个和 AI 协作的立场问题。Gemini 对 Kamailio 的语法和模块很熟,但它没有你线上环境的完整上下文,它给出的答案里偶尔也会有想当然的成分。比如它有一次建议我使用record_route()来解决所有跨网关的后续消息问题,但实际上那条呼叫链路根本不需要 record_route,我们只是简单地做转发,引入 record_route 反而可能因为 rr 模块的默认参数造成路由头多变。
所以我现在的态度是:把 AI 当成经验丰富的同事,它会给我提供分析方向和验证思路,但最后的确认还是要靠抓包和测试环境复现。讨论的价值在于它把我的思维盲区照亮了,而不是替我写出一份不需要思考的配置。
5. 基于这场讨论沉淀下来的路由骨架与审查工具
5.1 一个可复用的路由分层骨架
经过这次故障和后续的反复实验,我把自己的 Kamailio 路由脚本重新组织成了五个层。这个结构最大的好处是,每层只关心一件事,排查的时候不用把整个配置文件从头读到尾。
第一层是入口预处理。在这里只做全局性的事,比如限流、非法 UA 拦截、全局超时设置。第二层是认证与信任判断。REGISTER 和 INVITE 走不同的认证策略,该查数据库的查数据库,该走 IP 白名单的放行。第三层是号码与路由意图解析。把用户拨的号码转成标准 E.164,或者根据前缀判断这是本地呼叫、长途还是特定业务。第四层是目的地计算。这一步只负责算出最终要把请求交给谁,可能是用户位置里的注册地址,也可能是一个静态网关节点。第五层才是真正的转发和失败处理。
我把五层的样子简化成下面的代码骨架:
request_route { # 第一层:入口预处理 route(PREPROCESS); # 第二层:认证 if (!route(AUTH_CHECK)) { route(REJECT); exit; } # 第三层:号码规整 route(NORMALIZE_NUMBER); # 第四层:目的地计算 route(SELECT_DESTINATION); # 第五层:转发与失败 route(RELAY_CALL); }每个子路由内部,我都会在关键的出口处保证流程的唯一性:要么exit,要么明确返回状态。绝对不会再出现“已经转出去了,还继续往下跑”的情况。
5.2 防环与重复决策:给路由加两把锁
路由脚本写多了之后,最怕的不是转发失败,而是路由循环。Kamailio 自身有Max-Forwards头保护,你可以在请求进入时检查这个值,如果太小就直接丢弃。但除此之外,我还有两把锁。
第一把锁是检查请求来源。如果请求的源 IP 是某个我们已经明确识别为“自己人”的网段,就跳过部分不必要的处理逻辑,避免同一通呼叫反复进入认证环节。第二把锁是自定义防环头。在转发给下游之前,检查请求里是否有我们自己的标识,如果有,说明这个请求已经从下游绕回来了,此时直接回复 503,而不是再次把它扔进转发链。
防环逻辑看起来很简单,但很多线上故障都出在“没想到会绕回来”。我和 Gemini 讨论时的收获是:把可能形成环路的路径列成一个清单,比如“平台发给 Kamailio,Kamailio 发给网关,网关又把呼叫送回 Kamailio”,然后在脚本的入口处统一拦截,而不是在每一段转发里临时打补丁。
5.3 审查工具:上线前必跑的几道检查
最后分享几个我这段时间反复使用的排查命令和习惯。kamailio -c能做静态配置检查,能发现语法错误和明显的模块引用问题,但发现不了逻辑问题。所以我会配合kamcmd看运行时状态,比如用kamcmd dispatcher.list看网关健康状态,用kamcmd stats.get看事务统计。
真正要判断路由逻辑对不对,还得回到抓包。抓包不是只看 INVITE,要看 INVITE 之后的 100 Trying、180 Ringing、200 OK、ACK 以及 BYE 的全链路。路由逻辑出错通常会在后续消息上显形,比如 ACK 走错路径导致通话建立失败,或者 BYE 没有回传给正确的对端导致通话挂不断。头一次排查这类问题的人,往往会死盯着 INVITE 的转发逻辑,忽略了 Kamailio 是一个“面向事务和对话”的服务器,而不是一个简单的包转发器。
我和 Gemini 的那场长谈,最终沉淀成了一套自己习惯的路由审查清单:入口是否唯一、认证是否在转发之前、号码规整是否覆盖所有分支、目标计算是否明确、relay 后是否显式退出、失败重试是否有次数上限、防环检查是否在入口统一完成、关键路径是否有 xlog 打点。每次改完路由脚本,我都会照着这 8 条重新过一遍,别嫌麻烦,这个方法帮我拦住过好几次险些上线的事故。
最后再补一个小技巧。如果你也经常用 AI 讨论路由脚本,记得在对话开头就告诉它 Kamailio 的版本和主要模块列表。Kamailio 不同版本之间的模块参数和行为差异比想象中大,AI 如果按默认知识库回答,很可能给出旧版本才支持的写法。我在 Gemini 里养成了一个固定习惯:每次抛问题都先贴一行版本信息,比如kamailio 5.6.x with dispatcher, dialplan, usrloc, rr, tm,这样得到的建议会更贴近实际环境,也省掉了来回确认的沟通成本。