---
title: "Xử lý timeout, retry và crash - idempotency, outbox, lease và reconciliation"
description: "Khoá học về cách giữ dữ liệu đúng khi gọi service bên ngoài gặp timeout, crash hay phải retry. Bạn thiết kế được idempotency key, outbox, lease và reconciliation để retry không trừ tiền hai lần, crash không làm sót email, restart không làm mất hạn chờ của workflow. Khi chưa rõ kết quả, bạn tìm bằng chứng thay vì đoán."
canonical: "https://200lab.io/courses/xu-ly-timeout-retry-va-crash-idempotency-outbox-lease-va-reconciliation"
type: "course"
course_type: "course"
learning_type: "free_form"
published: "2026-10-02T17:15:12Z"
students: 72
---

## Khoá học này mang lại gì cho bạn

Khoá học xoay quanh những thao tác gây side effect lên service bên ngoài: trừ tiền, gửi email, tạo vận đơn, giữ chỗ. Timeout, retry và crash khiến những thao tác này có lúc chạy hai lần, có lúc không chạy, và có lúc kết quả của chúng còn chưa rõ. Khoá học cung cấp các cơ chế để retry an toàn và phục hồi đúng sau crash.

- **Tìm lúc kết quả chưa rõ** — Trên timeline của một lời gọi có side effect, bạn chỉ ra được thời điểm service của mình không còn biết việc ở service bên ngoài đã xảy ra hay chưa.
- **Phân loại lỗi trước khi retry** — Bạn quyết định lỗi nào được retry, retry mấy lần và cách nhau bao lâu, để vòng retry không làm sự cố kéo dài thêm.
- **Chặn một việc chạy hai lần** — Bạn gắn idempotency key với thao tác nghiệp vụ mà client muốn làm, ví dụ một lần thanh toán cho một đơn, và dùng precondition để chặn những lệnh đã được duyệt từ trước nhưng tới lúc chạy thì dữ liệu đã khác.
- **Dùng outbox đúng chỗ** — Bạn dùng transactional outbox khi việc ghi database và lời gọi sang service bên ngoài không thể nằm chung một transaction.
- **Giữ job chạy đúng khi worker chết** — Bạn dùng lease kèm fencing token để worker cũ không ghi đè kết quả của worker mới, và checkpoint để job nhiều bước chạy tiếp từ đúng bước sau crash.
- **Phục hồi dựa trên bằng chứng** — Sau crash, bạn đọc lại kết quả từ source of truth, hoặc retry với đúng idempotency key cũ khi API cam kết trả lại kết quả của lần đầu, rồi chọn giữa retry, reconcile hay compensate theo điều đã kiểm chứng được.
- **Đo trước và sau khi sửa** — Bạn đếm được số bản ghi bị trùng, số lệnh còn treo ở `pending` và mức lệch của tổng số dư, trước và sau khi thêm một cơ chế.

Đây là khoá học thiết kế ở mức cơ chế, độc lập với ngôn ngữ và framework: code minh họa viết bằng TypeScript, cơ chế dùng được với database và queue bạn đang có.

## Khoá học cung cấp những nền tảng nào

Cả khoá học xoay quanh một khái niệm: **kết quả chưa rõ** (ambiguous outcome), tức lúc service của bạn đã gửi lệnh sang service bên ngoài nhưng không biết lệnh đó đã chạy hay chưa. Mọi cơ chế trong khoá học cùng trả lời một câu hỏi: gọi lại thế nào để không chạy một việc hai lần, cũng không bỏ sót việc nào.

Bài [Making retries safe with idempotent APIs](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/) của Malcolm Featonby giải thích vì sao client gặp timeout không thể biết server đã xử lý xong hay chưa. Phần outbox bám theo mô tả [Transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html) của Chris Richardson, còn phần lease bám theo bài [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) của Martin Kleppmann. Nội dung được chia thành sáu nhóm cơ chế, đi từ timeout, retry, chống trùng tới chạy job, phục hồi sau crash và workflow phải chờ nhiều ngày, cùng một nhóm bài tình huống ghép nhiều cơ chế trong cùng một nghiệp vụ.

### 1. Đọc timeline của lời gọi bị timeout, rồi đặt deadline và retry cho đúng

Nhóm bài đầu lấy một lời gọi tạo vận đơn bị timeout và tách nó thành ba timeline: request mà người bán đang chờ, state trong bảng `shipments`, và side effect là vận đơn được tạo ở đơn vị vận chuyển. Ba timeline này không kết thúc cùng lúc, nên khối `catch` ghi `failed` cho mọi lỗi sẽ ghi sai những lệnh chưa rõ kết quả. Bạn cũng xếp hai request đặt hàng chạy song song lên cùng một trục thời gian để tìm race condition, và phân biệt lost update với check-then-act.

Client ngừng chờ cũng không có nghĩa là handler phía sau dừng lại: gateway trả 504 sau **10 giây**, nhưng handler xuất báo cáo vẫn chạy tới giây thứ **36**, vẫn upload file và gửi email. Nhóm này trình bày cách truyền deadline và tín hiệu hủy từ request xuống database và service bên ngoài, khác biệt giữa idle timeout với max deadline, rồi cách phân loại lỗi trước khi retry: bạn xét thao tác chỉ đọc hay thay đổi dữ liệu, và lỗi thuộc loại tạm thời, vĩnh viễn hay chưa rõ kết quả. Exponential backoff và jitter giãn khoảng cách giữa các lần thử lại, còn retry budget giới hạn số lần thử để vòng retry không làm sự cố nặng thêm.

### 2. Chống chạy trùng bằng idempotency key và precondition

Request ID được tạo mới ở mỗi lần gửi, nên không chặn được lần trừ tiền thứ hai khi client retry sau khi mất response. Idempotency key thì gắn với thao tác nghiệp vụ: server lưu kết quả đã commit theo key, gặp lại cùng key thì trả lại kết quả cũ, cùng key mà khác tham số thì từ chối.

Nhưng một lệnh chỉ chạy đúng một lần vẫn có thể sai: lệnh hoàn tiền được duyệt lúc **10:00** khi đơn còn ở trạng thái đã giao, tới **10:05** mới thực thi, trong khi luồng trả hàng đã hoàn tiền cho đơn đó từ trước. Precondition kiểm tra lại trạng thái hiện tại ngay trong câu lệnh ghi, bằng compare-and-set dạng `UPDATE ... WHERE version = ?`.

### 3. Lời gọi nằm ngoài transaction: outbox và consumer chịu được message bị giao lại

Nếu commit đơn hàng rồi mới gọi service email mà process chết ở giữa, đơn đã có trong database nhưng email không bao giờ được gửi. Transactional outbox ghi việc cần gửi email vào cùng transaction với đơn hàng, rồi một relay gửi đi sau; tradeoff của cách này là email có thể đến trễ hoặc bị gửi trùng. Ở phía nhận, queue giao message theo kiểu at-least-once, nên consumer chống trùng bằng cách ghi event id vào cùng transaction với thay đổi nghiệp vụ.

### 4. Job vẫn chạy đúng khi worker chết hoặc bị treo: lease, fencing token và checkpoint

Lease giao quyền chạy job cho một worker trong một khoảng thời gian, nhưng không chứng minh worker cũ đã dừng: worker bị GC pause lâu hơn thời hạn lease vẫn có thể hoạt động lại và ghi ra hóa đơn trùng. Fencing token là con số tăng dần đi kèm mỗi lần cấp lease, để bảng hóa đơn từ chối mọi lần ghi mang token cũ.

Với job nhiều bước, như job onboarding người bán có gọi một service xác minh giấy tờ tính phí theo từng lần gọi, checkpoint lưu kết quả của từng bước. Khi job chạy lại sau crash, bước nào đã có kết quả trong checkpoint thì được replay thay vì gọi lại service bên ngoài; crash xảy ra đúng lúc một bước đang chạy vẫn có thể khiến service xác minh bị gọi hai lần cho bước đó.

### 5. Phục hồi sau crash bằng reconciliation và compensation

Sau crash, một lệnh chi tiền còn `pending` trong database không cho biết tiền đã chuyển hay chưa, nên nếu job phục hồi gửi lại mọi lệnh `pending` thì tiền có thể bị chuyển lần thứ hai. Reconciliation lấy bằng chứng từ đối tác, nơi giữ kết quả thật: hỏi lại trạng thái của lệnh, hoặc gửi lại với đúng idempotency key cũ khi API của đối tác cam kết không chuyển tiền lần nữa cho key đó.

Khi bước sau thất bại mà bước trước đã xong, bạn chọn cách hoàn tác hợp với từng bước: direct undo, compensating action, hoặc xử lý thủ công bên ngoài luồng tự động khi side effect không thể thu hồi, như một email đã gửi. Bài tổng hợp khép lại nhóm này bằng cách đặt một thao tác chi tiền vào năm kịch bản để bạn chọn cách phục hồi cho từng kịch bản.

### 6. Workflow chạy lâu, từ cron tới durable execution

Crontab chạy trên một máy không ghi lại lượt nào đã bị lỡ: máy chết nửa tiếng thì ba lượt xuất đơn sang kho không hề có trong log, cũng không để lại dòng lỗi nào. Bật thêm crontab y hệt trên máy thứ hai để dự phòng thì mỗi lượt lại chạy hai lần. Trong nhóm này, mỗi lượt chạy theo lịch được ghi thành một dòng trong database, để lượt bị lỡ hiện ra và được chạy bù theo chính sách của từng job, còn hai lượt của cùng một job không chạy chồng lên nhau. Việc chỉ đến hạn sau nhiều ngày, như khoá tài khoản khi hết hạn dùng thử, được lưu thành durable timer: mỗi timer là một dòng có cột `wake_at`, và worker lấy theo lô những timer đã đến hạn thay vì để cron quét cả bảng nghiệp vụ mỗi phút.

Workflow chờ một sự kiện từ bên ngoài, như chữ ký của khách trên hợp đồng, cần có hạn chờ; thiếu hạn chờ thì hợp đồng gửi từ hơn hai tuần trước vẫn nằm ở trạng thái chờ ký mà không ai được giao việc gọi cho khách. Mỗi lần chờ có một token chỉ dùng được một lần và một hạn `expires_at`; workflow gửi lời nhắc trước hạn, và quá hạn thì chuyển việc cho người phụ trách. Một workflow đang chờ cũng có thể chạy tiếp bằng code vừa deploy và gửi khách mức giá khác với giá đã duyệt, nên mỗi run (một lần workflow chạy) ghim `definition_version` ngay lúc được tạo. Bài cuối của nhóm so sánh những việc các bảng tự dựng trên Postgres đang làm với những việc một durable execution engine làm thay, để bạn dựa vào số liệu đo được mà quyết định khi nào nên chuyển sang engine.

### 7. Sáu bài tình huống nghiệp vụ

Sau mỗi cụm cơ chế là một bài tình huống đặt các cơ chế đó vào một nghiệp vụ cụ thể, có số đo trước và sau khi sửa. Để giữ phòng cho khách trong lúc khách thanh toán, bạn so sánh hai cách làm: compare-and-set trên Redis và exclusion constraint trên Postgres.

Với agent chăm sóc khách hàng gọi tool hoàn tiền rồi gọi lại sau timeout, yêu cầu hoàn tiền được ghi thành một step trong database trước khi gọi cổng thanh toán, và idempotency key lấy từ step đó thay vì từ id của tool call. Thanh toán qua cổng thanh toán cần một state machine khi webhook bị gửi trùng hoặc đến sớm; chuyển tiền giữa hai ví cần một transaction để crash không để lại lệnh chuyển mới xong một nửa, còn double-entry ledger giúp đối chiếu toàn bộ sổ và lần lại từng giao dịch.

Hai bài tình huống còn lại thuộc nhóm workflow. Trong bài đầu, gần **100.000 đơn sỉ** trả sau đang chờ thanh toán, và cron hủy đơn quá hạn ghi đè trạng thái của cả những đơn khách vừa chuyển khoản. Bạn đưa hạn thanh toán, lời nhắc và hạn chờ quản lý duyệt vào bảng timer, chỉ cho lệnh hủy chạy khi đơn vẫn đang chờ thanh toán, rồi tính xem một worker có còn xử lý kịp chừng ấy timer không. Bài thứ hai bắt đầu từ một job tự ngừng worker của những bản deploy đã quá **4 tuần**: sau một lượt chạy, **2.680 run** đang ghim bốn version cũ không còn worker nào xử lý. Từ những timer và lần chờ còn lại của các run đó, bạn dự báo ngày được bỏ code của từng version, và chỉ ngừng worker khi version đó không còn run nào chưa kết thúc.

## Vì sao 200Lab tạo ra khoá học này

Phần lớn backend hiện nay gọi sang những service mà bạn không kiểm soát, và nhiều lời gọi trong số đó không thể rút lại, như một lệnh chi tiền cho người bán qua đối tác chuyển khoản. Retry thì có sẵn ở khắp nơi: trong HTTP client, SDK của cổng thanh toán, queue, workflow engine, và cả vòng lặp của agent khi tool trả về timeout.

Bật retry chỉ tốn một dòng cấu hình, và công cụ sẽ gọi lại đúng như bạn cấu hình, nhưng nó không biết lần gọi lại đó có chuyển tiền thêm lần nữa hay không. Câu hỏi đó chỉ bạn trả lời được, mà timeout và crash trên production lại xảy ra thường xuyên: mạng chậm, instance bị restart giữa lúc deploy, GC pause kéo dài.

Lấy ví dụ một lần trừ tiền khách qua cổng thanh toán bị timeout. Cách dễ nhất là gọi lại cho chắc, hoặc ghi `failed` rồi trả lỗi cho client. Nếu bạn gọi lại trong khi lần đầu cổng thanh toán đã trừ tiền, khách bị trừ hai lần và bạn phải hoàn lại khoản thừa; nếu bạn ghi `failed` cho một đơn thật ra đã thanh toán, bạn phải đối chiếu giao dịch với cổng thanh toán rồi hoàn tiền bằng tay. Chọn sai cách nào cũng tốn tiền thật, tốn thời gian của kỹ sư trực và làm khách mất lòng tin.

Để chặn những lỗi này, bạn không cần hạ tầng lớn. Khi gọi Stripe, bạn gửi kèm `Idempotency-Key`; nếu gọi lại với cùng key và cùng tham số trong thời gian Stripe còn lưu key đó, bạn nhận lại kết quả của lần đầu thay vì một lần trừ tiền mới. Còn khi đọc message từ SQS, chính consumer của bạn phải chịu được việc message bị giao lại. Bạn tự dựng được phần lớn cơ chế tương tự bằng một bảng mới, một câu `UPDATE` có điều kiện hay một job chạy nền, rồi thêm vào code đang chạy mà không phải viết lại từ đầu.

Không công cụ nào quyết định thay bạn lời gọi nào cần idempotency key, job nào cần fencing token, hay khi nào thêm outbox là quá tay. Khoá học cho bạn đủ cơ sở để tự trả lời những câu hỏi đó trên hệ thống của mình: thêm cơ chế vừa đủ ở chỗ thật sự cần, biết rõ nó tốn thêm gì, và để nguyên những chỗ chưa cần.

## Những vấn đề thường gặp khi xử lý timeout, retry và crash

Khi một lời gọi báo lỗi, cách xử lý thường được chọn trước khi có ai kiểm tra lời gọi đó đã gây ra side effect hay chưa. Những triệu chứng dưới đây là hệ quả hay gặp nhất của thói quen đó.

- Màn hình báo lỗi 504 nhưng tài khoản của khách đã bị trừ tiền — API ngừng chờ không có nghĩa là cổng thanh toán chưa trừ tiền, và chỉ cổng thanh toán mới cho biết kết quả thật.
- Email provider đang quá tải thì lưu lượng gửi sang lại tăng gần gấp bốn — mỗi client retry ngay ba lần mà không có backoff, jitter hay retry budget, nên chính vòng retry kéo dài sự cố.
- Đơn hàng đã commit nhưng khách không nhận được email xác nhận — process chết giữa lúc commit và lúc gọi service email, mà lời gọi đó nằm ngoài transaction của database.
- Sau một đợt deploy, consumer cộng điểm thưởng hai lần cho cùng một đơn — consumer xử lý xong nhưng chết trước khi ack, và queue at-least-once giao lại message đó.
- Job xuất hóa đơn chạy trên ba replica, **620 đơn hàng** mà có **820 hóa đơn** — worker cũ bị pause lâu hơn thời hạn lease, hoạt động lại vẫn ghi được vì bảng hóa đơn không kiểm tra fencing token.
- Job phục hồi gửi lại mọi lệnh chi tiền còn `pending`, sáng ra **34 người bán** nhận tiền hai lần — job đoán kết quả thay vì hỏi lại đối tác chuyển khoản xem lệnh đã chạy hay chưa.
- Sau một lần deploy, **64 báo giá** gửi khách lệch với giá trưởng nhóm sales đã duyệt — những run đã chờ duyệt từ trước khi deploy, tới lúc chạy tiếp thì chạy bằng code mới, trong khi các bước đầu của chúng đã chạy theo code cũ.

Mỗi triệu chứng trên được phân tích trong một bài riêng, tới tận cơ chế gây lỗi và cách sửa.

## Bạn sẽ học theo cách nào

- **Triệu chứng trước, cơ chế sau** — Mỗi bài mở bằng một sự cố có log, metric và số bản ghi cụ thể, rồi mới gọi tên cơ chế giải thích sự cố đó.
- **Hình vẽ timeline** — Hình minh họa vẽ chính hệ thống trong bài: các bước theo thời gian, chỗ crash xảy ra, và phần mà cách sửa thêm vào.
- **Một ví dụ, một lần sửa** — Mỗi bài cơ chế đi theo một ví dụ nhỏ, sửa một đoạn code TypeScript ngắn rồi so sánh trạng thái trước và sau bằng số liệu đo được.
- **Quiz ở các bài cơ chế** — Bài nào trình bày một cơ chế thì có vài câu hỏi yêu cầu bạn dự đoán điều xảy ra hoặc quyết định cách xử lý trên chính ví dụ của bài.
- **Tám demo kit chạy bằng Docker** — Coding agent của bạn đọc file Markdown của kit, dựng hệ thống bằng Docker trên máy và tái hiện sự cố; bạn tự sửa theo bài rồi đo lại, vì kit không có lời giải.

## Khoá học dành cho ai

1. **Developer backend** — Bạn đã viết API nhận request, lưu dữ liệu vào database và gọi service bên ngoài, có code chạy trên production, và từng gặp đơn hàng, email hay lệnh chi tiền bị trùng hoặc bị mất mà chưa rõ vì sao.
2. **Người làm tích hợp thanh toán** — Bạn tích hợp cổng thanh toán, ví hay service đặt chỗ của đối tác, và cần cách xử lý timeout, webhook và crash để không phải đối chiếu bằng tay sau mỗi sự cố.
3. **Developer xây agent gọi tool** — Tool của bạn hoàn tiền, gửi email hay tạo đơn, và bạn cần chặn việc agent gọi lại tool sau timeout làm side effect xảy ra hai lần.

## Những điều cần lưu ý

- **Không hướng dẫn cấu hình một công cụ cụ thể** — Khoá học không đi vào cấu hình Kafka, Temporal hay Redis; bạn nắm cơ chế ở mức thiết kế, dùng được với công cụ bạn đang có.
- **Không đề cập tới consensus và leader election** — Lease trong khoá học chỉ dùng để giao quyền làm việc sang worker khác; bạn biết rõ lease bảo vệ được gì và cần thêm fencing token ở đâu.
- **Không gồm quy trình vận hành SRE** — Trực ca, cảnh báo hay postmortem nằm ngoài khoá học; bạn biết cần theo dõi những số liệu nào để thấy cơ chế đang chạy đúng.
- **Demo kit là phần tùy chọn** — Bạn hiểu trọn từng bài mà không cần dựng môi trường Docker; kit dành cho lúc bạn muốn thấy sự cố xảy ra ngay trên máy mình rồi đo lại sau khi sửa.

## Bắt đầu từ đâu

Bài đầu tiên trả lời câu hỏi: sau timeout hay crash, lệnh đã gửi sang service bên ngoài đã chạy hay chưa. Bài này đứng đầu vì mọi cơ chế phía sau đều bắt đầu từ việc chỉ ra chính xác những lúc service của bạn không biết kết quả.

## Chương trình

- Sau timeout hay crash, lệnh đã gửi sang service bên ngoài chạy hay chưa? (miễn phí)

### Kết quả chưa rõ sau timeout và cách retry cho đúng

- Request đã kết thúc nhưng kết quả ở service bên ngoài vẫn chưa rõ (miễn phí)
- Đọc timeline của hai request chạy song song để tìm race condition (miễn phí)
- Client đã timeout nhưng service phía sau vẫn chạy tiếp · quiz
- Lỗi nào nên retry, retry bao nhiêu lần và cách nhau bao lâu · quiz

### Lệnh không chạy hai lần khi retry, không bị mất khi crash

- Idempotency key giúp retry mà không trừ tiền khách hai lần · quiz
- Lệnh chỉ chạy một lần vẫn sai khi dữ liệu đã khác so với lúc duyệt · quiz
- Ghi database và gọi service bên ngoài không nằm chung một transaction · quiz · kit cho agent
- Chặn consumer xử lý một message hai lần khi queue giao lại · quiz

### Lease cho worker, giữ chỗ cho khách, checkpoint cho job

- Lease đã hết hạn mà worker cũ vẫn ghi dữ liệu · quiz
- Giữ chỗ có thời hạn khi khách đang thanh toán, làm bằng Redis hay Postgres · kit cho agent
- Job nhiều bước bị crash giữa chừng thì chạy tiếp từ đâu · quiz

### Phục hồi sau sự cố bằng reconciliation và compensation

- Sau crash, hỏi lại đối tác xem việc đã thật sự xảy ra chưa rồi mới retry · quiz
- Agent gọi lại tool hoàn tiền sau timeout và khách được hoàn tiền hai lần · kit cho agent
- Hoàn tác các bước đã xong khi bước sau thất bại · quiz
- Ghi đúng trạng thái khi gọi cổng thanh toán bị timeout hay nhận webhook trùng · kit cho agent
- Chuyển tiền giữa hai ví mà tổng số dư không bị lệch · kit cho agent
- Sau sự cố, đọc timeline để chọn retry, reconcile hay compensate

### Workflow chạy lâu, từ cron tới durable execution

- Ghi mỗi lượt chạy cron thành một dòng để không lượt nào bị lỡ hay chạy chồng · quiz · kit cho agent
- Lưu hàng chục nghìn timer trong Postgres để worker xử lý chúng đều đặn · quiz
- Đặt hạn chờ cho workflow đang đợi một sự kiện có thể không bao giờ đến · quiz
- Một bảng timer và một worker cho 100.000 đơn sỉ đang chờ thanh toán · kit cho agent
- Ghim version để workflow đang chờ không chạy tiếp bằng code mới sau deploy · quiz
- Tính ngày được bỏ code của một version cũ khi vẫn còn workflow ghim version đó · kit cho agent
- Khi nào nên chuyển từ bảng Postgres sang workflow engine
