搞监控后端的人,十有八九都遇到过这个需求:一台普通服务器,不装GPU,把园区里几十路RTSP摄像头拉进来,给上层的智能分析模块供帧。很多人第一反应是“这不就FFmpeg读流嘛,每路跑一条命令或者循环调API就行了”,结果路数一上5、6路,CPU直接爆表,延迟忽高忽低,甚至某一路断流之后整个进程都跟着遭殃。
这篇文章就是讲我在这类“纯CPU多路RTSP抓帧”需求里的完整设计和实现过程。重点不是怎么调用FFmpegAPI,而是把“多路”“高性能”“插件化”这三个词背后的瓶颈、取舍、调优和踩坑讲清楚。适合正在做摄像头接入、视频分析后端、或者想把一段“能跑的demo”改成“扛得住25路生产系统”的朋友参考。
1. 先拆瓶颈:RTSP多路抓帧为什么在CPU上跑不起来
1.1 每路一个进程的老方案为什么撑不到10路
最粗暴的做法,是给每一路摄像头起一个FFmpeg子进程,输出原始帧或者直接转成图片文件。这种方案在路数少的时候确实省事,但你别指望它能跑大路数。
每个FFmpeg子进程意味着:一份完整的解码器初始化开销、一套独立的地址空间和内存分配、一个进程调度实体。假设一路1080p的H.264流,解码器本身加上各种缓冲要占150~200MB内存,6路就是1GB以上。更麻烦的是进程切换开销和cache污染。多路视频流数据量大、访问模式又高度相似,多个进程同时跑,CPU的L2/L3缓存被反复冲刷,实际解码速度会肉眼可见地下降。
我最早用这个方案压测,4路还凑合,到第6路CPU已经90%以上,而且时不时出现某路卡顿。这时候你根本说不清是网络问题、解码器问题还是调度问题,因为整个链路全是黑的。
1.2 抓帧管线里每一步的时间究竟去哪了
与其说RTSP抓帧慢,不如说大多数人把时间浪费在几件“看不见”的事情上:
第一,重复的内存拷贝。每路视频从网络收包、RTP组帧、解码器输出AVFrame,到业务侧拿到可用的YUV数据,中间起码隔着两三次memcpy。25路1080p 30fps,每帧YUV420大小差不多3MB,每秒750帧,光拷贝就是2GB/s以上的带宽。内存带宽被白白吃掉了。
第二,线程和锁的失控。如果每路分配两个线程(一个拉流、一个解码),25路就是50个线程。再叠加全局队列的互斥锁、日志锁,大量CPU时间都在等锁和上下文切换,而不是在真正干活。
第三,不必要的颜色转换。很多人习惯把帧从YUV转成RGB再交给上层算法,但抓帧线程做转换是最亏的。1080p的YUV→RGB转换,单帧几毫秒,看起来不多,乘上25路30fps,再乘上多路同时触发,CPU占用立刻再涨一截。而这个转换很可能根本不是你的业务方需要的。
1.3 先搞清楚:FFmpeg本身不是瓶颈,用法才是
FFmpeg的软解能力没有很多人想的那么弱。一台普通X86服务器,单核软解1080p H.264,一帧差不多5~15ms,30fps每帧预算33ms,单线程完全来得及。也就是说,单单“解码”这个环节,只要路数不超过核数,理论上都能跑。
问题几乎都出在解码之外:线程模型、内存分配、队列设计、超时机制、重连策略。把瓶颈拆到这里,你就知道后面所有设计都该围绕什么展开了。我自己定的目标是:25路1080p H.264,纯CPU,每路20~30fps,端到端延迟控制在150ms以内,对外提供插件化接口。
2. 网络层切入:RTSP传输模式、超时与缓冲必须显式控制
2.1 TCP和UDP不是二选一,而是要按链路特征切换
RTSP的SETUP协商决定底层RTP走UDP还是TCP。默认很多实现偏爱UDP,因为延迟低、没有TCP队头阻塞问题。但跨网段、跨运营商、或者经过一层层代理的时候,UDP丢包会直接把画面打花,重传机制还不好做,这就要换成TCP模式。
我说的“按链路特征切换”不是让你在代码里hardcode,而是把传输模式做成每个摄像头源的配置项。FFmpeg这边控制起来很直接:
AVDictionary *opts = NULL; av_dict_set(&opts, "rtsp_transport", use_tcp ? "tcp" : "udp", 0); // stimeout 单位是微秒,5秒没有数据则报错返回 av_dict_set(&opts, "stimeout", "5000000", 0); // max_delay 控制接收缓冲的排队深度 av_dict_set(&opts, "max_delay", "300000", 0); // 关闭内部缓冲,尽快出帧 av_dict_set(&opts, "fflags", "nobuffer", 0); AVFormatContext *fmt = avformat_alloc_context(); int ret = avformat_open_input(&fmt, url, NULL, &opts); if (ret < 0) { // 记录错误日志,并进入重连流程 }这里有个很多人不知道的细节:max_delay默认值对于RTSP直播流来说是偏大的。它本意是让解复用器对抗网络抖动,但设置过大的代价是整个链路的延迟被线性放大。你从启动到出第一帧等了好几秒,不一定是对端慢,很可能是这个缓冲把包都“攒”住了。
2.2 RTP组帧的脏活累活,FFmpeg替你做了,但你要懂原理
抓帧插件用FFmpeg的RTSP解复用器,意味着RTP包的接收、排序、H.264分片重组(包括FU-A、STAP-A这些常见封装)都由内部完成。但你必须知道它背后是什么样,否则遇到“画面马赛克”“时间戳乱跳”“PTS负数”这类问题,你会完全无从下手。
H.264视频在RTP里有两个常用打包模式:单一NAL单元传输,和分片传输。一个几MB的I帧通常会拆成好几个RTP包,每组包里有个相同的RTP时间戳;标记位标记分片结束。解复用器要做的事就是按SSRC、时间戳和序列号把包重新拼成一个完整NAL单元,再送给解码器。FFmpeg做这件事很成熟,但它的代价是内存和排序缓冲,特别是丢包发生时,它必须等重传或者超时丢弃。
理解这点之后,你就该明白两个工程结论:第一,网络丢包直接导致RTP重排缓冲变大,max_delay设得太大,延迟就会失控;第二,排查花屏问题时,不要第一反应是“解码器坏了”,先看RTP层的丢包统计。
2.3 接收端socket缓冲区也值得调一调
UDP模式下,如果某个瞬间摄像头码率突增,或者业务端处理慢了,内核socket缓冲区不够会把RTP包直接丢掉。调大这个缓冲区几乎零成本,收益却很直接:
int fd = ...; // RTSP/RTP socket fd int rcvbuf = 4 * 1024 * 1024; setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));不过注意,setsockopt必须在FFmpeg内部创建socket之前介入会比较麻烦。更省事的做法是在系统层把net.core.rmem_max调大,然后让FFmpeg通过rw_timeout配合。实际项目里我用的是后者,成本低、维护简单。
3. 帧缓冲与零拷贝:内存池和环形队列是插件的心脏
3.1 别再自己memcpy一份数据了
抓帧模块输出AVFrame后,很多人习惯把frame->data[0]的数据拷到自己定义的buffer里,再转给上层算法。这个拷贝在单路上不明显,多路一放大就是灾难。
正确做法是直接引用AVFrame本身,增加引用计数,让上层业务在需要时持有同一个帧,处理完再释放。FFmpeg的av_frame_ref/av_frame_unref就是干这个的:
// 采集线程内:解码器拿到一帧后 AVFrame *deliver = av_frame_alloc(); ret = av_frame_ref(deliver, dec_frame); // 只增加引用计数,不拷贝数据 if (ret >= 0) { push_to_queue(stream, deliver); // 入队,队列负责释放 } else { av_frame_free(&deliver); }入队之后,采集线程可以立刻去读下一帧包,至于业务侧什么时候处理完,跟它没关系。这个模型就是典型的“生产者—消费者”加“零拷贝”。
3.2 对外暴露的帧结构,不要带FFmpeg类型
插件接口给上层业务时,我建议定义一个纯C的帧结构,不直接暴露AVFrame。这样业务方不用引入FFmpeg头文件,也不用关心FFmpeg版本升级带来的ABI变化。我常用的定义长这样:
typedef struct { uint8_t *data[4]; // YUV各通道指针 int linesize[4]; // 每行字节数 int width; int height; int pix_fmt; // AV_PIX_FMT_YUV420P 等 int64_t pts_us; // 解码时间戳,微秒 int64_t recv_us; // 到达墙钟时间,微秒 int keyframe; // 是否为关键帧 uint64_t frame_id; // 帧序号,排查问题用 } rtsp_frame_t; typedef int (*frame_cb)(void *user, const rtsp_frame_t *frame); typedef void (*status_cb)(void *user, int evt, const char *detail);你可以管这个叫“帧的对外契约”。接口的稳定性和内部实现的自由度都保住了。
3.3 环形队列要避免锁竞争和伪共享
队列设计是整个插件最容易翻车的地方。如果你用std::queue加std::mutex,25路同时高频入队出队,互斥锁的争抢会非常可观。
我在采集线程到业务线程之间用的是一对一的无锁SPSC环形队列。核心思路是:生产者和消费者各自维护自己的索引,生产者只改尾指针,消费者只改头指针,两个索引用原子变量分开存放,甚至要做缓存行对齐,避免伪共享。
struct spsc_queue { alignas(64) std::atomic<uint32_t> head; alignas(64) std::atomic<uint32_t> tail; // 数据槽位... };24路就是24个独立队列,每一路一个消费者线程,完全不需要跨路加锁。业务端多算法同时订阅怎么办?那就做多播:采集线程把同一帧的引用计数加好,分别往多个队列各推一份,每个队列对应一路消费端。宁可多做几次原子操作,也不要让一个全局锁成为瓶颈。
3.4 队列满了,丢掉最老帧而不是阻塞
队列容量始终是有限的。业务侧一旦处理变慢,比如算法做了耗时操作,入队就会堆积。这时候如果入队阻塞,采集线程会被拖死,整路延迟越拉越大,最终画面卡到没法看。
我在插件里的策略是“丢最老,保最新”。队列满时,把队尾最旧的一帧直接丢弃,新帧正常写入。这个策略在实时分析类业务里非常合理:算法永远处理最新画面,不会因为某一帧处理太久而积累起越来越大的延迟积压。顺带一提,丢帧率要做计数器上报,方便你事后判断是哪一路、哪个算法拖了后腿。
4. 软解调优与线程模型:多路场景下不要迷信多线程解码
4.1 解码上下文怎么配:每路单线程,反而更稳
给FFmpeg解码器开多线程,对1080p实时流来说收益很小,因为单帧解码本来就在几毫秒到十几毫秒这个量级。但多线程解码的代价非常实在:每个解码上下文会创建若干线程,25路如果每路开4线程,就是100个解码线程在抢CPU。调度开销、缓存竞争会让总吞吐不升反降。
我的配置很明确:
AVCodecContext *dec_ctx = avcodec_alloc_context3(codec); dec_ctx->thread_count = 1; // 每路单线程解码 dec_ctx->flags2 |= AV_CODEC_FLAG2_FAST; // 允许一些速度优先的优化 dec_ctx->skip_loop_filter = AVDISCARD_ALL; // 能省则省 dec_ctx->flags |= AV_CODEC_FLAG_LOW_DELAY; // 优先低延迟skip_loop_filter这个参数值得说一下。环路滤波本来是改善画质、减少块效应的,但对实时分析场景来说,它对算法识别结果的影响微乎其微,却能省下不少解码时间。监控抓帧场景里,我选择牺牲一点画质换取CPU。
4.2 每路一个工作线程的模型,为什么足够用了
最终实现里,我没有做一个复杂的共享线程池,而是简单直接:每路分配一个工作线程,在这个线程里完成“av_read_frame → avcodec_send_packet → avcodec_receive_frame → 入队分发”整个循环。
这样做有两个原因。第一,RTSP本身是带状态的长连接协议,一路线程独立控制,超时、重连、解码错误都只影响这一路,隔离性最好。第二,线程数本身可控:25路就是25个工作线程,加上几个后续分发线程,总量不大,完全在操作系统的承受范围内。
相比“每路一个拉流线程+一个解码线程+一个分发线程”的过度设计,合并成一个工作线程反而减少了线程切换和队列中转。你可能觉得“单线程听起来不够高性能”,但高性能恰恰是省掉多余动作的结果。
4.3 抽帧降CPU的大坑:不是把包丢了就行
算法侧往往只需要每秒钟处理几帧,比如5fps或者10fps。很多人在接入层做“抽帧”——隔几个包才送一次解码。这是个大坑,因为视频编码帧之间有强依赖关系,P帧必须参考前面的帧才能解。你丢了一个P帧,后面一连串P帧全部花掉,直到下一个IDR出现才能恢复。
我踩过这个坑之后的结论是:解码可以全量解,业务回调抽帧。也就是说,采集线程照样每帧都解,但传给业务侧时按照fps配置做等间隔采样。解码CPU省不了,但算法推理的CPU能省下来,这个架构才健康。
真要省解码CPU,唯一可靠的办法是修改源端的编码策略,让它降低帧率,而不是在接收端丢包。所以生产环境里,摄像头本身的帧率配置才是第一道闸门。
5. 会话状态机、断线重连与I帧等待:自愈能力不能少
5.1 RTSP连接不是开着就不管的
摄像头和网络设备都很容易出幺蛾子。网线松了、码率突增、设备重启、IP冲突,任何一股妖风都能让一路流断开。如果一个抓帧插件只是“能拉流”,那它顶多算个demo;真正的生产插件必须有一套完整的会话状态机和重连逻辑。
我维护的状态很简单:READY、PLAYING、RECONNECTING、STOPPED。收到视频帧且解码正常时处于PLAYING;超过配置的静默时间还没收到RTP包,就进入RECONNECTING;重连成功恢复PLAYING,失败则继续退避重试。
状态机的好处是:上层能清楚知道每一路吞吐是否健康,也能在界面上直观看到哪一路出问题了。
5.2 断线重连要指数退避,别把服务器打崩
摄像头重新上线也就几秒的事,但如果你用固定的1秒间隔重试,几十路同时断线再同时重连,RTSP的握手风暴会把对端设备直接打挂。我用的退避序列是1秒、2秒、4秒、8秒,最大到30秒封顶,一旦成功进入PLAYING就重置退避计数。
另外,重连之后最忌讳的是立刻把新数据往里送。摄像头重连后,大概率从当前实时位置开始推流,而这个位置不一定是一个IDR关键帧。如果把半路的数据丢给解码器,解码器会输出花屏直到下一个关键帧到来。
我的实现是维护一个waiting_for_keyframe标志:重连后所有包照样送解码器,但不对外回调帧,直到遇到pkt->flags & AV_PKT_FLAG_KEY才把标志位清除,开始正常投递。这样最坏情况下也就是黑屏一两秒(取决于GOP长度),但永远不会给算法喂花屏帧。
5.3 保活机制:定期发OPTIONS或者GET_PARAMETER
很多网络设备为了省资源,会回收空闲的TCP连接。RTSP的TCP模式挂在那很久不发包,中间路由器或摄像头自己就可能把会话断掉。解决办法是在会话空闲时定期发送OPTIONS或者GET_PARAMETER请求。
static void send_keepalive(rtsp_session_t *s) { av_opt_set(s->fmt_ctx, "rtsp_transport", "tcp", 0); // FFmpeg内部没有直接的保活API, // 这里通过向服务器发送RTSP请求维持会话 avformat_network_close(); avformat_network_open(); }严格说FFmpeg没有暴露通用的RTSP保活请求接口,我在工程里是通过后台线程周期性检查最近收包时间,配合重连机制来实现的。只要收包时间在阈值内,连接就是活的;一旦超时,自然进入重连流程,不一定非要发颗粒度很细的保活包。
6. 插件化接口设计:把采集内核与算法业务彻底解耦
6.1 为什么是库内插件,而不是独立服务
抓帧模块有两种落地形态:独立进程,或者作为库被业务进程加载。独立进程的好处是崩溃隔离、资源可控,但代价是跨进程帧传递的成本和复杂度——共享内存、进程同步、生命周期管理都是额外负担;帧数据每次还要经历一次跨进程拷贝,等于把前面零拷贝的努力全部抵消。
我最终选择的是“动态库插件”形态。抓帧内核编译成so,业务方通过头文件和回调接入。这样帧传递是纯内存内的引用计数,没有序列化、没有拷贝,调用链也短。
6.2 主控API要少而稳
插件对外的主控API,我认为越少越好,降低使用者的心智负担:
// 打开并启动一路源 rtsp_source_t *rtsp_source_open(const char *url, const rtsp_capture_options_t *opts, frame_cb on_frame, status_cb on_status, void *user); // 暂停/恢复/关闭 int rtsp_source_pause(rtsp_source_t *src); int rtsp_source_resume(rtsp_source_t *src); int rtsp_source_close(rtsp_source_t *src); // 多路管理 rtsp_capture_t *rtsp_capture_create(); int rtsp_capture_add(rtsp_capture_t *mgr, rtsp_source_t *src); int rtsp_capture_remove(rtsp_capture_t *mgr, rtsp_source_t *src);我强烈建议把配置项集中到一个rtsp_capture_options_t结构体里,包含传输模式、超时阈值、队列容量、抽帧fps、重试间隔这些。千万不要让API膨胀成每个参数一个setter,否则版本迭代的时候你会被兼容性拖死。
6.3 回调里的约束条件要写清楚
帧回调是高频调用,插件内部是拿不到业务侧的执行时间的。如果业务在回调里做了一次耗时的RGB转换、一次数据库写入或者一次HTTP请求,整个采集链路立刻被堵住,所有路数一起遭殃。
所以接口文档里必须明确写:回调是采集线程直接触发的,处理时间必须保持在亚毫秒级;任何耗时操作请先把帧数据引用到自己的线程,再在业务线程里处理。为了兜底,插件还在入队侧做了队列容量限制和丢帧保护,防止某个业务方拖垮全局。
6.4 状态回调和统计信息是排查问题的千里眼
除了帧数据,插件还需要向业务层同步“状态事件”和“统计信息”。状态事件包括连接断开、重连开始、重连成功、首帧到达、解码异常终止;统计信息包括当前fps、码率、解码CPU耗时、队列积压数、丢帧数。
这些信息平时看起来不起眼,一旦线上出了“某路画面卡顿”,你拿到的第一手判断依据全靠它们。我在插件里维护了一个环形统计区,线程安全地更新计数,业务侧随时可以拉取快照。
7. 压测数据与踩坑记录:从6路卡顿到25路平稳的过程
7.1 压测环境和数据对比
测试机是一台双路老服务器,具体型号就不说了,反正就是普通X86,逻辑核32个,内存32GB,千兆网卡。用FFmpeg在另一台机器上推了25路1080p H.264流,码率8Mbps,GOP长度50。
压测结果对比如下:
| 方案 | 路数 | CPU占用 | 内存占用 | 端到端延迟 | 丢帧 |
|---|---|---|---|---|---|
| 每路一个FFmpeg子进程 | 6 | 92% | 约1.2GB | >500ms | 严重 |
| FFmpeg API,未优化 | 12 | 88% | 约900MB | 260ms | 偶尔 |
| 最终插件版本 | 25 | 72% | 约1.1GB | <120ms | <0.5% |
数据本身受机器性能影响比较大,但趋势是明确的。主要的优化收益来自三块:去掉子进程、去掉多余拷贝、收敛线程数量和锁竞争。最终版本里解码耗时占据了绝大多CPU,说明瓶颈回到了“真正该花时间的地方”。
7.2 调试过程中让我印象深刻的三类坑
第一个坑是TCP模式下的延迟慢慢涨。现象是画面不卡,但总觉得“慢半拍”,过了几分钟延迟越来越明显。查下来发现是max_delay默认值太大,加上TCP本身的队头阻塞效应,数据在接收端排队时间越来越长。把值压到300ms级别之后,延迟立刻恢复稳定。
第二个坑是内存缓慢上涨。查引用计数发现,开发时有人在回调分支里只拷贝了frame结构体、没有调用释放函数,导致AVFrame引用计数永远不归零。排查手段就是靠前面提到的frame_id和统计计数,定位到漏释放的代码路径。
第三个坑是解码器在摄像头切换分辨率之后集体“放花屏”。摄像头运行一段时间后DP分辨率变了,FFmpeg解码器需要重新配置。捕获到AVERROR_INVALIDDATA或AVERROR_UNKNOWN之后,我没有简单重试,而是先avcodec_flush_buffers再重建解码上下文,同时把waiting_for_keyframe置位,画面恢复速度明显变好。
7.3 日志和监控是隐形性能杀手
多路抓帧线程都往std::cout或者printf打日志,会在不知不觉间吃掉大量CPU。原因不是打印本身,而是printf内部的锁竞争和IO调度。我踩过这个坑之后,把所有高频日志改成了批量环形缓冲,后台单独线程只做文本格式化。线上排查靠统计接口,不再靠打印流。
一句话总结调优经验:性能优化不是堆线程,而是减少无用功。每一次“省掉一次拷贝、省掉一次锁、省掉一个不必要线程”的决策,最终都会体现在路数上限上。
8. 什么时候该换方案:纯CPU方案的边界与扩容信号
CPU方案虽好,但不是万能的。如果你接到的是“64路4K”“全网分析并发需求巨大”“延迟低于30ms”这类需求,纯CPU解RTSP抓帧就会变得吃力。我的经验是:解码本身还能勉强扛,但整个服务器的功耗、散热、业务侧算法推理的CPU预算,都会亮红灯。
遇到这种情况,可以考虑两条路:一是引入支持QSV或NVDEC的硬件解码通道,把解码从CPU上挪走,但抓帧插件的外部接口保持不变,业务侧无感;二是直接让摄像头走私有SDK或者GB28181协议,绕开RTSP在某些场景下的低效。
我自己做这套插件最大的体会是:把“采集”和“业务”切成两个清晰的边界,收益远不止于性能。采集层专注做帧的实时性、稳定性、资源效率,业务层专注做自己的算法,两边互不拖累,各自可以独立演进。如果你也在做类似的监控接入平台,不妨从这个角度重新拆一拆自己的代码结构。