Một đầu bếp giỏi chuẩn bị sẵn nguyên liệu (mise en place) để khỏi phải thái hành mỗi lần có đơn — nghe luôn có lợi. Nhưng tưởng tượng anh ta chuẩn bị sẵn một thực đơn cố định cho "món trung bình", rồi cứ thế nấu cho mọi khách mà không nhìn khách gọi gì: người gọi món chay cũng nhận một đĩa như người gọi món mặn. Với khách trung bình thì ổn; với một khách đặc biệt hiếm gặp thì thành thảm họa. Prepared statement của PostgreSQL có đúng hai mặt đó — tiết kiệm công chuẩn bị, nhưng cái "thực đơn chung" nó dựng sẵn đôi khi sai kinh khủng cho một tham số đặc biệt. Và bài này là lần tôi tưởng chuẩn bị sẵn luôn tốt, rồi đo ra một cái bẫy chậm gấp trăm lần.

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.

Cái bẫy: 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 (đúng cái "thực đơn cố định cho món trung bình"). 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ề đến code hằng ngày

Điều đầ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, database/sql của Go với tham số $1/?, psycopg cùng họ) đều làm việc này; đừng ghép chuỗi SQL bằng tay. Đây là chỗ prepared statement không phải tùy chọn tối ưu mà là mặc định bắt buộc.

Điều 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.

Gói lại bằng số: 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.

Và cái bẫy sâu nhất ở đây — tối ưu bằng cách "chuẩn bị chung một lần" đánh mất thông tin riêng của từng lần chạy — lặp lại ở mọi nơi có caching hay biên dịch sẵn. JIT của JVM biên dịch một phương thức dựa trên hồ sơ đã thấy, rồi nếu giả định đó sai với dữ liệu mới, nó phải deoptimize — đúng cái giá của một kế hoạch dựng sẵn gặp đầu vào bất thường. Một hàm memoize cache kết quả theo khóa chung có thể trả về đáp án cũ khi ngữ cảnh đã đổi. Một CDN cache một trang "trung bình" phục vụ sai cho người dùng cần bản cá nhân hóa. Mẫu số chung đáng mang theo: mọi tối ưu kiểu "làm sẵn một lần dùng nhiều lần" đều đánh cược rằng các lần chạy đủ giống nhau — nên khi dữ liệu lệch hay ngữ cảnh biến thiên, hãy hỏi cái bản dựng sẵn đó có còn đúng cho trường hợp cụ thể trước mắt không, chứ đừng tin nó nhanh vì nó bỏ qua bước tính lại.

Muốn tự thấy thì 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.