Outbox 里的事件怎么送到消息队列?定时轮询表,还是读数据库的变更日志(CDC)?这篇用同一个 PostgreSQL 和 Kafka,把轮询的延迟与空闲负载、自增 id 顺序陷阱、多个 relay 的重复、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 | 600 | 458 | 142 标志位:where not sent | 600 | 600 | 0 游标轮询只要恰好在“T2 提交了、T1 还没提交”的间隙里跑了一次(这里设成 50% 的概率),就会把游标推过 T1 的 id,T1 提交后永远读不到;标志位轮询不依赖 id 的顺序,没有丢失
id 顺序是拿到号的顺序,不是提交顺序。游标 id > last 会永久错过后提交的小 id;用 where not sent 的标志位就不会。
配置 | 发完 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 让它们各取各的。
发布方式 | 收到的事件 | 延迟中位数 | 延迟 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 里已提交的变更:延迟毫秒级、空闲时没有轮询、按提交顺序不丢事件。
刚建好的槽: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;消费者停了,槽的落后量随写入线性增长,追上之后还要等它汇报进度才释放。