Đồng bộ giữa hệ thống, CDC, incremental syncSyncing systems, CDC, incremental sync
Debezium đọc WAL của Postgres qua replication slot và đẩy mỗi thay đổi vào Kafka với key là id đơn hàng. Mỗi target có sink riêng, upsert theo version. Job đối soát so checksum từng khoảng id giữa nguồn và Elasticsearch. Chọn “Connector ngừng một giờ” hoặc “Đối soát phát hiện lệch” để xem cách hệ thống tự phục hồi.Debezium reads the Postgres WAL through a replication slot and publishes every change to Kafka keyed by order id. Each target has its own sink that upserts by version. A reconciliation job compares checksums per id range between the source and Elasticsearch. Pick “Connector down for an hour” or “Reconciliation finds drift” to see how the system recovers.
1Order Service đổi trạng thái đơn 81723 sang SHIPPED và commit. Service không biết gì về search hay warehouse.The Order Service sets order 81723 to SHIPPED and commits. It knows nothing about search or the warehouse.
Transaction bắt đầu lúc 10:00:00 và commit lúc 10:00:05 mang updated_at = 10:00:00; poller đã qua watermark 10:00:03 sẽ không bao giờ thấy nó. Phải lùi watermark một khoảng an toàn (ví dụ 5 phút) và upsert idempotent, và polling vẫn không thấy hard delete. CDC đọc WAL theo thứ tự commit nên không gặp vấn đề này.A transaction that starts at 10:00:00 and commits at 10:00:05 carries updated_at = 10:00:00; a poller already past watermark 10:00:03 never sees it. You must lag the watermark by a safety window (say 5 minutes) and upsert idempotently, and polling still cannot see hard deletes. CDC reads the WAL in commit order, so it avoids this.
Kafka chỉ giữ thứ tự trong một partition, nên key phải là khoá chính. Sink vẫn phải so version (LSN hoặc cột version) vì retry và resync có thể đưa bản cũ tới sau bản mới.Kafka keeps order only within a partition, so the key must be the primary key. The sink must still compare versions (the LSN or a version column) because retries and resyncs can deliver an old image after a newer one.
Debezium phát sự kiện op=d rồi một tombstone (value null) để log compaction xoá key. Sink xoá theo sự kiện op=d và bỏ qua tombstone. Elasticsearch chỉ nhớ version của tài liệu đã xoá trong index.gc_deletes (60 giây); muốn chặn bản update cũ “hồi sinh” tài liệu thì dùng soft delete trong index.Debezium emits an op=d event and then a tombstone (null value) so log compaction can drop the key. The sink deletes on op=d and ignores the tombstone. Elasticsearch remembers a deleted document's version only for index.gc_deletes (60 seconds); to stop a late update from resurrecting it, soft-delete inside the index.
CDC trên bảng gốc biến từng cột thành hợp đồng với mọi consumer. Với dữ liệu chia sẻ giữa các team, dùng bảng outbox và Outbox Event Router của Debezium: service tự quyết định hình dạng sự kiện, CDC chỉ vận chuyển.CDC on the raw table turns every column into a contract with every consumer. For data shared across teams, use an outbox table with Debezium's Outbox Event Router: the service decides the event shape, CDC only carries it.
Dùng Schema Registry với Avro hoặc Protobuf ở chế độ BACKWARD: thêm cột nullable là an toàn, đổi tên hoặc đổi kiểu cột thì bị từ chối. Đổi tên làm theo expand/contract: thêm cột mới, ghi cả hai, chuyển consumer, rồi mới bỏ cột cũ.Use a Schema Registry with Avro or Protobuf in BACKWARD mode: adding a nullable column is safe, renaming or retyping is rejected. Renames follow expand/contract: add the new column, write both, move consumers, then drop the old one.
Target mới cần toàn bộ 12M dòng cũ trong khi stream vẫn chạy. Incremental snapshot của Debezium (thuật toán watermark kiểu DBLog) đọc từng chunk theo khoá chính, xen kẽ với sự kiện stream, không khoá bảng và không cần dừng connector.A new target needs all 12M existing rows while streaming continues. Debezium's incremental snapshot (DBLog-style watermarks) reads chunks by primary key, interleaved with streamed events, without locking the table or stopping the connector.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
orders (source) | id, customer_id, status, total_minor, version, updated_at, REPLICA IDENTITY DEFAULT | version tăng mỗi lần ghi, dùng để so khi đối soát; publication dbz_orders chỉ gồm bảng cần đồng bộversion grows on every write and is what reconciliation compares; publication dbz_orders lists only the tables to sync |
connect-offsets (Kafka topic) | key=[connector, server], value={lsn, txId, ts_usec} | Compacted topic; điểm tiếp tục khi connector khởi động lạiA compacted topic; the resume point when the connector restarts |
debezium_signal | id, type, data | Ghi một dòng execute-snapshot để chạy incremental snapshot cho một bảng hoặc một tập idInsert an execute-snapshot row to run an incremental snapshot for a table or a set of ids |
sync_drift | run_id, bucket, stale_ids, orphan_ids, missing_ids, repaired_at | Lịch sử đối soát; cảnh báo khi số dòng lệch mỗi ngày vượt 100Reconciliation history; alert when more than 100 rows drift in a day |
Sink đọc sự kiện Debezium và upsert vào Elasticsearch với external version, nên sự kiện trùng hoặc đến muộn bị bỏ qua. Job đối soát so digest từng khoảng id và chỉ đồng bộ lại phần lệch.The sink reads Debezium events and upserts into Elasticsearch with an external version, so duplicates and late events are dropped. The reconciliation job compares a digest per id range and resyncs only what differs.
@Component
@RequiredArgsConstructor
public class OrderSearchSink {
private final ElasticsearchClient es;
@KafkaListener(topics = "pg.public.orders", groupId = "orders-search")
public void onChange(ConsumerRecord<String, ChangeEvent> rec) throws IOException {
ChangeEvent e = rec.value();
if (e == null) return; // tombstone, only for compaction
String id = rec.key();
long version = e.source().lsn(); // grows with every commit
try {
if (e.op() == Op.DELETE) {
es.delete(d -> d.index("orders").id(id)
.version(version).versionType(VersionType.External));
} else { // c, u, r (snapshot read)
es.index(i -> i.index("orders").id(id)
.version(version).versionType(VersionType.External)
.document(OrderDoc.from(e.after())));
}
} catch (ElasticsearchException ex) {
if (ex.status() != 409) throw ex; // 409: stale or duplicate, skip
}
}
}type Digest struct{ Count, VSum int64 } // count(*), sum(version)
type Side interface {
Digest(ctx context.Context, lo, hi int64) (Digest, error)
Versions(ctx context.Context, lo, hi int64) (map[int64]int64, error)
}
type Repairer interface {
Resync(ctx context.Context, ids []int64) error // Debezium signal
Delete(ctx context.Context, ids []int64) error // straight to target
}
type Reconciler struct {
src, dst Side
fix Repairer
step int64 // 10_000 ids per bucket
}
func (r *Reconciler) Run(ctx context.Context, maxID int64) error {
for lo := int64(0); lo <= maxID && ctx.Err() == nil; lo += r.step {
a, err := r.src.Digest(ctx, lo, lo+r.step)
if err != nil {
return err
}
b, err := r.dst.Digest(ctx, lo, lo+r.step)
if err != nil {
return err
}
if a != b {
if err := r.repair(ctx, lo, lo+r.step); err != nil {
return fmt.Errorf("bucket %d: %w", lo/r.step, err)
}
}
}
return ctx.Err()
}
func (r *Reconciler) repair(ctx context.Context, lo, hi int64) error {
want, err := r.src.Versions(ctx, lo, hi)
if err != nil {
return err
}
got, err := r.dst.Versions(ctx, lo, hi)
if err != nil {
return err
}
var stale, orphan []int64
for id, v := range want {
if got[id] != v { // missing or different version
stale = append(stale, id)
}
}
for id := range got {
if _, ok := want[id]; !ok {
orphan = append(orphan, id)
}
}
return errors.Join(r.fix.Resync(ctx, stale), r.fix.Delete(ctx, orphan))
}| CDC (Debezium)CDC (Debezium) | Polling + watermarkPolling + watermark | |
|---|---|---|
| Delete và thứ tựDeletes and ordering | Thấy mọi delete, đúng thứ tự commitSees every delete, in commit order | Không thấy hard delete, cần soft delete; dễ sót transaction dàiMisses hard deletes, needs soft delete; long transactions slip through |
| Tải lên nguồnLoad on the source | Thấp, đọc WAL; nhưng slot giữ WAL khi connector dừngLow, reads the WAL; but the slot retains WAL while the connector is down | Truy vấn định kỳ, cần index updated_at, tốn hơn khi dữ liệu lớnPeriodic queries, needs an index on updated_at, costlier as data grows |
| Độ trễLatency | Dưới giâySub-second | Bằng chu kỳ quét cộng cửa sổ an toàn, thường vài phútThe poll interval plus a safety window, usually minutes |
| Chọn khiPick it when | Đã có Kafka, cần gần thời gian thực và đủ deleteKafka is already there and you need near real time with deletes | Đồng bộ batch theo giờ, không được cấp quyền replicationHourly batch sync, or no replication privileges are granted |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Hai lần ghi không có commit chung: DB commit rồi service chết thì search thiếu, gọi search xong DB rollback thì search thừa. Hai request song song còn có thể ghi search theo thứ tự ngược với DB. CDC hoặc outbox lấy thứ tự từ chính log commit.The two writes share no commit: the DB commits and the service dies, so search misses it; search is called and the DB rolls back, so search has a ghost. Two parallel requests can also reach search in the opposite order to the DB. CDC or an outbox takes the order from the commit log itself.
Replication slot giữ WAL nên đĩa primary đầy dần, có thể làm sập database. Đặt max_slot_wal_keep_size, cảnh báo theo pg_replication_slots, bật heartbeat để slot tiến lên cả khi bảng ít ghi. Nếu slot bị vô hiệu thì offset không còn dùng được và phải chạy snapshot lại.The replication slot keeps WAL, so the primary's disk fills and can take the database down. Set max_slot_wal_keep_size, alert on pg_replication_slots, enable heartbeats so the slot advances on quiet tables. If the slot is invalidated the offset is useless and a new snapshot is required.
Partition theo khoá chính để giữ thứ tự trong Kafka, rồi upsert có điều kiện theo version: external version ở Elasticsearch, hoặc UPDATE … WHERE version < :v / MERGE ở warehouse. Thứ tự giúp hiệu quả, version mới là thứ đảm bảo đúng.Partition by primary key to keep order in Kafka, then upsert conditionally on version: an external version in Elasticsearch, or UPDATE … WHERE version < :v / MERGE in the warehouse. Ordering makes it efficient; the version is what makes it correct.
Vì lỗi vẫn xảy ra ngoài pipeline: ai đó sửa tay trên target, sink có bug, message bị bỏ vào DLQ mà chưa phát lại, slot bị vô hiệu. Đối soát theo khoảng với digest rẻ phát hiện và sửa phần lệch, đồng thời là thước đo độ tin cậy của cả hệ thống.Because failures still happen outside the pipeline: someone edits the target by hand, the sink has a bug, messages sit in the DLQ unreplayed, a slot gets invalidated. Range-based reconciliation with a cheap digest finds and repairs drift, and doubles as a measure of how reliable the whole system is.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.