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.

Ảnh chụp đoạn mã Go nền tối minh hoạ prepared statement caching trong Go chuẩn bị một lần chạy nhiều lầ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 cộng lập kế hoạch thực thi đắt 2 EXECUTE chạy kế hoạch với tham số cụ thể rẻ cùng câu lệnh chạy N lần nên PREPARE 1 lần EXECUTE N lần thay vì phân tích cộng lập kế hoạch lại mỗi lần, cách 1 QueryRow mỗi lần không tái dùng for i nhỏ hơn N db QueryRowContext ctx SELECT x WHERE id bằng dấu hỏi i Scan x với driver không có fast-path mỗi lần database sql phải Prepare Exec Close trả giá PREPARE cho mỗi truy vấn, cách 2 Prepare một lần tái dùng stmt stmt bằng db PrepareContext ctx SELECT x WHERE id bằng dấu hỏi defer stmt Close for i nhỏ hơn N stmt QueryRowContext ctx i Scan x chỉ EXECUTE PREPARE đúng 1 lần N truy vấn sau chỉ gửi tham số cộng chạy kế hoạch, cạm bẫy sql.Stmt gắn với kết nối trong pool sql.Stmt nhớ nó được Prepare trên kết nối nào nếu kết nối đó đang bận pool phải Prepare lại trên một kết nối khác stmt dùng chung nhiều goroutine có thể nhân số prepare theo số kết nối driver hiện đại pgx tự cache statement theo từng kết nối nên thường không cần Prepare tay đo trên driver thật của bạn

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.

Ảnh chụp bảng kết quả đo thật nền tối prepared statement caching trong Go chạy bằng go run Go 1.23 arm64 driver gia lập Prepare 2ms Exec 0.5ms 500 truy vấn, chạy 500 truy vấn cùng một câu lệnh QueryRow mỗi lần không tái dùng số Prepare 500 thời gian 1.98s tb mỗi truy vấn 3.96ms Prepare 1 lần rồi tái dùng stmt số Prepare 1 thời gian 673ms tb mỗi truy vấn 1.346ms, giảm số lần Prepare 500 tới 1 nhanh hơn 2.9x không tái dùng mỗi truy vấn trả giá PREPARE 2ms cộng EXEC 0.5ms tái dùng PREPARE 2ms một lần rồi 500 lần chỉ EXEC 0.5ms càng nhiều lần chạy cùng câu lệnh tiết kiệm càng lớn, vì sao nhanh tách chi phí một lần khỏi chi phí mỗi lần không tái dùng 500 nhân 2ms prepare cộng 0.5ms exec bằng tốn prepare 500 lần tái dùng 1 nhân 2ms prepare cộng 500 nhân 0.5ms exec bằng prepare đúng 1 lần PREPARE phân tích cộng lập kế hoạch là chi phí một lần bị lặp thừa khi không tái dùng caching xóa phần lặp đó, cốt lõi 2 pha PREPARE phân tích lập kế hoạch đắt rồi EXECUTE rẻ caching Prepare 1 lần tái dùng cho N truy vấn cùng câu lệnh đo thật 500 Prepare tới 1 thời gian 1.98s tới 673ms 2.9x an ninh tham số hóa cũng chống SQL injection lợi ích kép pool sql.Stmt gắn kết nối pool có thể Prepare lại trên conn khác đánh đổi driver hiện đại pgx tự cache thường khỏi Prepare tay đo trên driver thật vì kết quả phụ thuộc driver DB

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ề

  1. 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.
  2. 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ự Prepare tay 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.
  3. sql.Stmt gắ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.