ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

数据库全量增量同步实战:从项目代号拆解到上线调优全复盘

数据库全量增量同步实战:从项目代号拆解到上线调优全复盘 只丢过来一个 dballgts01e01-1配套的项目正文一个字都没有——这大概是每个经历过内部项目的人都很熟悉的场面。第一眼看到这个编号我以为是哪个游戏的关卡存档又像某个测绘点号但冷静下来按老规矩拆内部代号从来不是随机乱码拆开就是需求说明书。db 指向数据库域all 指向全量数据gts 我按团队习惯理解为通用同步服务Generic Transfer Service01e01-1 则是第一版第一次联调批次下的第一个任务。合起来这个项目要做的事情就很清楚了搭建一条数据库全量同步链路并且让源端的增量变更也能近实时地流到目标端。这次就把我从拿到代号、拆需求到选型、实现、压测、上线再到踩坑和优化全过程完整复盘。如果你正准备做数据同步、数据汇聚或者正在纠结全量增量怎么衔接、binlog 消费怎么保证不丢不重这篇应该能帮你少走不少弯路。1. 先把这个项目代号拆开dballgts01e01-1 到底在说什么1.1 代号背后的信息密度很多人觉得内部项目代号没有意义其实恰恰相反越是大团队代号越讲究。dballgts01e01-1 这种命名至少包含了业务域、功能域、版本批次、任务序号四层信息。db 是业务域说明这个项目落在数据库基础设施这一块all 是功能范围指的是存量数据的整体处理gts 是能力域也就是同步服务本身01e01 是迭代编号读作第一版本第一次联调最后的 -1 代表本批次拆分出来的第一个任务。我当时拿到这个代号时项目文档是空的甚至连目标端都没写清楚。这种时候反而要感谢这套编号规则——它把大方向钉死了这是一个“从零搭建全量加增量数据同步能力”的最小闭环任务。后面的所有工作都是围绕这个闭环展开的。范围不明确的时候先拆代号再列问题清单是内部项目最有效的启动方式。1.2 需求边界与验收口径任何同步项目开始前最重要的事情不是写代码而是定义清楚“什么叫做完”。没做过数据同步的人容易低估这一点觉得只要数据过去了就算完成但真实生产环境里衡量标准苛刻得多。我在项目启动第一天整理了三条验收口径后来所有开发和排障都拿这个当标尺。第一条全量阶段结束后目标表行数与源库完全一致允许暂时不一致的时间窗口只存在于同步过程中最终状态必须相等。第二条增量阶段延迟保持在 5 秒以内这不是拍脑袋定的是业务方给的容忍上限超过这个值下游报表和数据服务就会出现可感知的滞后。第三条切换和重跑过程中不允许丢数据也不允许因为重放造成主键冲突或数据翻倍也就是要同时满足不丢、不重、最终一致。这三条口径看着简单实际上每一条都对应一套完整设计。不丢需要依赖位点记录和可靠的日志订阅不重需要幂等写入最终一致需要比对机制来兜底。后面整个项目里所有技术选型都是围绕这三条验收口径反复权衡出来的。2. 架构选型全量导出加增量订阅为什么这套组合最稳2.1 两条主流路线的对比与取舍做数据同步摆在面前的无非是两条路一条是定时批量 ETL另一条是基于数据库日志的增量订阅。前者实现简单一个定时任务跑 SQL 把数据拉过来就行但缺点是延迟只能做到小时级甚至天级而且每次全量跑完还要考虑这次跑了多少、上次跑了多少判断哪些数据变了非常痛苦。后者用 Debezium、Canal 这类工具订阅 binlog能拿到近实时的变更事件延迟做到秒级但只用增量方案也有个致命问题——存量数据不会产生 binlog 事件新接入的表、历史积压的数据增量工具永远感知不到。两条路单独走都不完整我当时的目标很明确全量负责历史数据增量负责实时变更二者衔接起来才能形成一个完整闭环。维度全量导出方案增量订阅方案数据范围存量历史数据实时变更事件延迟分钟级到小时级秒级实现难度低高对源库压力大需要控制并发较小基于日志解析典型工具DataX、Sqoop、自研脚本Canal、Debezium、Flink CDC单独使用的缺陷无法感知实时变更无法处理存量数据最终方案定为“全量导出 增量订阅并行推进”。全量先跑增量同时订阅关键是通过位点把两者对齐这部分后面单独讲。2.2 我最终选定的组件搭配及理由组件选型上我没有全用现成工具而是定了一个组合Debezium 负责解析 MySQL binlogKafka 做事件缓冲和解耦同步 Worker 自研负责把事件写入目标端。全量部分没有直接用 DataX而是用分批 SELECT 拉取。这个选择在当时的会议上有过争论。有人提意见说直接用 DataX 加 Flink CDC一套组合拳打下来多省事。但我们的目标端不是单一数据库而是异构存储集群有些表落到 ClickHouse有些落到 Elasticsearch还有些要进 Redis 做缓存预热。DataX 擅长数据库到数据库的批量迁移对异构写入和自定义清洗逻辑的扩展性不够Flink CDC 实时性很好但要在里面做复杂的幂等和分流逻辑开发和运维成本并不低。自研 Worker 反而更贴合这些定制化需求代价是得自己处理并发、幂等、监控这些脏活累活。现在回头看这个选型是值得的。Debezium 把最容易出错的 binlog 解析和断点续传问题解决了Kafka 天然提供了削峰能力自研 Worker 把业务定制逻辑隔离在一个地方后续加表、加目标端、加清洗规则都只需要改 Worker 这一段。3. 全量与增量的衔接这是整个项目最容易翻车的地方3.1 先全量再开增量是经典错误没有实操经验的人很容易把全量和增量设计成两个串行阶段先跑全量等全量结束再开启增量订阅。这个方案看起来简单实际上数据丢失几乎是必然的。原因在于从全量开始的那一刻起源库的数据就在不断变化如果全量期间不记录 binlog那么这段时间内发生的更新、插入、删除在全量结束后没有任何事件可追溯丢失是静默的不报错但数据已经错了。正确姿势是反过来的先开启 binlog 订阅并记录位点再开始全量。全量结束之后从最初记录的位点开始重放增量事件这样全量期间源端发生的所有变更都会在重放阶段被补齐。很多文章把这叫做“增量追平”实际上等价于把源库在某一个历史时间点的快照加上从该时间点开始的完整事件流重放到目标端。3.2 位点对齐的三种可行方案位点处理是整个衔接过程的心脏。我当时梳理了三种方案各有适用场景。第一种直接查SHOW MASTER STATUS拿到 binlog 文件名和 Position这个方案最朴素但要求取位点和开启订阅之间不能有间隙否则会丢事件。第二种用 Debezium 的 snapshot 机制它启动时会自动记录一个 offset全量跑完之后从 offset 续传这是最顺滑的方式也是我最终采用的。第三种源库如果开了 GTID 模式可以直接用 GTID 作为全局锚点主从切换或 binlog 轮转之后还能准确找到位置适合复杂拓扑但不适合所有 MySQL 版本。实际操作中我让 Debezium 先启动进入只记录不消费的状态拿到稳定位点后全量任务再开始并行拉取。全量期间产生的增量事件会堆积在 Kafka 里这时候消费者延迟增长是正常的不要慌全量结束之后Worker 会快速消费积压数据延迟会迅速追平。这个阶段唯一需要操心的是 Kafka 的保留时间要足够长否则全量跑太久早期事件被 Kafka 清理掉追平就会缺数据。我当时直接把retention.ms调到了 24 小时给自己留足余量。3.3 大表分批策略与并发控制全量拉取最忌讳的是所有表一视同仁。小表像用户配置表几千行数据一次 SELECT 就能拿完大表像订单流水表几亿行数据一次性查询大概率会把源库的连接池打爆还会产生超长事务把主从延迟拖上去。我的做法是把表按容量分成三档。小表直接整表拉中表按主键范围切分成若干分片大表则按主键取模分桶每个分片独立线程拉取。分片大小不是拍脑袋定的我按行数除以目标线程数再乘以每行平均字节数尽量把单个分片的传输量控制在 100MB 左右。这样既不会因为分片太小导致线程切换开销过大也不会因为分片太大让单个任务成为长尾瓶颈。并发控制上用了一个很简单的策略连接池大小固定分片任务通过信号量控制同时执行的数量。第一版我图省事所有表统一并发 8结果小表浪费资源大表又跑不满。后来改成按表档位分别配置小表并发 2中表并发 8大表并发 16配合错峰启动整体吞吐才算稳定下来。4. 一致性保障比对、幂等、延迟监控三板斧4.1 全量完成后的双层比对全量跑完看到目标端行数和源库一样我当时并没有松口气。行数一致不代表数据一致业务上最常见的脏数据场景恰恰是行数相同但内容不同某一行被更新过但更新事件丢了插入一条又删除另一条净行数不变数据却错得离谱。所以我写了一个双层比对工具。第一层是粗筛对每张表计算行数和关键字段的聚合校验值。这里有个性能关键点MySQL 计算校验值时不要用ORDER BY否则几亿行的表会跑出极其难看的耗时直接全表扫描聚合即可。第二层是对粗筛不一致的表做逐行 diff按主键分批取出源端和目标端的数据逐字段比较。实际跑下来绝大多数表第一层就能通过真正需要逐行 diff 的表非常少比对成本可控。4.2 增量任务的幂等设计宁可重复不可错乱数据同步过程中重复消费几乎是不可避免的。Kafka 可能因为消费者组重平衡导致消息重投Worker 可能处理到一半崩溃重启后从上一个提交位点重新消费。如果目标端写入走的是纯 INSERT重复消费直接导致主键冲突或数据翻倍。我在目标表设计上做了一个关键决定写入操作全部走 upsert而不是 insert。幂等键由源表唯一键加事件时间戳组成更新时以事件时间戳作为版本判断依据时间戳更旧的事件直接丢弃。这样即使同一条变更被重放了三次最终落库的数据也不会变。这个设计在后来一次 Worker 灰度发布导致的消息重放中帮我省掉了清表重灌的灾难性操作。4.3 延迟到底怎么量化增量同步最容易被主观感受欺骗。看 Kafka 的 lag 为 0就觉得实时性很好实际上 Kafka lag 只能说明消息被消费组拉走了不能说明消息已经写入目标端。如果 Worker 写入目标端卡住了消费已经完成但数据还在内存里排队Kafka lag 照样是 0。我在 Worker 里对每个事件记录了两段时间从 binlog 产生到事件被 Worker 接收的传输耗时以及事件从接收完成到写入目标端的应用耗时。这两个指标分别上报到 Prometheus用 Grafana 看 P95 和 P99 分位数。项目运行起来之后我才能确定地说增量的真实延迟在大多数时候是 1 到 3 秒而不是“感觉上很快”。量化是一切优化的前提没有指标就没有发言权。5. 压测与调优从吞吐 2000 到 1.5 万的调整过程5.1 第一轮压测卡在把源库打爆功能跑通之后我用 50 张表、每张 200 万行的数据量做了第一轮压测结果非常难看全量同步吞吐只有 2000 行每秒。定位后发现瓶颈不在 Worker而在源库。全量任务把所有并发线程一拥而上源库的连接池瞬间被打满大量查询排队慢查询数量猛增数据库主从延迟也跟着飙升。解决办法并不复杂给全量导出加一个全局连接池限制分片任务启动时加随机 jitter让各分片错开启动时间。这相当于给源库做了一层流量整形不让同步任务变成数据库杀手。调整之后吞吐提升到了 7000 行每秒数据库压力也回到了正常水位。5.2 第二轮压测瓶颈转移到序列化开销吞吐到 7000 行每秒后优化空间再次见顶这次通过监控发现 Worker 的 CPU 使用率已经接近 90%。进一步 profiling 发现罪魁祸首是事件序列化。Debezium 默认输出的变更事件包含前镜像和后镜像一条简单的 UPDATE 也可能带着完整行数据JSON 格式下序列化和反序列化开销极大。优化动作有两个一是把 Kafka 消息从 JSON 切换成紧凑的二进制格式序列化和反序列化耗时直接下降一个量级二是 Worker 从单条写入改成批量写入攒满 500 条或 200 毫秒时间窗口满足任一条件就批量提交一次。这两个改动叠加吞吐成功突破 1.5 万行每秒CPU 占用也降到了可接受的水平。5.3 参数分档配置才能真正贴合业务调优收尾前我把所有表重新梳理了一遍按单表数据量分成了大、中、小三档每一档单独配置批大小、并发数、提交间隔。非要给一个参考起点的话表类型数据量参考并发数批量大小提交间隔小表100 万行以下2200 条500ms中表100 万到 1000 万行8500 条200ms大表1000 万行以上161000 条100ms统一参数在小表上浪费资源在大表上又不够用分档配置之后全量阶段的整体耗时比第一版压测缩短了四分之三。6. 踩坑实录三个让排查花掉一整天的坑6.1 时区不一致导致所有时间字段偏移 8 小时全量比对时发现一个诡异现象一部分表行数一致、校验值也一致但逐行 diff 时所有 datetime 字段都不相等差异稳定在 8 小时。第一反应是数据源问题查了半天才发现根因在 JDBC 连接参数——源库和 Worker 所在机器时区不一致连接串里又没有显式指定serverTimezone驱动解析时间时用了机器默认时区导致时间整体偏移。修复方案很简单连接参数统一指定serverTimezoneUTC应用层读写时间也全部显式用 UTC展示层再按业务时区转换。这个坑提醒我任何涉及跨机器的时间处理必须在连接层面就钉死时区不能依赖环境默认值。6.2 大事务导致的 binlog 事件乱序另一个坑是数据错乱。表现是有几张表的最终金额字段对不上不是丢数据而是先后顺序错了。追查链路后发现问题出在 Kafka 分区策略上。一个大事务更新了同一张表的上百万行数据这些 binlog 事件本身在日志里是有序的但 Kafka 默认按 key 哈希分区如果分区键设计不合理同一行数据的多个更新事件会被分到不同分区消费者并行处理后后写入的旧事件反而可能先落到目标端造成最终值不是最新值。修复方式是双管齐下一是 Kafka 生产端把分区键统一设为表主键保证同一行的变更事件一定进入同一个分区从源头上保证行内顺序二是 Worker 消费时对同一主键的事件按 binlog 位点排序后再应用双保险兜底。这个坑之后我给自己立了一条规矩凡是涉及金额、库存这类强状态字段的表必须验证事件顺序不能假设 Kafka 天然保序。6.3 无主键表带来的连锁反应项目中最麻烦的一张表是一张历史数据表压根没有主键。没有主键直接引发三个问题全量分片没办法按主键范围切只能全表扫描幂等键无处构造更新事件重复消费时没办法去重逐行 diff 也失去锚点没法准确定位不一致的行。排查到最后是找业务方确认这张表的写入模式之后临时加了一列自增主键问题才彻底解决。这件事给我最大的教训是项目启动后第一件事不是写代码而是梳理所有待同步表的主键情况。无主键表要尽早和业务方确认要么补列要么用组合列拼接唯一键不要等到全量跑完、增量上线时才暴露那种阶段再返工代价是整个链路推倒重来。7. 上线后的运行数据与后续演进7.1 真实运行指标一周内的表现系统上线后我连续盯了一周多的监控数据。单日同步行数稳定在 8000 万左右增量延迟 P99 控制在 3 秒以内全量阶段的最大峰值吞吐达到了 1.5 万行每秒。日常运行中最让我安心的是幂等设计和延迟监控及时发现了两次问题一次是 Kafka 滚动重启导致的消息重放另一次是目标端磁盘 IO 抖动引发的写入积压都做到了快速定位和干预没有造成数据丢失。7.2 这个架构还能怎么演进dballgts01e01-1 作为第一个迭代目标是把同步链路跑通、跑稳。后续如果继续演进有几个方向值得投入把全量导出改成基于数据库快照的方式减少对源库的查询压力引入 Schema 变更自动同步源库加列、删列时目标端能自动感知把 Worker 改造成无状态服务配合 Kafka 分区做到水平扩容。这些方向每一个都能把系统的能力边界向外推一层但也都自带新的复杂度和坑扩的时候一定要控制住范围一次只动一块验证稳定后再动下一块。如果让我重新做一次这个项目我会在第一天先把所有待同步表的无主键情况梳理清楚而不是等埋好的雷炸了再去拆。最后再分享一个小技巧每次调整 Worker 配置或者升级 Debezium 版本不要直接切生产先挑一张核心小表走十分钟影子流量看延迟和写入量有没有异常再逐步放大范围。这套“先影子、再小流量、后全量”的发布节奏帮我避掉了好几次潜在的生产事故。同步系统不像普通接口问题往往延迟暴露等你发现数据已经错了修复成本就完全不一样了。
返回列表