Phần trước đo JDBC source và tìm ra nó mù hoàn toàn với UPDATE và DELETE. Bài này dựng CDC cho cùng bảng đó, đo lại, và tìm ra cái giá phải trả — nó nằm ở phía cơ sở dữ liệu.

Độ trễ CDC, hình dạng sự kiện, và cái giá ở khe sao chép

Đọc từ WAL thay vì đọc bảng

Khác biệt cốt lõi: JDBC source hỏi bảng "có gì mới không". Debezium đọc nhật ký ghi trước của PostgreSQL — cùng dòng dữ liệu mà bản sao dùng để đồng bộ.

WAL chứa mọi thay đổi theo đúng thứ tự, kể cả DELETE. Đó là toàn bộ lý do CDC làm được thứ JDBC source không làm được.

{"connector.class":"io.debezium.connector.postgresql.PostgresConnector",
 "database.hostname":"pg","database.dbname":"shop",
 "topic.prefix":"cdc","table.include.list":"public.don_hang",
 "plugin.name":"pgoutput","slot.name":"dbz_slot","publication.name":"dbz_pub",
 "snapshot.mode":"initial"}

Phía PostgreSQL cần ba thứ:

wal_level = logical
max_replication_slots >= 1
alter table don_hang replica identity full;    -- nếu muốn "before" đầy đủ

replica identity full là chỗ hay bị bỏ qua. Không có nó, before chỉ chứa khoá chính — và bạn mất khả năng biết giá trị cũ là gì.

Số đo

snapshot 140.003 dòng 9,1 giây
độ trễ UPDATE 614, 421, 741, 540, 839 ms
độ trễ DELETE 596, 361, 668 ms

Cả UPDATE lẫn DELETE đều tới. Phần 29 đo cùng thao tác qua JDBC source và nhận 0 bản ghi.

Con số độ trễ có cộng thêm thời gian chạy psql qua docker exec, khoảng 200–300 ms. Độ trễ thật của Debezium thấp hơn, và phần lớn phần còn lại là poll.interval.ms mặc định 500 ms.

snapshot.mode=initial chụp toàn bộ bảng khi khởi động lần đầu, rồi chuyển sang đọc WAL. 140.003 dòng trong 9,1 giây. Chú ý: offset topic sau snapshot là 190.003 chứ không phải 140.003 — vì Debezium ghi cả những thay đổi xảy ra trong lúc snapshot đang chạy.

Hình dạng một sự kiện

op     = u                    c=chèn  u=sửa  d=xoá  r=snapshot
before = {id: 96001, trang_thai: PENDING}
after  = {id: 96001, trang_thai: Z1}
source = {lsn: 77378000, txId: 770, table: don_hang, snapshot: false}
ts_ms  = 1788030721068

beforeafter cùng có mặt — đó là thứ JDBC source không bao giờ cho được. Bạn biết giá trị cũ, biết giá trị mới, và biết đây là sửa chứ không phải chèn.

txId cho phép ghép lại các thay đổi thuộc cùng một giao dịch — quan trọng khi một giao dịch nghiệp vụ chạm nhiều bảng và hạ nguồn cần thấy chúng như một đơn vị.

lsn là vị trí trong WAL, tăng đơn điệu và là thứ dùng để sắp xếp lại nếu cần.

Với op=d, afternull và Debezium còn gửi thêm một bia mộ (khoá có, giá trị null) — đúng dạng phần 18 mô tả, để topic nén xoá được khoá đó.

Cái giá: khe sao chép giữ WAL lại

Đây là phát hiện quan trọng nhất, và nó không nằm ở Kafka.

WAL giữ lại
lúc bình thường 12 kB
tạm dừng connector, chèn 300.000 dòng 50 MB
sau khi chạy lại và bắt kịp 50 MB — chưa nhả

PostgreSQL không xoá WAL mà khe sao chép chưa xác nhận. Đó là cơ chế đúng — nếu nó xoá, Debezium khởi động lại sẽ mất dữ liệu. Nhưng hệ quả là: connector dừng lâu nghĩa là WAL tích lại cho tới khi đầy đĩa máy chủ cơ sở dữ liệu.

Tôi chỉ tạm dừng 8 giây và chèn 300.000 dòng. Trên hệ thống thật, một connector chết qua đêm với lưu lượng bình thường có thể tích hàng chục gigabyte — và khi PostgreSQL hết đĩa, nó dừng nhận ghi. Sự cố lan từ đường ống phân tích sang chính hệ thống giao dịch.

Và chú ý dòng thứ ba: nó không nhả ngay cả khi đã bắt kịp. restart_lsn chỉ tiến khi Connect ghi offset xuống, theo chu kỳ offset.flush.interval.ms (mặc định 60.000). Nên sau khi chữa sự cố, đĩa vẫn chưa được trả lại ngay.

Ba việc bắt buộc phải làm:

Theo dõi kích thước khe sao chép, và cảnh báo sớm.

select slot_name, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as giu_lai
from pg_replication_slots;

Cảnh báo ở vài gigabyte, đừng đợi tới lúc đĩa đầy.

Đặt max_slot_wal_keep_size (PostgreSQL 13 trở lên). Nó cho phép PostgreSQL vô hiệu hoá khe khi vượt ngưỡng, thay vì để đầy đĩa. Đổi lại, khe bị vô hiệu hoá thì Debezium phải chụp snapshot lại từ đầu.

Đây là một lựa chọn thật giữa hai điều khó chịu: mất đường ống và phải snapshot lại, hay mất cơ sở dữ liệu. Lựa chọn đầu tiên gần như luôn đúng.

Xoá khe khi gỡ connector. Xoá connector trong Connect không xoá khe trong PostgreSQL. Khe mồ côi sẽ giữ WAL mãi mãi và không ai biết vì sao đĩa cứ đầy dần.

select pg_drop_replication_slot('dbz_slot');

Khi nào dùng CDC, khi nào không

Dùng CDC khi:

  • Bảng nguồn có UPDATE và DELETE — tức gần như mọi bảng nghiệp vụ.
  • Bạn cần giá trị cũ, không chỉ giá trị mới.
  • Không được đụng vào lược đồ hay mã của hệ thống nguồn.
  • Cần thứ tự đúng theo giao dịch.

Không dùng CDC khi:

  • Bảng chỉ có INSERT — JDBC source đơn giản hơn nhiều và không đụng vào WAL.
  • Ứng dụng nguồn có thể tự phát sự kiện. Mẫu "hộp thư đi" (ghi sự kiện vào một bảng trong cùng giao dịch với dữ liệu, rồi CDC đọc bảng đó) cho sự kiện có ý nghĩa nghiệp vụ thay vì thay đổi cấp dòng — và đó thường là thứ hạ nguồn thật sự cần.

Điểm cuối đáng nhấn: CDC phát ra thay đổi bảng, không phát ra sự kiện nghiệp vụ. trang_thai đổi từ PENDING sang DA_HUY là một thay đổi bảng; "đơn hàng bị huỷ vì hết hàng" là một sự kiện nghiệp vụ. Hạ nguồn phải tự suy ra cái thứ hai từ cái thứ nhất, và mỗi lần lược đồ nguồn đổi thì suy luận đó có thể sai.

Đó là lý do CDC rất tốt cho sao chép dữ liệu và kém hơn cho tích hợp giữa các dịch vụ.

Thử ba mươi giây

Kiểm khe sao chép trên cơ sở dữ liệu của bạn:

select slot_name, plugin, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as giu_lai
from pg_replication_slots
order by pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) desc;

Khe nào có active = fgiu_lai lớn là một quả bom hẹn giờ: không ai đang đọc nó, và nó vẫn đang chặn PostgreSQL dọn WAL.

Phần sau đo giám sát Kafka: những chỉ số nào thật sự nói lên điều gì.