先问干过同步的兄弟们一个问题:你们做异构同步的时候,瓶颈一般出在哪?
我猜有人会说源端日志解析慢,有人会说网络带宽不够。这俩我都遇到过,但说真的,我这几年踩下来,最让人窝火的瓶颈不在源端,也不在网络,是在目标端——数据到了门口,就是"灌"不进库里去。
这个问题我琢磨了挺久,正好前段时间把KFS目标端入库这块的实现扒了一遍,有些东西挺有意思的,就想着写下来,分上下两篇。上篇先讲清楚目标端到底卡在哪、KFS入库这整条流水线长什么样,还有批量提交、小事务合并、语句缓存这几个招是怎么回事。下篇专门讲多通道并行——那个路由策略怎么一路演进过来的,我觉得是最有嚼头的部分。
先说清楚我面对的是个什么场面
事情得从一个迁移项目说起,老规矩,先交代背景。
客户那边业务系统从传统商业库往国产库搬,这玩意儿不是搬完家当天就完事的,新老系统得并行跑好长一段时间。我们做的时候,每天要扛几百GB、有时候上TB的增量。你们想想这个量,同步链路只要稍微慢一点,延迟就开始往上堆,跟滚雪球似的。
刚开始我没太当回事,源端采集我测过,日志解析那部分性能没问题,跑得飞快。网络也是内网,带宽管够。结果链路一挂起来,延迟数据就不对劲——源端那边一秒钟产生的变更,目标端要两秒多才消化得完。就这么个比例,短时间看看还行,跑上一天,延迟能积到几十分钟。
这不是开玩笑吗,割接的时候你得等增量追平,按这个速度,停机窗口翻倍都不够。
我把监控调出来,一个环节一个环节看时间消耗,最后定位到目标端写入上。具体什么情况呢,我给你们描述描述,你们肯定觉得眼熟。
目标端为什么慢——根子上就不对等
先理解一个事儿,我也是后来才想明白的:源端和目标端,这两边写数据的方式本来就完全不对等。
源端是什么场面?业务系统几百个连接同时往库里写,大家各写各的,数据库内部有自己的并发机制,日志产生的速度自然快得很。
目标端呢?CDC工具拿到这些变更,本质上是在"回放"——源端干了什么,我照着在目标端再干一遍。问题就出在这,源端几百个连接并行干出来的活,目标端要是一条条地串行回放,那能不慢吗。
这就是最核心的矛盾。源端决定了"能不能抓到",目标端决定了"能不能追上"。我以前总觉得抓取是难点,各种数据库日志格式不一样,适配起来头疼。后来才体会到,抓取那块再不济,格式是固定的、死的东西;反而是入库这个环节,稍微不注意,吞吐直接腰斩。
具体难点,我总结了这么几个,每个都实实在在的。
第一个,密集小事务,提交能把人逼疯。 源端业务每秒产生好多个小事务,每个事务都要commit。commit这个动作,你们做运维的都懂,不是内存里改个数就完了,要刷日志、要等磁盘IO确认。目标端老老实实照着回放,每个事务也commit一次,磁盘IO直接被打满,TPS被压在一个很低的水平上,你CPU还闲着呢,先被IO卡住了。
第二个,串行入库,性能天花板肉眼可见。 单线程回放,多核CPU只用了一个核,其他核全在看戏。你可能会说,这还不简单,多开几个线程不就行了。真没这么简单,下面第三个问题就是。
第三个,一旦并行,数据顺序就乱。 多线程往目标端写,你怎么保证顺序?同一行数据,源端先INSERT再UPDATE,目标端要是UPDATE那个动作先到了、先执行了,直接报错或者数据错乱。不能简单粗暴地按表分线程——碰到单个大事务往一张表猛灌的场景,按表分等于没分。
第四个,出错之后怎么恢复。 不同线程、不同通道各跑各的,位点得分开记。挂了重启,从哪儿开始?哪些已经提交了不能重复写,哪些没提交要重来?这些都得有精确的策略,不然要么丢数据,要么重复数据。
这几个问题摆在一起,你就明白目标端入库为什么是个硬骨头了。我当时看完这一堆,心里想的是,理想的方案得同时做到几件事:并发写入能用上多核、吞吐还能线性扩展;事务一致性得保证,不乱序不丢数;小事务得能合并、大事务得能拆分还不能把内存撑爆;出了错能精确定位、自动处理。
听起来有点贪心。但KFS这套入库方案,我扒完发现,它基本就是照着这个目标一路做出来的。
KFS目标端入库,整条流水线长什么样
讲具体的招数之前,先把整条流水线过一遍,有个整体印象,不然后面说什么批量啊通道啊,容易没概念。
大流程分三段,其实跟所有同步工具差不多,但KFS在每一段里塞的东西有点讲究。
源端数据库
│
▼
[Extractor 采集] 解析 Binlog / Redo / WAL
│ 实时抓 INSERT / UPDATE / DELETE
▼
[Filter 链] 过滤、转换
│
▼
[Partitioner] 事件分流,按算法路由到不同通道
│
├──► 通道0 [Applier 线程0 + 连接0]
├──► 通道1 [Applier 线程1 + 连接1]
└──► 通道N [Applier 线程N + 连接N]
│
▼
目标端数据库
源端采集那段我不展开,就是解析日志,把变更封装成统一的中间格式——他们叫KUFL,加密存的,好处是源端系统不用为采集额外建什么表,影响很小。数据通过网络传到目标端,重点就从Partitioner开始了。
这里多嘴一句,很多人以为数据到了目标端是直接照着源端SQL原样执行,其实不是。中间有个统一格式的转换过程——源端的变更被封装成统一的事件格式,目标端拿到之后再解析、重新生成对应目标库方言的SQL。这么设计是为了异构,源端换一种库、目标端换一种库,中间这层不用动,两头各自适配就行。
Partitioner是个事件路由引擎,它要实时决定,每一个变更事件应该进哪个通道。这个"怎么决定"就是下篇要讲的路由策略,演进了好多个版本,先放一放。
每个通道背后是一个独立的Applier线程,拿着自己独立的数据库连接,各跑各的。通道之间并行,这是吞吐能上去的基础。
现在把镜头拉近,看看单个通道内部是怎么干活的,这才是上篇的重点。
事件到达
│
▼
判断类型 ──► 是 DML 行数据吗?
│ │
│ ▼
│ 生成 INSERT/UPDATE/DELETE SQL
│ │
│ ▼
│ prepare 预编译 → 绑定值
│ │
│ ▼
│ addBatch 攒批(先攒着,不马上发)
│ │
│ ▼
│ Batch 满了?或者表/SQL变了?
│ ├─ 是 → 批量执行,刷出去
│ └─ 否 → 继续攒,等下一条
│
▼
是事务提交事件吗?
├─ 是 → 刷出剩余的批 → commit → 更新位点
└─ 否 → 继续下一条
看懂这个流程,你就明白它的几个优化分别插在哪儿了。所谓的批量提交、小事务合并、语句缓存,都不是什么玄学,就是在这个单通道流程的不同节点上做文章。一个一个说。
招之一:批量提交,先把网络往返砍下来
最容易理解的一个优化。如果不做批量,目标端每执行一条SQL,就得走一次网络往返:把SQL发过去、等数据库执行完、等结果回来。一条两条无所谓,几百万条变更堆在一起,这个等待时间就很恐怖了。
不攒批:
SQL1 → 网络往返 → 执行
SQL2 → 网络往返 → 执行
SQL3 → 网络往返 → 执行
6条SQL = 6次网络RTT,每次就算只有1ms,光网络就6ms
攒批:
SQL1 ┐
SQL2 │
SQL3 ├► addBatch 一条条往批里加
... │
SQL6 ┘
└► executeBatch 一次全发出去
6条SQL = 1次网络RTT
原理简单到我都不好意思展开,就是addBatch攒一批,executeBatch一次发出去,一次网络往返把一大批SQL全送到数据库。我们实测下来,光这一项,网络往返能减少大概二十倍,TPS肉眼可见地往上蹿。
但这里有个绕不开的权衡,也是我后来才留意到的:批攒得越大,虽然吞吐越高,可数据"可见"的时间也被推迟了。你想啊,数据在批里攒着还没发出去,目标库里就查不到,这个延迟是批量机制自己引入的。所以刷批不能光看"攒满了没有",还得有个时间策略——就算没攒满,过了一定时间也得刷出去,不能让数据一直捂着。
KFS里这个叫Buffer Flush,两种触发方式,定量和定时,本质上就是在吞吐和数据可见性之间找平衡。这个平衡没有标准答案,你要是对延迟特别敏感,就把批调小、定时刷得勤点;你要是先追平数据、延迟可以忍,就把批开大,吞吐优先。我一般是同步刚开始追数据的时候开大批,等快追平了、进入稳态了,再调小控制延迟。
说个真实的事儿,这个定时刷批我是被教训过才记住的。有次测试环境我把批开得特别大,又图省事把定时刷出关掉了,想看纯靠"攒满"能到多少吞吐。吞吐数字确实漂亮,但我中间连到目标库去查刚同步过去的数据,死活查不到,还以为同步挂了,折腾半天一看,数据全在批里捂着呢,批次没满就一直不发。后来生产上我再也不敢这么配,定量定时两条腿走路,缺一条都可能让你误判。
招之二:小事务合并,这个我得好好说说
批量提交解决的是网络往返,但还有个开销它没解决——prepare。
我先描述个场景,你们就知道问题在哪了。源端一个事务里,操作顺序可能是这样的:先写表1,再写表2,表3,表4,然后又回到表1、表2、表3……交叉着来,乱序跨表。这在业务上太正常了,一个订单事务,写订单表、库存表、流水表,中间可能又回头改订单表。
目标端要是严格按源端顺序处理,会发生什么?
按源端顺序一条条来:
表1 → prepareStatement(第1次)
表2 → prepareStatement(第2次)
表3 → prepareStatement(第3次)
表4 → prepareStatement(第4次)
表1 → 又得重新 prepare(第5次) ← 表1之前prepare过,但中间插了别的表
表2 → 重新 prepare(第6次)
表3 → 重新 prepare(第7次)
7次操作 = 7次 prepare,表重复出现,prepare也跟着重复
prepare这个动作,数据库要做词法分析、语法解析、生成执行计划,是要花CPU的。每切一次表就重新prepare一次,同样一条INSERT模板,被反复解析,纯属浪费。
KFS的做法是按表归并——把同一个事务里、对同一张表的操作归拢到一起:
按表归并后:
表1、表1 → 连续处理,prepare 1次,连续 addBatch,只是绑定不同的值
表2、表2 → prepare 1次
表3、表3 → prepare 1次
表4 → prepare 1次
7次操作 = 4次 prepare(等于去重后的表数)
prepare次数从7降到4,差不多少了四成。但我要提醒一句,这个优化看着简单,实际上有个很要命的前提——归并不能破坏事务的语义。
你们想过没有,把对同一张表的操作从"夹在中间"提到一起,顺序变了,结果会不会不一样?如果同一个事务里对同一行先INSERT后UPDATE,这俩是有依赖的,你归并的时候这个先后绝对不能动。我理解它的归并不是无脑重排,是在保证同一行、同一主键操作顺序的前提下,把能合的合起来。这个边界要是没处理好,数据就错了,而且是那种你单测发现不了、上线跑两天才爆的错。
所以我对这类"自动重排"的优化,态度一直是——收益认,但要验证。后面实测部分我专门构造了乱序跨表的数据去压它,就是想确认这个边界守没守住。
核心收益就三点:减少PreparedStatement的创建和解析开销;同表SQL复用同一条语句,只重新绑参数;再配合Batch连续addBatch,网络往返进一步减少。三招是套在一起用的,不是各干各的。
招之三:语句缓存,把重复解析彻底干掉
小事务合并解决的是"一个事务内部切表导致重复prepare",但拉长了看,还有另一层重复——跨事务的。
不同事务里,对同一张表的INSERT,SQL模板其实一模一样,都是INSERT INTO t VALUES(?, ?)这种,只是问号绑的值不同。如果每个事务、每条语句都重新prepare一次,同样的模板一天可能被解析几万遍。
没缓存:
"INSERT INTO t VALUES(?,?)" → prepare → 词法分析+语法解析
"INSERT INTO t VALUES(?,?)" → prepare → 又分析一遍
"INSERT INTO t VALUES(?,?)" → prepare → 再来一遍
同一个模板重复解析3次
有缓存:
生成SQL模板
│
▼
模板在缓存里吗? ── 命中 → 直接复用,跳过解析
│
没命中
│
▼
prepareStatement → 存进缓存
KFS会维护一个语句缓存,我记得默认大小是500条模板。相同的SQL模板命中了就直接复用,不用再走解析,只绑参数。省下来的是什么?词法语法分析、语法树构建、语义检查这些CPU密集的活儿,连执行计划都能复用。
这个优化单独看好像不起眼,每次省的CPU不多,但在高并发、几万条同类SQL的场景下,目标端数据库的CPU能明显降一截。我做过对比,开和不开,目标库CPU占用差了能有十几个百分点,在CPU本来就紧张的库上,这点余量有时候就是救命的。
这里我也有个批判性的看法。语句缓存这东西,缓存大小是个需要琢磨的参数——开小了,命中率上不去,省不了多少;开大了,缓存本身吃内存,而且模板太杂的话,大量模板只出现一两次,缓存里全是这种"冷"模板,命中率照样难看。500这个默认值对普通业务够用,但如果你的系统表特别多、SQL形态特别碎,得自己盯一下命中率再调,别以为开了缓存就万事大吉。
光说不练假把式,我做了个小事务合并的对照
扒完原理,我自己动手做了个演练,专门验证小事务合并,顺便看看真实效果。
数据是这么构造的:建t1到t10十张表,每张表三个字段,id主键加两个普通列。然后构造事务,每个事务里t1到t10轮流插,交错着来,关键是每张表在一个事务里出现两次、中间被别的表隔开。
-- 表结构,十张表都长这样
CREATE TABLE t1 (
id VARCHAR(50) PRIMARY KEY,
col1 VARCHAR(50),
col2 VARCHAR(50)
);
-- t2 ~ t10 同样的结构
-- 事务里的数据长这样,先插一轮 t1~t10,再回头插一轮
INSERT INTO t1 (id, col1, col2) VALUES (1, 'trx1_t1_r1', 'value_1');
INSERT INTO t2 (id, col1, col2) VALUES (2, 'trx1_t2_r1', 'value_2');
-- ... t3 到 t10 ...
INSERT INTO t10 (id, col1, col2) VALUES (10, 'trx1_t10_r1', 'value_10');
INSERT INTO t1 (id, col1, col2) VALUES (11, 'trx1_t1_r2', 'value_11');
INSERT INTO t2 (id, col1, col2) VALUES (12, 'trx1_t2_r2', 'value_12');
-- ... 再到 t10,等于每张表出现两次
然后分两组对比,A组按源端顺序一条条处理、每切表重新prepare,B组开启按表归并、同表连续复用。
日志里看得很清楚。没开合并的时候,刷出日志是这样的,一条接着一条切表:
SQL变更刷出: table=[public.t2], pendingCnt=[1]
SQL变更刷出: table=[public.t3], pendingCnt=[1]
SQL变更刷出: table=[public.t4], pendingCnt=[1]
...每刷一张就是一个新表,prepare跟着走
开了合并之后,日志变成了建缓存、命中缓存:
[MergeTxn 新建缓存: 表[public.t1], 当前缓存表数=1
[MergeTxn 新建缓存: 表[public.t2], 当前缓存表数=2
...一直建到 t10,每个表一个缓存...
[MergeTxn 命中缓存: 表[public.t1], 当前pendingCnt=1
[MergeTxn 命中缓存: 表[public.t2], 当前pendingCnt=1
...第二次出现的表全部命中,不再重新prepare...
第二次出现的t1到t10,日志里全是"命中缓存",没有再新建,说明归并确实生效了,prepare只在每张表第一次出现时发生。回放耗时我也对比了,数据量不大的时候差距不算夸张,但prepare次数是实打实砍下来了,等数据量放大到几十万、上百万条,这个差距就会被放大得很明显。
我特意核对了最终数据,十张表的行数和内容跟源端完全一致,没有因为归并重排出现丢行或者串行。这个是我最关心的,性能优化要是拿正确性换,那就本末倒置了。
顺带说个容易漏的:数据类型映射
讲入库,有个事绕不开,就是类型。SQL语句生成得再漂亮,数据类型对不上,要么直接插不进去报错,要么插进去了值是错的——后者更可怕,因为你当场发现不了。
我在项目里碰到过几个典型的。
Oracle那边的NUMBER类型,变化特别多。不写精度的NUMBER、NUMBER(10)、NUMBER(10,2),落到目标库映射规则不太一样。整数型的我一般让它落整数类型,带小数的落NUMERIC。这个前面批量转换也提过,关键是别图省事全部映射成一个大而全的NUMERIC,关联键、索引列全用高精度数值类型,执行计划和性能都会受拖累。
大对象也是个坑。CLOB、BLOB、TEXT、XML这些,还有老的LONG、LONG RAW。普通的批量绑定对大对象有时候不那么好使,驱动对单字段大小有限制,特别大的对象可能要特殊处理。我遇到过一次,某张表里存了个几百MB的对象,跟着批次一起走,整批都被它拖累得很慢,最后把这种超大对象单独拎出来处理才顺。
入库时要留意的类型(我的项目清单)
────────────────────────────
NUMBER 系 → 整数/小数分开映射,别一刀切
CLOB/BLOB → 大对象走单独逻辑,别混在普通批里
LONG/LONG RAW → 老类型,转 TEXT/BYTEA
DATE 系 → 注意时区和精度,毫秒微秒的差异
ROWID → 物理地址概念,目标端一般不保留
GIS 类型 → 单独的空间同步方案
────────────────────────────
日期类型我得单独念一句。时区、毫秒微秒精度,这俩是数据校验时"假差异"的主要来源。源端存的时间精度到秒,目标端到微秒,显示出来不一样,但数据其实是对的。入库和校验的时候都得把这个规则对齐,不然能给你报出一堆根本不存在的差异。
KFS白皮书里说它支持DML、DDL、DCL三类语句复制,SEQUENCE、函数、存储过程、视图、同义词、索引这些对象也都能同步,甚至无主键表也支持。我前面说过无主键表在并行的时候会退化——没法判断两行是不是冲突,只能整表串行,这就是类型和结构设计反过来制约并行性能的一个例子。所以老库要是有条件,迁移前补补主键,对后面并行入库帮助很大。
JDBC这块再说一嘴,目标端入库本质上是通过JDBC连接往库里写的,驱动版本别用太老的,批量写入、参数绑定这些行为,新旧版本表现不一样。我有个同事在这上面翻过车,同样的配置换了台机器就不对,查了半天是JDBC驱动版本差了好几个大版本,默认的批量行为变了。这种坑不写在明面上,全靠经验。
上篇先到这
一口气讲了不少,收个尾。
目标端入库这个事,我现在的理解是:它慢,根子在于源端几百连接并行写、目标端逐条回放这个天生的不对等。要破局,单通道内部先得把三件事做好——批量提交砍掉网络往返,小事务合并把prepare次数降下来,语句缓存把跨事务的重复解析也干掉,再用定量定时的刷批策略在吞吐和延迟之间平衡。
但这些说到底,都还局限在"单个通道内部怎么优化"。就算你把单通道的效率压榨到极致,它本质上还是一个线程在跑,多核CPU还是没用上。真正让吞吐量级跃升的,是多通道并行——开N个通道、N个线程同时灌库。
可一旦并行,前面埋的那个雷就引爆了:数据顺序怎么保证?同一张表、同一行的操作散到不同通道怎么办?DDL和DML在不同通道撞车怎么处理?KFS的路由策略为了解决这堆问题,从最原始的单通道,到哈希、轮询、负载均衡,再到表拆分、行哈希,前前后后演进了七八个版本,每个版本都是被上一个版本的坑逼出来的。
这部分我觉得最能看出设计思路,下篇专门掰扯。还有挂了之后多通道位点怎么恢复、怎么保证一条不丢一条不重,也放下篇讲。
你们要是也在被目标端写入慢折磨,先别急着加机器,按我上面说的,看看是不是没攒批、是不是在反复prepare,这几个是成本最低的优化,往往先就能解决一大半问题。剩下的硬骨头,咱们下篇见。