bigywater.tech
分布式事务 · 12

Outbox 的发布:轮询与 CDC

Outbox 里的事件怎么送到消息队列?定时轮询表,还是读数据库的变更日志(CDC)?这篇用同一个 PostgreSQL 和 Kafka,把轮询的延迟与空闲负载、自增 id 顺序陷阱、多个 relay 的重复、CDC 的延迟顺序与 WAL 保留的代价逐一量出来。

7.0 ms vs 552.7 ms
轮询间隔 10 ms 与 1000 ms 时的延迟中位数(CDC 是 1.2 ms)
142 / 600
“记住最大 id”的游标轮询丢失的事件数(标志位轮询与 CDC 都是 0)
37.2 MiB
消费者停了、写 9 万个 200 字节事件之后,CDC 复制槽落后的 WAL 量

轮询的取舍

实测 · 本机执行输出
  发布方式           | 收到的事件 | 延迟中位数    | 延迟 p99   | 空闲时数据库每秒事务数(≈轮询次数)
  ---------------+-------+----------+----------+-------------------
  每 10 ms 轮询一次   | 150   | 7.0 ms   | 14.2 ms  | 82                
  每 100 ms 轮询一次  | 150   | 54.1 ms  | 104.2 ms | 10                
  每 1000 ms 轮询一次 | 150   | 552.7 ms | 994.9 ms | 1                 
  延迟 ≈ 事件等到下一次轮询的时间(均匀分布时约为间隔的一半)再加发布耗时;间隔越短,空闲时对数据库的查询越多

延迟约等于间隔的一半,间隔越短,数据库空闲时被问得越多。

自增 id 的陷阱

实测 · 本机执行输出
  轮询方式               | 应发出的事件 | 实际读到 | 丢失 
  -------------------+--------+------+----
  游标:id > 上次读到的最大 id | 600    | 458  | 142
  标志位:where not sent | 600    | 600  | 0  
  游标轮询只要恰好在“T2 提交了、T1 还没提交”的间隙里跑了一次(这里设成 50% 的概率),就会把游标推过 T1 的 id,T1 提交后永远读不到;标志位轮询不依赖 id 的顺序,没有丢失

id 顺序是拿到号的顺序,不是提交顺序。游标 id > last 会永久错过后提交的小 id;用 where not sent 的标志位就不会。

多个 relay

实测 · 本机执行输出
  配置                               | 发完 20000 个用时(3 轮中位数) | 事件/秒   | Kafka 里收到的条数(第 1 轮) | 重复的条数(第 1 轮)
  ---------------------------------+----------------------+--------+---------------------+-------------
  1 个 relay,不加锁                    | 0.19 s               | 104992 | 20000               | 0           
  2 个 relay,不加锁                    | 0.18 s               | 110972 | 37800               | 17800       
  4 个 relay,不加锁                    | 0.24 s               | 83992  | 58200               | 38200       
  2 个 relay,FOR UPDATE SKIP LOCKED | 0.13 s               | 148355 | 20000               | 0           
  4 个 relay,FOR UPDATE SKIP LOCKED | 0.16 s               | 122649 | 20000               | 0           
  不加锁时,多开 relay 不但没有变快,还带来大量重复;SKIP LOCKED 让每个 relay 只取没被别人锁住的行,没有重复

不加锁时多个 relay 读到同一批行,大量重复;FOR UPDATE SKIP LOCKED 让它们各取各的。

CDC

实测 · 本机执行输出
  发布方式           | 收到的事件 | 延迟中位数    | 延迟 p99   | 空闲时数据库每秒事务数
  ---------------+-------+----------+----------+------------
  每 10 ms 轮询一次   | 150   | 7.0 ms   | 14.2 ms  | 82         
  每 100 ms 轮询一次  | 150   | 54.1 ms  | 104.2 ms | 10         
  每 1000 ms 轮询一次 | 150   | 552.7 ms | 994.9 ms | 1          
  CDC(逻辑解码)      | 150   | 1.2 ms   | 3.7 ms   | 0          
  同样的 300 组“T1 先拿 id、T2 先提交”:应发 600 个事件,CDC 收到 600 个不同的(丢失 0),重复 0;其中 300 组里 Kafka 里的顺序是 T2 在 T1 前面 —— 事件按提交顺序出现,和 id 的大小无关

读 WAL 里已提交的变更:延迟毫秒级、空闲时没有轮询、按提交顺序不丢事件。

CDC 的代价

实测 · 本机执行输出
  刚建好的槽:active=False,落后当前 WAL 0 KiB
  消费者没在读,又写了 3 万个事件(共 30000 个,每个 payload 200 字节),做一次 checkpoint 后:active=False,槽落后 12.4 MiB,pg_wal 目录里 16 个文件
  消费者没在读,又写了 3 万个事件(共 60000 个,每个 payload 200 字节),做一次 checkpoint 后:active=False,槽落后 24.8 MiB,pg_wal 目录里 16 个文件
  消费者没在读,又写了 3 万个事件(共 90000 个,每个 payload 200 字节),做一次 checkpoint 后:active=False,槽落后 37.2 MiB,pg_wal 目录里 16 个文件
  消费者回来之后一口气追上 90000 个事件,用时 0.7 s(131130 事件/秒);刚追上就 checkpoint,槽还是落后 38045 KiB,pg_wal 里 16 个文件(槽的位置要等消费者向服务器汇报进度才会前进)
  消费者继续连着、过了 11 秒(pg_recvlogical 默认每 10 秒汇报一次进度)再 checkpoint:槽落后 0 KiB,pg_wal 里 16 个文件
  消费者长时间不回来,WAL 会一直堆到磁盘写满(PostgreSQL 13+ 可以用 max_slot_wal_keep_size 给槽设上限,超出后槽失效,这一项没有实验)

复制槽会为没读走的消费者一直保留 WAL;消费者停了,槽的落后量随写入线性增长,追上之后还要等它汇报进度才释放。

分步演示

Outbox 的发布
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇轮询和 CDC 用的是同一个 PostgreSQL 16.15(wal_level=logical)和同一张 outbox 表,Kafka 4.3.1 单节点;CDC 用 pg_recvlogical + test_decoding 插件,我写的程序解析输出再发到 Kafka;延迟用同一台机器上的时钟,从事务提交前一刻算到消费者收到;第二节里“轮询落在间隙里”的 50% 概率是我设的
    • 没有验证:MySQL binlog 作为 CDC 来源(本篇用的是 PostgreSQL);Debezium 等产品的实际表现;pgoutput 插件(这里用 test_decoding);max_slot_wal_keep_size 对槽的保护(只在文字里提到,没有实验);长时间运行、大事务、DDL 变更下 CDC 的行为;pg_wal 目录的文件数在实验里保持 16 个没有变化,没有对此做进一步解释
    京ICP备2026067502号-1京公网安备11010502063487号