Chín phần trước phần lớn nói về dữ liệu đi ra. Phần này nói về dữ liệu đi vào — và câu hỏi nghe đơn giản: kiểm tra ở đâu? Ở biên, ngay khi vừa parse JSON? Ở tầng nghiệp vụ, nơi biết "tuổi phải từ 18" nghĩa là gì? Hay cứ để database từ chối, vì nó có CHECK và UNIQUE rồi? Mỗi câu trả lời đều có người bảo vệ. Bài này đo từng chỗ, và kết luận không phải "chọn một" mà là "mỗi chỗ làm một việc khác nhau".
Ba lớp, ba việc

Hình 1: Kiểm ở biên gom lỗi theo trường, ràng buộc trong database, bản "kiểm rồi chèn" có đua tranh, và giới hạn kích thước thân.
- Kiểm ở biên nhìn vào thân request và chỉ thân request: đúng dạng email không, tuổi trong khoảng không, mã bưu điện đủ 5 ký tự không. Không cần database, chạy trong micro-giây, và biết tên trường để báo lại.
- Ràng buộc trong database (
CHECK,UNIQUE,NOT NULL, khoá ngoại) là thứ duy nhất đúng khi nhiều request chạy cùng lúc — vì nó được kiểm bên trong giao dịch, bởi thứ giữ dữ liệu. - Giới hạn kích thước đứng trước cả hai: một thân 20 MB phải bị từ chối trước khi ai đó cấp phát 20 MB để parse nó.
Đo thật: tốn gì khi lỗi lọt tới đâu

Hình 2: Kết quả thật. Cột "rollback DB" lấy từ pg_stat_database.xact_rollback — số giao dịch database thật sự phải huỷ.
Mục 1 — 1.000 request có tuoi: 7. Bản A không kiểm gì, chèn thẳng, để CHECK (tuoi between 18 and 120) từ chối rồi dịch mã lỗi 23514 thành 422. Nó đúng — client nhận 422 — nhưng tốn 110 µs mỗi request và 1.000 rollback trên database: mỗi request sai là một giao dịch mở ra, một câu INSERT được phân tích, lập kế hoạch, chạy tới lúc kiểm ràng buộc, rồi huỷ. Thân lỗi mang tên customers2_tuoi_check — tên constraint, thứ client không có cách nào ánh xạ về ô nhập liệu nào trên màn hình. Bản B kiểm ở biên: 41 µs, 0 rollback, thân lỗi nói "truong":"tuoi". Nhanh hơn 2,7 lần, nhưng điểm chính không phải tốc độ — mà là database không phải làm việc vô ích và client biết ô nào sai.
Mục 2 — form sai cả 5 trường. Client làm điều hợp lý: sửa đúng những trường server báo, gửi lại. Server dừng ở lỗi đầu tiên: 6 vòng (5 lỗi + 1 lần thành công). Server gom mọi lỗi: 2 vòng. Với người dùng thật, 6 vòng là 6 lần bấm "Gửi" và 6 lần thấy một thông báo đỏ mới xuất hiện ở chỗ khác — trải nghiệm quen thuộc đến mức nhiều người tưởng nó là bình thường. Gom lỗi không tốn gì thêm phía server; nó chỉ đòi hàm kiểm tra không return sớm.
Mục 3 — 50 request cùng email, cùng lúc. Đây là chỗ bài này muốn tới. Bản "kiểm rồi chèn": SELECT count(*) thấy email chưa có, băm mật khẩu bằng bcrypt (~60 ms, việc thật mà mọi form đăng ký đều làm), rồi INSERT. 50 request cùng đi qua SELECT trong lúc chưa request nào tới INSERT, cả 50 đều thấy "chưa có", và kết quả là 48 dòng trùng cho một email (2 request may mắn đến muộn). Bản có UNIQUE: 1 dòng, 49 request nhận 409. Câu SELECT trước đó không sai — nó chỉ trả lời đúng tại thời điểm hỏi, và thời điểm đó cách lúc chèn 60 ms. Đây là time-of-check to time-of-use, cùng họ với đua tranh ở phần 2 và phần 8: đọc-rồi-ghi là chỗ để lọt.
Mục 4 — thân 20 MB. Không giới hạn: server đọc hết 20 MB, cấp phát, parse JSON, rồi mới trả 422 vì... tuổi sai, sau 66,6 ms. Một client cố ý gửi 100 request như vậy song song là 2 GB bộ nhớ. http.MaxBytesReader 1 MB: dừng đọc ở byte thứ 1.048.577, trả 413 sau 2,1 ms. Đây là lớp kiểm tra rẻ nhất và hay bị bỏ quên nhất, vì framework thường không bật mặc định — bài học UploadSizeExceededHandler trong chính blog này là một ví dụ.
Vậy kiểm ở đâu
Không phải "chọn một" mà là ba lớp, mỗi lớp một việc:
| lớp | kiểm gì | vì sao ở đây |
|---|---|---|
| Giới hạn thân (trước parse) | kích thước | rẻ nhất; chặn trước khi tốn bộ nhớ |
| Biên (sau parse) | dạng, khoảng, bắt buộc, định dạng | biết tên trường; gom được nhiều lỗi; không chạm DB |
| Database | duy nhất, khoá ngoại, bất biến dữ liệu | thứ duy nhất đúng khi có đua tranh |
Tầng nghiệp vụ nằm giữa biên và database: những quy tắc cần dữ liệu khác để kiểm (tồn kho còn không, tài khoản bị khoá chưa) — và cũng là nơi dịch mã lỗi của database (23505, 23503) thành hợp đồng lỗi ở phần 3. Điều cần tránh là kiểm ở biên rồi tin rằng không cần ràng buộc nữa: mục 3 là cái giá.
Đánh đổi cần cân nhắc
Hai chỗ cùng một quy tắc là hai chỗ phải đồng bộ. tuoi between 18 and 120 giờ nằm ở hàm Go lẫn CHECK của bảng. Đổi một chỗ quên chỗ kia thì request hợp lệ ở biên bị database từ chối với mã 500 kèm tên constraint — đúng kiểu lỗi mục 1 cho thấy là tệ. Cách giữ: sinh cả hai từ một nguồn (schema), hoặc test tích hợp cố tình gửi giá trị biên (17, 18, 120, 121) qua toàn bộ đường đi.
Ràng buộc có giới hạn biểu đạt. UNIQUE (email) dễ; "mỗi khách tối đa 3 đơn đang mở" thì không viết được bằng CHECK — cần SELECT ... FOR UPDATE trong giao dịch, hoặc khoá tư vấn, hoặc một bảng đếm với ràng buộc. Lúc đó câu hỏi lại là mức cô lập giao dịch, và sê-ri PostgreSQL nội bộ đã đo riêng.
Lỗi 409 từ UNIQUE phải được dịch, không được lộ. Bản đúng ở Hình 1 bắt mã 23505 và trả 409 sạch; để nó rơi vào default là client nhận 500 kèm chuỗi duplicate key value violates unique constraint "customers2_email_key" — đúng lỗi lộ nội bộ ở phần 3.
Số đo loopback, database cùng máy. 110 µs của bản A gồm một round-trip tới PostgreSQL; qua mạng thật là 0,5–2 ms, và chênh lệch với bản B tăng theo. Số rollback thì không đổi: mỗi request sai lọt tới database là một rollback, ở bất kỳ phần cứng nào.
Ba ý mang về
- Kiểm ở biên để trả lời nhanh và nói đúng tên trường: 41 µs so với 110 µs, 0 so với 1.000 rollback,
"truong":"tuoi"so vớicustomers2_tuoi_check. Và gom mọi lỗi trong một phản hồi — 2 vòng thay vì 6 — không tốn gì thêm. - Ràng buộc database để đúng khi có đua tranh: kiểm-rồi-chèn với 60 ms việc thật ở giữa tạo 48 dòng trùng;
UNIQUEgiữ đúng 1. Kiểm ở biên không thay được nó, chỉ bổ sung cho nó. - Giới hạn kích thước thân trước khi parse: 413 sau 2,1 ms thay vì cấp phát 20 MB rồi mới chê sau 66,6 ms — lớp rẻ nhất, và thường không bật sẵn.
Nguồn
- RFC 9457 — Problem Details for HTTP APIs (cấu trúc
errorstheo trường là phần mở rộng): https://www.rfc-editor.org/rfc/rfc9457 - PostgreSQL docs — Constraints: https://www.postgresql.org/docs/16/ddl-constraints.html
- PostgreSQL docs — pg_stat_database (
xact_rollback): https://www.postgresql.org/docs/16/monitoring-stats.html#MONITORING-PG-STAT-DATABASE-VIEW - Go — http.MaxBytesReader: https://pkg.go.dev/net/http#MaxBytesReader
- OWASP — Input Validation Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
Phần sau là phần đo cuối: timeout, retry và deadline xuyên suốt — khi một request đi qua ba service, ai chịu trách nhiệm chờ, và chuyện gì xảy ra khi mỗi tầng tự retry.