Table of Contents
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
事情的起因
北京冬奥会前夕,zlmediakit的一位用户完成了iptv系统的迁移; 由于zlmediakit对hls的支持比较完善,支持包括鉴权、统计、溯源等独家特性,所以他把之前的老系统都迁移到zlmediakit上了。
但是很不幸,在冬奥会开幕式当天,zlmediakit并没有承受起考验,当hls并发数达到3000左右时,zlmediakit线程负载接近100%,延时非常高,整个服务器基本不可用:
思考
zlmediakit定位是一个通用的流媒体服务器,主要精力聚焦在rtsp/rtmp等协议,对hls的优化并不够重视,hls之前在zlmediakit里面实现方式跟http文件服务器实现方式基本一致,都是通过直接读取文件的方式提供下载。所以当hls播放数比较高时,每个用户播放都需要重新从磁盘读取一遍文件,这时文件io承压,由于磁盘慢速度的特性,不能承载太高的并发数。
有些朋友可能会问,如果用内存虚拟磁盘能不能提高性能?答案是能,但是由于内存拷贝带宽也存在上限,所以就算hls文件都放在内存目录,每次读取文件也会存在多次memcopy,性能并不能有太大的飞跃。前面冬奥会直播事故那个案例,就是把hls文件放在内存目录,但是也就能承载2000+并发而已。
歧途: sendfile
为了解决hls并发瓶颈这个问题,我首先思考到的是sendfile方案。我们知道,nginx作为http服务器的标杆,就支持sendfile这个特性。很早之前,我就听说过sendfile多牛逼,它支持直接把文件发送到socket fd;而不用通过用户态和内核态的内存互相拷贝,可以大幅提高文件发送的性能。
我们查看sendfile的资料,有如下介绍:
于是,在事故反馈当日,2022年春节期间的某天深夜,我在严寒之下光着膀子在zlmediakit中把sendfile特性实现了一遍:

实现的代码如下:
//HttpFileBody.cpp
int HttpFileBody::sendFile(int fd) {
#if defined(__linux__) || defined(__linux)
off_t off = _file_offset;
return sendfile(fd, fileno(_fp.get()), &off, _max_size);
#else
return -1;
#endif
//HttpSession.cpp
void HttpSession::sendResponse(int code,
bool bClose,
const char *pcContentType,
const HttpSession::KeyValue &header,
const HttpBody::Ptr &body,
bool no_content_length ){
//省略大量代码
if (typeid(*this) == typeid(HttpSession) && !body->sendFile(getSock()->rawFD())) {
//http支持sendfile优化
return;
}
GET_CONFIG(uint32_t, sendBufSize, Http::kSendBufSize);
if (body->remainSize() > sendBufSize) {
//在非https的情况下,通过sendfile优化文件发送性能
setSocketFlags();
}
//发送http body
AsyncSenderData::Ptr data = std::make_shared<AsyncSenderData>(shared_from_this(), body, bClose);
getSock()->setOnFlush([data]() {
return AsyncSender::onSocketFlushed(data);
});
AsyncSender::onSocketFlushed(data);
}
由于sendfile只能直接发送文件明文内容,所以并不适用于需要文件加密的https场景;这个优化,https是无法开启的;很遗憾,这次hls事故中,用户恰恰用的就是https-hls。所以本次优化并没起到实质作用(https时关闭sendfile特性是在用户反馈tls解析异常才加上的)。
优化之旅一:共享mmap
很早之前,zlmediakit已经支持mmap方式发送文件了,但是在本次hls直播事故中,并没有发挥太大的作用,原因有以下几点:
-
1.每个hls播放器访问的ts文件都是独立的,每访问一次都需要建立一次mmap映射,这样导致其实每次都需要内存从文件加载一次文件到内存,并没有减少磁盘io压力。
-
2.mmap映射次数太多,导致内存不足,mmap映射失败,则会回退为fread方式。
-
3.由于hls m3u8索引文件是会一直覆盖重写的,而mmap在文件长度发送变化时,会触发SIGBUS的错误,之前为了修复这个bug,在访问m3u8文件时,zlmediakit会强制采用fread方案。
于是在sendfile优化方案失败时,我想到了共享mmap方案,其优化思路如下:
共享mmap方案主要解决以下几个问题:
-
防止文件多次mmap时被多次加载到内存,降低文件io压力。
-
防止mmap次数太多,导致mmap失败回退到fread方式。
-
mmap映射内存在http明文传输情况下,直接写socket时不用经过内核用户态间的互相拷贝,可以降低内存带宽压力。
于是大概在几天后,我新增了该特性:
实现代码逻辑其实比较简单,同时也比较巧妙,通过弱指针全局记录mmap实例,在无任何访问时,mmap自动回收,其代码如下:
static std::shared_ptr<char> getSharedMmap(const string &file_path, int64_t &file_size) {
{
lock_guard<mutex> lck(s_mtx);
auto it = s_shared_mmap.find(file_path);
if (it != s_shared_mmap.end()) {
auto ret = std::get<2>(it->second).lock();
if (ret) {
//命中mmap缓存
file_size = std::get<1>(it->second);
return ret;
}
}
}
//打开文件
std::shared_ptr<FILE> fp(fopen(file_path.data(), "rb"), [](FILE *fp) {
if (fp) {
fclose(fp);
}
});
if (!fp) {
//文件不存在
file_size = -1;
return nullptr;
}
//获取文件大小
file_size = File::fileSize(fp.get());
int fd = fileno(fp.get());
if (fd < 0) {
WarnL << "fileno failed:" << get_uv_errmsg(false);
return nullptr;
}
auto ptr = (char *)mmap(NULL, file_size, PROT_READ, MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) {
WarnL << "mmap " << file_path << " failed:" << get_uv_errmsg(false);
return nullptr;
}
std::shared_ptr<char> ret(ptr, [file_size, fp, file_path](char *ptr) {
munmap(ptr, file_size);
delSharedMmap(file_path, ptr);
});
{
lock_guard<mutex> lck(s_mtx);
s_shared_mmap[file_path] = std::make_tuple(ret.get(), file_size, ret);
}
return ret;
}
通过本次优化,zlmediakit的hls服务有比较大的性能提升,性能上限大概提升到了6K左右(压测途中还发现拉流压测客户端由于mktime函数导致的性能瓶颈问题,在此不展开描述),但是还是离预期有些差距:
小插曲: mktime函数导致拉流压测工具性能受限
优化之旅二:去除http cookie互斥锁
在开启共享mmap后,发现性能上升到6K并发时,还是上不去;于是我登录服务器使用gdb -p调试进程,通过info threads 查看线程情况,发现大量线程处于阻塞状态,这也就是为什么zlmediakit占用cpu不高,但是并发却上不去的原因:
为什么这么多线程都处于互斥阻塞状态?zlmediakit在使用互斥锁时,还是比较注意缩小临界区的,一些复杂耗时的操作一般都会放在临界区之外;经过一番思索,我才恍然大悟,原因是:
压测客户端由于是单进程,共享同一份hls cookie,在访问zlmediakit时,这些分布在不同线程的请求,其cookie都相同,导致所有线程同时大规模操作同一个cookie,而操作cookie是要加锁的,于是这些线程疯狂的同时进行锁竞争,虽然不会死锁,但是会花费大量的时间用在锁等待上,导致整体性能降低。
虽然在真实使用场景下,用户cookie并不一致,这种几千用户同时访问同一个cookie的情况并不会存在,但是为了考虑不影响hls性能压测,也为了杜绝一切隐患,针对这个问题,我于是对http/hls的cookie机制进行了修改,在操作cookie时,不再上锁:
之前对cookie上锁属于过度设计,当时目的主要是为了实现在cookie上随意挂载数据。
优化之旅三:hls m3u8文件内存化
经过上面两次优化,zlmediakit的hls并发能力可以达到8K了,但是当hls播放器个数达到在8K 左右时,zlmediakit的ts切片下载开始超时,可见系统还是存在性能瓶颈,联想到在优化cookie互斥锁时,有线程处于该状态:
所以我严重怀疑原因是m3u8文件不能使用mmap优化(而是采用fread方式)导致的文件io性能瓶颈问题,后面通过查看函数调用栈发现,果然是这个原因。
由于m3u8是易变的,使用mmap映射时,如果文件长度发生变化,会导致触发SIGBUS的信号,查看多方资料,此问题无解。所以最后只剩下通过m3u8文件内存化来解决,于是我修好了m3u8文件的http下载方式,改成直接从内存获取:
结果:性能爆炸
通过上述总共3大优化,我们在压测zlmediakit的hls性能时,随着一点一点增加并发量,发现zlmediakit总是能运行的非常健康,在并发量从10K慢慢增加到30K时,并不会影响ffplay播放的流畅性和效果,以下是压测数据:
压测16K http-hls播放器时,流量大概7.5Gb/s: (大概需要32K端口,由于我测试机端口不足,只能最大压测到这个数据)
后面用户再压测了30k https-hls播放器:
后记:用户切生产环境
在完成hls性能优化后,该用户把所有北美节点的hls流量切到了zlmediakit,
状况又起:
今天该用户又反馈给我说zlmediakit的内存占用非常高,在30K hls并发时,内存占用30+GB:
但是用zlmediakit的getThreadsLoad接口查看,却发现负载很低:
同时使用zlmediakit的getStatistic接口查看,发现BufferList对象个数很高,初步怀疑是由于网络带宽不足导致发送拥塞,内存暴涨,通过询问得知,公网hls访问,确实存在ts文件下载缓慢的问题:
同时让他通过局域网测试ts下载,却发现非常快:
后来通过计算,发现确实由于网络带宽瓶颈每个用户积压一个Buffer包,而每个Buffer包用户设置的为1MB,这样算下来,30K用户,确实会积压30GB的发送缓存:
结论
通过上面的经历,我们发现zlmediakit已经足以支撑30K/50Gb级别的https-hls并发能力, 理论上,http-hls相比https-hls要少1次内存拷贝,和1次加密,性能应该要好很多;那么zlmediakit的性能上限在哪里?天知道!毕竟,我已经没有这么豪华的配置供我压测了;在此,我们先立一个保守的flag吧:
单机 100K/100Gb级别 hls并发能力。
那其他协议呢? 我觉得应该不输hls。
测试文档
使用教程
- 代码依赖与版权声明
- 快速开始
- vcpkg安装zlmediakit
- 服务器的启动与关闭
- GB28181教程
- 推流播放测试
- RESTful 接口
- RESTful 接口 postman自动生成
- Web Hook 接口
- 配置文件详解
- 播放URL规则
- 按需拉流
- 按需推流
- 播放鉴权
- 推流鉴权
- 怎样创建直播流
- webrtc编译与使用
- webrtc信令交互格式
- webrtc重磅更新
- 怎么开启https相关功能
相关文档和资源
- zlmediakit独家特性
- zlmediakit的hls高性能之旅
- 高并发实现原理
- RTSP推流流程
- 流媒体相关技术介绍
- 直播延时的本质
- rtmp对H265/opus的支持
- ssl自签名证书测试
- 视频会议相关资源
























