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

Ảnh chụp đoạn mã nền tối validation kiểm ở biên để trả lời nhanh ràng buộc DB để đúng với Go net/http pgx PostgreSQL 16 bảng customers2 có CHECK và UNIQUE bcrypt cho mật khẩu, kiểm ở biên chỉ nhìn thân request gom mọi lỗi theo trường gồm func kiemBien k khach dungSom bool trả về slice loiTruong, hàm them append lỗi rồi trả về dungSom, năm dòng kiểm email không đúng dạng, ten không được để trống, tuoi phải từ 18 đến 120, sdt phải có ít nhất 9 chữ số, ma_buu_dien phải đúng 5 ký tự, mỗi dòng return ls nếu dungSom, cuối cùng return ls với ghi chú dungSom false thì trả cả 5 lỗi trong một phản hồi, rồi if ls bằng kiemBien k false khác nil thì tra422 và return chưa chạm DB, ràng buộc trong DB thứ duy nhất đúng khi có đua tranh gồm create table customers2 với email text unique not null check theo regex email và tuoi int not null check tuoi between 18 and 120, bản sai kiểm rồi chèn giữa hai bước có việc thật băm mật khẩu khoảng 60 ms gồm QueryRow select count from customers where email rồi nếu n lớn hơn 0 thì 409, bcrypt.GenerateFromPassword với ghi chú 50 request cùng đứng ở đây, rồi Exec insert với ghi chú cả 50 đều thấy chưa có, bản đúng cứ chèn để UNIQUE quyết dịch mã lỗi 23505 thành 409 bằng errors.As err pg và pg.Code bằng 23505 thì WriteHeader 409, giới hạn thân trước khi parse bằng r.Body bằng http.MaxBytesReader w r.Body 1 shift 20 tức 1 MB vượt thì MaxBytesError thành 413 rồi json.NewDecoder Decode

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

Ảnh chụp output thật nền tối kiểm rồi chèn 48 dòng trùng cho một email với Go 1.23 pgx v5 PostgreSQL 16 bảng customers2 CHECK và UNIQUE bcrypt cost 10, mục 1 1.000 request sai tuoi 7 lỗi lọt tới đâu tốn gì cho thấy bản A để DB bắt CHECK mã 422 p50 110 µs p99 490 µs rollback DB 1000 thân lỗi truong customers2_tuoi_check, bản B kiểm ở biên mã 422 p50 41 µs p99 193 µs rollback DB 0 thân lỗi truong tuoi ly_do phải từ 18 đến 120, mục 2 form sai cả 5 trường client sửa đúng trường server báo rồi gửi lại cho thấy B dừng ở lỗi đầu 6 vòng gửi để thành công còn C gom mọi lỗi 2 vòng với ghi chú vòng 2 là lần chèn thành công, mục 3 50 request đăng ký cùng email đến cùng lúc giữa kiểm và chèn là bước băm bcrypt khoảng 60 ms cho thấy SELECT rồi INSERT không UNIQUE mã 201 nhân 48 409 nhân 2 dòng trong DB 48 còn kiểm ở biên cộng UNIQUE thành 409 mã 201 nhân 1 409 nhân 49 dòng trong DB 1, mục 4 thân request 20 MB chuỗi khổng lồ trong trường ten cho thấy không giới hạn trả 422 sau 66.6 ms vì đọc hết cấp phát 20 MB parse rồi mới chê tuổi sai còn giới hạn 1 MB trả 413 sau 2.1 ms dừng ở byte thứ 1.048.577, nói thẳng kiểm ở biên không thay được ràng buộc nó chỉ trả lời nhanh hơn 2,7 lần và nói được tên trường thay vì tên constraint thứ giữ cho dữ liệu đúng khi 50 người bấm cùng lúc là UNIQUE trong DB không phải câu SELECT trước đó

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ề

  1. 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ới customers2_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.
  2. 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; UNIQUE giữ đúng 1. Kiểm ở biên không thay được nó, chỉ bổ sung cho nó.
  3. 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

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.