每秒百萬級高效 C-- 異步日誌實踐
一個高效可拓展的異步 C++ 日誌庫:RING LOG,本文分享了了其設計方案與技術原理等內容
詳細代碼見 github 路徑:https://github.com/LeechanX/RingLog
導論
同步日誌與缺點
傳統的日誌也叫同步日誌,每次調用一次打印日誌 API 就對應一次系統調用 write 寫日誌文件,在日誌產生不頻繁的場景下沒什麼問題
可是,如果日誌打印很頻繁,同步日誌有什麼問題?
-
一方面,大量的日誌打印陷入等量的 write 系統調用,有一定系統開銷
-
另一方面,使得打印日誌的進程附帶了大量同步的磁盤 IO,影響性能
那麼,如何解決如上的問題?就是
異步日誌與隊列實現的缺點
異步日誌,按我的理解就是主線程的日誌打印接口僅負責生產日誌數據(作爲日誌的生產者),而日誌的落地操作留給另一個後臺線程去完成(作爲日誌的消費者),這是一個典型的生產 - 消費問題,如此一來會使得:
主線程調用日誌打印接口成爲非阻塞操作,同步的磁盤 IO 從主線程中剝離出來,有助於提高性能
對於異步日誌,我們很容易藉助隊列來一個實現方式:主線程寫日誌到隊列,隊列本身使用條件變量、或者管道、eventfd 等通知機制,當有數據入隊列就通知消費者線程去消費日誌
但是,這樣的異步隊列也有一定的問題:
-
生產者線程產生 N 個日誌,對應後臺線程就會被通知 N 次,頻繁日誌寫入會造成一定性能開銷
-
不同隊列實現方式也各有缺點:
-
用數組實現:空間不足時,隊列內存不易拓展
-
用鏈表實現:每條消息的生產消費都對應內存的創建銷燬,有一定開銷
好了,可以開始正文了
簡介
RING LOG 是一個適用於 C++ 的異步日誌, 其特點是效率高(實測每秒支持 125 + 萬日誌寫入)、易拓展,尤其適用於頻繁寫日誌的場景
一句話介紹原理:
使用多個大數組緩衝區作爲日誌緩衝區,多個大數組緩衝區以雙循環鏈表方式連接,並使用兩個指針 p1 和 p2 指向鏈表兩個節點,分別用以生成數據、與消費數據
生產者可以是多線程,共同持有 p1 來生產數據,消費者是一個後臺線程,持有 p2 去消費數據
大數組緩衝區 + 雙循環鏈表的設計,使得日誌緩衝區相比於隊列有更強大的拓展能力、且避免了大量內存申請釋放,提高了異步日誌在海量日誌生成下的性能表現
此外,RING LOG 還優化了每條日誌的 UTC 格式時間的生成,明顯提高日誌性能
具體工作原理
數據結構
Ring Log 的緩衝區是若干個 cell_buffer 以雙向、循環的鏈表組成
cell_buffer 是簡單的一段緩衝區,日誌追加於此,帶狀態:
-
FREE:表示還有空間可追加日誌
-
FULL:表示暫時無法追加日誌,正在、或即將被持久化到磁盤;
Ring Log 有兩個指針:
-
Producer Ptr:生產者產生的日誌向這個指針指向的 cell_buffer 裏追加,寫滿後指針向前移動,指向下一個 cell_buffer;Producer Ptr 永遠表示當前日誌寫入哪個 cell_buffer,被多個生產者線程共同持有
-
Consumer Ptr:消費者把這個指針指向的 cell_buffer 裏的日誌持久化到磁盤,完成後執行向前移動,指向下一個 cell_buffer;Consumer Ptr 永遠表示哪個 cell_buffer 正要被持久化,僅被一個後臺消費者線程持有
起始時刻,每個 cell_buffer 狀態均爲 FREE Producer Ptr 與 Consumer Ptr 指向同一個 cell_buffer
整個 Ring Log 被一個互斥鎖 mutex 保護
大致原理
消費者
後臺線程(消費者)forever loop:
- 上鎖,檢查當前 Consumer Ptr:
-
如果對應 cell_buffer 狀態爲 FULL,釋放鎖,去 STEP 4;
-
否則,以 1 秒超時時間等待條件變量 cond;
- 再次檢查當前 Consumer Ptr:
-
若 cell_buffer 狀態爲 FULL,釋放鎖,去 STEP 4;
-
否則,如果 cell_buffer 無內容,則釋放鎖,回到 STEP 1;
-
如果 cell_buffer 有內容,將其標記爲 FULL,同時 Producer Ptr 前進一位;
-
釋放鎖
-
持久化 cell_buffer
-
重新上鎖,將 cell_buffer 狀態標記爲 FREE,並清空其內容;Consumer Ptr 前進一位;
-
釋放鎖
生產者
- 上鎖,檢查當前 Producer Ptr 對應 cell_buffer 狀態:
如果 cell_buffer 狀態爲 FREE,且生剩餘空間足以寫入本次日誌,則追加日誌到 cell_buffer,去 STEP X;
- 如果 cell_buffer 狀態爲 FREE 但是剩餘空間不足了,標記其狀態爲 FULL,然後進一步探測下一位的 next_cell_buffer:
-
如果 next_cell_buffer 狀態爲 FREE,Producer Ptr 前進一位,去 STEP X;
-
如果 next_cell_buffer 狀態爲 FULL,說明 Consumer Ptr = next_cell_buffer,Ring Log 緩衝區使用完了;則我們繼續申請一個 new_cell_buffer,將其插入到 cell_buffer 與 next_cell_buffer 之間,並使得 Producer Ptr 指向此 new_cell_buffer,去 STEP X;
-
如果 cell_buffer 狀態爲 FULL,說明此時 Consumer Ptr = cell_buffer,丟棄日誌;
-
釋放鎖,如果本線程將 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 日誌格式字符串
每當準備寫一條日誌:
-
調用 gettimeofday 獲取系統經過的秒 tv.tv_sec,與_sys_acc_sec 比較;
-
如果 tv.tv_sec 與 _sys_acc_sec 相等,說明此日誌與上一條日誌在同一秒內產生,故年月日時分秒是一樣的,直接使用緩存即可;
-
否則,說明此日誌與上一條日誌不在同一秒內產生,繼續檢查:tv.tv_sec/60 即系統經過的分鐘數與_sys_acc_min 比較;
-
如果 tv.tv_sec/60 與_sys_acc_min 相等,說明此日誌與上一條日誌在同一分鐘內產生,故年月日時分是一樣的,年月日時分 使用緩存即可,而秒 sec = tv.tv_sec%60,更新緩存的秒 sec,重組 UTC 日誌格式字符串的秒部分;
-
否則,說明此日誌與上一條日誌不在同一分鐘內產生,調用 localtime 重新獲取 UTC 時間,並更新緩存的年月日時分秒,重組 UTC 日誌格式字符串
小結:如此一來,localtime 一分鐘纔會調用一次,頻繁寫日誌幾乎不會有性能損耗
性能測試
對比傳統同步日誌、與 RingLog 日誌的效率(爲了方便,傳統同步日誌以 sync log 表示)
- 單線程連續寫 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 字節的日誌的寫入
- 對 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
-
日誌本身緩存大小的配置
-
程序正常退出、異常退出,此時在 buffer 中緩存的日誌會丟失
-
第 N 天 23:59:59 秒產生的日誌有時會被刷寫到第 N+1 天的日誌文件中
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/JclXlWcdYRub2Al-sbh9CA