@toc

兼容 是对前人努力的尊重 是确保业务平稳过渡的基石 然而 这仅仅是故事的起点
上篇把单通道内部那几个优化讲完了,这篇来啃硬骨头------多通道并行。
我前面说过,单通道你优化到天上去,本质还是一个线程、一个连接在写,多核CPU大部分核心在摸鱼。真正让吞吐产生量级变化的,是开多个通道同时往目标库里灌。但并行这扇门一推开,数据顺序、事务完整性、DDL安全、故障恢复这些雷全冒出来了。
KFS的路由策略不是一开始就完备的,它是被一个又一个坑逼着,从最原始的样子一路演进过来的。我扒的时候最大的乐趣就在这------你能清楚看到每一代策略解决了什么、又留下了什么新麻烦。这篇我就按这个演进的路子往下讲,最后再说说多通道挂了怎么恢复,以及我自己做的460万条数据的实测。
先把要解决的问题钉在墙上
多通道并行,说白了就是上游的变更事件流进来,经过Partitioner这么一个分流器,被分配到不同通道,每个通道一个线程、一个连接,各写各的。
csharp
上游事件流(DML / DDL / 提交)
│
▼
[Partitioner 路由引擎] ← 每个事件该进哪个通道?
│
┌────┼────────────┐
▼ ▼ ▼
Q0 Q1 ... QN
线程0 线程1 线程N
连接0 连接1 连接N
│ │ │
└────┴────────────┘
▼
目标端数据库
整个多通道设计,翻来覆去就是在回答一个问题:一个事件,到底该分到哪个通道?
这个问题看着简单,实际上是一连串互相打架的要求------你要并行度高,就容易牺牲顺序;你要保证同表有序,就可能让某个通道忙死;你要绝对均衡,又可能把有依赖的事务拆开。后面这些策略,每一个都是在这几个要求之间做不同的取舍。
我先把结论撂这:没有银弹。KFS提供了好几种路由策略让你按场景选,还能包着组合用,这才是它真正聪明的地方。
第一代:原始单通道,连路由都没有
最开始,目标端就是单通道跑,压根没有路由逻辑,任务和通道一对一绑定就完事。
arduino
Task 0 → 通道0
Task 1 → 通道1
不需要任何分配计算,零开销
它提供了最简单的多通道基础,外部系统想自己决定怎么分也行,自己没有任何计算成本。
但问题明摆着------同一张表的INSERT可能进通道0,UPDATE却进了通道1。这俩要是在不同通道并行,UPDATE先执行了,数据顺序直接乱掉,一致性没法保证。而且分配权在外部,KFS自己控制不了负载均衡。
所以需要一个办法,让同一个数据源的事件始终去同一个通道。哈希策略就这么来了。
第二代:哈希分配,同源有序
思路很直接,对数据源标识(shardId)做哈希,再对通道数取模:
matlab
shardId "oracle" → hashCode() → 42 → 42 % 4 → 通道2
公式:abs(shardId.hashCode()) % 通道数
"oracle" → 42 → 通道2
"kingbase" → 17 → 通道1
同一个shardId,哈希值固定,永远落到同一个通道。这么一来,同一个数据源的所有操作都在一个通道里顺序执行,顺序有了。
上篇说的那个"INSERT和UPDATE散到不同通道"的问题,在这一代就解决了------只要它们来自同一个数据源,就一定在同一个通道,通道内部又是严格有序的。
但新坑紧跟着就来了。哈希只认shardId,不关心每个数据源的数据量。万一某个数据源是热点------比如核心的订单库,产生的变更占了大头------它对应的那个通道就长期满载,其他通道却闲得发慌。忙的忙死,闲的闲死,整体吞吐还是被那个热点通道卡住。
于是需要一种不关心数据源归属、只关心各通道事务数均不均衡的办法。轮询出场。
第三代:轮询,事务数绝对均衡
轮询更简单,按事务序号对通道数取模:
scss
transno % 通道数
Tx1 (订单库) → Q0
Tx2 (日志库) → Q1
Tx3 (订单库) → Q2
Tx4 (报表库) → Q0
Tx5 (订单库) → Q1
Tx6 (日志库) → Q2
四个通道,每个通道分到的事务数一样多
不管你是哪个数据源,轮到哪个通道就是哪个,事务数量上绝对均衡。热点数据源的事务被摊到各个通道,热点通道的问题没了。
听起来挺美,但它又忽略了一个维度------事务大小。
事务数均衡,不等于实际负载均衡。一个事务可能包含几十万行变更,另一个事务只有两三行。轮询只数事务个数,那个几十万行的大事务整个砸进一个通道,其他通道处理完小事务早早空转,就等着它。我在真实项目里就碰到过,凌晨批量跑一个超大事务,单个通道被堵得死死的,监控上看其他通道全空闲,但整体吞吐就是上不去。
所以得能实时感知每个通道实际积压了多少活儿,动态选最空的那个。这就是负载均衡。
第四代:负载均衡,盯着队列深度
负载均衡的逻辑是,每次分配前先看一圈各通道的队列,谁队列里积压的行数最少就给谁:
yaml
argmin( queue[i].size )
Q0: 5000 行
Q1: 5 行 ← 选我!
Q2: 3200 行
每次都挑当前最空的通道
这样能实时把活儿往空闲通道送,队列深度始终比较均衡,大事务小事务的差异被动态消化了。
但负载均衡也不是终点,它留下了两个很要命的盲区,我必须重点说。
第一,它只看队列深度,不管同一张表的数据不能散到不同通道。 这么分,同一张表的事件完全可能进不同通道,等于把第二代好不容易解决的"同表无序"问题又放回来了。
第二,没有DDL并发保护。 这个更危险。DDL------比如ALTER TABLE加列、CREATE INDEX建索引------要是和同一张表的DML在不同通道同时执行,一个在改表结构、一个在往表里插数据,元数据直接冲突,严重的会导致数据损坏。前面哈希、轮询、负载均衡,没有一个考虑到DDL这个事。
另外,生产上还有个需求它满足不了------DBA有时候就想手动指定,核心表走高速通道、日志表走慢通道、报表表走专用通道,这种人为调度它做不到。
第五代:文件映射,把控制权交还给DBA
针对手动调度,搞了个配置文件的方式,读一个叫shard.list的映射表:
arduino
# shard.list 配置示例
db_order.* → 通道0 (核心业务)
db_log.* → 通道1 (日志)
db_report.* → 通道2 (报表)
* → 通道0 (默认兜底)
哪张表去哪个通道,DBA写得明明白白,重启配置生效。核心表想独占通道、日志表往后放,都能控制。
但它只解决了"手动控制路由",前面说的DDL并发安全问题,它照样没碰。这个DDL的雷一直挂到最后才被专门处理。
但注意,它对有主键和没主键的表处理不一样,这个区别很关键:
ini
✅ 有主键表:
hashKey = 模式名.表名 + 主键值
同表不同主键行可以分散,单表并行
⚠️ 无主键表:
hashKey = 模式名.表名(退化成表级)
因为没有主键,没法判断两行会不会冲突
整张表必须串行,安全第一
我上篇就念叨过补主键的事,到这儿你就看明白为什么了------没主键,行哈希直接退化成表亲和,并行能力作废。所以迁移前给关键大表补上主键,等于白捡的并行性能。
最后一道防线:DDL安全和表级一致性兜底
DDL那个雷,前面几代都没拆,最后用一个安全包装层来解决。思路是这样,用一个叫NoneCritical的包装器把内部分配策略包起来:
arduino
事件到达
│
▼
是 DDL 吗? ── 是 → 固定送到通道0
│
否(DML)
│
▼
查"表→通道"映射表
├ 这张表已经在通道2了?→ 必须去通道2(表亲和)
└ 是张新表?→ 交给内部Partitioner分配,然后记下映射并持久化
效果:
☑ DDL永远走通道0,不会和DML在不同通道撞车
☑ 同一张表的DML一定在同一个通道,有序
☑ 里面可以包任意一种内部分配策略
DDL全部固定到通道0串行执行,从根上杜绝了DDL和同表DML在不同通道并发导致元数据冲突的问题。DML则通过一张"表到通道"的映射表保证同表同通道。这层包装最妙的是它不替换你选的内部分配策略,而是套在外面做安全兜底------你想用行哈希就用行哈希,外面再包一层DDL保护。
到这儿,并行的几个雷才算基本拆完:同源有序、负载均衡、手动调度、跨表并行、单表并行、DDL安全,各有各的策略,还能组合。这就是我前面说的,没有银弹,但给你一箱子零件,让你拼出最合适的方案。
多通道挂了怎么办------位点的学问
并行还有个绕不开的难题:故障恢复。单通道好办,记一个位点,挂了从那儿继续。多通道各跑各的,进度还不一样,怎么保证恢复后一条不丢、一条不重?
这个问题刚接触的时候我是真有点懵,单通道记一个位点天经地义,可你想象一下十六个通道,有的处理到第100号事务、有的才到97号,中间还有事务被拆成了好几个片段,这状态乱成一锅粥,凭一个数字根本描述不清楚。我一开始甚至怀疑这事儿能不能靠位点解决,后来看完它那套复合位点的设计才服气,人家是真把每个通道的进度和全局的事务完成情况都揉到一起记了。
KFS目标端有张位点表,每个通道一行:
arduino
task_id | seqno | fragno | last_frag | wait_seqnos
────────────────────────────────────────────────
通道0 | 100 | 2 | true | 10 | 100-2 | 98-0,99-1 | 97-3
通道1 | 99 | 1 | true | 9 | 100-2 | 98-0,99-1 | 97-3
通道2 | 97 | 3 | true | 8 | 100-2 | 98-0,99-1 | 97-3
wait_seqnos是个复合位点,前面是个单调递增的全局提交计数,后面跟着各通道待处理的位点。每个通道提交后,把"自己处理到哪了、全局还有哪些事务没完成"都写进去,崩了之后靠这个重建状态。
恢复的时候,有两个关键位点,再配合三段区间判定:
ini
通道进度:通道0=100,通道1=99,通道2=97
重发起点 = min(100, 99, 97) = 97 ← 从这里开始重发
上界 lastMaxPoint = max = 100 ← 重发到这儿为止
从 seqno=97 重发,逐条判定三段:
① 已提交:97 ≤ seqno ≤ 100,且不在待处理列表
→ 跳过,绝不重复写
② 未提交:在待处理列表里
→ 重放到原来的通道
③ 新事件:seqno > 100
→ 正常处理,恢复完成
逻辑很严密------min决定从哪儿重发(保证不丢,最保守地从最慢通道的位置开始),max决定重发到哪儿为止,待处理列表决定具体哪些要补。已经提交的一律跳过防重复,没提交的重放回原通道,之后的新事件正常走。这么下来,Exactly-Once------不多不少,正好一次。
说实话,我自己恢复过一次,进程意外退出,重启后自动从位点接上,数据没重没丢,延迟接着往下追。这套机制平时看不见,但它是你敢放心并行的底气。
460万条数据实测,表拆分对决行哈希
光看演进不过瘾,我做了个实测,专门对比表拆分和行哈希这两种对付大事务的策略。
数据构造:58个事务,总共460万条数据,两张表a和b。前18个事务往表里插60万,然后20个事务只往a表插、每个10万条纯INSERT共200万,最后20个事务往a、b两表插、每个10万条且INSERT/UPDATE/DELETE都有,又200万。通道数配16。
场景一:表拆分(验证跨表事务不独占单通道)
场景二:行哈希(验证单表海量数据能均衡)
结果对比很说明问题。
表拆分方式,因为我这数据核心就两张表,按表拆完,你看监控里真正在跑的通道就两个,其他通道都是空的(数据是0、seqno=-1):
css
q-to-dbms Events=41 ← 通道0在跑
q-to-dbms1 Events=4 ← 通道1在跑
q-to-dbms2 Events=0 ← 通道2空闲
q-to-dbms3 Events=0 ← 空闲
...一直到 q-to-dbms15 全是 0
两张表 → 只有两个通道在执行
行哈希方式就完全不同,虽然还是那两张表,但数据按主键打散,所有16个通道全在跑:
css
q-to-dbms Events=1
q-to-dbms1 Events=1
q-to-dbms2 Events=1
...一路到...
q-to-dbms15 Events=6
全部16个通道都有数据,没有一个空闲
这就直观印证了前面的结论:表拆分对"跨表大事务"有效,表越多并行度越高,但如果事务集中在一两张表上,并行度就被表的数量卡住;行哈希直接按行打散,哪怕只有一张表,也能把所有通道全部用满。
吞吐上,多通道并行确实接近线性增长------通道数往上加,吞吐跟着往上走(当然到数据库自身IO、CPU的极限就到头了,不能无限加)。我实测的体会是,开通道之前先摸清目标库的承载能力,16个线程同时写,连接数、CPU、IO都得兜得住,不然通道开得越多,数据库锁竞争和IO争抢越严重,反而变慢。参数和数据库容量得一起规划。
光看实验室数据不够,唠两个真实大场面
我自己的实测才几百万行数据,说实话在有些客户面前不算什么。再给你们说两个资料里看到的大场面,能更直观感受目标端入库扛到极限是什么样。
一个是解放军总医院的医疗云数据汇聚。他们要把下属好多个医学中心和医疗区的所有医信系统数据,全部归集到数据仓库里做分析。涉及的系统你们听听------HIS、LIS检验、电子病历、急诊、康复、护理、超声、心电,全是关键业务,一刻不能停。源库也是五花八门,好几个版本的Oracle、SQL Server、MySQL、KingbaseES都有,操作系统Windows、Linux、AIX、Solaris全齐。存量数据60TB往上,每天还新增300多GB。
他们的架构是每个医学中心部署KFS前置节点,实时把业务数据采集过来,清洗、转换之后合并入湖。这种多源汇聚的场景,目标端要同时接几十个源的写入,入库吞吐和数据冲突处理都是硬考验。最后做下来,60TB全量加每天300多GB增量,业务不停、性能无损,采集转换的延迟控制在10秒以内。这个数据量,目标端入库但凡差一点,整个湖就别想实时更新了。
另一个是某直辖市的市政交通一卡通清结算系统。这系统承载1.8亿用户,存量12TB多,早高峰三个小时5900万笔交易,核心清结算系统,绝对不允许停机。要从原来的Oracle切到国产读写分离集群。存量12TB、日增500多GB,而且是金融级,数据一致性要求极高,一笔都不能错。
他们靠KFS做零停机迁移,之后还在业务中台的结算系统和数据分析平台之间持续同步。这种场景目标端入库压力有多大,你们想想早高峰那三小时,每秒涌进来的都是真金白银的交易记录,写入稍一卡顿,结算就积压。
举这俩例子不是为了凑数字,是想说明一个事------目标端入库这套并行机制,不是实验室里的花架子,60TB、12TB这种规模、还不能停机的核心业务,真的是靠它硬扛下来的。规模会放大所有设计上的缺陷,扛得住这种场面,才说明这套入库架构是真扎实。
调参这事儿,我再啰嗦几句
前面讲策略演进的时候没细说参数,这里集中念叨下,因为我踩过的坑大半跟参数有关。
properties
# 我实际调过的几类(具体名字以你手上的版本为准)
# 通道相关,这是并行度的总开关
channels = 16
# 单批大小,多少条刷一次
applier.batch.size = 2000
# 定时刷批,毫秒,防止数据一直捂着
# applier.flush.interval = 500
# 语句缓存大小,看命中率调
# statement.cache.size = 500
# 是否开表拆分(跨表大事务)
# table.split.enable = true
# 是否开行哈希(单表热点)
# row.hash.enable = true
我调参的路子一般是这样:先定通道数,这个取决于目标库能扛多少并发连接和IO,不是越多越好;然后定批大小,追数据阶段开大、稳态调小;定时刷批务必配上;缓存和路由策略根据数据分布选。每改一组,就压一轮,盯着三个东西------同步延迟、目标库CPU、目标库IO,找到吞吐上去了但库还没被压垮的那个点。
千万别一上来把所有参数都拉满,那不是调优,是赌博。一个变量一个变量来,你才知道瓶颈到底在哪、每个参数起了什么作用。我早年图快,通道、批次全开最大,结果目标库IO直接打穿,还查不出来是哪个参数惹的祸,老老实实回退重来。这个教训送给你们。