# Ba replica, một cron job: làm sao để khách không nhận ba email

> Scale out từ một bản chạy lên ba thì cron job cũng chạy ba lần. Scheduler-Agent-Supervisor và leader election giúp kiểm soát chuyện này, nhưng cách sửa bền nhất không nằm ở chiếc khoá.

Bản gốc: https://fdetimes.net/vi/bach-khoa/scheduler-agent-supervisor-leader-election/

Thử hình dung bạn deploy một service đồng bộ hoá đơn cho khách hàng. Trong code có một cron chạy lúc 2 giờ sáng. Tuần đầu chỉ có một replica nên mọi thứ êm. Đến tuần thứ hai, team hạ tầng của khách tăng lên ba replica cho chịu tải tốt hơn, và sáng hôm sau kế toán gọi tới vì mỗi khách nhận ba email nhắc nợ.

Lỗi này không do ai viết code ẩu. Tài liệu best practices về background job của Microsoft nói rõ: scale out máy chủ chứa scheduler là sinh thêm nhiều scheduler, và các scheduler này có thể khởi chạy nhiều bản của cùng một tác vụ.

Còn một đường dẫn tới chạy trùng nữa, ít người để ý hơn: một lần chạy kéo dài quá khoảng cách giữa hai lần kích hoạt, scheduler sẽ khởi động bản mới trong khi bản cũ vẫn đang chạy.

Nếu giải pháp bạn deploy có job định kỳ như đồng bộ dữ liệu hay gửi báo cáo và có thể chạy nhiều bản, tình huống này đáng được chuẩn bị từ đầu. Bài này dạy cách thiết kế để có bao nhiêu bản chạy song song thì kết quả vẫn đúng.

## Khoá chỉ là lớp thứ hai, lớp đầu tiên là idempotency

Phản xạ đầu tiên của nhiều người là thêm khoá cho chỉ một bản được chạy. Khoá cần có, nhưng thứ tự ưu tiên thì ngược lại. Tài liệu background job của Microsoft yêu cầu thiết kế tác vụ định kỳ theo kiểu idempotent, để chạy cùng một tác vụ nhiều lần cũng không sinh ra kết quả trùng.

Tài liệu về pattern Scheduler-Agent-Supervisor cũng đòi logic của từng bước phải idempotent, vì khi retry thì một bước có thể chạy hơn một lần.

Lý do rất thực tế. Kubernetes tự nhận trong tài liệu CronJob rằng ở một số tình huống, một CronJob vẫn có thể tạo ra nhiều Job chạy đồng thời. Khi nền tảng đã thừa nhận khoá có lúc trượt, code của bạn phải chịu được lúc trượt đó.

**Điểm mấu chốt:** Khoá giúp giảm số lần chạy trùng; chỉ idempotency mới làm chạy trùng trở nên vô hại.

## Ba vai trò, một bảng trạng thái

Khi job có nhiều bước, chẳng hạn kéo dữ liệu từ ERP, tính công nợ rồi gửi email, chỉ idempotent thôi thì chưa đủ. Bạn còn cần biết bước nào treo và ai sẽ dọn. Pattern Scheduler-Agent-Supervisor chia việc cho ba vai trò logic để cả tác vụ thành công hoặc thất bại như một khối.

Scheduler ghi trạng thái của từng bước vào một state store bền vững (durable), trong đó có mốc *complete-by* giới hạn thời gian bước được phép chạy. Agent thực thi bước. Supervisor chạy định kỳ, tìm các bước quá hạn hoặc lỗi rồi yêu cầu khôi phục. Supervisor không tự tay khôi phục: nó chỉ đưa ra yêu cầu, còn việc khôi phục do Scheduler và Agent làm.

Cách pattern nhận diện lỗi cũng rất đơn giản. Bước bị timeout và bước bị crash trông giống hệt nhau trong state store: bản ghi vẫn ghi *running* nhưng complete-by đã qua. Supervisor chỉ cần quét đúng điều kiện đó, khỏi phải đoán xem Agent chết vì lý do gì.

## Ví dụ cụ thể: job công nợ chạy trên ba replica

Quay lại service hoá đơn. Thay vì để cron gọi thẳng hàm gửi email, bạn tạo bảng `job_steps` với các cột `step_id`, `status`, `locked_by`, `complete_by`. Lúc 2 giờ sáng Scheduler trên cả ba replica cùng thức dậy, và cả ba chạy câu lệnh claim dưới đây:

```sql
UPDATE job_steps
SET status = 'running',
locked_by = :instance_id,
complete_by = now() + interval '15 minutes'
WHERE step_id = :step_id
AND status = 'pending';
-- rows_affected = 1: bản này giành được bước
-- rows_affected = 0: bản khác đã nhận, thoát
```

Đây là chuyển trạng thái có điều kiện, giống ví dụ của Microsoft dùng trường `LockedBy`. Nhiều orchestration Scheduler có thể cùng chạy, nhưng chỉ một lần thử giành được đơn hàng. Database lo phần tranh chấp, nên bạn không phải dựng thêm hệ thống bầu chọn nào.

Supervisor là một job nhỏ chạy mỗi vài phút:

```sql
SELECT step_id, locked_by FROM job_steps
WHERE status = 'running' AND complete_by < now();
```

Với mỗi dòng trả về, Supervisor ghi một yêu cầu khôi phục, ví dụ đặt lại `pending` hoặc đẩy một message. Scheduler sẽ nhận yêu cầu đó và giao lại bước cho Agent. Vì bước có thể chạy thêm lần nữa, lệnh gửi email phải idempotent.

Cách làm an toàn là giành quyền gửi trước rồi mới gửi. Tạo bảng `sent_reminders` có ràng buộc unique trên `(customer_id, ky_cong_no)`, chèn dòng vào trước khi gọi dịch vụ email. Bản nào chèn thành công thì gửi, bản nào bị lỗi vi phạm unique thì bỏ qua, nên hai bản chạy song song không thể cùng gửi.

Đừng làm ngược lại kiểu "kiểm tra, gửi, rồi mới ghi": hai bản có thể cùng qua bước kiểm tra trước khi bản nào kịp ghi, và khách vẫn nhận hai email.

Cái giá của cách chèn trước là nếu Agent chết ngay sau khi chèn, email có thể không đi. Bạn nên nói rõ với khách lựa chọn này, hoặc thêm trạng thái `sending` để Supervisor tìm ra những lần gửi bỏ dở.

## Khi nào thật sự cần leader election?

Supervisor cũng có thể chạy nhiều bản. Tài liệu Microsoft lưu ý rằng khi có nhiều Supervisor cùng hoạt động, chúng phải phối hợp để không cùng lao vào khôi phục một bước lỗi. Leader election là một cách: bầu một instance làm leader và để nó điều phối các instance còn lại.

Bầu leader khó hơn vẻ ngoài. Quy trình bầu phải ngăn hai instance cùng lên làm leader một lúc, và hệ thống phải phát hiện được khi leader chết, qua heartbeat hoặc polling. Với cách dùng blob lease, lease phải có thời hạn để leader hỏng không giữ nó mãi, và leader phải liên tục gia hạn.

Cái bẫy kinh điển nằm ở đó. Nếu tác vụ của leader bị treo mà luồng gia hạn lease vẫn chạy, leader cứ gia hạn mãi và không instance nào khác giành được lease. Vì thế phải kiểm tra sức khoẻ của chính công việc leader đang làm, đừng chỉ nhìn lease còn sống hay không.

Microsoft cũng nói thẳng rằng nhiều khi không cần leader election. Một tiến trình singleton, hỏng thì tắt rồi khởi động lại, hoặc một cơ chế khoá đơn giản là đủ. Nhưng một dịch vụ mutex dùng chung cũng thành điểm lỗi duy nhất. Bảng dưới gom các lựa chọn phổ biến:

| Cơ chế | Bảo đảm gì | Điểm yếu cần nhớ |
|---|---|---|
| Timer trigger của Azure Functions | Distributed lock để chỉ một instance chạy | Vẫn cần job idempotent |
| CronJob với `concurrencyPolicy: Forbid` | Bỏ qua lần chạy mới nếu lần trước chưa xong | Có lúc vẫn tạo nhiều Job đồng thời |
| Claim có điều kiện (`LockedBy`) | Mỗi bước chỉ một lần thử giành được | Phụ thuộc vào state store |
| Leader election bằng lease | Một instance điều phối các bản còn lại | Leader treo vẫn có thể gia hạn lease |

## Các bước làm ở dự án của khách

Bắt đầu từ việc kiểm kê. Với mỗi job định kỳ, hỏi khách: chạy hai lần thì hậu quả là gì, mỗi lần chạy lâu nhất bao lâu so với chu kỳ, và replica có tự scale không. Câu trả lời về thời gian chạy cho bạn con số đặt complete-by, và cho biết nguy cơ chạy chồng có thật hay không.

Sau đó làm theo thứ tự: biến mỗi bước thành idempotent, thêm bảng trạng thái với complete-by, dùng claim có điều kiện, viết Supervisor quét bước quá hạn. Đến lúc này mới cân nhắc leader election, và chỉ khi có một vai trò điều phối thật sự không chia nhỏ theo từng bản ghi được.

## Những lỗi hay gặp

Lỗi phổ biến nhất là tin vào một cấu hình duy nhất, kiểu đặt `Forbid` rồi coi như xong, trong khi chính Kubernetes nói nó không chặn được hết.

Lỗi thứ hai là để Supervisor tự tay chạy lại bước, làm nhoè ranh giới vai trò và tạo thêm một nguồn chạy trùng. Lỗi thứ ba là đặt complete-by ngắn hơn thời gian chạy thực tế, khiến Supervisor khôi phục cả những bước vẫn đang chạy bình thường.

## Kể kỹ năng này thế nào khi đi xin việc?

Trong CV, thay vì viết "dùng cron", hãy kể bạn đã làm job idempotent và chịu được nhiều replica ra sao, có số lần chạy trùng trước và sau khi sửa.

Bài kiểm tra tốt nhất là tự gây sự cố. Bật hai bản job công nợ cùng lúc, kill một bản giữa chừng, rồi xem hệ thống có tự hồi phục mà khách vẫn chỉ nhận đúng một email hay không.

**Thử ngay tuần này:**

- Liệt kê mọi job định kỳ trong dự án hiện tại và ghi bên cạnh mỗi job: chuyện gì xảy ra nếu job chạy hai lần cùng lúc?
- Thêm cột status, locked_by, complete_by vào bảng trạng thái của một job, viết câu UPDATE claim có điều kiện rồi thử chạy song song từ hai terminal
- Kiểm tra các CronJob Kubernetes đang dùng: concurrencyPolicy đặt là gì, và job đó có còn an toàn nếu Kubernetes vẫn tạo hai Job cùng lúc không?

## Nguồn

- [Scheduler Agent Supervisor pattern - Azure Architecture Center | Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/patterns/scheduler-agent-supervisor)

- [Leader Election pattern - Azure Architecture Center | Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/patterns/leader-election)

- [Best Practices for Background Jobs - Azure Architecture Center | Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/best-practices/background-jobs)

- [CronJob | Kubernetes](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)
