---
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 sang service bên ngoài gặp timeout hay crash, và khi phải retry. Bạn thiết kế được idempotency key, outbox, lease kèm fencing token, reconciliation và compensation để retry không trừ tiền hai lần, crash không làm sót email. 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: 0
---

## 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 và phục hồi sau crash, 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 một lời gọi có kết quả chưa rõ

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ài tiếp theo 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.

### 2. Đặt timeout và retry để sự cố không nặng thêm

Gateway trả 504 sau 10 giây, nhưng handler xuất báo cáo phía sau vẫn chạy tiếp 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, cùng khác biệt giữa idle timeout với max deadline. Tiếp theo là câu hỏi lỗi nào nên 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ả, rồi dùng exponential backoff và jitter để giãn các lần thử lại, dùng retry budget để giới hạn số lần thử.

### 3. 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 = ?`.

### 4. 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; cái giá phải trả 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ụ.

### 5. 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 đó.

### 6. 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 cuối khoá đặ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.

### 7. Bốn 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.

## 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.

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 con số đ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.
- **Nă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 đo những con số 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í)
- 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
- 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 đã 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
- 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
- Giữ đúng trạng thái thanh toán khi cổng timeout, webhook bị gửi trùng hoặc đến sớm · 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
