
fastlivo2这个修改记录其实应该叫fastlivo从1.x到2.0的重构复盘更准确。项目本身是个开源的轻量直播分发服务名字是当年拼写失误留下来的——原本想叫fast live手滑成了fastlivo后来将错就错沿用到了2.0。这次的修改跨度比想象中大得多不光是版本号跳动核心推流链路、缓冲机制、断线重连逻辑几乎都推倒重来了一遍。我打算把这大半年改代码的过程完整记录下来包括设计取舍、参数调整和一些实测数据给正在做流媒体网关、推流服务或者准备重构自己核心模块的朋友提供一份可以直接参考的记录。这篇文章不打算讲太多理论也不会把代码逐行贴出来。我更想聊的是当时为什么决定重写、每个核心模块在改之前遇到了什么问题、改完之后效果怎么样。毕竟流媒体这块光看架构图是没有体感的只有把真实业务压上去才能明白能用和扛得住之间的差距。如果你正准备把手头的老服务升级换代这篇记录里踩过的坑应该能帮你省下不少时间。1. 项目背景与整体定位1.1 这个项目到底解决了什么问题fastlivo最开始是为了内网直播推流场景写的。视频源通过RTMP协议推上来服务端负责把流转封装成HTTP-FLV输出给前端播放器同时附带一份HLS切片方便那些不在实时观看、只想之后回看的用户。它解决的问题其实很朴素在带宽有限、设备性能参差不齐的情况下如何把一路高清流稳定地分发给几百上千个观看端。很多人觉得直播分发直接用CDN或者开源方案现成的东西就行了没必要自己写。这话对了一半。CDN在公网场景确实香但在内网专线、混合云机房或者前端播放器对延迟有极苛刻要求的场景下通用的方案反而不好用。fastlivo最初就是在这种规模不大但需求特殊的背景下诞生的它不需要处理海量房间更看重单路流在弱网下的稳定性以及资源占用是否够低。1.x版本在实际使用中表现其实还行支撑了两个核心业务跑了大半年。但随着在线峰值从几百人涨到两千人问题开始暴露。最明显的是CPU和内存的占用单机部署的情况下旧版本在压力高的时候CPU能冲到80%以上内存也一直降不下来。到后面我们甚至不敢在一次直播活动中同时推超过三路流怕把机器跑挂了。1.2 1.x版本积累的痛点1.x的架构是典型的能用但不够稳型。整个服务跑在单线程事件循环上RTMP收流、转封装、HTTP分发全部挤在同一帧循环里处理。这个设计在低并发下没有任何问题代码写起来也省心但并发一旦上来所有瓶颈都集中在同一个线程上。CPU被单个核心打满其他核心闲着吞吐量直接被卡死。另一个痛点是缓冲策略。1.x用的是固定长度的缓冲队列推流端网络波动时缓冲队列很快被填满填满之后新进来的数据就开始强制丢弃。结果就是音频流跳帧严重视频画面频繁卡顿用户端的体感非常差。最要命的是这种情况在日志里还不容易发现因为丢帧逻辑是静默丢弃不会记录任何错误信息只有观看端反馈怎么老卡的时候我们才知道出了问题。断线重连也是1.x的老大难。当时的实现是断开之后等固定5秒再重连完全不考虑当前网络状况和推流端的状态。网络差的时候推流端断线重连成功率和网络好的时候完全不一样但重连策略却完全一样导致弱网下频繁重连、越重连越乱甚至有几次把服务端推到了假死状态。这些问题累积了大半年最终让我决定在2.0里把核心链路整个换掉。2. 核心模块的改动思路2.1 推流链路从单线程改成多线程流水线2.0重写时最核心的一件事就是把推流链路从单线程事件循环改成多线程流水线模型。这条链路由三个节点组成收流节点、转封装节点、分发节点。收流节点负责从网络读取RTMP数据做协议解析转封装节点负责把原始音视频数据重封装成HTTP-FLV或HLS需要的格式分发节点负责把封装好的数据发送给所有订阅的观看端。改动的原因不用多解释单线程的瓶颈太明显了。流水线模型的好处是每个环节可以跑在自己的线程里互不阻塞。收流节点慢的时候不会直接影响分发节点的写操作。而且每个节点可以独立扩容收流压力大就加收流线程分发压力大就加分发线程而不是像以前那样只能干瞪眼。但多线程不是把代码从单线程挪到多线程就完事线程之间的数据传递才是难点。我第一版实现用的是传统的加锁队列每个节点之间放一个互斥锁保护的buffer。压测下来发现并发高的时候锁竞争非常严重吞吐量反而比1.x还差。后来把节点间的数据传递全部改成无锁的SPSC环形队列每个数据块用引用计数管理生命周期问题才解决。提示多线程流水线改造最怕的不是多线程本身而是线程之间的数据传递。如果锁竞争成了瓶颈不要盲目优化锁考虑换无锁数据结构往往更有效。2.2 缓冲策略从固定改成动态水位线缓冲策略这次是彻底重做的。1.x的固定缓冲队列本质上是一刀切思路不管网络好不好缓冲长度都一样。网络好的时候缓冲太长反而增加延迟网络差的时候缓冲太短又不够用。2.0改成动态水位线策略核心逻辑是根据当前网络的RTT和丢包率动态决定缓冲队列的目标水位。具体来说系统每隔500毫秒统计一次最近一段时间的网络质量数据。RTT低、丢包率低的时候水位线自动下调保证延迟尽量小RTT升高、丢包率变高的时候水位线自动上调用更深的缓冲换取播放的流畅度。这个思路其实和TCP拥塞控制有点像只不过TCP在调整发送窗口我们调整的是缓冲水位。调参花了不少时间。水位线的变化不能太激进否则缓冲长度频繁振荡音画同步反而会出问题。我最后加了两个限制条件水位线每次调整的步长不能超过上下限范围的10%并且调整之后必须稳定至少2秒才能做下一次调整。这两个限制条件加上去之后整个缓冲系统才稳定下来。2.3 断线重连状态机重新设计1.x的断线重连就是固定时间重试这次我把它改成了带状态机的重连策略。整个重连过程分为三个状态检测状态、退避状态、重连状态。检测状态负责判断是否真的断线排除网络抖动造成的短暂超时退避状态根据当前网络质量计算下一次重连的等待时间重连状态则真正发起握手。退避时间的计算不是简单的指数退避而是结合了网络质量参数。网络质量好时初始退避时间短重连间隔也短网络质量差时退避时间拉长避免无效重连。同时引入了最大重连次数限制连续重连超过5次仍然失败时直接放弃当前推流链路等待推流端重新发起推流防止服务端被无效重连拖垮。这个改动上线后效果很明显。弱网环境下旧版可能一分钟内重连十几次新版往往只需要重连两三次就能恢复。而且因为退避策略更合理重连成功率也明显提高了。3. 关键参数调优与实测数据3.1 线程池参数怎么定多线程流水线模型上线后最现实的问题就是线程数怎么定。我一开始图省事直接用CPU核心数作为线程池大小但压测发现效果并不理想。后来反复调了几轮才确定了一套比较合理的配置。收流线程组: cpu核数 / 2 (最小2最大4) 转封装线程组: cpu核数 - 1 分发线程组: cpu核数 (最小2最大8)这套配置的逻辑是收流和分发属于IO密集型操作线程数受网卡和协议栈限制不是越多越好转封装属于CPU密集型操作可以把核心用足。如果你的机器是8核那收流4线程、转封装7线程、分发8线程是这个配置下比较均衡的组合。3.2 动态水位线调参动态水位线的参数我在配置文件里暴露了三个核心项调起来比想象中简单buffer: min_water_mark: 200ms # 最低水位延迟下限 max_water_mark: 1200ms # 最高水位延迟上限 adjust_step_ratio: 0.1 # 每次调整最大步长比例这里的时间单位不是缓冲字节数而是缓冲数据对应的播放时长。用时间作单位的好处是不同码率下都能统一衡量。比如码率2Mbps和8Mbps的流同样200ms长度的缓冲实际字节数差了4倍但播放体感基本一致。最低水位200ms的意思是只要网络质量够好缓冲至少保持200ms的数据量避免因为瞬间抖动直接卡顿最高水位1200ms则是延迟上限防止弱网下缓冲无限膨胀。调这一步时踩了一个坑把min_water_mark调太低比如调到50ms弱网表现反而变差。原因是水位太低时缓冲区几乎没有抗抖动的余量网络稍有一点波动就直接触发丢帧。我个人建议min_water_mark不要低于150ms实际使用中200ms是比较稳妥的起点。3.3 新旧版本同场景对比参数调好后我在同样的硬件环境、同样的推流码率和同样的并发观看数下做了对比测试。测试场景是单路推流码率4Mbps300个HTTP-FLV观看端网络模拟加20ms延迟和1%丢包。指标1.x版本2.0版本CPU占用76%41%内存占用1.2GB680MB单流平均卡顿次数/分钟6.4次1.8次首帧播放延迟约800ms约400ms弱网重连成功率62%91%这个对比是在同一台物理机上跑的数据基本能说明问题。CPU占用几乎降了一半内存也好了一些。最关键的是卡顿次数从6.4次降到了1.8次用户端体感从经常卡变成了偶尔轻微卡一下。注意流媒体优化不要只看单指标。CPU降了内存涨了、或者延迟降了卡顿增加都是常见的按了葫芦起了瓢情况。优化完一定要回到整体性能指标上看问题。4. 兼容性处理与升级迁移4.1 配置文件迁移怎么做2.0版本的核心配置项和1.x相比变化不小。不少字段改了名字还有几个旧字段被完全移除了。为了让老用户升级时不至于一杯茶的时间全耗在改配置上我在2.0里做了一个自动迁移的启动逻辑。服务启动时会先检测配置文件版本号如果没有版本号或者版本号低于2就按旧格式解析然后自动转换到新格式。转换过程会打印详细的提示信息标出哪些字段被重命名、哪些字段被删除、哪些字段有默认值可以直接沿用。这样老配置基本可以实现不改也能启动改了能更优。我自己测试下来一份1.8版本的配置直接放到2.0下启动大约95%的配置项能自动迁移。剩余5%主要是那些在新版本里已经移除的字段比如旧版的fixed_buffer_size它在2.0里被动态水位线完全取代了启动时会被忽略并打印警告。4.2 灰度升级需要注意什么流媒体服务不像普通的HTTP接口改坏了可以马上切流量。推流是长连接观看端持续拉流升级过程中稍有不慎就会导致大量用户同时掉线。我在灰度升级的时候采用了两个步骤先在一台低负载的边缘节点上升级观察30分钟确认没有报错、没有内存异常增长、推流和播放都正常再逐步扩大到其他节点。第二步是关键升级过程中不能直接杀掉旧进程重新拉新进程因为这样会断掉所有活跃连接。我用了优雅重启机制旧进程接收新配置后停止接收新连接但继续处理存量连接直到它们自然结束新进程在同一端口上通过socket继承接管新的推流和播放请求。灰度升级里最容易被忽略的是推流端的兼容性。我遇到过一种情况服务端升级到2.0了但推流端还是老版本结果新老版本在RTMP握手和时序上的细微差异导致推流经常中断。后来我在2.0的握手协议里做了兼容处理老版本推流端也能正常推流。如果你的服务也有类似的推拉流客户端升级前一定要检查协议兼容性别只盯着服务端本身。5. 修改过程中踩过的坑5.1 自旋锁在极端并发下反而拖垮吞吐最开始实现流水线节点间数据传递时我图省事用了一个自旋锁保护的队列。在本地压测中表现很好100并发下吞吐量比互斥锁版本高了20%。正当我以为找到了银弹拿到真实业务环境一跑峰值并发300多吞吐量反而比1.x版本还差。排查之后才发现问题自旋锁在低并发下确实快因为等待时间极短自旋不会消耗太多CPU。但并发上来之后大量线程同时在自旋等待锁释放CPU被空转吃得干干净净真正的业务逻辑反而没有资源执行。这个教训让我明白性能优化不能只看低并发测试结果一定要在目标的极限并发下反复验证不然上线等于自爆。5.2 悄悄增长的内存和断开的文件句柄2.0版本在测试环境跑了一周发现内存占用缓慢上升从最初的600MB涨到了1.5GB。用Go自带的内存分析工具查了之后定位到问题出在access log的buffer上。我在记录分发日志时为每个HTTP-FLV请求分配了一个buffer连接正常结束时会释放但遇到播放端异常断开时释放逻辑没有被触发buffer就一直挂在内存里。这类问题在开发机上根本发现不了因为开发时的连接数量少内存泄漏速度很慢。但生产环境一天几十万个连接泄漏积累一个礼拜就非常明显。我后来用工具在内存分析里找到了泄漏点加了defer确保所有连接结束路径都会释放buffer问题才解决。经验之谈内存泄漏排查不能等到内存爆了再动手。线上服务建议从一开始就接入内存监控设置内存持续增长超过基线30%的报警阈值。5.3 弱网不能靠猜用工具注入延迟和丢包流媒体服务最怕弱网但测弱网环境不能靠把网线拔了这种极端手段。我这次用了Linux自带的tc命令可以精准模拟网络延迟、丢包和带宽限制比手动拔网线靠谱得多。# 模拟延迟20ms tc qdisc add dev eth0 root netem delay 20ms # 模拟5%的丢包 tc qdisc add dev eth0 root netem loss 5% # 模拟带宽限制2Mbps tc qdisc add dev eth0 root netem rate 2mbit用tc的好处是参数可调可以模拟各种弱网场景并且随时可以恢复。延迟叠加丢包叠加带宽限制模拟出来的效果和真实弱网环境已经非常接近了。我建议每个做流媒体服务的团队都把这套模拟命令写进测试脚本里每次发版前至少跑一遍20ms延迟5%丢包的稳定性和卡顿测试脚本。5.4 升级中发现的时序问题还有一个小问题虽然在测试环境没引起注意但上线后带来了不小的麻烦。2.0版本重构了收流和转封装之间的时序逻辑结果在高码率8Mbps以上推流时偶尔会出现音频和视频不同步的情况。排查后发现是转封装线程在处理音视频帧时没有仔细处理时间戳。当视频帧先到、音频帧后到的时候时间戳差值超过一定阈值音画同步逻辑就会崩溃。这个问题在1.x版本中不存在因为单线程模型下帧的顺序是严格保序的。多线程改造后帧到达顺序不再严格保证就必须在转封装节点做显式的音视频同步处理。修复方式是加了一个时间戳对齐机制转封装节点会缓存最近一段时间的音视频帧根据时间戳重新排序之后再封装。这样虽然引入了约30ms的额外延迟但音画同步的稳定性大幅提升。6. 修改完成后的回顾与建议fastlivo2的核心改动在线上跑了三个月总体表现远超预期。最明显的收益是同样配置的机器原来只能扛3路并发推流现在可以扛到8路以上CPU和内存的占用也下降了很多给未来的业务增长留出了余量。如果让我总结这次修改里最值得分享的经验大概有三点第一重写老项目的核心模块不要试图一步到位先把最痛点的问题解决掉快速验证收益再继续优化非核心部分第二动态参数一定要有合理的上下限并且变化不要过于激进稳定比极致更重要第三改任何一个核心模块都要有对应的可观测性支撑不然出问题的时候根本无从下手。最后再说一个小细节。这次重构的流水线模型本质上就是把一件事拆成多个环节让每个环节都专心做好自己的事。这个思路不光适用于流媒体服务你做任何有性能瓶颈的单线程服务都可以先考虑拆流水线这个方向。当然拆了流水线之后线程通信、资源管理这些新问题会接踵而来但这本身就是在成长。fastlivo2的修改记录先写到这里。后续如果继续优化我会优先看看分发节点的发送队列在高并发下的表现以及要不要引入更灵活的插件机制。有进展了再回来更新记录。