Đây là bài cuối của sê-ri "Cơ sở dữ liệu cho lập trình viên". Suốt bốn mươi bốn bài trước, ta mổ xẻ PostgreSQL từng mảnh một — chỉ mục, kế hoạch truy vấn, MVCC, khóa, WAL, bộ đệm, checkpoint — và mỗi bài đều đi theo một khuôn giống nhau: đo thật, và để phép đo — chứ không phải trực giác — nói lời cuối cùng, thường là lật ngược điều ta tưởng. Bài này gói tất cả lại thành một quy trình tối ưu bạn dùng được mọi ngày, và minh họa bằng một vòng đo trọn vẹn từ đầu đến cuối. Nếu chỉ mang theo được một câu từ cả sê-ri, hãy để nó là câu này: đừng đoán khi bạn có thể đo.

Tối ưu truy vấn đúng cách

Quy trình bốn bước: đo, đừng đoán

Cách sai để tối ưu là bắt đầu bằng linh cảm — "chắc truy vấn này chậm vì thiếu index" hay "chắc do JOIN" — rồi sửa theo phỏng đoán. Nó sai vì phỏng đoán thường trật, và bạn tốn công sửa nhầm chỗ. Cách đúng là một vòng lặp có đo:

Bước 1 — Đo trước, tìm đúng thủ phạm. Đừng tối ưu truy vấn bạn cảm thấy chậm; tối ưu truy vấn tốn tổng thời gian nhiều nhất. Như bài pg_stat_statements đã đo, một truy vấn nhẹ gọi hàng triệu lần ngốn nhiều thời gian máy chủ hơn một báo cáo nặng chạy vài lần — xếp theo total_exec_time, không theo thời gian một lần.

Bước 2 — Đọc kế hoạch thật. Chạy EXPLAIN ANALYZE và đọc những gì CSDL thực sự làm, không đoán: có Seq Scan trên bảng lớn không? Rows Removed by Filter có khổng lồ không (dấu hiệu quét thừa)? Ước lượng số hàng có lệch xa thực tế không (dấu hiệu thống kê cũ)?

Bước 3 — Sửa một thứ. Thêm một index đúng cột hoặc biểu thức; viết lại truy vấn để planner dùng được index (tránh bọc cột trong hàm); cập nhật thống kê. Một thay đổi mỗi lần, để biết cái gì thật sự có tác dụng. Sửa ba thứ cùng lúc rồi thấy nhanh hơn thì bạn không biết cái nào giúp, cái nào vô ích, hay cái nào thậm chí làm tệ đi mà bị hai cái kia che.

Bước 4 — Đo lại, ở đúng điều kiện. Chạy lại và so số trang, thời gian. Đo ở điều kiện giống thật (cache nóng hay lạnh, scale đủ lớn) — bài benchmark đã cho thấy một con số đo sai điều kiện là vô nghĩa. Nếu nhanh hơn thật, khép vòng; nếu không, quay lại bước 2.

Một vòng trọn vẹn, đo từ đầu đến cuối

Để bốn bước không chỉ là lý thuyết, đây là một ca thật. Tôi có bảng don 2 triệu hàng (100MB), và một truy vấn tra đơn theo khách: SELECT * FROM don WHERE khach = 12345.

Bước 1–2, đo và đọc kế hoạch (chưa có index trên khach):

Parallel Seq Scan on don
  Buffers: shared hit=11458 read=1281   (~12739 trang)
  Rows Removed by Filter: 666653  (mỗi worker)
  thời gian: ~17 ms

Kế hoạch nói thẳng vấn đề: PostgreSQL quét cả 2 triệu hàng chỉ để tìm ra 40 hàng khớp. Rows Removed by Filter khổng lồ là dấu hiệu kinh điển của một truy vấn đáng lẽ dùng index mà không có. Như bài quét chỉ mục đã đo, đây đúng là chỗ index biến một lần quét cả bảng thành một cú tra vài trang.

Bước 3, sửa một thứ: CREATE INDEX ON don(khach);

Bước 4, đo lại:

Bitmap Index Scan on don_khach_idx
  Index Cond: (khach = 12345)
  Buffers: shared hit=37 read=6   (43 trang)
  thời gian: ~0,15 ms

Từ 12739 trang xuống 43 trang, từ 17ms xuống 0,15ms — nhanh khoảng 113 lần, đọc ít hơn khoảng 296 lần. Vòng đo khép lại bằng một con số không cãi được: cùng truy vấn, cùng dữ liệu, chỉ khác một index, và EXPLAIN chứng minh đúng thứ đã đổi (Seq Scan thành Index Scan). Không phỏng đoán, không tranh luận — đo là xong.

Bốn nguyên tắc gói cả sê-ri

Nhìn lại 45 bài, có bốn câu lặp đi lặp lại, và chúng là cốt lõi của "đo thật":

Một con số bất khả là công cụ đang nói dối, hoặc bạn đo nhầm đại lượng. Khi bốn kiểu dữ liệu cùng cho 35MB, đó không phải "kiểu không quan trọng" mà là header hàng che mất khác biệt. Khi read=12760 mà thời gian vẫn ổn định, đó không phải đọc đĩa mà là OS cache.

Hai con số mâu thuẫn nghĩa là có biến ẩn. VACUUM chạy mà bảng vẫn phình — vì một giao dịch dài giữ snapshot. Con số cao đáng ngờ luôn có một biến bạn chưa kiểm — cache nóng hay lạnh, thống kê mới hay cũ, một phiên khác đang giữ khóa, một tầng cache thứ hai bạn quên mất.

Tin đồng hồ và số trang hơn con số ước lượng. cost của planner là phỏng đoán từ thống kê; actual timeBuffers là sự thật đo được. Khi hai cái lệch, tin cái đo.

"Nhanh" vô nghĩa nếu chưa hỏi "đúng và bền chưa". synchronous_commit=off nhanh gấp đôi nhưng đánh đổi độ bền; một benchmark 200k tps là vô nghĩa nếu đo sai điều kiện. Tốc độ không phải mục tiêu duy nhất. Một truy vấn nhanh mà trả sai kết quả, hay một cấu hình nhanh mà mất dữ liệu khi sập, tệ hơn nhiều so với một truy vấn chậm mà đúng và bền.

Vì sao điều này quan trọng khi lập trình

Hệ quả bao trùm cả sê-ri: cơ sở dữ liệu không phải hộp đen, và bạn không cần đoán về nó. Mọi thứ trong bài này — kế hoạch truy vấn, số trang đọc, trang bẩn, kích thước index, tps — đều đo được bằng công cụ có sẵn trong chính PostgreSQL. Khi một truy vấn chậm, bạn không cần mê tín hay copy một "mẹo tối ưu" từ mạng; bạn chạy EXPLAIN ANALYZE, đọc sự thật, sửa một thứ, đo lại. Con số mang theo — cũng là con số mang theo của cả sê-ri: quy trình tối ưu đúng là đo trước (pg_stat_statements xếp theo tổng thời gian) → đọc kế hoạch thật (EXPLAIN ANALYZE) → sửa một thứ → đo lại ở đúng điều kiện; một ví dụ khép vòng: seq scan 12739 trang 17ms thành index scan 43 trang 0,15ms (nhanh 113 lần) chỉ bằng một index đúng chỗ. Đo thật lật ngược trực giác; số bất khả là đo nhầm; số không kèm điều kiện là nói dối; và nhanh phải kèm đúng và bền. Đừng tối ưu theo linh cảm — đo. Và khi con số hiện ra trái với điều bạn tin, hãy tin con số: gần như suốt cả sê-ri này, đúng lúc đó mới là lúc học được điều gì đó thật.

Thử ba mươi giây

Lần tới một truy vấn của bạn chậm, đừng đoán. Đặt EXPLAIN (ANALYZE, BUFFERS) trước nó và đọc ba thứ: nút quét (có Seq Scan trên bảng lớn không?), Rows Removed by Filter (có quét thừa không?), và Buffers (đọc bao nhiêu trang?). Ba con số đó gần như luôn chỉ thẳng vấn đề — thường là một index còn thiếu hay một điều kiện bọc cột trong hàm. Sửa đúng một thứ, chạy lại EXPLAIN (ANALYZE, BUFFERS), và xem số trang tụt xuống. Nửa phút đó là toàn bộ tinh thần của sê-ri này gói trong một thói quen: đo, đọc, sửa, đo lại — đừng bao giờ đoán khi bạn có thể đo.