Khi bạn gửi một câu lệnh SQL có tham số tới database, nó không chạy ngay. Nó đi qua hai pha: đầu tiên DB phải phân tích cú pháp câu lệnh và lập kế hoạch thực thi (chọn index nào, join thế nào) — gọi là PREPARE, và đây là phần đắt. Sau đó nó chạy kế hoạch đó với tham số cụ thể — gọi là EXECUTE, phần này rẻ.
Điểm mấu chốt: nếu bạn chạy cùng một câu lệnh N lần (chỉ khác tham số), thì phần PREPARE — phân tích và lập kế hoạch — cho ra kết quả y hệt mỗi lần. Prepare lại N lần là lãng phí thuần túy. Prepared statement caching là kỹ thuật prepare một lần rồi tái dùng cho cả N lần execute. Bài này giải thích cơ chế, đo thật lợi ích, và — quan trọng — chỉ ra vì sao driver hiện đại thường đã tự làm việc này cho bạn.
Prepared statement là gì và vì sao rẻ hơn
// Một câu lệnh có tham số đi qua 2 pha ở DB:
// 1. PREPARE: phân tích cú pháp + lập kế hoạch thực thi (đắt)
// 2. EXECUTE: chạy kế hoạch với tham số cụ thể (rẻ)
Vì PREPARE cho cùng câu lệnh luôn ra cùng kế hoạch, ta muốn tách nó thành chi phí một lần thay vì trả nó cho mỗi lần.
Cách 1: QueryRow mỗi lần (không tái dùng)
for i := 0; i < N; i++ {
db.QueryRowContext(ctx, "SELECT x WHERE id=?", i).Scan(&x)
}
Với một driver không có đường tắt (fast-path), mỗi lần gọi QueryContext với câu lệnh có tham số buộc database/sql làm trọn chu trình Prepare → Exec → Close dưới nền. Nghĩa là bạn trả giá PREPARE cho mỗi truy vấn, dù câu lệnh y hệt.
Cách 2: Prepare một lần, tái dùng stmt
stmt, _ := db.PrepareContext(ctx, "SELECT x WHERE id=?")
defer stmt.Close()
for i := 0; i < N; i++ {
stmt.QueryRowContext(ctx, i).Scan(&x) // chỉ EXECUTE
}
Ở đây PrepareContext chạy đúng một lần, trả về một sql.Stmt tái dùng được. N truy vấn sau chỉ gửi tham số và chạy kế hoạch đã có — bỏ hẳn phần phân tích + lập kế hoạch lặp lại.

Hình 1: Prepared statement có hai pha PREPARE (đắt) và EXECUTE (rẻ); tái dùng sql.Stmt prepare một lần thay vì mỗi lần; và cạm bẫy sql.Stmt gắn với kết nối trong pool cùng ghi chú driver hiện đại tự cache.
Đo thật: 500 truy vấn, prepare 500 lần hay 1 lần
Dùng driver giả lập với PREPARE tốn 2ms (mô phỏng round-trip phân tích + lập kế hoạch) và EXECUTE tốn 0.5ms, chạy 500 truy vấn cùng một câu lệnh:
Cách số Prepare thời gian tb/truy vấn
QueryRow mỗi lần (không tái dùng) 500 1.98s 3.96ms
Prepare 1 lần rồi tái dùng stmt 1 673ms 1.346ms
Giảm số lần Prepare: 500 -> 1 | nhanh hơn: 2.9x
Con số rõ ràng:
- Không tái dùng: mỗi truy vấn trả giá PREPARE 2ms + EXEC 0.5ms, tổng 500 lần prepare → 1.98s.
- Tái dùng: PREPARE 2ms một lần, rồi 500 lần chỉ EXEC 0.5ms → 673ms.
- Nhanh 2.9 lần, và số lần prepare giảm từ 500 xuống 1.
Bản chất là tách chi phí một-lần (phân tích + lập kế hoạch) khỏi chi phí mỗi-lần (chạy kế hoạch). Khi không tái dùng, chi phí một-lần bị lặp thừa N lần; caching xóa phần lặp đó. Càng chạy nhiều lần cùng câu lệnh, tiết kiệm càng lớn.

Hình 2: Đo thật 500 truy vấn cùng câu lệnh — prepare mỗi lần: 500 prepare, 1.98s; prepare một lần rồi tái dùng: 1 prepare, 673ms, nhanh 2.9 lần bằng cách tách chi phí một-lần khỏi chi phí mỗi-lần.
Đánh đổi cần cân nhắc
Driver hiện đại thường đã tự cache — đây là điểm quan trọng nhất. Con số 2.9x ở trên đo trên một driver không có cache statement. Nhưng các driver PostgreSQL hiện đại như pgx tự động cache prepared statement theo từng kết nối: lần đầu chạy một câu lệnh trên một kết nối, nó prepare và nhớ; lần sau trên cùng kết nối, nó tái dùng — mà bạn không cần gọi Prepare tay. Nghĩa là với pgx, lợi ích của việc tự Prepare nhỏ hơn nhiều (thậm chí bằng 0) so với con số này. Luôn đo trên driver thật của bạn thay vì giả định — kết quả phụ thuộc hoàn toàn vào driver và DB.
sql.Stmt gắn với kết nối, và điều này tương tác phức tạp với pool. Một sql.Stmt nhớ nó được prepare trên kết nối nào trong pool. Nếu bạn tái dùng stmt đó nhưng kết nối gốc đang bận (goroutine khác đang dùng), database/sql phải prepare lại câu lệnh trên một kết nối rảnh khác. Với một stmt dùng chung nhiều goroutine dưới tải cao, số lần prepare thật có thể nhân lên theo số kết nối trong pool — không đơn giản là "1 lần" như ví dụ một-goroutine ở trên. Đây là lý do việc tự quản lý sql.Stmt trong ứng dụng đồng thời phức tạp hơn vẻ ngoài.
Tham số hóa còn chống SQL injection — lợi ích kép. Dù bàn về hiệu năng, đừng quên: dùng tham số (? hoặc $1) thay vì nối chuỗi vào SQL là biện pháp chống SQL injection cốt lõi. Prepared statement tách dữ liệu khỏi câu lệnh nên tham số không bao giờ bị hiểu thành SQL. Ngay cả khi lợi ích hiệu năng nhỏ (vì driver tự cache), tham số hóa vẫn là bắt buộc vì lý do an ninh.
Ba ý mang về
- Prepared statement tách hai pha PREPARE (đắt, một-lần) và EXECUTE (rẻ, mỗi-lần): chạy cùng câu lệnh N lần thì nên prepare một lần rồi tái dùng — đo thật, 500 truy vấn giảm từ 500 prepare/1.98s xuống 1 prepare/673ms, nhanh 2.9 lần.
- Driver hiện đại thường tự cache statement theo kết nối (pgx làm điều này), nên lợi ích của việc tự
Preparetay có thể nhỏ hoặc bằng 0 — luôn đo trên driver thật của bạn thay vì giả định con số cố định. sql.Stmtgắn với kết nối trong pool: dưới đồng thời cao, pool có thể prepare lại trên kết nối khác nên số prepare thật không đơn giản là 1 — và nhớ tham số hóa còn là biện pháp chống SQL injection cốt lõi, bắt buộc bất kể hiệu năng.
Phần sau ta xét một tối ưu ghi dữ liệu lớn: batch insert — vì sao chèn từng dòng một chậm hơn nhiều lần so với gộp nhiều dòng vào một câu lệnh, và cách làm đúng trong Go, đo bằng số thật.