☰
基于FFmpeg库的RTSP汇聚转RTMP推流:EasyLive架构解析
2026/10/14 12:08:28 网站建设 项目流程

简介: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库,而不是直接命令行调ffmpeg

2.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编码模式,如果不重建Context,avformat_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_transport=tcp这个参数在设备侧尤其重要。RTSP默认支持UDP传输,但如果摄像头和服务器之间隔了NAT或者防火墙,UDP会丢得妈都不认。强制走TCP,丢包问题就变成了延迟问题,在转发网关场景里这是可接受的代价。stimeout=5000000的单位是微秒,表示5秒内收不到任何RTSP数据包就判定超时断开。这个值不能太大,否则断线后要等很久才能进重连逻辑;也不能太小,否则高负载时稍微卡顿一下就被误判超时。

4. 部署与避坑:编译选项、参数调优与四个高频翻车点

4.1 编译FFmpeg时最容易踩的坑:enable-librtmp 与 enable-openssl

EasyLive依赖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:pass@ip:554/Streaming/Channels/102,其中102代表通道1的子码流;大华一般是rtsp://user:pass@ip:554/cam/realmonitor?channel=1&subtype=1。在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"这条路,再也不会出现改完代码直接上生产、翻车之后再灰头土脸回滚的情况。这个习惯持续下来,我几乎没有再因为这个项目本身的功能问题加班过,希望这个思路也能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询