Có hai cách truyền thống để nói chuyện với database trong Go, và cả hai đều có nhược điểm rõ. ORM (như GORM) cho bạn viết Go, nó sinh SQL đằng sau — nhưng SQL đó bị ẩn (khó tối ưu, dễ dính N+1 query), và nó dùng reflection lúc chạy nên chậm hơn. database/sql viết tay cho bạn toàn quyền với SQL, nhưng phải Scan từng cột thủ công vào biến — dễ sai thứ tự, sai kiểu, và tệ nhất: một lỗi gõ sai tên cột chỉ lộ ra lúc chạy, có khi trên production.

sqlc lật ngược bài toán một cách thông minh: bạn viết SQL thật (schema và query), và nó sinh ra code Go an toàn kiểu từ đó. Quan trọng nhất, nó phân tích SQL của bạn so với schema ngay lúc sinh code — nên một cột sai, một kiểu lệch, một cú pháp hỏng bị bắt trước cả khi bạn biên dịch Go. Bài này chạy sqlc thật trên một schema PostgreSQL, xem code nó sinh ra, và đo thật khả năng bắt lỗi sớm.

Ý tưởng: SQL là nguồn sự thật

Với sqlc, bạn khai hai thứ bằng SQL thuần: schema (các CREATE TABLE) và các query, mỗi query kèm một chú thích -- name: dẫn hướng sinh code:

-- schema.sql
CREATE TABLE khach_hang (
  id BIGSERIAL PRIMARY KEY, ten TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE, diem INTEGER NOT NULL DEFAULT 0);

-- query.sql
-- name: LayKhachTheoID :one
SELECT * FROM khach_hang WHERE id = $1;

-- name: DanhSachKhachTheoDiem :many
SELECT id, ten, email, diem FROM khach_hang
WHERE diem >= $1 ORDER BY diem DESC LIMIT $2;

Hậu tố :one, :many, :exec cho sqlc biết query trả về một hàng, nhiều hàng, hay không trả gì — từ đó nó sinh chữ ký hàm Go tương ứng.

sqlc sinh struct và hàm an toàn kiểu

Chạy sqlc generate, nó đọc schema và query rồi sinh ra code Go. Từ CREATE TABLE ra một struct khớp kiểu:

// models.go — sinh tự động từ CREATE TABLE
type KhachHang struct {
	ID int64; Ten string; Email string
	Diem int32; TaoLuc pgtype.Timestamptz // kiểu khớp cột SQL
}

Từ mỗi query ra một hàm Go có kiểu vào/ra rõ ràng:

func (q *Queries) DanhSachKhachTheoDiem(ctx, arg DanhSachKhachTheoDiemParams)
    ([]DanhSachKhachTheoDiemRow, error) { /* Scan tĩnh, không reflection */ }

Điểm mấu chốt là Scan được sinh tĩnh — sqlc viết thẳng rows.Scan(&i.ID, &i.Ten, ...) theo đúng thứ tự cột, không dùng reflection lúc chạy như ORM. Nên nó nhanh ngang database/sql viết tay, mà không có rủi ro Scan sai tay.

Ảnh chụp đoạn mã Go và SQL nền tối minh hoạ sqlc trong Go viết SQL sinh code Go an toàn kiểu bắt lỗi lúc biên dịch, ý tưởng SQL là nguồn sự thật sinh Go từ nó ORM viết Go sinh SQL ẩn khó tối ưu dễ N cộng 1 viết tay database sql linh hoạt nhưng Scan thủ công dễ sai lỗi tên cột chỉ lộ lúc chạy sqlc bạn viết SQL thật nó sinh code Go an toàn kiểu kiểm SQL so với schema ngay lúc sinh trước cả khi build Go, khai schema và query bằng SQL thuần CREATE TABLE khach_hang id BIGSERIAL PRIMARY KEY ten TEXT NOT NULL email TEXT NOT NULL UNIQUE diem INTEGER NOT NULL DEFAULT 0 query.sql chú thích name dẫn hướng sinh code name LayKhachTheoID one SELECT sao FROM khach_hang WHERE id bằng 1 name DanhSachKhachTheoDiem many SELECT id ten email diem FROM khach_hang WHERE diem lớn hơn bằng 1 ORDER BY diem DESC LIMIT 2, sqlc sinh struct cộng hàm an toàn kiểu models.go sinh tự động từ CREATE TABLE type KhachHang struct ID int64 Ten string Email string Diem int32 TaoLuc pgtype Timestamptz kiểu khớp cột SQL query.sql.go mỗi query thành 1 hàm Go có kiểu vào ra rõ func Queries DanhSachKhachTheoDiem ctx arg trả slice Row error Scan tĩnh không reflection, gọi hoàn toàn an toàn kiểu trình biên dịch canh ks err bằng q DanhSachKhachTheoDiem ctx Params Diem 100 Limit 10 sai kiểu tham số lỗi biên dịch for range ks Println k Ten k Diem k Ten là string gõ nhầm k Tenn trình biên dịch báo ngay không đợi chạy

Hình 1: sqlc lật ngược quan hệ — SQL là nguồn sự thật, sinh ra Go từ nó: khai schema và query bằng SQL thuần, sqlc sinh struct khớp kiểu và hàm có kiểu vào/ra rõ, gọi hoàn toàn an toàn kiểu để trình biên dịch canh.

Đo thật: sqlc bắt lỗi cột sai ngay lúc sinh

Chạy sqlc generate trên schema và bốn query, nó sinh ba file Go:

OK sinh code
gen/db.go          // Queries struct, New(db)
gen/models.go      // type KhachHang (khớp CREATE TABLE)
gen/query.sql.go   // 4 hàm: LayKhachTheoID, TaoKhach,
                   //        DanhSachKhachTheoDiem, CongDiem

Giờ đến phần giá trị nhất. Thêm một query sai — chọn cột ho_ten không tồn tại (bảng chỉ có ten):

-- SELECT id, ho_ten FROM khach_hang WHERE diem > $1;

Chạy sqlc generate:

db/query/bad.sql:2:12: column "ho_ten" does not exist

sqlc phân tích SQL so với schema và chặn ngay tại dòng 2, cột 12 — trước cả khi bạn biên dịch Go. Với database/sql viết tay, cùng lỗi này chỉ nổ ra lúc chạy truy vấn đó, có khi là lần đầu ai đó gọi tính năng ấy trên production. sqlc kéo lỗi lên sớm hàng tháng.

Sau khi bỏ query lỗi, code sinh ra biên dịch sạch:

$ go build ./...
code sinh ra BIÊN DỊCH SẠCH (type-safe, không reflection)

Ảnh chụp bảng kết quả đo thật nền tối sqlc sinh code từ SQL và bắt lỗi cột sai ngay lúc sinh chạy bằng sqlc generate cộng go build Go 1.23 arm64 sqlc v1.27 pgx v5, sqlc generate sinh 3 file Go từ schema cộng query OK sinh code gen db.go Queries struct New db gen models.go type KhachHang khớp CREATE TABLE gen query.sql.go 4 hàm LayKhachTheoID TaoKhach DanhSachKhachTheoDiem CongDiem mỗi name one many exec thành 1 hàm Go kiểu tương ứng, bắt lỗi lúc sinh cột không tồn tại viết query sai SELECT id ho_ten FROM khach_hang sqlc generate db query bad.sql dòng 2 cột 12 column ho_ten does not exist sqlc phân tích SQL so với schema chặn ngay lúc sinh trước cả khi biên dịch Go lỗi này với database sql viết tay chỉ nổ lúc chạy có khi trên production, code sinh ra biên dịch sạch an toàn kiểu go build code sinh ra biên dịch sạch type-safe không reflection Scan tĩnh vào struct đúng kiểu không dùng reflection lúc chạy nhanh như viết tay an toàn như ORM không có phần dở của cả hai, ba tầng bắt lỗi sớm dần lúc sinh sqlc kiểm SQL so với schema cột kiểu cú pháp lúc build Go compiler kiểm kiểu tham số và trường struct lúc chạy chỉ còn lỗi thật sự động kết nối ràng buộc unique càng bắt lỗi sớm càng rẻ, cốt lõi nguồn SQL là sự thật sqlc sinh Go từ SQL không ngược lại an toàn struct hàm có kiểu gõ sai cột tham số là lỗi biên dịch bắt sớm lỗi SQL lộ lúc sinh không đợi lúc chạy như viết tay nhanh Scan tĩnh không reflection ngang database sql viết tay đánh đổi thêm bước generate vào build SQL động khó phải viết tay

Hình 2: Đo thật — sqlc sinh 3 file Go từ schema và query; thêm query sai bị chặn ngay tại bad.sql:2:12: column "ho_ten" does not exist trước cả khi biên dịch Go; code sinh ra biên dịch sạch, type-safe, không reflection.

Ba tầng bắt lỗi, càng sớm càng rẻ

Giá trị thật của sqlc là nó đẩy lỗi ngược lên các tầng sớm hơn, nơi sửa rẻ hơn nhiều:

  • Lúc sinh (sqlc generate): kiểm SQL so với schema — cột tồn tại không, kiểu có khớp không, cú pháp có đúng không. Đây là tầng mà ORM và viết tay đều không có.
  • Lúc build (go build): trình biên dịch Go kiểm kiểu tham số bạn truyền vào và trường struct bạn đọc ra. Gõ k.Tenn thay vì k.Ten là lỗi biên dịch ngay.
  • Lúc chạy: chỉ còn lại các lỗi thật sự động — mất kết nối, vi phạm ràng buộc unique, timeout. Những lỗi không thể biết trước.

So với đó, database/sql viết tay đẩy lỗi tên cột và lỗi Scan xuống tận lúc chạy; ORM giấu SQL nên lỗi tối ưu (N+1) chỉ lộ khi hồ sơ hiệu năng xấu đi. Nguyên tắc chung của kỹ thuật phần mềm: bắt lỗi càng sớm càng rẻ, và sqlc dịch chuyển cả một lớp lỗi từ "lúc chạy" lên "lúc sinh".

Đánh đổi cần cân nhắc

Thêm một bước generate vào quy trình build. sqlc là một công cụ ngoài, phải chạy sqlc generate lại mỗi khi đổi schema hoặc query, và commit code sinh ra (hoặc sinh trong CI). Đây là chi phí quy trình nhỏ nhưng có thật; nhóm phải quen với việc "đổi SQL thì generate lại".

SQL động khó biểu diễn. sqlc tỏa sáng với query tĩnh, biết trước. Nhưng khi bạn cần query dựng động lúc chạy (ví dụ bộ lọc tùy chọn: WHERE thêm điều kiện tùy tham số người dùng), sqlc không sinh được — bạn phải quay lại viết tay database/sql hoặc dùng query builder cho phần đó. Nhiều dự án dùng sqlc cho phần lớn query tĩnh và viết tay cho số ít query động.

Không phải ORM — không có quan hệ tự động. sqlc không tự nạp quan hệ (không có "eager loading" như ORM). Muốn join thì bạn viết JOIN trong SQL — điều này thực ra tốt (bạn thấy và kiểm soát SQL thật), nhưng là thay đổi tư duy nếu bạn quen ORM lo hết. Đây là triết lý "SQL là ngôn ngữ, hãy dùng nó" chứ không phải giấu nó đi.

Ba ý mang về

  1. sqlc lật ngược quan hệ SQL–Go: bạn viết SQL thật làm nguồn sự thật, nó sinh code Go an toàn kiểu (struct khớp cột, hàm có kiểu vào/ra rõ, Scan tĩnh không reflection) — nhanh như viết tay, an toàn kiểu như ORM.
  2. Bắt lỗi SQL ngay lúc sinh: đo thật, sqlc chặn cột ho_ten không tồn tại tại bad.sql:2:12 trước cả khi biên dịch Go — cùng lỗi này với database/sql viết tay chỉ nổ lúc chạy, có khi trên production.
  3. Ba tầng bắt lỗi sớm dần (sinh → build → chạy) dịch chuyển một lớp lỗi từ lúc chạy lên lúc sinh, càng sớm càng rẻ — đổi lại là thêm bước generate vào build và SQL động vẫn phải viết tay.

Phần sau ta xét một tối ưu quan trọng ở tầng driver: prepared statement caching — vì sao chuẩn bị câu lệnh một lần rồi tái dùng nhanh hơn, cách driver cache chúng, và cạm bẫy với connection pool.