Một người dùng nhấn một nút. Yêu cầu bị treo. Mười giây im lặng. Họ nhấn nút dự phòng. Giờ đây có hai tác vụ đang chạy cho cùng một ý định. Kết quả là bạn gặp phải các tác dụng phụ trùng lặp, bị tính phí hai lần và một mớ hỗn độn dữ liệu làm tiêu tốn cả buổi chiều của bạn.

Đây không phải là lỗi frontend. Một nút bị vô hiệu hóa hay một bộ đếm debounce trong React sẽ không cứu được bạn. Yêu cầu đầu tiên đã đang được thực thi. Mạng chỉ đơn giản là đã "nuốt mất" phản hồi. Nếu backend của bạn coi mọi yêu cầu đến là một chỉ thị hoàn toàn mới, thì việc thử lại (retry) sẽ trở thành một rủi ro. Bạn cần khắc phục điều này trong thiết kế API và lược đồ cơ sở dữ liệu (database schema) của mình.

Giải pháp bắt đầu với một sự phân tách cấu trúc đơn giản.

Tách biệt Job khỏi Attempt

Hãy coi một job là bản ghi bền vững về những gì người dùng muốn. Nó ghi lại chủ sở hữu, các tham số, nhà cung cấp mục tiêu và ý định chính xác. Một attempt là một lần thử cụ thể để thực hiện ý định đó.

Hãy tưởng tượng một tiệm in. Bạn đưa một tệp và họ đưa cho bạn phiếu số #45. Tấm phiếu đó chính là job. Tiệm thử máy in phun. Nó bị kẹt. Đó là attempt thứ nhất. Họ chuyển tệp sang máy in laser. Đó là attempt thứ hai. Trong suốt quá trình, phiếu số #45 không bao giờ thay đổi. Nếu tiệm cấp một phiếu mới cho mỗi loại máy in họ thử, bạn sẽ phải trả tiền ba lần và nhận được ba bản in không mong muốn.

Cơ sở dữ liệu của bạn nên phản ánh điều này. Một bảng lưu trữ các job. Một bảng khác lưu trữ các attempt. Dòng (row) job giữ nguyên trong khi các attempt tích lũy bên dưới nó.

Sự phân tách này mang lại cho bạn quyền kiểm soát. Nó cũng tạo ra một nơi để gắn một idempotency key có thể tồn tại qua các sự cố mạng tạm thời.

Yêu cầu Idempotency Key cho mỗi Job

Mọi yêu cầu POST tạo một job đều phải mang theo một idempotency key duy nhất. Khóa này thuộc về người dùng, không phải phiên làm việc (session). Hãy kết hợp owner ID và key, sau đó áp dụng ràng buộc duy nhất (unique constraint) trong cơ sở dữ liệu trên hai cột đó.

Tại sao lại cần ràng buộc cơ sở dữ liệu? Bởi vì việc kiểm tra sự tồn tại trong mã ứng dụng trước khi chèn dữ liệu là một lỗi race condition chực chờ xảy ra. Hai yêu cầu giống hệt nhau có thể lọt qua cùng một khoảng hở micro giây. Hãy để cơ sở dữ liệu thực thi việc này. Nếu một người dùng gửi cùng một owner ID và key hai lần, yêu cầu thứ hai sẽ vấp phải vi phạm ràng buộc duy nhất và bạn sẽ trả về job hiện có. Cả hai yêu cầu đều nhận được cùng một job ID. Không có công việc trùng lặp nào được bắt đầu.

Hãy nghiêm ngặt về phạm vi. Nếu ai đó tái sử dụng key nhưng thay đổi payload đầu vào, hãy trả về lỗi xung đột (conflict). Idempotency key phải gắn chặt với một ý định chính xác, chứ không chỉ là người dùng. Cùng một key với đầu vào khác nhau có nghĩa là client đang bị nhầm lẫn, và hệ thống của bạn nên từ chối nó thay vì tự đoán.

Bảo vệ các bước chuyển đổi trạng thái

Một attempt là một bước chuyển đổi trạng thái, không phải là một job mới. API của bạn phải từ chối tạo ra một attempt mới nếu một attempt trước đó vẫn đang treo ở trạng thái bắt đầu hoặc trạng thái không xác định.

Timeout chính là nguyên nhân. Khi một yêu cầu đến nhà cung cấp bị timeout, client thấy thất bại, nhưng tiến trình phía server có thể vẫn đang chạy. Cụm GPU có thể vẫn đang miệt mài xử lý yêu cầu inference của bạn. Container có thể vẫn đang ghi dữ liệu vào blob storage. Nếu bạn đánh dấu attempt bị timeout là thất bại và ngay lập tức kích hoạt attempt thứ hai, bạn đang đánh cược với các tác dụng phụ trùng lặp.

Hãy coi timeout là một trạng thái không xác định, chứ không phải là trạng thái thất bại. Hãy chặn các attempt mới cho đến khi attempt trước đó đạt đến trạng thái kết thúc (terminal state) hoặc được hủy bỏ rõ ràng bởi một tiến trình bên ngoài. Sự tạm dừng này có thể gây khó chịu. Nó buộc người dùng phải chờ đợi. Nhưng nó cũng ngăn chặn sự hỗn loạn khi có hai worker cùng thay đổi các tài nguyên hạ nguồn (downstream resources) giống nhau.

Giải quyết Race Condition bằng Compare-and-Swap

Những vấn đề khó khăn nhất xuất hiện khi nhiều attempt cùng hoàn thành. Có thể hệ thống của bạn đã thực hiện attempt thứ nhất với nhà cung cấp chính. Sau mười giây im lặng, nó thực hiện attempt thứ hai với nhà cung cấp dự phòng. Giờ đây cả hai attempt đều đã xong. Bạn không thể để cả hai cùng ghi kết quả của chúng vào cùng một dòng job.

Hãy sử dụng logic compare-and-swap. Thêm một số phiên bản (version number) vào dòng job. Khi một attempt hoàn thành, nó sẽ thực hiện một lệnh update với các điều kiện:

  • Phiên bản hiện tại phải khớp với phiên bản mà attempt đã đọc lúc bắt đầu.
  • Không có attempt nào khác đã chiếm chỗ của kết quả.
  • Nếu cả hai điều kiện đều thỏa mãn, hãy ghi kết quả và tăng phiên bản lên.

Theo thuật ngữ SQL, điều đó trông giống như một câu lệnh update với WHERE id = $1 AND version = $2 AND completed_by IS NULL. Nếu lệnh update trả về không có dòng nào, nghĩa là một attempt khác đã giành chiến thắng. Yêu cầu đến muộn phải bị bỏ qua. Hãy loại bỏ kết quả của nó. Không hợp nhất. Không thêm vào. Hãy vứt bỏ công việc đó đi. Một kết quả đến muộn ghi đè lên người chiến thắng trước đó là sự sai lệch dữ liệu (data corruption), và hành động an toàn duy nhất là loại bỏ nó.

This handles the reverse-order finish cleanly. Attempt A leaves first but returns after thirty seconds. Attempt B leaves second but returns after five seconds. Attempt B wins the compare-and-swap. Attempt A’s update touches zero rows. Your system logs the race, ignores the stale payload, and moves on.

Test the Breakpoints

You will not catch these bugs in happy-path testing. Your suite needs to target the fractures.

  • Simulate a double-click. Two simultaneous POST requests with the same idempotency key must return identical job IDs.
  • Send the same key with mismatched input. Expect a conflict response. The system must not silently return the existing job if the parameters differ.
  • Provoke a timeout. Verify the job lands in an unknown state, not a failed state, and that the system blocks further attempts until the ambiguity clears.
  • Force two attempts to finish in reverse order. Confirm that the second one to return loses, even if the first one to leave was the official primary provider.

These tests are not edge-case luxuries. They are the contract your API makes with the rest of the system.

Validate Provider Intent Before You Fail Over

If you run a multi-provider setup, you might be tempted to treat different AI models as interchangeable slots. They share the same code path, the same HTTP client, and the same JSON schema. That does not mean they behave the same.

One model might hallucinate a top-level key. Another might ignore your system prompt formatting. Schema validation catches syntax errors, but it will pass a response that your business logic cannot interpret. A provider might return valid JSON that simply does the wrong thing with your prompt template.

Run provider-specific tests before you allow automatic model switching. Confirm that the fallback model actually respects your output structure at low temperature. Verify that your prompt renders correctly through that provider’s tokenizer. Test the full round trip with real inputs. Automatic failover is only safe when you have proven that the fallback shares the same operational contract.

Keep One Job Per Intent

Fallback paths are good. Uncontrolled fallback multiplication is a bug. Every layer of your stack needs to evaluate whether it has already seen the exact task. The load balancer, the API handler, the database, and the worker must all respect the same identity.

Build your system so that retries and fallbacks surface as new attempts under one stable job. Lock the job down with a database-backed idempotency key. Guard the transitions. Race the attempts. Let exactly one win. That is how you keep a single user click from turning into a weekend of data cleanup.