Thứ Ba tuần trước mình nhận một ticket: endpoint tra đơn hàng theo mã khách hàng chạy mất 4 giây. Bảng orders có 2 triệu dòng, và người viết trước đã thêm index cho cột customer_code đàng hoàng. Vậy mà mở EXPLAIN ANALYZE ra, Postgres vẫn đang Seq Scan — index nằm đó nhưng planner không thèm đụng tới.
Đây là kiểu lỗi mình gặp hoài khi làm backend: index không thiếu, chỉ là điều kiện viết sai chỗ. Dưới đây là cách mình đọc EXPLAIN và mấy nguyên nhân khiến index bị bỏ qua.
EXPLAIN ANALYZE — nhìn gì trước?
Đừng đọc cả cây plan từ trên xuống. Mình thường nhìn 4 thứ theo thứ tự:
- Node Type:
Seq Scanlà quét cả bảng,Index Scanlà đi qua index. ThấySeq Scantrên bảng lớn là có mùi. - rows estimate vs actual: dòng đầu plan ghi
rows=50nhưngactual rows=180000là dấu hiệu statistic cũ hoặc điều kiện quá mơ hồ. - loops: một node chạy
loops=1500gần như chắc chắn là N+1 hoặc join thiếu index. - Filter vs Index Cond:
Filter:nghĩa là dòng đã đọc lên rồi mới lọc — index không tham gia.
Helper nhỏ mình hay nhét vào repo để không phải gõ tay:
import { pool } from "./db";
type PlanNode = {
"Node Type": string;
"Actual Rows": number;
"Actual Total Time": number;
"Index Name"?: string;
Filter?: string;
};
export async function explain(sql: string, params: unknown[] = []): Promise<PlanNode> {
const { rows } = await pool.query(
`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${sql}`,
params,
);
const plan: PlanNode = rows[0]["QUERY PLAN"][0].Plan;
console.log(
`[${plan["Node Type"]}]${plan["Index Name"] ? " " + plan["Index Name"] : ""}`,
`rows=${plan["Actual Rows"]}`,
`time=${plan["Actual Total Time"].toFixed(2)}ms`,
);
return plan;
}
Bốn lý do index bị bỏ qua
1. Bọc hàm lên cột
Query gốc là WHERE lower(customer_code) = lower($1). Postgres không biết lower(customer_code) là gì cho tới khi đọc từng dòng, nên index trên customer_code thành vô dụng. Cách chữa: hoặc lưu sẵn dạng chuẩn hoá, hoặc tạo functional index đúng biểu thức:
CREATE INDEX orders_customer_lower_idx ON orders (lower(customer_code));
2. Lệch kiểu dữ liệu
customer_code là text, nhưng code JS truyền xuống kiểu khác hoặc tham số bị cast ngầm khiến index không khớp. Với Drizzle thì type đã ép từ schema, mình chỉ cần chắc là cột và tham số cùng kiểu:
import { pgTable, text, timestamp, uuid, index } from "drizzle-orm/pg-core";
export const orders = pgTable(
"orders",
{
id: uuid("id").primaryKey().defaultRandom(),
customerCode: text("customer_code").notNull(),
status: text("status").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
// cột đẳng thức trước, cột range sau — đúng thứ tự mới dùng được
index("orders_customer_status_created_idx").on(
t.customerCode,
t.status,
t.createdAt.desc(),
),
],
);
3. Composite index sai thứ tự cột
Đây là lỗi tốn tiền nhất. Index (created_at, customer_code) không giúp gì cho WHERE customer_code = $1 vì nguyên tắc leftmost prefix: chỉ dùng được phần bên trái liên tục. Mình đặt cột so sánh bằng (customer_code, status) lên trước, cột range (created_at) xuống cuối, rồi ORDER BY created_at sát luôn được index đọc theo thứ tự có sẵn, khỏi sort.
4. Planner thấy Seq Scan rẻ hơn — và nó đúng
Nếu điều kiện lấy ra 40% bảng thì quét tuần tự rẻ hơn nhảy index rồi về heap. Bảng nhỏ, hoặc statistic vừa ANALYZE xong cũng cho kết quả tương tự. Gặp case này thì đừng ép index, hãy hỏi lại: query có thật sự cần lấy nhiều dòng vậy không?
Kết quả thật
Sau khi tạo functional index và đổi thứ tự cột composite:
- Query tra theo mã khách hàng: 4.1s xuống 12ms.
- Query theo khách hàng + trạng thái, sắp theo thời gian: từ
Sort180k dòng sangIndex Scan40 dòng. - Thêm
ANALYZE ordersđịnh kỳ, độ lệch estimate/actual từ 20x còn dưới 1.5x. - Tổng thêm 38MB index — đổi bằng 2.4s latency, rất đáng.
Một điều nữa: mình đặt luôn test so plan trong CI. Cách này không bắt được mọi thứ nhưng đủ để ai đó thêm lower() vào cột là pipeline đỏ ngay, chứ không đợi tới lúc production chậm rồi mới mò.
Kết
Đọc EXPLAIN ANALYZE không khó, khó là bỏ thói quen "thấy chậm thì thêm index". Index chỉ là một nửa câu chuyện, nửa còn lại là query có viết đúng để planner dùng được nó hay không.
Bạn có đang giữ plan trong CI không, hay vẫn để production làm người review? Mình tò mò coi team khác xử lý chuyện này thế nào.
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| Seq Scan | Quét toàn bộ bảng, đọc từng dòng rồi lọc |
| Index Scan | Đi qua index để tìm đúng dòng, rồi lấy dữ liệu |
| Bitmap Heap Scan | Đọc index theo lô, gom con trỏ rồi quét heap một lượt |
| Leftmost prefix | Chỉ dùng được index composite khi các cột bên trái nhất được so sánh liên tục |
| Functional index | Index tạo trên biểu thức (ví dụ lower(cột)), không phải cột trần |
| Filter vs Index Cond | Filter là lọc sau khi đọc dòng; Index Cond là điều kiện được index dùng để tìm |
| Loops | Số lần một node trong plan được thực thi, cao bất thường là dấu hiệu N+1 |