PostgreSQL CDC 实践:统一分析库、断点恢复与 DTS 取舍
业务拆成多个服务之后,数据也分散到了不同的数据库中。项目、内容、推荐和通知各自维护自己的表,但运营分析往往需要把这些数据放在一起:按天看项目生成情况,关联内容的发布与曝光,再按不同维度计算中间结果。
我们为此建设了独立的分析库,并把生产 PostgreSQL 到分析库的同步交给 GamePlatform 管理。底层使用 Debezium 读取数据库变更,平台负责接入范围、表映射、运行调度和故障恢复。本文介绍这条链路如何工作、上线后遇到的具体问题,以及我们为什么在已有阿里云 DTS 的情况下继续建设它。
以下实现与运行记录核对至 2026 年 10 月 14 日 14:36(北京时间);文中的示例表名、主键和执行代次均为示意。
1. 统一分析库要解决什么问题
最初讨论这个需求时,我们希望把生产数据库的数据集中到一个独立的 AnalyticDB PostgreSQL 实例,以下简称 ADB。在原始副本之上,按小时或按天计算中间表,最后由 Grafana、运营后台查询这些结果。
这里有两层不同的工作。第一层是让分析侧持续获得业务数据,第二层是定义指标、执行聚合并产出报表。CDC,也就是 Change Data Capture,负责捕获数据库里的变化,主要解决第一层问题。把表同步过去,并不会自动得到留存率、转化率或推荐效果,这些仍然依赖后续的数据模型和计算任务。
独立分析库让跨服务查询有了统一的位置,也把长时间扫描、复杂关联和报表计算移出了业务数据库。业务库依然会承担首次补数读取、逻辑解码和日志保留的成本,因此这不是一条没有源端开销的链路。收益在于,后续每一种分析需求都可以复用同一份副本,不必再向各个在线服务增加一套查询接口。
我们最初考虑通过平台自动创建 DTS 任务。随着纳管范围从少数数据库扩大到多个实例、二十多个逻辑库,逐库维护任务以及对应的资源成本,开始成为需要单独处理的问题。我们最终保留已有 DTS,同时为新的统一分析库建设共享的 CDC 执行层。
这个决定包含两个目标:让多库同步复用计算资源;让数据库的发现、接入、变更和排障,都能使用研发平台已有的目录与操作记录。是否比 DTS 更便宜,还需要把开发、维护和故障处理一起算进去,后文会展开。
2. 三部分如何配合
整条链路由源数据库、同步进程和目标数据库组成,GamePlatform 在外层管理它们之间的关系。
2.1. 源数据库保存业务数据和 WAL
PostgreSQL 在写入数据时生成 WAL,即预写日志。逻辑解码可以从日志中提取行的插入、更新和删除。我们使用 PostgreSQL 的 pgoutput 插件,并为每个逻辑数据库建立独立的 publication 和 replication slot。
publication 描述需要发布哪些表的变化;replication slot,也就是复制槽,跟踪消费者需要的日志位置。一个物理 RDS 实例可以有多个逻辑数据库,而复制槽属于具体数据库。因此,平台界面可以按实例管理接入,底层仍然需要按库组织复制进程。PostgreSQL 的逻辑解码说明介绍了复制槽的这一边界。
2.2. 同步进程负责读取、转换和写入
每个数据库对应一个 reader。reader 使用独立的 Java 进程运行 Debezium Server,通过 JDBC 把数据写入 ADB。多个 reader 共享 GamePlatform 的 analytics 容器,由 TypeScript 编写的管理逻辑负责启动、停止、续租和重试。
我们没有重新实现 PostgreSQL 日志解析,也没有在这条链路中部署 Kafka。Debezium 负责快照与变更捕获,平台增加表名映射、目标写入隔离、断点校验和扫描失败保护。这一分工使平台代码主要围绕“哪些数据可以同步、谁能写、失败后如何继续”展开。
不经过独立消息队列也有直接后果:ADB 写入变慢时,变更无法无限堆在一个中间系统里等待。尚未安全消费的日志仍依赖源数据库保留,源端 WAL 容量因此成为运行约束。
2.3. 目标数据库保存副本和恢复信息
业务副本统一写入 ADB 的 game_analytics.public。目标表名由来源实例、数据库、schema 和表名生成,以避免多个数据库中的同名表互相覆盖。下面是命名示意,名称不对应实际业务表:
prod_a / project / public.projects
→ prod_a_project_projects
prod_b / project / public.projects
→ prod_b_project_projects
prod_a / project / archive.projects
→ prod_a_project_archive_projects
对于非 public schema,名称中额外保留 schema。超过标识符长度限制时,保留可读前缀并追加稳定摘要。完整映射另行持久保存,实例改名不会自动改掉已经使用的目标表名。发现命名碰撞,或者目标库已有同名表却没有对应归属记录时,平台会停止接入。
除了业务副本,ADB 的独立元数据 schema 还保存原生 offset、快照完成状态和 writer epoch。offset 是引擎的恢复位置;epoch 是递增的执行代次,用来拒绝旧进程继续写入。平台自身的控制账本则保存接入意图、执行计划、租约和恢复操作。两边的记录用途不同,不能只凭平台显示 running 就判断目标数据已经完整。
3. 全量数据与增量变化如何衔接
3.1. 先判断一张表适合哪种方式
平台先读取源库结构,检查读取权限、主键、字段类型、复制标识和 publication 成员,再生成执行计划。当前实现把表分成四类:
| 类型 | 当前处理方式 |
|---|---|
| 满足条件的普通主键表 | 分块补齐存量,并持续消费 WAL 变更 |
| 无主键、含 vector 或生成列的表 | 每小时执行有界完整快照 |
| 系统表、平台内部表、同步设施表 | 按明确规则排除 |
| 权限不足、类型未支持或结构不符合要求的表 | 保留阻塞原因,停止相应 reader 接入 |
小时快照中,vector 按文本表示保存,生成列复制源端已经计算出的值。执行器在源端开启只读的可重复读事务,在目标端用一个事务替换本轮表集合;某张表读取失败或超过限额时,目标事务回滚,查询者继续看到上一份已提交快照。
当前这条备用路径按每个 reader 的整轮任务限制为 10 万行、64 MiB 和 60 秒。它适合已验证规模内的小表,也意味着这些表只有小时级刷新能力。不能因为它们被纳入“CDC 项目”,就把所有表都描述为实时同步。
3.2. 用主键分块建立基线
最早的实现使用初始全量快照,未完成时遇到进程重启,往往需要重新开始。平台持续发布,而大表可能需要很长时间才能复制完成,两者叠加后,已有进度会反复丢失。
后来我们把新建和受控重建的 reader 改为 Debezium 的只读增量快照:先建立 WAL 消费边界,再按主键区间分块读取存量数据。这里的“增量快照”是分块补数机制,仍然可以用来复制整张表的已有数据;它与持续消费 WAL 的“增量同步”是两个概念。
假设有一张示意表 projects(id, title, status),我们正在读取包含 id=42 的分块。读取时,这行的状态是 draft;分块处理期间,业务把它更新为 published,WAL 中也出现了相应更新。快照记录与日志变化必须协调,否则较旧的快照值可能覆盖较新的状态。
Debezium 在快照窗口中比较主键,去掉已经被对应日志事件取代的旧快照记录,同时继续处理实时变化。分块状态可以进入恢复断点,使中断后的任务从已经提交的位置继续。窗口与去重由引擎实现,平台不自行拼接两路无序的数据流。Debezium 增量快照文档说明了这一机制。
普通记录库当前采用 512 行分块,大消息库保持 64 行分块。行数需要结合单行大小选择:几千万条短记录和包含几十 MB 内容的记录,并不适合相同参数。
3.3. 写入与断点之间允许重放
目标写入使用主键 upsert,并处理删除。upsert 的含义是:目标已有这个主键时更新它,否则插入。假设 id=42 的更新已经提交到 ADB,但 reader 在保存下一次 offset 前退出,恢复时就可能再次收到这条更新。
再次 upsert 相同主键,可以把目标恢复到同一行状态;重复删除也不应生成额外业务动作。因此,我们按“至少一次传递,加幂等写入”理解这条链路。JDBC sink 的幂等写入能力也依赖合适的主键和 upsert 配置,不能由它推导出跨数据库事务或端到端 exactly-once 保证。Debezium JDBC sink 文档对此作了区分。
平台增加的约束是:每次原生 offset 提交都在事务内校验当前 epoch,快照完成标记与 offset 一起提交。扫描发生失败或跳过表时,保护逻辑拒绝继续提交检查点,避免把不完整副本报告为完成。
“完成”的判定本身也需要验证。10 月 14 日的专项排查发现,快速完成的空表快照可能在两次 offset flush 之间结束,中间快照状态没有被持久化,旧判定就可能一直等不到完成证明。这是已经找到代码及隔离实验依据的边界问题;同次生产异常的最初触发原因仍有未确认部分。把完成标记写入事务,只解决记录的原子性,还需要证明这个标记确实对应所要求的数据集合。
4. 重启之后,谁有权继续写
平台有多个副本,网络断开或进程暂停时,旧执行器可能尚未退出,新执行器已经尝试接管。只在控制账本中设置“任务被谁领取”,不足以阻止旧进程向 ADB 写数据。
我们把执行权分成几层约束。平台用有期限的租约分配 reader;原生 sink 在实际目标连接上持有该 reader 的 advisory lock,排除同时存在的写入连接;持久化 epoch 再排除失联后重新连接的旧进程。
例如旧进程属于 epoch 7,新进程接管后推进到 epoch 8。旧连接仍在时,新进程不能取得同一个 reader 的写锁;旧连接消失后,新进程才可以接管。即使 epoch 7 的进程稍后恢复网络,也会在重新连接或提交断点时被拒绝。
这些保护还依赖正确的排空顺序:先使旧计划失效,等待旧写入者停止,再准备目标并启动新一代。平台对 reader 的长期运行采用有限重试与退避,预算不足时等待资源,不把等待本身计为一次运行失败。
另一条恢复边界在源端。复制槽要求 PostgreSQL 保留后续仍需消费的 WAL,消费者停滞会扩大保留区间。同一物理实例其他数据库的写入,也会增加这段 WAL 的体积。若日志已经被回收、复制槽失效,或者目标断点早于源端可恢复范围,就不能简单从最新位置继续并声称没有漏数。
这时需要停止普通重试,根据来源、目标归属和连续性证据选择受控重建或定向对账补数。重建有自己的操作编号和范围检查;有效断点、其他 reader 的数据和历史保留表,不应被一次通用“重试”顺手清掉。
5. 表结构变化也是同步流程的一部分
业务持续新增表和修改字段,同步计划也必须跟着变化。我们的实现读取数据库目录,比较新旧计划,再决定是否可以接续;它没有把 PostgreSQL 所有 DDL 原样转发给 ADB。
计划包含来源身份、表映射、主键、列类型和同步方式。行数估计变化、实例展示名称变化、被排除的内部表变化,不应使同一份有效执行计划失效。真正改变数据含义的差异,则需要分类处理。
| 变化 | 当前处理原则 |
|---|---|
| 兼容的追加列 | 排空旧 writer,只修改受影响目标表,保留原断点 |
| 新增符合条件的主键表 | 只为新增表安排分块快照,保留其他表及 WAL 位置 |
| 源表退役 | 在通过相应检查的路径下保留历史目标副本与归属 |
| 改主键、删列、不兼容类型或映射变化 | 保留已有数据,阻塞并等待复核 |
| 源槽丢失 | 在明确允许的范围内,进入受控恢复流程 |
追加列也不等于任何 ALTER TABLE 都能自动执行。例如新列要求非空,却没有可移植的常量默认值,平台不能凭空为历史行生成合法值。对于允许接续的非空新列,分析目标还需要容纳加列前保留的 WAL 记录,因此不会机械照搬所有源约束。
新增表的另一个问题在源端。表存在,不代表复制账号能够读取它,也不代表它已加入 publication。我们曾遇到按月建表成功,而 SELECT 权限、REPLICA IDENTITY FULL 和 publication 准备缺失,导致新分区阻塞同步的情况。
在已经接入的业务建表流程中,我们把所属表的 CDC 准备放进同一事务,准备失败就不能把建表工作报告为完整成功。平台也增加了按数据库显式启用的新增表准备策略,但它有独立的权限和范围限制,不能把“代码支持”理解为所有生产库默认开放。创建新来源实例,仍需要纳管、配置和预检。
要求 REPLICA IDENTITY FULL 是当前链路的具体选择:它帮助我们处理更新事件中大型 TOAST 字段的旧值与完整行内容,同时增加日志和内存开销。不同于应用层双写,这些准备不会要求每个业务写接口再调用一次分析服务;但数据库建表方需要承担结构接入责任。
6. 上线后遇到的几类问题
这套系统的工作量,大部分出现在第一次把数据写入 ADB 之后。下面选取几类实际问题,说明平台为什么需要超出“启动一个 Debezium 进程”的能力。
6.1. 大记录与大表是两种负载
一次生产排查发现,消息表中存在约 41 MB 的单行记录。复制时可能同时存在更新前后图像、解码对象和写入参数,额外生成一份并不使用的 JSON 副本,也会放大内存。即使容器仍有空闲内存,单个 JVM 也可能先耗尽自己的堆。
我们因此去掉无用序列化,限制事件批次和队列,并为大消息 reader 预留更大的堆。队列的 4 MiB 字节限制只约束排队部分,不能限制已出队批次和单条超大记录的全部内存占用。
另一次问题恰好相反:一张约 5600 万行的表仍使用 64 行分块,任务一直前进,但大量数据库往返与提交拖慢了补数,源端 WAL 积压一度达到约 13 GiB。普通记录库改为 512 行分块后继续完成补数,大消息库仍保留较小分块。
这两次经历不能合并成“批次越大越好”或“批次越小越稳定”。需要同时观察记录大小、每次往返成本、目标写入速度和 WAL 余量。
6.2. 类型正确性需要真实数据验证
早期使用的 JDBC 批量优化路径绕过了正确的逻辑类型绑定,数组和高精度数值出现写入失败,部分二进制值还曾被写成对象的描述文字。后来改回普通 JDBC 类型绑定,并针对已经写错的历史目标做了单独核验与受控修复。
分块边界也有类型问题。带时间戳的复合主键,如果把边界值按字符串绑定,PostgreSQL 可能拒绝比较;表现就是首个分块已经完成,后续却不再推进。修复参数类型推断后,还需要验证微秒精度、分区和中途续跑。
这些问题说明,小量字符串和整数的测试,只能证明那一组输入能够通过。数组、精确数值、二进制、大字段和复合主键,都需要进入相应的验证样本。
6.3. 恢复范围过大会制造新的停顿
新表出现时重补整个数据库,会把局部变化放大成所有表的恢复工作。源表退役后,如果要求“当前活动表集合”与“历史目标归属集合”完全相等,又会把应当保留的旧副本误判成冲突。
我们逐步把这些情况拆开:新表只补新表,兼容加列只更新对应目标结构,退役表保留经过核验的历史副本。恢复时仍然核对归属,但不再把不同语义的集合强行当作同一集合。
10 月 14 日还发现了恢复操作影响其他 reader 的问题:长时间对账过早更新共享的围栏元数据表,并持有写锁直到复制结束,ADB 的锁行为让其他 reader 的断点提交也受到影响。相应修改把 epoch 的比较更新移到最终提交阶段,复制期间保留该 reader 自身的互斥。截至本文核查时间,这项修改已经进入主干,发布验收仍由专项任务继续跟进;不能把本地并发回归通过写成生产验收完成。
6.4. 运行状态不能替代数据进度
我们也遇到过进程和 HTTP 健康检查正常,但原生复制线程已经失败的情况;以及所有业务表都处于 blocked,健康计算却误把它们当作空库的情况。
现在的检查需要把几种证据放在一起:控制器是否持续工作,reader 的租约和心跳是否有效,原生错误是否出现,持久 LSN 是否推进,分块是否提交,以及源槽是否仍有足够日志保留空间。
低流量数据库尤其容易误判。“最后一条业务数据很久没有变化”可能只是没有新业务,不一定是同步停了。每库的独立心跳行参与 publication,也写入目标,使静默库能够推进复制位置。反过来,只更新平台时间戳、或者仅有 WAL 心跳,也不能证明一张大表的分块补数仍在前进。
最后还要确认告警规则确实部署成功。曾经有三条 CDC 规则因权限或配额失败而没有生效,后续条件恢复后也没有自动重试。监控代码存在、页面有状态、告警实际能够发现异常,是三个需要分别核实的事实。
7. 与阿里云 DTS 相比,优势在哪里
比较之前,需要确定具体链路。这里讨论的是我们使用的生产 PostgreSQL 到 ADB PostgreSQL,而不是把 DTS 所有数据源的限制混在一起。以下产品能力按 2026 年 10 月 14 日查阅的官方文档描述。
7.1. DTS 已经提供的能力
DTS 的 RDS PostgreSQL → ADB PostgreSQL 链路支持结构、全量和增量同步,并在版本、账号和任务条件满足时支持创建表、追加列、删除表等多类 DDL。特定调用方式和事务组合仍有限制,应按实际链路核对。阿里云官方链路文档给出了支持范围。
DTS 也提供任务管理、同步对象修改和告警等 API,可以被研发平台统一编排;它还提供结构、全量及增量数据校验,具体适用性需要核对源端、目标端与校验方式。因此,自动化接入、DDL 和一致性检查,都不能笼统地写成自建方案独有的功能。参见 DTS API 概览和数据校验说明。
7.2. 我们已经获得的几项价值
第一项是资源组织方式。 底层仍然一库一个 reader,但多个 reader 可以共享已有计算容器,并按内存预算分配资源。新增一个逻辑库,不必同时新建一个对应的 DTS 计费实例。对于数据库较多、负载差异较大的场景,这提供了合并资源预算的空间;高负载库也仍然需要独立评估容量。
第二项是接入规则与业务生命周期结合。 来源使用平台已纳管目录,目标名称、表归属、生产环境边界和建表准备遵循同一套规则。新增服务、新建月份表、退役业务表时,可以沿用这些规则处理后续同步,而不是让分析侧再次解释一遍业务意图。
第三项是能够修改具体的恢复行为。 大记录如何限制内存,兼容加列是否保留断点,退役副本是否保留,恢复操作是否影响其他 reader,都是我们可以在实现中检查、复现和修改的内容。定制空间已经在上述故障处理中发挥作用,同时也意味着这些实现的正确性由我们负责。
第四项是运维证据集中。 平台能够关联来源目录、执行计划、阻塞原因、目标映射、重试操作和现有告警体系。排查新增表未同步时,可以沿着这些记录定位,而不只看到一个外部任务的最终状态。这是当前平台整合后的收益,并不意味着 DTS 的信息不能通过 API 接入同一平台。
| 比较项 | 当前自建方案 | DTS |
|---|---|---|
| 数据传输基础 | Debezium 与 JDBC,团队维护运行适配 | 云服务提供并维护传输能力 |
| 资源安排 | 多库 reader 共享预算,自行规划接管余量 | 按产品提供的规格和计费模式选择 |
| 平台整合 | 已接入自身目录、映射、建表与恢复流程 | 可通过 API 建设相应整合 |
| 结构变化 | 按已实现的安全差异分类处理 | 按具体链路的 DDL 支持范围处理 |
| 故障修复 | 可以改代码,同时承担定位、回归和发布 | 使用产品运维能力及服务支持 |
| 数据验证 | 现有类型、恢复回归和有界抽样 | 提供专门的校验能力,需核对适用范围 |
7.3. 成本优势需要按总成本验证
最初希望避免逐库采购,是这次选择的重要动机,但不能把少买几条链路直接换算成总成本下降。DTS 当前也有不同计费模式,实际费用取决于所选产品和规格,不能沿用某次历史报价推导今天的价格。DTS 计费概述列出了相应规则。
可以用下面这个简化模型组织对比,它不是实测账单:
托管方案成本 ≈ DTS 实际费用 + 接入与日常运维投入
自建方案成本 ≈ 计算与容灾余量 + 开发维护投入 + 值守与故障处理投入
两种方式都还需要考虑源端读取、WAL 保留、网络和目标写入等成本。要得出可靠结论,应在相同数据规模、延迟目标、恢复要求和统计周期下比较,并记录工程时间。本文没有这组完整数据,也没有同负载的 DTS 性能对照,因此不宣称已经节省某个百分比,或同步速度超过 DTS。
还有一种可行组合是保留平台的目录、策略与操作记录,让 DTS 承担底层复制。平台管理能力与自建传输引擎并不需要绑定选择。我们目前选择 Debezium,主要看中共享资源和定制恢复路径的空间;已有四条 DTS 仍与新链路并存,账号、publication、slot 和目标各自独立。
8. 当前规模与仍需解决的问题
2026 年 10 月 14 日的目录核查覆盖 4 个来源实例、21 个有效 reader;当日 14:21 的目标覆盖记录包含 563 张应有目标表。这里的“有效”排除了空库和已经退出生产目录的历史恢复库。表存在、归属正确和状态处于 streaming,分别只说明对应层面的事实,不能合起来替代所有数据的完整校验。
10 月 13 日的完整验收记录确认 Gateway 历史补数完成,21 个 reader 在两轮采样中均有持久 LSN 推进,并完成了 69 张表的有界抽样。10 月 14 日仍然出现了前文所述的完成证明和恢复锁问题。这些记录说明链路已经承担实际同步工作,也说明一个时点的验收不能作为长期可靠性的证明。
接下来首先需要处理发布与容量边界。analytics 虽然是独立容器,仍与平台位于同一组 Pod,并使用同一制品发布。分块续跑减轻了重启代价,却没有消除共同发布带来的中断。让同步执行器拥有独立发布节奏,是后续架构方向,尚不能当作已完成能力。
同样,两个副本能够分担正常负载,不等于任意一个退出后,另一个就能接管全部 reader。容量规划需要把 JVM 堆、原生内存、大记录和接管余量一起计算,并通过故障演练验证。只看集群平均 CPU,无法回答这个问题。
数据语义也有明确限制。目标是分析副本,不完整复制源库的外键和次级唯一约束;多个来源之间没有共同的事务快照;部分表按小时刷新。因此它适合构建分析原始层,不能直接作为业务主库替代品,跨库关联和时间窗口仍需在分析模型中处理。
如果需求是少量标准数据库之间的持续复制,产品支持范围已经满足,而且团队希望减少底层维护工作,DTS 是值得优先评估的方案。当多库接入、业务建表、历史副本策略和平台恢复流程成为主要工作时,自建这一层也有实际价值。对我们而言,后续要继续验证的是:这些整合收益,能否在可接受的维护投入下,持续换来明确的数据时效、恢复时间和正确性。