Race condition trong ứng dụng CRUD có thể gây đơn hàng trùng và tổng số sai. Tìm hiểu điểm va chạm phổ biến và cách khắc phục thực tế bằng ràng buộc, khóa và biện pháp bảo vệ UX.

Race condition xảy ra khi hai (hoặc nhiều) yêu cầu cập nhật cùng một dữ liệu gần như cùng lúc, và kết quả cuối cùng phụ thuộc vào thứ tự thời gian. Mỗi yêu cầu khi nhìn riêng lẻ đều hợp lý. Nhưng khi kết hợp, chúng tạo ra kết quả sai.
Một ví dụ đơn giản: hai người nhấn Lưu trên cùng một bản ghi khách hàng trong vòng một giây. Người nọ cập nhật email, người kia cập nhật số điện thoại. Nếu cả hai gửi toàn bộ bản ghi, lần ghi thứ hai có thể ghi đè lần đầu và một thay đổi biến mất mà không báo lỗi.
Bạn thấy vấn đề này hay hơn ở các app nhanh vì người dùng có thể gây ra nhiều hành động mỗi phút. Nó cũng tăng vọt vào những lúc bận: giảm giá flash, báo cáo cuối tháng, chiến dịch email lớn, hoặc bất cứ khi nào một dồn ứ yêu cầu tấn công cùng vài hàng.
Người dùng hiếm khi báo "race condition." Họ báo các triệu chứng: đơn hàng hoặc bình luận bị trùng, cập nhật mất ("tôi đã lưu, nhưng nó quay lại"), tổng số lạ (tồn kho âm, bộ đếm lùi), hoặc trạng thái nhảy múa bất ngờ (đã duyệt rồi lại về đang chờ).
Thử lại làm tình hình tệ hơn. Người ta nhấp đúp, refresh sau khi phản hồi chậm, gửi từ hai tab, hoặc mạng kém khiến trình duyệt/app gửi lại. Nếu server xử lý mọi yêu cầu như một ghi mới, bạn có thể có hai tạo, hai thanh toán, hoặc hai thay đổi trạng thái vốn chỉ nên xảy ra một lần.
Hầu hết app CRUD trông đơn giản: đọc một hàng, thay một trường, lưu lại. Điểm mấu chốt là app không điều khiển thời gian. Cơ sở dữ liệu, mạng, retry, công việc nền và hành vi người dùng đều chồng chéo lên nhau.
Một kích hoạt phổ biến là hai người cùng sửa một bản ghi. Cả hai tải cùng giá trị "hiện tại", đều thực hiện thay đổi hợp lệ, và lần lưu sau cùng lặng lẽ ghi đè lần trước. Không ai sai, nhưng một cập nhật bị mất.
Nó cũng xảy ra với một người. Nhấp đúp nút Lưu, chạm lại do kết nối chậm, hoặc nhấn Submit lần hai có thể gửi cùng một ghi hai lần. Nếu endpoint không idempotent, bạn có thể tạo bản sao, charge hai lần, hoặc chuyển trạng thái tiến hai bước.
Sử dụng hiện đại thêm nhiều chồng lấp. Nhiều tab hoặc thiết bị đăng nhập cùng tài khoản có thể gây cập nhật mâu thuẫn. Job nền (email, billing, sync, cleanup) có thể chạm cùng hàng với request web. Retry tự động trên client, load balancer hoặc job runner có thể lặp lại một yêu cầu đã thành công.
Nếu bạn đang giao features nhanh, cùng một bản ghi thường bị cập nhật từ nhiều nơi hơn bạn nhớ. Nếu bạn dùng trình tạo bằng chat như Koder.ai, app có thể lớn nhanh hơn nữa, nên hãy coi cạnh tranh (concurrency) là hành vi bình thường, không phải trường hợp rìa.
Race condition hiếm khi lộ diện trong demo "tạo một bản ghi". Chúng xuất hiện nơi hai yêu cầu chạm cùng một nguồn sự thật gần như cùng lúc. Biết các điểm nóng thông thường giúp bạn thiết kế ghi an toàn ngay từ đầu.
Bất cứ thứ gì cảm thấy như "chỉ cộng 1" có thể vỡ dưới tải: like, lượt xem, tổng, số hóa đơn, số vé. Mẫu rủi ro là đọc giá trị, cộng rồi ghi lại. Hai yêu cầu có thể đọc cùng giá trị khởi đầu và ghi đè lẫn nhau.
Luồng công việc như Draft -> Submitted -> Approved -> Paid trông đơn giản, nhưng va chạm rất thường. Rắc rối bắt đầu khi hai hành động cùng khả thi (duyệt và sửa, hủy và thanh toán). Nếu không có hàng rào, bạn có thể có một bản ghi bỏ qua bước, lật ngược, hoặc hiển thị trạng thái khác nhau ở các bảng khác nhau.
Hãy coi thay đổi trạng thái như một hợp đồng: chỉ cho phép bước hợp lệ tiếp theo và từ chối mọi thứ khác.
Số ghế còn, số hàng trong kho, slot hẹn, và trường "sức chứa còn lại" tạo ra bài toán oversell cổ điển. Hai người mua check-out cùng lúc, cả hai thấy còn hàng, và cả hai thành công. Nếu cơ sở dữ liệu không phải thẩm phán cuối cùng, bạn sẽ bán vượt quá thực tế.
Một số quy tắc là tuyệt đối: một email cho mỗi tài khoản, một subscription hoạt động mỗi user, một giỏ hàng mở mỗi user. Những thứ này thường hỏng khi bạn kiểm tra trước ("đã tồn tại chưa?") rồi mới insert. Dưới cạnh tranh, cả hai request có thể vượt qua phép kiểm tra.
Nếu bạn tạo flow CRUD nhanh (ví dụ bằng cách chat app vào đời trên Koder.ai), hãy ghi lại các điểm nóng này sớm và chống lưng bằng ràng buộc và ghi an toàn, không chỉ kiểm tra UI.
Rất nhiều race bắt đầu từ điều nhàm chán: cùng một hành động được gửi hai lần. Người dùng nhấp đúp. Mạng chậm nên họ nhấn lại. Điện thoại ghi nhận hai lần chạm. Đôi khi không cố ý: trang refresh sau POST và trình duyệt hỏi có gửi lại form không.
Khi đó, backend có thể chạy hai create hoặc update song song. Nếu cả hai thành công, bạn có bản sao, tổng sai, hoặc chuyển trạng thái chạy hai lần (ví dụ, approve rồi lại approve). Nó trông ngẫu nhiên vì phụ thuộc vào thời gian.
Cách an toàn nhất là phòng thủ nhiều lớp. Sửa UI, nhưng giả sử UI sẽ thất bại.
Các thay đổi thực tế có thể áp dụng cho hầu hết luồng ghi:
Ví dụ: người dùng nhấn "Thanh toán hóa đơn" hai lần trên mobile. UI nên chặn lần nhấn thứ hai. Server cũng nên từ chối yêu cầu thứ hai khi thấy cùng idempotency key, trả lại kết quả thành công ban đầu thay vì charge hai lần.
Trường trạng thái trông đơn giản cho tới khi hai thứ cùng cố thay đổi nó. Người dùng nhấn Approve trong khi job tự động đánh dấu cùng bản ghi là Expired, hoặc hai thành viên cùng làm trên tab khác nhau. Cả hai cập nhật có thể thành công, nhưng trạng thái cuối cùng phụ thuộc vào thời gian, không phải luật của bạn.
Hãy coi trạng thái như một máy trạng thái nhỏ. Giữ một bảng ngắn các chuyển hợp lệ (ví dụ: Draft -> Submitted -> Approved, và Submitted -> Rejected). Rồi mỗi lần ghi kiểm tra: "Bước này có hợp lệ từ trạng thái hiện tại không?" Nếu không, từ chối thay vì lặng lẽ ghi đè.
Optimistic locking giúp bạn bắt các cập nhật lỗi thời mà không chặn người khác. Thêm số phiên (version) hoặc updated_at và yêu cầu nó trùng khi lưu. Nếu ai đó đã thay đổi hàng sau khi bạn tải, cập nhật của bạn sẽ affect 0 rows và bạn có thể hiện thông báo rõ ràng như "Mục này đã thay đổi, làm mới và thử lại."
Một mẫu đơn giản cho cập nhật trạng thái:
Ngoài ra, giữ mọi thay đổi trạng thái ở một chỗ. Nếu cập nhật nằm rải rác khắp màn hình, job nền và webhook, bạn sẽ bỏ sót quy tắc. Đặt chúng sau một hàm hoặc endpoint duy nhất mà luôn thực thi cùng kiểm tra chuyển đổi.
Lỗi bộ đếm phổ biến nhất trông vô hại: app đọc một giá trị, cộng 1, rồi ghi lại. Dưới tải, hai request có thể đọc cùng số và cả hai ghi cùng giá trị mới, nên một lần tăng bị mất. Điều này dễ bỏ qua vì "thường thì nó hoạt động" trong test.
Nếu một giá trị chỉ được tăng hoặc giảm, hãy để cơ sở dữ liệu làm trong một câu lệnh. Khi đó DB áp dụng thay đổi an toàn ngay cả khi nhiều request cùng đến.
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
Ý tưởng tương tự áp dụng cho tồn kho, lượt xem, bộ đếm retry và bất cứ thứ gì có thể diễn đạt là "mới = cũ + delta".
Tổng thường sai khi bạn lưu một số trị dẫn xuất (order_total, account_balance, project_hours) rồi cập nhật nó từ nhiều nơi. Nếu bạn có thể tính tổng từ các hàng nguồn (line items, ledger entries), bạn tránh được một nhóm lỗi drift.
Khi phải lưu tổng để nhanh, xử lý nó như một ghi quan trọng. Giữ việc cập nhật các hàng nguồn và tổng lưu trong cùng một transaction. Đảm bảo chỉ một writer có thể cập nhật cùng tổng cùng lúc (khóa, cập nhật có điều kiện, hoặc một đường sở hữu duy nhất). Thêm ràng buộc ngăn giá trị vô lý (ví dụ: tồn kho âm). Sau đó so sánh định kỳ bằng một job nền tính lại và đánh dấu sai lệch.
Ví dụ cụ thể: hai người cùng thêm món vào cùng giỏ hàng. Nếu mỗi request đọc cart_total, cộng giá món rồi ghi lại, một món có thể biến mất. Nếu bạn cập nhật items và cart_total cùng nhau trong một transaction, tổng vẫn đúng ngay cả khi có nhiều click song song.
Nếu bạn muốn ít race condition hơn, hãy bắt đầu từ cơ sở dữ liệu. Code app có thể retry, timeout, hoặc chạy hai lần. Một ràng buộc DB là cổng cuối cùng luôn đúng ngay cả khi hai request đến cùng lúc.
Ràng buộc unique ngăn trùng lặp mà "không bao giờ nên xảy ra" nhưng vẫn xảy ra: địa chỉ email, số đơn hàng, ID hóa đơn, hoặc quy tắc "một subscription hoạt động mỗi user". Khi hai đăng ký cùng lúc, DB chấp nhận một hàng và từ chối hàng kia.
Foreign key ngăn tham chiếu hỏng. Không có chúng, một request có thể xóa bản ghi cha trong khi request khác tạo con trỏ tới không đâu, để lại hàng mồ côi khó dọn dẹp.
Check constraint giữ giá trị trong phạm vi an toàn và thực thi các quy tắc trạng thái đơn giản. Ví dụ: quantity >= 0, rating giữa 1 và 5, hoặc status giới hạn trong tập hợp cho phép.
Hãy coi lỗi ràng buộc là kết quả mong đợi, không phải "lỗi server." Bắt các vi phạm unique, foreign key, check, trả về thông báo rõ ràng như "Email đó đã được dùng" và log chi tiết để debug mà không lộ thông tin nội bộ.
Ví dụ: hai người nhấn "Tạo đơn" hai lần khi mạng lag. Với ràng buộc unique trên (user_id, cart_id), bạn không có hai đơn. Bạn có một đơn và một từ chối sạch sẽ, dễ giải thích.
Một số ghi không chỉ là một câu lệnh. Bạn đọc hàng, kiểm tra quy tắc, cập nhật trạng thái, và có thể insert audit log. Nếu hai request làm điều đó cùng lúc, cả hai đều có thể vượt qua kiểm tra và ghi. Đây là mẫu thất bại cổ điển.
Bọc thao tác nhiều bước trong một transaction để mọi bước cùng thành công hoặc cùng rollback. Quan trọng hơn, transaction cho bạn chỗ để kiểm soát ai được phép thay đổi cùng dữ liệu cùng lúc.
Khi chỉ một tác nhân được phép sửa một bản ghi tại một thời điểm, dùng row-level lock. Ví dụ: khóa hàng order, xác nhận nó vẫn ở trạng thái "pending", rồi chuyển sang "approved" và ghi audit. Yêu cầu thứ hai sẽ chờ, rồi kiểm tra lại trạng thái và dừng.
Chọn dựa trên tần suất va chạm:
Giữ thời gian giữ khóa ngắn. Làm càng ít công việc càng tốt khi đang giữ nó: không gọi API ngoài, không làm việc file chậm, không vòng lặp lớn. Nếu bạn xây flow bằng công cụ như Koder.ai, giữ transaction chỉ quanh các bước DB, làm phần còn lại sau khi commit.
Chọn một luồng có thể mất tiền hoặc uy tín khi va chạm. Một ví dụ phổ biến: tạo order, giữ hàng, rồi đặt trạng thái order là confirmed.
Viết ra các bước chính xác code đang làm hôm nay, theo thứ tự. Cụ thể về thứ gì được đọc, gì được ghi, và "thành công" nghĩa là gì. Va chạm ẩn trong khoảng trống giữa đọc và ghi sau đó.
Một con đường gia cố hoạt động với hầu hết stack:
Thêm một test chứng minh fix. Chạy hai request đồng thời với cùng sản phẩm và số lượng. Khẳng định rằng đúng một order được confirm, và order kia thất bại theo cách có kiểm soát (không tồn kho âm, không row reservation trùng).
Nếu bạn generate app nhanh (kể cả với Koder.ai), checklist này vẫn đáng làm cho vài luồng ghi quan trọng nhất.
Một trong những nguyên nhân lớn là tin tưởng UI. Nút bị vô hiệu hóa và kiểm tra phía client giúp, nhưng người dùng có thể nhấp đúp, refresh, mở hai tab, hoặc phát lại request từ kết nối kém. Nếu server không idempotent, bản sao vẫn lọt qua.
Một lỗi im lặng khác: bạn bắt lỗi DB (như unique violation) nhưng vẫn tiếp tục workflow. Điều đó thường thành "tạo thất bại, nhưng ta vẫn gửi email" hoặc "thanh toán thất bại, nhưng vẫn đánh dấu đơn đã thanh toán." Khi side effect đã xảy ra, khó mà quay lại.
Transaction dài cũng là bẫy. Nếu bạn giữ transaction mở khi gọi email, thanh toán hoặc API bên thứ ba, bạn giữ khóa lâu hơn cần thiết. Điều đó tăng đợi, timeout và khả năng các request chặn lẫn nhau.
Trộn job nền và hành động người dùng mà không có nguồn duy nhất của sự thật tạo ra trạng thái split-brain. Job retry và cập nhật hàng trong khi user đang sửa, cả hai đều nghĩ mình là writer cuối cùng.
Một vài "fix" mà thực ra không sửa được:
Nếu bạn xây trên công cụ chat-to-app như Koder.ai, cùng quy tắc áp dụng: yêu cầu ràng buộc phía server và ranh giới transaction rõ ràng, không chỉ UI đẹp hơn.
Race thường chỉ xuất hiện dưới tải thực. Một lượt kiểm tra trước khi ship có thể bắt được các điểm va chạm phổ biến nhất mà không cần rewrite.
Bắt đầu từ DB. Nếu điều gì đó phải duy nhất (email, số hóa đơn, một subscription hoạt động mỗi user), làm cho đó thành unique constraint thực sự, không phải rule "ta kiểm tra trước" ở app. Rồi đảm bảo code của bạn mong đợi ràng buộc có thể fail và trả về phản hồi rõ ràng, an toàn.
Tiếp theo, nhìn vào trạng thái. Mọi thay đổi trạng thái (Draft -> Submitted -> Approved) nên được xác thực so với tập chuyển đổi cho phép. Nếu hai request cố gắng di chuyển cùng một bản ghi, request thứ hai nên bị từ chối hoặc thành no-op, không tạo trạng thái giữa chừng.
Checklist thực tế trước release:
Nếu bạn xây flow trên Koder.ai, coi đây là tiêu chí chấp nhận: app sinh ra nên fail an toàn khi lặp lại và cạnh tranh, chứ không chỉ pass happy path.
Hai nhân viên mở cùng một yêu cầu mua. Cả hai nhấn Approve trong vài giây. Cả hai request tới server.
Chuyện có thể sai lộn xộn: yêu cầu bị "duyệt" hai lần, hai thông báo được gửi và các tổng liên quan (ngân sách dùng, số duyệt trong ngày) tăng thêm 2. Cả hai cập nhật đều hợp lệ riêng lẻ, nhưng chúng va chạm.
Đây là kế hoạch sửa hoạt động tốt với DB kiểu PostgreSQL.
Thêm quy tắc đảm bảo chỉ có một bản ghi approval tồn tại cho một request. Ví dụ, lưu approvals trong bảng riêng và enforce unique constraint trên request_id. Lần insert thứ hai sẽ fail ngay cả khi code app sai.
Khi duyệt, làm toàn bộ chuyển đổi trong một transaction:
Nếu nhân viên thứ hai đến muộn, họ sẽ thấy 0 rows được cập nhật hoặc lỗi unique-constraint. Dù sao chỉ một thay đổi thắng.
Sau fix, người đầu tiên thấy Approved và nhận xác nhận. Người thứ hai thấy thông báo thân thiện: "Yêu cầu này đã được duyệt bởi người khác. Làm mới để xem trạng thái mới." Không quay vòng vô hạn, không thông báo trùng, không lỗi im lặng.
Nếu bạn generate CRUD flow trên nền như Koder.ai (backend Go với PostgreSQL), bạn có thể nhúng các kiểm tra này vào action approve một lần và tái sử dụng cho các hành động "chỉ một người thắng" khác.
Race condition dễ sửa nhất khi bạn coi chúng như quy trình lặp, không phải săn lỗi từng trường hợp. Tập trung vào vài luồng ghi quan trọng nhất, và làm cho chúng đúng đến mức nhàm chán trước khi tinh chỉnh phần khác.
Bắt đầu bằng việc đặt tên các điểm va chạm hàng đầu. Trong nhiều app CRUD, bộ ba giống nhau: bộ đếm (like, tồn kho, số dư), thay đổi trạng thái (Draft -> Submitted -> Approved), và gửi hai lần (nhấp đúp, retry, mạng chậm).
Một quy trình giữ vững:
Nếu bạn xây trên Koder.ai, Planning Mode là nơi thực tế để map từng luồng ghi thành các bước và quy tắc trước khi sinh code Go và PostgreSQL. Snapshot và rollback cũng hữu ích khi triển khai ràng buộc mới hoặc thay đổi khóa và cần quay lại nhanh nếu gặp edge case.
Theo thời gian, điều này thành thói quen: mỗi feature ghi mới có một constraint, một kế hoạch transaction và một test concurrency. Đó là cách các race condition trong app CRUD không còn là bất ngờ.