基于FFmpeg库的RTSP汇聚转RTMP推流:EasyLive架构解析 简介EasyLive是一款基于ffmpeg实现的音视频流汇聚与转发工具主要面向音视频开发者、流媒体运维人员用于解决RTSP/RTMP等多协议流的接入、汇聚、转发及本地录制需求。压缩包共74个文件大小16.54MB核心程序为EasyLive.exe同时包含avcodec、avformat、swscale等ffmpeg动态库以及conf配置、bat启动脚本、log日志和使用说明txt方便直接部署和二次配置。目前已有664人学习下载适合需要快速搭建流媒体转发服务或研究ffmpeg封装原理的开发者。资源还带有nginx、easydarwin等相关组件和live.xml等配置文件结合使用方法说明可以了解整套推拉流转发的实现思路并以此为基础进行定制开发是入门流媒体汇聚和转发实践的实用资料。1. 想把手头十几路RTSP摄像机汇聚成一路RTMP推出去EasyLive这个项目给了个不太一样的答案做安防和直播流对接的工程师多半遇到过这种尴尬现场十几路海康或者大华的RTSP摄像头平台侧却只想要一路RTMP推流地址或者想先汇聚再统一转发给上游。传统做法要么是挨个起ffmpeg进程外挂脚本管理要么掏出笨重的商业流媒体服务器动辄几万路授权费。EasyLive这个开源项目解决的就是这个中间层问题——它用FFmpeg的库而不是命令行外壳把RTSP拉流、转封装、汇聚、RTMP推流这些动作全部收敛到一个进程里自带会话管理适合做中继网关、拉流转推的桥接服务甚至和ZLMediaKit这类流媒体服务器配合做边缘节点。如果你是做摄像头接入、视频汇聚网关或者直播分发链路的开发者这份源码值得拆一遍。它不是那种浏览器里能跑的Web Demo是真正跑在Linux服务端做数据面转发的工具读完这篇文章你能知道它的设计思路也能直接对着关键代码做二次开发。2. 选型逻辑为什么是FFmpeg库而不是直接命令行调ffmpeg2.1 拉流到转发之间FFmpeg命令行做不了的事很多被引到这个问题上的人第一反应是shell里写个循环每路摄像头起一条ffmpeg -i rtsp://... -f flv rtmp://...不就完了吗?如果只盯着转发两个字这个方案确实能跑通。但一旦你要做的是流汇聚(多路输入、一路输出或者动态切换输出)命令行方案的复杂度就失控了。进程要起多少个断流之后谁负责拉起各路流的元信息怎么汇聚到一处转封装参数怎么共用?这些问题在shell层几乎无解。EasyLive改走FFmpeg的libavformat、libavcodec接口把拉流端(Input)、推流端(Output)都抽象成可以动态创建和销毁的上下文。它的基本模型是:一路RTSP进来对应一个AVFormatContext;这个Context交给一个会话(Session)管理;Session的职责是确定这一路流要走什么内部逻辑——是原样透传还是需要转码最终发到哪个输出节点。输出节点同样是一个AVFormatContext调avformat_write_header之后喂AVPacket进去就行。这样做的好处在于:实时流的中断恢复、多路流的复用、码率的协商都能写进同一个进程的逻辑里而不是靠外部进程守护去保活。实际线上场景里十几路摄像头同时掉线再同时恢复是大概率事件如果你用命令行方案那一下就要拉起十几条ffmpeg进程反复重启的过程里推流地址会抖动丢流是必然的。2.2 协议组合怎么定RTSP in、RTMP out是最常见的一对EasyLive核心支持的输入协议是RTSP输出协议的优先级是RTMP SRT HTTP-FLV这个排序是有现实依据的。绝大部分IPC(网络摄像头)出厂就带RTSP服务;而你要把视频送出去无论是推到某个直播平台还是自建流媒体服务RTMP依然是兼容性最好的推流协议。SRT更多是用于公网弱网传输HTTP-FLV一般需要配合WebSocket分发给浏览器端播放器。如果你要对接的不是腾讯云、某直播平台这类常规RTMP收流端而是内部自建的流媒体服务那EasyLive的输出协议可以在代码里直接改:// 输出端URL的协议由URL前缀决定框架本身不限定死协议 AVFormatContext *out_ctx NULL; const char *out_url rtmp://192.168.1.50:1935/live/stream_01; int ret avformat_alloc_output_context2(out_ctx, NULL, flv, out_url); if (ret 0) { // avformat_alloc_output_context2 失败多数是因为找不到对应的muxer // flv muxer是RTMP推流的默认封装在FFmpeg编译时必须启用 av_log(NULL, AV_LOG_ERROR, alloc output context failed: %s\n, av_err2str(ret)); return ret; }这段代码的作用是把输出上下文绑定到RTMP推流地址上。值得说明的是第二个参数传的是NULL这意味着让FFmpeg根据URL后缀去推断封装格式——rtmp://前缀会被判断为flv封装这是可行的;但如果你是推rtp://或者udp://就必须显式指定对应的muxer。一般做汇聚转发推流目标多数是RTMP服务器用flv muxer不会错。参数live/stream_01是RTMP的app和流名这个路径要和接收端约定一致否则会握手成功但推不上流。2.3 转封装链路怎么组织不转码、只换壳EasyLive默认不做转码这一点一定要先理清楚。它的默认行为是拉流源什么样转发出去就什么样——编码格式、分辨率、帧率都不动只是在容器层面做转换(比如把RTSP里的H.264裸流包一层FLV的tag再推出去)。这个不转码的设计是刻意的:转码是要吃CPU和算力的一路1080p的H.264转码就要占掉一个较高配的单核如果想汇聚几十路服务器成本立刻失控;而只做转封装的话一路流的CPU开销几乎可以忽略EasyLive真正做的是一层流代理(Stream Proxy)。那什么时候必须让EasyLive转码呢?一种情况是输出端不认H.264比如某个老平台只吃MPEG4;另一种情况是输入源码率过高、输出端带宽不够。这时需要在Session的初始化逻辑里插入avcodec_find_encoder和转码循环:// 需要转码时的Encoder初始化这里以H.264为例 const AVCodec *encoder avcodec_find_encoder(AV_CODEC_ID_H264); if (!encoder) { av_log(NULL, AV_LOG_ERROR, H.264 encoder not found, check your ffmpeg configure\n); return -1; } AVCodecContext *enc_ctx avcodec_alloc_context3(encoder); enc_ctx-width 1280; enc_ctx-height 720; enc_ctx-time_base (AVRational){1, 25}; enc_ctx-framerate (AVRational){25, 1}; enc_ctx-pix_fmt AV_PIX_FMT_YUV420P; enc_ctx-bit_rate 1500 * 1000; // 目标码率 1.5Mbps // 打开编码器 int ret avcodec_open2(enc_ctx, encoder, NULL); if (ret 0) { av_log(NULL, AV_LOG_ERROR, open encoder failed: %s\n, av_err2str(ret)); return -1; }注意这段代码里显式指定了分辨率、帧率和码率在实际业务里这些值应该从配置中心或Session创建参数里读取而不是硬编码。转码模式下从输入读到的AVPacket要先经avcodec_send_packet送进解码器再从avcodec_receive_frame取出AVFrame做必要的sws_scale像素格式转换最后喂给编码器输出新AVPacket。这个过程在EasyLive里有独立的转码线程避免阻塞拉流线程。3. 核心模块拆解会话管理、拉流循环与推流队列3.1 Session生命周期从创建到销毁一个流一份上下文EasyLive的架构核心是Session对象。每接一个流任务就创建一个Session;流结束或者对端断开Session销毁。Session内部维护一个输入侧AVFormatContext、一个输出侧AVFormatContext以及一条传递AVPacket的队列。设计上刻意让每个Session独占两个线程——一个负责从输入读取一个负责向输出写入。为什么这么设计?如果只用一个线程既读又写那么网络抖动时输入阻塞会直接卡死输出导致对端掉线。拆成双线程之后中间靠一个有界队列解耦。读线程把拉到的AVPacket塞进队列即刻返回写线程从队列里取包往RTMP推。队列长度可以配默认是300个包。注意这个队列是AVPacket的指针队列不是数据拷贝;每塞一个包需要手动av_packet_ref一次取出消费完再av_packet_unref释放这个引用计数的逻辑绕不开。// 读线程从RTSP源拉流并压入中间队列 while (!session-stop_flag) { AVPacket *pkt av_packet_alloc(); int ret av_read_frame(session-input_ctx, pkt); if (ret 0) { // AVERROR_EOF 代表输入源正常关闭 // 其他错误要区分是临时网络错误还是输入源彻底断开 if (ret AVERROR_EOF) { av_log(NULL, AV_LOG_INFO, input stream EOF, session will be closed\n); break; } else if (is_transient_network_error(ret)) { av_usleep(500 * 1000); // 网络抖动让出500ms再重试 continue; } else { break; } } // 压入队列这里做一次引用计数写完线程负责释放 ret av_packet_ref(session-queue_tail_pkt, pkt); if (ret 0) { av_packet_free(pkt); continue; } push_to_queue(session-pkt_queue, pkt); av_packet_free(pkt); // 原始引用释放 }这里is_transient_network_error是一个需要你自己实现的函数用来判断RTSP层的错误是属于可重试的临时故障还是结构性断开。我一般会在里面判断AVERROR(ETIMEDOUT)、AVERROR(ECONNRESET)这类errno以及FFmpeg自定义的AVERROR_UNKNOWN。关键细节是:出错后不要立刻重读先sleep几百毫秒否则RTSP服务器会认为你是风暴请求直接把连接拉黑。3.2 输出侧队列为什么漫无边际地interleave会拖垮内存推流侧和拉流侧有个不对称的地方:输入读取是拉模式读到多少算多少;输出写入是推模式对端慢了你就得等。RTMP服务器一般允许客户端缓冲几秒数据但如果你的写线程一直写不进去(对端网络拥塞)av_interleaved_write_frame会内部排队内存占用会悄悄涨上去。EasyLive的处理办法是在写线程里加了水位线判断:// 写线程从队列取包并推给RTMP while (!session-stop_flag) { AVPacket *pkt take_from_queue(session-pkt_queue); if (!pkt) { av_usleep(1000); // 空队列让出1ms continue; } // 检查队列水位如果积压超过阈值直接丢最老的包 int queue_depth get_queue_size(session-pkt_queue); if (queue_depth session-max_queue_depth session-drop_policy DROP_OLDEST) { av_log(NULL, AV_LOG_WARNING, queue depth %d exceeds limit %d, dropping oldest packet\n, queue_depth, session-max_queue_depth); av_packet_free(pkt); continue; } // 重设时间戳基准RTSP的时间基和RTMP/flv的时间基不一样 if (session-do_timestamp_convert) { pkt-pts av_rescale_q(pkt-pts, session-input_ctx-streams[pkt-stream_index]-time_base, session-output_ctx-streams[0]-time_base); pkt-dts av_rescale_q(pkt-dts, session-input_ctx-streams[pkt-stream_index]-time_base, session-output_ctx-streams[0]-time_base); pkt-duration av_rescale_q(pkt-duration, session-input_ctx-streams[pkt-stream_index]-time_base, session-output_ctx-streams[0]-time_base); } // 写入RTMP int ret av_interleaved_write_frame(session-output_ctx, pkt); if (ret 0) { av_log(NULL, AV_LOG_ERROR, write frame failed: %s\n, av_err2str(ret)); // 写失败要区分是网络断还是格式错误网络断就触发重连逻辑 } av_packet_free(pkt); }这个丢弃策略值得多说几句。视频流的GOP(关键帧间隔)如果设定为2秒那么中间B帧P帧丢几个播放端最多花屏一下;但如果丢到了关键帧播放端可能要等下一个关键帧才能恢复。所以更精细的做法是永远不丢AV_PKT_FLAG_KEY的包实在要丢也只丢非关键帧。这个判断在EasyLive里是通过pkt-flags里的AV_PKT_FLAG_KEY做的代码里加一行if (pkt-flags AV_PKT_FLAG_KEY) skip_drop就行。3.3 断流重连重试逻辑与指数退避的配合RTSP摄像头不是永远稳定的无线网桥、弱电供电、NVR并发拉流超过上限都会导致RTSP直接断开。EasyLive的重连策略大体是这样:第一次断流立即重试;连续失败3次后退避间隔翻倍(1s→2s→4s→8s→最大30s);重连超过设置的上限(默认600次或者持续2小时)就不再自动拉起等上层手动干预。重连时最需要小心的不是重连本身而是重连后的上下文复位。很多人在重连时只重新调了avformat_open_input忘记旧AVFormatContext还挂着旧流的stream元数据。如果你复用同一个Context新流的stream index会从旧Context的尾部继续追加导致输出侧映射错乱。我见过最典型的翻车是:第一次连接拉回来一路H.264断开重连后摄像头被切换成了H.265编码模式如果不重建Contextavformat_write_header依旧按H.264写FLV的extradata头推出去的流播放器根本解不出来。// 重连逻辑必须整体释放旧的输入上下文 void reconnect_session(Session *session) { // 先断开旧的输入 if (session-input_ctx) { avformat_close_input(session-input_ctx); } // 重新创建新的AVFormatContext AVDictionary *opts NULL; av_dict_set(opts, rtsp_transport, tcp, 0); av_dict_set(opts, stimeout, 5000000, 0); // 5秒无数据判定超时 av_dict_set(opts, max_delay, 500000, 0); // 500ms延迟容忍 const char *url session-rtsp_url; AVFormatContext *new_ctx avformat_alloc_context(); int ret avformat_open_input(new_ctx, url, NULL, opts); if (ret 0) { av_log(NULL, AV_LOG_ERROR, reconnect failed: %s\n, av_err2str(ret)); av_dict_free(opts); schedule_next_reconnect(session); return; } // 重连成功读取流信息和参数注意流信息必须重新探测 ret avformat_find_stream_info(new_ctx, NULL); if (ret 0) { avformat_close_input(new_ctx); schedule_next_reconnect(session); return; } // 用新Context替换旧的让写线程后续读到的是新流 session-input_ctx new_ctx; session-reconnect_count 0; av_dict_free(opts); }rtsp_transporttcp这个参数在设备侧尤其重要。RTSP默认支持UDP传输但如果摄像头和服务器之间隔了NAT或者防火墙UDP会丢得妈都不认。强制走TCP丢包问题就变成了延迟问题,在转发网关场景里这是可接受的代价。stimeout5000000的单位是微秒表示5秒内收不到任何RTSP数据包就判定超时断开。这个值不能太大否则断线后要等很久才能进重连逻辑;也不能太小否则高负载时稍微卡顿一下就被误判超时。4. 部署与避坑编译选项、参数调优与四个高频翻车点4.1 编译FFmpeg时最容易踩的坑enable-librtmp 与 enable-opensslEasyLive依赖FFmpeg但FFmpeg的编译配置是有讲究的。如果你用系统自带的ffmpeg-dev包,那RTSP大概率是能用的但RTMP推流不一定。编译FFmpeg时最常见的错误是缺少--enable-librtmp这会导致rtmp://协议不可用程序运行时直接报Protocol not found。推荐的最小编译配置是这样的:./configure --prefix/usr/local/ffmpeg \ --enable-gpl \ --enable-nonfree \ --enable-libx264 \ --enable-librtmp \ --enable-openssl \ --enable-libmp3lame \ --disable-debug \ --disable-doc \ --disable-shared \ --enable-static make -j$(nproc) make install--enable-nonfree这块很多第一次编译的人会困惑以为非免费就违规了其实它对应的是FFmpeg里那些不兼容GPL/LGPL的组件比如librtmp它的许可证和GPL冲突必须显式打开。不编openssl的话RTMPS(rtmp://的加密版本)完全不可用虽然RTMPS用得不算多但一旦对端要求必开你就得重新编译。这里有个系统级的建议:不要尝试用系统自带的ffmpeg替换你编译出来的版本安装到独立前缀/usr/local/ffmpeg项目里通过pkg-config指定这个路径。4.2 系统参数文件描述符与vmem是汇聚型服务的基础EasyLive是单进程多线程架构但这不代表你可以忽略操作系统级限制。每路RTSP输入至少占用一个TCP socket(如果用TCP传输)和一个UDP端口;每路RTMP输出再占用一个TCP socket。所以如果你同时汇聚20路光socket至少30-40个加上日志文件句柄、配置读取的文件句柄系统默认的1024文件描述符很快耗尽。# 查看当前限制 ulimit -n # 临时提高会话级生效 ulimit -n 65535 # 永久修改写到limits.conf cat /etc/security/limits.conf EOF * soft nofile 65535 * hard nofile 65535 EOF还有一个必须提的是虚拟内存ulimit -v。FFmpeg在解码H.264时即使不做转码也要为parse和附加数据分配不少内存;如果虚拟内存限制太死FFmpeg内部的av_malloc会失败表现是程序不报错但流一直不出画面。我排查过几次这种玄学问题最后都是ulimit -v导致malloc失败FFmpeg的很多内部函数拿不到大块连续内存就静默返回非常难定位。4.3 避坑记录1:推流地址带错app名握手成功却没数据现象RTMP推流时avformat_open_output成功、avformat_write_header返回值正常但接收端播放器黑屏无数据。原因RTMP的URL结构是rtmp://ip:port/app/stream_name其中app必须和接收端的ApplicationMatch。比如自建的Nginx-RTMP默认app是live但如果你的Nginx配置里写的是application myapp你推rtmp://ip:1935/live/stream握手能成功但服务器找不到对应的Application会把连接静默挂起。解决先在同机用ffprobe或ffplay验证接收端能力确认app名称完全匹配。更稳妥的做法是让团队里维护流媒体服务的人直接把app和stream_name的命名规范写进配置管理库不靠口头约定。4.4 避坑记录2:摄像头双码流没规划主码流推出去卡顿现象同一路摄像机,拉流能通、转发也能通但多路转发时CPU不高、内存不高、带宽也够接收端就是频繁卡顿。原因绝大多数IPC默认主码流是1080p/4K高码率子码流是720p或CIF低码率。EasyLive按RTSP URL拉流如果URL里没有指定编码通道默认拉的是主码流。4K主码流即使不做转码单路也要占8-12Mbps带宽10路就是100Mbps交换机或服务器网卡跑满后自然卡顿。解决RTSP URL里明确指定子码流通道。海康的URL一般是rtsp://user:passip:554/Streaming/Channels/102其中102代表通道1的子码流;大华一般是rtsp://user:passip:554/cam/realmonitor?channel1subtype1。在EasyLive的流配置里给不同场景预设两套URL汇聚预览用子码流录像用主码流。4.5 避坑记录3:时间戳没换算播放器进度条直接抽风现象推流到接收端后播个几分钟播放器的进度条突然跳变或者画面不卡但时间疯狂往前飞。原因RTSP的RTP时间戳基准和FLV的毫秒时间戳不是一回事。RTP的时间戳通常基于90kHz时钟而FLV要求是毫秒。如果直接拿RTP的pts塞到FLV封装里播放器会按毫秒去解析一个90kHz的值时间轴完全错乱。EasyLive里的av_rescale_q就是干这个事的。解决写线程里统一用av_rescale_q把pts和dts从输入流的时间基换算到输出流的时间基。换算公式不用记直接用代码里演示的那套。但要提醒一点:如果输入流的时间基本来就接近毫秒(有些摄像头厂商在SDP里给出的时间基不是标准90k)强行换算反而会出错。所以这个换算逻辑在EasyLive里最好做成可配置开关只有在异常时才启用。4.6 避坑记录4:内存只涨不降最后被OOM Killer杀掉现象进程跑两三天后内存占用从几百M涨到几个G最后直接被系统OOM killer杀掉重启。原因绝大多数情况是AVPacket的引用计数没有正确释放。FFmpeg的AVPacket默认有引用计数的容器管理av_packet_free只释放引用容器的内存真正包含帧数据的内存只有在ref count归零时才释放。如果代码里av_packet_ref一次却没有对应的av_packet_unref数据内存永远不会释放。解决设定铁律——所有进队的包必须av_packet_ref一次消费完统一av_packet_free;没有进队的包用完立刻av_packet_free。另外EasyLive里设置的队列深度上限也是防止消费端故障时包在队列里积压。每加一个中间层都要重新审计引用计数的配对情况这是FFmpeg开发最让人头疼又最不能省的事。5. 进阶用法把EasyLive的会话信息暴露成HTTP接口做黑匣子式可视化管理很多用EasyLive的人把它当纯后台进程用日志打到文件里出了问题再翻日志效率很低。如果你的服务端接了十几二十路流每次都登服务器翻日志不现实。我的习惯是给EasyLive加一层HTTP管理接口——不引额外的Web框架就基于libmicrohttpd起一个轻量HTTP服务把每个Session的状态、推拉流的帧率、码率、队列深度暴露成JSON。这样外部的监控系统可以直接curl拿到结构化数据不用SSH上台机器。接口设计可以做成两页:/api/status返回全局汇总/api/session/{id}返回单路详情。拿/api/status来说数据直接遍历Session管理器:// 伪代码: 遍历所有Session聚合出全局状态 void build_status_json(cJSON *root) { cJSON *sessions cJSON_CreateArray(); for (int i 0; i session_manager-session_count; i) { Session *s session_manager-sessions[i]; cJSON *item cJSON_CreateObject(); cJSON_AddStringToObject(item, stream_id, s-stream_id); cJSON_AddStringToObject(item, input_url, s-rtsp_url); cJSON_AddStringToObject(item, output_url, s-rtmp_out_url); cJSON_AddNumberToObject(item, fps, get_realtime_fps(s)); cJSON_AddNumberToObject(item, bitrate_kbps, get_realtime_bitrate_kbps(s)); cJSON_AddNumberToObject(item, queue_depth, get_queue_size(s-pkt_queue)); cJSON_AddNumberToObject(item, lost_packets, s-stat_lost_packets); cJSON_AddStringToObject(item, state, session_state_to_str(s-state)); cJSON_AddItemToArray(sessions, item); } cJSON_AddItemToObject(root, sessions, sessions); }这层管理接口的价值不只是看状态它还能当控制面用。比如我可以从接口侧下发一个POST /api/session/{id}/reconnect的命令让某一路有问题的流不经过重启进程就单独重新拉流;甚至可以加一个POST /api/session/{id}/switch把输出目标动态切到备份的RTMP地址。这些操作都是在线生效的不用停进程。自从我把这套管理接口加上之后整个运维逻辑从登服务器翻日志变成了看面板点按钮省心太多了。关于验证这件事我每次改完EasyLive的代码都会先起一路RTSP源用ffmpeg -re -i sample.mp4 -f rtsp rtsp://127.0.0.1:8554/test来模拟摄像头(注意不能用本机已经占用的RTSP端口)再把EasyLive的输出推到本地起的rtmp://127.0.0.1:1935/app/stream。用ffplay同时看两台服务是否连通:# 验证拉流源是否正常 ffprobe -rtsp_transport tcp rtsp://127.0.0.1:8554/test # 验证推流端是否正常 ffplay -rtsp_transport tcp rtmp://127.0.0.1:1935/app/stream这种本地回环验证的好处是能把网络问题和代码问题彻底分开:如果回环能通、换成公网摄像头不通那问题出在防火墙或者RTSP服务端不是EasyLive的逻辑。从那以后我每次改完代码都强制走一遍本地模拟源-EasyLive-本地RTMP-ffplay这条路再也不会出现改完代码直接上生产、翻车之后再灰头土脸回滚的情况。这个习惯持续下来我几乎没有再因为这个项目本身的功能问题加班过希望这个思路也能帮到你。本文还有配套的精品资源点击获取