Loyalty 5 — Kiến trúc dữ liệu & tích hợp hệ thống
Kiến trúc dữ liệu & tích hợp hệ thống
Bốn bài đầu của series nói về nghiệp vụ loyalty: tổng quan, points engine, hạng & quyền lợi, và kho quà & đổi thưởng. Bài này nhìn từ góc kỹ thuật: những mảnh đó ghép thành một hệ thống chạy được ra sao? Dữ liệu earn/burn từ đâu chảy về, được tính ở đâu, lưu thế nào, và làm cách nào để một giao dịch thẻ lúc 20h tối thứ Bảy trở thành "+150 điểm" trên app của khách sau vài giây — mà vẫn đối soát được vào cuối tháng.
Đây là bài "xương sống": nó kết nối loyalty với các series kỹ thuật khác của KB — Kafka, Oracle CDC, và vòng đời giao dịch thẻ — để cho thấy loyalty thực chất là một hệ thống event-driven ngồi ở hạ nguồn của core banking và hệ thống thẻ.
Quy ước: các ví dụ
text/json/sqldưới đây là minh hoạ cấu trúc dữ liệu và luồng, không phải bản ghi trích từ một hệ thống cụ thể.
1. Loyalty là một hệ thống hạ nguồn (downstream)
Điều quan trọng nhất cần nắm ngay: loyalty không sinh ra dữ liệu gốc. Nó không phải nơi tiền chạy qua, không ghi sổ cái kế toán của ngân hàng. Loyalty quan sát những gì xảy ra ở các hệ thống nguồn (system of record) rồi diễn giải chúng thành điểm. Các nguồn sự kiện điển hình:
- Giao dịch thẻ — nguồn earn lớn nhất. Mỗi lần khách quẹt/chạm/thanh toán online, hệ thống thẻ sinh một giao dịch. Loyalty quan tâm tới giao dịch đã posting (đã ghi nợ), không phải mới authorization — vì auth có thể bị huỷ. Xem vòng đời giao dịch thẻ: khác biệt auth vs posting quyết định thời điểm tích điểm.
- Giao dịch tài khoản / thanh toán — chuyển khoản, nạp tiền, thanh toán hoá đơn, QR. Nối Thanh toán & chuyển tiền và core banking.
- Hành vi trên app / kênh số — đăng ký sản phẩm, referral, hoàn thành nhiệm vụ (gamification). Những sự kiện này thường đến từ hệ digital, không từ core.
- Sự kiện đối tác — mua sắm tại merchant liên kết, đặt vé qua đối tác du lịch.
Vì loyalty ở hạ nguồn, hai câu hỏi kiến trúc cốt lõi là: (1) lấy dữ liệu từ các nguồn này về bằng cách nào? và (2) làm sao không tích/trừ trùng, không sót, khi các nguồn kia không hề "biết" loyalty đang lắng nghe?
2. Mô hình event-driven & points ledger bất biến
Loyalty được mô hình hoá tốt nhất bằng event sourcing. Ba loại sự kiện điểm là bất biến (append-only, không sửa không xoá):
- earn — cộng điểm (từ giao dịch, khuyến mãi, thưởng hạng…).
- burn — trừ điểm (đổi thưởng, chuyển đối tác — nối kho quà).
- adjust — điều chỉnh (clawback do hoàn giao dịch, sửa lỗi vận hành, hết hạn điểm/expiry).
Mỗi sự kiện được ghi vào points ledger — sổ cái điểm — vận hành đúng như một sổ kế toán bút toán kép mà bạn đã thấy ở sổ cái tổng hợp GL: ta không bao giờ "sửa số dư", ta chỉ ghi thêm một dòng. Số dư điểm không phải là một trường được cập nhật tại chỗ, mà là kết quả tổng hợp từ toàn bộ ledger:
(minh hoạ) — nguyên tắc
số_dư_điểm(khách) = Σ(earn) − Σ(burn) ± Σ(adjust) [trên toàn ledger của khách]
Vì sao chọn ledger bất biến thay vì chỉ giữ một con số balance?
- Đối soát & audit: mọi điểm phát sinh đều truy vết được về đúng sự kiện gốc (giao dịch nào, quy tắc nào). Khi khách khiếu nại "sao tôi bị trừ điểm", ta chỉ cho họ dòng ledger tương ứng.
- Tái tạo (replay): nếu số dư cache bị sai, ta dựng lại từ ledger. Không có "nguồn sự thật ẩn" nào khác.
- Điểm có thời hạn: điểm thường hết hạn theo lô (FIFO/theo tháng phát sinh). Ledger giữ được lô nào phát sinh khi nào, nên tính expiry mới chính xác.
Chi tiết vận hành của ledger (hold/capture khi đổi thưởng, FIFO expiry, số dư khả dụng vs số dư giữ) nằm ở points engine. Ở bài này ta xem ledger như một store và tập trung vào việc dữ liệu chảy vào và chảy ra khỏi nó.
Một sự kiện earn đi vào rule engine (minh hoạ) — đây là thứ chảy trên Kafka:
// (minh hoạ) — một sự kiện earn
{
"event_type": "earn",
"customer_id": "CUST-88213",
"points": 150,
"reason_code": "CARD_SPEND",
"source_system": "CARDS",
"source_ref": "TXN-9f2a71c4",
"rule_id": "RULE-BASE-1PT-PER-10K",
"idempotency_key": "CARDS:TXN-9f2a71c4:POSTED",
"occurred_at": "2026-06-30T20:14:07+07:00"
}
Và đây là dòng ledger tương ứng sau khi points service ghi nó xuống (minh hoạ):
(minh hoạ) — một dòng points ledger
ledger_id: LDG-2026-06-30-000148277
customer_id: CUST-88213
event_type: earn # earn | burn | adjust
points: +150 # dấu: earn (+), burn (−), adjust (±)
source_ref: TXN-9f2a71c4 # khoá về giao dịch gốc
idempotency_key: CARDS:TXN-9f2a71c4:POSTED # chống ghi trùng
batch_id: null # null = real-time; có giá trị nếu batch
posted_at: 2026-06-30T20:14:07+07:00
status: posted # posted | held | reversed
Lưu ý idempotency_key và source_ref — hai trường này là tuyến phòng thủ chống tích/trừ trùng, sẽ nói ở mục 5.
3. Sơ đồ luồng dữ liệu tổng thể
Ghép mọi thứ lại, đây là xương sống dữ liệu của một hệ thống loyalty:
Đọc từ trái sang phải: nguồn → thu thập → Kafka → rule engine → ledger → số dư/API → thông báo, và một nhánh song song đẩy dữ liệu sang kho phân tích. Phần còn lại của bài đi qua từng chặng.
4. Real-time vs batch — hai nhịp tính điểm
Không phải mọi việc trong loyalty đều cần tức thời, và không phải mọi việc đều có thể chờ tới cuối ngày. Kiến trúc phải phục vụ hai nhịp khác nhau:
Nhịp real-time (stream). Khi khách quẹt thẻ, họ kỳ vọng thấy điểm cộng gần như ngay. Số dư điểm cập nhật trong vài giây, kèm push notification "+150 điểm". Đây là chặng Kafka → rule engine → ledger → số dư → notify chạy liên tục. Nó tối ưu cho độ trễ thấp và trải nghiệm khách hàng.
Nhịp batch. Một số phép tính bản chất là gộp cả kỳ, không thể (và không nên) làm từng giao dịch:
- Tính hạng (tier): hạng thường xét theo chi tiêu/điểm tích luỹ trong một cửa sổ (quý, năm) — tổng hợp cuối kỳ, không đổi hạng theo từng giao dịch.
- Điểm hết hạn (expiry): quét các lô điểm tới hạn, sinh sự kiện
adjusttrừ điểm hết hạn. - Breakage & báo cáo: ước tính điểm sẽ không bao giờ được đổi (nợ phải trả kế toán), báo cáo chi phí chương trình. Nối tổng quan về breakage như một khái niệm kinh tế.
- Thưởng khuyến mãi hồi tố: "chi tiêu tháng 6 ≥ 10 triệu thì thưởng x2" — chỉ tính được khi tháng đã đóng.
Đây chính là cặp khái niệm Lambda / Kappa architecture trong dữ liệu (nhắc để định vị): Lambda chạy song song một lớp stream (nhanh, gần đúng) và một lớp batch (chậm, chính xác, đối soát lại); Kappa cố gắng làm mọi thứ chỉ bằng stream rồi reprocess khi cần. Với loyalty, thực tế phổ biến là stream cho earn/burn tức thời + batch cho tier/expiry/report — vừa đáp ứng kỳ vọng khách, vừa giữ được điểm đối soát cuối kỳ. Điểm mấu chốt để cả hai nhịp chung sống được là: ledger bất biến là nguồn sự thật duy nhất — cả stream lẫn batch đều chỉ append vào ledger, nên không "đá nhau".
5. Tích hợp hệ thống nguồn: CDC + Kafka
Câu hỏi thực dụng nhất: làm sao lấy giao dịch từ core banking, hệ thống thẻ, payment về loyalty? Có hai cách, thường dùng kết hợp.
Cách 1 — API / event trực tiếp. Nếu hệ thống nguồn đã publish sự kiện (ví dụ app số phát event "khách hoàn thành referral"), loyalty chỉ cần subscribe. Sạch sẽ nhưng phụ thuộc việc nguồn chịu thay đổi để publish — điều hiếm gặp với các hệ core cũ.
Cách 2 — CDC (Change Data Capture). Với core banking/T24, hệ thống thẻ, payment — thường là hệ đóng, không thể bắt chúng "gọi loyalty" — ta đọc thay đổi từ chính database của chúng: Debezium hoặc Oracle GoldenGate đọc redo log / WAL, biến mỗi INSERT/UPDATE thành sự kiện và đẩy vào Kafka. Loyalty tiêu thụ topic đó mà nguồn không cần biết gì. Đúng pattern của series Oracle CDC: non-intrusive, near real-time, không thêm tải ghi lên hệ nguồn.
Kafka đứng giữa vì lý do đã bàn ở kiến trúc Kafka: decoupling nguồn và loyalty, buffer khi tải tăng vọt, cho phép replay khi cần tính lại, và fan-out cùng một luồng cho nhiều consumer (rule engine, nhánh analytics, hệ chống gian lận…).
Idempotency & exactly-once — vấn đề sống còn
Kafka và CDC đảm bảo at-least-once: một sự kiện có thể được giao nhiều lần (khi consumer restart, khi replay, khi rebalance partition). Nếu rule engine cứ thấy sự kiện là cộng điểm, một giao dịch bị giao lại 3 lần = tích điểm gấp 3. Với loyalty, tích/trừ trùng là lỗi tiền bạc, không thể chấp nhận.
Giải pháp là idempotency ở tầng ledger, không tin vào việc "sự kiện chỉ đến một lần":
- Mỗi sự kiện earn/burn mang một khoá idempotent suy ra từ nguồn — ví dụ
CARDS:TXN-9f2a71c4:POSTED. Khoá phải ổn định giữa các lần giao lại (deterministic từ dữ liệu nguồn, không phải sinh ngẫu nhiên). - Ledger có ràng buộc unique trên
idempotency_key. Ghi trùng bị từ chối một cách vô hại.
-- (minh hoạ) — chống ghi trùng bằng ràng buộc + upsert
CREATE UNIQUE INDEX uq_ledger_idem ON points_ledger (idempotency_key);
INSERT INTO points_ledger (ledger_id, customer_id, event_type, points, idempotency_key, ...)
VALUES (:id, :cust, 'earn', 150, 'CARDS:TXN-9f2a71c4:POSTED', ...)
ON CONFLICT (idempotency_key) DO NOTHING; -- lần giao thứ 2,3... không ghi thêm
Kết hợp với consumer offset được commit cùng transaction ghi ledger (transactional outbox / read-process-write pattern của Kafka), ta đạt hiệu quả effectively-once: dù sự kiện được giao nhiều lần, ledger chỉ có đúng một dòng cho mỗi giao dịch gốc. Đây là điểm mà nhiều đội làm loyalty vấp phải khi bật replay hoặc chuyển cụm Kafka — nếu không có khoá idempotent, mọi lần reprocess đều nhân đôi điểm.
6. Xử lý reversal / chargeback — điểm phải "chảy ngược"
Giao dịch không phải lúc nào cũng một chiều. Khách trả hàng, giao dịch bị huỷ, hoặc mở tranh chấp và ngân hàng hoàn tiền (chargeback — xem cơ chế ở vòng đời giao dịch thẻ). Khi tiền chảy ngược, điểm đã tích cho giao dịch đó phải bị thu hồi — gọi là clawback.
Đây là lúc mô hình ledger bất biến toả sáng. Ta không xoá dòng earn cũ (nó là sự thật lịch sử — điểm đã từng được cộng). Thay vào đó ta sinh một sự kiện adjust bù trừ:
(minh hoạ) — clawback do chargeback
dòng gốc: earn +150 source_ref=TXN-9f2a71c4 (giữ nguyên)
dòng bù: adjust −150 source_ref=TXN-9f2a71c4-RVSL reason=CHARGEBACK_CLAWBACK
links_to=LDG-...148277 idempotency_key=CARDS:TXN-9f2a71c4:REVERSED
Vài điểm kỹ thuật:
- Thứ tự (ordering): reversal phải xử lý sau earn tương ứng. Kafka chỉ đảm bảo thứ tự trong một partition, nên partition theo
customer_idđể mọi sự kiện của cùng một khách vào cùng partition, giữ đúng nhân quả earn → reversal. - Sự kiện đến trước dữ liệu: đôi khi reversal về trước khi earn kịp xử lý (out-of-order). Rule engine cần chịu được — cho phép số dư âm tạm thời hoặc giữ reversal pending tới khi earn xuất hiện; đây là quyết định thiết kế, không có đáp án duy nhất.
- Bù trừ, không xoá: nhờ chỉ append, kiểm toán vẫn thấy cả earn lẫn clawback — quan trọng khi khách khiếu nại và khi đối soát.
- Idempotent cả chiều ngược: reversal cũng phải có khoá idempotent riêng để không clawback hai lần.
7. Đưa dữ liệu sang kho phân tích
Ledger tối ưu cho OLTP: ghi nhanh, tra số dư một khách nhanh, ràng buộc chặt. Nó không hợp để chạy "phân khúc 3 triệu khách theo hành vi tích/đổi trong 12 tháng" — đó là việc của kho phân tích.
Cách đưa dữ liệu sang:
- CDC từ chính ledger: đặt Debezium lên bảng
points_ledger(append-only nên CDC cực sạch — chỉ INSERT) → Kafka → nạp vào lakehouse hoặc BigQuery. Vì ledger bất biến, đây gần như một stream sự kiện thuần. - Nhánh phân tích tách từ Kafka: cùng topic giao dịch có thể có consumer riêng đẩy raw event vào lake cho phân tích hành vi, độc lập rule engine.
- Transform bằng dbt / Spark: dựng bảng phân tích — điểm tích/đổi theo khách, merchant, kênh — làm nguyên liệu cho segmentation và CLV.
Kho phân tích phục vụ đúng các câu hỏi ở bài phân tích & phân khúc: ai sắp rời bỏ, nhóm nào đổi thưởng nhiều, CLV theo hạng, hiệu quả chiến dịch. Nguyên tắc: OLTP (ledger) và OLAP (lake/warehouse) tách bạch, nối bằng CDC/stream một chiều — phân tích không bao giờ ghi ngược vào ledger.
8. Kiến trúc dịch vụ (microservices)
Ở mức triển khai, loyalty thường được chia thành vài dịch vụ có trách nhiệm rõ ràng, giao tiếp qua Kafka và API:
- Loyalty / points service — API tích, tiêu, truy vấn số dư; chủ sở hữu points ledger. Đây là lõi.
- Rule engine — nhận sự kiện giao dịch, áp quy tắc (hệ số điểm theo merchant/hạng/khuyến mãi), sinh sự kiện earn. Tách riêng để đội nghiệp vụ đổi quy tắc mà không đụng lõi ledger.
- Catalog / redemption service — kho quà & luồng đổi, nối bài kho quà; phát sinh sự kiện burn.
- Profile / tier service — hồ sơ khách và hạng; chạy phần lớn ở nhịp batch.
- Notification — phát push/SMS khi số dư đổi.
Về lưu trữ: OLTP cho ledger (nhất quán mạnh, giao dịch), cache/materialized view cho số dư khả dụng (đọc nhanh, dựng lại từ ledger), và OLAP cho phân tích. Các dịch vụ không gọi thẳng database của nhau — chúng trao đổi qua sự kiện Kafka, đúng tinh thần event-driven.
Yêu cầu phi chức năng
- Nhất quán số dư điểm — số dư luôn phải bằng tổng hợp ledger; cache chỉ là ảnh chụp có thể dựng lại. Không bao giờ có "hai nguồn sự thật".
- Độ trễ thấp cho earn/burn/tra số dư — vì gắn trực tiếp trải nghiệm khách.
- Chịu tải cao mùa lễ — Tết/Black Friday, lượng giao dịch tăng vọt. Kafka làm buffer, các consumer scale ngang theo partition. Đây là lý do partition-key hợp lý (theo
customer_id) lại quan trọng cả về đúng thứ tự lẫn về khả năng mở rộng. - Đối soát — cuối ngày/tháng khớp: (số sự kiện earn) vs (số giao dịch nguồn hợp lệ); (số dư tổng hợp) vs (cache); (điểm burn) vs (quà đã cấp — nối kho quà). Ledger bất biến làm việc này khả thi.
Use case thực tế
A. Giao dịch thẻ → earn real-time
Anh Minh quẹt thẻ tín dụng 1.500.000đ mua sắm lúc 20h14 tối thứ Bảy.
- Hệ thống thẻ posting giao dịch (không phải chỉ authorization) → ghi vào database thẻ.
- Debezium đọc thay đổi từ redo log của DB thẻ, sinh sự kiện
TXN-9f2a71c4 POSTED→ đẩy vào Kafka topiccard-transactions, partition theocustomer_id. - Rule engine consume sự kiện. Quy tắc "1 điểm / 10.000đ" → tính +150 điểm. Sinh sự kiện earn với
idempotency_key = CARDS:TXN-9f2a71c4:POSTED. - Points service ghi một dòng vào ledger (INSERT
ON CONFLICT DO NOTHING). Số dư materialized của anh Minh cập nhật. - Notification phát push: "Bạn vừa nhận 150 điểm". Toàn bộ mất vài giây.
- Song song, sự kiện earn được CDC từ ledger đẩy sang lakehouse cho phân tích — không ảnh hưởng đường real-time.
Nếu Kafka giao lại sự kiện này (consumer restart giữa chừng): bước 4 gặp idempotency_key đã tồn tại → DO NOTHING. Anh Minh không bị cộng 300 điểm. Đây là toàn bộ lý do idempotency tồn tại.
B. Chargeback → clawback
Ba tuần sau, anh Minh tranh chấp giao dịch trên và ngân hàng hoàn tiền (chargeback).
- Hệ thống thẻ ghi giao dịch reversal → Debezium → Kafka, cùng partition (cùng
customer_id) nên xử lý sau earn gốc. - Rule engine nhận diện reversal, sinh sự kiện adjust −150,
reason=CHARGEBACK_CLAWBACK,idempotency_key = CARDS:TXN-9f2a71c4:REVERSED,links_todòng earn gốc. - Points service ghi dòng adjust. Dòng earn giữ nguyên (lịch sử bất biến); số dư giảm 150 qua tổng hợp.
- Nếu anh Minh đã tiêu hết điểm trước đó, số dư khả dụng có thể tạm âm — chính sách xử lý (giữ âm chờ earn tương lai, hay không cho âm) là quyết định nghiệp vụ, nhưng ledger vẫn ghi trung thực.
- Cuối tháng, đối soát thấy: earn +150 và clawback −150 cùng trỏ về
TXN-9f2a71c4→ khớp, giải thích được.
Ghi nhớ
- Loyalty là hệ thống hạ nguồn: nó quan sát giao dịch từ thẻ/core/payment rồi diễn giải thành điểm, chứ không sinh dữ liệu gốc. Tích điểm theo posting, không theo authorization.
- Sự kiện earn/burn/adjust bất biến → points ledger append-only, vận hành như sổ kế toán. Số dư điểm là tổng hợp từ ledger, không phải một con số sửa tại chỗ — nhờ vậy mới đối soát, audit và replay được.
- Real-time cho earn/burn/số dư (kỳ vọng khách), batch cho tier/expiry/breakage/báo cáo; đây là tinh thần Lambda/Kappa, dính lại nhờ ledger là nguồn sự thật duy nhất.
- Tích hợp bằng CDC (Debezium/GoldenGate) → Kafka với hệ nguồn đóng; API/event trực tiếp với hệ số. Kafka để decouple, buffer, replay, fan-out.
- Idempotency là sống còn: at-least-once của Kafka đồng nghĩa sự kiện có thể lặp; khoá idempotent + ràng buộc unique trên ledger biến nó thành effectively-once, tránh tích/trừ trùng.
- Reversal/chargeback = sinh adjust bù trừ (clawback), không xoá dòng cũ; partition theo
customer_idđể giữ đúng thứ tự earn → reversal. - Tách OLTP (ledger) và OLAP (lake/warehouse), nối bằng CDC/stream một chiều để phục vụ segmentation/CLV — nối bài phân tích.
- Phi chức năng cốt lõi: nhất quán số dư, độ trễ thấp, chịu tải mùa lễ (scale theo partition), và khả năng đối soát cuối kỳ.
Bài tiếp theo — phân tích & phân khúc khách hàng — dùng chính kho dữ liệu dựng ở đây để trả lời các câu hỏi kinh doanh: ai giá trị nhất, ai sắp rời bỏ, và chương trình có thực sự thay đổi hành vi chi tiêu hay không.
Bài viết liên quan
Dòng chảy dữ liệu core -> ODS -> DWH -> BI, mô hình dữ liệu cốt lõi và bộ ví dụ SQL thực hành chạy được.
Mô hình dữ liệu giao dịch thẻ, các KPI thẻ, phân tích chi tiêu & phát hiện gian lận, kèm ví dụ SQL chạy được trên sandbox.
Thẻ ghi nợ/tín dụng/trả trước, vai trò issuer–acquirer–scheme, vòng đời giao dịch thẻ, bù trừ & quyết toán, 3DS, chargeback.
Quy trình phát hành thẻ, cấu trúc PAN/BIN, chip EMV, cá thể hoá, tokenization và vòng đời thẻ từ mở đến đóng.