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.
Bài tập làm thử
Bài 1 (đọc hiểu). Giải thích hai pha mà một câu lệnh SQL có tham số phải trải qua ở phía database, và tại sao pha PREPARE có thể được tách ra chạy chỉ một lần khi chạy N truy vấn cùng câu lệnh.
Đáp án
Hai pha: PREPARE (phân tích cú pháp + lập kế hoạch thực thi — chọn index, cách join; đắt) và EXECUTE (chạy kế hoạch đã lập với tham số cụ thể; rẻ). Vì PREPARE cho cùng một câu lệnh luôn cho ra cùng một kế hoạch bất kể tham số truyền vào khác nhau, nên nếu chạy N lần cùng câu lệnh, chỉ cần PREPARE một lần rồi EXECUTE N lần với tham số khác nhau — tránh lặp lại công việc phân tích/lập kế hoạch giống hệt nhau N lần.
Bài 2 (sửa lỗi). Đoạn mã sau chạy 500 truy vấn cùng một câu lệnh nhưng chậm không cần thiết. Chỉ ra vấn đề và viết lại theo cách tối ưu mà bài đề xuất.
for i := 0; i < 500; i++ {
db.QueryRowContext(ctx, "SELECT x FROM t WHERE id=?", i).Scan(&x)
}
Đáp án
Vấn đề: với driver không có cơ chế cache tự động, mỗi lần gọi QueryRowContext với một câu lệnh có tham số buộc database/sql thực hiện trọn chu trình Prepare→Exec→Close ngầm — trả giá PREPARE cho cả 500 lần dù câu lệnh y hệt. Sửa lại bằng cách prepare một lần rồi tái dùng sql.Stmt:
stmt, err := db.PrepareContext(ctx, "SELECT x FROM t WHERE id=?")
if err != nil { /* xử lý lỗi */ }
defer stmt.Close()
for i := 0; i < 500; i++ {
stmt.QueryRowContext(ctx, i).Scan(&x)
}
Theo số đo trong bài, cách này giảm từ 500 prepare/1.98s xuống 1 prepare/673ms — nhanh 2,9 lần.
Bài 3 (vận dụng thực tế). Dự án của bạn dùng driver pgx để kết nối PostgreSQL, và một đồng nghiệp đề xuất tự gọi db.PrepareContext thủ công ở mọi nơi để "chắc chắn được lợi ích 2,9 lần như bài đo". Dựa trên đánh đổi trong bài, đây có phải luôn là quyết định đúng không?
Đáp án
Không hẳn. Bài nhấn mạnh: "driver hiện đại thường đã tự cache" — 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 thì nó tự prepare và nhớ, lần sau trên cùng kết nối tự tái dùng mà không cần gọi Prepare tay. Con số 2,9x đo trên một driver không có cache sẵn; với pgx, lợi ích của việc tự Prepare có thể nhỏ hơn nhiều, thậm chí gần như 0. Nguyên tắc đúng là đo trên driver thật của dự án trước khi quyết định tự quản lý sql.Stmt.
Bài 4 (bẫy/đánh đổi). Nhiều goroutine cùng dùng chung một sql.Stmt đã prepare, dưới tải cao. Theo bài, vì sao số lần PREPARE thực tế có thể không đơn giản là "1 lần" như ví dụ một-goroutine?
Đáp án
Vì sql.Stmt gắn với kết nối cụ thể trong pool mà nó được prepare lần đầu. Nếu goroutine khác muốn dùng cùng stmt đó nhưng kết nối gốc đang bận (một goroutine khác đang dùng nó), database/sql phải prepare lại câu lệnh trên một kết nối rảnh khác trong pool. Dưới tải cao với nhiều goroutine dùng chung một stmt, số lần prepare thực tế có thể nhân lên theo số kết nối khác nhau trong pool đã từng phải prepare lại câu lệnh đó.
Bài 5 (đọc hiểu số liệu / bảo mật). Từ số đo QueryRow mỗi lần: 500 prepare, 1.98s và Prepare 1 lần: 1 prepare, 673ms, tính lại xem nếu N=5000 (thay vì 500) thì theo mô hình chi phí của bài (PREPARE 2ms, EXEC 0.5ms), hai cách sẽ mất khoảng bao lâu? Ngoài lợi ích tốc độ, dùng tham số hóa (placeholder ?/$1) còn mang lại lợi ích nào khác không liên quan tới hiệu năng?
Đáp án
Không tái dùng: 5000 × (2ms + 0.5ms) = 5000 × 2.5ms = 12.5s. Tái dùng: 2ms (1 lần) + 5000 × 0.5ms = 2ms + 2500ms ≈ 2.502s — chênh lệch còn lớn hơn (khoảng 5x) vì phần chi phí một-lần PREPARE ngày càng nhỏ so với tổng khi N tăng. Về lợi ích khác: tham số hóa còn là biện pháp chống SQL injection cốt lõi — vì dữ liệu tham số được tách khỏi câu lệnh SQL nên không bao giờ bị hiểu nhầm thành cú pháp SQL, nên bắt buộc dùng dù lợi ích hiệu năng có nhỏ tới đâu.
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.