EXPERIMENT / API LOGGING

多加一行日誌,API會慢多少?輸出端跟不上時,一行日誌也可能拖慢回應

新增日誌後API變慢,該從哪裡查起?分開實測建立內容與寫入處理,整理定位延遲的比較方法,以及把寫入交給其他處理時的注意事項。

新增日誌後API變慢時,應該修改哪裡,取決於是建立日誌內容太耗時,還是寫出日誌時必須等待。

因此,我們在同一個API改變日誌量、寫出方式與輸出端的速度,比較建立內容的時間與等待寫入的時間,如何影響回應。

讀完後,你會知道如何在自己的API比較「只建立日誌」與「實際寫出日誌」,縮小延遲發生的位置。我們也會說明,把寫入交給其他處理時,為什麼除了回應時間,還需要確認是否留下未完成的日誌工作。

實驗中觀察到的日誌寫入等待

我們建立一個小型API,每收到一個請求,就寫出一行記錄程式動作的「日誌」。

輸出端使用實驗用程式,每接收一小段日誌就加入等待時間,讓輸出端的處理跟不上API寫出的速度。結果,日誌寫入必須等待,有些API回應花了約3秒。

這個條件下的一行日誌,是以JSON格式建立、約16KB(16KiB)的長字串。它不是簡短訊息,而是在一行裡放入大量資料。即使只有一行,寫出量與接收端的速度,也可能讓API等待。

我們也嘗試把日誌寫入與API回應處理分開。回應變快了,但最後一個API回應送出後,日誌讀取仍持續約2秒。回應變快,與日誌處理完成,是不同的結果。

另一方面,128位元組的短日誌從1行增加到10行,在這次條件下,回應並沒有隨行數等比例變慢。以下分開比較建立日誌內容的時間、等待寫入的時間,以及讀完日誌的時間。

在同一個API比較7種日誌處理

實驗使用Python 3.11.2標準函式庫。HTTP API在單一asyncio事件迴圈中執行相同的小型雜湊計算,並回傳相同的JSON回應。改變的只有以下日誌處理。

條件 每個請求的處理 輸出位元組數
不記錄日誌 不建立也不輸出日誌 0
短文1行 同步輸出固定短文 128
短文10行 同一段短文同步輸出10次 1,280
僅產生JSON 建立JSON字串但不輸出 0
預先產生JSON,再同步輸出 輸出啟動時建立的JSON字串 16,384
產生JSON+同步輸出 每次建立JSON字串並輸出 16,384
產生JSON+佇列輸出 每次建立JSON字串,將輸出交給另一個執行緒 16,384

位元組數包含換行。大型JSON是固定的合成資料,主要內容是一段很長的ASCII字串。它不是實際請求的完整紀錄,也沒有測量複雜物件的走訪或敏感欄位遮罩成本。

同步輸出使用logging.StreamHandler,佇列輸出使用QueueHandler與QueueListener。輸出目標是專用FIFO,也就是在行程之間傳遞位元組的管道。依照StreamHandler的行為進行寫入與flush,再由另一個行程讀取。

讀取端分成「不刻意限制速度」與「上限256KiB/秒」兩種。這不是實際磁碟持久化或外部日誌服務傳輸效能的測試。

FIG. 01 / 概念圖API回應與日誌讀取完成,是不同的時間點
同步輸出:在API路徑呼叫輸出處理常式
  1. 應用程式處理
  2. 產生JSON
  3. StreamHandler
    寫入與flush
  4. API回應

FIFO沒有空間時,寫入會等待。flush返回,也不代表讀取端已讀完全部內容。

佇列輸出:由另一個執行緒執行輸出處理常式
  1. 應用程式處理
  2. 產生JSON
  3. QueueHandler
    加入佇列
  4. API回應

另一個執行緒繼續處理QueueListener → StreamHandler → FIFO → 讀取端。JSON產生與入列仍留在API路徑。

表示處理區間的概念圖,方框寬度不代表耗時。兩種方式都另外量測最後一個API回應至全部日誌讀完的時間。

QueueHandler的官方說明也提到,可以把處理常式移到另一個執行緒。這次實驗仍將JSON產生、呼叫端的日誌格式化與入列保留在API路徑,只把輸出處理常式移到後段。

短日誌的差異小,大型JSON則受輸出端影響

每秒20個請求,以50ms間隔持續發送8秒;每次160個請求都觀察到完成。7種條件×2種讀取端,各重複3次,共42次量測、6,720個請求。HTTP錯誤、日誌遺失、佇列丟棄皆為零。

為了觀察回應延遲,我們除了平均值,也比較較慢一側的回應時間。例如將100個回應由快到慢排列,取第99個回應的時間。這裡將各條件3次、共480個請求合併計算。起點是預定發送時刻,終點是接收完回應本文。建立連線及API堵塞造成的等待也包括在內。

條件 快速讀取端 慢速讀取端
不記錄日誌 3.54ms 4.32ms
短文1行 5.24ms 3.83ms
短文10行 4.35ms 4.10ms
僅產生JSON 4.40ms 4.51ms
預先產生JSON,再同步輸出 5.22ms 2,912ms
產生JSON+同步輸出 4.42ms 3,005ms
產生JSON+佇列輸出 5.97ms 4.86ms
7種條件下較慢一側的回應時間。短文與僅產生JSON是數ms,大型JSON同步輸出到慢速讀取端則約3秒,並顯示各次變動範圍。
圖2:圓點是3次合併後的值,橫線是各次相同指標的最小至最大值,並非信賴區間。橫軸為對數刻度,相同寬度不代表相同的ms差距。 放大SVG

1行與10行的排名並未依照行數排列。在快速讀取端,各次量測值的範圍分別是:不記錄日誌3.12~4.69ms、短文1行3.87~6.70ms、短文10行3.66~5.31ms,範圍彼此重疊。這次短時間量測無法斷言「多一行短日誌一定會增加幾ms」。

差異明顯的是,將大型JSON同步輸出到慢速讀取端的條件。每次產生JSON時,各次量測值為2.87~3.13秒;即使事先建立字串,仍為2.87~2.96秒。省略產生步驟的對照組,依然留下秒級等待。

比起產生JSON,等待寫入花了更多時間

我們也在API內分別測量JSON產生與logger呼叫的區間。慢速讀取端下,每個請求的平均值如下。

條件 JSON產生相當區間 呼叫端的日誌處理區間
僅產生JSON 97µs 不輸出
預先產生JSON,再同步輸出 不在每個請求產生 55,319µs
產生JSON+同步輸出 105µs 55,255µs
產生JSON+佇列輸出 103µs 122µs

這些區間也包含取得時間戳、計算輸出量、字串編碼等量測開銷,不是logger單獨的純效能值。此外,API內的平均值與客戶端觀測的較慢一側的回應時間是不同指標。一個請求等待寫入而停住事件迴圈時,這段期間到達的其他請求也會等待。

這次要求的輸出量為16KiB × 每秒20個請求 = 每秒320KiB,超過讀取端每秒256KiB的上限。FIFO容量是64KiB。緩衝區填滿後,同步寫入必須等待,進而影響同一事件迴圈處理的HTTP;這個機制可以解釋觀測到的結果。

短文10行則只需每秒25KiB,遠低於相同的讀取上限。這次比較中,比起行數本身,傳遞多少位元組、接收端能讀多快,更能說明差異。

佇列把等待移走,回應後仍留下日誌

使用佇列輸出時,即使讀取端較慢,合併480個請求後,較慢一側的回應時間仍為4.86ms,各次為3.63~5.67ms。不過,最後一個API回應送出,不代表所有日誌都已讀完。

慢速讀取端的累積日誌量。同步輸出連產生都會等待;佇列輸出約8秒就完成API回應,但讀取持續到約10秒。
圖3:顯示各條件第1次量測,虛線為最後一個API回應。讀取約每50ms取樣,因此產生與讀取曲線之間的差距,是此粒度下的概算。 放大SVG

佇列輸出條件下,產生日誌量與已讀取量之差的最大估計值為548KiB。這個估計使用前一個讀取取樣點,因此可能高估同時刻的未讀量。它包含應用程式、FIFO與佇列中的未讀部分,不是佇列本身的占用量。

最後一個API回應到全部日誌讀完,平均是2.11秒,3次都在2.10~2.11秒之間。同步輸出則平均為0.25秒。同步方式在回應前等待,佇列方式則在回應後也留下工作。

這次佇列上限是256筆。在8秒的負載下,佇列沒有滿,也沒有丟失日誌,全部內容都已讀完。不過,如果輸入持續超過讀取能力,未處理工作就會增加。這次沒有持續施加負載直到佇列滿,也沒有測量滿載時的回應。實作上,佇列滿或輸出錯誤會視為實驗失敗。

CPU用量低,不代表回應快

對慢速讀取端同步輸出JSON時,API平均CPU用量約0.011核心;佇列輸出約0.019核心。回應慢到秒級的同步方式,平均CPU反而較低。

這不代表日誌處理的總工作量減少了。等待寫入時不使用CPU的時間增加,計算平均值的回應觀測區間也變長。只看CPU用量,無法判斷API在哪裡等待。

比較回應觀測區間的API平均CPU,以及最後回應後的日誌讀取時間。佇列輸出仍消耗CPU,也留下約2.11秒的後續讀取。
圖4:CPU是API行程的CPU秒數除以回應觀測區間。1核心代表1個邏輯CPU,API上限為0.25核心。數值也包含應用程式與量測處理,並非日誌單獨的CPU成本。讀取時間為3次平均。 放大SVG

每秒完成的請求數,也會隨「完成」的定義而改變。對每秒20個預定請求,在慢速讀取端的結果如下。

條件 計至API回應完成的請求數/秒 計至全部日誌讀完的請求數/秒
產生JSON+同步輸出 16.29 15.88
產生JSON+佇列輸出 20.00 15.90

第一欄的分母是從最初預定發送起,到「8秒」與「最後回應」之中較晚的時刻;第二欄還包括全部日誌的讀取完成。先合計3次的請求數與觀測時間,再相除。不記錄日誌、短文1行、短文10行、僅產生JSON這4種條件,計至API回應完成時都是每秒20個請求。這是固定輸入負載結束前的比較,不是API最大處理能力的測量。

在自己的API上,可以怎麼查

這次只看短日誌中較慢一側的回應時間,還無法穩定量化小幅的額外成本。但當大型日誌超過輸出端能力時,API路徑的等待與回應後剩下的工作,就能清楚分開。

新增日誌後若API變慢,可以在同一API比較「僅產生」「輸出既有字串」「產生並輸出」,縮小需要調查的區間。接著除了每個請求的行數,也要看總位元組數與每秒輸出量。即使CPU用量低,輸出端等待仍是可能原因。

嘗試佇列時,應把未處理量、丟棄筆數、全部讀完所需時間,與HTTP回應時間一起測量。如果輸入超過輸出端能力,除了使用佇列,也需要重新檢視記錄內容、輸出量或讀取端能力。

量測條件、變動範圍,以及這次實驗無法回答的事
  • 2026年10月29日量測:Raspberry Pi 5、4個邏輯CPU、16GB記憶體、Linux/aarch64、Python 3.11.2。同一主機的127.0.0.1、HTTP/1.1、每個請求建立一條連線。專用API使用單一事件迴圈,並非特定Web框架或正式應用程式的效能測試。
  • 每次重新啟動API行程,先執行2個暖身請求。讀完所有暖身日誌後重設計數,再開始每秒20個請求、持續8秒的正式量測。14種組合跑3輪,每輪輪換起始位置。預備量測未納入正式集計。
  • 較慢一側的回應時間取排序後第ceil(筆數 × 0.99)筆。本文的點是合併480筆後的值;圖上的範圍來自每次160筆、共3次量測。時間短、重複次數少,小差異或罕見延遲仍需額外驗證。未計算信賴區間或統計顯著性。
  • CPU以cgroup計數器量測,區間從暖身後至回應觀測區間結束,排除後續取得統計資料的處理。佇列條件也包含這段時間內另一個執行緒進行的輸出。這是API整體的平均值,不保證各方式的區間都涵蓋相同的已完成日誌工作量。
  • API與負載產生/讀取端各限制為0.25核心,記憶體上限分別128MiB/256MiB,另設執行時間與輸出量上限。主機整體CPU約每秒取樣,中位數7.3%、最大53.9%,load1最大0.79;其中也包含共用主機上的其他工作。未達停止條件。結束後已移除專用行程、FIFO與暫存檔。
  • 慢速讀取端每讀取4,096位元組便等待,因此256KiB/秒是上限。讀完不代表已持久化至磁碟,也不代表外部服務已確認接收。
  • 所有輸出皆以位元組數、換行數與SHA-256核對。日誌內容直接丟棄、不儲存。不同執行緒模型的Web伺服器、其他語言的logger、檔案或網路輸出、無限期負載,都可能得到不同結果。

集計值可參考各條件CSV、42次量測結果;重新計算可使用6,720個請求的回應資料與時間序列資料。另附欄位與集計方法。

關於KamuiDash

KamuiDash是支援日本區域的PaaS,可直接從GitHub部署Web應用程式。