前面六章各自處理一種問題:計畫、優化器、JOIN、排序、鎖、schema。但線上出事的時候,你手上只有一句話:
「系統變慢了。」
沒有人會告訴你「是深分頁把 Buffer Pool 沖掉了」或「有支批次持有鎖不放」。這一章要處理的就是從那句話到根因之間的路徑——也就是這一軌第三條主線「先量再調」的完整版本。
而這一章要對付的最大敵人,是一個很自然的反射動作:
這一章要建立的是一套分流:
long_query_time 預設 10 秒是什麼都抓不到的。Threads_running 是最被低估的一個。Seconds_Behind_Source 顯示 0 不代表沒有延遲。這一章是前六章的索引:每一種症狀最後都會指向某一章。而最後那個失敗場景會直接把你送進 ch08。
這是整條排查鏈的分岔點,而它決定了接下來要用完全不同的工具。跳過這一步直接去翻慢查詢日誌,是最常見的浪費時間方式。
| 某一支變慢 | 全部變慢 | |
|---|---|---|
| 典型徵狀 | 只有某個 API 的 p99 上升 | 不相干的 API 一起變慢,甚至健康檢查逾時 |
| 先看 | 慢查詢日誌/digest → EXPLAIN | 全域指標:Threads_running、鎖、Buffer Pool、複本 |
| 通常是 | 計畫走錯、缺索引、深分頁(ch01~ch04) | 有東西塞住:鎖、連線池、快取被沖掉(ch04、ch05) |
| 最糟的做法 | 直接加索引試試 | 逐句 EXPLAIN——會看到一堆漂亮的計畫,什麼也找不到 |
-- 這一個數字就能分流:目前「正在執行」的執行緒數
SHOW GLOBAL STATUS LIKE 'Threads_running';Threads_running 比 Threads_connected 重要得多。連線數高只代表「連著」(連線池本來就會養著一堆閒置連線),正在跑的數量才代表壓力。很多監控只畫連線數,於是塞車的時候看起來一切正常。還有一種容易被誤判成「慢」的症狀:使用者說「我剛改的資料不見了」。那多半不是效能問題,是讀寫分離的複本延遲(本章後面會講)。這種情況去查慢查詢日誌永遠查不到東西。
「系統變慢了」
│
├─ Threads_running 正常 → 某支慢
│ └→ digest / 慢查詢日誌 → EXPLAIN(ch01)
│ → 估算失準?(ch02)/JOIN?(ch03)/排序分頁?(ch04)
│
├─ Threads_running 飆高 → 有東西塞住
│ ├→ 鎖等待?(ch05)
│ ├→ Buffer Pool 命中率掉?(ch04 深分頁沖快取)
│ ├→ 磁碟 I/O 打滿?
│ └→ 都正常但 CPU 高 → 量的問題(本章失敗場景 → ch08)
│
└─ DB 指標全正常,但應用端很慢
└→ 根本不是 DB:連線池、GC、網路、序列化慢查詢日誌是最多人知道、也最多人設錯的工具。預設設定下它基本上不會記到任何東西。
SELECT @@slow_query_log, @@long_query_time;
-- 0, 10.000000 ← 沒開,而且門檻是 10 秒SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5; -- 依服務的 SLA 調,通常 0.2~1 秒
SET GLOBAL log_slow_admin_statements = ON; -- ALTER/ANALYZE 這類也記(ch06 用得到)
SET GLOBAL log_slow_replica_statements = ON; -- 複本上的慢語句也記log_queries_not_using_indexes:聽起來很有用,但小表的全表掃描是完全正常的(ch02 講過),開了之後日誌會被那些無害的查詢刷爆,真正的問題反而被淹沒。要開的話務必配 min_examined_row_limit 設個門檻。long_query_time = 0(記錄全部):診斷時很有用,但它本身有成本——每一句都要寫檔案,高 QPS 下會拖慢資料庫。只在短時間的診斷視窗開,開完記得關。原始日誌不適合直接讀。用 pt-query-digest 聚合成報表:
pt-query-digest /var/log/mysql/slow.log | head -50一句 5 秒、一天跑 1 次 → 總共 5 秒
一句 10 毫秒、一天跑 200 萬次 → 總共 5.5 小時 ← 該修的是這個直覺會被那句 5 秒吸引,但它對整體幾乎沒有影響。這個排序方式的差別,就是這一章與下一章的橋樑。它只能記錄「單句超過門檻」的語句。所以下面這些它全部看不到:
rows_examined 是 1,用掃描列數排序時完全隱形(ch05 提過)。所以慢查詢日誌是必要但不充分的工具。真正的主力是下一節。
performance_schema 會把所有執行過的語句正規化成「指紋」(digest)後聚合統計——把參數值換成 ?,於是同一種查詢的一百萬次呼叫會被歸成一筆。
SELECT
LEFT(digest_text, 80) AS query,
count_star AS 次數,
ROUND(sum_timer_wait/1e12, 1) AS 總秒數,
ROUND(avg_timer_wait/1e9, 2) AS 平均毫秒,
sum_rows_examined AS 掃過列數,
sum_rows_sent AS 回傳列數
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC -- ← 總耗時,不是平均
LIMIT 10;sum_timer_wait 排序,那句 8 毫秒的 SQL 會自己浮到第一名。sum_rows_examined / sum_rows_sent 的比值就是「掃了多少、才回傳多少」。比值越大越可疑——比值 100 代表掃 100 列只回傳 1 列,那是索引或計畫的問題(ch01)。sys 是 performance_schema 的人類可讀版,直接給答案:
SELECT * FROM sys.statement_analysis LIMIT 10; -- 綜合分析
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10; -- 全表掃描的(→ ch01)
SELECT * FROM sys.statements_with_sorting LIMIT 10; -- 有排序的(→ ch04)
SELECT * FROM sys.statements_with_temp_tables LIMIT 10; -- 用暫存表的(→ ch04)
SELECT * FROM sys.schema_unused_indexes; -- 沒被用過的索引(→ ch06 清理)
SELECT * FROM sys.innodb_lock_waits; -- 誰擋誰(→ ch05)performance_schema_digests_size 預設 5000 種指紋,超過之後新的指紋會被丟進一個叫 NULL 的桶子。看到 digest_text 為 NULL 卻佔了很大比重時,代表統計已經失真——通常是因為有人在拼字串 SQL(每次的指紋都不同)。SELECT /* app=order-api,ep=list */ ... FROM orders WHERE ...註解會出現在 digest 裡,於是統計表就能直接對應到程式碼的位置。這件事只要做一次,之後每一場故障都受益。「全部變慢」那條路上,看這六個就夠了。每個指標異常時,答案都在前面某一章。
SHOW GLOBAL STATUS LIKE 'Threads_running';飆高=請求出不去。接著看是被什麼卡住(②③④)。
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%';
-- Innodb_row_lock_waits 等過鎖的次數
-- Innodb_row_lock_time_avg 平均等多久
-- Innodb_row_lock_current_waits 現在有幾個在等 ← 案發時看這個
SELECT * FROM sys.innodb_lock_waits\G -- 誰擋誰SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
-- 命中率 = 1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)健康的 OLTP 系統通常在 99% 以上。重點不是絕對值,是「突然掉下來」——那就是有人在大量掃冷資料(深分頁、全表掃描、批次)。
SELECT * FROM sys.io_global_by_file_by_bytes LIMIT 10;
-- 順便看誰在寫:暫存檔很大 = 排序或暫存表落磁碟(→ ch04)SHOW GLOBAL STATUS LIKE 'Questions'; -- 兩次取樣相減再除以秒數
SHOW GLOBAL STATUS LIKE 'Com_select'; -- 也可以分讀寫看QPS 突然翻倍而單句沒變慢——那是量的問題,不是 SQL 的問題(本章失敗場景)。
SHOW REPLICA STATUS\G
-- Seconds_Behind_Source: 0 ← 不要盡信Seconds_Behind_Source 算的是「套用執行緒落後多久」。如果是接收執行緒斷線或卡住,它可能顯示 0,而實際上資料已經落後好幾分鐘。可靠的做法是自己維護一張心跳表(主庫每秒寫入時間戳,複本讀出來相減),或看 performance_schema.replication_applier_status_by_worker。複本延遲的症狀特別容易被誤判:使用者說「我剛儲存的資料不見了」——那不是慢,是讀到了舊資料。去查慢查詢日誌永遠查不到。
有四種情況,症狀是「資料庫很慢」,但問題完全不在資料庫。認出它們可以省下好幾個小時。
判斷方式很明確:應用端的延遲很高,但資料庫的 Threads_running 很低。那代表請求根本還沒送到資料庫,它們卡在應用端等連線。
// HikariCP 要看的是「等待取得連線」的時間,不是 SQL 執行時間
hikaricp_connections_pending // 有幾個在排隊
hikaricp_connections_acquire_seconds // 拿到連線花多久特徵是週期性的 p99 尖刺,而且 DB 端完全看不到對應的慢語句。查 GC 日誌比查資料庫快得多。ch08 會看到一個典型成因:一次載入太多實體。
資料庫端 5 毫秒、應用端 2 秒——時間花在傳輸幾 MB 的結果、以及 ORM 把它映射成物件。digest 的 sum_rows_sent 很大就是這個訊號(回扣 ch04 的 SELECT *)。
使用者的描述會是「資料不見了」「剛改的沒生效」。判斷方式是去主庫查同一筆——如果主庫是新的、複本是舊的,就是延遲。常見成因:大交易、批次寫入、以及 ch06 那個在複本上也要跑一次的 DDL。
| 症狀 | DB 端的樣子 | 去哪裡查 |
|---|---|---|
| 某支 API 慢 | digest 裡找得到那句 | ch01~ch04 |
| 全部慢、Threads_running 高 | 鎖等待或命中率掉 | ch05、ch04 |
| 全部慢、Threads_running 低 | DB 指標都正常 | 連線池/GC(backend ch03、ch04) |
| CPU 高、慢日誌空 | digest 的 count_star 極高 | 量的問題 → ch08 |
| 「資料不見了」 | 主庫有、複本沒有 | 複本延遲,不是效能問題 |
週一上午,資料庫 CPU 從平常的 30% 升到 95% 並維持不下。
long_query_time = 0.5,設定沒問題)。Threads_running 在 40~60 之間(平常 5 以下)。最後那一條是關鍵:流量沒怎麼變,但送到資料庫的查詢變成七倍。
慢查詢日誌是空的,代表沒有任何單句超過 0.5 秒。所以問題不在「單價」,在「數量」——換 digest,依總耗時排序:
SELECT LEFT(digest_text, 60) AS q, count_star,
ROUND(sum_timer_wait/1e12, 0) AS 總秒數,
ROUND(avg_timer_wait/1e9, 2) AS 平均毫秒,
sum_rows_examined, sum_rows_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC LIMIT 3;
-- q count_star 總秒數 平均毫秒
-- SELECT ... FROM products WHERE id = ? 8,214,003 65,712 8.00
-- SELECT ... FROM members WHERE id = ? 2,104,881 14,734 7.00
-- SELECT ... FROM orders WHERE user_id = ? 92,441 1,201 13.00rows_examined = 1、走主鍵、計畫完美——它永遠不會進慢查詢日誌。但它一小時被呼叫了 820 萬次,總共吃掉 18 小時的 CPU 時間。整場故障的元兇,是一句「一點都不慢」的查詢。接著要回答「誰在呼叫它」。digest 不記錄呼叫端,所以用之前埋好的 SQL 註解標籤:
SELECT digest_text FROM performance_schema.events_statements_summary_by_digest
WHERE digest_text LIKE '%products%' LIMIT 1;
-- SELECT /* app=web,ep=order-list */ ... FROM products WHERE id = ?ep=order-list——訂單列表頁。一個列表頁為什麼要查商品表八百萬次?
item.getProduct()。這就是 N+1——而它在資料庫端的樣子,就是「一堆完美的查詢」。
EXPLAIN:type=const、rows=1,漂亮得不得了(ch01 早就說過這個盲點)。@BatchSize 或 EntityGraph 把 200 次變成 1~2 次;更好的是列表頁直接用 DTO 投影,一句 SQL 取齊所有欄位——這是 ch08 的主題。datasource-proxy 計數,在整合測試裡直接斷言「這個端點不得超過 5 句 SQL」——N+1 是少數可以被自動化測試擋住的效能問題。這一節把前面六章接成一條可以直接照著跑的流程。照順序走,不要跳。
Threads_running。正常=某支慢;飆高=有東西塞住;DB 全正常但應用慢=根本不在 DB。count_star。量的問題 → ch08。鎖與 N+1 這類問題必須抓現場,事後查不到。所以這些要在沒事的時候先做:
slow_query_log = ON,long_query_time 設成符合 SLA 的值(不是預設的 10 秒)。innodb_print_all_deadlocks = ON(ch05)。/* app=…,ep=… */)——讓 digest 對得回程式碼。innodb_trx 裡超過 N 秒的交易。Seconds_Behind_Source。innodb_buffer_pool_size 以外的參數,在沒有量測之前調整多半是換一種問題。而且參數是全域的,為了一句 SQL 動全域是最糟的交易。SHOW ENGINE INNODB STATUS、digest 統計、正在跑的交易),下次還會再發生一次,而你少了一份證據。EXPLAIN 去查鎖等待、拿資料庫指標去查連線池問題,都會得到「一切正常」這個最誤導人的結論。這一章推薦了一堆要開的東西。它們都不是免費的,這一節把帳算清楚。
long_query_time = 0(記錄全部) → 每句都寫檔案,高 QPS 下明顯拖慢資料庫。只在診斷視窗開,而且要設鬧鐘記得關。log_queries_not_using_indexes → 小表全表掃描是正常的,日誌會被無害查詢刷爆,真問題反而被淹沒。performance_schema → 預設開啟,佔幾百 MB 記憶體,而那是從 Buffer Pool 分走的。要調的是收集哪些 instrument,而不是整個關掉——關掉之後你就沒有 digest 了。performance_schema_digests_size 預設 5000,超過的指紋全被丟進 NULL 桶。拼字串 SQL 的專案很容易撐爆它,統計就此失真。EXPLAIN ANALYZE 在正式環境 → 它真的執行(ch01),對重查詢等於再壓一次。配 max_execution_time。Seconds_Behind_Source 可能顯示 0 而實際落後很多(本章講過)。| 症狀 | 關鍵判斷 | 根因方向 | 去哪一章 |
|---|---|---|---|
| 某支 API 慢 | Threads_running 正常 | 計畫、索引、JOIN、排序 | ch01~ch04 |
| 全部慢 + Threads_running 高 | Innodb_row_lock_current_waits 大於 0 | 鎖等待 | ch05 |
| 全部慢 + I/O 打滿 | Buffer Pool 命中率突然掉 | 有人在掃冷資料(深分頁/批次) | ch04 |
| 整張表突然不能用 | 大量 Waiting for table metadata lock | DDL 卡在 MDL 佇列 | ch06、ch05 |
| CPU 高但慢日誌空 | digest 的 count_star 極高 | 量的問題(N+1、缺快取) | ch08 |
| 全部慢但 DB 指標正常 | Threads_running 很低 | 連線池排隊/GC/序列化 | backend ch03、ch04 |
| 「資料不見了」 | 主庫有、複本沒有 | 複本延遲——不是效能問題 | 本章第六個指標 |
| 工具 | 回答什麼 | 看不到什麼 | 設定要點 |
|---|---|---|---|
| 慢查詢日誌 | 哪些單句超過門檻 | 不慢但很多的查詢;鎖等待在裡面也很隱形 | long_query_time 設成符合 SLA 的值,不要用預設 10 秒 |
| digest(events_statements_summary_by_digest) | 哪些 SQL 總共吃掉最多時間 | 誰呼叫的、一個請求發幾句、p99 | 依 sum_timer_wait 排序;SQL 加註解標籤 |
| sys schema 視圖 | 誰全表掃描/誰在排序/誰擋誰 | 同 digest | 直接查,已經寫好了 |
| 應用端 APM / datasource-proxy | 一個請求發幾句 SQL、連線等待時間、p99 | 資料庫內部的鎖與計畫 | N+1 只有這裡看得見 |
| 指標 | 怎麼看 | 異常代表 | 對應章節 |
|---|---|---|---|
| Threads_running | 正在執行的執行緒數 | 飆高=有東西塞住(比連線數更重要) | 分流起點 |
| Innodb_row_lock_current_waits | 現在有幾個在等鎖 | 大於 0 就去看 sys.innodb_lock_waits | ch05 |
| Buffer Pool 命中率 | 1 - reads / read_requests | 突然掉=有人在掃冷資料 | ch04 |
| 暫存檔 I/O | sys.io_global_by_file_by_bytes | 很大=排序或暫存表落磁碟 | ch04 |
| QPS(Questions) | 兩次取樣相減 | 翻倍但流量沒變=N+1 或缺快取 | ch08 |
| 複本延遲 | 心跳表,不要只信 Seconds_Behind_Source | 讀到舊資料——是正確性問題不是效能問題 | 本章 |
點擊卡片翻面查看答案,共 13 張。