事情的起因
北京冬奧會(huì)前夕,zlmediakit的一位用戶完成了iptv系統(tǒng)的遷移; 由于zlmediakit對(duì)hls的支持比較完善,支持包括鑒權(quán)、統(tǒng)計(jì)、溯源等獨(dú)家特性,所以他把之前的老系統(tǒng)都遷移到zlmediakit上了。
但是很不幸,在冬奧會(huì)開(kāi)幕式當(dāng)天,zlmediakit并沒(méi)有承受起考驗(yàn),當(dāng)hls并發(fā)數(shù)達(dá)到3000左右時(shí),zlmediakit線程負(fù)載接近100%,延時(shí)非常高,整個(gè)服務(wù)器基本不可用:

思考
zlmediakit定位是一個(gè)通用的流媒體服務(wù)器,主要精力聚焦在rtsp/rtmp等協(xié)議,對(duì)hls的優(yōu)化并不夠重視,hls之前在zlmediakit里面實(shí)現(xiàn)方式跟http文件服務(wù)器實(shí)現(xiàn)方式基本一致,都是通過(guò)直接讀取文件的方式提供下載。所以當(dāng)hls播放數(shù)比較高時(shí),每個(gè)用戶播放都需要重新從磁盤讀取一遍文件,這時(shí)文件io承壓,由于磁盤慢速度的特性,不能承載太高的并發(fā)數(shù)。
有些朋友可能會(huì)問(wèn),如果用內(nèi)存虛擬磁盤能不能提高性能?答案是能,但是由于內(nèi)存拷貝帶寬也存在上限,所以就算hls文件都放在內(nèi)存目錄,每次讀取文件也會(huì)存在多次memcopy,性能并不能有太大的飛躍。前面冬奧會(huì)直播事故那個(gè)案例,就是把hls文件放在內(nèi)存目錄,但是也就能承載2000+并發(fā)而已。
歧途: sendfile
為了解決hls并發(fā)瓶頸這個(gè)問(wèn)題,我首先思考到的是sendfile方案。我們知道,nginx作為http服務(wù)器的標(biāo)桿,就支持sendfile這個(gè)特性。很早之前,我就聽(tīng)說(shuō)過(guò)sendfile多牛逼,它支持直接把文件發(fā)送到socket fd;而不用通過(guò)用戶態(tài)和內(nèi)核態(tài)的內(nèi)存互相拷貝,可以大幅提高文件發(fā)送的性能。
我們查看sendfile的資料,有如下介紹:

于是,在事故反饋當(dāng)日,2022年春節(jié)期間的某天深夜,我在嚴(yán)寒之下光著膀子在zlmediakit中把sendfile特性實(shí)現(xiàn)了一遍:

實(shí)現(xiàn)的代碼如下:
//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優(yōu)化
return;
}
GET_CONFIG(uint32_t, sendBufSize, Http::kSendBufSize);
if (body->remainSize() > sendBufSize) {
//在非https的情況下,通過(guò)sendfile優(yōu)化文件發(fā)送性能
setSocketFlags();
}
//發(fā)送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只能直接發(fā)送文件明文內(nèi)容,所以并不適用于需要文件加密的https場(chǎng)景;這個(gè)優(yōu)化,https是無(wú)法開(kāi)啟的;很遺憾,這次hls事故中,用戶恰恰用的就是https-hls。所以本次優(yōu)化并沒(méi)起到實(shí)質(zhì)作用(https時(shí)關(guān)閉sendfile特性是在用戶反饋tls解析異常才加上的)。
優(yōu)化之旅一:共享mmap
很早之前,zlmediakit已經(jīng)支持mmap方式發(fā)送文件了,但是在本次hls直播事故中,并沒(méi)有發(fā)揮太大的作用,原因有以下幾點(diǎn):
1.每個(gè)hls播放器訪問(wèn)的ts文件都是獨(dú)立的,每訪問(wèn)一次都需要建立一次mmap映射,這樣導(dǎo)致其實(shí)每次都需要內(nèi)存從文件加載一次文件到內(nèi)存,并沒(méi)有減少磁盤io壓力。
2.mmap映射次數(shù)太多,導(dǎo)致內(nèi)存不足,mmap映射失敗,則會(huì)回退為fread方式。
3.由于hls m3u8索引文件是會(huì)一直覆蓋重寫的,而mmap在文件長(zhǎng)度發(fā)送變化時(shí),會(huì)觸發(fā)SIGBUS的錯(cuò)誤,之前為了修復(fù)這個(gè)bug,在訪問(wèn)m3u8文件時(shí),zlmediakit會(huì)強(qiáng)制采用fread方案。
于是在sendfile優(yōu)化方案失敗時(shí),我想到了共享mmap方案,其優(yōu)化思路如下:

共享mmap方案主要解決以下幾個(gè)問(wèn)題:
- 防止文件多次mmap時(shí)被多次加載到內(nèi)存,降低文件io壓力。
2.防止mmap次數(shù)太多,導(dǎo)致mmap失敗回退到fread方式。
3.mmap映射內(nèi)存在http明文傳輸情況下,直接寫socket時(shí)不用經(jīng)過(guò)內(nèi)核用戶態(tài)間的互相拷貝,可以降低內(nèi)存帶寬壓力。
于是大概在幾天后,我新增了該特性:

實(shí)現(xiàn)代碼邏輯其實(shí)比較簡(jiǎn)單,同時(shí)也比較巧妙,通過(guò)弱指針全局記錄mmap實(shí)例,在無(wú)任何訪問(wèn)時(shí),mmap自動(dòng)回收,其代碼如下:
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;
}
}
}
//打開(kāi)文件
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;
}
通過(guò)本次優(yōu)化,zlmediakit的hls服務(wù)有比較大的性能提升,性能上限大概提升到了6K左右(壓測(cè)途中還發(fā)現(xiàn)拉流壓測(cè)客戶端由于mktime函數(shù)導(dǎo)致的性能瓶頸問(wèn)題,在此不展開(kāi)描述),但是還是離預(yù)期有些差距:

小插曲: mktime函數(shù)導(dǎo)致拉流壓測(cè)工具性能受限
圖片.png
優(yōu)化之旅二:去除http cookie互斥鎖
在開(kāi)啟共享mmap后,發(fā)現(xiàn)性能上升到6K并發(fā)時(shí),還是上不去;于是我登錄服務(wù)器使用gdb -p調(diào)試進(jìn)程,通過(guò)info threads 查看線程情況,發(fā)現(xiàn)大量線程處于阻塞狀態(tài),這也就是為什么zlmediakit占用cpu不高,但是并發(fā)卻上不去的原因:

為什么這么多線程都處于互斥阻塞狀態(tài)?zlmediakit在使用互斥鎖時(shí),還是比較注意縮小臨界區(qū)的,一些復(fù)雜耗時(shí)的操作一般都會(huì)放在臨界區(qū)之外;經(jīng)過(guò)一番思索,我才恍然大悟,原因是:
壓測(cè)客戶端由于是單進(jìn)程,共享同一份hls cookie,在訪問(wèn)zlmediakit時(shí),這些分布在不同線程的請(qǐng)求,其cookie都相同,導(dǎo)致所有線程同時(shí)大規(guī)模操作同一個(gè)cookie,而操作cookie是要加鎖的,于是這些線程瘋狂的同時(shí)進(jìn)行鎖競(jìng)爭(zhēng),雖然不會(huì)死鎖,但是會(huì)花費(fèi)大量的時(shí)間用在鎖等待上,導(dǎo)致整體性能降低。
雖然在真實(shí)使用場(chǎng)景下,用戶cookie并不一致,這種幾千用戶同時(shí)訪問(wèn)同一個(gè)cookie的情況并不會(huì)存在,但是為了考慮不影響hls性能壓測(cè),也為了杜絕一切隱患,針對(duì)這個(gè)問(wèn)題,我于是對(duì)http/hls的cookie機(jī)制進(jìn)行了修改,在操作cookie時(shí),不再上鎖:


之前對(duì)cookie上鎖屬于過(guò)度設(shè)計(jì),當(dāng)時(shí)目的主要是為了實(shí)現(xiàn)在cookie上隨意掛載數(shù)據(jù)。
優(yōu)化之旅三:hls m3u8文件內(nèi)存化
經(jīng)過(guò)上面兩次優(yōu)化,zlmediakit的hls并發(fā)能力可以達(dá)到8K了,但是當(dāng)hls播放器個(gè)數(shù)達(dá)到在8K 左右時(shí),zlmediakit的ts切片下載開(kāi)始超時(shí),可見(jiàn)系統(tǒng)還是存在性能瓶頸,聯(lián)想到在優(yōu)化cookie互斥鎖時(shí),有線程處于該狀態(tài):

所以我嚴(yán)重懷疑原因是m3u8文件不能使用mmap優(yōu)化(而是采用fread方式)導(dǎo)致的文件io性能瓶頸問(wèn)題,后面通過(guò)查看函數(shù)調(diào)用棧發(fā)現(xiàn),果然是這個(gè)原因。
由于m3u8是易變的,使用mmap映射時(shí),如果文件長(zhǎng)度發(fā)生變化,會(huì)導(dǎo)致觸發(fā)SIGBUS的信號(hào),查看多方資料,此問(wèn)題無(wú)解。所以最后只剩下通過(guò)m3u8文件內(nèi)存化來(lái)解決,于是我修好了m3u8文件的http下載方式,改成直接從內(nèi)存獲取:

結(jié)果:性能爆炸
通過(guò)上述總共3大優(yōu)化,我們?cè)趬簻y(cè)zlmediakit的hls性能時(shí),隨著一點(diǎn)一點(diǎn)增加并發(fā)量,發(fā)現(xiàn)zlmediakit總是能運(yùn)行的非常健康,在并發(fā)量從10K慢慢增加到30K時(shí),并不會(huì)影響ffplay播放的流暢性和效果,以下是壓測(cè)數(shù)據(jù):
壓測(cè)16K http-hls播放器時(shí),流量大概7.5Gb/s:
(大概需要32K端口,由于我測(cè)試機(jī)端口不足,只能最大壓測(cè)到這個(gè)數(shù)據(jù))
圖片.png
圖片.png
圖片.png
后面用戶再壓測(cè)了30k https-hls播放器:
圖片.png
圖片.png
后記:用戶切生產(chǎn)環(huán)境
在完成hls性能優(yōu)化后,該用戶把所有北美節(jié)點(diǎn)的hls流量切到了zlmediakit,


狀況又起:
今天該用戶又反饋給我說(shuō)zlmediakit的內(nèi)存占用非常高,在30K hls并發(fā)時(shí),內(nèi)存占用30+GB:

但是用zlmediakit的getThreadsLoad接口查看,卻發(fā)現(xiàn)負(fù)載很低:

同時(shí)使用zlmediakit的getStatistic接口查看,發(fā)現(xiàn)BufferList對(duì)象個(gè)數(shù)很高,初步懷疑是由于網(wǎng)絡(luò)帶寬不足導(dǎo)致發(fā)送擁塞,內(nèi)存暴漲,通過(guò)詢問(wèn)得知,公網(wǎng)hls訪問(wèn),確實(shí)存在ts文件下載緩慢的問(wèn)題:

同時(shí)讓他通過(guò)局域網(wǎng)測(cè)試ts下載,卻發(fā)現(xiàn)非??欤?/p>

后來(lái)通過(guò)計(jì)算,發(fā)現(xiàn)確實(shí)由于網(wǎng)絡(luò)帶寬瓶頸每個(gè)用戶積壓一個(gè)Buffer包,而每個(gè)Buffer包用戶設(shè)置的為1MB,這樣算下來(lái),30K用戶,確實(shí)會(huì)積壓30GB的發(fā)送緩存:



結(jié)論
通過(guò)上面的經(jīng)歷,我們發(fā)現(xiàn)zlmediakit已經(jīng)足以支撐30K/50Gb級(jí)別的https-hls并發(fā)能力, 理論上,http-hls相比https-hls要少1次內(nèi)存拷貝,和1次加密,性能應(yīng)該要好很多;那么zlmediakit的性能上限在哪里?天知道!畢竟,我已經(jīng)沒(méi)有這么豪華的配置供我壓測(cè)了;在此,我們先立一個(gè)保守的flag吧:
單機(jī) 100K/100Gb級(jí)別 hls并發(fā)能力。
那其他協(xié)議呢? 我覺(jué)得應(yīng)該不輸hls。





