為什麼後端工程師要會這個

ORM 把 SQL 藏得很深,寫 Product.findAll({ where: { status: 'active' } }) 不用懂 index 也能跑。但查詢從 10ms 變 3s,通常不是 code 寫錯,是 index 沒加——或加錯了。

這不是 DBA 專屬的問題。後端工程師在寫 migration 時就要決定:

  • 這張 table 會怎麼被查?
  • 哪些欄位需要 index?
  • 複合 index 欄位順序對嗎?

等到上線、資料量大了再跑 EXPLAIN,改 index 要 lock table(或用 CREATE INDEX CONCURRENTLY),成本高很多。


新建 Table 的 Index 規劃流程

Step 1:先問「這張 table 會被怎麼查」

在寫 CREATE TABLE 之前,列出這張 table 預計會有的查詢模式:

orders table:
1. 查某個用戶的所有訂單(WHERE user_id = ?)
2. 查某段時間內的訂單(WHERE created_at BETWEEN ? AND ?)
3. 查某個用戶最近 X 天的訂單(WHERE user_id = ? AND created_at > ?)
4. 後台查特定狀態的訂單(WHERE status = 'pending' ORDER BY created_at)
5. 訂單詳情頁(WHERE id = ?)← PK 自動處理

先有查詢清單,才能規劃 index,不是反過來。

Step 2:自動有 Index 的欄位(不用自己加)

情況Index 類型說明
PRIMARY KEYunique btree自動建立,不用寫
REFERENCES foreign key(PostgreSQL)無!PostgreSQL 不自動加,要自己加
UNIQUE constraintunique btree自動建立

PostgreSQL 的 FK 沒有自動 index 是最常踩的坑。REFERENCES users(id) 只保證 constraint,不建 index。如果你常用 WHERE user_id = ? 查子表,這個欄位一定要手動加 index。

-- ❌ 只有 FK constraint,沒有 index
user_id UUID REFERENCES users(id) ON DELETE CASCADE
 
-- ✅ 加上 index
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
CREATE INDEX idx_orders_user_id ON orders(user_id);

Step 3:單欄位 Index

規則很簡單:WHERE 條件常出現的欄位、ORDER BY 常用的欄位,優先考慮加 index。

-- orders table 的單欄位 index
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at);

但不要無腦加——每個 index 都有寫入成本。INSERT / UPDATE / DELETE 時每個 index 都要維護。

Step 4:複合 Index(最重要也最容易搞錯)

欄位順序決定一切。

規則:選擇性高的欄位(cardinality 高)在前,等值條件(=)在前,範圍條件(>、<、BETWEEN)在後。

-- 場景:WHERE user_id = ? AND created_at > ?
-- ✅ 正確順序:等值 user_id 在前,範圍 created_at 在後
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
 
-- ❌ 反過來:created_at 在前,這個 index 對 WHERE user_id 無效
CREATE INDEX idx_orders_created_user ON orders(created_at, user_id);

複合 index 的最左前綴原則

(user_id, status, created_at) 這個 index 可以服務:

  • WHERE user_id = ?
  • WHERE user_id = ? AND status = ?
  • WHERE user_id = ? AND status = ? AND created_at > ?
  • WHERE status = ? ❌(跳過 user_id,index 無效)
  • WHERE user_id = ? AND created_at > ? ✅ 但只用到 user_id 部分,created_at 需要額外過濾
-- 這一個 index 覆蓋多個查詢場景
CREATE INDEX idx_orders_user_status_created
  ON orders(user_id, status, created_at DESC);

Step 5:Covering Index(覆蓋 index)

如果查詢只需要 index 裡的欄位(不需要回到主表取其他欄位),就是 covering index,可以省掉一次 heap fetch,效能差距在大表上很明顯。

-- 查詢:SELECT id, status, created_at FROM orders WHERE user_id = ?
-- 如果 index 包含 id、status、created_at,就不需要回表
 
-- PostgreSQL: 用 INCLUDE 加入非搜尋欄位
CREATE INDEX idx_orders_user_covering
  ON orders(user_id)
  INCLUDE (status, created_at);

不是每個 index 都要做 covering,增加 index 大小。當某個查詢是熱點路徑時才考慮。

Step 6:Partial Index(局部 index)

只對部分資料建 index,index 更小、更快、更省空間:

-- 只 index 未完成的訂單(通常遠少於總訂單數)
CREATE INDEX idx_orders_pending
  ON orders(created_at)
  WHERE status = 'pending';
 
-- 後台查詢:SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at
-- 這個 index 比全表 status index 小很多,且 PostgreSQL 可以直接用

軟刪除的 partial index

-- 沒有軟刪除過濾的 index 會掃到所有已刪除資料
CREATE INDEX idx_products_name ON products(name) WHERE deleted_at IS NULL;
 
-- unique constraint 也要配合
CREATE UNIQUE INDEX idx_products_sku_active
  ON products(sku) WHERE deleted_at IS NULL;

實際案例:電商 orders table

CREATE TABLE orders (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id),
  status      VARCHAR(20) NOT NULL DEFAULT 'pending',
  amount      INTEGER NOT NULL,
  currency    VARCHAR(3) NOT NULL DEFAULT 'TWD',
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  deleted_at  TIMESTAMPTZ
);
 
-- FK:user_id 沒有自動 index,手動加
CREATE INDEX idx_orders_user_id ON orders(user_id);
 
-- 後台:查 pending 訂單,按時間排序
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);
 
-- 用戶訂單列表:按用戶 + 時間
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);
 
-- 軟刪除:只 index 未刪除的資料
-- (如果 deleted_at IS NULL 是 WHERE 條件的話)
CREATE INDEX idx_orders_user_id_active
  ON orders(user_id)
  WHERE deleted_at IS NULL;

什麼時候不加 Index

Index 有成本:寫入(INSERT / UPDATE / DELETE)都要維護所有 index,index 越多,寫入越慢。

不加或延後加的情況:

情況原因
資料量小(< 10 萬筆)Seq scan 通常比 index scan 快,optimizer 會自己選
很少被查的欄位維護成本 > 收益
高寫入比例的 table(log、事件流)每次寫入都要維護 index,成本高
重複值極多(true/false 的布林欄位)Cardinality 低,index 幾乎沒用
-- ❌ is_deleted BOOLEAN 做 index 沒意義
-- 只有兩個值,PostgreSQL 通常選 seq scan
CREATE INDEX idx_orders_is_deleted ON orders(is_deleted);
 
-- ✅ 改用 partial index
CREATE INDEX idx_orders_not_deleted ON orders(id) WHERE is_deleted = false;

Migration 時的注意事項

生產環境加 index 要用 CONCURRENTLY,否則會 lock table:

-- ❌ 鎖表,生產環境不能跑(除非能停服)
CREATE INDEX idx_orders_user_id ON orders(user_id);
 
-- ✅ 不鎖表,build 時間更長但安全
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

CONCURRENTLY 的限制:

  • 不能在 transaction 裡跑(BEGIN/COMMIT 包著會報錯)
  • Build 時間是一般的 2–3 倍
  • 如果 build 中途失敗,會留下 INVALID 狀態的 index,要手動 DROP INDEX 再重試

驗證 Index 有沒有被用到

加完 index 後,用 EXPLAIN 確認:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 'xxx' ORDER BY created_at DESC LIMIT 20;

看輸出:

  • Index Scan using idx_orders_user_created → index 被用了 ✅
  • Seq Scan on orders → index 沒被用(可能欄位順序錯或資料量太小)

如果查詢計畫不符預期,用 SET enable_seqscan = off 強制走 index,確認 index 存在且欄位正確。


延伸閱讀