Files
sys-analysis-design/sa-output/e-commerce/02-architecture/adr/ADR-014_transactional-outbox-publish-su-kien.md
Canhchimlac 343ad8bbbc save
2026-09-15 16:02:30 +07:00

208 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-014 — Transactional Outbox cho publish sự kiện Cart & Order / Payment
*Tên file: `adr/ADR-014_transactional-outbox-publish-su-kien.md`*
| | |
|---|---|
| **Status** | `Proposed` |
| **Date** | 2026-09-15 |
| **Người quyết** | SA + Tech Lead (cấu trúc/tích hợp — `decision-radar.md §5`) — chưa ký thật |
| **Người đề xuất** | SA (qua skill `sa-2-architecture`, hoạt động `adr`) |
| **Điểm radar** | **6/10** *(chấm theo `decision-radar.md §2`, kế thừa nguyên ước lượng đã ghi ở `DAT_e-commerce_v1.0.md §4.1`: Chi phí đảo ngược=1 (1–3 tuần — `outbox_event` là bảng nội bộ, không phải dữ liệu nghiệp vụ, chưa tới mức "phải migrate dữ liệu") · Bán kính ảnh hưởng=1 (Cart&Order + Payment, 2 `CMP` publish, cộng tiền lệ cho các module tương lai) · Chạm `QAS` Must=1 (gián tiếp `QAS-005`/`DRV-08` — không mất giao dịch, không phải chính nó là con số `QAS`) · Ràng buộc dài hạn=2 (trở thành chuẩn chung cho mọi module publish sự kiện, ≥1 năm) · Tranh cãi=0, cộng +1 vì đây là quyết định **thiết lập tiền lệ** áp dụng toàn hệ thống (`decision-radar.md §4`) → **5–7 ⇒ `ADR` bắt buộc**, dưới ngưỡng 8 nên không bắt buộc POC trước khi `Accepted`, nhưng cần bài đo/`FIT` xác nhận trước khi chuyển `Accepted` theo §6)* |
| **Supersedes** | — |
| **Superseded by** | — |
| **Liên quan** | `ASR-004` · `ASR-005` · `QAS-005` · `DRV-08` · `ADR-004` · `ADR-005` · `ADR-013` · `DAT_e-commerce_v1.0.md §1.3, §1.4, §4.1` · `FAIL_e-commerce_v1.0.md §7` · `OQ-044` · `DEC-25` |
> ⚠️ **ADR bất biến sau khi `Accepted`.** Muốn đổi quyết định thì viết ADR mới có
> `Supersedes: ADR-014` và đổi trạng thái bản cũ thành `Superseded by`. Không sửa nội dung ADR
> đã `Accepted`, không xoá ADR đã `Rejected`.
---
## 0. Ghi chú Preflight & ngoại lệ gate — bắt buộc đọc trước
AG1 vẫn chưa ký thật; AG2 (gate của chính GĐ2) chưa tới hạn. Theo `SKILL.md`, hoạt động 1 (`QAS`)
→ 2 (`ASR`) → 3 (`SAD`) đã hoàn tất và được duyệt từng phần từ trước; hoạt động 4–8 (`ICD`/`DAT`/
`SEC`/`INF`/`FAIL`) đã chạy và duyệt từng phần (`DEC-18…30`). `ADR-001…013` đã viết (`Proposed`).
`DAT_e-commerce_v1.0.md §4.1` (duyệt từng phần, `DEC-25`) đã chọn **TẠM** hướng Transactional
Outbox cho khoảng trống dual-write phát hiện ở `FAIL §7`, và yêu cầu SA viết `ADR-014` chính thức
ở lượt hoạt động `adr` này. Người dùng (vai điều phối dự án chạy thử) đã được cảnh báo lại và
**khẳng định muốn tiếp tục**.
> **DEC-31 (SA)** — Viết `ADR-014`/`ADR-015`/`ADR-016`/`ADR-017` (`Proposed`) cho e-commerce trong
> khi AG1 chưa ký thật và AG2 chưa tới hạn — tiếp nối `DEC-01…30`. Hiện thực hoá 4 hướng đã chọn
> TẠM qua `OQ-044`/`DEC-25` (outbox), `OQ-050`/`DEC-28` (service JWT), và 2 ứng viên mới của `INF`
> (`DEC-30`: blue-green, Terraform). Tất cả 4 `ADR` giữ `Status Proposed` — không `Accepted`. Chỉ
> link 4 `ADR` này vào `DAT §4.1`, `SEC §2.4`/§4 (TB3/TB4), `INF §7.2`/§11 (thay "ứng viên"/"chưa
> viết" bằng đường dẫn thật), không đổi nội dung chuyên môn khác của ba tài liệu đó — mỗi file
> chạm tới bump `+0.1` kèm Change Log "chỉ link ADR, không đổi nội dung". Cập nhật `ADL`/`DTM`.
> **Người quyết:** Điều phối dự án (đại diện PO, dự án chạy thử).
> **Radar (ước lượng cho việc tiếp tục dưới ngoại lệ, khác điểm radar riêng của từng ADR):** ~4 —
> chi phí đảo ngược thấp (ghi 4 ADR + link tài liệu, chưa có dòng code phụ thuộc) · bán kính ảnh
> hưởng: AG2 phụ thuộc nhưng bản thân *việc ghi dưới ngoại lệ* thì nhỏ · chạm gián tiếp `QAS-005`/
> `QAS-007`/`QAS-002`/`QAS-010` Must (đang hiện thực hoá các cơ chế đạt chúng) nhưng chưa cam kết
> thi công (không `ADR` nào `Accepted`) · không ràng buộc dài hạn tự thân (nội dung từng `ADR` đã
> được chấm radar riêng, 5–7, không cái nào ≥8) · không tranh cãi mới, tiếp nối tiền lệ `DEC-17/23`
> → **ghi `DEC-nn`, không cần thêm `ADR` cho chính việc chạy dưới ngoại lệ**.
> **Hệ quả nếu không chấp nhận ngoại lệ:** `OQ-044`/`OQ-050` tiếp tục treo ở trạng thái "đã chọn
> hướng nhưng chưa có `ADR`" — Dev không có tài liệu chính thức để thi công outbox/service-authn;
> `INF §7.2`/§11 tiếp tục ghi "đề xuất ADR mới, chưa viết", để lộ khoảng trống ở tiêu chí AG2
> "`ADR` tồn tại cho mọi quyết định đạt ngưỡng radar".
---
## 1. Bối cảnh
`FAIL_e-commerce_v1.0.md §7` phát hiện một khoảng trống: nếu service chết **sau** `COMMIT`
transaction tạo `Order` nhưng **trước** khi publish `OrderPlaced`/`PaymentInitRequested` thành
công, sự kiện bị mất vĩnh viễn — không consumer nào (Commission, Promotion, Notification,
Shipping, Payment) biết `Order` đó tồn tại. Đây là bài toán **dual write** kinh điển (ghi DB + gọi
message queue không nguyên tử). `DAT_e-commerce_v1.0.md §4.1` đã phân tích khoảng trống này và đề
xuất Transactional Outbox, được duyệt **TẠM** qua `DEC-25` với yêu cầu SA viết `ADR` chính thức.
`ADR-005` đã chốt message backbone (SQS FIFO + EventBridge); `ADR-013` bổ sung một sự kiện mới
(`PaymentInitRequested`) publish **cùng lúc** với `OrderPlaced` ngay sau khi commit — càng làm
tăng tầm quan trọng của việc publish nguyên tử, vì nay có ≥2 sự kiện phải cùng "sống hay chết"
với transaction ghi `Order`.
**Ràng buộc đang chi phối:**
| Nguồn | Nội dung |
|---|---|
| `ASR-004` | Webhook + xác nhận thanh toán phải idempotent + có cơ chế bù — mở rộng tinh thần sang cả bước publish sự kiện tạo đơn |
| `ASR-005` | Message backbone SQS FIFO + EventBridge đã chọn (`ADR-005`) — outbox là cơ chế publish nguyên tử **lên trên** backbone đó, không thay thế nó |
| `QAS-005` | Fan-out lag ≤5 giây từ `OrderPlaced` tới consumer — outbox relay (đề xuất 1–2 giây) phải nằm trong ngân sách này |
| `DRV-08` | Xác nhận thanh toán/giao dịch không được mất — outbox là biện pháp trực tiếp chống mất sự kiện `OrderPlaced`/`PaymentInitRequested` |
**Cái đã biết chắc / cái còn là giả định:**
| Điều | 🟢 Đã kiểm chứng / 🔴 Giả định | Bằng chứng |
|---|---|---|
| Dual-write (ghi DB + gọi queue không cùng transaction) có thể mất sự kiện nếu service chết giữa hai bước | 🟢 Đã kiểm chứng — đây là vấn đề đã biết rộng rãi trong kiến trúc phân tán, không cần đo riêng cho dự án này | `FAIL §7` |
| Relay poller in-process (đọc `outbox_event` mỗi 1–2 giây) đủ nhanh để không ăn hết ngân sách `QAS-005` (≤5 giây) | 🔴 Giả định (`ASM-38`, `DAT §4.1`) | Chưa đo — cần bài đo GĐ3 |
| Consumer đã có dedupe theo `eventId` (chống publish trùng khi relay publish thành công nhưng đánh dấu `published` thất bại — at-least-once) | 🟢 Đã kiểm chứng — đã thiết kế ở `ICD §4` | `ICD_e-commerce_v1.0.md §4` |
## 2. Phương án đã cân nhắc
### PA-1 — Job quét đối chiếu định kỳ
| | |
|---|---|
| **Mô tả** | Job định kỳ (đề xuất mỗi 5 phút) quét `order` không có sự kiện tương ứng đã publish thành công (dựa cột đánh dấu `event_published_at`), publish lại |
| **Ưu** | Không cần bảng/hạ tầng mới, chỉ thêm 1 cột đánh dấu |
| **Nhược** | Không giải quyết nguyên nhân gốc — luồng publish "bình thường" (ngay sau commit) vẫn là dual-write không nguyên tử, job chỉ là lưới vá muộn; có race condition giữa "đang xử lý" và "đã mất" nếu không thiết kế cẩn thận cột đánh dấu; độ trễ phát hiện tối đa bằng chu kỳ job (5 phút) — vượt xa ngân sách `QAS-005` (≤5 giây) cho trường hợp cần vá; không tổng quát — mỗi module publish event sau này phải tự cài lại logic quét riêng |
| **Chi phí đảo ngược** | Trung bình — đổi sang cơ chế khác sau khi nhiều module đã cài job quét riêng tốn công dọn dẹp từng nơi |
### PA-2 — Transactional Outbox *(chọn)*
| | |
|---|---|
| **Mô tả** | Ghi một dòng `outbox_event` **trong cùng transaction DB** với việc tạo `Order`/`OrderSeller`/`OrderItem` (cùng schema `cart_order`, cùng ACID). Một relay (poller in-process, đề xuất chu kỳ 1–2 giây, `ASM-38`) đọc `outbox_event` trạng thái `pending`, publish lên SQS/EventBridge, đánh dấu `published` khi thành công. Payment Service dùng bảng `outbox_event` riêng trong RDS Payment của nó (2 DB khác nhau, không chia sẻ bảng) |
| **Ưu** | Đảm bảo tính nguyên tử thật sự giữa ghi DB và phát sự kiện (industry-standard); dùng được cho **mọi** module publish event sau này, không chỉ Cart & Order; độ trễ phát hiện/publish thấp (1–2 giây), nằm gọn trong ngân sách `QAS-005` (≤5 giây) |
| **Nhược** | Thêm bảng `outbox_event` mỗi module publish + một relay process cần giám sát riêng (lag alerting); độ trễ publish tăng nhẹ (1–2 giây thay vì tức thời) |
| **Chi phí đảo ngược** | Trung bình (1–3 tuần) — sau khi Cart&Order + Payment đã tích hợp, đổi sang cơ chế khác cần sửa lại relay + rà lại mọi module đã dùng, nhưng `outbox_event` là bảng nội bộ kỹ thuật, không phải dữ liệu nghiệp vụ, nên chưa tới mức "phải migrate dữ liệu" |
### PA-3 — 2PC (Distributed transaction giữa DB và message broker)
| | |
|---|---|
| **Mô tả** | Dùng giao thức 2-phase commit (XA transaction) trải rộng cả việc ghi DB lẫn publish message, đảm bảo cả hai cùng commit hoặc cùng rollback |
| **Ưu** | Về lý thuyết là đảm bảo nhất quán mạnh nhất giữa hai hệ thống |
| **Nhược** | **Không khả thi kỹ thuật** — SQS FIFO và EventBridge (đã chốt ở `ADR-005`) là dịch vụ managed của AWS, **không hỗ trợ tham gia giao thức XA/2PC**; ngay cả nếu đổi sang một message broker hỗ trợ 2PC, cơ chế này làm giảm throughput đáng kể (transaction coordinator là điểm nghẽn tập trung) và đội chưa có kinh nghiệm vận hành (`CON-05`) |
| **Chi phí đảo ngược** | Không áp dụng — loại ngay từ đầu vì không khả thi kỹ thuật với hạ tầng đã chọn |
### Bảng so sánh
| Tiêu chí | PA-1 | PA-2 | PA-3 |
|---|---|---|---|
| Đảm bảo nguyên tử ghi DB + publish | ❌ Không (chỉ vá muộn) | ✅ Có | ✅ Về lý thuyết |
| Khả thi với SQS FIFO/EventBridge (`ADR-005`) | ✅ | ✅ | ❌ Không hỗ trợ XA |
| Độ trễ phát hiện/publish khi lỗi | Tối đa chu kỳ job (5 phút) — vượt `QAS-005` | 1–2 giây (`ASM-38`) — trong ngân sách `QAS-005` | N/A (không khả thi) |
| Tổng quát cho mọi module tương lai | 🔶 Phải cài riêng từng nơi | ✅ Một chuẩn chung | N/A |
## 3. Quyết định
> **Chọn PA-2 — Transactional Outbox (bảng `outbox_event` + relay poller in-process chu kỳ
> 1–2 giây).**
**Vì sao:** Đây là phương án duy nhất vừa giải quyết đúng nguyên nhân gốc (dual-write không
nguyên tử) vừa khả thi với message backbone đã chọn (`ADR-005`), và tổng quát hoá được cho mọi
module publish sự kiện trong tương lai — đúng tinh thần `DRV-08` (không mất giao dịch).
**Phạm vi áp dụng:** Toàn hệ thống — mọi module publish sự kiện qua SQS FIFO/EventBridge (bắt đầu
với Cart & Order publish `OrderPlaced`/`PaymentInitRequested`, và Payment Service publish
`PaymentConfirmed`/`PaymentInitReady`/`PaymentInitFailed` qua bảng `outbox_event` riêng trong RDS
Payment của nó). Module nào publish sự kiện mới sau này (Commission, Shipping, …) dùng cùng mẫu.
### Điều kiện chuyển `Proposed → Accepted`
| Điều kiện | Ai xác nhận | Trạng thái hiện tại |
|---|---|---|
| Tech Lead xác nhận thiết kế relay (poller in-process, chu kỳ 1–2 giây, số instance/HA của relay) | Tech Lead | Mở (`OQ-044` chưa đóng) |
| Bài đo xác nhận relay lag thật không ăn quá nhiều vào ngân sách `QAS-005` (≤5 giây) | Tech Lead + QA | Chưa chạy — GĐ3 |
| `FIT` §6 dưới đây chạy PASS ít nhất 1 lần ở staging | Tech Lead/SRE | Chưa chạy |
## 4. Phương án bị loại và lý do
| Phương án | Loại vì | Gắn với | Điều kiện nào thì xét lại |
|---|---|---|---|
| PA-1 — Job quét đối chiếu định kỳ | Không giải quyết nguyên nhân gốc (vẫn dual-write ở luồng bình thường), độ trễ phát hiện tối đa 5 phút vượt xa ngân sách `QAS-005` (≤5 giây), không tổng quát cho module tương lai | `QAS-005`, `DRV-08` | Chỉ xét lại như biện pháp bổ sung (không thay thế outbox) nếu cần một lớp giám sát "vá muộn" cho các dòng `outbox_event` bị kẹt bất thường — bản thân outbox relay đã đóng vai trò chính |
| PA-3 — 2PC (XA transaction) | Không khả thi kỹ thuật — SQS FIFO/EventBridge không hỗ trợ XA/2PC (`ADR-005`); ngay cả đổi broker, giảm throughput đáng kể và đội chưa có kinh nghiệm (`CON-05`) | `ADR-005`, `CON-05` | Chỉ xét lại nếu đổi hẳn message backbone sang một hệ có hỗ trợ 2PC **và** có bằng chứng throughput đáp ứng — chưa có tiền lệ ở dự án này |
## 5. Hệ quả
**Hệ quả tích cực**
- Đảm bảo tính nguyên tử thật sự giữa ghi `Order` và phát sự kiện — đóng khoảng trống `FAIL §7`
- Một chuẩn chung dùng được cho mọi module publish sự kiện sau này (Commission, Shipping,
Notification, …), không phải thiết kế lại từ đầu mỗi lần
- Cho phép `ADR-013` publish đồng thời `OrderPlaced` + `PaymentInitRequested` một cách an toàn
**Hệ quả tiêu cực phải sống chung**
- Thêm bảng `outbox_event` ở mỗi module publish (Cart & Order, Payment Service — đã thiết kế ở
`DAT §1.3`/§1.4) — tăng độ phức tạp schema
- Relay process là một thành phần mới cần giám sát riêng (lag alerting theo tuổi dòng `pending`,
chưa thiết kế ngưỡng cụ thể — thuộc `inf`)
- Độ trễ publish tăng nhẹ (1–2 giây thay vì tức thời) — cần xác nhận không ăn quá nhiều vào ngân
sách `QAS-005` (≤5 giây) khi cộng cả thời gian consumer xử lý
**Cái quyết định này khoá lại**
| Muốn đổi về sau thì | Tốn |
|---|---|
| Đổi khỏi Outbox sang cơ chế khác (VD CDC — Change Data Capture) sau khi mọi module đã tích hợp | Cần thay relay + rà lại từng module đã dùng — nhiều tuần, dù không phải migrate dữ liệu nghiệp vụ |
**Việc phát sinh**
| Việc | Chủ | Hạn | Ghi ở đâu |
|---|---|---|---|
| Thiết kế chi tiết relay (số instance, HA, cơ chế lock tránh 2 relay cùng publish 1 dòng) | Tech Lead + SA (hoạt động `inf` lượt sau) | Trước khi Dev thi công | `INF_e-commerce_v1.0.md` (bổ sung) |
| Thêm alerting lag `outbox_event` (tuổi dòng `pending` vượt ngưỡng) | SA (hoạt động `inf`)/Ops | Trước AG2 | `INF_e-commerce_v1.0.md` |
| Viết `FIT-26`/`FIT-27` thật | QA/Dev (GĐ3) | GĐ3 | `03-enablement/FIT_e-commerce.md` |
## 6. Cách kiểm chứng quyết định này được tuân thủ
| Cách kiểm | Công cụ | Chạy ở đâu | `FIT` |
|---|---|---|---|
| Không có publish nào đi thẳng SQS/EventBridge mà bỏ qua ghi `outbox_event` trong cùng transaction ("không publish ngoài outbox") | Static analysis (lint rule cấm gọi trực tiếp SDK publish ngoài lớp outbox) + integration test kiểm tra mọi `Order` tạo ra đều có dòng `outbox_event` tương ứng trong cùng transaction | CI + staging | `FIT-26` *(ứng viên GĐ3)* |
| Chaos: kill relay process giữa lúc có tải, xác nhận `outbox_event` tồn đọng không mất; khi relay phục hồi (restart), toàn bộ dòng `pending` được publish, không mất và không publish thiếu | Chaos test — dừng relay, tạo N `Order`, khởi động lại relay, đếm số sự kiện consumer nhận được so với N | Staging | `FIT-27` *(ứng viên GĐ3)* |
Cả `FIT-26`/`FIT-27` là **ứng viên**, chưa viết thật (thuộc GĐ3 `sa-3-enablement`).
## 7. Điều kiện xét lại
| Dấu hiệu | Ngưỡng | Ai theo dõi |
|---|---|---|
| Relay lag thật (từ lúc `outbox_event` ghi tới lúc publish) thường xuyên vượt ngưỡng, ăn quá nhiều vào ngân sách `QAS-005` | p95 lag > 3 giây (margin còn lại quá mỏng so với ngân sách 5 giây) | Tech Lead + SRE |
| Số dòng `outbox_event` tồn đọng (`pending`) tăng liên tục, không giảm | Tồn đọng > vài phút liên tục theo dõi qua CloudWatch (ngưỡng cụ thể chưa chốt — thuộc `inf`) | Ops/SRE |
## 8. Tham chiếu
- POC: Không bắt buộc (radar 6/10, dưới ngưỡng 8) — nhưng khuyến nghị bài đo relay lag trước khi
`Accepted`
- Bài đo: `FIT-26`/`FIT-27` (ứng viên GĐ3)
- Tài liệu ngoài: —
- Thảo luận: `DAT_e-commerce_v1.0.md §4.1` (phát hiện + đề xuất), `FAIL_e-commerce_v1.0.md §7`
(khoảng trống gốc), `OQ-044`/`DEC-25` (chọn hướng TẠM)