Hình dung một cuốn nhật ký chung mà thư viện không được xé trang cũ cho tới khi người đọc chậm nhất xác nhận đã đọc xong. Ý tưởng thì tốt: không ai bị mất trang. Nhưng nếu một người đọc bỏ đi mà không trả lời, cuốn nhật ký cứ dày lên mãi — cho tới khi hết chỗ trong phòng. CDC với Debezium đứng đúng ở chỗ đó: nó đảm bảo không mất dữ liệu bằng cách ghim nhật ký của PostgreSQL lại, và cái giá của lời đảm bảo ấy là một thứ tôi không ngờ tới tận khi đo.

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.

Đọ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

before và after 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, after là null 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ụ.

Cùng một cái bẫy "đảm bảo không mất dữ liệu ghép số phận ngược chiều", đổi ngăn xếp

Phát hiện đáng nhớ nhất ở đây — một consumer chết lại có thể giết chính cơ sở dữ liệu nó đang đọc — không phải đặc sản Debezium. Nó là hệ quả tất yếu của mọi cơ chế "không mất dữ liệu" dựa trên ghim nguồn lại cho tới khi bên nhận xác nhận.

Ngay trong chính PostgreSQL, cùng cơ chế gây ra cái bẫy autovacuum kinh điển: một giao dịch mở rất lâu (hay một standby tụt lại, hay một replication slot như ở đây) giữ lại "xmin horizon", và vacuum không dọn được phiên bản dòng cũ của cả cơ sở dữ liệu — bảng phình, truy vấn chậm dần, chỉ vì một kẻ chậm chân ghim toàn bộ việc dọn dẹp lại. Đúng một hình dạng với khe sao chép giữ WAL.

Ở tầng mạng, bộ đệm gửi TCP không được giải phóng cho tới khi bên nhận ACK: một receiver chết làm bộ đệm của sender đầy lên rồi tạo backpressure ngược về ứng dụng gửi. Và mọi hàng đợi bền "giữ tới khi ack" đều có cùng rủi ro: một consumer không bao giờ ack biến bộ đệm giữ-lại thành một chỗ rò không đáy.

Mẫu số chung: một cơ chế đảm bảo "không mất dữ liệu" cho bên đọc bằng cách ghim nhật ký của nguồn lại cho tới khi bên đọc xác nhận cũng đồng thời ghép số phận hai bên theo chiều ngược — một bên đọc treo hoặc chết khiến nguồn không thu hồi được chỗ, nên một sự cố hạ nguồn leo ngược lên chính hệ thống gốc mà nó đáng lẽ chỉ quan sát thụ động; và việc nhả chỗ thường trễ, vì xác nhận diễn ra theo chu kỳ chứ không tức thì. Nên câu hỏi đúng khi cắm bất kỳ thứ gì vào một nguồn theo kiểu này không phải "nó đọc có đúng không" mà là "cơ chế không-mất-dữ-liệu này có ghim tài nguyên của nguồn lại cho tới khi tôi xác nhận không — nếu có, thì một consumer chết của tôi có thể giết chính cái nó đang đọc, và tôi đã giám sát cùng đặt trần cho phần bị ghim đó chưa".

Muốn tự kiểm khe sao chép trên cơ sở dữ liệu của bạn, chạy thử:

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 = f mà giu_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ì.