土法炼钢兴趣小组的算法知识备份

【TiKV / HTAP 内核】Split / Merge / 热点:Region 何时该切、何时该并

文章导航

分类入口
databasestorage
标签入口
#tikv#region#split#merge#hotspot#load-base-split#batch-split#prepare-merge#raftstore

目录

《数据再平衡》 把 TiKV 归类为”动态分区”策略的代表,讲了分裂/合并的一般性目标:自适应数据量、避免空集群冷启动。但那篇文章里的”负载分裂”只是教学示意——真实 TiKV 里,触发分裂的信号有两条独立管线:一条看 Region 静态大小(字节数、key 数),另一条看 Region 实时负载(QPS、字节速率、CPU 占用),且后者是 TiKV 4.0 之后才补上的能力,专门解决”小表全表扫描”这类静态大小判断完全捕捉不到的热点。合并方向同样不是简单的”数据变小就合并”——它要走一个类似两阶段提交的 PrepareMerge/CommitMerge 流程,避免脏读脏写。本篇钉住这两条管线各自的判定条件、执行路径,以及和 PD 端 hot-region-scheduler第 7 篇)的分工边界。

本文是「TiKV / HTAP 内核」系列第 8 篇(共 18 篇)。上一篇PD 元数据与调度下一篇TSO→ 系列目录

版本锚定:TiKV 7.1–8.48.4.0 起 region-split-size 默认值从 96MiB 改为 256MiB、max-merge-region-size 从 20MiB 改为 54MiB,文内区分新旧默认值,不混用。


一、分裂管线一:按大小/按 key 数

TiKV 用两组独立的阈值判断”是否该切”:

维度 达到阈值即分裂 8.4.0 前默认 8.4.0 起默认
Region 字节大小 region-max-size(= region-split-size / 2 * 3 96MiB → max 144MiB 256MiB → max 384MiB
Region key 数 region-max-keys 960,000 → max 1,440,000 2,560,000 起算

来源:TiKV Development Guide Region;TiDB Docs TiKV Configuration FileTiDB 8.4.0 Release Notes。触发后,新分裂出的两个 Region 各自约等于 region-split-size(不是 region-max-size)——分裂点选在让两半大致均分的位置。

为什么两组阈值都要看:region 的字节大小由 RocksDB SST 的 TableProperties 估算得到(不必扫描整个 range),但统计行数在存在大量已删除/重叠版本时会失真——一个刚被批量删除的表可能仍占用较大字节数但 key 数极低,或者反过来。两个维度独立判断,任一触发都会分裂。

推荐 region 大小区间是 [48MiB, 256MiB];调大能减少 Region 总数(降低心跳与元数据开销),代价是单 Region 迁移/分裂耗时变长、查询大范围扫描的并发度下降(TiDB Docs Tune Region Performance)。


二、分裂的执行路径:不拷贝数据

分裂在 Raft 层是一条 BatchSplit admin 命令(早期版本叫 Split,只能切一刀;BatchSplit 支持一次提议切多刀)。raft_cmdpb.protoSplitRequest 携带 split_keynew_region_id;分裂命令本身像任何写命令一样,要经过 propose → append → commit → apply(第 5 篇)才算生效。

关键点:分裂不搬运一个字节的数据。原 Region 覆盖 key range [a, b);分裂只是在 apply 阶段把它在元数据上变成两个 Region:[a, m)[m, b),两者仍然共享同一个 RocksDB 实例——之后的读写按各自的 key range 过滤即可。这与 《数据再平衡》 里 B+ 树式”中点分裂”的直觉一致,但 TiKV 是逻辑分裂,物理数据的迁移(如果需要)留给 PD 后续下发的 move-peer 算子去做。

flowchart LR
  R0["Region R<br/>[a, b)<br/>same RocksDB instance"]
  R0 -- "BatchSplit at key m<br/>(Raft admin cmd, no data copy)" --> R1["Region R (derived)<br/>[a, m)"]
  R0 -- "BatchSplit at key m" --> R2["Region R' (new id)<br/>[m, b)"]
  R1 -. "still same RocksDB, filtered by key range" .- R2

share_source_region_size 字段(kvproto SplitRequest)区分两类分裂:用户发起的分裂(例如按表分区手动 split,新 Region size 记为 0,需要重新统计)与负载/大小检查触发的分裂(新 Region 继承原 Region 一半的估算大小)。

分裂点的选择有两种策略,pd-ctl operator add split-region 暴露了这个选项:

来源:TiKV pd-ctl 文档。默认按大小/key 数触发的自动分裂走的是内部统计路径,不需要每次全量扫描。


三、分裂管线二:Load Base Split

按大小分裂解决不了一个典型场景:一张小表(数据量远低于 96MiB/256MiB 分裂阈值)被大量并发访问——它只占一个 Region,所有请求打到同一个 Raft 组的 Leader,无论集群有多少节点都分不掉这个压力。TiDB 4.0 引入 Load Base Split,判定条件不看大小,看持续负载

If a Region meets one of the following conditions for 10 consecutive seconds, TiKV tries to split the Region: - the sum of its read requests exceeds split.qps-threshold; - its traffic exceeds split.byte-threshold; - its CPU usage in the Unified Read Pool exceeds split.region-cpu-overload-threshold-ratio

—— TiDB Docs, Load Base Split

参数 默认值(region-split-size < 4GB 时) 引入版本
split.qps-threshold 3000/s v4.0
split.byte-threshold 30MiB/s v5.0
split.region-cpu-overload-threshold-ratio 0.25 v6.2

Load Base Split 在 TiKV 节点本地跑raftstore::store::worker::split_controller),采样访问的 key range 分布,挑一个能让分裂后两半负载均衡的切点——这与按大小分裂”取中点”不同,它要考虑访问热度分布,可能选在数据量并不居中,但访问量对半分的位置。

这条管线完全不经过 PD:TiKV Store 自己发现某个 Region 持续过载,自己决定切、自己算切点,切完之后才通过 Region 心跳把新 Region 信息报给 PD。PD 端的 hot-region-scheduler第 7 篇)解决的是另一个问题:Region 已经被分开之后,把热的那个 Region(或它的 Leader)挪到更空的 Store 上。两者是接力关系,不是同一个机制:

flowchart TD
  A["Small table, single Region<br/>high QPS but low size"] --> B{"Load Base Split<br/>local to TiKV, no PD involved"}
  B -- "qps/byte/cpu over threshold<br/>for 10s" --> C["BatchSplit at load-balanced key"]
  B -- "under threshold" --> D["stay as one Region"]
  C --> E["Two smaller Regions,<br/>each reported via region heartbeat"]
  E --> F{"PD hot-region-scheduler"}
  F -- "still hot on this store" --> G["transfer-leader / move-peer<br/>to spread across stores"]

常见的操作误区:把 hot-region-scheduler 调得更激进(缩短判定周期、降低阈值)并不能替代 Load Base Split——如果热点集中在一个 Region 里,PD 只能把这个 Region 的 Leader 挪到别的 Store,挪不动”一个 Region 装不下的并发度”这个本质问题;必须先分裂,才有多个 Region 可以分散到多个 Leader 上。

Load Base Split 之前:scatter-range-scheduler 与手动 split-region

Load Base Split 是 TiDB 4.0 才引入的能力。在那之前(以及今天遇到 Load Base Split 覆盖不到的场景),PingCAP 官方运维手册给出的是两条手动兜底路径(Best Practices for PD Scheduling):

scatter-range-schedulerPD 端 的调度器(server/schedulers/scatter_range.go),和 Load Base Split 不在同一层:前者只是”把已经存在的 Region 打散到不同 Store”,并不会分裂 Region;后者在 TiKV 端 决定”是否要把一个 Region 切开”。官方文档特别提醒 scatter-range-scheduler 可能和全局调度器打架,不建议长期挂在大表上,用完应该 stop-scatter 关掉——这是一个明确写在文档里的运维边界,不是隐藏细节。


四、合并:两阶段流程,不是简单的反向分裂

合并(Region Merge)由 PD 发起,两个条件必须同时满足,且要相邻

条件 8.4.0 前默认 8.4.0 起默认
max-merge-region-size 20MiB 54MiB
max-merge-region-keys 200,000 540,000

以及一条时间约束:split-merge-interval(默认 1h)——新分裂出来的 Region、或 PD 刚启动后的一段时间内的 Region,都不会被立即拿来合并,避免”刚分裂就合并回去”的抖动。

合并不是分裂的简单逆操作,因为两个 Region 的副本此刻分布在不同的 Store 组合上,必须先让它们的副本落在同一组 Store 上(PD 先下发 move-peer 对齐副本分布),再走 Raft admin 层面的两阶段流程(源码 components/raftstore/src/store/fsm/apply.rsExecResult::PrepareMerge / CommitMerge / RollbackMerge):

  1. PrepareMerge:源 Region(将被并入目标 Region 的那个)的 Leader 提议一条 PrepareMerge 日志,其中记录目标 Region 的 epoch。提交后,源 Region 进入”准备合并”状态并设置一个 write fence——Peer::prepare_merge_fence 字段存在的目的是:如果这个 Region 上有悲观锁(第 11 篇),必须等这些锁相关的写命令先 apply 完,才能真正提出 PrepareMerge,否则可能把”已经删除的悲观锁”错误地带进合并后的状态。
  2. CommitMerge:目标 Region 的 Leader 确认源 Region 已经追平日志(catch_up_logs)后,提议 CommitMerge;提交后目标 Region 的 key range 扩展为两者之和,源 Region 的 peer 被销毁。
  3. RollbackMerge:如果目标 Region 的 epoch 在等待期间发生了变化(例如被其他调度打断),或者多数副本联署要求回滚,源 Region 会退出”准备合并”状态,恢复正常读写。
sequenceDiagram
    participant PD
    participant Src as Source region leader
    participant Dst as Target region leader

    PD->>Src: ensure replicas co-located with target (move-peer if needed)
    PD->>Src: issue merge (via heartbeat operator)
    Src->>Src: propose PrepareMerge (after write fence)
    Note over Src: source enters "preparing to merge",<br/>rejects new proposals
    Src->>Dst: source catches up logs
    Dst->>Dst: propose CommitMerge
    Note over Dst: target region range extended,<br/>source peer destroyed
    alt epoch conflict or rollback requested
        Src->>Src: propose RollbackMerge
        Note over Src: source resumes normal serving
    end

这套流程本质上是把”跨 Raft 组的原子变更”通过先冻结源、确认追平、再原子提交的方式实现,思路上和 第 10 篇 的 Percolator 两阶段提交同属”先准备、再统一收口”的家族,但这里协调的是 Region 元数据而不是用户事务,不要把两者混为一谈。


五、与 distributed/27 的对齐

《数据再平衡》 第三节把 TiKV 归入”动态分区”一类,用 check_split/check_merge 的教学伪代码说明”分裂看阈值,合并看相邻且都小”的一般规则;第 8.1 节的”负载分裂”示例(隔离热点 key 到独立分区)是示意性的,没有对应到真实参数。

本篇补的是三层机制细节,distributed/27 没有覆盖:

  1. 分裂在 TiKV 里有两条独立管线(大小/key 数 vs Load Base Split),不是一条统一的”负载判断”逻辑。
  2. 分裂是逻辑操作,不拷贝数据;只有后续调度才可能触发物理搬迁。
  3. 合并不是分裂的镜像操作,而是一个专门处理跨 Raft 组一致性的两阶段流程(PrepareMerge/CommitMerge/RollbackMerge),且要先由 PD 完成副本对齐这个前置条件。

六、学术谱系、争论与开放问题

谱系:按 key range 动态切分存储单元并用共识协议复制,最早的生产级先例是 Bigtable(Chang et al., Bigtable: A Distributed Storage System for Structured Data, OSDI 2006)的 tablet 机制——tablet 是 Bigtable 里可分裂/合并的最小调度单元,Region 与 tablet 是同构概念的不同实现(TiKV 额外把 tablet 的复制协议从 Chubby 锁 + GFS 换成了 Multi-Raft)。TiKV Development Guide 在解释 Region 的设计取舍时,明确用”按 range 分片 vs 按 hash 分片”的对比框架,这正是 Bigtable/HBase 一脉沿用至今的分片范式选择题:range 分片利于扫描和动态分裂,代价是需要一个中心化的元数据服务(PD/Master)维护路由表;hash 分片省掉了路由表,但牺牲了范围查询的局部性。

争论:静态大小阈值和 Load Base Split 该以谁为主,工程实践里没有定论。纯大小阈值的问题是响应不了小表热点(本文 §3 已展开);纯负载阈值的问题是可能对着一个持续增长但访问均匀的大表反复触发不必要的分裂,增加 Region 数量与心跳开销。TiKV 选择的是”两条管线并行,任一触发都切”的折中,而不是用负载阈值完全取代大小阈值——这本身是一种没有理论最优解、只能靠生产反馈迭代阈值的工程判断,PingCAP 官方文档也只给出经验默认值,没有给出理论推导。

开放问题:Load Base Split 的切分点选择依赖采样窗口内的访问分布,如果热点本身极度集中在单个 key(而不是一段 key range),任何切分都无法把这个单 key 的写压力分散到多个 Region——这是范围分片模型的结构性限制,不是参数调优能解决的问题。业界常见的规避手段是应用层加盐(key 前缀打散),但这会牺牲范围扫描的局部性;是否应该在存储引擎层提供”单 key 内部再分片”的原生机制,目前没有被 TiKV 或同类系统采纳,仍是开放的工程权衡。


七、常见误解

误解 事实
「分裂会先复制一份数据再切」 BatchSplit 只改元数据和 key range 过滤规则,两个新 Region 仍指向同一个 RocksDB 实例,不拷贝数据
「热点问题靠调大 hot-region-scheduler 的敏感度就能解决」 单 Region 装不下的并发度,必须先分裂出多个 Region 才有”挪 Leader”的空间
「合并是分裂的逆操作,一步到位」 合并需要先对齐副本分布,再走 PrepareMerge/CommitMerge/RollbackMerge 三态流程
「Region 越小越好,方便迁移」 Region 数量过多会增加心跳、元数据与调度开销;推荐区间是 48MiB–256MiB,不是越小越好
「按 key 数和按大小是同一个判断」 两者独立统计、独立触发,删除产生的大量 tombstone 会让两个维度的判断结果不一致

八、小结

三句话小结

  1. TiKV 的分裂由两条独立管线驱动:静态的大小/key 数阈值(8.4.0 起默认 256MiB/2,560,000 keys),和响应持续 10 秒过载的 Load Base Split(QPS/字节/CPU 三个阈值),后者专门解决静态阈值捕捉不到的小表热点。
  2. 分裂在 Raft 层是一条不拷贝数据的 BatchSplit 命令;合并则是需要先对齐副本分布、再走 PrepareMerge → CommitMerge(或 RollbackMerge)两阶段流程的跨 Region 操作。
  3. PD 的 hot-region-scheduler 和 TiKV 本地的 Load Base Split 是接力关系——后者先把一个扛不住的 Region 切开,前者再把切开后的 Region/Leader 分散到不同 Store,谁也替代不了谁。

下一篇进入 TSO:PD 如何用物理+逻辑混合时钟分配全局单调递增的时间戳,以及它和 Spanner TrueTime 的路线分歧。


上一篇PD 元数据与调度

下一篇TSO

返回 系列目录


参考资料

规范 / 文档(A 级)

  1. TiKV Development Guide, Region(分裂/合并阈值、range 分片 vs hash 分片取舍)。
  2. TiDB Docs, TiKV Configuration Fileregion-max-sizeregion-split-sizeregion-max-keysregion-split-keys)。
  3. TiDB Docs, TiDB 8.4.0 Release Notes(region-split-size 96MiB→256MiB、max-merge-region-size 20MiB→54MiB 的版本边界)。
  4. TiDB Docs, Tune Region PerformanceRegion Merge Config(合并触发条件、split-merge-interval)。
  5. TiDB Docs, Load Base Splitsplit.qps-thresholdsplit.byte-thresholdsplit.region-cpu-overload-threshold-ratio 及引入版本)。
  6. TiKV pd-ctl 文档(operator add split-region --policy=scan|approximateoperator add merge-region)。

源码(A 级)

  1. pingcap/kvprotoproto/raft_cmdpb.protoSplitRequestBatchSplitRequestshare_source_region_size)。
  2. tikv/tikvcomponents/raftstore/src/store/fsm/apply.rsExecResult::PrepareMerge/CommitMerge/RollbackMergeexec_batch_split)。
  3. tikv/tikvcomponents/raftstore/src/store/peer.rsprepare_merge_fencepending_merge_statecatch_up_logs)。
  4. tikv/tikvcomponents/raftstore/src/store/worker/split_controller.rs(Load Base Split 采样与切点选择)。

论文(A 级)

  1. Chang, Fay, et al. Bigtable: A Distributed Storage System for Structured Data. OSDI 2006(tablet 分裂/合并,Region 模型的谱系起点)。

站内对读

  1. 《数据再平衡》(动态分区一般性叙事);第 5 篇 raftstore 写路径第 7 篇 PD 元数据与调度第 11 篇 悲观事务与 ResolveLock

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。


By .