Change Data Capture với Debezium và Kafka
1. Tổng quan
Khi một service cần biết dữ liệu ở database khác vừa thay đổi, cách thường thấy là ghi vào database rồi gửi thêm một message lên Kafka. Cách này gọi là dual-write và dễ gây lệch dữ liệu. Change Data Capture (CDC) giải quyết vấn đề này bằng cách đọc thẳng transaction log của database.
Trong bài này, bạn sẽ dựng PostgreSQL, Kafka và Debezium bằng Docker Compose, rồi đăng ký một connector. Sau đó bạn viết một consumer Spring Boot để xử lý các sự kiện INSERT, UPDATE và DELETE của bảng orders.
2. Vì sao cần CDC
Hãy xem đoạn code dual-write quen thuộc sau:
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
kafkaTemplate.send("orders", order.getId().toString(), toJson(order));
}
Đoạn code này có hai điểm yếu:
- Nếu Kafka lỗi sau khi
savethành công, database có đơn hàng nhưng không có event nào được gửi đi. - Nếu
sendthành công nhưng transaction rollback, consumer nhận được event của một đơn hàng không tồn tại.
CDC đảo ngược luồng này. Ứng dụng chỉ ghi vào database. Một tiến trình riêng đọc transaction log (WAL với PostgreSQL, binlog với MySQL) và phát event cho mỗi thay đổi đã commit. Vì log chỉ chứa thay đổi đã commit, event luôn khớp với dữ liệu thật.
Debezium là một bộ connector CDC chạy trên Kafka Connect. Luồng dữ liệu như sau:
Ứng dụng ──ghi──> PostgreSQL ──WAL──> Debezium (Kafka Connect) ──> Kafka topic ──> Consumer
3. Dựng môi trường
3.1. Docker Compose
Bạn cần ba container: PostgreSQL bật logical replication, Kafka chạy chế độ KRaft và Kafka Connect có sẵn plugin Debezium.
services:
postgres:
image: postgres:16
command: ["postgres", "-c", "wal_level=logical"]
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: shop
ports:
- "5432:5432"
kafka:
image: apache/kafka:3.8.0
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:29092,CONTROLLER://:9093,EXTERNAL://:9092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,EXTERNAL://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
connect:
image: quay.io/debezium/connect:2.7
depends_on: [kafka, postgres]
ports:
- "8083:8083"
environment:
BOOTSTRAP_SERVERS: kafka:29092
GROUP_ID: 1
CONFIG_STORAGE_TOPIC: connect_configs
OFFSET_STORAGE_TOPIC: connect_offsets
STATUS_STORAGE_TOPIC: connect_statuses
Có ba điểm cần chú ý trong file này:
wal_level=logicallà bắt buộc. Thiếu tham số này, Debezium không tạo được replication slot.- Kafka có hai listener. Container dùng
kafka:29092, còn ứng dụng Spring Boot chạy trên máy bạn dùnglocalhost:9092. - Kafka Connect lưu cấu hình, offset và trạng thái vào ba topic riêng. Nhờ đó connector restart xong sẽ đọc tiếp từ vị trí cũ trong WAL.
Khởi động và tạo bảng:
docker compose up -d
docker compose exec postgres psql -U postgres -d shop -c "
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_email VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL,
total NUMERIC(10,2) NOT NULL
);
ALTER TABLE orders REPLICA IDENTITY FULL;"
REPLICA IDENTITY FULL yêu cầu PostgreSQL ghi toàn bộ dòng cũ vào WAL. Nếu không có lệnh này, trường before trong event UPDATE sẽ là null, còn event DELETE chỉ chứa khóa chính.
3.2. Đăng ký connector
Kafka Connect cung cấp REST API ở cổng 8083. Bạn đăng ký connector PostgreSQL như sau:
curl -X POST -H "Content-Type: application/json" localhost:8083/connectors -d '{
"name": "shop-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"plugin.name": "pgoutput",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "postgres",
"database.password": "postgres",
"database.dbname": "shop",
"topic.prefix": "shop",
"table.include.list": "public.orders",
"slot.name": "shop_slot",
"publication.autocreate.mode": "filtered",
"decimal.handling.mode": "string",
"key.converter": "org.apache.kafka.connect.json.JsonConverter",
"key.converter.schemas.enable": "false",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter.schemas.enable": "false"
}
}'
Ý nghĩa các thuộc tính quan trọng:
plugin.name: pgoutputdùng plugin decoding có sẵn của PostgreSQL 10+, nên bạn không phải cài thêm extension.topic.prefixcùng schema và tên bảng tạo ra tên topic. Ở đây topic làshop.public.orders.table.include.listgiới hạn những bảng được capture.decimal.handling.mode: stringkhiến cộtNUMERICđược gửi dưới dạng chuỗi"149.90". Mặc định Debezium mã hóa giá trị này thành bytes base64, rất khó đọc.schemas.enable: falsebỏ phầnschemađi kèm mỗi message JSON, nên payload gọn hơn.
Kiểm tra trạng thái connector:
curl -s localhost:8083/connectors/shop-connector/status
{"name":"shop-connector","connector":{"state":"RUNNING","worker_id":"172.18.0.4:8083"},
"tasks":[{"id":0,"state":"RUNNING","worker_id":"172.18.0.4:8083"}],"type":"source"}
4. Cấu trúc change event
Thêm rồi cập nhật một đơn hàng:
docker compose exec postgres psql -U postgres -d shop -c "
INSERT INTO orders (customer_email, status, total) VALUES ('an@example.com', 'NEW', 149.90);
UPDATE orders SET status = 'PAID' WHERE id = 1;"
Sau đó đọc topic bằng console consumer có sẵn trong image Kafka:
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic shop.public.orders --from-beginning
Event UPDATE trông như sau (đã rút gọn trường source):
{
"before": {"id":1,"customer_email":"an@example.com","status":"NEW","total":"149.90"},
"after": {"id":1,"customer_email":"an@example.com","status":"PAID","total":"149.90"},
"source": {"connector":"postgresql","db":"shop","table":"orders","lsn":24023128, ...},
"op": "u",
"ts_ms": 1760170000123
}
Trường op cho biết loại thay đổi:
op |
Ý nghĩa | before |
after |
|---|---|---|---|
c |
INSERT | null |
dòng mới |
u |
UPDATE | dòng cũ | dòng mới |
d |
DELETE | dòng cũ | null |
r |
Đọc trong snapshot ban đầu | null |
dòng hiện có |
Khi xóa một dòng, Debezium gửi thêm một tombstone, tức message có cùng key nhưng value là null. Tombstone cho phép log compaction của Kafka dọn hẳn key đó. Consumer của bạn phải xử lý được value null.
5. Tiêu thụ event bằng Spring Boot
5.1. Dependency và cấu hình
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Phiên bản của các dependency trên do spring-boot-starter-parent 3.x quản lý.
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: order-sync
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
Ở đây consumer nhận value dạng String rồi tự parse. Cách này giúp bạn kiểm soát tombstone và lỗi JSON dễ hơn so với dùng JsonDeserializer.
5.2. Model và parser
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderRow(
Long id,
@JsonProperty("customer_email") String customerEmail,
String status,
BigDecimal total) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderChangeEvent(
OrderRow before,
OrderRow after,
String op,
@JsonProperty("ts_ms") Long tsMs) {
}
@JsonIgnoreProperties(ignoreUnknown = true) bỏ qua trường source và những trường mới Debezium có thể thêm sau này. Jackson tự chuyển chuỗi "149.90" sang BigDecimal, nên bạn không bị mất độ chính xác như khi dùng double.
@Component
public class OrderChangeParser {
private final ObjectMapper objectMapper;
public OrderChangeParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public Optional<OrderChangeEvent> parse(String value) {
if (value == null) {
return Optional.empty(); // tombstone
}
try {
return Optional.of(objectMapper.readValue(value, OrderChangeEvent.class));
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Invalid Debezium event", e);
}
}
}
Parser trả Optional.empty() cho tombstone, nên listener chỉ cần bỏ qua trường hợp này.
5.3. Listener
@Component
public class OrderCdcListener {
private static final Logger log = LoggerFactory.getLogger(OrderCdcListener.class);
private final OrderChangeParser parser;
public OrderCdcListener(OrderChangeParser parser) {
this.parser = parser;
}
@KafkaListener(topics = "shop.public.orders")
public void onOrderChange(@Payload(required = false) String value) {
parser.parse(value).ifPresent(event -> {
switch (event.op()) {
case "c", "r" -> log.info("Order created: {}", event.after());
case "u" -> log.info("Order {} status {} -> {}",
event.after().id(), event.before().status(), event.after().status());
case "d" -> log.info("Order deleted: {}", event.before().id());
default -> log.warn("Unknown op: {}", event.op());
}
});
}
}
@Payload(required = false) cho phép Spring truyền null vào method khi gặp tombstone. Nếu thiếu annotation này, Spring sẽ báo lỗi conversion. Nhánh "r" được gộp với "c" vì với consumer, một dòng có sẵn trong snapshot cũng giống một dòng vừa được tạo.
Chạy ứng dụng với dữ liệu ở mục 4, bạn sẽ thấy:
INFO OrderCdcListener : Order created: OrderRow[id=1, customerEmail=an@example.com, status=NEW, total=149.90]
INFO OrderCdcListener : Order 1 status NEW -> PAID
6. Kiểm thử parser
Logic parse là phần dễ hỏng nhất khi Debezium hoặc schema thay đổi. Bạn có thể test phần này mà không cần Kafka:
class OrderChangeParserTest {
private final OrderChangeParser parser = new OrderChangeParser(new ObjectMapper());
@Test
void parsesUpdateEvent() {
String json = """
{"before":{"id":1,"customer_email":"an@example.com","status":"NEW","total":"149.90"},
"after":{"id":1,"customer_email":"an@example.com","status":"PAID","total":"149.90"},
"source":{"connector":"postgresql"},
"op":"u","ts_ms":1760170000123}
""";
OrderChangeEvent event = parser.parse(json).orElseThrow();
assertThat(event.op()).isEqualTo("u");
assertThat(event.before().status()).isEqualTo("NEW");
assertThat(event.after().status()).isEqualTo("PAID");
assertThat(event.after().total()).isEqualByComparingTo("149.90");
}
@Test
void returnsEmptyForTombstone() {
assertThat(parser.parse(null)).isEmpty();
}
@Test
void rejectsMalformedJson() {
assertThatThrownBy(() -> parser.parse("{not json"))
.isInstanceOf(IllegalArgumentException.class);
}
}
Test đầu kiểm tra mapping tên cột customer_email và kiểu BigDecimal. Test thứ hai đảm bảo tombstone không làm listener lỗi. Test thứ ba xác nhận JSON hỏng được báo lỗi rõ ràng thay vì bị nuốt im lặng.
7. Lỗi hay gặp và khi nào không nên dùng
Lưu ý: Replication slot giữ WAL cho đến khi Debezium xác nhận đã đọc. Nếu connector dừng nhiều ngày, WAL sẽ phình to và có thể làm đầy ổ đĩa của database. Bạn cần giám sát
pg_replication_slotsvà xóa slot khi gỡ connector:SELECT pg_drop_replication_slot('shop_slot');.
Một số lỗi khác bạn sẽ hay gặp:
- Consumer không idempotent. Debezium và Kafka Connect đảm bảo at-least-once. Sau khi restart, cùng một event có thể đến hai lần. Hãy dùng
idcùng vớisource.lsnđể bỏ qua bản trùng, hoặc thiết kế thao tác dạng upsert. - Giá trị
NUMERICthành chuỗi base64 khó hiểu. Bạn quên cấu hìnhdecimal.handling.mode. Tương tự, cộtTIMESTAMPmặc định được gửi dưới dạng số microsecond kể từ epoch, nên cần tự chuyển đổi. beforeluônnull. Bảng chưa đặtREPLICA IDENTITY FULL.- Snapshot ban đầu quá lâu. Với bảng lớn,
snapshot.mode=initialsẽ đọc toàn bộ bảng trước khi stream. Hãy cân nhắcno_datanếu bạn chỉ cần thay đổi từ bây giờ, hoặc dùng incremental snapshot. - Rò rỉ dữ liệu nội bộ. Event phản ánh đúng cấu trúc bảng. Nếu đưa thẳng ra cho service khác, bạn đang công khai schema database. Với event nghiệp vụ, hãy kết hợp Debezium với Outbox pattern: ứng dụng ghi vào bảng
outboxtrong cùng transaction, và Debezium chỉ capture bảng đó. - Dùng tài khoản
postgrestrong production. Hãy tạo user riêng có quyềnREPLICATIONvà quyềnSELECTtrên các bảng cần capture.
Bạn không nên dùng CDC khi:
- Chỉ có một ứng dụng và nó tự phát event được trong cùng process. Khi đó transactional outbox với một poller đơn giản là đủ.
- Bạn cần event mang ý nghĩa nghiệp vụ như “OrderPaid” thay vì “dòng X đổi cột status”. CDC thô chỉ biết dữ liệu thay đổi, không biết lý do.
- Đội vận hành chưa sẵn sàng quản lý Kafka Connect, replication slot và việc tiến hóa schema.
8. Kết luận
Trong bài này, bạn đã dựng PostgreSQL với logical replication, Kafka và Debezium, rồi đăng ký connector capture bảng orders. Bạn cũng đã tìm hiểu cấu trúc change event với các trường before, after và op, sau đó viết consumer Spring Boot xử lý cả tombstone, kèm test cho phần parse.
CDC loại bỏ rủi ro dual-write vì database trở thành nguồn sự thật duy nhất. Đổi lại, bạn phải giám sát replication slot, viết consumer idempotent và cân nhắc dùng Outbox pattern khi cần event nghiệp vụ rõ ràng.