Hệ thống Dữ liệu Phái sinh/Change Data Capture — biến changelog của DB thành stream
16/21
Bài 16 / 21~13 phútKiến trúc phái sinh (CDC, event sourcing, CQRS)Miễn phí lượt xem

Change Data Capture — biến changelog của DB thành stream

CDC đọc chính replication log của database và phát ra như một stream sự kiện, để đồng bộ index/cache/warehouse mà không phải poll. Nối thẳng WAL đã học ở SQL.

TL;DR: CDC (Change Data Capture) đọc transaction log nội bộ của database — WAL (Write-Ahead Log) ở PostgreSQL, binlog (binary log) ở MySQL — và phát mọi INSERT/UPDATE/DELETE thành stream sự kiện để hệ phái sinh (search index, cache, warehouse) nhận và đồng bộ. Log-based CDC như Debezium đạt lag 100–500ms sau commit; polling định kỳ 10 giây có lag tối thiểu 10 giây, bỏ sót DELETE, và tốn CPU source database. Pitfall căn bản: dual-write — ghi database rồi ghi Kafka riêng, crash giữa hai bước → DB commit nhưng event mất → bất nhất âm thầm. Giải pháp: Transactional Outbox — ghi event vào bảng outbox cùng transaction, Debezium đọc CDC từ đó.

Đội kỹ thuật của một nền tảng thương mại điện tử cần giữ đồng bộ ba hệ phái sinh với database PostgreSQL chính: Elasticsearch cho tìm kiếm sản phẩm, Redis cho cache giỏ hàng, và Redshift cho báo cáo doanh thu. Ban đầu họ dùng polling — một tác vụ cron chạy mỗi 10 giây:

-- Poll bang orders tim thay doi moi
SELECT * FROM orders WHERE updated_at > :last_poll_time

Trong vài tuần đầu, polling chạy ổn. Rồi ba sự cố liên tiếp xuất hiện: (1) lag 10 giây khiến Elasticsearch hiển thị sản phẩm đã hết hàng; (2) khi sản phẩm bị xoá khỏi PostgreSQL, Elasticsearch không nhận được tín hiệu vì DELETE không để lại updated_at; (3) DBA thêm bảng inventory nhưng quên cột updated_at — toàn bộ thay đổi của bảng đó biến mất. Khi mở rộng lên 120 bảng, tải polling ngốn hơn 40% CPU database vào giờ cao điểm, với độ trễ tích luỹ lên nhiều giờ.

Bài này giải thích vì sao CDC đọc transaction log thay vì poll bảng, ba cách triển khai có gì khác nhau, và hai pitfall nghiêm trọng nhất khi đưa CDC vào sản xuất.

Nhớ lại Log-based broker (Kafka)

Module 2 đã dạy Kafka giữ log chỉ-append với offset cố định — nhiều consumer group đọc cùng một topic độc lập. CDC là phần "upstream" của chuỗi đó: nó biến thay đổi trong database thành các event Kafka, rồi Elasticsearch/Redis/Redshift tự consume theo tốc độ riêng. Nếu bạn chưa đọc bài log-based broker, hãy đọc trước để hiểu vì sao Kafka là nơi CDC đẩy event tới.

1. Analogy — sổ nhật ký giao dịch vs kiểm kê định kỳ

Hình dung một kho hàng cần biết lượng tồn kho mọi lúc. Có hai cách:

Cách một — kiểm kê định kỳ (polling): nhân viên cứ 10 phút lại ra kho đếm số lượng từng mặt hàng trên kệ. Biết được tổng tồn kho tại thời điểm đếm, nhưng không biết chuyện gì xảy ra giữa hai lần. Nếu một thùng hàng bị xuất kho và không để lại dấu vết trên kệ, lần đếm tiếp theo vẫn không biết.

Cách hai — nhật ký giao dịch (CDC): mọi hoạt động nhập-xuất-điều chỉnh đều được ghi ngay vào sổ kế toán khi phát sinh, theo thứ tự thời gian. Muốn biết tồn kho, đọc sổ. Không có thay đổi nào bị bỏ qua — kể cả xuất nguyên pallet.

Kiểm kê định kỳNhật ký giao dịchKhái niệm kỹ thuật
Đếm hàng trên kệ mỗi 10 phútGhi từng bút toán ngay khi phát sinhPolling vs Log-based CDC
Không biết chuyện gì xảy ra giữa hai lần đếmMọi thay đổi được ghi theo thứ tựBỏ sót window vs Bắt đủ
Không thấy hàng biến mất không dấu vếtThấy rõ từng bút nhập, xuất, huỷBỏ sót DELETE vs Bắt DELETE
Tốn nhân lực mỗi lần đếmSổ ghi song song với vận hànhTải thêm vs Gần không tải thêm
💡 Cách nhớ

Polling = đếm kho định kỳ (biết tổng hiện tại, bỏ sót giữa hai lần). CDC = đọc sổ nhật ký (thấy từng bút toán ngay khi ghi, kể cả huỷ bỏ).

2. CDC là gì — đọc transaction log thay vì poll bảng

Database quan hệ như PostgreSQL và MySQL không chỉ lưu dữ liệu trong các bảng. Trước khi ghi bất kỳ thay đổi nào xuống file dữ liệu chính, database ghi bút toán vào một transaction log phụ trợ — gọi là WAL (Write-Ahead Log) ở PostgreSQL và binlog (binary log) ở MySQL. Log này phục vụ hai mục đích: phục hồi sau crash (recovery) và đồng bộ sang replica (replication).

CDC khai thác hạ tầng đã có sẵn này: thay vì poll bảng với SELECT ... WHERE updated_at > ?, nó đọc trực tiếp WAL/binlog và phát mỗi entry thành một event.

Mỗi entry trong WAL chứa đủ thông tin để tái dựng sự kiện:

# Minh hoa WAL entry (PostgreSQL logical replication)
table:    orders
op:       UPDATE           -- INSERT | UPDATE | DELETE
lsn:      0/1A2B3C4       -- Log Sequence Number: vi tri trong WAL
before:   {id: 42, status: "pending", amount: 159000}
after:    {id: 42, status: "paid",    amount: 159000}

Điểm then chốt: WAL ghi trước khi commit xuống storage chính, và giữ cả ba loại thay đổi — INSERT, UPDATE, DELETE — với giá trị trước và sau (before/after). Polling SELECT chỉ thấy trạng thái hiện tại của hàng còn tồn tại trong bảng, không thấy gì đã bị DELETE và không có before-value cho UPDATE.

3. Ba phương pháp CDC — nên chọn cái nào?

Có ba cách triển khai CDC, mỗi cách đọc thay đổi từ database theo cơ chế khác nhau:

Polling (query-based): tác vụ định kỳ chạy SELECT ... WHERE updated_at > :watermark. Đơn giản nhất, không cần cấu hình gì trên database. Nhưng bỏ sót DELETE, cần cột updated_at trên mọi bảng, và lag bằng interval poll.

Trigger-based: database trigger ghi thay đổi vào bảng phụ change_log; CDC đọc từ đó. Bắt được DELETE, nhưng trigger chạy trong cùng transaction với thay đổi → tăng tải trực tiếp trên database nguồn; mỗi bảng cần 3 trigger (INSERT/UPDATE/DELETE); schema phức tạp hơn.

Log-based (đọc WAL/binlog): Debezium (hoặc Maxwell, Kafka Connector) đọc WAL/binlog qua giao thức replication. Không cần schema change, không cần trigger, lag thấp nhất, bắt đủ INSERT/UPDATE/DELETE với before/after value.

Tiêu chíPollingTrigger-basedLog-based (Debezium)
Cách đọc thay đổiSELECT WHERE updated_atTrigger ghi bảng phụĐọc WAL / binlog
Bắt DELETEKhông
Lag điển hìnhBằng interval poll (thường 10+ giây)Gần real-time100–500ms sau commit
Tải lên source DBTăng theo số bảng pollTăng (trigger trong transaction)Gần không (đọc log riêng)
Cần schema changeCần cột updated_atCần bảng change_log + triggerKhông cần
Giá trị trước thay đổiKhôngTuỳ triggerCó (before/after)
Khuyến nghịHệ nhỏ, không cần bắt DELETEHiếm dùngSản xuất — mặc định nên chọn
Log-based CDC cần gì trên database nguồn?

PostgreSQL: bật wal_level = logical trong postgresql.conf và tạo replication slot. MySQL 8.0: binary log + binlog_format = ROW đã bật sẵn theo mặc định, chỉ cần đặt thêm binlog_row_image = FULL (để có before/after) và server_id cho CDC. Lưu ý về PostgreSQL: wal_level = logical không phải mặc định — read replica chỉ cần wal_level = replica (physical replication), còn logical decoding cho CDC phải bật thủ công (trên RDS PostgreSQL đặt rds.logical_replication = 1).

4. Log-based CDC với Debezium — flow thực tế

Debezium là open-source CDC framework chạy trên Kafka Connect. Mỗi connector lắng nghe WAL/binlog của một database và đẩy thay đổi vào Kafka topic tương ứng.

flowchart TB
    APP["Application"] -->|"INSERT/UPDATE/DELETE"| DB["PostgreSQL (source DB)"]
    DB -->|"ghi WAL truoc commit"| WAL["WAL / binlog (append-only replication log)"]
    WAL -->|"Debezium doc log qua replication protocol"| DBZ["Debezium connector"]
    DBZ -->|"publish event co before+after"| KAFKA["Kafka topic: orders.changes"]
    KAFKA -->|"consume"| ES["Elasticsearch (search index)"]
    KAFKA -->|"consume"| REDIS["Redis (cache)"]
    KAFKA -->|"consume"| DW["Redshift (warehouse)"]

Throughput mặc định của Debezium đạt 5.000–15.000 events/s mỗi connector; sau khi tuning lên 30.000–80.000 events/s; khi initial snapshot (đồng bộ lần đầu toàn bộ dữ liệu) đạt 100.000–500.000 rows/s. JSON serialize gốc nặng gấp 3–5 lần Avro — team có data volume lớn thường chuyển sang Avro kèm schema registry.

Mỗi event Debezium đẩy lên Kafka gồm: tên bảng, kiểu thao tác (INSERT/UPDATE/DELETE), trường before (giá trị trước), trường after (giá trị sau), và lsn — Log Sequence Number xác định vị trí chính xác trong WAL. Consumer downstream dùng thông tin này để cập nhật Elasticsearch, Redis, hay Redshift theo cách riêng của từng hệ.

5. Đồng bộ hệ phái sinh bằng CDC — worked example

Quay lại nền tảng thương mại điện tử. Sau khi chuyển từ polling sang Debezium, pipeline hoạt động như sau:

# Worked example: don hang 42 chuyen sang trang thai "paid"

# Buoc 1: Application ghi vao PostgreSQL (mot transaction duy nhat)
BEGIN;
  UPDATE orders SET status = 'paid', paid_at = NOW() WHERE id = 42;
COMMIT;

# Buoc 2: WAL da ghi thay doi (flush tai commit); Debezium doc sau commit
#   WAL entry: table=orders op=UPDATE
#     before: {id: 42, status: "pending"}
#     after:  {id: 42, status: "paid"}

# Buoc 3: Debezium doc WAL, phat event len Kafka topic "orders.changes" (lag 100-500ms)

# Buoc 4: Ba consumer group nhan event doc lap
#   - elasticsearch-sync: update document id=42 trong index "orders"
#   - redis-cache:        invalidate cache key "order:42"
#   - warehouse-sink:     append row vao Redshift table "order_events"

Điều quan trọng: application chỉ ghi một nơi (PostgreSQL), không cần biết có bao nhiêu hệ downstream đang nghe. Khi team thêm hệ phái sinh thứ tư (ví dụ Typesense cho full-text search), họ chỉ cần tạo consumer group mới trên Kafka topic đã có — không đụng application.

6. Pitfall tổng hợp

Pitfall 1: Dual-write — ghi DB rồi ghi Kafka riêng

Pattern hay gặp nhất là sau khi ghi database, code gọi thêm kafkaProducer.send(event) riêng. Nếu ứng dụng crash sau khi database commit nhưng trước khi gửi Kafka, database có record mới nhưng Elasticsearch không biết. Bất nhất âm thầm — không có exception nào được throw, không có alert nào từ Kafka.

❌ SAI — dual-write: hai ghi doc lap, khong co atomicity
   db.save(order)                      -- commit ok
   kafkaProducer.send(orderEvent)      -- crash o day -> event mat vinh vien
   => PostgreSQL co record, Kafka khong co event
   => Elasticsearch mai mai khong biet don hang 42 da "paid"

Giải pháp — Transactional Outbox: thay vì ghi Kafka trực tiếp, ghi event vào bảng outbox trong cùng transaction với dữ liệu chính. Debezium đọc CDC từ bảng outbox và đẩy lên Kafka. Tính nguyên vẹn (atomicity) do PostgreSQL đảm bảo: hoặc cả orders lẫn outbox được commit, hoặc cả hai rollback.

✅ DUNG — Transactional Outbox
BEGIN;
  UPDATE orders SET status = 'paid' WHERE id = 42;
  INSERT INTO outbox (event_type, payload)
    VALUES ('order.paid', '{"id":42,"status":"paid"}');
COMMIT;
-- Debezium doc CDC tu bang outbox -> phat len Kafka
-- Atomicity: ca hai commit hoac ca hai rollback. Khong bao gio lech.

Pitfall 2: Schema change vỡ consumer downstream

Khi DBA đổi tên cột user_id thành customer_id trong PostgreSQL, Debezium hiểu đây là sự kiện drop cột cũ + add cột mới. Consumer đang đọc field user_id từ event sẽ nhận null hoặc gặp lỗi deserialization — ngay lập tức, với mọi message từ thời điểm ALTER.

❌ SAI — rename column truc tiep
ALTER TABLE orders RENAME COLUMN user_id TO customer_id;
=> Debezium: drop "user_id" + add "customer_id"
=> Consumer expect "user_id" -> nhan null hoac crash

✅ DUNG — expand-contract migration (5 buoc an toan)
Buoc 1: ADD COLUMN customer_id (ca hai cot ton tai song song)
Buoc 2: Cap nhat consumer chap nhan ca hai field
Buoc 3: Backfill customer_id = user_id
Buoc 4: Consumer chuyen han sang doc "customer_id"
Buoc 5: DROP COLUMN user_id (sau khi xac nhan khong ai dung nua)

Schema registry (Confluent Schema Registry) kiểm tra tương thích tự động và reject schema mới nếu phá vỡ hợp đồng: BACKWARD (mặc định) cho phép consumer nâng cấp trước đọc được cả dữ liệu schema cũ; muốn bảo vệ consumer cũ đang chạy khỏi dữ liệu schema mới thì dùng FORWARD. Nên dùng Avro hoặc Protobuf thay JSON thuần khi pipeline CDC vào sản xuất.

7. 📚 Deep Dive

📚 Deep Dive — CDC patterns và Debezium

Reference chính thức:

Ghi chú: đọc Morling 2018 trước để nắm lý do log-based thắng polling/trigger; Sellers 2023 cho góc nhìn pattern-level khi thiết kế pipeline; Debezium Architecture cho chi tiết triển khai thực tế. Debezium PostgreSQL connector docs giải thích wal_level=logical và pgoutput plugin khi cần cấu hình replication slot.

8. Liên hệ các bài khác

  • M2 — Log-based broker (bài 02): CDC biến thay đổi trong database thành event → Kafka giữ event đó trong log chỉ-append → nhiều consumer group (Elasticsearch, Redis, Redshift) đọc độc lập. CDC là phần "upstream" nạp dữ liệu vào Kafka; log-based broker là "downstream" vận chuyển.
  • Bài 02 — Event Sourcing: event sourcing lưu mọi thay đổi như chuỗi event bất biến ngay trong application — tương đương thiết kế ở tầng ứng dụng những gì CDC làm ở tầng hạ tầng. Bài sau giải thích khi nào chọn event sourcing thay CDC và vì sao hai kỹ thuật hay kết hợp với nhau.
  • Bài 03 — CQRS: CDC thường là cơ chế đồng bộ từ write model sang read model trong kiến trúc CQRS. Read model (Elasticsearch, Redis) cập nhật theo stream event từ CDC thay vì đọc trực tiếp write model.
  • M1 — Tư duy batch (bài 01): batch reprocess cả lịch sử từ input bất biến; CDC reprocess được nhờ Kafka retention — chạy lại consumer từ offset cũ. Cùng triết lý: derived data dựng lại được từ nguồn bất biến (WAL/log).

9. Tóm tắt

  • CDC đọc transaction log của database (WAL ở PostgreSQL, binlog ở MySQL) và phát mọi INSERT/UPDATE/DELETE thành event stream — không poll bảng, bắt được DELETE và before/after value.
  • Log-based CDC (Debezium): lag 100–500ms sau commit; throughput mặc định 5.000–15.000 events/s mỗi connector; không thêm tải lên source DB; không cần schema change. Là lựa chọn mặc định cho sản xuất.
  • Ba phương pháp: polling (đơn giản, bỏ sót DELETE, lag bằng interval), trigger-based (bắt DELETE nhưng tăng tải source DB, hiếm dùng), log-based (khuyến nghị: lag thấp nhất, không tăng tải, đầy đủ nhất).
  • Dual-write là anti-pattern nguy hiểm: ghi DB + ghi Kafka riêng → crash giữa hai bước → bất nhất âm thầm. Giải pháp: Transactional Outbox — ghi event vào bảng outbox cùng transaction, Debezium đọc từ đó.
  • Schema change (rename/drop cột) làm vỡ consumer downstream. Dùng expand-contract migration (5 bước) và schema registry với backward compatibility.
  • CDC là nền để đồng bộ hệ phái sinh: application chỉ ghi một nơi (source DB), mọi downstream (Elasticsearch, Redis, Redshift) là derived data cập nhật tự động qua event stream từ Kafka.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao log-based CDC như Debezium đạt lag thấp hơn polling rất nhiều? Polling bỏ sót loại thay đổi nào mà log-based bắt được?

Polling phải chờ đến lần chạy tiếp theo (thường 10 giây) để phát hiện thay đổi — lag tối thiểu bằng interval poll. Log-based CDC đọc WAL ngay khi database commit, nên event xuất hiện trong Kafka chỉ 100–500ms sau commit.

Polling dùng WHERE updated_at > :watermark — chỉ thấy hàng vẫn còn tồn tại trong bảng và có cột updated_at. DELETE làm hàng biến mất khỏi bảng → polling không thể thấy. Log-based đọc WAL nên thấy rõ từng bút toán DELETE với giá trị before, giúp consumer downstream biết cần xoá document tương ứng.

Q2
WAL là gì? Vì sao Debezium đọc từ WAL thay vì trực tiếp poll bảng như polling thông thường?

WAL (Write-Ahead Log) là transaction log nội bộ mà database ghi trước khi áp dụng thay đổi vào file dữ liệu chính — đảm bảo không mất dữ liệu khi crash và phục vụ replication sang replica. PostgreSQL dùng WAL với wal_level=logical để phát ra thay đổi theo row (không chỉ WAL binary level).

Debezium đọc từ WAL vì WAL đã chứa đủ mọi thay đổi INSERT/UPDATE/DELETE theo thứ tự thời gian với giá trị before/after — không cần schema thêm, không tăng tải lên database. Nếu poll bảng, phải thêm cột updated_at, không bắt được DELETE, và mỗi lần poll là một query tốn CPU chạy cùng lúc với workload sản xuất.

Q3
Trigger-based CDC bắt được DELETE (không như polling), nhưng vì sao ít được dùng trong sản xuất so với log-based?

Trigger-based CDC cài trigger trên mỗi bảng để ghi thay đổi vào bảng phụ change_log. Trigger này chạy trong cùng transaction với dữ liệu chính — nghĩa là mỗi INSERT/UPDATE/DELETE trên database nguồn phải hoàn thành thêm một ghi vào change_log trước khi commit. Điều này tăng tải trực tiếp lên source DB; với bảng có nhiều write, trigger trở thành nút thắt cổ chai.

Ngoài ra, mỗi bảng cần 3 trigger riêng (INSERT/UPDATE/DELETE), và khi schema thay đổi trigger cũng phải cập nhật theo. Log-based CDC không có các vấn đề này vì nó đọc WAL bên ngoài vòng đời transaction, không can thiệp vào đường ghi chính của database.

Q4
Giải thích dual-write problem. Vì sao ghi database rồi gọi kafkaProducer.send() riêng lại tạo bất nhất dữ liệu âm thầm?

Dual-write là pattern ghi dữ liệu vào hai nơi độc lập trong hai thao tác riêng biệt, không có atomicity. Ví dụ: db.save(order) commit thành công, rồi kafkaProducer.send(event). Giữa hai thao tác này không có bảo đảm nguyên vẹn — nếu process crash, mạng đứt, hay Kafka tạm không nhận được sau khi DB đã commit, event bị mất vĩnh viễn.

Nguy hiểm là sự cố này âm thầm: không có exception nào được throw về phía application (commit DB đã thành công), không có alert nào từ Kafka. Database có record nhưng Elasticsearch/Redis không biết — bất nhất tích luỹ dần, chỉ lộ ra khi user thấy kết quả tìm kiếm sai hoặc cache trả dữ liệu cũ.

Q5
Transactional Outbox giải quyết dual-write thế nào? Vì sao nó đảm bảo tính nhất quán mà dual-write không làm được?

Outbox pattern: thay vì ghi Kafka trực tiếp, application ghi event vào bảng outbox trong cùng transaction với dữ liệu chính. Nếu transaction commit thành công, cả orders lẫn outbox được ghi — nếu rollback, cả hai rollback. Database đảm bảo atomicity cho hai bảng trong cùng transaction.

Debezium đọc CDC từ bảng outbox (qua WAL) và đẩy lên Kafka. Đây là thao tác riêng, bên ngoài transaction. Nếu Debezium bị chậm hay Kafka tạm không nhận, event vẫn an toàn trong outbox và sẽ được gửi khi ổn định trở lại (at-least-once delivery). Như vậy không bao giờ có trường hợp DB commit mà Kafka không nhận được event.

Q6
DBA đổi tên cột user_id thành customer_id trong PostgreSQL. CDC pipeline vỡ thế nào? Expand-contract migration giải quyết ra sao?

Khi ALTER TABLE orders RENAME COLUMN user_id TO customer_id, Debezium đọc WAL và hiểu đây là sự kiện drop cột cũ + add cột mới. Event mới không còn field user_id — consumer đang expect field đó sẽ nhận null hoặc gặp deserialization error. Sự cố xảy ra tức thì sau khi ALTER, ảnh hưởng mọi consumer đang chạy.

Expand-contract migration tránh điều này qua 5 bước tuần tự: (1) ADD cột customer_id song song với user_id; (2) cập nhật consumer chấp nhận cả hai field; (3) backfill customer_id = user_id; (4) consumer chuyển hẳn sang đọc customer_id; (5) DROP cột user_id sau khi xác nhận không consumer nào còn dùng. Mỗi bước an toàn rollback độc lập, không có điểm nào làm vỡ consumer đang chạy.

Q7
Một team đề xuất dùng polling thay log-based CDC để đơn giản hoá hạ tầng. Khi nào đề xuất này hợp lý? Khi nào log-based CDC là lựa chọn bắt buộc?

Polling hợp lý khi: (1) mọi bảng cần theo dõi đều có cột updated_at và không cần bắt DELETE; (2) lag vài chục giây chấp nhận được (dashboard không real-time, báo cáo batch); (3) số bảng nhỏ và tải database thấp. Polling đơn giản — không cần cấu hình replication slot, không cần Kafka Connect.

Log-based CDC bắt buộc khi: (1) cần theo dõi DELETE (xoá sản phẩm, huỷ đơn hàng phải phản ánh ngay trên search index/cache); (2) lag sub-second là yêu cầu; (3) số bảng lớn và polling tạo tải đáng kể; (4) cần giá trị before/after để đồng bộ chính xác. Hệ thống có lưu lượng cao và yêu cầu nhất quán nghiêm ngặt nên dùng log-based CDC từ đầu — retrofit sau tốn công hơn nhiều.

Bài tiếp theo: Event Sourcing — lưu chuỗi sự kiện thay vì trạng thái

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên