每秒百萬級高效 C-- 異步日誌實踐

一個高效可拓展的異步 C++ 日誌庫:RING LOG,本文分享了了其設計方案與技術原理等內容

詳細代碼見 github 路徑:https://github.com/LeechanX/RingLog

導論


同步日誌與缺點

傳統的日誌也叫同步日誌,每次調用一次打印日誌 API 就對應一次系統調用 write 寫日誌文件,在日誌產生不頻繁的場景下沒什麼問題

可是,如果日誌打印很頻繁,同步日誌有什麼問題?

那麼,如何解決如上的問題?就是

異步日誌與隊列實現的缺點

異步日誌,按我的理解就是主線程的日誌打印接口僅負責生產日誌數據(作爲日誌的生產者),而日誌的落地操作留給另一個後臺線程去完成(作爲日誌的消費者),這是一個典型的生產 - 消費問題,如此一來會使得:

主線程調用日誌打印接口成爲非阻塞操作,同步的磁盤 IO 從主線程中剝離出來,有助於提高性能

對於異步日誌,我們很容易藉助隊列來一個實現方式:主線程寫日誌到隊列,隊列本身使用條件變量、或者管道、eventfd 等通知機制,當有數據入隊列就通知消費者線程去消費日誌

但是,這樣的異步隊列也有一定的問題:

好了,可以開始正文了

簡介


RING LOG 是一個適用於 C++ 的異步日誌, 其特點是效率高(實測每秒支持 125 + 萬日誌寫入)、易拓展,尤其適用於頻繁寫日誌的場景

一句話介紹原理:

使用多個大數組緩衝區作爲日誌緩衝區,多個大數組緩衝區以雙循環鏈表方式連接,並使用兩個指針 p1 和 p2 指向鏈表兩個節點,分別用以生成數據、與消費數據

生產者可以是多線程,共同持有 p1 來生產數據,消費者是一個後臺線程,持有 p2 去消費數據

大數組緩衝區 + 雙循環鏈表的設計,使得日誌緩衝區相比於隊列有更強大的拓展能力、且避免了大量內存申請釋放,提高了異步日誌在海量日誌生成下的性能表現

此外,RING LOG 還優化了每條日誌的 UTC 格式時間的生成,明顯提高日誌性能

具體工作原理


數據結構

Ring Log 的緩衝區是若干個 cell_buffer 以雙向、循環的鏈表組成

cell_buffer 是簡單的一段緩衝區,日誌追加於此,帶狀態:

Ring Log 有兩個指針:

起始時刻,每個 cell_buffer 狀態均爲 FREE Producer Ptr 與 Consumer Ptr 指向同一個 cell_buffer

整個 Ring Log 被一個互斥鎖 mutex 保護

大致原理


消費者

後臺線程(消費者)forever loop:

  1. 上鎖,檢查當前 Consumer Ptr:
  1. 再次檢查當前 Consumer Ptr:
  1. 釋放鎖

  2. 持久化 cell_buffer

  3. 重新上鎖,將 cell_buffer 狀態標記爲 FREE,並清空其內容;Consumer Ptr 前進一位;

  4. 釋放鎖

生產者

  1. 上鎖,檢查當前 Producer Ptr 對應 cell_buffer 狀態:

如果 cell_buffer 狀態爲 FREE,且生剩餘空間足以寫入本次日誌,則追加日誌到 cell_buffer,去 STEP X;

  1. 如果 cell_buffer 狀態爲 FREE 但是剩餘空間不足了,標記其狀態爲 FULL,然後進一步探測下一位的 next_cell_buffer:
  1. 如果 cell_buffer 狀態爲 FULL,說明此時 Consumer Ptr = cell_buffer,丟棄日誌;

  2. 釋放鎖,如果本線程將 cell_buffer 狀態改爲 FULL 則通知條件變量 cond

在大量日誌產生的場景下,Ring Log 有一定的內存拓展能力;實際使用中,爲防止 Ring Log 緩衝區無限拓展,會限制內存總大小,當超過此內存限制時不再申請新 cell_buffer 而是丟棄日誌

圖解各場景

初始時候,Consumer Ptr 與 Producer Ptr 均指向同一個空閒 cell_buffer1

然後生產者在 1s 內寫滿了 cell_buffer1,Producer Ptr 前進,通知後臺消費者線程持久化

消費者持久化完成,重置 cell_buffer1,Consumer Ptr 前進一位,發現指向的 cell_buffer2 未滿,等待

超過一秒後 cell_buffer2 雖有日誌,但依然未滿:消費者將此 cell_buffer2 標記爲 FULL 強行持久化,並將 Producer Ptr 前進一位到 cell_buffer3

消費者在 cell_buffer2 的持久化上延遲過大,結果生產者都寫滿 cell_buffer3\4\5\6,已經正在寫 cell_buffer1 了

生產者寫滿寫 cell_buffer1,發現下一位 cell_buffer2 是 FULL,則拓展換衝區,新增 new_cell_buffer

UTC 時間優化

每條日誌往往都需要 UTC 時間:yyyy-mm-dd hh:mm:ss(PS:Ring Log 提供了毫秒級別的精度)

Linux 系統下本地 UTC 時間的獲取需要調用 localtime 函數獲取年月日時分秒

在 localtime 調用次數較少時不會出現什麼性能問題,但是寫日誌是一個大批量的工作,如果每條日誌都調用 localtime 獲取 UTC 時間,性能無法接受

在實際測試中,對於 1 億條 100 字節日誌的寫入,未優化 locatime 函數時 RingLog 寫內存耗時 245.41s,僅比傳統日誌寫磁盤耗時 292.58s 快將近一分鐘;而在優化 locatime 函數後,RingLog 寫內存耗時 79.39s,速度好幾倍提升

策略

爲了減少對 localtime 的調用,使用以下策略

RingLog 使用變量_sys_acc_sec 記錄寫上一條日誌時,系統經過的秒數(從 1970 年起算)、使用變量_sys_acc_min 記錄寫上一條日誌時,系統經過的分鐘數,並緩存寫上一條日誌時的年月日時分秒 year、mon、day、hour、min、sec,並緩存 UTC 日誌格式字符串

每當準備寫一條日誌:

  1. 調用 gettimeofday 獲取系統經過的秒 tv.tv_sec,與_sys_acc_sec 比較;

  2. 如果 tv.tv_sec 與 _sys_acc_sec 相等,說明此日誌與上一條日誌在同一秒內產生,故年月日時分秒是一樣的,直接使用緩存即可;

  3. 否則,說明此日誌與上一條日誌不在同一秒內產生,繼續檢查:tv.tv_sec/60 即系統經過的分鐘數與_sys_acc_min 比較;

  4. 如果 tv.tv_sec/60 與_sys_acc_min 相等,說明此日誌與上一條日誌在同一分鐘內產生,故年月日時分是一樣的,年月日時分 使用緩存即可,而秒 sec = tv.tv_sec%60,更新緩存的秒 sec,重組 UTC 日誌格式字符串的秒部分;

  5. 否則,說明此日誌與上一條日誌不在同一分鐘內產生,調用 localtime 重新獲取 UTC 時間,並更新緩存的年月日時分秒,重組 UTC 日誌格式字符串

小結:如此一來,localtime 一分鐘纔會調用一次,頻繁寫日誌幾乎不會有性能損耗

性能測試

對比傳統同步日誌、與 RingLog 日誌的效率(爲了方便,傳統同步日誌以 sync log 表示)

  1. 單線程連續寫 1 億條日誌的效率

分別使用 Sync log 與 Ring log 寫 1 億條日誌(每條日誌長度爲 100 字節)測試調用總耗時,測 5 次,結果如下:

單線程運行下,Ring Log 寫日誌效率是傳統同步日誌的近 3.7 倍,可以達到每秒 127 萬條長爲 100 字節的日誌的寫入

2、多線程各寫 1 千萬條日誌的效率

分別使用 Sync log 與 Ring log 開 5 個線程各寫 1 千萬條日誌(每條日誌長度爲 100 字節)測試調用總耗時,測 5 次,結果如下:

多線程(5 線程)運行下,Ring Log 寫日誌效率是傳統同步日誌的近 3.8 倍,可以達到每秒 135.5 萬條長爲 100 字節的日誌的寫入

  1. 對 server QPS 的影響

現有一個 Reactor 模式實現的 echo Server,其純淨的 QPS 大致爲 19.32 萬 / s

現在分別使用 Sync Log、Ring Log 來測試:echo Server 在每收到一個數據就調用一次日誌打印下的 QPS 表現

對於兩種方式,分別採集 12 次實時 QPS,統計後大致結果如下:

傳統同步日誌 sync log 使得 echo Server QPS 從 19.32w 萬 / s 降低至 11.42 萬 / s,損失了 40.89%RingLog 使得 echo Server QPS 從 19.32w 萬 / s 降低至 16.72 萬 / s,損失了 13.46%

TODO

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/JclXlWcdYRub2Al-sbh9CA