sz

Sejan

Blog

Change Data Capture (CDC) Architecture Guide: Debezium, Postgres Logical Replication, and Transactional Outbox

Home › System Architecture › Change Data Capture (CDC) Architecture Guide: Debezium, Postgres Logical Replication, and Transactional Outbox
Change Data Capture (CDC) Architecture Guide: Debezium, Postgres Logical Replication, and Transactional Outbox

In modern microservices and event-driven architectures, transactional data rarely lives in a single database. An e-commerce order must be stored in an ACID relational database (PostgreSQL/MySQL), indexed in a search engine (Elasticsearch/OpenSearch), cached in memory (Redis), and broadcast to downstream microservices (Inventory, Billing, Analytics) via an event broker (Apache Kafka).

How do you synchronize these disparate storage engines reliably in real time? The naive approach—having the application write to the database and immediately publish a message to Kafka (the Dual-Write Anti-Pattern)—inevitably leads to catastrophic data corruption and state drift during network partitions or process crashes.

The industry standard solution is Change Data Capture (CDC). By mining the database's internal transaction log (PostgreSQL's Write-Ahead Log or MySQL's Binlog) at the storage engine level, CDC extracts committed row-level mutations (INSERT, UPDATE, DELETE) as a continuous event stream with zero application overhead, 100% data consistency, and sub-second latency.

In this technical deep dive, we explore the dual-write problem, dissect PostgreSQL Logical Decoding (pgoutput) and Replication Slots, examine Debezium event formats, master the Transactional Outbox Pattern, and build an end-to-end CDC synchronization pipeline in Go.


The Dual-Write Problem: Why Application-Level Sync Fails

Consider what happens when an application service attempts to write to both a database and a message broker within the same business transaction:

The Dual-Write Race Condition:

Scenario 1: DB Succeeds, Kafka Fails
  1. App executes: BEGIN; INSERT INTO orders; COMMIT; -> (DB Success!)
  2. App calls: kafkaProducer.send(orderCreatedEvent);
  3. Network glitch / Kafka timeout occurs -> (Message FAILS!)
  RESULT: Database has order, but Inventory service never receives the event! (Ghost State)

Scenario 2: Kafka Succeeds, DB Rolls Back
  1. App calls: kafkaProducer.send(orderCreatedEvent); -> (Message Sent!)
  2. App executes: INSERT INTO orders; -> (Unique Constraint Violation / Crash!)
  3. DB Transaction Rolls Back!
  RESULT: Downstream services process order that does not physically exist!

Scenario 3: Concurrent Out-of-Order Overwrites
  Thread A: UPDATE user SET email = '[email protected]' (DB) ───[ Delayed on Wire ]───> Kafka
  Thread B: UPDATE user SET email = '[email protected]' (DB) ─────────────────────────> Kafka (Arrives First!)
  RESULT: Database has '[email protected]', but Kafka ends on '[email protected]'! (Permanent Drift)

Because distributed two-phase commit (XA / 2PC) across relational databases and Kafka brokers is notoriously fragile and slow, application-level dual-writes are fundamentally broken.


PostgreSQL Logical Replication: Write-Ahead Log (WAL) Decoding

PostgreSQL achieves durability by appending every atomic mutation to the Write-Ahead Log (WAL) before modifying database pages on disk. The WAL is a strict, immutable chronological record of truth.

PostgreSQL Logical Replication Architecture:

┌────────────────────────────────────────────────────────┐
│ PostgreSQL Primary Instance                            │
│                                                        │
│  1. SQL Mutation (INSERT / UPDATE / DELETE)            │
│           │                                            │
│           ▼                                            │
│  [ Write-Ahead Log (WAL) on Disk ]                     │
│     Record 1: LSN 0/16B3D40 (Table: users, Row: 101)   │
│     Record 2: LSN 0/16B3E08 (Table: orders, Row: 42)   │
│           │                                            │
│           ▼                                            │
│  [ Logical Decoding Plugin (pgoutput / test_decoding) ]│
│     Decodes raw WAL binary into structured row events  │
│           │                                            │
│           ▼                                            │
│  [ Replication Slot: 'debezium_slot' ]                 │
│     Tracks Consumer LSN Position -> Retains WAL files  │
└───────────┼────────────────────────────────────────────┘
            │ Streaming Protocol (TCP)
            ▼
┌────────────────────────────────────────────────────────┐
│ Debezium / CDC Consumer                                │
│   Consumes JSON/Avro events -> Publishes to Kafka      │
│   Acknowledges LSN -> Postgres frees old WAL segments  │
└────────────────────────────────────────────────────────┘

Key PostgreSQL Concepts:

  1. wal_level = logical: Instructs PostgreSQL to include row metadata, old/new column values, and transaction boundaries in the WAL.
  2. Logical Decoding Plugin (pgoutput): Converts internal binary WAL records into an understandable logical replication stream (standard in PostgreSQL 10+).
  3. Replication Slot: A persistent state tracker on the primary server that guarantees PostgreSQL will not delete or recycle WAL segments until the CDC consumer acknowledges receipt of that Log Sequence Number (LSN).
Production Warning on Replication Slots: If your CDC consumer (Debezium) stops running, the PostgreSQL replication slot will hold onto unconsumed WAL segments. If left unmonitored, WAL files will fill up disk storage and cause PostgreSQL to halt. Always set max_slot_wal_keep_size!

Debezium: The Open-Source CDC Standard

Debezium is a distributed, Kafka Connect-native platform that provides production-grade connectors for PostgreSQL, MySQL, MongoDB, SQL Server, and Oracle.

When a row is modified, Debezium emits a rich, self-describing event containing both the before and after state, transaction metadata, and source coordinates:

{
  "schema": { "...": "JSONSchema / Avro schema metadata" },
  "payload": {
    "before": {
      "id": 42,
      "email": "[email protected]",
      "status": "pending"
    },
    "after": {
      "id": 42,
      "email": "[email protected]",
      "status": "active"
    },
    "source": {
      "version": "2.7.0.Final",
      "connector": "postgres",
      "db": "production_db",
      "schema": "public",
      "table": "users",
      "txId": 10842,
      "lsn": 23828944,
      "ts_ms": 1789452000000
    },
    "op": "u", 
    "ts_ms": 1789452000120
  }
}
Operation Code (op) SQL Action Payload Content
c (Create) INSERT before: null, after: { ... new values ... }
u (Update) UPDATE before: { ... old ... }, after: { ... new ... }
d (Delete) DELETE before: { ... old ... }, after: null
r (Read) Initial Snapshot Initial load records generated during initial connector sync.

The Transactional Outbox Pattern: CDC-Powered Domain Events

While raw table CDC is powerful for data replication, publishing internal database schemas directly to external microservices tightly couples your internal tables to public API contracts.

The Transactional Outbox Pattern combines local database transactions with CDC to emit clean, business-level domain events:

The Transactional Outbox Architecture:

1. Application executes ACID Transaction in PostgreSQL:
   BEGIN;
     INSERT INTO orders (id, user_id, total) VALUES ('ord_1', 'usr_42', 99.00);
     
     -- Write domain event into the dedicated Outbox table in the SAME transaction!
     INSERT INTO outbox_events (
       id, aggregate_type, aggregate_id, event_type, payload
     ) VALUES (
       'evt_99', 'Order', 'ord_1', 'OrderCreated', '{"userId": "usr_42", "total": 99.00}'
     );
   COMMIT;
   (Atomic guarantee: Either BOTH write or NEITHER writes!)

2. Debezium captures 'outbox_events' table via Postgres WAL.
3. Debezium Outbox Event Router re-routes event to Kafka topic: 'events.orders.OrderCreated'.
4. Debezium auto-deletes the processed outbox row (or marks it published).

Hands-On Implementation: Postgres Logical Replication in Go

You don't always need a heavy Java/Kafka-Connect cluster. You can consume PostgreSQL logical replication streams directly in Go using jackc/pglogrepl:

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/jackc/pglogrepl"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgproto3"
)

const (
	DB_CONN = "postgres://postgres:secret@localhost:5432/app_db?replication=database"
	SLOT    = "go_cdc_slot"
	PLUGIN  = "pgoutput"
)

func main() {
	ctx := context.Background()

	// 1. Establish replication connection to PostgreSQL
	conn, err := pgconn.Connect(ctx, DB_CONN)
	if err != nil {
		log.Fatalf("Failed to connect to PostgreSQL: %v", err)
	}
	defer conn.Close(ctx)

	// 2. Create Logical Replication Slot (if not exists)
	_, err = pglogrepl.CreateReplicationSlot(ctx, conn, SLOT, PLUGIN, pglogrepl.CreateReplicationSlotOptions{Temporary: false})
	if err != nil {
		log.Printf("Slot may already exist: %v", err)
	}

	// 3. Start Replication Stream
	sysident, err := pglogrepl.IdentifySystem(ctx, conn)
	if err != nil {
		log.Fatalf("IdentifySystem failed: %v", err)
	}
	log.Printf("Postgres System ID: %s, Current XLogPos: %s", sysident.SystemID, sysident.XLogPos)

	err = pglogrepl.StartReplication(ctx, conn, SLOT, sysident.XLogPos, pglogrepl.StartReplicationOptions{
		PluginArgs: []string{"proto_version '1'", "publication_names 'cdc_publication'"},
	})
	if err != nil {
		log.Fatalf("StartReplication failed: %v", err)
	}

	log.Println("Listening for live PostgreSQL WAL changes...")

	// 4. Ingestion Loop
	standbyMessageTimeout := 10 * time.Second
	nextStandbyMessageDeadline := time.Now().Add(standbyMessageTimeout)

	for {
		if time.Now().After(nextStandbyMessageDeadline) {
			// Send Standby Status Update heartbeat to Postgres to acknowledge processed LSN
			err = pglogrepl.SendStandbyStatusUpdate(ctx, conn, pglogrepl.StandbyStatusUpdate{
				WALWritePosition: sysident.XLogPos,
			})
			if err != nil {
				log.Fatalf("SendStandbyStatusUpdate failed: %v", err)
			}
			nextStandbyMessageDeadline = time.Now().Add(standbyMessageTimeout)
		}

		ctxTimeout, cancel := context.WithDeadline(ctx, nextStandbyMessageDeadline)
		rawMsg, err := conn.ReceiveMessage(ctxTimeout)
		cancel()

		if err != nil {
			if pgconn.Timeout(err) {
				continue
			}
			log.Fatalf("ReceiveMessage failed: %v", err)
		}

		if msg, ok := rawMsg.(*pgproto3.CopyData); ok {
			switch msg.Data[0] {
			case pglogrepl.PrimaryKeepaliveMessageByte:
				pkm, _ := pglogrepl.ParsePrimaryKeepaliveMessage(msg.Data[1:])
				if pkm.ReplyRequested {
					nextStandbyMessageDeadline = time.Time{}
				}
			case pglogrepl.XLogDataByte:
				xld, _ := pglogrepl.ParseXLogData(msg.Data[1:])
				fmt.Printf("[CDC Event] LSN: %s | Bytes: %d\n", xld.WALStart, len(xld.WALData))
				// Forward decoded WAL event to Elasticsearch / Redis / Kafka!
			}
		}
	}
}

Summary & Engineering Checklist

  • Eliminate Dual-Writes: Never write to databases and message queues sequentially in user-space code. Standardize on CDC or the Transactional Outbox pattern.
  • Monitor Replication Slots Vigilantly: Implement Prometheus metrics on pg_replication_slots.wal_status and replication lag bytes to prevent disk exhaustion.
  • Decouple Schemas with the Outbox Pattern: Use dedicated outbox tables to publish curated domain event schemas rather than leaking raw database table structures to downstream consumers.
  • Ensure Idempotent Consumers: Because network reconnections can deliver duplicate change events during failovers (At-Least-Once delivery), all downstream consumers (Elasticsearch/Redis) must process events idempotently using natural database keys or Log Sequence Numbers (LSN).

Comments (0)

Leave a Reply ↓

No comments yet. Be the first to start the discussion!

Leave a Comment