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);
last_run(disebut juga watermark) adalahupdated_atterbesar yang sudah disalin.ON CONFLICT (id) DO UPDATEadalah upsert: kalauid-nya sudah ada, baris lama diperbarui; kalau belum, disisipkan. Ini sintaks SQLite dan PostgreSQL; MySQL menulisnyaON DUPLICATE KEY UPDATE.
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:
- DELETE gak kelihatan. Baris yang dihapus gak punya
updated_atlagi untuk dibaca. Duplikat item 9 hidup terus di warehouse. - Detik yang sama.
updated_atLaravel disimpan per detik. Checkout Siti selesai di detik 10:15:30, sesudah sync membaca, jadi> '10:15:30'gak pernah mengambilnya. Mengganti>jadi>=menangkap item 20 (item 16 terbaca ulang, aman karena upsert), tapi item 9 tetap tertinggal.
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
}
beforedanafter: baris sebelum dan sesudah. INSERT punyabeforenull; DELETE punyaafternull.op:c(create),u(update),d(delete),r(read, baris dari snapshot awal),t(truncate).source.ts_ms: kapan perubahan terjadi di database (10:15:30 WIB = 03:15:30 UTC);ts_msdi luar: kapan Debezium memprosesnya.filedanpos: posisinya di binlog.- Setelah event
d, Debezium mengirim tombstone: key yang sama dengan valuenull, supaya Kafka boleh membuang semua event lama untuk key itu.
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.
- Urutan. Event untuk satu baris harus diterapkan sesuai urutannya. Debezium memakai primary key sebagai key, jadi semua event satu baris masuk partisi Kafka yang sama dan tetap berurutan (pelajaran Kafka berikutnya).
- Idempoten. Kafka bisa mengirim event yang sama dua kali. Upsert dengan isi
afterlengkap danDELETEper key memberi hasil yang sama walau dijalankan dua kali.UPDATE ... SET qty = qty + 1enggak.
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]),
]);
});
- Pesanan dan event-nya tersimpan bersama atau gagal bersama.
- Outbox Event Router Debezium membaca baris
outboxdan mengirimnya ke topicoutbox.event.order(dariaggregatetype), denganaggregateidsebagai key.
⚠️ Jebakan yang sering ketemu
- Sync
updated_atdengan hard delete. Baris yang dihapus gak pernah sampai ke warehouse. Pakai CDC, soft delete, atau bandingkan seluruh key sesekali. - Watermark per detik dengan
>. Perubahan di detik yang sama dengan sync terlewat selamanya.>=plus upsert lebih aman. - Menerapkan event dengan cara yang gak idempoten. Menambah
qtyper event menggandakan angka saat event terkirim ulang. Simpan isiafter, jangan hitung selisih. - Binlog keburu dihapus. MySQL 8 menyimpan binlog 30 hari (
binlog_expire_logs_seconds). Kalau Debezium mati lebih lama dari itu, posisinya hilang dan harus snapshot ulang.
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.