Ngăn bản ghi trùng lặp trong ứng dụng CRUD cần nhiều lớp: ràng buộc duy nhất ở DB, idempotency key, và trạng thái UI ngăn gửi hai lần.

Bản ghi trùng lặp là khi ứng dụng của bạn lưu cùng một thứ hai lần. Có thể là hai đơn hàng cho cùng một checkout, hai ticket hỗ trợ với cùng chi tiết, hoặc hai tài khoản được tạo từ cùng một flow đăng ký. Trong một ứng dụng CRUD, các bản ghi trùng lặp thường trông như những hàng bình thường riêng lẻ, nhưng chúng sai khi nhìn vào dữ liệu tổng thể.
Hầu hết trùng lặp bắt đầu từ hành vi bình thường. Ai đó bấm Create hai lần vì trang chậm. Trên di động, một lần chạm kép dễ bị bỏ sót. Ngay cả người dùng cẩn thận cũng sẽ thử lại nếu nút vẫn trông như đang hoạt động và không có dấu hiệu rõ ràng đang xảy ra gì.
Rồi có phần rối rắm ở giữa: mạng và server. Một request có thể timeout và bị retry tự động. Một thư viện client có thể lặp lại POST nếu nó nghĩ lần đầu thất bại. Yêu cầu đầu tiên có thể thành công, nhưng phản hồi bị mất, nên người dùng thử lại và tạo bản sao thứ hai.
Bạn không thể giải quyết chỉ bằng một lớp vì mỗi lớp chỉ thấy một phần của câu chuyện. Giao diện có thể giảm các lần gửi nhầm, nhưng không thể ngăn retry do kết nối kém. Server có thể phát hiện lặp, nhưng cần một cách tin cậy để nhận ra “đây là cùng một create lặp lại.” Cơ sở dữ liệu có thể thực thi quy tắc, nhưng chỉ khi bạn định nghĩa rõ “cùng một thứ” nghĩa là gì.
Mục tiêu đơn giản: làm cho các thao tác tạo an toàn ngay cả khi cùng một yêu cầu xảy ra hai lần. Lần thử thứ hai nên trở thành no-op, trả về "đã tạo rồi" một cách sạch sẽ, hoặc một xung đột được kiểm soát — chứ không phải một hàng thứ hai.
Nhiều nhóm coi trùng lặp là vấn đề của cơ sở dữ liệu. Trên thực tế, trùng lặp thường sinh ra sớm hơn, khi cùng một hành động tạo được kích hoạt nhiều lần.
Người dùng bấm Create và không thấy gì xảy ra, nên họ bấm lại. Hoặc họ bấm Enter rồi sau đó bấm nút. Trên di động, bạn có thể gặp hai lần chạm nhanh, sự kiện touch và click chồng chéo, hoặc một cử chỉ bị ghi nhận hai lần.
Ngay cả khi người dùng chỉ gửi một lần, mạng vẫn có thể lặp lại yêu cầu. Một timeout có thể kích hoạt retry. Ứng dụng ngoại tuyến có thể xếp hàng một "Save" và gửi lại khi kết nối trở lại. Một số thư viện HTTP tự động retry khi gặp lỗi nhất định, và bạn sẽ không nhận ra cho tới khi thấy các hàng trùng lặp.
Server lặp lại công việc có chủ đích. Job queue retry các job thất bại. Nhà cung cấp webhook thường gửi lại cùng một event nhiều lần, đặc biệt nếu endpoint của bạn chậm hoặc trả trạng thái không phải 2xx. Nếu logic tạo của bạn được kích hoạt bởi những event này, hãy giả định trùng lặp sẽ xảy ra.
Concurrency tạo ra các bản sao lắt léo nhất. Hai tab gửi cùng một form trong vòng vài mili-giây. Nếu server của bạn làm “đã tồn tại chưa?” rồi mới insert, cả hai request có thể vượt qua kiểm tra trước khi bất kỳ insert nào xảy ra.
Hãy coi client, mạng, và server là các nguồn khác nhau của việc lặp. Bạn sẽ cần các biện pháp phòng thủ ở cả ba nơi.
Nếu bạn muốn một chỗ đáng tin cậy để dừng trùng lặp, đặt quy tắc vào cơ sở dữ liệu. Sửa lỗi ở UI và kiểm tra ở server giúp, nhưng chúng có thể thất bại khi retry, trễ, hoặc hai người dùng hành động cùng lúc. Ràng buộc duy nhất của cơ sở dữ liệu là quyền quyết định cuối cùng.
Bắt đầu bằng cách chọn một quy tắc duy nhất thực tế khớp với cách mọi người nghĩ về bản ghi. Một vài ví dụ phổ biến:
Cẩn trọng với các trường trông như duy nhất nhưng không phải, như họ tên đầy đủ.
Khi đã có quy tắc, thực thi nó bằng unique constraint (hoặc unique index). Điều này khiến cơ sở dữ liệu từ chối một insert thứ hai sẽ vi phạm quy tắc, ngay cả khi hai request tới cùng lúc.
Khi constraint kích hoạt, quyết định trải nghiệm người dùng. Nếu tạo trùng luôn luôn sai, chặn nó với thông báo rõ ràng (“Email này đã được dùng”). Nếu retry là phổ biến và bản ghi đã tồn tại, thường tốt hơn là coi retry là thành công và trả về bản ghi hiện có (“Đơn hàng của bạn đã được tạo”).
Nếu thao tác tạo thực sự là “tạo hoặc tái sử dụng”, thì upsert có thể là mẫu sạch nhất. Ví dụ: “tạo khách hàng theo email” có thể insert một hàng mới hoặc trả về hàng đã có. Chỉ dùng khi nó khớp với nghĩa nghiệp vụ. Nếu payload hơi khác nhau có thể đến cho cùng một key, quyết định trường nào được phép cập nhật và trường nào phải giữ nguyên.
Unique constraint không thay thế idempotency key hay trạng thái UI tốt, nhưng chúng cung cấp một giới hạn cứng để mọi thứ khác có thể dựa vào.
Idempotency key là một token duy nhất đại diện cho một ý định của người dùng, như “tạo đơn hàng này một lần.” Nếu cùng yêu cầu được gửi lại (bấm hai lần, retry mạng, resume trên di động), server coi đó là retry, không phải tạo mới.
Đây là một trong những công cụ thực tế nhất để làm cho các endpoint tạo an toàn khi client không biết lần đầu có thành công hay không.
Các endpoint hưởng lợi nhất là những endpoint mà tạo trùng gây tốn kém hoặc gây nhầm lẫn, như orders, invoices, payments, invites, subscriptions, và các form kích hoạt email hoặc webhook.
Khi retry, server nên trả lại kết quả gốc từ lần thành công đầu tiên, bao gồm cùng ID bản ghi và mã trạng thái. Để làm điều đó, lưu một bản ghi idempotency nhỏ được khoá theo (user hoặc account) + endpoint + idempotency key. Lưu cả kết quả (ID bản ghi, body phản hồi) và trạng thái “đang tiến hành” để hai request gần như đồng thời không tạo hai hàng.
Giữ các bản ghi idempotency đủ lâu để che các retry thực tế. Một baseline phổ biến là 24 giờ. Với thanh toán, nhiều nhóm giữ 48–72 giờ. TTL giữ cho lưu trữ có hạn và phù hợp với thời gian retry khả thi.
Nếu bạn sinh API bằng công cụ hỗ trợ như Koder.ai, bạn vẫn nên làm idempotency rõ ràng: chấp nhận key do client gửi (header hoặc field) và đảm bảo “cùng key, cùng kết quả” ở phía server.
Idempotency làm cho một request tạo an toàn khi lặp. Nếu client retry do timeout (hoặc người dùng bấm hai lần), server trả lại cùng kết quả thay vì tạo hàng thứ hai.
Idempotency-Key), nhưng gửi trong JSON body cũng được.Chi tiết then chốt là “kiểm tra + lưu” phải an toàn với concurrency. Trên thực tế, bạn lưu bản ghi idempotency với unique constraint trên (scope, key) và xử lý xung đột như tín hiệu để tái sử dụng.
if idempotency_record exists for (user_id, key):
return saved_response
else:
begin transaction
create the row
save response under (user_id, key)
commit
return response
Ví dụ: một khách hàng bấm “Create invoice”, app gửi key abc123, và server tạo invoice inv_1007. Nếu điện thoại mất tín hiệu và retry, server trả lại cùng phản hồi inv_1007, không phải inv_1008.
Khi test, đừng chỉ dừng ở “bấm hai lần.” Mô phỏng một request timeout ở client nhưng vẫn hoàn thành ở server, rồi retry cùng key.
Các biện pháp phía server quan trọng, nhưng nhiều trùng lặp vẫn bắt đầu từ con người làm điều bình thường hai lần. UI tốt làm con đường an toàn trở nên rõ ràng.
Vô hiệu hóa nút gửi ngay khi người dùng gửi. Làm việc này ở lần bấm đầu tiên, không phải sau khi validate hoặc khi request bắt đầu. Nếu form có thể gửi qua nhiều control (nút và Enter), khóa trạng thái toàn bộ form, không chỉ một nút.
Hiển thị trạng thái tiến trình rõ ràng trả lời một câu hỏi: có đang làm việc không? Một nhãn “Đang lưu...” hoặc spinner là đủ. Giữ bố cục ổn định để nút không nhảy và khiến người dùng bấm lần hai.
Một tập quy tắc nhỏ ngăn hầu hết gửi hai lần: đặt cờ isSubmitting ngay đầu handler submit, bỏ qua các submit mới khi cờ này true (cho cả click và Enter), và không xóa nó cho tới khi nhận được phản hồi thực sự.
Các phản hồi chậm là nơi nhiều app trượt. Nếu bạn kích hoạt lại nút theo bộ đếm cố định (ví dụ sau 2 giây), người dùng có thể gửi lại trong khi yêu cầu đầu vẫn đang chờ. Chỉ kích hoạt lại khi thao tác hoàn tất.
Sau khi thành công, làm cho gửi lại ít khả năng xảy ra. Điều hướng đi nơi khác (sang trang bản ghi mới hoặc danh sách) hoặc hiển thị trạng thái thành công rõ ràng với bản ghi đã tạo. Tránh để lại cùng form đã điền trên màn hình với nút được bật.
Các lỗi trùng lặp cứng đầu thường xuất phát từ hành vi "lạ nhưng phổ biến": hai tab, refresh, hoặc điện thoại mất tín hiệu.
Trước hết, xác định phạm vi duy nhất đúng. “Duy nhất” hiếm khi có nghĩa là “duy nhất toàn bộ cơ sở dữ liệu.” Có thể là một per user, per workspace, hoặc per tenant. Nếu bạn đồng bộ với hệ thống ngoài, bạn có thể cần tính duy nhất theo nguồn ngoài cộng với external ID. Cách an toàn là viết rõ câu bạn muốn (ví dụ “Một số hóa đơn trên mỗi tenant mỗi năm”), rồi thực thi như vậy.
Hành vi đa tab là bẫy kinh điển. Trạng thái tải ở UI giúp trong một tab, nhưng không có tác dụng qua tab. Đây là lúc các biện pháp phía server vẫn phải chắc chắn.
Nút Back và refresh có thể kích hoạt gửi nhầm. Sau khi tạo thành công, người dùng thường refresh để “kiểm tra”, hoặc bấm Back và gửi lại một form còn sửa được. Ưu tiên xem bản ghi đã tạo thay vì giữ lại form gốc, và để server xử lý các replay an toàn.
Di động thêm các gián đoạn: backgrounding, mạng không ổn, và retry tự động. Một request có thể thành công nhưng app không nhận được phản hồi, nên nó thử lại khi resume.
Chế độ lỗi phổ biến nhất là coi UI như hàng rào duy nhất. Vô hiệu hóa nút và spinner giúp, nhưng không che refresh, mạng lỏng lẻo trên di động, mở tab khác, hay lỗi client. Server và database vẫn phải có khả năng nói “thao tác tạo này đã xảy ra rồi.”
Một bẫy khác là chọn trường duy nhất sai. Nếu bạn đặt unique constraint trên thứ không thực sự duy nhất (họ, timestamp làm tròn, tiêu đề tự do), bạn sẽ chặn những bản ghi hợp lệ. Thay vào đó, dùng một định danh thực sự (như external provider ID) hoặc quy tắc có phạm vi (duy nhất theo user, theo ngày, hoặc theo bản cha).
Idempotency key cũng dễ bị triển khai sai. Nếu client tạo key mới cho mỗi retry, bạn sẽ có một bản tạo mới mỗi lần. Giữ cùng key cho ý định người dùng, từ lần bấm đầu đến mọi retry.
Cũng chú ý những gì bạn trả về khi retry. Nếu lần đầu tạo ra bản ghi, một retry nên trả về cùng kết quả (hoặc ít nhất cùng ID bản ghi), không phải lỗi mơ hồ khiến người dùng thử lại.
Nếu unique constraint chặn một duplicate, đừng che nó bằng “Có lỗi gì đó.” Hãy nói đúng: “Số hóa đơn này đã tồn tại. Chúng tôi giữ bản gốc và không tạo bản thứ hai.”
Trước khi release, rà soát nhanh các đường dẫn tạo trùng lặp. Kết quả tốt nhất đến từ việc xếp chồng các biện pháp phòng thủ để một cú click nhầm, retry, hoặc mạng chậm không thể tạo hai hàng.
Xác nhận ba điều:
Kiểm tra thực tế: mở form, bấm submit hai lần thật nhanh, rồi refresh giữa lúc submit và thử lại. Nếu bạn có thể tạo hai bản ghi, người dùng thực sự sẽ làm được.
Giả sử một app invoicing nhỏ. Người dùng điền hóa đơn mới và bấm Create. Mạng chậm, màn hình không đổi ngay, và họ bấm Create lần nữa.
Chỉ có bảo vệ UI, bạn có thể vô hiệu hóa nút và hiển thị spinner. Điều đó giúp, nhưng chưa đủ. Một lần chạm đôi vẫn có thể lọt qua trên vài thiết bị, một retry có thể xảy ra sau timeout, hoặc người dùng có thể submit từ hai tab.
Chỉ có unique constraint ở DB, bạn có thể chặn trùng lặp chính xác, nhưng trải nghiệm có thể xấu. Yêu cầu đầu thành công, yêu cầu thứ hai chạm constraint, và người dùng thấy lỗi ngay cả khi hóa đơn đã được tạo.
Kết quả sạch sẽ là idempotency cộng với unique constraint:
Một thông điệp UI đơn giản sau lần bấm thứ hai: “Hóa đơn đã được tạo - chúng tôi bỏ qua gửi trùng và giữ yêu cầu đầu tiên của bạn.”
Khi đã có nền tảng, những lợi ích tiếp theo là về khả năng quan sát, dọn dẹp và nhất quán.
Thêm logging nhẹ quanh các đường dẫn tạo để bạn biết khác biệt giữa hành động người dùng thực và retry. Ghi idempotency key, các trường duy nhất liên quan, và kết quả (tạo mới vs trả về bản có sẵn vs từ chối). Bạn không cần công cụ nặng để bắt đầu.
Nếu đã có bản ghi trùng lặp, dọn dẹp chúng theo quy tắc rõ ràng và giữ audit trail. Ví dụ, giữ bản ghi cũ nhất làm “bản chiến thắng”, gắn lại các hàng liên quan (payments, line items), và đánh dấu các bản khác là đã merge thay vì xóa. Điều đó làm cho hỗ trợ và báo cáo dễ dàng hơn.
Ghi lại quy tắc duy nhất và idempotency ở một nơi: cái gì là duy nhất và trong phạm vi nào, idempotency key sống bao lâu, lỗi trông như thế nào, và UI nên làm gì khi retry. Điều này ngăn các endpoint mới lờ đi các hàng rào an toàn.
Nếu bạn đang xây màn hình CRUD nhanh trên Koder.ai (Koder.ai), đáng để làm các hành vi này trở thành template mặc định: ràng buộc duy nhất trong schema, endpoint tạo idempotent trong API, và trạng thái tải rõ ràng trong UI. Như vậy, tốc độ xây dựng không đánh đổi bằng dữ liệu bừa bộn.
Bản ghi trùng lặp là khi cùng một thực thể trong thế giới thực được lưu hai lần, ví dụ hai đơn hàng cho cùng một checkout hoặc hai ticket cho cùng một vấn đề. Nó thường xảy ra vì cùng một hành động “tạo” chạy hơn một lần do người dùng gửi hai lần, retry tự động, hoặc các yêu cầu đồng thời.
Bởi vì một thao tác tạo thứ hai có thể được kích hoạt mà người dùng không nhận ra, như một lần chạm kép trên di động hoặc vừa nhấn Enter vừa bấm nút. Ngay cả khi người dùng chỉ gửi một lần, client, mạng hoặc server có thể retry yêu cầu sau timeout, và server không nên giả định “POST là chỉ một lần”.
Không đáng tin cậy. Vô hiệu hóa nút và hiển thị “Đang lưu…” giảm các thao tác gửi nhầm, nhưng không ngăn được retry do mạng không ổn, refresh, nhiều tab, job nền, hoặc webhook được gửi lại. Bạn cần có biện pháp ở server và database nữa.
Ràng buộc duy nhất là hàng rào phòng vệ cuối cùng, ngăn hai hàng cùng chèn được ngay cả khi hai yêu cầu đến cùng lúc. Nó hiệu quả nhất khi bạn xác định rõ quy tắc duy nhất theo thực tế (thường có phạm vi, ví dụ theo tenant hoặc workspace) và áp dụng trực tiếp ở cơ sở dữ liệu.
Chúng giải quyết các vấn đề khác nhau. Ràng buộc duy nhất chặn trùng lặp dựa trên trường (ví dụ số hóa đơn), còn idempotency key bảo đảm một lần thử tạo cụ thể an toàn để lặp lại (cùng key trả về cùng kết quả). Dùng cả hai vừa an toàn vừa cho trải nghiệm người dùng tốt hơn khi retry.
Tạo một key cho mỗi ý định của người dùng (một lần bấm “Create”), dùng lại key đó cho bất kỳ retry nào của cùng ý định đó, và gửi kèm key trong mọi yêu cầu. Key phải ổn định qua timeout và khi app được khôi phục, nhưng không được dùng lại cho hành động tạo khác sau đó.
Lưu một bản ghi idempotency theo phạm vi (ví dụ user hoặc account), endpoint và idempotency key, đồng thời lưu lại phản hồi bạn trả cho lần thành công đầu tiên. Nếu cùng key đến lần nữa, trả lại phản hồi đã lưu với cùng ID bản ghi đã tạo thay vì chèn bản ghi mới.
Dùng cách “kiểm tra + lưu” an toàn với concurrency, thường là bằng cách áp ràng buộc duy nhất trên bản ghi idempotency (phạm vi + key). Khi đó, hai yêu cầu gần như đồng thời không thể đều tuyên bố là “lần đầu”; một trong số chúng sẽ phải tái sử dụng kết quả đã lưu.
Giữ chúng đủ lâu để che được các retry thực tế; mặc định phổ biến là khoảng 24 giờ, với các luồng như thanh toán thì giữ 48–72 giờ. Thêm TTL để dung lượng không tăng vô hạn và đảm bảo thời gian lưu phù hợp với khả năng retry của client.
Xem một retry như một lần thành công khi rõ ràng đó là cùng ý định, và trả về bản ghi gốc (cùng ID) thay vì lỗi mơ hồ. Nếu người dùng thực sự đang cố tạo thứ phải duy nhất (ví dụ email), trả về thông báo xung đột rõ ràng giải thích cái gì đã tồn tại và hệ thống đã xử lý như thế nào.