做呼叫中心或者VoIP接入服务的同行,一定对“通话到一半,交换机崩了”这件事有切肤之痛。FreeSWITCH 作为开源软交换里使用率非常高的一款系统,平时扛着 IVR、外呼、排队、录音这些业务都很稳,但一旦进程被 OOM Kill、服务器强制重启、或者机房断电,正在通话中的 SIP 会话怎么办?用户那头还举着电话在说话,这边交换机却已经把会话状态丢了个干净。
FreeSWITCH SIP会话恢复机制,解决的就是“进程没了,通话尽量不能没”的问题。但开篇得先把丑话说在前面:会话恢复不是魔法,做不到百分之百无损还原所有通话。我们真正要做的,是设计一套机制,在异常退出之后能最大限度地把能救的会话捞回来,把业务损失压到最低。这篇文章我会结合自己的落地经验,把 FreeSWITCH 的 SIP 会话模型、崩溃后的数据状态、恢复方案的取舍,以及实际搭建恢复模块的步骤和踩坑记录,一次性讲清楚。适合部署过 FreeSWITCH、对接呼叫中心、正在做高可用改造的开发和运维朋友参考。如果你用的是 Windows 下的 FreeSWITCH 环境,或者接的是安卓 SIP 软电话客户,很多思路同样能直接套用。
1. 会话恢复的核心思路与边界判断
1.1 恢复的本质,不是让进程不死,而是让业务不断
很多人一开始理解“会话恢复”,以为目标是要保证 FreeSWITCH 进程永远不挂,这个方向其实从一开始就跑偏了。进程层面的高可用,交给 systemd、supervisor 或者容器编排工具去处理就够了,真正难的是进程死了以后,那批正在通话的会话怎么办。
恢复的本质,是把“业务状态”和“进程生命周期”解耦。也就是说,哪怕 FreeSWITCH 进程没了,外部还存有一份能够描述“当前有哪些通话、通话双方是谁、通话处于什么阶段”的记录,等进程重新拉起来之后,再根据这份记录把会话重建出来。
所以,一套能落地的恢复机制至少包含三个环节:状态记录、崩溃检测、会话重建。状态记录是恢复的地基,崩溃检测决定恢复多快启动,会话重建决定最终的用户体验。我在实际项目里感受最深的一点是:很多人会把大部分精力花在崩溃检测上,却严重低估了状态记录的质量要求。这个顺序反了。状态记录做不好,后面两个环节搞得再花哨,恢复出来的也是一团乱麻。
1.2 哪些会话值得恢复,哪些必须放弃
不是所有会话都值得去恢复。先想清楚边界,再谈方案,这是恢复设计里最容易被人忽略的一步。
按通话阶段来分,三类会话的恢复价值完全不同:
| 通话阶段 | 恢复价值 | 说明 |
|---|---|---|
| 振铃未接通 | 高 | 很多场景用户还在等待,恢复后可以继续振铃 |
| 已接通通话中 | 最高 | 正在通话的双方,恢复成本最低,价值最高 |
| 呼叫已结束 | 低 | 已结束的会话不用恢复,只需确保 CDR 完整落库 |
另外,还要看会话是否涉及收费或核心业务。比如接运营商线路、每分钟都在计费的通话,肯定比一条纯内部测试呼叫值得救。我的经验是,恢复策略要支持白名单和黑名单:白名单里放的是必须强制恢复的业务号码段,黑名单里的号码段即使还在通话中也直接放弃。
为什么要做区分?原因很简单,会话恢复本身是有代价的。盲目恢复所有会话,轻则会话重建请求打爆对端,重则引发呼叫风暴,把刚恢复起来的交换机再次压垮。一个合格的生产系统,恢复功能首先要学会“克制”,而不是想都不想就把所有会话一股脑全救。恢复的第一原则不是“全保”,而是“保住最该保的”。
注意:恢复边界必须提前跟业务方对齐。尤其是计费类平台,要明确“恢复出来的通话,继续计费还是不计费”,这直接关系到后续账单的准确性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 理解FreeSWITCH的SIP会话模型
2.1 B2BUA架构与会话生命周期
FreeSWITCH 是典型的 B2BUA(Back-to-Back User Agent),它在呼叫中被看作“背靠背”的两个 UA:一个 UA 面向主叫,也就是 A-leg,一个 UA 面向被叫,也就是 B-leg,两个 UA 通过内部的 session 对象关联起来。
为什么一定要先理解这点?因为 B2BUA 意味着会话是“双边有状态”的。主叫发来的 INVITE 会在 FreeSWITCH 这里终止,FreeSWITCH 再作为新的主叫向被叫发起第二个 INVITE。两边各自维护 SIP 事务状态、鉴权状态和 RTP 媒体状态。SIP协议本身是无连接协议,所有状态都靠两端各自维护,而 FreeSWITCH 的会话状态全部落在内存里。
这就带来一个关键后果:一旦 FreeSWITCH 崩溃,A-leg 和 B-leg 两边的 SIP 对话状态会同时丢失,对端的 UA 处于“通话还挂着、控制面却没人回应”的状态。如果换成普通 SIP Proxy,代理节点崩了以后,两端还能顺着原来的 RTP 路径继续通话;但在 B2BUA 架构下,控制面一断,整个会话就成了无人认领的孤儿。
2.2 崩溃时会话状态停留在哪个阶段
FreeSWITCH 的会话生命周期大致是 NEW -> ROUTING -> EXECUTING -> HANGUP。用 show channels 命令看到的每个 channel,在内部就对应一个 switch_channel_t 对象,整个生命周期都只存在于内存。进程一崩,这个对象就跟着内存一起被回收了。
从恢复的角度看,最关心两个时间点:主叫接通时间和通话结束时间。只要数据库里记录了这两条信息,崩溃时就能立刻判断哪些是需要恢复的候选会话。很多 CDR 表设计里都有 answer_stamp 和 end_stamp 字段,有接通时间但没有结束时间的记录,就是要捞回来的那批。
还有一个细节经常被忽略:hold 状态、会议状态、录音状态这些更细的会话阶段,只有在通话真正建立之后才存在,而且它们大部分只存在于内存里。所以恢复方案设计时,要把这些“衍生状态”一起考虑进去,否则会出现一种很尴尬的情况:会话重建成功了,但用户发现通话被摇到半路,等待音乐倒是响起来了,会议却早被拆散了。
2.3 媒体与控制面的分离决定恢复难度
SIP 会话能不能恢复、恢复难度有多大,很大程度上取决于媒体流(RTP)是怎么走的。FreeSWITCH 里媒体有两种路径:媒体经过 FreeSWITCH 转发,也就是 Media Bypass 关闭;以及媒体在两端直连,也就是 Media Bypass 开启。
这是一个非常重要的决策点。如果做了 media bypass,那么即使 FreeSWITCH 进程崩溃,两端的 RTP 媒体流依然会继续走,因为媒体包根本不经过 FreeSWITCH。这种情况下,进程拉起来以后要做的就少得多,重点只是把两边的 SIP 控制面重新搭起来,或者干脆等通话自然结束。
反过来,如果媒体经过 FreeSWITCH,进程一崩,RTP 就被截断,双方便立即失去声音,这时候恢复就必须连媒体一起重建。我的建议是:如果业务场景允许,核心通话尽量开启 media bypass,这不仅降低了系统负载,也大大提高了崩溃后的“自然幸存率”。但代价也很直接,媒体绕过之后,DTMF 收号、录音、转接这类需要触碰媒体的功能就不好做了,具体取舍要看业务主次。
3. 三种可落地的会话恢复方案
3.1 基于数据库记录的会话重建
这是我最常用、也认为最稳妥的方案,核心思路就一句话:把要恢复的会话信息先落库,等进程恢复后查库重建。
具体做法分三步。第一步,在 FreeSWITCH 里通过 CDR 模块或者自定义的拨号计划节点,把每次呼叫的关键信息写入数据库。字段至少要包含:呼叫 UUID、主叫号码、被叫号码、A-leg 的 SIP 地址、B-leg 的 SIP 地址、呼叫开始时间、接通时间、当前业务标签。
第二步,进程启动后,由启动脚本或者恢复守护进程去查询数据库里“有接通时间但无结束时间”的记录。这里有坑,正常挂断的呼叫结束时间会被写入,但进程崩溃时可能整条 CDR 还没来得及落库,所以光靠 CDR 不够,还要配合一张独立的状态表:开会话之前先写一条状态,会话结束时再标记一次,而不是依赖 CDR 事后补写。
第三步,拿到候选会话列表后,按策略过滤掉白名单之外和黑名单之内的记录,然后批量发起恢复呼叫。恢复呼叫的创建方式,在 FreeSWITCH 里通常用 originate 命令或者 ESL 的 originate API 来实现。查库的 SQL 大致长这样:
sql复制SELECT call_uuid, caller_number, callee_number,
sip_from, sip_to, answer_stamp
FROM call_state
WHERE answer_stamp IS NOT NULL
AND end_stamp IS NULL
AND create_stamp > NOW() - INTERVAL '4 HOUR'
AND recovered = FALSE;
这种方案的优点是实现简单、可控性强,不依赖 FreeSWITCH 内部那些不可靠的恢复特性;缺点是恢复不是瞬时的,从进程崩溃到用户重新听到声音,中间会有几秒甚至十几秒的空窗。如果业务能接受这个空窗,这个方案的性价比最高。
3.2 基于对端协商的媒体与呼叫恢复
第二种思路是尽量让会话“自愈”,不急着重新拨号,而是等对端配合恢复。
FreeSWITCH 的 Sofia SIP 协议栈支持在 profile 里配置会话定时器,也就是 RFC 4028 定义的 Session Timers。开启之后,通话双方会定期发送 re-INVITE 或者 UPDATE 来刷新会话,一旦发现会话长期没有刷新,对端 UA 会主动拆除这个会话。对软电话类终端,这个机制配好以后,能减少大量“脏会话”残留。
对于真正想保留通话的场景,更实用的做法是在崩溃重启后,利用 uuid_bridge、uuid_transfer 这类命令,把存活的双方重新桥接起来。举个例子,A 和 B 通话到一半,FreeSWITCH 挂了,我们从状态表查出 A 和 B 的注册信息。进程起来后,先让 A 的软电话自动重注册,大多数 SIP 软电话都有这个机制,然后用一条控制命令把 A 和 B 重新拉到一起。FreeSWITCH 的命令行大致是:
bash复制uuid_bridge <A_leg_uuid> <B_leg_uuid>
我见过有团队把整个过程做成一个 Lua 脚本,定时扫描“待恢复队列”,一旦检测到双方都已重新注册就自动执行桥接。这个方案资源开销小,体验也接近原先的通话,但依赖终端设备配合,不是所有话机都支持自动重注册,上线前要提前验证终端兼容性。
3.3 基于ESL外部编排的优雅降级
第三种方案,是前两种的“升级版”,适合本身就已经搭建了外部业务平台的团队。核心思路是把 FreeSWITCH 定位成一个“无状态执行器”,真正的会话状态全放在外部平台,FreeSWITCH 只负责按指令执行呼叫。
这种架构下,平台在和 FreeSWITCH 建立呼叫的同时,自己维护一份完整的状态机。FreeSWITCH 崩溃后,平台通过 ESL(Event Socket)检测到连接断开,立即走自己的容灾流程:把这两个用户重新分发到另一台 FreeSWITCH,或者等原机恢复后重新发起呼叫。ESL 的默认端口是 8021,密码是 ClueCon,生产环境必须改掉,而且只监听内网接口。
严格意义上,这已经不是“FreeSWITCH 的会话恢复”,而是“平台的会话恢复”。但对生产环境来说,这种架构反而最可靠。因为 FreeSWITCH 本身不保证崩溃后能恢复内存态,把状态外置,才是真正高可用的路径。代价是要多维护一套状态同步机制,开发量会明显上升。
提示:三种方案并不互斥。我在生产环境里通常是 3.1 保底,3.2 辅助,3.3 用于核心计费业务。分层恢复,比单选一种更能扛故障。
4. 实操:从0到1实现一个会话恢复模块
4.1 环境准备与基础配置
先说环境。FreeSWITCH 建议直接用 1.10 或者更新版本,稳定性上比早期版本好很多,对系统资源的管理也更完善,崩溃概率低不少。Windows 上有官方安装包,配置路径略有差异但原理一样;Linux 上直接用发行版仓库或者源码编译都行。
进程守护这一步不能省。无论用 systemd 还是 supervisor,都要确保 FreeSWITCH 进程退出后能自动拉起,并且配置好日志转储。我常用的 systemd 单元配置大致如下:
ini复制[Unit]
Description=FreeSWitch
After=network.target postgresql.service
[Service]
Type=simple
User=freeswitch
Group=freeswitch
EnvironmentFile=-/etc/default/freeswitch
ExecStart=/usr/bin/freeswitch -nc -nonat
ExecReload=/usr/bin/fs_cli -x 'reloadxml'
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
TimeoutStopSec=60
[Install]
WantedBy=multi-user.target
Restart=on-failure 保证崩溃后 5 秒自动重启,但这只是让进程回来,会话恢复的逻辑要靠后面的模块来完成,两者不能混为一谈。
4.2 会话状态记录与心跳标记
状态记录是整个恢复机制的地基,我强烈不建议只依赖 CDR,而是要单独建一张会话状态表。理由前面说了:CDR 的写入时机通常在呼叫结束时,崩溃场景下很可能根本没来得及写。状态表的写入时机要放在呼叫开始前和通话关键节点。
我通常在拨号计划的开始处写一条接通前的状态,接听时再更新为接通状态。具体可以通过 ODBC 对接数据库,也可以更优雅一点,用 ESL 外部脚本监听事件来写库。拨号计划里最简单的写法是调用系统脚本:
xml复制<extension name="recovery-tracking">
<condition field="destination_number" expression="^(\d+)$">
<action application="set" data="recovery_tag=1"/>
<action application="system" data="/usr/local/bin/write_call_state.sh ${uuid} ${caller_id_number} ${destination_number} ${sip_from_host}"/>
<action application="answer"/>
<action application="park"/>
</condition>
</extension>
write_call_state.sh 里其实就一条插入语句,把 UUID、主叫、被叫、SIP 地址和当前时间写进数据库。这里的细节要留意:SIP 地址一定要存完整的 sip: 格式,不要只存 IP,否则恢复时发起呼叫可能因为缺少端口或者传输协议参数而失败。另外,脚本调用的方式在高并发下会频繁拉起子进程,量大的时候建议换成 ESL 事件监听写库,性能和稳定性都好得多。
4.3 崩溃检测与恢复流程实现
崩溃检测这块,我的经验是双通道:本地检测加远端检测。本地检测就是 systemd 服务状态判断,进程起来了就认为恢复完成,简单粗暴。远端检测更重要,因为 FreeSWITCH 可能出现“进程活着但服务不可用”的半死状态,单靠进程检测发现不了。
我习惯用一个独立的健康检查脚本,定时用 ESL 连接 FreeSWITCH 执行 status 命令。连续三次失败,就判定节点不可用,触发恢复流程。恢复流程本身,用 Python 或者 Go 写一个守护进程都行。我用的是 Python 加 ESL 库,核心逻辑如下:
python复制import ESL
con = ESL.ESLconnection("127.0.0.1", "8021", "ClueCon")
if con.connected():
rows = query_recoverable_calls()
for row in rows:
cmd = (
f"originate {{recovery_uuid={row['call_uuid']}}}"
f"user/{row['caller_number']} "
f"&bridge(user/{row['callee_number']})"
)
con.api("originate", cmd)
这里关键的参数是 recovery_uuid。恢复拨出的新会话要尽量沿用原 call_uuid,或者在变量里记录原 UUID,这样外部平台可以用原 UUID 做上下文关联。如果完全新建 UUID,恢复出来的通话就和原业务记录对不上,后面查账、关联录音都会很痛苦。
4.4 恢复测试与验证方法
恢复机制建好之后,不测试等于白做。但怎么在没有真实故障的情况下做有效测试,很多人没有章法。
我的做法分三层。第一层是进程级测试:手动 kill -9 FreeSWITCH 进程,观察 systemd 是否正常拉起、恢复脚本是否扫描到未完成会话、是否产生了恢复呼叫。第二层是会话级测试:开两条测试分机互打,接通后 kill 掉进程,等恢复完成后检查两条电话是否重新振铃并接通,同时核对数据库里状态表的新会话是否关联上原 UUID。第三层是媒体验证:恢复接通后,两边对着电话说话,确认不是单通,DTMF 能不能收到,录音文件是否正常写入。
测试过程中最容易踩的坑是“重复恢复”。我遇到过恢复脚本跑完一遍,把 A 和 B 重新拉起来了,可是因为数据库里“原会话”没有被标记为已恢复,下一轮扫描又把同一对号码当作待恢复对象,导致两边电话反复响铃。解决办法是给状态表加一个 recovered 标志,恢复完成之后把原会话标记为“已恢复”,后续扫描直接跳过。
注意:kill -9 测试之前,先把数据库连接数监控打开,并且准备好测试结束后清空测试数据,否则一堆“半恢复”的测试会话会干扰后续真实业务的恢复判断。
5. 常见问题与排查技巧实录
5.1 恢复后出现单通或静音
恢复出的通话里,最常见的问题就是单通。原因多半出在 RTP 媒体流的重建上。FreeSWITCH 崩溃前,两端的 SDP 协商结果只存在于内存里,恢复时如果只重建了 SIP 控制面,没有重新协商好 RTP 端口,就会出现“电话通了但说不了话”的情况。
排查思路是抓包看 RTP 有没有双向流量。如果 A 到 B 方向有包、反向没包,多半是 NAT 或者 ACL 问题;如果两边都有包但听不到声音,大概率是编解码协商出了问题。恢复发起之前,在拨号计划里强制指定音频编解码,比如在 set 里固定 PCMU、PCMA、G722 三选一,能减少大量单通情况。
5.2 INVITE时序错乱导致对端拒绝
另一个高频问题,是恢复时发起的 INVITE 被对端直接用 4xx 拒绝。原因是恢复脚本在 FreeSWITCH 刚启动时就立刻发 INVITE,此时整个 Sofia 协议栈还没完全就绪,或者对端软电话还停留在旧的会话状态中没有恢复好。
解决办法是给恢复流程加一个“等待期”。FreeSWITCH 起来后,先留一个重注册等待窗口,比如 15 秒,让所有 SIP 终端先完成重注册,再开始下发恢复呼叫。同时,每次 INVITE 之间加一点随机延迟,避免同时涌出大量请求把对端打懵。我一般会先用 sofia status profile internal 确认注册数量恢复正常后再启动恢复队列,效果很明显。
5.3 数据库连接池被拖垮
恢复机制本身不出问题,数据库倒是先垮了。这个坑不少人都踩过。原因是恢复脚本在短时间内发起大量查询和写入,加上 FreeSWITCH 本身的 CDR 写入,数据库连接池直接被打满,所有业务一起卡死。
解决思路有两个:一是给恢复脚本的数据操作单独走一个连接池,和 FreeSWITCH 的运行时写入隔离开;二是恢复扫描不要一次性拉全量,分批处理,每批 20 条,处理完一批再拉下一批。另外,SQL 里一定要有 create_stamp 的时间窗口条件,否则历史积累的脏数据会被反复当成待恢复对象,数据库压力会越来越大。
5.4 恢复触发后的重复呼叫
最后一个常见问题,是恢复出来的呼叫和用户自己拨回去的呼叫撞在一起。比如用户 A 和 B 正在通话,崩溃后 A 的手机自动按了重拨,同时恢复脚本又把 A 和 B 重新拉起来,于是 B 同时接了两个来电。
这个问题没有完美的自动解法,只能靠业务侧约束。我的做法是:恢复前把原会话的 call_uuid 推给外部平台,平台侧做去重判断,如果发现用户已经在新的会话里,就过滤掉恢复请求。如果没有外部平台,可以在发起恢复呼叫之前,先给被叫号码发一条提示消息,提醒用户不要接听重复的恢复呼叫,这算是一个体验上的补救。
| 问题现象 | 根因 | 优先排查项 |
|---|---|---|
| 恢复后单通 | RTP 未重建或编解码不一致 | 抓包看 RTP 双向流量,检查 SDP 协商 |
| 对端拒收 INVITE | 对端未完成重注册或时序太早 | 等待重注册窗口,错峰发起 INVITE |
| 数据库连接满 | 恢复查询与 CDR 写库争抢连接 | 独立连接池,分批扫描,加时间窗口 |
| 重复呼叫 | 恢复与用户重拨撞车 | 依赖业务平台去重或推送提示 |
6. 个人体会与后续扩展方向
做了这么多套恢复机制之后,我最大的体会是:会话恢复做得好不好,九成取决于会话状态记录做得好不好,只有一成取决于恢复动作本身。很多人反复折腾恢复命令、折腾心跳检测,却不愿意花心思去设计状态表,最终效果往往很差。状态记录是那个“1”,其他都是后面的“0”。
另一个体会是,恢复机制必须分级。核心计费通话用外部平台编排恢复,普通通话用数据库重建恢复,测试号码直接放弃。如果所有通话都不分轻重缓急地恢复,系统在故障后的表现往往更差。合理取舍,比追求完美恢复更实际。
往后的扩展方向,我个人觉得有两个值得关注。一是把状态表和现在的容器化部署结合起来,FreeSWITCH 崩溃后由编排系统在另一台机器拉起新实例,同时从数据库恢复会话状态,实现真正意义上的跨机恢复。二是用更轻量的方式做媒体旁路,让崩溃后媒体幸存率更高,同时通过独立的信令代理保持控制链路,这样普通用户可以明显感觉“故障好像没发生过”。
最后分享一个小技巧:无论方案怎么设计,一定要在每周的维护窗口里主动做一次崩溃恢复演练。演练不只是验证脚本,更是逼着自己把状态表、权限、依赖关系这些平时看不见的细节暴露出来。等真正出事那天,你会发现提前演练过的那套流程,就是整个系统最可靠的保命绳。
