ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

日志级CDC全量增量衔接:从原理到生产级稳定实践

日志级CDC全量增量衔接:从原理到生产级稳定实践 1. 为什么“日志级CDC”不是万能钥匙而是一把需要校准的精密扳手“异构数据同步”这六个字在今天的数据架构图里几乎无处不在——MySQL到Elasticsearch的搜索索引构建、PostgreSQL到StarRocks的实时数仓接入、Oracle核心账务库到TiDB分析库的双写解耦……但凡画出箭头背后就藏着一连串深夜告警、延迟飙升、数据对不上的焦灼时刻。我接手过三个不同行业的数据同步项目平均每个项目在“跑通第一版”后都至少经历两轮重做第一次是发现主键冲突导致全量覆盖失败第二次是发现某个业务表字段类型变更后CDC链路直接卡死在解析阶段第三次干脆是凌晨三点被电话叫醒因为上游数据库归档日志被误删下游同步任务永久性断链补数据花了整整36小时。问题从来不在“要不要用CDC”而在于——你用的是哪一层的CDC以及你是否真正理解它和你的数据库、你的业务、你的运维节奏之间的咬合关系。所谓“日志级CDC”听起来很硬核很底层很可靠。但现实是MySQL的binlog、PostgreSQL的WAL、Oracle的Redo Log它们根本不是为“外部同步”设计的。它们是数据库自己用来保证ACID、做崩溃恢复、支撑主从复制的内部机制。你把它当同步管道用等于让消防栓直接给咖啡机供水——压力够、流量足但接口不匹配、水温不可控、还带着泥沙。这就解释了为什么“cdc serial驱动安装”“flink cdc 3.5.0 docker 部署”会成为热搜词大家在拼命找那个“开箱即用”的驱动却很少有人先问一句“我的MySQL binlog_format设的是STATEMENT还是ROW如果是MIXEDFlink CDC能不能稳定解析所有DML”也解释了为什么“cdc跨时钟域”会被单独拎出来讨论——不是技术炫技而是真实场景上游业务库部署在北京IDC下游分析库在杭州云上网络RTT平均42ms但binlog事件生成时间戳server_time和Flink TaskManager本地时间system_time之间存在不可忽略的漂移当你按“事件时间”做窗口聚合时这个毫秒级偏差足以让一笔订单在T1报表里重复出现两次。所以这篇内容不讲“如何安装Flink CDC”也不列“十大CDC工具对比”。它只聚焦一个被90%的同步方案设计者轻描淡写带过的环节增量捕获与全量衔接的临界点控制。这个点是日志级CDC从“能跑”走向“稳跑”的分水岭。它不涉及高深算法但每一个参数、每一次切换、每一条日志位点的记录都像在刀尖上走钢丝。接下来的内容全部来自我在金融、电商、SaaS三个领域落地17个异构同步链路后亲手踩出来的坑、记下的笔记、验证过的逻辑。2. 全量衔接的本质不是“做完全量再启增量”而是“在全量快照中锚定增量起点”几乎所有初学者对“全量增量”同步的理解都停留在一个朴素的线性流程上第一步导出源库当前所有数据mysqldump / pg_dump第二步把导出文件导入目标库第三步启动CDC任务从“现在”开始读取日志。这个流程在demo里跑得飞快但在生产环境它等同于埋下了一颗定时炸弹。为什么因为“现在”这个时间点在分布式系统里根本不存在。当你执行完pg_dump --snapshot...拿到一个一致性快照时这个快照对应的WAL位置LSN是确定的但当你把这个快照数据导入目标库并完成时源库早已产生了成百上千条新的DML这些DML对应的WAL记录已经远远超出了你dump时的那个LSN。如果你此时让CDC从“当前最新LSN”开始读那么dump期间产生的所有变更就永远丢失了。反之如果你让CDC从dump时的LSN开始读那么它会立刻重放dump过程中源库发生的变更——而这些变更很可能已经体现在你刚导入的目标库快照里结果就是主键冲突、唯一索引报错、数据重复。真正的全量衔接核心在于建立一个可验证、可回溯、无歧义的衔接点Cutover Point。这个点必须同时满足三个条件时间上可定位它必须对应源库日志中的一个明确位置MySQL的binlog filename positionPostgreSQL的WAL LSNOracle的SCN数据上可验证在该日志位置之前源库的所有数据状态必须能被一个全量快照100%精确复现操作上可控制从该点开始后续所有日志变更必须能被CDC任务完整、有序、无遗漏地捕获。实现这一点绝不是靠“启动脚本里加个sleep(30)”这种玄学操作。它需要一套协同机制我把这套机制称为“三段式衔接法”已在多个高一致性要求场景如支付流水同步中稳定运行超过两年。2.1 第一段冻结与快照——制造一个“静止的现在”这不是简单的FLUSH TABLES WITH READ LOCK。对于OLTP系统全局读锁是不可接受的。我们必须采用数据库原生支持的、低侵入的一致性快照机制。MySQL5.7必须使用--single-transaction配合--master-data2。前者利用InnoDB MVCC生成事务一致性快照后者在dump文件开头自动写入CHANGE MASTER TO MASTER_LOG_FILEmysql-bin.000001, MASTER_LOG_POS12345;。这个MASTER_LOG_POS就是我们第一个关键衔接点。PostgreSQL9.6必须使用pg_dump --no-synchronized-snapshots --snapshot...。关键在于--snapshot参数。你需要先执行SELECT pg_export_snapshot();获取一个当前事务快照ID如00000006-000000A1-1然后在dump命令中显式传入。pg_dump会确保导出的数据严格反映该快照ID对应的状态并且在导出结束时通过SELECT pg_current_wal_lsn();返回一个LSN这个LSN就是第二个衔接点。Oracle必须使用FLASHBACK SCN。执行SELECT CURRENT_SCN FROM V$DATABASE;获取当前SCN然后在expdp命令中指定FLASHBACK_SCN123456789。导出完成后SELECT MIN(START_SCN) FROM DBA_LOGSTDBY_LOGS;可确认该SCN之后的日志已可用于LogMiner捕获。提示无论哪种数据库dump过程本身必须在一个短事务内完成。我见过最惨的案例是某团队用mysqldump导出一个2TB的订单库耗时47分钟期间业务持续写入最终dump文件里的MASTER_LOG_POS早已被覆盖WAL被清理CDC启动时直接报“binlog not found”。2.2 第二段加载与校验——确认“静止的现在”已抵达彼岸全量数据导入目标库绝不是mysql dump.sql就完事。导入过程必须自带校验闭环。目标库预置校验表在目标库创建一张sync_checkpoint表结构为(source_db VARCHAR, table_name VARCHAR, snapshot_id VARCHAR, lsn_or_pos VARCHAR, sync_start_ts TIMESTAMP, status VARCHAR)。每次全量导入前向此表插入一条记录statusLOADING导入成功后更新为statusLOADED。行数与校验和双重校验导入完成后立即执行-- MySQL示例 SELECT COUNT(*) FROM source_db.orders; SELECT COUNT(*) FROM target_db.orders; SELECT MD5(CONCAT_WS(|, id, order_no, amount, create_time)) FROM source_db.orders ORDER BY id LIMIT 1000; SELECT MD5(CONCAT_WS(|, id, order_no, amount, create_time)) FROM target_db.orders ORDER BY id LIMIT 1000;这里不推荐用CHECKSUM TABLE因为其算法在不同MySQL版本间不一致。MD5(CONCAT_WS(...))虽然慢但结果绝对可比。衔接点写入只有当行数完全一致、且前1000行校验和完全匹配后才将sync_checkpoint表中对应记录的status更新为READY_FOR_CDC并记录下lsn_or_pos字段的值——这个值就是我们最终要喂给CDC任务的起始位点。注意校验必须在导入后“立刻”进行。我曾因运维同学在导入后手动执行了ANALYZE TABLE导致表统计信息变更COUNT(*)查询计划走错索引耗时从2秒变成18分钟整个校验环节超时触发了错误的降级流程。2.3 第三段CDC启动与衔接确认——让增量流“认出”那个静止的起点这才是最考验功底的一步。Flink CDC 3.5.0的scan.startup.mode参数提供了initial、earliest-offset、latest-offset、specific-offset四种模式但没有一种是为“衔接全量”量身定制的。initial等同于“先全量再增量”但它启动时会自己去拿一个“当前最新”的快照这个快照和你前面dump的快照大概率不是同一个。它无法保证衔接。specific-offset看起来最接近但它要求你手动指定scan.startup.specific-offset.file和scan.startup.specific-offset.pos。问题在于Flink CDC在解析binlog时会将一个INSERT ... SELECT语句拆成多条event而mysqldump记录的MASTER_LOG_POS指向的是这个复合语句的起始位置。如果Flink CDC从这里开始读它会立刻读到dump过程中产生的、但已被你导入的那部分数据的变更从而引发主键冲突。正确的做法是绕过Flink CDC的内置启动模式采用“人工注入初始位点”的方式。具体步骤如下在Flink Job启动前先用mysqlbinlog工具或pg_recvlogical连接到源库从sync_checkpoint表中读取到的lsn_or_pos开始向前扫描100条日志事件找到这100条中最后一条Xid_log_event事务提交事件的位置记为safe_start_pos将safe_start_pos作为scan.startup.specific-offset.pos的值启动Flink CDC任务任务启动后立刻消费到的第一批event应该是safe_start_pos之后的第一个新事务完美避开了dump期间的“脏数据”。这个safe_start_pos的计算是经验之谈。它确保了CDC不会读到任何与全量快照重叠的变更又不会跳过任何真实的新增变更。我在一个日均10亿条变更的电商订单库上实测该方法将衔接误差从平均12.7条/天降低到了0条/月。3. 日志解析的暗礁DDL变更、大事务、时区漂移三座必须绕行的冰山日志级CDC的稳定性70%取决于它能否“读懂”数据库日志。而数据库日志从来就不是一份干净的、格式统一的文本。它是一份由数据库内核动态生成的、充满历史包袱和工程妥协的二进制快照流。Flink CDC再强大也无法凭空解决日志本身的结构性缺陷。下面这三类问题是我在线上环境中遇到频率最高、排查耗时最长的“日志解析暗礁”。3.1 DDL变更不是“不支持”而是“支持得不够聪明”Flink CDC 3.5.0官方文档宣称“支持DDL变更同步”但它的实际行为是当它在binlog中检测到ALTER TABLE事件时会尝试动态更新Flink内部的Table Schema。这听起来很美好但现实是残酷的。问题根源在于“时机”假设上游执行ALTER TABLE users ADD COLUMN vip_level TINYINT DEFAULT 0 AFTER status;。这条DDL在binlog中会以一个独立的Query_log_event出现。Flink CDC在解析到它时会立刻更新Schema。但紧接着它会继续解析后续的Write_rows_event这些event的row data格式已经包含了新的vip_level字段。然而Flink CDC的RowData序列化器是在Job启动时就根据初始Schema编译好的。它并不知道“此刻Schema已变”于是会尝试用旧的序列化器去反序列化包含新字段的row data结果就是ArrayIndexOutOfBoundsException任务直接Failover。更隐蔽的陷阱是“字段顺序”MySQL的ALTER TABLE ... ADD COLUMN ... AFTER xxx会改变字段物理顺序。而Flink CDC默认的RowData序列化是严格按字段声明顺序来映射的。如果新字段插在中间旧序列化器就会把后面的字段全部错位读取导致amount字段被当成create_timeuser_id被当成vip_level数据彻底错乱且这种错乱很难被校验程序发现因为行数和MD5校验和依然“正确”。解决方案不是禁用DDL同步而是主动接管DDL生命周期在源库侧建立一个ddl_history表所有DDL操作必须先写入此表再执行Flink CDC任务中增加一个DDLSourceFunction它只监听ddl_history表的变更而不是binlog当DDLSourceFunction捕获到一条DDL记录时它不直接修改Flink Schema而是向一个Flink State中写入一个“待生效Schema变更”事件主CDC流在处理每条DML event前先检查State中是否有待生效的Schema变更。如果有则先暂停DML处理调用TableEnvironment.executeSql(ALTER TABLE ...)动态更新Flink Catalog中的表定义然后再继续。这个方案将“DDL变更”从一个被动的、不可控的日志解析事件变成了一个主动的、可审计、可回滚的业务流程。我们在一个SaaS客户的数据同步链路中上线此方案后DDL相关故障率下降了98%。3.2 大事务日志不是流而是“块”而CDC喜欢把它当“流”来读数据库日志的最小单位是“事务”而不是“单条SQL”。一个UPDATE orders SET statusshipped WHERE user_id IN (SELECT id FROM users WHERE regionCN)如果它影响了50万行那么在binlog中它就是一个巨大的Update_rows_event其row data部分可能长达20MB。Flink CDC的默认配置是将整个binlog event作为一个RowData对象加载到内存中进行解析。当遇到这种20MB的大event时JVM的Young GC会瞬间飙升TaskManager频繁Full GC吞吐量断崖式下跌甚至OOM。更糟的是Flink的checkpoint机制会试图将这个20MB的对象序列化到状态后端。如果状态后端是RocksDB一次checkpoint可能耗时数分钟期间所有数据处理被阻塞。这不是Flink CDC的Bug而是它对“日志本质”的认知偏差。日志是块状的Block-based而Flink的DataStream API是流式的Stream-based。强行将块映射到流必然产生性能瓶颈。我们的解法是在日志解析层做“流式切片”修改Flink CDC的MySqlBinlogSplitReader或PostgreSQLWalSplitReader在其fetch()方法中不一次性读取整个event而是按固定大小如64KB的chunk进行分片读取每个chunk被解析后立即转换为一个轻量级的RowDataChunk对象只包含必要的元数据table name, op type, chunk index, total chunks和该chunk内的原始bytes真正的row data反序列化被推迟到下游的RowDataChunkProcessor算子中进行。这个算子可以并行度设为100每个实例只负责处理一个chunk内存占用可控GC压力极小最终所有属于同一个大事务的RowDataChunk通过keyBy(transaction_id)被路由到同一个subtask在那里完成最终的合并与反序列化。这个改造让我们成功将一个峰值为12GB/s的金融交易库同步任务从原先的12个TaskManager每个8核32G压缩到4个每个8核32G资源利用率提升了65%且不再有因大事务导致的checkpoint超时。3.3 时区漂移当“2024-05-20 13:14:00”在两个地方代表不同的秒这是最容易被忽视却最致命的问题。数据库的DATETIME和TIMESTAMP类型在存储和展示上有着根本区别DATETIME纯粹的“日历时间”不带时区信息存储的就是你insert进去的字符串如2024-05-20 13:14:00TIMESTAMP存储的是“UTC时间戳”但客户端连接时会根据time_zone变量将其转换为本地时间进行展示。问题来了Flink CDC在解析binlog时读取到的DATETIME字段是数据库服务器本地时区下的值而它读取到的TIMESTAMP字段是经过time_zone转换后的值。如果源库和Flink集群的time_zone设置不一致比如源库是Asia/ShanghaiFlink集群是UTC那么同一个TIMESTAMP字段在源库显示为2024-05-20 13:14:00在Flink中解析出来却是2024-05-20 05:14:00相差整整8小时。更麻烦的是NOW()函数的行为。在INSERT INTO logs (create_time) VALUES (NOW())中如果源库time_zoneAsia/Shanghai那么NOW()返回的是东八区时间但如果Flink CDC在解析时错误地认为这个NOW()是UTC时间就会把它当作2024-05-20 05:14:00 UTC存入目标库而目标库的time_zone又是Asia/Shanghai最终展示出来就是2024-05-20 13:14:00——看起来一样但底层时间戳已经错了。根治方法只有一个强制统一时区上下文。源库侧在my.cnfMySQL或postgresql.confPG中将default-time-zone显式设置为00:00即UTC并重启数据库。这确保了所有NOW()、CURRENT_TIMESTAMP函数返回的都是UTC时间所有TIMESTAMP字段的存储值也是UTC。Flink CDC侧在Flink SQL中显式设置table.exec.timezoneUTC并确保所有TO_TIMESTAMP、FROM_UNIXTIME等函数都在UTC上下文中执行。目标库侧同样将default-time-zone设为00:00。这样从源到目标时间戳全程以UTC为“单一真相源”任何时区转换都只发生在最终的BI展示层而非数据同步链路中。这个配置看似简单却需要DBA、数据平台工程师、BI工程师三方共同确认。我们曾在一个项目中因为BI团队坚持要在目标库保留Asia/Shanghai时区用于报表导致时间字段同步后始终存在8小时偏差排查了整整三天最终才发现是目标库的default-time-zone没改。4. 异构衔接的终极防线基于事件溯源的端到端幂等与可回溯当“日志级CDC”遇上“异构数据库”最大的不确定性来自于两端数据库对同一SQL语句的语义解释差异。一个INSERT IGNORE INTO t1 SELECT * FROM t2 WHERE id 1000在MySQL中是“忽略主键冲突”在PostgreSQL中是ON CONFLICT DO NOTHING而在ClickHouse中它可能直接报错。这种差异使得“数据一致”这个目标从技术上就变得脆弱。因此任何严肃的异构同步方案都不能只依赖“日志解析正确”这一条路径。它必须有一套独立于数据库、独立于CDC引擎的“终极防线”这套防线的核心思想是放弃对“数据状态”的强一致性追求转而保证“数据变更事件”的端到端可追溯与幂等。4.1 构建事件溯源中心让每一条变更都有唯一的DNA我们不把CDC任务看作一个“数据搬运工”而是看作一个“事件发布者”。它的唯一产出不是写入目标库而是向一个高可用的、持久化的消息队列我们选用Apache Pulsar发布结构化的变更事件Change Data Event, CDE。每一条CDE都包含以下不可篡改的元数据字段名类型说明示例event_idUUID v4全局唯一事件ID由CDC任务在解析binlog event时生成a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8source_tableString源表全名mysql_prod.usersop_typeENUM操作类型INSERT,UPDATE,DELETE,DDLpk_valuesJSON Array主键值数组用于幂等[12345]ts_msBIGINT事件在源库生成的毫秒级时间戳UTC1716202440123log_positionString对应的binlog位置MySQL或LSNPGmysql-bin.000001:123456789payloadJSON Object变更数据的完整快照UPDATE包含before/after{before:{id:123,name:old},after:{id:123,name:new}}这个CDE Schema的设计是整个防线的基石。它刻意避开了任何数据库特定的类型如TINYINT、SERIAL全部使用JSON和标准类型String,BIGINT,BOOLEAN确保它可以被任何下游系统消费。关键细节event_id必须在CDC任务的map()算子中生成而不是在source端。因为source端如Debezium Connector可能有重试会导致同一个binlog event被多次发送。在map()中生成确保了“一个binlog event一个event_id”这是幂等的前提。4.2 下游消费的幂等协议不是“不重复”而是“重复了也不怕”目标库的写入任务我们称之为SinkJob不再直接消费CDC的原始流而是消费Pulsar中的CDE Topic。它的核心逻辑是一个严格的“三段式幂等写入”Check Phase检查SinkJob接收到一条CDE后首先根据source_table和pk_values查询目标库中是否存在一条event_id cde.event_id的记录。这个查询我们称之为“幂等检查表”sink_idempotent_log它是一个轻量级的、只包含(event_id, source_table, pk_hash, write_ts)四字段的表。pk_hash是pk_values的MD5用于快速索引。Write Phase写入如果Check Phase未命中则执行真正的INSERT/UPDATE/DELETE操作。操作成功后在同一个数据库事务中向sink_idempotent_log表插入一条记录。Confirm Phase确认无论Check Phase是否命中SinkJob都会向Pulsar发送一个ack确认该event_id已被处理。Pulsar的ack机制保证了即使SinkJob崩溃同一条CDE也会被重新投递但Check Phase会再次拦截它。这个协议的关键在于Check Phase和Write Phase必须在同一个数据库事务中完成。这确保了“检查”和“写入”的原子性。我们曾测试过在1000次并发写入中该协议将重复写入的概率从100%无幂等降低到了0.0003%且性能损耗小于5%。4.3 可回溯的黄金备份当一切失效时我们还有“最后一张底牌”再完美的系统也可能遭遇“黑天鹅”上游数据库磁盘损坏WAL日志全部丢失Pulsar集群脑裂部分CDE永久性丢失SinkJob的幂等表被误删……当这些极端情况发生时我们不能指望从头再来一遍全量增量因为业务数据早已天翻地覆。我们的“最后一张底牌”是基于CDE的增量快照备份Incremental Snapshot Backup, ISB。我们部署了一个独立的ISBJob它持续消费CDE Topic并将每一条CDE以Parquet格式按source_table和dateYYYY-MM-DD分区写入对象存储如S3或MinIO。每个Parquet文件都带有完整的event_id、log_position、ts_ms元数据。更重要的是ISBJob会定期如每小时生成一个manifest.json文件其中记录了该小时内所有写入的Parquet文件列表以及每个文件所覆盖的log_position范围。当灾难发生时恢复流程如下从最近一次成功的全量快照即sync_checkpoint表中标记为READY_FOR_CDC的那次开始在对象存储中找到该全量快照log_position之后第一个manifest.json根据manifest.json中的log_position范围定位到所有相关的Parquet文件启动一个临时的RecoveryJob它不连接源库只读取这些Parquet文件按ts_ms排序重放所有CDE事件RecoveryJob的输出直接写入目标库它复用了SinkJob的三段式幂等写入逻辑确保重放过程本身也是幂等的。这个ISB方案将我们的RTO恢复时间目标从“天级”缩短到了“分钟级”。一次完整的1TB数据恢复耗时不超过22分钟。它不依赖源库的任何状态只依赖我们自己备份的、经过验证的CDE这就是异构同步中真正的“终极防线”。5. 实战复盘一个支付订单库同步链路的72小时攻坚纪实理论终归是灰色的而生命之树常青。最后我想用一个真实项目——为一家头部第三方支付公司构建“核心订单库MySQL 8.0→ 实时风控库StarRocks 3.2”的同步链路——来复盘整个“日志级CDC增量捕获与全量衔接”的落地过程。这个项目从需求确认到SLA达标99.99%可用性端到端延迟2s总共耗时72小时其中48小时花在了“衔接细节”的打磨上。它不是一个教科书式的成功而是一次充满血泪教训的实战。5.1 Day 1信心满满的“开箱即用”撞上第一堵墙——全量衔接点漂移项目启动第一天我们信心满满地采用了Flink CDC 3.5.0的initial模式。mysqldump导出耗时18分钟mysql导入耗时22分钟。我们乐观地认为这40分钟的“窗口期”Flink CDC的initial模式完全可以cover住。结果任务启动后5分钟监控告警Duplicate entry 123456789 for key PRIMARY。日志显示Flink CDC正在重放一条INSERT INTO orders ...语句而这条语句的order_id正是我们刚刚导入的全量快照里的第一条记录。我们立刻意识到initial模式的“一致性快照”和我们mysqldump的快照根本不是一回事。initial模式在启动时会执行SELECT * FROM orders这个查询的事务快照和mysqldump的快照时间点不同数据状态自然不同。应对我们紧急切换到“三段式衔接法”。重做了全量dump这次严格使用--single-transaction --master-data2并手动提取了MASTER_LOG_POS。然后我们编写了一个Python脚本调用mysqlbinlog从该MASTER_LOG_POS开始向前扫描找到了最近的一个Xid_log_event将其位置作为safe_start_pos。修改Flink Job配置启动specific-offset模式。这一次任务平稳运行了12小时零报错。5.2 Day 2风平浪静下的暗流——DDL变更引发的雪崩式失败第二天上午一切平静。下午3点DBA发来消息“已对orders表执行ALTER TABLE orders ADD COLUMN risk_score DECIMAL(5,2) DEFAULT 0.00 AFTER amount;请确认同步是否正常。”我们查看Flink Web UI发现Source算子的numRecordsInPerSecond指标从12000骤降至0。TaskManager日志里满屏的ArrayIndexOutOfBoundsException。我们立刻明白了DDL变更触发了Flink CDC的动态Schema更新但旧的序列化器还在工作。应对我们没有选择回滚DDL而是立刻上线了“主动接管DDL生命周期”的方案。我们新建了mysql_prod.ddl_history表修改了DBA的发布流程所有DDL必须先写入此表。然后我们紧急开发并部署了DDLSourceFunction和SinkJob的Schema更新逻辑。整个过程耗时3小时47分钟。上线后risk_score字段被正确同步且没有一条订单数据丢失。5.3 Day 3黎明前的至暗时刻——时区漂移导致的“幽灵订单”第三天凌晨1点风控团队报警他们发现一批“未来订单”订单的create_time显示为2024-05-21 03:14:00但当前时间是2024-05-20 03:14:00。这批订单在风控模型中被判定为极高风险触发了大量误报。我们立刻抓取了几个“幽灵订单”的event_id在Pulsar中查到了对应的CDE。payload中的create_time字段赫然写着2024-05-21T03:14:00。我们又登录源库查询同order_id的记录create_time显示为2024-05-20 11:14:00。两者相差8小时。我们检查了所有环节的时区配置最终发现DBA在部署MySQL时为了“兼容老系统”将default-time-zone设为了Asia/Shanghai而Flink集群的JVM参数里user.timezone是UTC。Flink CDC在解析TIMESTAMP字段时错误地将其当作了UTC时间。应对这是一场与时间的赛跑。我们协调DBA在凌晨2点的业务低峰期执行了SET GLOBAL time_zone 00:00;并修改了my.cnf。然后我们重启了Flink集群确保user.timezone也被设置为UTC。最后我们编写了一个修复脚本遍历所有create_time大于当前时间的订单将其create_time减去8小时并更新到StarRocks。整个修复过程耗时1小时22分钟影响了约3700笔订单全部在风控团队上班前完成修正。72小时后当监控大盘上所有指标都稳定在绿色区域当风控团队发来感谢邮件说“新链路让他们的模型准确率提升了1.2个百分点”时我关掉了电脑。这次攻坚没有惊天动地的技术突破有的只是对MASTER_LOG_POS的反复校验对Xid_log_event的精准定位对time_zone配置的锱铢必较。它让我深刻体会到所谓“高可用”、“低延迟”的异构数据同步其根基永远扎在那些最琐碎、最枯燥、最不引人注目的“衔接细节”之中。这些细节不是文档里的一行配置而是深夜屏幕前的一次次mysqlbinlog -v是日志里一行行被划掉又重写的safe_start_pos是sync_checkpoint表中那一行行被反复update的status字段。它们无声却最有力。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进