事情的起因
北京冬奥会前夕,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压力。
2.防止mmap次数太多,导致mmap失败回退到fread方式。
3.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。