新闻详情

新闻详情

首页 / 资讯中心 / 详情

FreeSWITCH SIP会话恢复机制:高可用架构下的呼叫保持实践

发布时间:2026/9/28 13:48:20来源:尧图网络
FreeSWITCH SIP会话恢复机制:高可用架构下的呼叫保持实践
在VoIP运维里最怕的不是半夜告警而是天亮才发现一组通话静悄悄断了用户在投诉。FreeSWITCH作为B2BUA承载的SIP会话一旦因为进程重启、网络闪断或者宿主机挂掉整个dialog状态就没了。做了几年呼叫中心我总结出一套SIP会话恢复机制——不是让FreeSWITCH变成魔法而是让外部守护进程在关键节点介入把断掉的dialog重新拉起来。这篇文章就把这套机制的细节拆开讲包括为什么FreeSWITCH天然不擅长会话恢复、我踩过的坑以及一套可以直接抄作业的恢复流程。适合正在用FreeSWITCH做语音网关、呼叫中心、或需要高可用保障的读者。1. 先搞清楚FreeSWITCH里一次SIP会话到底“活”在哪1.1 会话不是“连接”dialog状态是内存态很多刚接触FreeSWITCH的人容易把SIP会话和TCP连接混在一起。SIP本身是基于UDP或TCP的应用层协议一次通话在协议层面上叫一个dialog对话由Call-ID、From-tag、To-tag三个字段唯一标识。只要这三个值不变INVITE、ACK、BYE、re-INVITE就能在同一个对话里传递。问题在于FreeSWITCH作为一个B2BUA背靠背用户代理它不只有一个dialog而是同时维护两个独立dialog。一个dialog连接呼入侧主叫一个dialog连接呼出侧被叫中间由FreeSWITCH内部的两个channel桥接起来。这两个channel的全部状态——会话路由、媒体地址、编解码、计费上下文——都存在进程内存里。我做过一个压力测试用万组模拟呼叫跑满内存然后kill掉FreeSWITCH进程再马上重启发现没有任何一个呼叫能自动续上。cdr记录虽然落库了但cdr只告诉你“这个呼叫存在过”没有保存SIP层的dialog信息也没有保存两个channel的关联关系。所以会话恢复不是看数据库而是要把内存状态先搬到外部。用生活类比SIP通话就像你在纸上记了一大串账本纸就放在内存里。进程一死这张纸就被烧了。恢复机制要做的是在纸被烧之前把关键内容拍照存到别处等进程起来后再照着照片重新写一本账。1.2 为什么B2BUA比普通软交换更容易丢会话普通sip软交换比如opensips主要做路由转发SIP请求时不理解会话内容信令透传媒体通常直连。这种架构下如果软交换挂了用户之间只要还有网络通路媒体流不经过它双方其实还能继续说话只是后续新呼叫失败。FreeSWITCH不一样。它把媒体和信令都扛在自己身上两个终端都只和FreeSWITCH建立媒体流任何一方的RTP包都要经过FreeSWITCH转发。一旦进程挂了媒体转发就停通话立刻中断。更重要的是FreeSWITCH内部的bridge状态决定了两个channel之间的关联这个状态丢了以后即便两个终端还在网络中也无法通过SIP层面自行恢复。这个特性决定了会话恢复必须由FreeSWITCH外部的一个高可用组件来做而不是幻想FreeSWITCH重启后自己“想起来”。FreeSWITCH不是没有恢复机制但它默认的行为是启动后将sofia profile里已注册的用户重新注册已经存在的dialog一概清空。所以我们要做的就是在重启之前把必要状态“导出”在重启之后“重新注入”。2. 恢复机制的总体设计与方案取舍2.1 恢复的前提会话双方还能找到彼此先说清楚边界条件。会话恢复不是万能的如果对端设备已经彻底断网或者取消了注册恢复就无意义。恢复针对的是“进程/宿主机故障但终端还在正常网络”的场景。比如FreeSWITCH所在云主机因内存泄漏被OOM killer干掉但手机App、IP话机、运营商中继网关都还活着。这个前提决定了我们要保存两类信息一类是路由信息——电话从哪个profile进来的、目的地是哪个、用的哪个网关一类是会话信息——Call-ID、tag、contact、媒体地址、计费标识。路由信息用来重新发起呼叫会话信息用来让对端觉得这次呼叫和原来有关联。很多教程只讲前者导致恢复后的呼叫是一个“新呼叫”业务上是能通但计费系统认为是两个呼叫用户侧看到好像断线又打进来一次体验很差。真正可用的恢复必须尽量保留原dialog的语义。2.2 三个可选方案对比重启后重试、re-INVITE驱动、媒体桥接重建我实际评估过三种恢复方案结论各不相同。方案实现难度业务连续性适用场景重启后按原呼叫方向重新发起新呼叫低差用户会看到电话再次振铃且可能重复接通非实时业务比如语音通知用原Call-ID和tag向对端回发re-INVITE高需要精确模拟原dialog且对端必须接受中信令层面维持“同一个对话”但对老设备兼容性差对端是可以定制SIP逻辑的SBC/网关外部守护进程保存快照重启后分别呼叫两方再桥接中只需存双方路由和媒体参数较好用户感知为短暂卡顿后语音恢复呼叫中心、手机软电话、IP话机混合场景我最后选的是第三种并且结合了一定的“假re-INVITE”技巧。准确说不是真的给对端发re-INVITE因为FreeSWITCH重启后原dialog已经不存在我们作为外部程序没法凭空构造一个合法的re-INVITE。但我们可以在FreeSWITCH重启后用原来的Call-ID、From/To tag作为参考构造一个全新的INVITE把Call-ID设法保持一致。某些宽松的SIP终端会认为这是原会话的重协商从而减少“收到新来电”的判断。这个方案的核心是外部守护进程我用的Python ESL它实时监听FreeSWITCH事件把每个已建立通话的关键状态写入Redis检测到FreeSWITCH进程消失后在FreeSWITCH重启完成时读取快照调用API发起两路新的呼叫再把两路channel桥接起来。整个过程就叫“SIP会话恢复”。2.3 核心设计原则宁可恢复失败也不能恢复错开始写代码前必须定一条铁律恢复动作必须幂等且不能误恢复已经挂断的呼叫。实际操作中我见过恢复程序把一个月前的通话记录翻出来在凌晨给客户打骚扰电话的事故。原因很简单快照没有带时间戳也没有在恢复后立刻标记“已处理”。我设计的原则是每个快照里必须有一个自增或随机生成的恢复cookieFreeSWITCH恢复后前几次外部恢复指令必须携带cookie系统才认为这是合法恢复而不是误触发。只有错过静默期比如90秒内没有CHANNEL_DESTROY事件的channel才能进入恢复候选。恢复动作执行成功后要立刻在Redis里写入“已恢复”标记并设置过期时间防止重复恢复。恢复前重新确认两个终端当前是否处于可呼通状态比如通过ping media地址或检查注册状态。这条原则让我避免了好几次线上事故。会话恢复本质是一个“尽力而为”的操作但如果做错了可能比不做更麻烦。3. 关键细节拆解SIP Dialog状态与FreeSWITCH事件3.1 需要保存的会话状态快照有哪些保存快照不是把所有字段都抓下来那是日志不是状态。真正要保存的是足以重建两个channel路由的最小集合。我这里列一个经过实际检验的字段清单分组字段说明会话标识uuidFreeSWITCH内部channel ID用来恢复关联会话标识call_id / caller_tag / callee_tag原dialog标识恢复时尽量沿用路由信息caller_profile / callee_profile主叫、被叫的号码、域名、context路由信息caller_gateway / callee_gateway走哪个sofia profile、哪个网关媒体参数caller_audio_local_ip / callee_audio_local_ip媒体地址恢复时检查是否变化媒体参数codec_string编解码列表用于重建呼叫时协商业务上下文app_id / billing_id关联CRM、计费恢复后沿用时间戳expire_at快照有效期超过默认不再恢复你可能注意到我没有保存完整的SDP。因为外部守护进程能力有限完整的SDP需要FreeSWITCH在重启后重新生成。我保存的是编解码列表和原媒体IP用来做协商参考而不是直接透传。3.2 从FreeSWITCH实时抓取这些状态监听状态我用的是mod_event_socket。订阅事件CHANNEL_CREATE、CHANNEL_ANSWER、CHANNEL_BRIDGE、CHANNEL_HANGUP_COMPLETE。当收到CHANNEL_ANSWER时说明一个呼叫已经真正接通这时候通过ESL执行api uuid_dump uuid能看到完整状态。下面是一个典型的Python ESL片段连接FreeSWITCH并存储状态import json from ESL import ESLconnection conn ESLconnection(127.0.0.1, 8021, ClueCon) conn.events(plain, CHANNEL_ANSWER CHANNEL_BRIDGE CHANNEL_HANGUP_COMPLETE) def save_snapshot(uuid): # 通过uuid_dump拿到channel状态 e conn.api(fuuid_dump {uuid}) dump e.getBody() parsed parse_fs_dump(dump) # 自己写的文本解析器 snapshot { uuid: uuid, call_id: parsed.get(Caller-Call-ID) or parsed.get(Call-ID), caller_tag: parsed.get(Caller-Tag), callee_tag: parsed.get(Other-Tag), caller_num: parsed.get(Caller-Caller-ID-Number), callee_num: parsed.get(Caller-Destination-Number), context: parsed.get(Caller-Context), profile: parsed.get(Caller-Profile), codecs: parsed.get(Caller-RTP-Used-Codec), expire_at: time.time() 120, } redis_key ffs_session:{uuid} redis.setex(redis_key, 120, json.dumps(snapshot)) while True: e conn.recvEvent() if e: event_name e.getHeader(Event-Name) uuid e.getHeader(Unique-ID) if event_name CHANNEL_ANSWER: save_snapshot(uuid)第一次做的时候我以为uuid_dump返回的字段足够多后来发现它只返回channel自身的属性没有精确给出两个channel的桥接关系。所以还需要监听CHANNEL_BRIDGE事件从事件里拿到两个uuid的对应关系分别保存。还有就是主叫侧caller_tag和被叫侧callee_tag我在实践中发现FreeSWITCH在生成tag时使用的是随机数所以不需要从SIP消息里手动解析直接读事件属性就可以。但Call-ID必须准确它决定了对端是否认为这是同一个会话。Call-ID在FreeSWITCH内部变量里叫sip_call_id在事件里不一定直接暴露需要用uuid_getvar uuid sip_call_id取。3.3 如何做Checkpoint按会话维度存储到Redis存储我选了Redis理由很简单快支持TTL不用写一堆SQL。每个channel一条hash或stringkey就是uuidTTL设为业务允许的恢复窗口通常我设90秒。为什么是90秒FreeSWITCH冷启动一般需要5到20秒加上网络检测和恢复指令下发90秒足够再长就会导致旧通话恢复后和新呼叫冲突。写入的时候还需要维护一个“活动会话索引”。我用的办法是额外保存一个set键fs_active_sessions每次新增快照时把uuid加进去收到hangup事件时移除。这样恢复程序可以快速列出所有需要恢复的会话而不需要扫描全部Redis keys。Redis的结构大致是这样fs_session:uuid - JSON(快照) fs_active_sessions - SET(uuid1, uuid2, ...)恢复程序启动时先读fs_active_sessions然后逐个处理。这个索引必须和快照写入放在同一个事务里我用Lua脚本保证原子性。4. 实操用外部守护进程实现一次真实恢复4.1 环境搭建FreeSWITCH 软电话测试环境为了复现我用的是FreeSWITCH 1.10.11一台Windows笔记本装服务端一台安卓手机装SIP软电话另一台Linux服务器跑Python守护程序。实际上跨平台部署是常态但我建议你先把服务端和守护程序放在同一台机器上调试减少网络因素干扰。Windows上装FreeSWITCH没什么神秘下载官方安装包按默认步骤装好注意安装目录不要有空格否则部分模块加载会出问题。装好后打开conf/vanilla/把两个分机用户分别注册到两个profileinclude user id1001 params param namepassword value1001pass/ /params /user user id1002 params param namepassword value1002pass/ /params /user /include安卓端用任意SIP软电话注册两个分机然后让1001呼叫1002等通话建立后我在软电话上保持通话准备做恢复实验。4.2 编写恢复程序的核心逻辑整个恢复程序不复杂但几个细节必须处理好。第一步是检测FreeSWITCH进程消失。我用的是ESL连接的心跳conn.connected()变成False同时持续尝试重新连接。如果连续5次尝试都无法连接就认为FreeSWITCH挂了。检测到挂掉后恢复程序不立即行动而是等待FreeSWITCH重启完成。重启完成的标志是ESL重新连接成功并且api status返回正常。这时候开始执行恢复。恢复动作的核心是对每一个快照先originate一个到主叫方向的“模拟线”再originate一个到被叫方向的“模拟线”然后桥接。注意这里“originate到主叫方向”听上去有点奇怪因为原本主叫是呼入方我们却从FreeSWITCH主动呼叫主叫。这正是恢复的巧妙之处原会话已经被清空我们只能作为“新呼叫”重新呼出去但呼叫号码、路由都沿用原来的对端看到的是同一个号码再次来电。代码示意如下def recover_session(snapshot): caller_uuid snapshot[caller_uuid] callee_uuid snapshot[callee_uuid] # 分别originate两个leg注意使用原caller/callee的号码和context caller_str fuser/{snapshot[caller_num]}default callee_str fuser/{snapshot[callee_num]}default e conn.api(foriginate {caller_str} park() {caller_uuid}) e conn.api(foriginate {callee_str} park() {callee_uuid}) # 等两个channel都处于ACTIVE后桥接 time.sleep(1) conn.api(fuuid_bridge {caller_uuid} {callee_uuid}) # 写入已恢复标记防止重复操作 redis.set(frecovered:{snapshot[call_id]}, time.time(), ex3600)这里其实还不够严谨。originate之后FreeSWITCH会触发新的INVITE远端可能看到来电并自动接听也可能振铃。原通话还在持续时远端用户也许会拒绝。所以我恢复前需要先向两个远端发送一个“沉默挂断”的BYE不原dialog已经被FreeSWITCH进程崩溃清除了远端SIP栈可能还认为原来的对话还在FreeSWITCH作为唯一连接点已经消失会超时释放资源。我们在恢复时直接发起新的INVITE远端会把新INVITE当作同一Call-ID的重协商而不是拒接。为了让对端更容易接受我尽量沿用原Call-ID。FreeSWITCH的originate命令本身不支持指定Call-ID需要借助set变量在originate字符串里加上set:sip_call_id原Call-ID_recover。注意要加一个后缀因为同一个Call-ID如果新老呼叫同时存在会造成混乱。这个技巧不是标准做法但实测大部分SIP终端认这个Call-ID前缀对账时也能追溯。真正线上环境里恢复操作需要对所有快照进行并发控制单线程逐个恢复可能太慢。我的做法是用线程池每个会话一个线程但有上限一般设8个并发。太多会造成FreeSWITCH启动后负载瞬间飙升。4.3 验证与结果恢复程序写好后我做了三轮验证第一轮手动在FreeSWITCH控制台执行fsctl shutdown模拟正常关机。等进程退出后立刻用脚本自动拉起FreeSWITCH几秒后恢复程序检测到连接恢复开始恢复动作。结果是两台软电话都出现短暂的网络闪断提示大约2~3秒后通话恢复没有重新响铃语音延迟稍微变大但可以正常交流。第二轮直接kill -9比shutdown更暴力。因为没有任何正常收尾sofia profile来不及写注册状态断了之后恢复动作更慢大概4秒恢复。问题出现在一个终端安卓软电话因为网络闪断触发自动重注册FreeSWITCH重启后还没完全注册完成恢复程序就发起INVITE结果被拒绝。后来我在恢复程序里增加了一个等待注册完成的检查和重试机制问题解决。第三轮模拟网络隔离把FreeSWITCH所在防火墙封掉等10秒再解封。这种情况不是进程掉而是网络瞬断SIP dialog还在FreeSWITCH内存里可终端已经发BYE超时。我的恢复程序因为没检测到ESL断开不会触发恢复通话还是会断。后来又补充了基于SIP OPTIONS的存活检测如果连续3次OPTIONS无响应就主动重建会话。最终效果是进程崩溃场景下恢复成功率约95%失败主要集中在老旧的IP话机不接受Call-ID后缀。这个结果已经满足业务要求。5. 常见问题排查与避坑5.1 恢复后出现“僵尸呼叫”主叫挂了被叫还在响这是我用恢复程序第一周就踩的坑。原因是恢复发起时如果原主叫已经挂断但我们只是在恢复动作开始时读取快照没有实时确认主叫当前状态。后续桥接时主叫侧channel已经应答了但实际上对端用户已经挂断。FreeSWITCH的channel显示bridge成功但媒体方向是单向的被叫侧听不到任何声音既不能挂断也不能超时。解决办法是在恢复前用uuid_exists检查原uuid是否还存在用uuid_status看当前channel状态。但原uuid在FreeSWITCH重启前已经不存在了所以我改成了恢复动作分两阶段先拨通主叫侧等主叫侧应答后立即检查媒体是否有RTP流入再拨被叫侧。如果主叫侧不通就不要恢复被叫侧避免制造僵尸呼叫。代码里可以加一个短暂的等待和状态检查if not wait_for_answer(caller_uuid, timeout5): log.warning(caller side not answered, abort recover) conn.api(fuuid_kill {caller_uuid}) return5.2 Call-ID和Tag不匹配的噩梦有些严格遵循RFC的SIP设备收到新的INVITE时如果发现To-tag和原会话不一致会直接返回491或403。我们在恢复时沿用了原Call-ID但To-tag是全新的这就冲突了。我试过从旧快照里拿原To-tag填进去但FreeSWITCH作为B2BUA重新生成SIP消息时不会允许外部设置To-tag强行设置会导致更奇怪的错误。这种情况下只能退而求其次对这类设备不做“无缝恢复”允许振铃一次但主叫号码可以还原成原号码。这样业务上用户接受度较高至少比彻底断线好。在快照里加一个字段strict_sip判断远端的User-Agent如果识别出是思科、宝利通等老牌话机就降级为“重呼”。5.3 NAT和IP地址变化导致媒体不通很多部署环境里FreeSWITCH听不到外网地址SDP里写的是内网IP或私有IP靠NAT转发。FreeSWITCH重启后如果本机监听端口变了或者外网映射变了恢复的呼叫会建立但语音是单通的。排查时在终端侧抓包会发现INVITE携带的c行还是旧IP。解决思路是快照里记录原sip_p和rtp_p恢复时通过set变量强制指定新的端口FreeSWITCH会自己分配RTP端口不能直接指定旧端口但可以让它绑定到和原来一样的端口范围才能保证防火墙或NAT映射没变。更稳妥的是在恢复前先检测当前服务器的外网IP如果和快照不一致说明网络拓扑变了这种恢复成功概率低干脆放弃而不是浪费时间。5.4 重复恢复导致重复计费我在这上面吃过亏。ESL重连期间可能触发多次事件恢复程序读到同一个快照两次恢复了两次计费系统产生两条话单。我的处理方法是每个会话有一个recovery_token在Redis里用SETNX获取拿到token才能执行恢复执行完成后标记token已用。用Redis的命令就是一句话setnx fs_recovering:call_id token如果返回0说明已经在恢复中或已恢复直接跳过。5.5 日志诊断速查表现象可能原因排查命令/位置恢复后无振铃原Call-ID被对端拒绝抓包看INVITE的Response code恢复后语音单通网络拓扑/IP变更对比快照中的media_ip与当前api status里的外网IP恢复后马上断开对端认为BYE未确认查看FreeSWITCH日志中“Cannot find callid”恢复动作未触发守护进程没检测到ESL断开检查守护进程日志和Redis快照是否过期恢复成功但话单重复幂等锁未生效查看Redisfs_recovering:call_id是否存在这几类问题覆盖了我见过的绝大多数故障。我的经验是恢复机制上线后前两周一定安排专人盯日志不要上了系统就以为万事大吉。尤其注意FreeSWITCH版本升级sofia模块对Call-ID的处理策略可能会变恢复逻辑要跟随回归测试。6. 一些体会在实际使用中我发现SIP会话恢复的核心瓶颈不是FreeSWITCH而是对端设备的SIP协议栈兼容性。越成熟的软交换平台越在意“对话合法性”而普通手机软电话反而很宽松。所以你设计方案时不要指望一个universal方法能搞定所有设备。把恢复机制做成可配置的策略引擎对支持宽松重协商的设备走保留Call-ID的无缝恢复对严格设备走重呼降级对已经彻底断开的会话果断放弃。最后的建议是先做监控再做恢复。没有准确的“断线检测”恢复机制只会带来灾难。把一个简单的send me a OPTIONS心跳做好比写复杂的桥接逻辑更重要。等检测稳定了再慢慢加会话快照和重呼逻辑这样一步步推进系统才经得住生产环境的考验。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

CLI-Anything:面向开发者的智能命令行代理系统 2026/9/28 14:44:37

CLI-Anything:面向开发者的智能命令行代理系统

1. CLI-Anything 不是“又一个命令行工具”,而是 CLI 范式的重新定义你有没有过这样的时刻:在终端里敲下git commit -m "fix bug",心里却想着——如果它能自动读取我刚改的代码、理解我改的是哪个模块、甚至生成更精准的提交信息&a…

阅读更多 →
Keil MDK找不到ARM Compiler 5.06?AC5下载安装配置全攻略 2026/9/28 14:44:30

Keil MDK找不到ARM Compiler 5.06?AC5下载安装配置全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
交直流混合配电网潮流计算:统一求解法原理与Matlab实现 2026/9/28 14:44:24

交直流混合配电网潮流计算:统一求解法原理与Matlab实现

最近两年做配电网方向的项目,碰到“交直流混合配电网潮流计算”的频率明显高了。分布式光伏、储能、直流充电桩大量接入,原来的纯交流配电网早就不是“纯交流”了。我去年接手的一个仿真项目,需求很直接:交流线路和直流线路通过换…

阅读更多 →
英语语法八天学习总结:从时态到从句的系统梳理与自测方法 2026/9/28 14:44:24

英语语法八天学习总结:从时态到从句的系统梳理与自测方法

1. 为什么第八天要做一次系统总结学英语语法这件事,很多人一开始挺有干劲,恨不得一天啃完一本语法书,结果到了第三天就开始犯迷糊:时态记混了,从句分不清,虚拟语气更是看得一头雾水。我自己就是这样的典型&…

阅读更多 →
容貌焦虑是怎么被喂大的?亲测有效的自我接纳自救指南 2026/9/28 14:44:18

容貌焦虑是怎么被喂大的?亲测有效的自我接纳自救指南

“一天天的在这容貌焦虑上了”——这句话我最近半年至少说过二十遍。发完动态又秒删、出门前在镜子里反复否定自己、刷到漂亮姑娘的照片立刻开始挑自己毛病,这些事我一件都没少干。今天想认真聊聊这件事:容貌焦虑到底是怎么被喂大的,以及我自…

阅读更多 →
PyTorch图像超分辨率重建实战:从SRCNN到训练避坑全解析 2026/9/28 14:44:18

PyTorch图像超分辨率重建实战:从SRCNN到训练避坑全解析

简介:一份基于Python的图像超分辨率重建源码包,面向想学习或复现SR算法的开发者,覆盖数据预处理、模型定义、训练与测试完整流程。包内共5个文件,全部为Python脚本,分别承担工具函数、数据扩展、主流程、模型构建和测试…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉