数据复制总崩盘?这次我们把CDC塞进Postgres心脏,让它自己跑!
Snowflake把CDC直接推进Postgres内核,用推式架构加事务边界,把原本脆弱混乱的数据复制变成一套永不卡壳的发条机制,全程无需额外管道。
复制这个动作,从来就不是“复制”那么简单
任何一个干过数据迁移或者实时同步的工程师,听到“CDC”这三个字母,条件反射不是兴奋,是头疼。不是怕数据量大,是怕它莫名其妙就断掉。明明凌晨两点还在睡大觉,手机突然炸出一堆报警,说复制链路卡死了。爬起来一看,WAL日志堆积成山,磁盘报警,源库快被自己拖垮了。这种场景在行业里每天都在重演,不是哪家公司的错,是整个复制逻辑本身就很拧巴。
传统复制靠的是“拉”模式。一个外部工具像抽水机一样,吭哧吭哧从Postgres那边把变更日志抽出来,再塞到目标端。抽水机要管的事情太多了:既要处理表结构变更,又要应付快照和增量之间的时间差,还要时刻盯着网络别断。但凡有一个环节没对上,整个链路就陷入僵局。更离谱的是,抽水机根本不知道Postgres那边到底发生了啥。它只能看到流过来的数据,至于这数据是在哪个事务里产生的,表结构当时长什么样,Postgres是不是正在重启,抽水机全瞎。
这就导致了一个荒谬的局面:一个号称“实时同步”的系统,实际上每天都在跟不确定性搏斗。你问它数据准不准,它说大概准;你问它延迟多大,它说看运气;你问它会不会挂,它说别问了,再问现在就挂给你看。这种复制不是工具,是玄学。但有意思的是,所有人都觉得“复制本来就该这么复杂”,仿佛不停掉链子才是CDC的宿命。真的没有更干净的路了吗?还真有人硬生生把这条路给铲平重铺了。
把CDC塞进Postgres肚子里,让它自己往外吐
既然外部系统永远搞不清Postgres的实时状态,那最粗暴也最优雅的解法就是:把负责捕获变更的逻辑直接塞进Postgres进程内部。这一步等于把“抽水”变成了“主动吐”。Snowflake干的事就是写了一个叫 snowflake_cdc 的Postgres扩展,让它像寄生在心脏里的起搏器一样,时时刻刻盯着WAL日志的变化。
这个扩展不是什么花哨的外挂,它就住在数据库进程里,跟Postgres共享同一个内存空间,所有表结构变更、事务提交、回滚,它全部第一时间知道。以前外部工具要猜的事,现在它直接“看见”。看见之后怎么做?不是通过网络往外吐,而是直接把变更数据打包成压缩好的Parquet文件,扔到对象存储里,具体来说就是扔进基于Iceberg格式的变更日志表里。
这里有个巨大的认知反转:以前复制工具总想着怎么从A点拉到B点,中间的管道要稳、要快、要不断线。但推模式的思路是,我不在乎管道有多好,我只管把数据变成文件放到一个永远在线、无限扩容的存储层里。对象存储本身就是高可靠的底座,Postgres备份天天往S3扔,从来没听说哪家因为S3丢数据而崩盘的。那CDC数据凭什么不能也往这个篮子里丢?
推模式一落地,整个复制链条里最脆弱的那个环节——“我到底该从哪条WAL位置继续拉?”——直接被砍掉了。因为扩展知道自己的状态,知道哪些LSN已经推过了,哪些还没推,失败了就从本地检查点重来,完全不需要外部工具猜来猜去。而且变更日志和元数据日志是分开写的,元数据日志里记录的是每一次推的操作指令,比如“这张表结构变了”、“这个批次包含哪些事务”、“目标端该按什么顺序应用”。这样一来,复制不再是“拉一截、猜一截”,而是“推一批、记录一批”,每个动作都有据可查。
时间线、快照和变更混在一起,这场乱局靠什么理清
复制之所以容易翻车,最核心的症结在于时间线是乱的。Postgres里一个写操作从发生到最终落到目标表,要经历四个阶段:写入、解码、捕获、应用。这四个阶段像是同一条传送带上的四个工位,但每个工位看同一件产品的时间点不一样。
写入发生在“当下”,WAL日志是在数据页真正修改之前就写进去了。解码阶段跑的是过去某个时间点的WAL,要借助Postgres的“历史快照”能力,把二进制日志翻译成人类能懂的行级操作,比如“把id=5那行从A改成B”。这时候翻译器得知道当时那张表长什么样,列名叫啥,数据类型是啥,如果表后来被删了或者改了结构,解码器还得穿越回去看当时的元数据。
这个穿越能力在Postgres内部是天然支持的,因为解码器跑在数据库进程里,能随时翻阅系统表的历史版本。但外部工具没有这个能力,它们只能靠猜,或者靠额外的Schema Registry来拼凑当时的表结构。这就为数据错乱埋下了巨大的地雷。
推模式的扩展在解码这一环节就占了先天优势。它能准确地按照WAL写入时的表结构来解,解完放进临时文件里。等到解码器收到“批次结束”的信号,就把这一批临时文件直接塞给捕获进程,捕获进程二话不说往Iceberg的变更日志表里一挂,同时在元数据日志里写一笔“这个批次已经推到哪个LSN位置了”。
重点来了,表结构变更在推模式里也是走同一条流水线的。当你执行一条 ALTER TABLE 加列的时候,它也被当成一个特殊的WAL事件,按照写入→解码→捕获的路径走下去,最后在元数据日志里生成一条“改表结构”的指令。目标端的应用进程看到这条指令,不会乱套,因为它知道在哪个LSN点之后,新列就生效了,之前的数据怎么对齐。所有变更在时间线上被排得整整齐齐,没有突兀的插入,也没有模糊的重叠。
如果快照和增量搅在一起呢?传统工具最怕的就是一边在往目标端灌全量快照,一边增量变更又来了,二者冲突导致数据重复或者丢数据。推模式解决这个问题的办法很暴力也很优雅:快照本身也是按批次推的,快照的批次和增量的批次在元数据日志里共享同一个有序队列。目标端应用的时候,严格按照队列顺序执行,快照先落到哪条数据,增量就在它后面追加,永远不会出现“快照覆盖增量”的惨剧。
这种设计让整个复制系统不再是猜谜游戏,而是一个确定性的状态机。每一步该干啥,顺序是啥,失败了从哪儿重试,全部写在元数据里。外部应用进程只消照着剧本念,不添油加醋,不乱插队,整条流水线就像瑞士钟表一样滴答滴答走个不停。
事务这个被遗忘的基石,终于被请回来了
但凡写过ETL脚本的人都知道,跨系统同步数据时,事务一致性是最先被牺牲的东西。因为网络有延迟,目标端不是源端,两边的提交不是一回事。传统CDC工具只能做到“至少一次”或者“最多一次”,很难做到“恰好一次”。于是工程师们被迫设计各种补偿逻辑、幂等机制、去重表,每天跟各种边界条件搏斗。
但仔细想想,数据库系统早就帮我们解决了故障恢复的问题,手段就是事务。你在Postgres里执行一个事务,如果中途系统崩了,事务回滚,啥也没发生;如果提交成功了,数据库保证这个事务绝不再执行一次。这个简单的承诺屏蔽了多少底层的磁盘、网络、内存故障。但一出了数据库,到了CDC管道里,事务的庇护就没了,一切故障都得自己扛。
推模式最狠的一招,就是把事务边界这件事重新请回了复制流程。雪崩_cdc 扩展在做批次推送的时候,一个批次里可能包含多个Postgres事务的变更,但它会保证这些事务在源端的提交顺序原封不动地保留下来。而且所有这些变更文件的写入动作,在Postgres这边是一个本地事务,要么全写进Iceberg的变更日志里,要么全回滚,不存在写到一半断掉的情况。
目标端Snowflake这边的应用进程也不是拍脑袋乱合并的。它每次拉取一批元数据日志,解析出哪些变更日志文件属于同一个源端事务边界,然后在一个Snowflake侧的事务里把它们全部应用掉。应用完了,这一批数据才对外可见。这就意味着,Snowflake里的目标表永远处在一个一致的状态,要么看到完整的变更批次,要么啥都没看到,不存在只应用了一半让报表数据对不上的尴尬时刻。
这个事务对齐的机制对于保障外键约束和关联查询的正确性特别关键。试想一下,如果订单表和订单明细表的变更没有落在同一个事务边界里,明细先到了但订单主表还没到,这时候跑一个关联查询就会漏数据。而推模式的批次合并机制天然保证了同一批次的变更会一起落地,源端什么时序,目标端就什么时序,连因果关系都不会乱。
更重要的是,事务对齐让“恰好一次”变得不再昂贵。传统upsert方案为了处理重复数据,每条insert都要去查目标表里有没有这一行,在大数据量下这操作极其烧钱,列式存储最怕这种点查。而推模式因为保证了变更批次不重不漏,可以直接用delete+insert的追加方式,新数据直接怼到文件末尾,不需要回溯匹配历史数据,性能直接起飞几个数量级。插入密集型的表复制,效率提升让DBA做梦都能笑醒。
应用慢一点也无所谓,实时视图替你兜底
有了事务加持的复制管道,数据准确性是保住了,但性能够不够看?如果目标表已经存了几十亿行,每次合并变更还要做一次大规模重写,成本照样吃不消。这里就要引出推模式的另一个隐藏大招:实时视图。
实时视图听着高大上,拆穿了就是“变更日志+目标表”的联合查询视图。它把还没正式合并进目标表的零散变更文件,和目标端已经合并好的主表文件,在查询那一刻动态合并在一起。但因为Iceberg和Parquet本身支持谓词下推和列裁剪,这个合并操作并不需要全表扫描,过滤器可以直接压到存储层,只读必要的数据块。
这意味着你可以把目标表的应用频率调得很低,比如每小时甚至每天才正式合并一次,但查询的时候依然能读到秒级延迟的数据。变更日志里的文件是按批次组织的,每一批都带有源端LSN位置和时间戳,查询引擎在扫描的时候能精准地跳过已经应用过的批次,只把最新那几批变更动态拼到结果里。
这个机制的妙处在于,它把“复制延迟”和“查询延迟”解耦了。以前为了降低复制延迟,你得疯狂调高应用频率,每次应用都是一次重量级的文件合并,成本直线上升。现在你可以悠着点,低频合并节省计算资源,同时实时视图又保证了查询端几乎感觉不到延迟。这就像快递公司不再追求每一单都即时送货上门,而是在小区门口设个自提柜,随时能取,但集中配送的成本却降下来了。
实时视图让“永远在线”不再是奢侈的口号。即使网络偶尔抖一下,变更批次堆积了几个小时,查询依然能通过变更日志拿到最新结果,只是扫描的文件数量稍微多一点点,性能损耗微乎其微。这种弹性大大增强了系统的鲁棒性,也让运维人员终于不用半夜爬起来手工修复断裂的复制链路了。
从混乱到发条,复制终于变回无聊但正确的事
回顾整个推模式CDC的设计思路,你会发现它其实没什么神秘的黑科技。它只是把数据库系统里已经被验证过的事务、日志、快照这些成熟机制,顺势延伸到了目标存储层,而不是另起炉灶造一个脆弱的管道。扩展住在Postgres体内,变更推往Iceberg,应用在Snowflake里事务性地完成——三方各司其职,没有多余的中间件,没有复杂的协调器,故障恢复路径清晰得像地图上的高速公路。
以前那种复制工具,你配置完之后从来不敢安心睡大觉,总担心哪天它突然就卡在某个LSN位置再也不动了。而现在这种推式架构,配置完一次之后,它就像一部上了发条的钟,自己滴答滴答往前走,没人踢它一脚,它绝不会自己停。即使偶尔因为极端故障导致WAL被清理了,扩展也能自动触发新的快照推送,然后无缝衔接后续增量,整个过程对下游完全透明。
说到底,复制本不该是一件让人焦虑的事。数据从一个系统搬到另一个系统,如果每次都要跟表结构变更、事务顺序、故障恢复这些基础问题搏斗,那说明工具本身就设计错了。真正好的复制,是你忘了它在跑,但它一直在跑,而且从不犯错。当复制变得无聊、稳定、没有意外,工程师才能真正腾出手去做更有价值的事,而不是天天跟复制链路较劲。
把CDC推进数据库内部,让对象存储当缓冲层,用事务串起两端——这套组合拳打完,复制终于从玄学变回了工程。而工程的意义就在于,一旦做对了,它就自动运行,再也不用你操心。