ADR-0003. Tính idempotent của phiếu qua partial unique index
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-05-06 |
| Deciders | Phat Nguyen |
| Supersedes | - |
Bối cảnh
- Cơ chế gửi của Kafka là ít nhất một lần; consumer của finance chỉ commit offset sau khi handler thành công (
autocommit=false). Một sự cố sập sau khi đã ghi sổ nhưng trước khi commit sẽ khiến message bị gửi lại. - Nếu không khử trùng lặp, việc gửi lại sẽ ghi sổ phiếu hai lần và làm sai số dư tài khoản.
- Các nguồn khác nhau cần khóa khử trùng lặp khác nhau: một sale order có thể nhận nhiều lần thanh toán chia nhỏ một cách hợp lệ (khử trùng lặp theo từng sự kiện thanh toán), trong khi một purchase order chỉ nên ghi đúng một phiếu PAYMENT (khử trùng lặp theo từng chứng từ nguồn).
- Chứng từ đã hủy không được phép chặn một lần ghi lại hợp lệ về sau của cùng một nguồn.
Quyết định
Chúng tôi sẽ bảo đảm tính idempotent ngay trong database qua các partial unique index trên FinanceVoucher, kết hợp với một bước kiểm tra phát lại ở tầng ứng dụng:
| Key | Điều kiện index | Dùng bởi |
|---|---|---|
(merchantId, type, sourceType, sourceId) | còn sống, chưa VOIDED, sourceType NOT IN (MANUAL, POS_SESSION, SALE_ORDER) | khử trùng lặp theo từng nguồn (vd PURCHASE_ORDER) |
(sourceType, sourceEventUid) | còn sống, chưa VOIDED, sourceEventUid IS NOT NULL | khử trùng lặp theo từng sự kiện (SALE_ORDER attempt.uid, INVENTORY_ADJUSTMENT inventoryTrackingId) |
Trước khi ghi sổ, FinanceVoucherService.tryIdempotentReplay tra cứu phiếu đã tồn tại theo key tương ứng và, nếu trúng, trả về phiếu đó (kèm các dòng sổ cái) thay vì ghi sổ lại. Index là lớp chặn cuối cùng ở mức cứng; bước kiểm tra ở ứng dụng giúp tránh phải dựa vào một vòng tròn báo lỗi vi phạm ràng buộc unique. Mọi điều kiện đều được giới hạn trong phạm vi deletedAt IS NULL AND status <> 'VOIDED'.
Hệ quả
| Ưu | Nhược |
|---|---|
| Gửi lại vẫn an toàn - không ghi sổ trùng | Producer phải điền đúng key (sourceEventUid cho các nguồn theo từng sự kiện; được kiểm tra, ném lỗi nếu thiếu) |
| DB là nguồn chân lý ngay cả khi có tranh chấp giữa các replica consumer | Có hai chiến lược khử trùng lặp cần nắm (theo từng nguồn vs theo từng sự kiện) |
| Dòng đã hủy/đã xóa không bao giờ chặn một lần ghi lại hợp lệ | Điều kiện index khá tinh tế - phải luôn đồng bộ với FinanceVoucherSourceTypes.isDedupable* |
| Thanh toán chia nhỏ ghi đúng một RECEIPT cho mỗi attempt | - |
Các phương án đã cân nhắc
| Phương án | Ưu | Nhược | Lý do loại bỏ |
|---|---|---|---|
| Chỉ khử trùng lặp ở ứng dụng (không có index DB) | Schema đơn giản hơn | Tranh chấp giữa các replica consumer có thể ghi sổ trùng | Không an toàn khi mở rộng theo chiều ngang |
| Một khóa khử trùng lặp toàn cục cho mọi nguồn | Một quy tắc duy nhất | Phá vỡ thanh toán chia nhỏ (một khóa cho mỗi đơn) | Sai với đơn có nhiều lần thanh toán |
| Dùng kho khử trùng lặp bên ngoài (Redis set) | Tách rời | Thêm hạ tầng; có khoảng nhất quán so với lần ghi DB | Partial index trong DB diễn ra liền mạch cùng lúc với việc ghi sổ |
Tham chiếu
packages/core/src/models/schemas/finance/finance-voucher/schema.ts(partial unique index)packages/core/src/models/schemas/finance/finance-voucher/constants.ts(FinanceVoucherSourceTypes.isDedupable,isDedupablePerSourceEvent)packages/core/src/services/finance/finance-voucher.service.ts(tryIdempotentReplay,_lookupExistingForReplay)- API Events - Idempotency & Ordering