Chapter 05

關聯式資料庫與 SQL

從正規化、B+ tree 索引、EXPLAIN、交易與 MVCC、鎖,到複製、分庫分表與連線池,再加上 15 題附完整解答的 SQL 實戰題。以 MySQL/InnoDB 為主、PostgreSQL 為對照。Appier 面試明確會考「DB index」與「SQL 題」,廣告活動、曝光、點擊這類海量事件資料更是日常。

預估閱讀 75 分鐘

資料模型與正規化 基礎

關聯式資料庫(RDBMS)把資料存成「表(relation)」,每列(row / tuple)是一筆紀錄,每欄(column / attribute)有固定型別。它的核心價值有三個:宣告式查詢(SQL)、交易保證(ACID)、透過 schema 與約束(constraint)維持資料完整性。正規化(normalization)就是在設計 schema 時,透過拆表消除「資料重複」帶來的更新異常。

為什麼要正規化:三種異常

假設把廣告活動資訊和曝光紀錄塞進同一張大表 impressions(imp_id, campaign_id, campaign_name, advertiser_name, advertiser_email, ts, device):

更新異常(update anomaly)
廣告主改 email,要更新上百萬列;只改到一部分就產生不一致。
插入異常(insertion anomaly)
新廣告活動還沒有任何曝光,就無處存放它的名稱。
刪除異常(deletion anomaly)
清掉某活動所有曝光(例如資料保存期限到了),連活動本身的資訊都一起消失。

1NF → 2NF → 3NF → BCNF

正規形式要求違反的例子修正方式
1NF每個欄位都是不可再分的原子值,沒有重複群組tags = "sports,news,games"拆成 campaign_tags(campaign_id, tag)
2NF符合 1NF,且非鍵欄位完全依賴整個主鍵(消除部分相依)主鍵 (order_id, product_id),但 product_name 只依賴 product_id把 product 資訊拆到 products
3NF符合 2NF,且非鍵欄位不遞移依賴主鍵(非鍵 → 非鍵)campaigns(id, advertiser_id, advertiser_name):id → advertiser_id → advertiser_name拆出 advertisers
BCNF每個非平凡函數相依 X → Y 中,X 都必須是超鍵(superkey)(student, course, teacher),teacher → course 但 teacher 不是超鍵拆成 (teacher, course)、(student, teacher)

口訣:「The key, the whole key, and nothing but the key」——1NF 要有 key、2NF 依賴整個 key、3NF 只依賴 key。BCNF 是 3NF 的加強版,差別只在「候選鍵重疊」的少見情況。實務上做到 3NF 已經足夠。

何時反正規化(denormalization)

正規化減少重複、讓寫入簡單一致,但讀取時需要大量 JOIN。在讀多寫少、或資料量大到 JOIN 成本不可接受時,會刻意反正規化:

  • 冗餘欄位:在 clicks 表冗餘存 campaign_id,報表查詢就不用再 JOIN impressions。
  • 預先聚合表(summary / rollup table):campaign_daily_stats(campaign_id, dt, impressions, clicks, spend),由批次或串流任務更新,報表查這張表而不是掃原始事件。
  • 計數快取欄位:posts.comment_count,避免每次 COUNT(*)。
  • JSON 欄位:屬性稀疏且多變(例如 user attributes),用 MySQL JSON 或 PostgreSQL JSONB。

代價是:寫入要同步多處(或接受最終一致)、需要背景任務校正、資料不一致時要能修補。

面試官想聽到什麼

不是背定義,而是能講出 trade-off:「OLTP 交易資料我會正規化到 3NF,保證一致性;報表與分析需求則建立反正規化的彙總表或丟到 OLAP(如 ClickHouse、BigQuery),用批次 / 串流更新。反正規化的欄位要有明確的『事實來源(source of truth)』和修正機制。」

Appier 情境:廣告活動 / 曝光 / 點擊表設計

erDiagram ADVERTISER ||--o{ CAMPAIGN : owns CAMPAIGN ||--o{ CREATIVE : has CAMPAIGN ||--o{ IMPRESSION : serves IMPRESSION ||--o| CLICK : "may lead to" ADVERTISER { bigint id PK varchar name varchar email } CAMPAIGN { bigint id PK bigint advertiser_id FK varchar name decimal daily_budget datetime start_at datetime end_at tinyint status } CREATIVE { bigint id PK bigint campaign_id FK varchar format } IMPRESSION { bigint id PK bigint campaign_id FK bigint creative_id FK varchar user_id datetime ts decimal cost } CLICK { bigint id PK bigint impression_id FK bigint campaign_id datetime ts }
CREATE TABLE campaigns (
  id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  advertiser_id BIGINT UNSIGNED NOT NULL,
  name          VARCHAR(255)    NOT NULL,
  daily_budget  DECIMAL(12,4)   NOT NULL,         -- 金額用 DECIMAL,絕不用 FLOAT
  status        TINYINT         NOT NULL DEFAULT 0,
  start_at      DATETIME        NOT NULL,
  end_at        DATETIME        NOT NULL,
  created_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY idx_adv_status (advertiser_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE impressions (
  id          BIGINT UNSIGNED NOT NULL,           -- 分散式 ID(如 Snowflake),趨勢遞增
  campaign_id BIGINT UNSIGNED NOT NULL,
  creative_id BIGINT UNSIGNED NOT NULL,
  user_id     VARCHAR(64)     NOT NULL,
  device      VARCHAR(16)     NOT NULL,
  cost        DECIMAL(12,6)   NOT NULL DEFAULT 0,
  ts          DATETIME(3)     NOT NULL,
  PRIMARY KEY (id, ts),                            -- 分區表的分區鍵必須包含在每個 unique key 中
  KEY idx_campaign_ts (campaign_id, ts)
) ENGINE=InnoDB
PARTITION BY RANGE COLUMNS (ts) (
  PARTITION p20261101 VALUES LESS THAN ('2026-11-02'),
  PARTITION p20261102 VALUES LESS THAN ('2026-11-03'),
  PARTITION pmax      VALUES LESS THAN (MAXVALUE)
);

CREATE TABLE clicks (
  id            BIGINT UNSIGNED NOT NULL,
  impression_id BIGINT UNSIGNED NOT NULL,
  campaign_id   BIGINT UNSIGNED NOT NULL,         -- 反正規化冗餘,報表免 JOIN
  ts            DATETIME(3)     NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_imp (impression_id),              -- 同一曝光只算一次點擊(去重)
  KEY idx_campaign_ts (campaign_id, ts)
) ENGINE=InnoDB;
Appier 情境

以廣告平台常見做法為例:活動設定(campaign、budget、targeting)是低頻寫、需強一致的 OLTP 資料,放 MySQL;曝光、點擊每天可能數十億筆,原始事件通常寫入 Kafka 再落到資料湖 / OLAP,MySQL 只保存彙總表(每小時、每日統計)或短期資料。按日期 RANGE 分區的好處是過期資料可以 ALTER TABLE ... DROP PARTITION 秒刪,而不是 DELETE 上億列造成大量 undo/binlog 與複製延遲。

常見陷阱

金額用 FLOAT/DOUBLE 會有二進位浮點誤差;用 DECIMAL 或以最小單位存整數(micro-dollars,Google Ads API 就是用 micros)。時間統一存 UTC。MySQL 的 utf8 其實是 utf8mb3,存不了 emoji,請用 utf8mb4。

索引原理:為什麼是 B+ tree 高頻 進階

索引(index)是「用額外空間與寫入成本,換取查詢時不必全表掃描」的資料結構。要理解 InnoDB 為何選 B+ tree,得先理解瓶頸在磁碟 I/O:資料庫以「頁(page)」為單位在磁碟和記憶體(buffer pool)之間搬資料,InnoDB 預設頁大小是 16KB(innodb_page_size)。一次隨機 I/O 在 HDD 上約數毫秒、SSD 上約數十到一百多微秒,而記憶體存取約 100 奈秒——差了好幾個數量級。所以索引設計的目標是:讓一次查找需要讀的頁數(樹高)越少越好。

各種結構比較

結構等值查詢範圍查詢 / 排序問題
Hash tableO(1)不支援(雜湊後無序)無法範圍、排序、最左前綴;碰撞處理;擴容 rehash
二元搜尋樹(BST)O(log n),最差 O(n)中序走訪可可能退化成鏈結串列;每個節點只有 1 個 key,樹太高
平衡二元樹(AVL / 紅黑樹)O(log₂ n)可扇出(fan-out)只有 2,一億筆約 27 層 = 27 次隨機 I/O
B treeO(log_m n)可,但需在多層間來回走訪內部節點也存資料 → 每頁能放的 key 變少、樹變高;查詢效能不穩定(可能在中途或葉子找到)
B+ treeO(log_m n),固定走到葉子葉子以雙向鏈結串列相連,順序掃描極快寫入可能造成頁分裂(page split)
LSM tree需查多層,靠 bloom filter 加速可(合併多個有序檔)讀放大;適合寫多讀少(見第 6 章 NoSQL)

B+ tree 的關鍵特性:

  1. 只有葉子節點存資料(或指向資料),內部節點只存「key + 子頁指標」,所以一個 16KB 的內部頁能放上千個 key,扇出非常大、樹非常矮。
  2. 所有查詢都走到葉子,路徑長度相同 → 效能穩定可預期。
  3. 葉子節點以雙向鏈結串列串起,範圍查詢(BETWEEN、>、ORDER BY)找到起點後順著串列掃即可,不必回到上層。
  4. 樹高通常 3~4 層,根節點與上層內部節點幾乎常駐 buffer pool,實際一次查找只需 1~2 次磁碟 I/O。
flowchart TB R["Root: 100 | 200"] --> I1["Internal: 20 | 50 | 80"] R --> I2["Internal: 120 | 150"] R --> I3["Internal: 230 | 260"] I1 --> L1["Leaf: 1..19"] I1 --> L2["Leaf: 20..49"] I2 --> L3["Leaf: 120..149"] I3 --> L4["Leaf: 230..259"] L1 -. next .-> L2 L2 -. next .-> L3 L3 -. next .-> L4

樹高估算(面試常考)

假設主鍵是 BIGINT(8 bytes),InnoDB 子頁指標約 6 bytes,內部節點每筆約 14 bytes:

  • 每個內部頁可放約 16384 / 14 ≈ 1170 個指標。
  • 假設每列資料 1KB,一個葉子頁約放 16 列。
  • 樹高 2:1170 × 16 ≈ 1.9 萬列。
  • 樹高 3:1170 × 1170 × 16 ≈ 2,190 萬列。
  • 樹高 4:約 256 億列。

結論:千萬級資料的 B+ tree 只有 3 層。這也是「單表超過兩三千萬列要考慮分表」這個經驗法則的由來之一(但它不是硬限制,實際要看列寬、buffer pool 大小與存取模式)。如果主鍵改用 VARCHAR(64) UUID,內部節點每筆變大、扇出下降,樹也會變高。

面試官想聽到什麼

「為什麼用 B+ tree 而不是 B tree / hash / 紅黑樹?」標準回答:(1) 磁碟以頁為單位,B+ tree 內部節點不存資料、扇出大,樹高低 → I/O 次數少;(2) 葉子鏈結串列支援高效範圍查詢與排序,這是 hash 做不到的;(3) 所有查詢都到葉子,效能穩定。追問「頁分裂」:用隨機主鍵(UUID v4)插入時會在樹中間插入,導致頁分裂、碎片化與 buffer pool 命中率下降;自增或趨勢遞增 ID(Snowflake、UUID v7)總是追加在最右邊葉子,寫入友善。

索引設計:聚簇、回表、覆蓋、最左前綴 高頻

聚簇索引(clustered index)vs 二級索引(secondary index)

聚簇索引(主鍵索引)

InnoDB 的表本身就是一棵以主鍵排序的 B+ tree(index-organized table),葉子節點存的是整列資料。一張表只有一個聚簇索引。若沒有定義主鍵,InnoDB 會選第一個 NOT NULL 的 UNIQUE 索引,再沒有就用隱藏的 6-byte DB_ROW_ID。

二級索引(輔助索引)

葉子節點存的是「索引欄位值 + 主鍵值」,不是整列,也不是實體位址。要拿其他欄位,必須拿主鍵再去聚簇索引查一次,這叫回表(bookmark lookup)。

為什麼二級索引存主鍵而不是實體位址?因為聚簇索引的頁分裂、合併會移動資料列位置,存主鍵讓二級索引不必跟著更新。代價是:主鍵越長,所有二級索引都越肥,這也是不建議用長字串當主鍵的原因。

flowchart LR Q["SELECT * FROM users WHERE email = 'a@x.com'"] --> S["二級索引 idx_email
葉子: email + id"] S -->|"取得 id = 42"| C["聚簇索引 PRIMARY
葉子: 整列資料"] C --> RES["回傳整列"]
PostgreSQL 對照

PostgreSQL 是 heap table:資料列存在無序的 heap 檔,所有索引(包括主鍵)都是二級索引,葉子存 ctid(頁號 + 頁內偏移)指向 heap。因此 PG 沒有「回表到聚簇索引」,而是「回 heap」。更新一列在 PG 會產生新版本(新 ctid),若無法做 HOT(Heap-Only Tuple)更新,所有索引都要加新項目——這是 PG 寫放大的來源之一。CLUSTER 指令只是一次性重排,之後不會維持。

覆蓋索引(covering index)

如果查詢需要的欄位全都在二級索引裡(索引欄位 + 主鍵),就不必回表,EXPLAIN 的 Extra 會出現 Using index。

-- 索引 idx_campaign_ts (campaign_id, ts),葉子實際存 (campaign_id, ts, id)
-- 覆蓋:只需要索引中的欄位
SELECT id, ts FROM impressions WHERE campaign_id = 7 AND ts >= '2026-11-01';

-- 需回表:cost 不在索引中
SELECT id, ts, cost FROM impressions WHERE campaign_id = 7 AND ts >= '2026-11-01';

-- 想讓上面也覆蓋:把 cost 加進索引(代價:索引更大、寫入更慢)
ALTER TABLE impressions ADD INDEX idx_campaign_ts_cost (campaign_id, ts, cost);

-- PostgreSQL 11+ 可以用 INCLUDE 把欄位只放在葉子、不參與排序
-- CREATE INDEX idx ON impressions (campaign_id, ts) INCLUDE (cost);

複合索引與最左前綴原則

複合索引 (a, b, c) 的 B+ tree 先按 a 排序,a 相同再按 b,再按 c——就像電話簿先按姓、再按名。所以只有「從最左邊開始連續使用」的條件能利用索引排序性:

WHERE 條件(索引 a, b, c)可用到的索引欄位說明
a = 1a最左前綴
a = 1 AND b = 2a, b
b = 2 AND a = 1a, bWHERE 中的順序無所謂,優化器會調整
a = 1 AND b = 2 AND c = 3a, b, c全用到
b = 2無(8.0.13+ 可能 skip scan)缺最左欄 a
a = 1 AND c = 3a(c 可做 ICP 過濾)b 斷掉,c 無法用來定位
a = 1 AND b > 2 AND c = 3a, b範圍條件之後的欄位無法用來定位
a = 1 ORDER BY ba,且免排序a 固定後 b 已有序,無 filesort
a > 1 ORDER BY baa 是範圍,b 整體無序 → Using filesort

設計原則:(1) 等值條件欄位放前面、範圍條件欄位放最後;(2) 高選擇性欄位優先(但要配合查詢模式,不是絕對);(3) 盡量讓常用查詢變成覆蓋索引;(4) 考慮 ORDER BY/GROUP BY 能否直接利用索引順序;(5) 已有 (a, b) 就不需要單獨的 (a),屬於冗餘索引。

實戰提示:Index Condition Pushdown(ICP)

MySQL 5.6 起,對於 a = 1 AND c = 3 這種情況,雖然 c 不能用來定位,但儲存引擎在掃描索引時可以先用索引中的 c 過濾,減少回表次數。EXPLAIN Extra 會顯示 Using index condition。

索引選擇性(selectivity)

選擇性 = 不重複值數量 / 總列數,越接近 1 越好。gender、status 這種只有幾個值的欄位選擇性很低,單獨建索引通常沒用:優化器估算後會發現「走索引 + 大量回表」比全表掃描更慢,直接選擇全表掃描(經驗上,命中比例超過表的兩三成左右就可能放棄索引,實際由成本模型決定)。

-- 估算欄位選擇性
SELECT COUNT(DISTINCT device) / COUNT(*) AS sel_device,
       COUNT(DISTINCT user_id) / COUNT(*) AS sel_user
FROM impressions;

-- 長字串用前綴索引:找到夠高選擇性的最短前綴
SELECT COUNT(DISTINCT LEFT(url, 20)) / COUNT(*) FROM landing_pages;
ALTER TABLE landing_pages ADD INDEX idx_url (url(20));  -- 代價:前綴索引無法做覆蓋索引與 ORDER BY

-- 查看優化器看到的統計資訊;統計過時可重算
SHOW INDEX FROM impressions;      -- Cardinality 欄位是估計值
ANALYZE TABLE impressions;

索引失效的常見情境

情境錯誤寫法正確寫法 / 解法
對索引欄位使用函數或運算WHERE DATE(ts) = '2026-11-01'
WHERE id + 1 = 10
WHERE ts >= '2026-11-01' AND ts < '2026-11-02';或 MySQL 8.0.13+ 函數索引 INDEX ((DATE(ts)))
隱式型別轉換user_id 是 VARCHAR,卻寫 WHERE user_id = 12345WHERE user_id = '12345'。字串欄位與數字比較時,MySQL 把欄位轉成數字,等於對欄位套函數
字元集 / collation 不一致的 JOINutf8mb3 欄位 JOIN utf8mb4 欄位統一字元集,否則一側需要轉換而無法用索引
前導萬用字元WHERE name LIKE '%shoe%'LIKE 'shoe%' 可用(前綴範圍);中間比對改用全文索引或 Elasticsearch
OR 連接沒有索引的欄位WHERE a = 1 OR d = 2(d 無索引)給 d 加索引讓優化器做 index_merge,或改寫成 UNION ALL
範圍條件之後的欄位索引 (a, b, c),a = 1 AND b > 5 AND c = 2調整索引順序成 (a, c, b)
違反最左前綴索引 (a, b),只查 b = 2另建 (b) 或調整順序
否定條件!=、NOT IN、NOT LIKE常導致掃描大量資料;不是一定失效,取決於選擇性
優化器判斷全表掃描較便宜低選擇性欄位、小表這是正確行為;必要時 FORCE INDEX(慎用)
常見陷阱

IS NULL 其實可以走索引(InnoDB 把 NULL 存在索引裡,視為最小值)。「IS NOT NULL 一定不走索引」也是錯誤的說法——一切取決於成本估算。面試時避免講絕對的話,要說「通常」、「取決於優化器估算」。

Hash 索引、全文索引與其他類型

Hash index
MEMORY 引擎支援顯式 HASH 索引;InnoDB 使用者只能建 B+ tree,但有內部的自適應雜湊索引(Adaptive Hash Index, AHI):InnoDB 監測到某些索引頁被頻繁等值查詢時,自動在記憶體中為它們建 hash,加速查找。高併發下 AHI 的鎖可能成為瓶頸,有時會關掉(innodb_adaptive_hash_index=OFF)。PostgreSQL 有 USING HASH(10 版後才有 WAL 保護、可安全使用)。
Full-text index
InnoDB 5.6+ 支援 FULLTEXT,底層是倒排索引(inverted index)。中文、日文、韓文要用 WITH PARSER ngram。查詢用 MATCH(col) AGAINST('keyword' IN NATURAL LANGUAGE MODE)。功能與效能遠不及 Elasticsearch,適合輕量需求。
PostgreSQL 特殊索引
GIN(JSONB、陣列、全文檢索的倒排索引)、GiST(地理、範圍型別)、BRIN(Block Range Index,存每段頁的 min/max,適合與實體順序高度相關的大表,例如只追加的時間序列,索引極小)、部分索引(WHERE status = 'active')、表達式索引。
唯一索引 vs 普通索引
唯一索引插入時必須讀頁檢查重複;普通索引可以利用 change buffer 把對二級索引頁的修改暫存,等該頁被讀進記憶體時再合併,減少隨機 I/O。寫多讀少的二級索引,若業務能保證唯一性,普通索引寫入較快。
-- 全文索引(含中文 ngram)
ALTER TABLE creatives ADD FULLTEXT INDEX ft_title (title) WITH PARSER ngram;
SELECT id, title FROM creatives
WHERE MATCH(title) AGAINST('運動鞋' IN NATURAL LANGUAGE MODE);

-- PostgreSQL:JSONB + GIN
-- CREATE INDEX idx_attrs ON user_profiles USING GIN (attrs jsonb_path_ops);
-- SELECT * FROM user_profiles WHERE attrs @> '{"country": "TW"}';

-- PostgreSQL:BRIN 適合按時間追加的大表
-- CREATE INDEX idx_imp_ts_brin ON impressions USING BRIN (ts);
面試官想聽到什麼:「索引是不是越多越好?」

不是。每個索引都要:(1) 佔磁碟與 buffer pool;(2) 在 INSERT/UPDATE/DELETE 時維護,降低寫入吞吐;(3) 增加優化器選錯計畫的機會。做法:依實際慢查詢(slow query log、performance_schema、sys.schema_unused_indexes)新增或刪除索引;MySQL 8.0 可先把索引設成 INVISIBLE 觀察,確定沒影響再刪。

EXPLAIN 解讀 高頻

「這條 SQL 很慢,你怎麼排查?」第一步就是看執行計畫。MySQL 的 EXPLAIN 顯示優化器預估的計畫;8.0.18+ 的 EXPLAIN ANALYZE 會真的執行並回報實際時間與列數。

EXPLAIN
SELECT c.name, COUNT(*) AS imps
FROM impressions i
JOIN campaigns c ON c.id = i.campaign_id
WHERE i.ts >= '2026-11-01' AND i.ts < '2026-11-02'
  AND c.advertiser_id = 3
GROUP BY c.name
ORDER BY imps DESC;

-- +----+-------+------+----------------+---------------+------+-----------------------------------------------+
-- | id | table | type | key            | ref           | rows | Extra                                         |
-- +----+-------+------+----------------+---------------+------+-----------------------------------------------+
-- |  1 | c     | ref  | idx_adv_status | const         |   12 | Using temporary; Using filesort               |
-- |  1 | i     | ref  | idx_campaign_ts| db.c.id       | 8200 | Using where; Using index                      |
-- +----+-------+------+----------------+---------------+------+-----------------------------------------------+

重要欄位

欄位意義
id / select_type查詢編號與類型(SIMPLE、PRIMARY、SUBQUERY、DERIVED、UNION)。id 相同由上往下執行;id 越大越先執行。
table這一列處理哪張表;巢狀迴圈 JOIN 中上面的是驅動表。
type存取方式,最重要的欄位,見下表。
possible_keys / key可能用到的索引 / 實際選用的索引。key 為 NULL 表示沒用索引。
key_len使用到的索引位元組長度,可用來判斷複合索引用到了幾個欄位。
ref與索引比較的是常數(const)還是其他表的欄位。
rows預估要掃描的列數(估計值,基於統計資訊)。
filtered經過 WHERE 過濾後預估剩下的百分比。rows × filtered 為傳給下一步的列數。
Extra額外資訊,見下表。

type 由好到壞

type說明例子
system / const主鍵或唯一索引等值,最多一列WHERE id = 1
eq_refJOIN 時被驅動表用主鍵 / 唯一索引,每次只匹配一列JOIN campaigns c ON c.id = i.campaign_id
ref非唯一索引等值,可能多列WHERE campaign_id = 7
range索引範圍掃描BETWEEN、>、IN (...)
index掃整棵索引樹(比 ALL 好一點,因為索引比表小)覆蓋索引但沒有可定位的條件
ALL全表掃描沒有可用索引

經驗法則:OLTP 查詢至少要到 range,最好是 ref 或更好。看到大表上的 ALL 或 index 要警覺。

Extra 常見值

Using index
覆蓋索引,不需回表。好事。
Using where
在 server 層對儲存引擎回傳的列再做過濾。
Using index condition
使用了 ICP,在引擎層用索引欄位先過濾。
Using filesort
無法利用索引順序,需要額外排序(記憶體中由 sort_buffer_size 決定,放不下會用磁碟暫存檔)。名稱有 file 但不一定落地。解法:讓 ORDER BY 欄位符合索引順序。
Using temporary
需要建立內部暫存表,常見於 GROUP BY、DISTINCT、UNION 欄位無法用索引時。大量資料時暫存表可能落地磁碟,成本高。
Using join buffer (hash join / Block Nested Loop)
被驅動表的 JOIN 欄位沒有索引。MySQL 8.0.18+ 以 hash join 取代 BNL,但通常仍代表該加索引。
Select tables optimized away
例如 MIN(id) 直接從索引兩端取得。

EXPLAIN ANALYZE(MySQL 8 與 PostgreSQL)

-- PostgreSQL:實際執行並顯示每個節點的真實時間、列數、緩衝區命中
EXPLAIN (ANALYZE, BUFFERS)
SELECT campaign_id, COUNT(*) FROM impressions
WHERE ts >= '2026-11-01' AND ts < '2026-11-02'
GROUP BY campaign_id;

-- HashAggregate  (cost=25432.10..25434.10 rows=200 width=16) (actual time=180.2..180.3 rows=152 loops=1)
--   Group Key: campaign_id
--   Buffers: shared hit=1200 read=8400
--   ->  Index Scan using idx_imp_ts on impressions  (cost=0.43..21000.00 rows=886000 width=8)
--                                                   (actual time=0.03..120.5 rows=901233 loops=1)
--         Index Cond: ((ts >= '2026-11-01') AND (ts < '2026-11-02'))
-- Planning Time: 0.2 ms
-- Execution Time: 180.6 ms
  • cost=啟動成本..總成本是優化器估計的抽象單位;actual time 是毫秒、且是每個 loop 的平均值,要乘以 loops。
  • 估計 rows 與實際 rows 差很多(例如估 10、實際 100 萬)是計畫選錯的主因 → 跑 ANALYZE 更新統計,或建立延伸統計(CREATE STATISTICS)。
  • PG 節點類型:Seq Scan、Index Scan、Index Only Scan(需要 visibility map 夠新,否則仍要查 heap,Heap Fetches 會顯示)、Bitmap Heap Scan(先收集符合的頁再依序讀,適合中等選擇性)、Nested Loop / Hash Join / Merge Join。
  • BUFFERS 的 shared hit 是 buffer cache 命中、read 是從 OS / 磁碟讀,能判斷冷熱快取對結果的影響。
-- MySQL 8.0.18+
EXPLAIN ANALYZE
SELECT * FROM impressions WHERE campaign_id = 7 ORDER BY ts DESC LIMIT 20;
-- -> Limit: 20 row(s)  (cost=... rows=20) (actual time=0.05..0.12 rows=20 loops=1)
--     -> Index lookup on impressions using idx_campaign_ts (campaign_id=7; iterate backwards)
--        (actual time=0.05..0.11 rows=20 loops=1)

-- 也可以用樹狀格式看預估計畫
EXPLAIN FORMAT=TREE SELECT ...;
面試官想聽到什麼:慢查詢排查流程

(1) 從 slow query log(long_query_time)或 APM 找出慢且高頻的 SQL,用 pt-query-digest 彙總;(2) EXPLAIN 看 type、key、rows、Extra;(3) 判斷原因:缺索引、索引失效、回表過多、filesort/temporary、JOIN 順序錯、統計資訊過時、鎖等待(不是 SQL 本身慢);(4) 修正:加 / 調整索引、改寫 SQL、拆查詢、加快取、反正規化;(5) 用 EXPLAIN ANALYZE 與壓測驗證,並留意對寫入的影響。

交易與 ACID:各自如何實現 高頻 深入

交易(transaction)是一組要「全部成功或全部失敗」的操作。經典例子:廣告扣款時,「扣廣告主餘額」和「寫入扣款紀錄」必須一起成功。

特性意義InnoDB 如何實現
Atomicity 原子性全做或全不做undo log:記錄修改前的資料(邏輯上的反向操作),rollback 時依 undo log 還原
Consistency 一致性交易前後資料滿足所有約束與業務規則是目標,由 A、I、D 加上約束(PK、FK、UNIQUE、CHECK)與應用程式邏輯共同保證
Isolation 隔離性並行交易彼此不干擾(程度由隔離等級決定)MVCC(快照讀)+ 鎖(當前讀、寫入)
Durability 持久性commit 後即使當機也不遺失redo log(WAL):commit 時先把 redo log 刷盤,資料頁之後再慢慢寫回;當機後用 redo log 重做

Redo log 與 WAL(Write-Ahead Logging)

如果每次 commit 都把修改過的 16KB 資料頁寫回磁碟,會產生大量隨機寫,而且一頁只改了幾 bytes 也要寫整頁。WAL 的想法是:先把「改了什麼」以順序追加的方式寫到日誌,資料頁留在記憶體(dirty page),之後由背景執行緒批次刷回。順序寫比隨機寫快得多,且日誌記錄很小。

  • redo log 是物理日誌(「第 X 號表空間第 Y 頁偏移 Z 改成某值」),固定大小、循環寫(8.0.30+ 由 innodb_redo_log_capacity 控制)。checkpoint 推進代表對應的 dirty page 已刷盤,那段 redo 可被覆寫。
  • innodb_flush_log_at_trx_commit:1(預設)每次 commit 都寫入並 fsync,最安全;2 每次 commit 寫到 OS page cache、約每秒 fsync,MySQL 程序當掉不丟、但 OS 當機可能丟約 1 秒;0 每秒寫入並 fsync,MySQL 當掉就可能丟約 1 秒。
  • Double write buffer:資料頁 16KB 但 OS/磁碟原子寫入單位通常較小,寫到一半當機會有「部分寫入頁(torn page)」,redo log 無法修復損毀的頁,所以 InnoDB 先把頁寫到 doublewrite 區再寫到原位置。PostgreSQL 用 full_page_writes(checkpoint 後第一次修改寫入整頁映像到 WAL)解決同樣問題。

Undo log

undo log 是邏輯日誌:INSERT 記錄「刪除這列」、DELETE 記錄「把這列加回來」、UPDATE 記錄「舊值」。它有兩個用途:rollback 與 MVCC 的版本鏈。每列資料有隱藏欄位 DB_TRX_ID(最後修改它的交易 ID)與 DB_ROLL_PTR(指向 undo log 中上一個版本),形成 undo chain。當沒有任何交易的 read view 需要舊版本時,purge 執行緒才會清除它們。所以長交易會讓 undo log 無法清理而膨脹,這是線上常見問題。

Binlog 與兩階段提交

binlog 是 MySQL Server 層的邏輯日誌(與引擎無關),記錄所有修改資料的事件,用於主從複製與時間點還原(PITR)、CDC(Debezium、Canal)。它和 redo log 的差異:

redo logbinlog
層級InnoDB 引擎MySQL Server
內容物理:頁的修改邏輯:SQL 語句或列變更
寫法固定大小循環寫追加寫,檔案滿了切新檔
用途崩潰復原(crash-safe)複製、備份還原、CDC

兩份日誌必須一致,否則主庫和從庫資料不同。MySQL 用內部的兩階段提交(2PC):

sequenceDiagram participant C as Client participant S as MySQL Server participant E as InnoDB C->>S: COMMIT S->>E: 1. 寫 redo log,狀態 = PREPARE(fsync) S->>S: 2. 寫 binlog(sync_binlog=1 時 fsync) S->>E: 3. redo log 標記 COMMIT S-->>C: OK

崩潰復原時:若 redo 是 PREPARE 且 binlog 中有完整的對應交易 → 提交;binlog 沒有 → 回滾。如此保證 redo 與 binlog 一致。「雙 1 設定」(innodb_flush_log_at_trx_commit=1 + sync_binlog=1)是金融等級的安全設定,MySQL 用 group commit 把多個交易的 fsync 合併以降低成本。

PostgreSQL 對照

PG 只有一份 WAL,同時負責崩潰復原與串流複製(physical replication),也支援 logical decoding(logical replication、CDC)。PG 沒有 undo log:舊版本直接留在 heap 裡,由 VACUUM 清理死元組(dead tuple),所以 PG 的 rollback 幾乎是瞬間,但需要注意表膨脹(bloat)與 autovacuum 調校,以及 transaction ID wraparound(32-bit XID 需要定期 freeze)。

面試官想聽到什麼

「commit 成功後立刻斷電,資料會不會丟?」→ 在雙 1 設定下不會,因為 redo log 已 fsync,重啟後用 redo log 重做未刷盤的 dirty page。「那為什麼不直接寫資料頁?」→ 隨機寫且以頁為單位,太慢;WAL 把隨機寫轉成順序寫,並允許合併多次修改。若放寬成 =2 可換取更高吞吐,但要接受 OS 當機時遺失最多約 1 秒。

隔離等級與 MVCC 高頻 深入

三種讀取異常

髒讀(dirty read)
讀到其他交易尚未 commit 的資料,而該交易之後 rollback 了。
不可重複讀(non-repeatable read)
同一交易內兩次讀同一列,結果不同(中間有人 UPDATE 並 commit)。
幻讀(phantom read)
同一交易內兩次範圍查詢,第二次多出(或少了)列(中間有人 INSERT/DELETE 並 commit)。
隔離等級髒讀不可重複讀幻讀預設
READ UNCOMMITTED可能可能可能—
READ COMMITTED避免可能可能PostgreSQL、Oracle、SQL Server
REPEATABLE READ避免避免SQL 標準:可能;InnoDB:快照讀靠 MVCC、當前讀靠 next-key lock,大致避免;PG:快照隔離,避免MySQL InnoDB
SERIALIZABLE避免避免避免—

SERIALIZABLE 在 InnoDB 中是把所有普通 SELECT 隱式變成 SELECT ... FOR SHARE(加共享鎖),並行度很差;PostgreSQL 的 SERIALIZABLE 是 SSI(Serializable Snapshot Isolation),偵測到危險的讀寫相依時讓其中一個交易以 serialization failure 失敗,應用程式需重試。

常見陷阱:寫偏斜(write skew)

快照隔離(PG 的 RR、InnoDB 的快照讀)仍可能出現 write skew:兩個醫生都查詢「目前值班人數 = 2」,各自把自己設為下班,結果 0 人值班。兩個交易修改的是不同列,沒有寫寫衝突。解法:SELECT ... FOR UPDATE 鎖住被讀取的列、用 SERIALIZABLE、或把約束具體化成一列可被鎖住的資料。

MVCC 原理:read view + undo chain

MVCC(Multi-Version Concurrency Control)的核心是:寫入產生新版本,讀取依「快照」挑選可見的版本,所以普通的讀不必加鎖、讀寫不互相阻塞。InnoDB 中區分兩種讀:

  • 快照讀(consistent read):普通 SELECT,透過 read view 在 undo chain 中找可見版本,不加鎖。
  • 當前讀(locking read):SELECT ... FOR UPDATE / FOR SHARE、UPDATE、DELETE、INSERT,讀最新已提交版本並加鎖。

Read view 包含四個值:

m_ids
建立 read view 時所有活躍(未提交)交易的 ID 列表。
min_trx_id
m_ids 中最小值。
max_trx_id
建立時系統下一個要分配的交易 ID。
creator_trx_id
建立此 read view 的交易自己的 ID。

對某個版本,若其 DB_TRX_ID:

  1. 等於 creator_trx_id → 自己改的,可見。
  2. 小於 min_trx_id → 建立快照前就已提交,可見。
  3. 大於等於 max_trx_id → 快照之後才開始的交易,不可見。
  4. 介於兩者之間:在 m_ids 中 → 當時未提交,不可見;不在 → 已提交,可見。
  5. 不可見就沿著 DB_ROLL_PTR 到 undo log 找上一個版本,重複判斷。
flowchart LR V3["目前版本
balance=70
trx_id=300"] -->|roll_ptr| V2["undo: balance=90
trx_id=200"] V2 -->|roll_ptr| V1["undo: balance=100
trx_id=100"] RV["Read view
m_ids=[200,300]
min=200, max=301"] -. 判斷 .-> V3 RV -. 300 在 m_ids 不可見 .-> V2 RV -. 200 在 m_ids 不可見 .-> V1 V1 -. 100 小於 min 可見 .-> OK["讀到 100"]

RC 與 RR 的差別就在 read view 的建立時機:

  • READ COMMITTED:每個 SELECT 語句都建立新的 read view → 能看到其他交易剛提交的資料 → 不可重複讀。
  • REPEATABLE READ:交易中第一次快照讀時建立 read view,之後重複使用 → 可重複讀。(START TRANSACTION WITH CONSISTENT SNAPSHOT 會立即建立。)
-- 兩個 session 示範 RR 下的快照讀 vs 當前讀
-- Session A                                   -- Session B
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;   -- 100
                                               UPDATE accounts SET balance = 50 WHERE id = 1;  -- autocommit
SELECT balance FROM accounts WHERE id = 1;   -- 仍是 100(快照讀)
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- 50(當前讀,讀最新)
UPDATE accounts SET balance = balance - 10 WHERE id = 1;  -- 基於 50 計算 → 40
SELECT balance FROM accounts WHERE id = 1;   -- 40(自己的修改可見)
COMMIT;
常見陷阱:Lost update

在應用程式中「先 SELECT 讀出餘額 → 在 Python 計算 → 再 UPDATE 寫回固定值」,即使在 RR 下也會發生遺失更新:兩個交易都讀到 100,各自寫回 90,結果只扣了一次。解法:用原子更新 UPDATE ... SET balance = balance - 10 WHERE id = 1 AND balance >= 10、悲觀鎖 FOR UPDATE、或樂觀鎖 version 欄位(見下節)。

面試官想聽到什麼

「InnoDB 的 RR 能解決幻讀嗎?」好的回答:「快照讀透過 MVCC 看到同一個快照,不會看到新插入的列;當前讀透過 next-key lock 鎖住索引記錄與間隙,阻止其他交易在範圍內插入。但兩者混用仍可能出現異常:例如先快照讀、再對別人新插入的列做 UPDATE(當前讀會更新到它),之後快照讀就看得到那列了。所以嚴格說,RR 並非完全等價於 SERIALIZABLE。」

InnoDB 鎖、樂觀鎖與悲觀鎖、死結 高頻 深入

鎖的種類

共享鎖 S / 排他鎖 X
S 與 S 相容,其餘互斥。SELECT ... FOR SHARE(舊寫法 LOCK IN SHARE MODE)加 S;FOR UPDATE、UPDATE、DELETE 加 X。
記錄鎖(record lock)
鎖住索引記錄。重點:InnoDB 的行鎖是加在索引上的,若 UPDATE 的 WHERE 沒用到索引,會掃描並鎖住大量甚至全部記錄。
間隙鎖(gap lock)
鎖住兩個索引記錄之間的間隙,防止其他交易在其中插入,用來防幻讀。間隙鎖之間彼此相容(都只是「不准插入」)。僅在 RR 與 SERIALIZABLE 下用於搜尋與索引掃描。
臨鍵鎖(next-key lock)
記錄鎖 + 該記錄前面的間隙,即左開右閉區間 (prev, curr]。RR 下的預設加鎖單位,等值查詢唯一索引命中時會退化成記錄鎖。
插入意向鎖(insert intention lock)
INSERT 前在間隙上設定的特殊間隙鎖;多個交易插入同一間隙的不同位置不衝突,但會被其他交易的間隙鎖阻擋。
意向鎖(intention lock,IS / IX)
表級鎖。交易要對某列加 S/X 之前,先在表上加 IS/IX。目的:當有人要加表級鎖(例如 LOCK TABLES ... WRITE、某些 DDL)時,只要檢查表上的意向鎖,不必逐列檢查是否有行鎖。IS、IX 彼此相容,只和表級 S/X 衝突。
AUTO-INC 鎖
自增欄位分配用。8.0 預設 innodb_autoinc_lock_mode=2(interleaved),只用輕量 mutex,代價是批次插入的 ID 可能不連續。
MDL(metadata lock)
Server 層的表結構鎖。DDL 需要 MDL 寫鎖,若有長交易持有 MDL 讀鎖,DDL 會等待,而排在 DDL 後面的所有查詢也會被卡住——線上事故經典。
-- RR 下,假設 idx_age 上已有 age = 10, 20, 30
SELECT * FROM users WHERE age = 20 FOR UPDATE;
-- 非唯一索引等值:鎖住 (10, 20] 的 next-key lock + (20, 30) 的 gap lock
-- 其他交易 INSERT age = 15 或 25 會被阻塞;INSERT age = 35 不會

SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- 主鍵等值且存在:只鎖 id = 5 這筆記錄(退化為 record lock)

-- 查看目前的鎖與等待(MySQL 8.0)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM sys.innodb_lock_waits\G

悲觀鎖 vs 樂觀鎖

悲觀鎖(pessimistic locking)

假設衝突常發生,先鎖再改:SELECT ... FOR UPDATE。優點:保證成功、邏輯簡單;缺點:持鎖期間其他交易等待,併發低,可能死結。適合衝突頻繁、重試成本高的場景(例如熱門商品庫存在小量併發下)。

樂觀鎖(optimistic locking)

假設衝突少,不鎖,寫入時檢查:用 version 欄位或時間戳,UPDATE ... WHERE id = ? AND version = ?,影響列數為 0 代表被別人改過,重試或報錯。優點:無鎖等待、併發高;缺點:衝突多時大量重試浪費。適合讀多寫少、衝突率低。

import pymysql

conn = pymysql.connect(host="127.0.0.1", user="app", password="secret",
                       database="ads", autocommit=False)

# --- 悲觀鎖:扣廣告主餘額 ---
def charge_pessimistic(advertiser_id: int, amount_micros: int) -> bool:
    with conn.cursor() as cur:
        try:
            cur.execute("SELECT balance_micros FROM advertisers WHERE id = %s FOR UPDATE",
                        (advertiser_id,))
            (balance,) = cur.fetchone()
            if balance < amount_micros:
                conn.rollback()
                return False
            cur.execute("UPDATE advertisers SET balance_micros = balance_micros - %s WHERE id = %s",
                        (amount_micros, advertiser_id))
            cur.execute("INSERT INTO charges (advertiser_id, amount_micros) VALUES (%s, %s)",
                        (advertiser_id, amount_micros))
            conn.commit()
            return True
        except Exception:
            conn.rollback()
            raise

# --- 樂觀鎖:更新 campaign 設定(後台編輯,衝突少) ---
def update_budget_optimistic(campaign_id: int, new_budget: str, max_retry: int = 3) -> bool:
    for _ in range(max_retry):
        with conn.cursor() as cur:
            cur.execute("SELECT version FROM campaigns WHERE id = %s", (campaign_id,))
            (version,) = cur.fetchone()
            affected = cur.execute(
                "UPDATE campaigns SET daily_budget = %s, version = version + 1 "
                "WHERE id = %s AND version = %s",
                (new_budget, campaign_id, version))
            conn.commit()
            if affected == 1:
                return True
        # affected == 0:被其他人搶先修改,重讀後重試(或回報 409 Conflict 給前端)
    return False

# --- 最簡單也最常被忽略的:單一原子 UPDATE,不需要先 SELECT ---
def charge_atomic(advertiser_id: int, amount_micros: int) -> bool:
    with conn.cursor() as cur:
        affected = cur.execute(
            "UPDATE advertisers SET balance_micros = balance_micros - %s "
            "WHERE id = %s AND balance_micros >= %s",
            (amount_micros, advertiser_id, amount_micros))
        conn.commit()
        return affected == 1
實戰提示

PyMySQL 的 cursor.execute() 回傳的影響列數預設是「實際有變更的列數」;若新值與舊值相同,MySQL 會回報 0。若用 version 欄位則不受影響(version 必定改變)。若要改成「符合條件的列數」,連線時設定 client_flag=pymysql.constants.CLIENT.FOUND_ROWS。

死結(deadlock)範例與處理

sequenceDiagram participant T1 as 交易 T1 participant DB as InnoDB participant T2 as 交易 T2 T1->>DB: UPDATE accounts ... WHERE id = 1(鎖 row 1) T2->>DB: UPDATE accounts ... WHERE id = 2(鎖 row 2) T1->>DB: UPDATE accounts ... WHERE id = 2(等待 T2) T2->>DB: UPDATE accounts ... WHERE id = 1(等待 T1) DB-->>T2: ERROR 1213 Deadlock found, 回滾 T2 DB-->>T1: 取得 row 2 的鎖,繼續

InnoDB 預設開啟死結偵測(innodb_deadlock_detect=ON),用 wait-for graph 找環,回滾「代價較小」(修改列數較少)的交易,回傳錯誤碼 1213。另外還有 innodb_lock_wait_timeout(預設 50 秒)處理單純的長時間鎖等待(錯誤碼 1205)。極高併發熱點更新時,死結偵測本身會耗費大量 CPU,有人會關閉偵測改靠 timeout,但更好的做法是從業務上消除熱點。

避免死結的方法:

  • 固定加鎖順序:轉帳時永遠先鎖 ID 較小的帳戶。
  • 縮短交易:交易中不要有 RPC、使用者互動、sleep;先算好再開交易。
  • 確保 UPDATE/DELETE 的 WHERE 走索引,避免鎖大量列。
  • 批次操作按主鍵排序後再執行。
  • 在可接受的場景使用 READ COMMITTED,減少間隙鎖(很多網際網路公司會把 MySQL 設成 RC + ROW binlog)。
  • 應用程式要捕捉 1213 並重試整個交易,死結無法 100% 避免。
import random
import time
import pymysql

DEADLOCK = 1213
LOCK_WAIT_TIMEOUT = 1205

def run_in_tx(conn, fn, retries: int = 3):
    """執行交易函式,遇到死結或鎖等待逾時就以指數退避重試。"""
    for attempt in range(retries):
        try:
            with conn.cursor() as cur:
                result = fn(cur)
            conn.commit()
            return result
        except pymysql.err.OperationalError as e:
            conn.rollback()
            if e.args[0] in (DEADLOCK, LOCK_WAIT_TIMEOUT) and attempt < retries - 1:
                time.sleep((2 ** attempt) * 0.05 + random.uniform(0, 0.05))
                continue
            raise

def transfer(cur, src: int, dst: int, amount: int):
    first, second = sorted((src, dst))          # 固定加鎖順序避免死結
    cur.execute("SELECT id FROM accounts WHERE id IN (%s, %s) ORDER BY id FOR UPDATE",
                (first, second))
    cur.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (amount, src))
    cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, dst))

# run_in_tx(conn, lambda cur: transfer(cur, 7, 3, 100))
面試官想聽到什麼

「線上出現死結怎麼查?」→ SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 區段會列出兩個交易持有與等待的鎖、SQL;開 innodb_print_all_deadlocks=ON 把每次死結寫進 error log。再對照業務程式碼找出加鎖順序不一致或缺索引的地方。

Appier 情境

以廣告平台常見做法為例:每次曝光都要扣預算,若每次都 UPDATE campaigns SET spent = spent + ?,熱門活動那一列會成為鎖熱點(hot row)。常見解法:在 Redis 用 INCRBYFLOAT 做即時預算計數、或在記憶體中聚合幾百毫秒再批次寫回;或把一個計數拆成 N 個子列(campaign_spend_shards(campaign_id, shard, spent))分散寫入,讀取時 SUM。代價是預算控制有少量超花(overspend),需要在業務上接受並設定容忍度。

查詢優化實戰 進階

N+1 查詢問題

先查 1 次拿到 N 個 campaign,再對每個 campaign 各查 1 次它的 creatives,共 N+1 次往返。每次往返即使只有 0.5ms,N = 1000 就是半秒,而且還會打滿連線池。ORM 的 lazy loading 最容易不知不覺造成 N+1。

from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload

# 壞:N+1(每次存取 c.creatives 觸發一次 lazy load 查詢)
campaigns = session.scalars(select(Campaign).where(Campaign.advertiser_id == 3)).all()
for c in campaigns:
    print(c.name, len(c.creatives))

# 好 1:selectinload → 共 2 次查詢:SELECT campaigns...; SELECT creatives WHERE campaign_id IN (...)
stmt = select(Campaign).where(Campaign.advertiser_id == 3).options(selectinload(Campaign.creatives))

# 好 2:joinedload → 1 次 LEFT OUTER JOIN(一對多時會讓父列重複,資料量大時傳輸較浪費)
stmt = select(Campaign).where(Campaign.advertiser_id == 3).options(joinedload(Campaign.creatives))

# 不用 ORM 時:手動批次 + 在記憶體中分組
ids = [c.id for c in campaigns]
rows = session.execute(select(Creative).where(Creative.campaign_id.in_(ids))).scalars()
by_campaign = {}
for cr in rows:
    by_campaign.setdefault(cr.campaign_id, []).append(cr)
實戰提示

偵測 N+1:開發時開 SQLAlchemy echo=True 或統計每個 request 的查詢次數;SQLAlchemy 2.0 可在 relationship 設 lazy="raise",一旦觸發 lazy load 就直接拋例外。GraphQL API 用 DataLoader 模式批次化(見第 7 章)。

分頁:OFFSET 的問題與 keyset pagination

LIMIT 20 OFFSET 1000000 看似只回 20 列,但資料庫必須先掃過並丟棄前一百萬列(若需要回表,就是一百萬次回表),越往後越慢。此外資料在翻頁期間有新增 / 刪除時,會出現重複或漏掉的列。

-- 壞:深分頁
SELECT id, ts, cost FROM impressions
WHERE campaign_id = 7
ORDER BY ts DESC, id DESC
LIMIT 20 OFFSET 1000000;

-- 改善 1:延遲關聯(deferred join),先在覆蓋索引上找出主鍵,再回表 20 列
SELECT i.id, i.ts, i.cost
FROM impressions i
JOIN (
  SELECT id FROM impressions
  WHERE campaign_id = 7
  ORDER BY ts DESC, id DESC
  LIMIT 20 OFFSET 1000000
) t ON t.id = i.id
ORDER BY i.ts DESC, i.id DESC;

-- 最佳:keyset / seek / cursor pagination,用上一頁最後一筆當游標
-- 需要索引 (campaign_id, ts, id);排序鍵必須唯一,所以加上 id 打破平手
SELECT id, ts, cost FROM impressions
WHERE campaign_id = 7
  AND (ts, id) < ('2026-11-01 10:00:00.123', 987654321)   -- 上一頁最後一筆
ORDER BY ts DESC, id DESC
LIMIT 20;

Keyset 的代價:無法直接跳到第 N 頁、只能上一頁/下一頁(正好符合無限捲動與 API cursor)。API 設計上通常把 (ts, id) 編碼成不透明的 base64 cursor 回給客戶端。

常見陷阱

MySQL 的 row constructor 比較 (ts, id) < (?, ?) 在舊版本不一定能有效利用索引,保險寫法是展開:ts < ? OR (ts = ? AND id < ?)。用 EXPLAIN 確認 type 為 range。

COUNT(*) 的成本

  • InnoDB 沒有儲存精確的總列數,因為 MVCC 下不同交易看到的列數不同。COUNT(*) 需要掃描,優化器會挑最小的二級索引來掃(比聚簇索引小)。MyISAM 有存,但不支援交易。
  • COUNT(*) = COUNT(1)(InnoDB 處理方式相同),都計算所有列;COUNT(col) 只計算 col 非 NULL 的列,語意不同。
  • 大表的「總筆數」需求:(1) 用估計值 information_schema.TABLES.TABLE_ROWS 或 PG 的 pg_class.reltuples;(2) 用 Redis 或計數表維護(注意與 DB 的一致性);(3) 產品面:顯示「10,000+」而非精確值;(4) 分析型查詢丟給 OLAP。

大表 ALTER:Online DDL、gh-ost、pt-osc

對上億列的表加欄位 / 加索引,若鎖表或複製整張表,可能持續數小時並阻塞寫入、造成複製延遲。

方式原理注意
ALGORITHM=INSTANT(8.0.12+)只改 metadata,秒級完成。新增欄位(8.0.29+ 可在任意位置)、改欄位預設值等支援的操作有限;仍需短暫 MDL 鎖
ALGORITHM=INPLACE, LOCK=NONE在引擎內重建,期間的 DML 記錄在 online log,最後套用加二級索引等可用;結束時需 MDL 排他鎖;大表在從庫重放時會造成延遲
ALGORITHM=COPY建新表、複製資料,期間阻塞寫入最後手段
pt-online-schema-change建影子表 + 在原表上建觸發器同步 DML + 分批複製 + 原子 rename觸發器增加寫入負擔;不能有其他觸發器
gh-ost(GitHub)建影子表 + 讀 binlog(無觸發器)同步變更 + 分批複製 + cut-over需 ROW 格式 binlog;可暫停、可節流、可依複製延遲自動降速;可測試切換
-- 明確指定演算法,若不支援會直接報錯而不是默默鎖表
ALTER TABLE campaigns ADD COLUMN bid_strategy VARCHAR(32) NULL, ALGORITHM=INSTANT;
ALTER TABLE impressions ADD INDEX idx_user_ts (user_id, ts), ALGORITHM=INPLACE, LOCK=NONE;

-- 執行 DDL 前先檢查有沒有長交易,避免 MDL 等待連鎖阻塞
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs, trx_query
FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 10;
gh-ost \
  --host=replica-1 --database=ads --table=impressions \
  --alter="ADD INDEX idx_user_ts (user_id, ts)" \
  --chunk-size=1000 --max-lag-millis=1500 \
  --max-load=Threads_running=50 \
  --cut-over=default --exact-rowcount --concurrent-rowcount \
  --postpone-cut-over-flag-file=/tmp/ghost.postpone \
  --execute

其他常見優化手法

  • 只 SELECT 需要的欄位,避免 SELECT *(無法覆蓋索引、傳輸浪費、schema 變更時易出錯)。
  • 批次寫入:多列 INSERT ... VALUES (...), (...) 或 LOAD DATA,一次交易提交多筆,大幅減少 fsync 與往返。
  • 大量刪除 / 更新分批:DELETE ... WHERE ts < ? LIMIT 5000 迴圈執行,避免巨型交易造成 undo 膨脹與複製延遲;能用 DROP PARTITION 更好。
  • Upsert:MySQL INSERT ... ON DUPLICATE KEY UPDATE、PG INSERT ... ON CONFLICT DO UPDATE,用於彙總計數。
  • 避免在大表上 ORDER BY RAND(),改用隨機 ID 範圍。
  • JOIN 欄位兩邊型別與字元集一致、被驅動表的 JOIN 欄位要有索引;小表驅動大表。
-- 串流處理每分鐘把聚合結果寫回 MySQL 的彙總表(冪等 upsert)
INSERT INTO campaign_hourly_stats (campaign_id, hour, impressions, clicks, spend_micros)
VALUES (7, '2026-11-03 10:00:00', 1200, 15, 3600000),
       (8, '2026-11-03 10:00:00',  800,  4, 1900000)
ON DUPLICATE KEY UPDATE
  impressions  = impressions  + VALUES(impressions),
  clicks       = clicks       + VALUES(clicks),
  spend_micros = spend_micros + VALUES(spend_micros);
-- 8.0.20+ 建議改用別名語法:VALUES (...) AS new ON DUPLICATE KEY UPDATE impressions = impressions + new.impressions
-- 注意:「累加」型 upsert 遇到重送會重複計算;需要 exactly-once 時改為「覆寫」型(直接寫入該批次計算出的總值)或記錄批次 ID

複製(Replication)與讀寫分離 進階

flowchart LR App["應用程式"] -->|寫| P[("Primary")] App -->|讀| R1[("Replica 1")] App -->|讀| R2[("Replica 2")] P -->|"binlog"| IO1["Replica IO thread
寫入 relay log"] IO1 --> SQL1["SQL / applier threads
重放"] SQL1 --> R1 P -->|"binlog"| R2

MySQL 複製流程:主庫寫 binlog → 從庫的 I/O thread 拉取 binlog 寫入本地 relay log → SQL thread(或多個 applier worker,平行複製)重放。用途:讀取擴展、高可用(主庫故障時提升從庫)、備份、分析查詢隔離。

非同步 / 半同步 / 群組複製

模式主庫 commit 何時回應資料遺失風險延遲
非同步(async,預設)寫完本地 binlog 就回應主庫當機時,尚未傳到從庫的交易會遺失最低
半同步(semi-sync)至少一個從庫確認已收到並寫入 relay log後回應(不保證已重放)AFTER_SYNC(lossless,5.7+ 預設)下,failover 時不遺失已回應的交易;從庫逾時未回應會退化成非同步多一個網路往返
群組複製 / InnoDB Cluster多數節點透過 Paxos 類協定認證後多數派存活即不遺失較高;寫入衝突需認證

Binlog 格式

格式內容優點缺點
STATEMENT原始 SQL日誌小非確定性函數(NOW() 雖有處理、但 UUID()、RAND()、LIMIT 無 ORDER BY 等)在從庫可能結果不同
ROW(5.7.7+ 預設)每一列的前後映像精確、安全;CDC 必需大量更新時日誌很大(可用 binlog_row_image=MINIMAL 減少)
MIXED預設 STATEMENT,不安全時改 ROW折衷行為較難預測

複製延遲與 read-your-writes

原因:從庫重放是單執行緒或有限平行(主庫是多執行緒併發寫入)、大交易(一次 DELETE 千萬列,從庫要重放同樣久)、從庫機器較弱、從庫上跑重型查詢、無主鍵表的 ROW 格式重放(每列都要全表掃描)。監控 SHOW REPLICA STATUS 的 Seconds_Behind_Source(不精準,可用 pt-heartbeat 寫心跳表量測)。

最常見的問題是使用者剛寫入就讀不到自己的資料(例如剛建立的廣告活動在列表中沒出現)。解法:

  1. 寫後短時間內讀主庫:使用者寫入後在 session / cookie 記錄時間戳,N 秒內該使用者的讀取導向主庫。
  2. 關鍵讀永遠走主庫:例如付款、餘額、設定頁。
  3. GTID 等待:寫入後取得 GTID,讀從庫前執行 WAIT_FOR_EXECUTED_GTID_SET(gtid, timeout),確認從庫已追上。
  4. 前端樂觀更新:直接用寫入回應的資料更新畫面。
  5. 延遲過大時自動把該從庫移出讀取池。
import time
from contextvars import ContextVar

READ_PRIMARY_WINDOW = 3.0   # 秒;需大於一般複製延遲
_last_write_at: ContextVar[float] = ContextVar("last_write_at", default=0.0)

class RoutingDB:
    """簡化的讀寫分離路由:寫走 primary;最近寫過的請求讀也走 primary。"""
    def __init__(self, primary_pool, replica_pools):
        self.primary = primary_pool
        self.replicas = replica_pools
        self._rr = 0

    def for_write(self):
        _last_write_at.set(time.monotonic())
        return self.primary

    def for_read(self, critical: bool = False):
        if critical or time.monotonic() - _last_write_at.get() < READ_PRIMARY_WINDOW:
            return self.primary
        self._rr = (self._rr + 1) % len(self.replicas)
        return self.replicas[self._rr]
# 實務上「最近寫入時間」要跨 request 保存(cookie / session / Redis,以 user_id 為 key)
面試官想聽到什麼

讀寫分離不是免費的:它把強一致換成最終一致。要能主動提到 replication lag、read-your-writes、從庫故障的摘除、以及「交易內的讀必須和寫走同一個連線(主庫)」。實作層級可以在應用程式(如上)、在 ORM/driver、或在中介層(ProxySQL、MySQL Router、Vitess、RDS Proxy)。

高可用與故障切換

主庫故障時要選出資料最新的從庫提升為主庫,並讓其他從庫改指向它、讓應用程式改連新主庫(VIP / DNS / proxy)。工具:MHA(舊)、Orchestrator、MySQL InnoDB Cluster、雲端託管(AWS RDS Multi-AZ 用同步複製的 standby、Aurora 共享儲存層)。要注意腦裂(split brain):舊主庫恢復後仍接受寫入,需要 fencing(例如把舊主庫設 read_only、在 proxy 層隔離)。GTID 讓從庫切換主庫時不必手算 binlog 位置。

分庫分表與連線池 進階

單機 MySQL 的極限可能在寫入 QPS、儲存容量、或單表大小造成的索引與維運成本。擴展順序通常是:SQL 與索引優化 → 加快取 → 讀寫分離 → 垂直拆分 → 水平分片。分片是最後手段,因為它會大幅增加複雜度。

垂直 vs 水平

垂直拆分(vertical)

垂直分庫:按業務拆(廣告活動庫、使用者庫、計費庫),對應微服務邊界。垂直分表:把寬表的冷欄位 / 大欄位(例如 description TEXT)拆到另一張表,讓熱資料頁能放更多列。

水平拆分(horizontal / sharding)

同一張表的列按 shard key 分散到多個庫 / 表。每個分片 schema 相同、資料不同。解決單表過大與寫入瓶頸。

Shard key 選擇與分片策略

策略做法優點缺點
Range依 ID 或時間區間範圍查詢只打少數分片;擴容只需新增區間熱點集中在最新區間(時間序列寫入全打最後一片)
Hash(取模)hash(key) % N分布均勻N 改變時幾乎全部資料要搬;範圍查詢要打所有分片
一致性雜湊雜湊環 + 虛擬節點擴縮容只搬相鄰部分實作較複雜
目錄 / 查表(lookup)維護 key → shard 的對照表最彈性,可單獨搬遷大客戶查表本身成為依賴,需快取與高可用

選 shard key 的原則:

  • 最常見的查詢都帶有它,讓查詢只打一個分片(例如 SaaS 以 tenant_id / advertiser_id,使用者資料以 user_id)。
  • 高基數、分布均勻,避免熱點(不要用 country、status)。
  • 相關資料落在同一分片,使交易與 JOIN 留在單機。
  • 需留意超大租戶(一個大廣告主的資料量是小廣告主的萬倍),可能需要對大客戶二次拆分。
  • 預先切成較多的邏輯分片(例如 1024 個),再映射到少量實體庫,擴容時搬移邏輯分片而非重新雜湊。

分片後的問題

跨分片查詢
不帶 shard key 的查詢要 scatter-gather 到所有分片再合併,延遲由最慢的分片決定。解法:建立以其他欄位為 key 的「索引表 / 異構副本」,或同步到 Elasticsearch / OLAP 處理分析與搜尋。
跨分片排序分頁
取第 N 頁要每片都取前 N×size 再合併,深分頁極慢;改用 keyset。
跨分片交易
需要 2PC / XA 或 Saga 等分散式交易模式(見 第 10 章 分散式系統)。設計上盡量避免。
全域唯一 ID
不能再用各庫的 AUTO_INCREMENT。方案:Snowflake(時間戳 + 機器 ID + 序號)、號段模式(Leaf segment)、UUID v7,詳見 第 10 章。
擴容與資料遷移
雙寫 + 回填 + 校驗 + 切換,或使用支援線上 resharding 的中介層。

工具:Vitess(YouTube 開源,MySQL 分片中介層,支援線上 resharding)、ShardingSphere、Citus(PostgreSQL 擴充);或直接選分散式 SQL:TiDB、CockroachDB、Google Spanner、Aurora(只擴展讀與儲存,寫仍是單主)。

import hashlib

LOGICAL_SHARDS = 1024
# 邏輯分片 → 實體資料庫的對照(擴容時只改這張表並搬移對應邏輯分片)
SHARD_MAP = {range(0, 512): "mysql-a", range(512, 1024): "mysql-b"}

def logical_shard(advertiser_id: int) -> int:
    h = hashlib.md5(str(advertiser_id).encode()).digest()
    return int.from_bytes(h[:8], "big") % LOGICAL_SHARDS

def physical_db(advertiser_id: int) -> str:
    s = logical_shard(advertiser_id)
    for r, db in SHARD_MAP.items():
        if s in r:
            return db
    raise KeyError(s)

# 表名也可按邏輯分片命名:campaigns_0000 ... campaigns_1023
def table_name(advertiser_id: int) -> str:
    return f"campaigns_{logical_shard(advertiser_id):04d}"

連線池(connection pool)

為何需要:建立一條 MySQL 連線要 TCP 三次交握、(可能的)TLS 交握、認證、配置 session,耗時毫秒級;MySQL 預設每條連線一個執行緒(max_connections 預設 151),PostgreSQL 更是每條連線一個行程(數 MB 記憶體)。若每個 request 都開新連線,延遲高且資料庫很快被連線數壓垮。連線池預先建立並重複使用連線。

大小怎麼設:

  • 不是越大越好。資料庫能真正並行執行的數量受 CPU 核心數與磁碟限制,連線過多只會增加 context switch 與鎖競爭,吞吐反而下降。
  • 常見起點公式(出自 PostgreSQL wiki,HikariCP 文件引用):connections = (核心數 × 2) + 有效磁碟數。這是資料庫端的總量起點,再以壓測調整。
  • 總連線數 = 每個 instance 的池大小 × instance 數。K8s 上服務擴到 100 個 pod,每個 pod 池大小 20 → 2000 條連線,很容易超過 DB 上限。需要在中間加 ProxySQL / PgBouncer(transaction pooling)/ RDS Proxy 做連線多工。
  • 重要參數:pool_size、max_overflow、取連線逾時 pool_timeout(應快速失敗而非無限等待)、pool_recycle(小於 MySQL wait_timeout,預設 8 小時,避免拿到被 server 關掉的連線)、pool_pre_ping(借出前檢查)。
from sqlalchemy import create_engine, text

engine = create_engine(
    "mysql+pymysql://app:secret@db-primary:3306/ads?charset=utf8mb4",
    pool_size=10,          # 常駐連線數
    max_overflow=5,        # 尖峰時可額外建立的連線數
    pool_timeout=2,        # 取不到連線 2 秒就拋例外(fail fast,避免請求堆積)
    pool_recycle=1800,     # 30 分鐘回收,避免被 server 端 wait_timeout 或 LB 斷線
    pool_pre_ping=True,    # 借出前 ping,自動丟棄壞連線
)

with engine.connect() as conn:          # 離開 with 自動歸還連線池(不是關閉 TCP)
    rows = conn.execute(text("SELECT id, name FROM campaigns WHERE advertiser_id = :aid"),
                        {"aid": 3}).all()

# 監控:engine.pool.status() → 目前借出、閒置、overflow 數量;應輸出成 metrics
常見陷阱

(1) 借出連線後在交易中做慢 I/O(呼叫外部 API),連線長時間被占用 → 池枯竭,所有請求在 pool_timeout 排隊。(2) 多行程(gunicorn prefork、multiprocessing)在 fork 前就建立 engine,子行程共用同一個 socket → 資料錯亂;要在 fork 後建立,或呼叫 engine.dispose(close=False)。(3) PgBouncer transaction pooling 模式下不能依賴 session 狀態(SET、advisory lock;prepared statements 在較新版本才有支援)。

Appier 情境

高 QPS 的投放服務(RTB 需在約 100ms 內回應)通常不會在請求路徑上直接打 MySQL:campaign 設定會預先載入到記憶體或 Redis,定期 / 透過變更事件刷新;MySQL 只服務後台管理與報表。面試中描述「熱路徑不碰 RDBMS、RDBMS 是 source of truth」通常是加分的架構意識。

SQL 面試題實戰區(15 題) 高頻

以下題目以 MySQL 8.0 語法為主(支援 window function 與 CTE),PostgreSQL 差異會註明。面試時的答題節奏:先確認表結構與邊界條件(NULL、平手、重複、時區)→ 講思路 → 寫 SQL → 說明索引與效能。

先記住 SQL 的邏輯執行順序,很多題目的錯誤都源於此:

FROM / JOIN → WHERE → GROUP BY → HAVING → SELECT(含 window function)→ DISTINCT → ORDER BY → LIMIT/OFFSET

所以:WHERE 不能用 SELECT 中定義的別名、不能用聚合函數;window function 的結果不能直接放在 WHERE,要包一層子查詢或 CTE;ORDER BY 可以用別名。

題 1:JOIN 種類圖解

範例資料:campaigns 有 id 1, 2, 3;clicks 的 campaign_id 有 2, 3, 3, 4(4 是孤兒資料)。

JOIN集合意義結果(campaign.id, click.campaign_id)
INNER JOINA ∩ B(2,2) (3,3) (3,3)
LEFT JOINA 全部,B 對不到補 NULL(1,NULL) (2,2) (3,3) (3,3)
LEFT JOIN ... WHERE B.key IS NULLA − B(anti-join)(1,NULL)
RIGHT JOINB 全部(2,2) (3,3) (3,3) (NULL,4)
FULL OUTER JOINA ∪ B(1,NULL) (2,2) (3,3) (3,3) (NULL,4);MySQL 不支援,用 LEFT UNION RIGHT 模擬
CROSS JOIN笛卡兒積 A × B3 × 4 = 12 列
SELF JOIN表與自己 JOIN例如員工與主管、同使用者相鄰事件
flowchart LR subgraph A["campaigns (A)"] A1["1"] A2["2"] A3["3"] end subgraph B["clicks (B)"] B2["2"] B3a["3"] B3b["3"] B4["4 孤兒"] end A2 --- B2 A3 --- B3a A3 --- B3b
-- MySQL 模擬 FULL OUTER JOIN
SELECT c.id, k.campaign_id FROM campaigns c LEFT JOIN clicks k ON k.campaign_id = c.id
UNION ALL
SELECT c.id, k.campaign_id FROM campaigns c RIGHT JOIN clicks k ON k.campaign_id = c.id
WHERE c.id IS NULL;   -- 只補上右側孤兒,避免 INNER 部分重複

-- LEFT JOIN 的經典陷阱:把右表條件寫在 WHERE 會把 LEFT JOIN 變成 INNER JOIN
SELECT c.id, COUNT(k.id) AS clicks_today
FROM campaigns c
LEFT JOIN clicks k
  ON k.campaign_id = c.id
 AND k.ts >= CURDATE()          -- 正確:條件放 ON,沒點擊的 campaign 仍保留(count = 0)
GROUP BY c.id;
-- 若寫成 WHERE k.ts >= CURDATE(),k.ts 為 NULL 的列被過濾,沒點擊的 campaign 消失

題 2:GROUP BY + HAVING

找出「最近 7 天曝光數超過 10 萬、且平均 CPM 大於 2 美元」的廣告活動。

SELECT campaign_id,
       COUNT(*)                          AS imps,
       SUM(cost) / COUNT(*) * 1000       AS cpm
FROM impressions
WHERE ts >= NOW() - INTERVAL 7 DAY     -- WHERE:分組前過濾「列」,能用索引
GROUP BY campaign_id
HAVING COUNT(*) > 100000                -- HAVING:分組後過濾「組」,可用聚合
   AND SUM(cost) / COUNT(*) * 1000 > 2
ORDER BY imps DESC;
常見陷阱

(1) 能放 WHERE 的條件不要放 HAVING(例如 HAVING campaign_id = 7),否則先聚合全部再丟掉。(2) ONLY_FULL_GROUP_BY(5.7+ 預設開啟)下,SELECT 中非聚合欄位必須出現在 GROUP BY 或與之函數相依,否則報錯——這是好事,舊版會回傳任意一列的值。(3) MySQL 允許 HAVING 使用 SELECT 別名,PostgreSQL 不允許,可攜寫法是重複聚合表達式。

題 3:第 N 高的薪資(LeetCode 176 / 177)

邊界:有重複薪資時「第 2 高」指的是第 2 個不同的值;不存在時回傳 NULL。

-- 方法 1:DISTINCT + LIMIT OFFSET;外層包一層 SELECT 讓「沒有結果」變成 NULL
SELECT (
  SELECT DISTINCT salary FROM employees
  ORDER BY salary DESC
  LIMIT 1 OFFSET 1          -- 第 N 高就是 OFFSET N-1
) AS second_highest;

-- 方法 2:DENSE_RANK(相同薪資同名次,名次連續)
SELECT MAX(salary) AS nth_highest      -- 聚合在沒有列時回傳 NULL
FROM (
  SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
  FROM employees
) t
WHERE rnk = 2;

-- 方法 3:不用 window function(舊版 MySQL)
SELECT MAX(salary) FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);

-- 方法 4:相關子查詢(概念題):比它大的不同薪資恰好有 N-1 個
SELECT DISTINCT e1.salary FROM employees e1
WHERE 1 = (SELECT COUNT(DISTINCT e2.salary) FROM employees e2 WHERE e2.salary > e1.salary);

題 4:每組 Top N(ROW_NUMBER / RANK / DENSE_RANK)

每個廣告主花費最高的前 3 個活動(LeetCode 185 Department Top Three Salaries 的變形)。先搞清楚三個排名函數的差異(花費 100, 90, 90, 80):

spendROW_NUMBERRANKDENSE_RANK
100111
90222
90322
8044(跳號)3(不跳號)
WITH ranked AS (
  SELECT advertiser_id, campaign_id, spend,
         DENSE_RANK() OVER (PARTITION BY advertiser_id ORDER BY spend DESC) AS rnk
  FROM campaign_spend
)
SELECT advertiser_id, campaign_id, spend
FROM ranked
WHERE rnk <= 3                  -- window 結果只能在外層過濾
ORDER BY advertiser_id, rnk;
-- 「嚴格只要 3 筆」用 ROW_NUMBER(需加 tie-breaker:ORDER BY spend DESC, campaign_id)
-- 「前 3 個不同金額,平手都要」用 DENSE_RANK
-- PostgreSQL 取每組第 1 筆還有專屬語法:SELECT DISTINCT ON (advertiser_id) ... ORDER BY advertiser_id, spend DESC

效能:window function 需要依 PARTITION BY + ORDER BY 排序,索引 (advertiser_id, spend) 可避免 filesort。資料量極大時可用 LATERAL(MySQL 8.0.14+ / PG)對每組各取 LIMIT 3,每組都走索引。

題 5:連續登入 N 天的使用者

技巧:「日期 − 該使用者內的序號」對連續日期會得到相同的錨點。例如 11/01、11/02、11/03、11/05 的序號是 1, 2, 3, 4,相減得 10/31、10/31、10/31、11/01 → 前三天是同一個連續段。

WITH daily AS (                                   -- 先去重:同一天多次登入只算一次
  SELECT DISTINCT user_id, DATE(login_at) AS d
  FROM logins
),
numbered AS (
  SELECT user_id, d,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY d) AS rn
  FROM daily
),
grp AS (
  SELECT user_id, d, DATE_SUB(d, INTERVAL rn DAY) AS anchor
  FROM numbered
)
SELECT user_id, MIN(d) AS streak_start, MAX(d) AS streak_end, COUNT(*) AS days
FROM grp
GROUP BY user_id, anchor
HAVING COUNT(*) >= 3;              -- 連續 3 天以上
-- PostgreSQL:d - (ROW_NUMBER() OVER (...))::int AS anchor

-- 另一種解法:LAG 比對前一天,適合找「斷點」
SELECT user_id, d,
       CASE WHEN DATEDIFF(d, LAG(d) OVER (PARTITION BY user_id ORDER BY d)) = 1
            THEN 0 ELSE 1 END AS is_new_streak
FROM daily;
-- 再對 is_new_streak 做 running SUM 就得到 streak 編號(gaps and islands 經典解法)

題 6:留存率(retention)

定義:某天首次活躍(新使用者)的人中,第 1 天 / 第 7 天後仍活躍的比例。

WITH activity AS (
  SELECT DISTINCT user_id, DATE(event_ts) AS d FROM events
),
first_day AS (
  SELECT user_id, MIN(d) AS cohort_day FROM activity GROUP BY user_id
)
SELECT f.cohort_day,
       COUNT(DISTINCT f.user_id) AS new_users,
       COUNT(DISTINCT CASE WHEN a.d = f.cohort_day + INTERVAL 1 DAY THEN a.user_id END)
         / COUNT(DISTINCT f.user_id) AS d1_retention,
       COUNT(DISTINCT CASE WHEN a.d = f.cohort_day + INTERVAL 7 DAY THEN a.user_id END)
         / COUNT(DISTINCT f.user_id) AS d7_retention
FROM first_day f
LEFT JOIN activity a
  ON a.user_id = f.user_id
 AND a.d IN (f.cohort_day + INTERVAL 1 DAY, f.cohort_day + INTERVAL 7 DAY)
GROUP BY f.cohort_day
ORDER BY f.cohort_day;
-- 最近 7 天內的 cohort 還無法計算 D7(分母仍在、分子必為 0),報表上應顯示為 NULL 而不是 0%
實戰提示

要確認「留存」的定義:是「恰好第 N 天活躍」(classic retention)、「第 N 天或之後任一天活躍」(unbounded / rolling retention),還是「第 N 週內」?面試中主動問定義是加分項。上億事件時這類查詢不會在 MySQL 跑,而是在 Spark / BigQuery / ClickHouse(ClickHouse 有 retention() 聚合函數)。

題 7:廣告 CTR 計算(impressions / clicks 表)

表:impressions(id, campaign_id, ts)、clicks(id, impression_id, campaign_id, ts)。求每個活動昨天的曝光、點擊、CTR。

-- 錯誤示範:直接 JOIN 再 COUNT,會因一對多造成重複計數(fan-out),且 JOIN 兩張大表很貴
-- SELECT i.campaign_id, COUNT(i.id), COUNT(c.id) FROM impressions i LEFT JOIN clicks c ON ... GROUP BY ...

-- 正確:先各自聚合,再 JOIN 聚合後的小結果
WITH imp AS (
  SELECT campaign_id, COUNT(*) AS impressions
  FROM impressions
  WHERE ts >= CURDATE() - INTERVAL 1 DAY AND ts < CURDATE()
  GROUP BY campaign_id
),
clk AS (
  SELECT campaign_id, COUNT(DISTINCT impression_id) AS clicks   -- 同一曝光多次點擊只算一次
  FROM clicks
  WHERE ts >= CURDATE() - INTERVAL 1 DAY AND ts < CURDATE()
  GROUP BY campaign_id
)
SELECT imp.campaign_id,
       imp.impressions,
       COALESCE(clk.clicks, 0)                                AS clicks,
       ROUND(COALESCE(clk.clicks, 0) / NULLIF(imp.impressions, 0), 4) AS ctr
FROM imp
LEFT JOIN clk ON clk.campaign_id = imp.campaign_id
ORDER BY ctr DESC;
Appier 情境:追問會往哪裡走

(1) 點擊可能發生在曝光的隔天(跨日歸因)——「昨天的 CTR」是用點擊發生時間還是曝光時間?以曝光為基準時要 JOIN 回曝光表或在點擊事件中帶曝光時間;(2) 無效流量(bot、重複點擊)過濾;(3) 除以零用 NULLIF;(4) 低曝光數的 CTR 不可信,排序時可用貝氏平滑 (clicks + α) / (impressions + α + β);(5) 資料量上億時,改為串流預聚合到 campaign_hourly_stats,查報表只讀彙總表(見 第 12 章)。

題 8:每日 DAU 與 DAU/MAU

-- 每日 DAU
SELECT DATE(event_ts) AS d, COUNT(DISTINCT user_id) AS dau
FROM events
WHERE event_ts >= '2026-10-01' AND event_ts < '2026-11-01'
GROUP BY DATE(event_ts)
ORDER BY d;

-- 每日的「滾動 30 天 MAU」與黏著度 DAU/MAU:COUNT(DISTINCT) 不能直接當 window function 用,改用自連接日期範圍
WITH daily_users AS (
  SELECT DISTINCT DATE(event_ts) AS d, user_id FROM events
),
days AS (SELECT DISTINCT d FROM daily_users)
SELECT days.d,
       COUNT(DISTINCT CASE WHEN du.d = days.d THEN du.user_id END) AS dau,
       COUNT(DISTINCT du.user_id)                                  AS mau_30d
FROM days
JOIN daily_users du
  ON du.d BETWEEN days.d - INTERVAL 29 DAY AND days.d
GROUP BY days.d
ORDER BY days.d;

效能:GROUP BY DATE(event_ts) 會在每列計算函數,但這裡是全範圍掃描聚合所以可接受;WHERE 條件必須寫成範圍才能用索引。大規模的 distinct count 實務上用 HyperLogLog 近似(Redis PFADD/PFCOUNT、BigQuery APPROX_COUNT_DISTINCT),誤差約 1% 以內但記憶體極省(見 第 6 章)。

題 9:7 日移動平均

WITH daily AS (
  SELECT DATE(ts) AS d, SUM(cost) AS spend
  FROM impressions
  WHERE campaign_id = 7
  GROUP BY DATE(ts)
)
SELECT d, spend,
       AVG(spend) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)  AS ma7_rows,
       SUM(spend) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative
FROM daily
ORDER BY d;

-- ROWS 是「往前 6 列」:若某天完全沒資料(缺日期),會錯拿到更早的日期!
-- 解法 1:用 RANGE 按值計算(MySQL 8 支援日期 RANGE INTERVAL)
--   AVG(spend) OVER (ORDER BY d RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW)
--   但缺日期時分母仍只算有資料的天數;要把缺的天當 0,需先補齊日期(見題 14 遞迴 CTE 產生日期序列)
-- 解法 2:前 6 天資料不足 7 天時,可用 COUNT(*) OVER (...) 判斷並輸出 NULL

題 10:找出重複資料並刪除

-- 找出重複:同一使用者、同一曝光被重複上報的點擊
SELECT impression_id, COUNT(*) AS cnt
FROM clicks
GROUP BY impression_id
HAVING COUNT(*) > 1;

-- 刪除重複、保留 id 最小的那筆
-- MySQL 不允許 DELETE 時在子查詢直接讀同一張表(Error 1093),用 JOIN 自連接:
DELETE c1
FROM clicks c1
JOIN clicks c2
  ON c1.impression_id = c2.impression_id
 AND c1.id > c2.id;

-- 或用 window function(包一層衍生表繞過 1093 限制)
DELETE FROM clicks
WHERE id IN (
  SELECT id FROM (
    SELECT id, ROW_NUMBER() OVER (PARTITION BY impression_id ORDER BY id) AS rn
    FROM clicks
  ) t WHERE t.rn > 1
);

-- 最後加上唯一約束,從根本防止再次發生;之後用 INSERT IGNORE / ON DUPLICATE KEY 冪等寫入
ALTER TABLE clicks ADD UNIQUE KEY uk_imp (impression_id);
常見陷阱

線上大表一次 DELETE 數百萬列會產生巨型交易、鎖大量列、造成複製延遲。正確做法是先把要刪的 id 撈出來,分批(例如每批 1000 筆)刪除並 sleep,或建新表只放去重後的資料再 rename 切換。

題 11:NULL 行為陷阱

SQL 是三值邏輯:TRUE、FALSE、UNKNOWN。任何與 NULL 的比較(包括 NULL = NULL)結果都是 UNKNOWN,WHERE 只保留 TRUE。

表達式結果說明
NULL = NULLNULL(UNKNOWN)要用 IS NULL;MySQL 有 NULL-safe 等號 <=>,PG 用 IS NOT DISTINCT FROM
1 + NULLNULL算術傳染;用 COALESCE(x, 0)
COUNT(*) vs COUNT(col)不同COUNT(col) 不計 NULL
AVG(col)忽略 NULL分母是非 NULL 列數;若 NULL 代表 0,要 AVG(COALESCE(col, 0))
SUM 空集合NULL(不是 0)COALESCE(SUM(x), 0)
x NOT IN (1, NULL)永遠不是 TRUE等價於 x != 1 AND x != NULL,後者 UNKNOWN → 整體查無資料
GROUP BY、DISTINCTNULL 視為同一組與比較運算不同
ORDER BYMySQL 升冪 NULL 在前;PG 升冪 NULL 在後PG 可用 NULLS FIRST/LAST
UNIQUE 索引允許多個 NULLMySQL 與 PG 預設皆然(PG 15+ 可設 NULLS NOT DISTINCT)
-- 經典 bug:找出「沒有被點擊過的曝光」
-- 若 clicks.impression_id 中有任何一列是 NULL,這個查詢會回傳 0 列!
SELECT id FROM impressions
WHERE id NOT IN (SELECT impression_id FROM clicks);

-- 安全寫法 1:NOT EXISTS(不受 NULL 影響,且優化器通常轉成 anti-join)
SELECT i.id FROM impressions i
WHERE NOT EXISTS (SELECT 1 FROM clicks c WHERE c.impression_id = i.id);

-- 安全寫法 2:LEFT JOIN ... IS NULL
SELECT i.id FROM impressions i
LEFT JOIN clicks c ON c.impression_id = i.id
WHERE c.id IS NULL;

題 12:UNION vs UNION ALL

  • UNION 會去重:需要排序或雜湊整個結果集(EXPLAIN 會看到 UNION RESULT 與 Using temporary)。
  • UNION ALL 直接串接,不去重,快得多。
  • 原則:確定沒有重複、或重複是有意義的(例如合併多個分表的事件計數)時,一律用 UNION ALL。
  • 欄位數與型別要對應;欄位名稱取第一個 SELECT 的;ORDER BY / LIMIT 作用於整個結果時要放最後(子查詢內的 ORDER BY 需搭配 LIMIT 才有意義)。
-- 合併曝光與點擊成一條事件流
SELECT 'imp' AS type, campaign_id, ts FROM impressions WHERE ts >= CURDATE()
UNION ALL
SELECT 'click', campaign_id, ts FROM clicks WHERE ts >= CURDATE()
ORDER BY ts DESC
LIMIT 100;

-- 把 OR 改寫成 UNION ALL 以利用兩個不同索引(注意兩邊可能重疊時要排除)
SELECT * FROM campaigns WHERE advertiser_id = 3
UNION ALL
SELECT * FROM campaigns WHERE name = 'Black Friday' AND advertiser_id <> 3;

題 13:子查詢 vs JOIN vs EXISTS

「找出有任何點擊的活動」三種寫法:

-- IN 子查詢(semi-join)
SELECT id, name FROM campaigns
WHERE id IN (SELECT campaign_id FROM clicks WHERE ts >= CURDATE());

-- EXISTS(相關子查詢,找到一筆就停)
SELECT id, name FROM campaigns c
WHERE EXISTS (SELECT 1 FROM clicks k WHERE k.campaign_id = c.id AND k.ts >= CURDATE());

-- JOIN:一對多會產生重複列,需要 DISTINCT(成本更高),語意也較不直接
SELECT DISTINCT c.id, c.name
FROM campaigns c JOIN clicks k ON k.campaign_id = c.id
WHERE k.ts >= CURDATE();
  • MySQL 5.5 以前 IN (subquery) 常被執行成「對外表每一列跑一次子查詢」,所以有「用 JOIN 取代 IN」的老建議。5.6+ 有 semi-join 優化(materialization、FirstMatch、LooseScan 等),8.0.16+ 也會把 EXISTS 轉成 semi-join,現在三者效能常常相近,用 EXPLAIN 實測才是正解。
  • 語意:只需判斷「存在與否」用 EXISTS / IN(semi-join);需要右表欄位才用 JOIN。「不存在」用 NOT EXISTS(NULL 安全)。
  • 相關子查詢放在 SELECT 清單中(scalar subquery)對每一列執行一次,大結果集時要注意。

題 14:CTE 與遞迴 CTE

CTE(WITH)讓複雜查詢分段可讀;MySQL 8.0+ 與 PG 都支援。PG 12 以前 CTE 一定會被物化(optimization fence),12+ 若只被引用一次會被內聯;需要時可用 MATERIALIZED / NOT MATERIALIZED 指定。

-- 遞迴 CTE 1:產生連續日期,補齊報表缺的日子(缺的天顯示 0)
WITH RECURSIVE dates AS (
  SELECT DATE('2026-10-01') AS d
  UNION ALL
  SELECT d + INTERVAL 1 DAY FROM dates WHERE d < '2026-10-31'
)
SELECT dates.d, COALESCE(s.clicks, 0) AS clicks
FROM dates
LEFT JOIN campaign_daily_stats s ON s.dt = dates.d AND s.campaign_id = 7
ORDER BY dates.d;
-- MySQL 遞迴深度預設上限 cte_max_recursion_depth = 1000;PG 可直接用 generate_series()

-- 遞迴 CTE 2:組織 / 帳號階層(代理商 → 子帳號 → 廣告主),找出某節點底下所有後代
WITH RECURSIVE tree AS (
  SELECT id, parent_id, name, 0 AS depth, CAST(id AS CHAR(500)) AS path
  FROM accounts WHERE id = 1                     -- 錨點(anchor member)
  UNION ALL
  SELECT a.id, a.parent_id, a.name, t.depth + 1, CONCAT(t.path, '/', a.id)
  FROM accounts a
  JOIN tree t ON a.parent_id = t.id              -- 遞迴成員(recursive member)
  WHERE t.depth < 20                             -- 防止資料中有環造成無限遞迴
)
SELECT * FROM tree ORDER BY path;

題 15:條件聚合(pivot)與累計花費

把每個活動各裝置的曝光數轉成欄位,並計算當日累計花費(預算 pacing 用)。

-- Pivot:用 CASE / SUM(條件) 做條件聚合,一次掃描算出多個指標
SELECT campaign_id,
       SUM(device = 'ios')     AS ios_imps,       -- MySQL 中布林即 0/1
       SUM(device = 'android') AS android_imps,
       SUM(CASE WHEN device NOT IN ('ios', 'android') THEN 1 ELSE 0 END) AS other_imps
FROM impressions
WHERE ts >= CURDATE()
GROUP BY campaign_id;
-- PostgreSQL 寫法:COUNT(*) FILTER (WHERE device = 'ios')

-- 每小時花費與當日累計,找出何時超過日預算
WITH hourly AS (
  SELECT campaign_id, DATE_FORMAT(ts, '%Y-%m-%d %H:00') AS hr, SUM(cost) AS spend
  FROM impressions
  WHERE ts >= CURDATE()
  GROUP BY campaign_id, hr
),
running AS (
  SELECT h.*, SUM(spend) OVER (PARTITION BY campaign_id ORDER BY hr) AS cum_spend
  FROM hourly h
)
SELECT r.campaign_id, MIN(r.hr) AS budget_exhausted_at
FROM running r
JOIN campaigns c ON c.id = r.campaign_id
WHERE r.cum_spend >= c.daily_budget
GROUP BY r.campaign_id;
面試官想聽到什麼

寫完 SQL 後主動補充:「這個查詢在 (campaign_id, ts) 上需要索引;若資料量很大,我會改成串流預聚合每小時統計;window function 的 ORDER BY 沒指定 frame 時,預設是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,排序值平手的列會被視為同一組一起加總。」這種對細節與規模的意識,是 Appier 這類資料量大的公司特別看重的。

SQL Injection、參數化查詢與 ORM 取捨 基礎

SQL injection

當使用者輸入被字串拼接進 SQL,攻擊者就能改變 SQL 的結構。

# 危險:字串拼接 / f-string
name = request.args["name"]                 # 攻擊者輸入:' OR '1'='1
cur.execute(f"SELECT * FROM users WHERE name = '{name}'")
# 實際執行:SELECT * FROM users WHERE name = '' OR '1'='1'  → 回傳所有使用者
# 輸入 '; DROP TABLE users; -- 在允許多語句的驅動上更可怕

# 安全:參數化查詢(placeholder),值與 SQL 結構分開傳送
cur.execute("SELECT * FROM users WHERE name = %s", (name,))       # PyMySQL / mysqlclient
cur.execute("SELECT * FROM users WHERE name = $1", name)          # asyncpg(PostgreSQL)
session.execute(text("SELECT * FROM users WHERE name = :n"), {"n": name})   # SQLAlchemy

# IN 清單:產生對應數量的 placeholder,而不是拼接值
ids = [3, 5, 8]
placeholders = ", ".join(["%s"] * len(ids))
cur.execute(f"SELECT * FROM campaigns WHERE id IN ({placeholders})", ids)

# 識別字(表名、欄名、排序方向)無法參數化 → 一律用白名單
ALLOWED_SORT = {"created_at", "name", "daily_budget"}
sort = request.args.get("sort", "created_at")
if sort not in ALLOWED_SORT:
    raise ValueError("invalid sort column")
direction = "DESC" if request.args.get("desc") == "1" else "ASC"
cur.execute(f"SELECT id, name FROM campaigns ORDER BY {sort} {direction} LIMIT %s", (50,))
  • 為什麼參數化能防禦:server-side prepared statement 把 SQL 先解析成執行計畫,參數只會被當成「值」,無法改變語法樹。PyMySQL 屬於 client-side 參數替換,但驅動會正確跳脫字串,同樣安全——前提是用驅動的 placeholder,而不是自己做字串格式化。
  • 縱深防禦:資料庫帳號最小權限(應用程式帳號不給 DROP / GRANT)、關閉多語句、WAF、錯誤訊息不要把 SQL 錯誤細節回給前端、ORM 的 raw query 也要參數化。
  • 第二階攻擊(second-order injection):先存入惡意字串,之後在別處被拼接執行——從資料庫讀出的資料也不可信。

ORM 的取捨

ORM(SQLAlchemy ORM、Django ORM)Raw SQL / Query builder(SQLAlchemy Core)
開發速度快,CRUD 幾乎不用寫 SQL較慢
可移植性容易換資料庫(理論上)綁方言
安全預設參數化需自律
效能可控性容易產生 N+1、多餘欄位、低效 SQL;Unit of Work 物件追蹤有 CPU 與記憶體成本完全掌握;批次、upsert、window function 寫起來直接
適用業務邏輯複雜的後台 / CRUD 服務報表、批次、高效能熱路徑
from datetime import datetime
from decimal import Decimal
from sqlalchemy import BigInteger, ForeignKey, Numeric, String, create_engine, func, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.orm import (DeclarativeBase, Mapped, Session, mapped_column,
                            relationship, selectinload)

class Base(DeclarativeBase):
    pass

class Campaign(Base):
    __tablename__ = "campaigns"
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    advertiser_id: Mapped[int] = mapped_column(BigInteger, index=True)
    name: Mapped[str] = mapped_column(String(255))
    daily_budget: Mapped[Decimal] = mapped_column(Numeric(12, 4))
    version: Mapped[int] = mapped_column(default=0)
    creatives: Mapped[list["Creative"]] = relationship(back_populates="campaign", lazy="raise")
    __mapper_args__ = {"version_id_col": version}   # 內建樂觀鎖:UPDATE 自動帶 WHERE version = ?

class Creative(Base):
    __tablename__ = "creatives"
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    campaign_id: Mapped[int] = mapped_column(ForeignKey("campaigns.id"))
    format: Mapped[str] = mapped_column(String(32))
    campaign: Mapped[Campaign] = relationship(back_populates="creatives")

engine = create_engine("mysql+pymysql://app:secret@db:3306/ads", pool_pre_ping=True)

with Session(engine) as session, session.begin():          # begin() 結束時自動 commit / 例外時 rollback
    # 1) ORM 查詢 + 預先載入,避免 N+1(lazy="raise" 會讓意外的 lazy load 直接報錯)
    stmt = (select(Campaign)
            .where(Campaign.advertiser_id == 3)
            .options(selectinload(Campaign.creatives))
            .order_by(Campaign.id)
            .limit(50))
    for c in session.scalars(stmt):
        print(c.name, [cr.format for cr in c.creatives])

    # 2) 聚合查詢:直接用 Core 風格,回傳 tuple 而不是物件,較省資源
    rows = session.execute(
        select(Campaign.advertiser_id, func.count(), func.sum(Campaign.daily_budget))
        .group_by(Campaign.advertiser_id)
    ).all()

    # 3) 悲觀鎖
    camp = session.scalars(select(Campaign).where(Campaign.id == 7).with_for_update()).one()
    camp.daily_budget = Decimal("500.0000")                  # flush 時發出 UPDATE(帶 version 檢查)

    # 4) 批次 upsert:用方言專屬語法,ORM 物件逐筆 add 太慢
    stmt = mysql_insert(Campaign.__table__).values([
        {"id": 101, "advertiser_id": 3, "name": "A", "daily_budget": 100, "version": 0},
        {"id": 102, "advertiser_id": 3, "name": "B", "daily_budget": 200, "version": 0},
    ])
    stmt = stmt.on_duplicate_key_update(name=stmt.inserted.name,
                                        daily_budget=stmt.inserted.daily_budget)
    session.execute(stmt)
面試官想聽到什麼

「你會用 ORM 嗎?」成熟的回答是「看情境」:CRUD 與領域模型用 ORM 提升開發效率與安全;熱路徑、報表、批次寫入用 Core / raw SQL;並且知道 ORM 底層產生了什麼 SQL(開 echo、看 EXPLAIN),會用 eager loading 解 N+1、知道 session 的生命週期(一個 request 一個 session,不要跨執行緒共用)。

面試自測題

為什麼 InnoDB 用 B+ tree 而不是 B tree、hash 或紅黑樹?

瓶頸在磁碟 I/O,資料以 16KB 頁為單位讀取。B+ tree 的內部節點只存 key 與子頁指標,扇出可達上千,千萬級資料樹高約 3 層,而且上層節點常駐 buffer pool,一次查找只需 1~2 次 I/O。紅黑樹扇出只有 2,樹高數十層;B tree 內部節點也存資料,扇出較小,且範圍查詢要在各層間來回;hash 只支援等值查詢,不支援範圍、排序、最左前綴。B+ tree 葉子以雙向鏈結串列相連,範圍掃描與 ORDER BY 很有效率,且每次查詢都走到葉子,效能穩定。追問:頁分裂與為何主鍵要遞增——隨機主鍵會在樹中間插入導致頁分裂與碎片,遞增主鍵永遠追加在最右葉子。

估算一棵 3 層 B+ tree 大約能存多少列?

頁 16KB;BIGINT 主鍵 8 bytes + 指標約 6 bytes = 14 bytes,內部頁約可放 1170 個指標;若每列 1KB,葉子頁放 16 列。3 層 ≈ 1170 × 1170 × 16 ≈ 2,190 萬列。所以千萬級資料用主鍵查詢只需 3 次頁讀取,且前兩層通常在記憶體中。若主鍵用 UUID 字串,內部節點每筆變大、扇出下降、樹變高,所有二級索引也跟著變大。

什麼是聚簇索引、回表、覆蓋索引?

InnoDB 的表本身就是以主鍵組織的 B+ tree(聚簇索引),葉子存整列資料。二級索引葉子存「索引欄位 + 主鍵」,查到主鍵後若還需要其他欄位,要再去聚簇索引查一次,稱為回表。若查詢所需欄位都在二級索引中(含主鍵),就不必回表,稱為覆蓋索引,EXPLAIN Extra 顯示 Using index。優化手段:把常用查詢欄位加進複合索引、只 SELECT 需要的欄位、PG 用 INCLUDE。PostgreSQL 是 heap table,所有索引都指向 ctid,沒有聚簇索引。

複合索引 (a, b, c),以下哪些條件能用到索引:a=1 AND c=3、b=2、a>1 AND b=2、a=1 ORDER BY b?

a=1 AND c=3:a 用於定位,c 因 b 斷掉無法定位,但可透過 ICP 在索引上過濾。b=2:缺少最左欄位,一般無法用(MySQL 8.0.13+ 在 a 的不同值很少時可能用 skip scan)。a>1 AND b=2:只有 a 用於範圍定位,範圍之後的 b 無法定位(可 ICP 過濾)。a=1 ORDER BY b:a 等值後 b 有序,可避免 filesort。原則:等值欄位在前、範圍欄位在後,並盡量覆蓋查詢欄位。

列舉索引失效的常見情境。

(1) 對索引欄位做函數或運算,如 DATE(ts) = ...,應改寫為範圍或用函數索引;(2) 隱式型別轉換,VARCHAR 欄位與數字比較時 MySQL 會把欄位轉成數字;(3) JOIN 兩邊字元集或 collation 不同;(4) LIKE '%x' 前導萬用字元;(5) OR 連接無索引的欄位;(6) 違反最左前綴、範圍條件之後的欄位;(7) 低選擇性或命中比例過高,優化器判斷全表掃描更便宜;(8) 統計資訊過時導致選錯計畫。要強調一切以 EXPLAIN 與成本估算為準,避免說「絕對不走索引」。

EXPLAIN 要看哪些欄位?Using filesort 與 Using temporary 代表什麼?

重點看 type(const/eq_ref/ref/range 好,index/ALL 要警覺)、key(實際用的索引)、key_len(複合索引用到幾欄)、rows 與 filtered(預估掃描量)、Extra。Using index 是覆蓋索引;Using filesort 表示無法利用索引順序,需要額外排序(不一定落磁碟);Using temporary 表示 GROUP BY / DISTINCT / UNION 需要內部暫存表。解法通常是讓 ORDER BY / GROUP BY 欄位與索引順序一致。EXPLAIN 是預估,EXPLAIN ANALYZE(MySQL 8.0.18+、PG)會實際執行並給出真實時間與列數,估計與實際差距大時要更新統計資訊。

ACID 的四個特性分別由什麼機制實現?

原子性靠 undo log,rollback 時依 undo 還原;持久性靠 redo log(WAL),commit 時 redo log 先 fsync,資料頁之後由背景刷盤,當機後用 redo 重做;隔離性靠 MVCC(快照讀)與鎖(當前讀);一致性是目標,由前三者加上約束與應用邏輯共同保證。追問 redo 與 binlog 的差異與兩階段提交:redo 是引擎層物理日誌、循環寫、用於崩潰復原;binlog 是 server 層邏輯日誌、追加寫、用於複製與 PITR;commit 時先 redo prepare → 寫 binlog → redo commit,確保兩者一致。

四種隔離等級分別解決了哪些問題?MySQL 與 PostgreSQL 的預設是什麼?

READ UNCOMMITTED 什麼都不防;READ COMMITTED 防髒讀;REPEATABLE READ 再防不可重複讀;SERIALIZABLE 再防幻讀。MySQL InnoDB 預設 RR,且快照讀透過 MVCC、當前讀透過 next-key lock,大致避免了幻讀;PostgreSQL 預設 RC,其 RR 是快照隔離,SERIALIZABLE 使用 SSI,衝突時讓交易失敗需重試。也要提快照隔離仍有 write skew 問題,以及很多公司把 MySQL 改成 RC 以減少間隙鎖與死結。

說明 MVCC 的運作:read view 怎麼判斷版本可見?RC 與 RR 的差別在哪?

每列有隱藏欄位 DB_TRX_ID 與 DB_ROLL_PTR,舊版本存在 undo log 中形成版本鏈。read view 記錄建立時的活躍交易 ID 列表 m_ids、最小活躍 ID、下一個將分配的 ID、建立者 ID。版本的 trx_id 是自己的、或小於最小活躍 ID 則可見;大於等於下一個分配 ID 則不可見;在 m_ids 中則不可見,否則可見;不可見就沿版本鏈往前找。RC 每條 SELECT 建立新的 read view,所以能看到別人新提交的資料;RR 在交易第一次快照讀時建立並重用。長交易會讓舊版本無法 purge,造成 undo 膨脹。

什麼是 gap lock 與 next-key lock?為什麼需要意向鎖?

InnoDB 行鎖加在索引上。record lock 鎖單一索引記錄;gap lock 鎖兩個記錄之間的間隙,防止插入;next-key lock 是 record + 前面間隙,形成左開右閉區間,是 RR 下的預設加鎖單位,用來防止當前讀的幻讀;唯一索引等值命中時退化為 record lock。意向鎖是表級的 IS/IX,交易加行鎖前先加,讓要加表鎖的操作只檢查表上的意向鎖,不必掃描每一列的鎖狀態。追問:UPDATE 的 WHERE 沒走索引會鎖住大量列。

樂觀鎖和悲觀鎖如何實作?各適合什麼場景?

悲觀鎖:交易中 SELECT ... FOR UPDATE 先鎖住列再修改,保證成功但降低併發、可能死結,適合衝突頻繁。樂觀鎖:加 version 欄位,UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?,影響列數為 0 表示衝突,重試或回 409,適合讀多寫少、衝突少。很多情況其實一句原子 UPDATE(SET balance = balance - ? WHERE id = ? AND balance >= ?)就夠了,不需要先 SELECT。分散式場景還可用 Redis 分散式鎖,但要考慮鎖過期與 fencing token。

舉一個死結的例子,並說明如何預防與處理。

T1 鎖 row 1 後要 row 2,T2 鎖 row 2 後要 row 1,形成循環等待。InnoDB 透過 wait-for graph 偵測並回滾代價較小的交易,回傳 1213。預防:固定加鎖順序(例如按 ID 排序)、縮短交易、WHERE 走索引避免鎖大量列、批次按主鍵順序處理、適度使用 RC 減少 gap lock。處理:應用程式捕捉 1213/1205 並以退避重試整個交易;用 SHOW ENGINE INNODB STATUS 或 innodb_print_all_deadlocks 分析。

為什麼 LIMIT 20 OFFSET 1000000 很慢?怎麼改?

資料庫必須掃描並丟棄前一百萬列(可能還需回表),成本與 offset 成正比;而且翻頁時資料變動會造成重複或遺漏。解法:keyset pagination,用上一頁最後一筆的排序鍵當游標 WHERE (ts, id) < (?, ?) ORDER BY ts DESC, id DESC LIMIT 20,搭配 (ts, id) 索引,每頁成本固定;排序鍵需唯一(加 id 打破平手)。必須支援跳頁時可用延遲關聯(先在覆蓋索引上取 id 再回表)或限制最大頁數。

主從複製延遲有哪些原因?如何處理 read-your-writes?

原因:從庫重放並行度不足、大交易、從庫規格較差或跑重查詢、無主鍵表的 ROW 重放、網路。對策:開啟平行複製、拆大交易、監控延遲並自動摘除落後從庫。read-your-writes:寫入後一段時間該使用者讀主庫、關鍵讀永遠讀主庫、記錄寫入的 GTID 並在從庫等待追上(WAIT_FOR_EXECUTED_GTID_SET)、前端樂觀更新。也可提非同步與半同步的差異:半同步只保證從庫收到 relay log,不保證已重放,所以仍有讀取延遲。

什麼時候需要分庫分表?shard key 怎麼選?分片後有哪些問題?

先窮盡索引優化、快取、讀寫分離、垂直拆分、歸檔冷資料;當寫入 QPS 或單機容量真的到極限才分片。shard key 要是最常用查詢都帶有的欄位、高基數且分布均勻、讓相關資料與交易留在同一片(如 advertiser_id、user_id),並預先切成大量邏輯分片映射到少數實體庫以利擴容。問題:跨分片查詢與排序分頁(scatter-gather)、跨分片交易(2PC/Saga)、全域唯一 ID(Snowflake)、熱點大租戶、resharding 遷移。也可考慮 Vitess 或 TiDB 等分散式 SQL。

連線池為什麼需要?大小怎麼設?K8s 上擴容時要注意什麼?

建立連線需 TCP/TLS 交握與認證,且 DB 每條連線耗用執行緒或行程資源,重用連線可降低延遲並保護 DB。池大小不是越大越好,DB 的實際並行能力受 CPU 與磁碟限制,過多連線增加 context switch 與鎖競爭;起點可用 (核心數 × 2) + 有效磁碟數 作為 DB 端總量,再壓測調整。總連線數 = 每個實例池大小 × 實例數,水平擴容時容易超過 max_connections,需要 ProxySQL / PgBouncer / RDS Proxy 做多工。另外設定取連線逾時(fail fast)、pool_recycle 小於 wait_timeout、pre_ping,並避免交易中做慢 I/O 佔住連線。

寫 SQL:每個廣告活動的 CTR,要注意什麼?

先分別對 impressions 與 clicks 按 campaign_id 聚合,再 LEFT JOIN 聚合結果,避免直接 JOIN 原始大表造成一對多重複計數與巨大中間結果;點擊以 COUNT(DISTINCT impression_id) 去重;clicks / NULLIF(impressions, 0) 避免除以零,無點擊用 COALESCE 補 0。追問:點擊跨日歸因要以曝光時間為準、過濾無效流量、低曝光 CTR 不穩定可做貝氏平滑、海量資料改用串流預聚合的小時彙總表或 OLAP。

為什麼 WHERE id NOT IN (SELECT x FROM t) 可能回傳空結果?

SQL 是三值邏輯。若子查詢結果含 NULL,id NOT IN (1, NULL) 等價於 id <> 1 AND id <> NULL,後者為 UNKNOWN,整體永遠不為 TRUE,所以沒有任何列被選出。改用 NOT EXISTS 或 LEFT JOIN ... WHERE right.key IS NULL,或在子查詢中加 WHERE x IS NOT NULL。相關陷阱:COUNT(col) 不計 NULL、AVG 忽略 NULL、SUM 空集合回傳 NULL、NULL = NULL 不為真。

大表要加索引或欄位,如何不影響線上服務?

先確認操作支援的演算法:MySQL 8 的 ALGORITHM=INSTANT 可秒加欄位;加索引可用 INPLACE, LOCK=NONE,但開始與結束需要短暫 MDL 排他鎖,且從庫重放時可能造成延遲。執行前檢查並處理長交易,避免 MDL 等待阻塞後續所有查詢;設定 lock_wait_timeout 讓 DDL 取不到鎖時快速失敗。更安全的是 gh-ost(讀 binlog、無觸發器、可節流與暫停、依複製延遲降速)或 pt-online-schema-change(觸發器)。在離峰時段執行並監控複製延遲。

如何防範 SQL injection?表名或排序欄位能參數化嗎?

一律使用驅動的參數化查詢(placeholder),讓值與 SQL 結構分離;不要用 f-string 或字串拼接。IN 清單要產生對應數量的 placeholder。表名、欄名、排序方向等識別字無法參數化,必須用白名單驗證。縱深防禦:DB 帳號最小權限、不回傳 SQL 錯誤細節、關閉多語句、注意二階注入(從 DB 讀出的資料也不可信)。ORM 預設安全,但 raw query 與 text() 仍要用綁定參數。