CDC (Change Data Capture) — Data Engineering

Change Data Capture (CDC) CDC adalah teknik untuk mendeteksi dan menangkap perubahan data (INSERT, UPDATE, DELETE) dari database sumber, lalu meneruskannya ke…

CDC (Change Data Capture) adalah menangkap setiap perubahan data di database sumber (INSERT, UPDATE, DELETE) saat terjadi, lalu meneruskannya ke sistem lain, sehingga salinannya di warehouse ikut berubah tanpa menyalin seluruh tabel. Bayangin rekening bank. Mengecek saldo cuma memberi angka sekarang. Mutasi rekening mencatat tiap transaksi berurutan: masuk, keluar, dibatalkan. Dari saldo awal plus mutasi, saldo hari ini bisa dihitung ulang, dan gak ada transaksi yang terlewat. CDC membaca "mutasi" database.

🧪 Tebakan cepat: warehouse Toko Buku disinkronkan tiap jam dengan WHERE updated_at > last_run. Sabtu pagi admin menghapus baris dobel 9, membetulkan harga item 16, dan Siti checkout. Setelah sync terakhir, sumber dan warehouse sama-sama 19 baris. Isinya sama? Tebak dulu, jawabannya ada di bagian "Sync pakai updated_at: yang terlewat".

Kenapa menyalin ulang gak cukup

Cara paling sederhana: tiap malam salin seluruh order_items ke warehouse. Untuk 19 baris gak masalah. Untuk 50 juta baris, tiap malam membaca 50 juta baris padahal yang berubah mungkin seribu, dan warehouse tetap telat sampai besok pagi.

Cara kedua, incremental: ambil cuma baris yang berubah sejak sync terakhir. Tabel Laravel punya kolom updated_at dari $table->timestamps(), jadi query-nya kelihatan gampang:

INSERT INTO wh_order_items
SELECT * FROM order_items
WHERE updated_at > (SELECT last_run FROM sync_state)
ON CONFLICT (id) DO UPDATE SET
  order_id = excluded.order_id, book_id = excluded.book_id,
  qty = excluded.qty, price = excluded.price, updated_at = excluded.updated_at;

UPDATE sync_state SET last_run = (SELECT MAX(updated_at) FROM wh_order_items);

Sync pakai updated_at: yang terlewat

Sabtu 10 Oktober di MySQL Toko Buku, dengan sync terakhir Jumat 23:00:

Waktu Kejadian updated_at
09:00:00 admin menghapus item 9, baris dobel (barisnya hilang)
10:15:30 admin membetulkan harga item 16: 950000 jadi 95000 10:15:30
10:15:30 sync jalan: updated_at > '2026-10-09 23:00:00' mengambil item 16, last_run jadi 10:15:30
10:15:30 Siti checkout: pesanan 15, item 20 (Bumi Manusia, 119000) 10:15:30
11:00:00 sync jalan: updated_at > '2026-10-10 10:15:30' mengambil 0 baris

Lalu sumber dibandingkan dengan warehouse (FULL OUTER JOIN per id):

id sumber warehouse masalah
9 NULL 125000 dihapus di sumber, masih di warehouse
20 119000 NULL ada di sumber, belum di warehouse

Jumlah barisnya sama-sama 19, tapi isinya beda dua baris. Itu jawaban tebakannya. Dua lubangnya:

Laravel punya jalan tengah: soft delete (deleted_at), sehingga hapus jadi update. Tapi itu cuma menolong tabel yang memakainya.

CDC berbasis log: membaca binlog

MySQL sudah mencatat semua perubahan di binary log (binlog), buku mutasi yang dipakai untuk replikasi. Dengan binlog_format=ROW dan binlog_row_image=FULL (default di MySQL 8), tiap baris yang berubah dicatat lengkap: isinya sebelum dan sesudah, termasuk yang dihapus.

Debezium (rilis stabil terbaru 3.7.0.Final, 29 September 2026) membaca binlog seperti replica, lalu mengirim satu change event per perubahan baris ke Kafka lewat Kafka Connect. Topic-nya <topic.prefix>.<database>.<tabel>, misalnya tokobuku.toko_buku.order_items, dan key-nya primary key baris itu: {"id": 16}. Event untuk perbaikan harga item 16, bentuknya mengikuti contoh di dokumentasi Debezium (dipendekkan: kolom updated_at dan sebagian source dihilangkan):

{
  "before": { "id": 16, "order_id": 12, "book_id": 4, "qty": 1, "price": 950000 },
  "after":  { "id": 16, "order_id": 12, "book_id": 4, "qty": 1, "price": 95000 },
  "source": {
    "version": "3.7.0.Final",
    "connector": "mysql",
    "name": "tokobuku",
    "ts_ms": 1791602130000,
    "db": "toko_buku",
    "table": "order_items",
    "file": "mysql-bin.000003",
    "pos": 484,
    "row": 0
  },
  "op": "u",
  "ts_ms": 1791602130412
}

Di Lab, binlog-nya tabel SQLite yang diisi trigger, supaya isinya kelihatan. MySQL menulis binlog sendiri, tanpa trigger dan tanpa membebani query aplikasi.

Menerapkan event ke warehouse

Consumer membaca event lalu menerapkannya: c, u, dan r jadi upsert dengan isi after; d jadi DELETE per key. Kalau satu id punya beberapa event dalam satu batch, event terakhir yang menang:

INSERT INTO wh_order_items (id, order_id, book_id, qty, price, updated_at)
SELECT e.id, json_extract(e.after, '$.order_id'), json_extract(e.after, '$.book_id'),
       json_extract(e.after, '$.qty'), json_extract(e.after, '$.price'), json_extract(e.after, '$.updated_at')
FROM binlog e
WHERE e.op IN ('c', 'u') AND e.pos = (SELECT MAX(pos) FROM binlog WHERE id = e.id)
ON CONFLICT (id) DO UPDATE SET
  order_id = excluded.order_id, book_id = excluded.book_id,
  qty = excluded.qty, price = excluded.price, updated_at = excluded.updated_at;

DELETE FROM wh_order_items
WHERE id IN (SELECT e.id FROM binlog e
             WHERE e.op = 'd' AND e.pos = (SELECT MAX(pos) FROM binlog WHERE id = e.id));

Binlog Sabtu berisi 3 event: d item 9, u item 16, c item 20. Setelah diterapkan, perbandingan sumber dan warehouse: 0 baris beda. Di BigQuery, Snowflake, atau DuckDB, dua langkah ini biasanya satu MERGE.

Outbox pattern

CDC langsung dari tabel mengirim baris mentah aplikasi; kalau kolomnya diganti nama, consumer ikut rusak. Kalau aplikasi mau mengirim event bisnis yang rapi ("pesanan dibuat"), jangan simpan ke database lalu kirim ke Kafka sebagai dua langkah terpisah: kalau yang kedua gagal, keduanya gak cocok lagi. Tulis event-nya ke tabel outbox di transaksi yang sama dengan pesanannya, dan biarkan Debezium membaca tabel itu:

DB::transaction(function () use ($order) {
    $order->save();

    DB::table('outbox')->insert([
        'id' => (string) Str::uuid(),
        'aggregatetype' => 'order',
        'aggregateid' => (string) $order->id,
        'type' => 'PesananDibuat',
        'payload' => json_encode(['order_id' => $order->id, 'customer_id' => $order->customer_id]),
    ]);
});

⚠️ Jebakan yang sering ketemu

TL;DR: CDC menangkap tiap INSERT, UPDATE, dan DELETE dari database sumber dan meneruskannya near real-time, tanpa menyalin seluruh tabel. Sync dengan WHERE updated_at > last_run melewatkan DELETE (item 9 tertinggal) dan perubahan di detik yang sama dengan sync (item 20 terlewat), walau jumlah barisnya sama-sama 19. CDC berbasis log membaca binlog MySQL (binlog_format=ROW): Debezium 3.7 mengirim satu event per baris ke Kafka dengan before, after, source, op (c, u, d, r, t), dan ts_ms, ber-key primary key. Warehouse menerapkannya sebagai upsert untuk c/u/r dan DELETE untuk d, event terakhir per key menang, dan cara itu idempoten. Untuk event bisnis, pakai outbox pattern: tulis event ke tabel outbox di transaksi yang sama, Debezium yang mengirimnya.

Yang akan kamu pelajari