16个通道并行灌库是什么体验——KFS入库这点事儿(下)

@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直接打穿,还查不出来是哪个参数惹的祸,老老实实回退重来。这个教训送给你们。

相关推荐
H.莓飛1 小时前
【Linux】命令行参数、环境变量与程序地址空间
linux·c语言·chrome·后端·centos
粥里有勺糖1 小时前
拿 Codex 协助清理磁盘,Nice!
前端·后端·github
专业程序开发源1 小时前
springboot高校学生社团管理系统74810-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·spring·php·课程设计
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】从鼠标点击到屏幕像素:LibreCAD绘图架构全链路解析之整体架构与源码组织
后端
专业程序开发源2 小时前
springboot生命故事书制作小程序65290-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·mysql·小程序·课程设计
AINative软件工程2 小时前
LLM Prompt Registry 工程实践:集中管理 Prompt,让模型调用不再散落在代码各处
后端·python·llm
IT_陈寒2 小时前
SpringBoot自动配置的坑我帮你踩过了
前端·人工智能·后端
vx_Biye_Design2 小时前
springboot角色扮演服务平台65161-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·python·spring·课程设计
vx_Biye_Design3 小时前
springboot咖啡厅顾客点单管理系统12080-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·spring·课程设计·express