Mỗi lần PostgreSQL nhận một câu SQL, nó không chạy ngay: trước tiên phải phân tích cú pháp (parse) rồi lập kế hoạch (plan) — quyết định dùng index nào, join theo thứ tự nào. Với một truy vấn lặp đi lặp lại hàng nghìn lần, làm lại hai bước đó mỗi lần nghe có vẻ lãng phí. Prepared statement (câu lệnh chuẩn bị sẵn) sinh ra để bỏ phần lặp: phân tích và lập kế hoạch một lần, rồi chạy nhiều lần với tham số khác nhau. Nghe như luôn có lợi — nhưng khi đo, tôi thấy cả một cái bẫy khiến nó chậm hơn trăm lần.
Chuẩn bị một lần, chạy nhiều lần
Một prepared statement khai báo hình dạng truy vấn với chỗ trống cho tham số:
PREPARE p(text) AS SELECT count(*) FROM don WHERE tt = $1;
EXECUTE p('hiem'); -- chạy với tham số khác nhau
EXECUTE p('thuong');
Lần PREPARE, PostgreSQL phân tích và (thường) lập kế hoạch. Các lần EXECUTE sau chỉ việc chạy với giá trị $1 mới, không phân tích lại. Ngoài tốc độ, cách này còn có một lợi ích an ninh lớn: tham số $1 đi tách rời khỏi văn bản câu lệnh, nên một giá trị độc hại như '; DROP TABLE ... chỉ được coi là dữ liệu để so sánh, không bao giờ bị hiểu thành mã SQL. Đây chính là cách chống SQL injection đúng đắn — không phải bằng lọc chuỗi, mà bằng tách dữ liệu khỏi lệnh. Riêng lý do này đã đủ để luôn dùng tham số hóa; tốc độ chỉ là phần thưởng thêm.
Đo: lợi ích tốc độ có thật, nhưng khiêm tốn
Tôi dùng pgbench chạy cùng một truy vấn ở hai chế độ: -M simple (phân tích + lập kế hoạch mỗi lần) và -M prepared (chuẩn bị một lần). Với một truy vấn nhẹ lọc theo một index:
query nhẹ (1 bảng):
simple 11962 tps (0,084 ms)
prepared 13510 tps (0,074 ms) -> nhanh ~13%
Chỉ 13%. Tôi đã vào bài với niềm tin "prepared nhanh hơn hẳn", và con số này kéo tôi lại: với truy vấn nhẹ, bản thân việc parse+plan cũng nhẹ, nên bỏ nó đi tiết kiệm không nhiều. Lợi ích lớn dần khi kế hoạch đắt hơn. Thử với một truy vấn JOIN ba bảng, có gom nhóm và sắp xếp — nơi lập kế hoạch tốn công thật:
JOIN 3 bảng (plan đắt):
simple 2295 tps (0,436 ms)
prepared 2710 tps (0,369 ms) -> nhanh ~18%
Phần trăm nhích lên, và quan trọng hơn là lượng tuyệt đối tiết kiệm được (0,067ms mỗi lần so với 0,010ms ở truy vấn nhẹ) lớn hơn hẳn, vì kế hoạch phức tạp hơn thì việc lập lại nó tốn nhiều hơn. Với một truy vấn nhiều bảng gọi hàng vạn lần mỗi giây, phần tiết kiệm đó cộng dồn thành tải CPU thật sự trên máy chủ. Bài học đầu: prepared có lợi, nhưng lợi bao nhiêu tùy kế hoạch đắt cỡ nào và bạn gọi lại bao nhiêu lần — không phải một phép màu cố định.
Một lần tôi đo hớ: generic plan quét cả bảng
Đây mới là chỗ tôi suýt kết luận sai. Ngoài chuyện "nhanh hơn ít hay nhiều", tôi tưởng prepared statement chỉ có thể tốt hơn hoặc bằng — cùng lắm là không lợi gì. Sai. Nó có thể khiến truy vấn chậm hơn trăm lần.
Nguyên nhân là cách PostgreSQL chọn kế hoạch cho prepared statement. Năm lần đầu, nó dùng custom plan — lập kế hoạch riêng cho đúng giá trị tham số bạn đưa, tận dụng thống kê về giá trị đó. Nhưng sau vài lần, nó có thể chuyển sang generic plan — một kế hoạch chung, lập mà không biết tham số cụ thể, để khỏi phải lập lại. Với dữ liệu phân bố đều thì generic plan ổn. Với cột lệch (skewed) thì tai họa.
Tôi có bảng don với cột tt: 999.990 hàng giá trị 'thuong', chỉ 10 hàng 'hiem', và có index trên tt. Truy vấn SELECT count(*) FROM don WHERE tt = $1. Ép hai kiểu kế hoạch rồi đo:
tt = 'hiem' (chỉ 10 hàng):
CUSTOM plan -> Index Only Scan, ~0,13 ms
GENERIC plan -> Parallel Seq Scan cả bảng, ~13 ms (~100 lần chậm hơn)
Custom plan biết tham số là 'hiem' — một giá trị cực hiếm — nên chọn quét index, chạm đúng 10 hàng, xong trong 0,13ms. Generic plan không biết giá trị là gì; nó phải ước lượng theo phân bố trung bình của cột, mà trung bình thì cột này gần như toàn 'thuong', nên nó đoán truy vấn sẽ chạm rất nhiều hàng và chọn quét song song cả một triệu hàng — 13ms. Cùng một câu, cùng một tham số, chỉ khác cách lập kế hoạch, mà chậm gấp trăm lần. EXPLAIN cho thấy rõ: cost 4,45 (index) so với 13.620 (seq scan).
Cái sai của tôi là gộp hai thứ khác nhau: nhanh về phân tích/lập kế hoạch không đồng nghĩa nhanh về thực thi. Prepared statement tiết kiệm ở khâu lập kế hoạch, nhưng nếu cái kế hoạch chung đó tệ cho giá trị cụ thể, bạn thắng vài phần trăm ở parse rồi thua trăm lần ở execute. Một con số "nhanh hơn" chỉ đúng cho khâu nó đo.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: luôn dùng tham số hóa, trước hết vì an toàn. Dù bạn không quan tâm 13% tốc độ, việc tách tham số khỏi câu lệnh là tuyến phòng thủ SQL injection vững nhất — vững hơn mọi bộ lọc chuỗi tự viết. Mọi thư viện truy cập CSDL nghiêm túc (JDBC PreparedStatement, cùng họ ở các ngôn ngữ khác) đều làm việc này; đừng ghép chuỗi SQL bằng tay.
Hệ quả thứ hai: biết về generic plan để không bị nó cắn. Nếu một truy vấn tham số hóa bỗng chậm hẳn sau khi "chạy nóng" một lúc — nhanh vài lần đầu rồi đột ngột chậm — rất có thể nó vừa chuyển sang generic plan tệ trên cột lệch. PostgreSQL cho bạn điều khiển bằng plan_cache_mode (force_custom_plan bắt luôn lập kế hoạch riêng, đổi chút chi phí parse lấy kế hoạch đúng). Như bài thống kê đã đo, kế hoạch tốt phụ thuộc thống kê về giá trị cụ thể; generic plan cố ý bỏ qua thông tin đó, và trên dữ liệu lệch, bỏ qua nó là bỏ qua điều quan trọng nhất.
Con số mang theo: prepared statement phân tích+lập kế hoạch một lần rồi chạy nhiều lần — nhanh ~13% với query nhẹ, ~18% với JOIN đắt (lợi tùy kế hoạch đắt cỡ nào), và chống SQL injection nhờ tách tham số khỏi câu; nhưng sau 5 lần chạy PostgreSQL có thể chuyển generic plan, và trên cột lệch generic plan quét cả bảng 13ms thay vì index scan 0,13ms — nhanh parse không có nghĩa nhanh execute. Dùng tham số hóa mọi lúc vì an toàn; và khi một truy vấn tham số hóa chậm bất thường, nghi ngay generic plan.
Thử ba mươi giây
Trong psql, tạo một bảng lệch: CREATE TABLE t (x text); INSERT INTO t SELECT CASE WHEN g=1 THEN 'hiem' ELSE 'thuong' END FROM generate_series(1,100000) g; CREATE INDEX ON t(x); ANALYZE t;. Giờ PREPARE p(text) AS SELECT count(*) FROM t WHERE x=$1; rồi so hai kế hoạch: SET plan_cache_mode=force_custom_plan; EXPLAIN EXECUTE p('hiem'); (thấy Index Scan) và SET plan_cache_mode=force_generic_plan; EXPLAIN EXECUTE p('hiem'); (thấy Seq Scan cả bảng). Hai dòng EXPLAIN đó, trong nửa phút, cho bạn tận mắt thấy vì sao "chuẩn bị sẵn" đôi khi lại là bẫy — và vì sao thống kê về giá trị cụ thể đáng giá tới mức nào.