Skip to content

ADR-0003. Tính idempotent của phiếu qua partial unique index

FieldValue
StatusAccepted
Date2026-05-06
DecidersPhat 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 indexDù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 NULLkhử 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ả

ƯuNhược
Gửi lại vẫn an toàn - không ghi sổ trùngProducer 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 consumerCó 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ƯuNhượcLý do loại bỏ
Chỉ khử trùng lặp ở ứng dụng (không có index DB)Schema đơn giản hơnTranh chấp giữa các replica consumer có thể ghi sổ trùngKhô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ồnMột quy tắc duy nhấtPhá 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ờiThêm hạ tầng; có khoảng nhất quán so với lần ghi DBPartial 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

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.