Debezium ile MySQL CDC: Binlog'tan Kafka'ya

Selamlar, bu yazımda Debezium ile MySQL'den nasıl gerçek zamanlı CDC yapıldığına bakacağız. Konu klasik gibi görünür ama prod'a koyduğumuzda 'binlog formatı niye ROW olmak zorunda', 'snapshot sırasında ne oluyor', 'schema değişince consumer ne yapacak' gibi sorular hep birden gelir. Bu yüzden temel kurulumdan ziyade pratikte canınızı sıkacak yerlere odaklanmak istiyorum.

Neden CDC, neden Debezium?

Mikroservis dünyasında en sık duyduğumuz dertlerden biri 'dual-write problemi'. Bir tarafta veritabanına yazıyorsunuz, hemen ardından Kafka'ya event basıyorsunuz. Aralarda biri patlarsa? Veriler ile event'ler ayrışır, tutarsızlık başlar. Outbox pattern bunun bir cevabı, ama bence asıl sağlam yol veritabanının kendi commit log'unu kaynak olarak kullanmak. MySQL'de bu log binary log, kısaca binlog. Debezium da tam olarak bunu yapar: kendisini bir replikasyon istemcisi gibi MySQL'e tanıtır, binlog'u takip eder ve her satır değişikliğini Kafka'ya yapılandırılmış bir event olarak yazar.

Buradaki kritik nokta şu: yazma işlemi zaten commit edildiyse Debezium o değişikliği kaçırmaz. Servisinizin başına ne gelirse gelsin, kaynak hâlâ binlog'tur. Yani 'önce DB'ye yazayım sonra Kafka'ya event basayım' kıvranmasından kurtuluyoruz.

Binlog row formatı şart

Debezium kurulumuna geçmeden önce MySQL tarafında mutlaka halletmeniz gereken bir şey var: binlog'un ROW formatında olması. STATEMENT ya da MIXED ile çalışmaz, çünkü Debezium her bir satırın değişen halini görmek ister, sadece çalıştırılan SQL cümlesini değil.

[mysqld]
server-id = 1
log_bin = /var/log/mysql/mysql-bin.log
binlog_format = ROW
binlog_row_image = FULL
expire_logs_days = 10

binlog_row_image = FULL da önemli; aksi halde sadece değişen sütunlar log'a düşer, before görüntüsü eksik kalır. Tecrübemden söyleyebilirim ki, MINIMAL ile başlayan bir setup downstream'de 'before' alanını kullanan herkesi bir noktada üzer.

Bir de Debezium için yetkili bir kullanıcı:

CREATE USER 'debezium'@'%' IDENTIFIED BY 'dbz_password';
GRANT SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'debezium'@'%';
FLUSH PRIVILEGES;

REPLICATION SLAVE ve REPLICATION CLIENT yetkileri olmadan binlog okuyamaz.

Connector kaydı ve snapshot modları

Debezium, Kafka Connect üstünde bir connector olarak koşar. Connect REST API'sine connector tanımı POST'ladığınızda iş başlar:

curl -X POST http://localhost:8083/connectors \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "mysql-connector",
    "config": {
      "connector.class": "io.debezium.connector.mysql.MySqlConnector",
      "database.hostname": "mysql-host",
      "database.user": "debezium",
      "database.password": "dbz_password",
      "database.server.id": "184054",
      "topic.prefix": "myapp",
      "database.include.list": "myapp",
      "snapshot.mode": "initial",
      "schema.history.internal.kafka.bootstrap.servers": "kafka:9092",
      "schema.history.internal.kafka.topic": "schema-changes.myapp"
    }
  }'

Burada snapshot.mode parametresine dikkat. Debezium ilk başladığında ne yapacağını bu belirler. Birkaç önemli mod var: initial ile önce tüm tabloların full snapshot'ı çekilir, sonra binlog'a geçilir. schema_only snapshot atlamaz ama mevcut veriyi de dökmez, sadece şu andan itibaren değişiklikleri yakalar. never ise binlog'a doğrudan atlar; yeni başlayan tablolarda neredeyse hiç istemezsiniz çünkü ilk halini hiç bilmeyeceksiniz.

Bence yeni bir tablo bağlıyorsanız varsayılan initial neredeyse her zaman doğru tercih. Ama büyük tablolarda snapshot uzar ve bu sırada binlog tutulur, dolayısıyla disk'e dikkat etmek lazım.

Schema evolution ve schema change topic

Sıradan kullanıcının kafasını en çok karıştıran şey burası. Tablonuza yeni bir kolon ekleyin, eski tüketiciler ne yapacak?

Debezium her DDL değişikliğini ayrı bir 'schema change topic'e yazar (yukarıdaki config'de schema-changes.myapp). Bu topic, connector'un kendi iç hafızası için kritik: yeniden başladığında geçmişteki şema durumunu buradan oluşturur. Yani siz silmeyin, retention'ı sonsuz tutun.

Consumer tarafında ise schema evolution daha çok kullandığınız serializer'a bağlı. Schema Registry ile Avro kullanıyorsanız geriye dönük uyumlu (BACKWARD) değişiklikler sorunsuz akar. Düz JSON kullanıyorsanız iş daha gevşek; eski consumer yeni alanları görmezden gelir, ama tip değişimi sizi yakar. Ben prod'da Avro + Schema Registry kombinasyonunu daha güvenli buluyorum.

Sık karşılaşılan tuzaklar

  • binlog format yanlış: STATEMENT veya MIXED ile Debezium başlamaz bile. Logda anlamsız hata alırsanız önce buraya bakın.
  • server-id çakışması: Aynı MySQL'e birden fazla replica veya connector bağlıysa database.server.id herkeste farklı olmalı.
  • expire_logs_days çok kısa: Snapshot 6 saat sürerken binlog 4 saat sonra siliniyorsa, snapshot biter bitmez 'binlog bulunamadı' hatası yersiniz. Ortamınıza göre rahat bir tampon bırakın.
  • Connector'u sıfırdan başlatmak: Offset topic'i silip connector'u yeniden kurarsanız tüm snapshot'ı tekrar çeker. Büyük tablolarda bu hiç hoş değil.

Kapanış

Debezium, dual-write derdine veritabanının kendi log'unu kaynak yaparak çok temiz bir cevap veriyor. MySQL tarafında ROW formatı ve doğru yetki, Connect tarafında doğru snapshot.mode ve schema history topic'i ayarladıysanız geri kalanı oldukça öngörülebilir akar. Şahsi kanaatim, event-driven bir mimari kuruyorsanız outbox yazmak yerine Debezium'a yatırım yapmak uzun vadede çok daha az baş ağrısı çıkarıyor. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.