sharding - routing

07. 分片:一台装不下之后的所有套路

0. 本章先解决什么问题

复制解决了”机器挂了”,但每个副本都存全量数据,数据涨到一台装不下(或单机写入吞吐到顶)时,复制帮不上忙,只能把数据切开分到多台。这就是分片。所有分片方案的核心是同一个函数:

route(key) -> 节点

本章把这个函数的四种写法(取模、一致性哈希、槽位、范围)逐个拆开,再把 Redis Cluster、MySQL 分库分表、Kafka、Elasticsearch 各自的完整方案讲透,包括扩容、路由表、深分页、全局 ID 这些配套零件。

key 到节点的四种路由

这张图怎么读

同一个 key 流进四种路由函数:取模最简单但扩容全乱,一致性哈希只影响相邻段,槽位映射加了一层间接表,范围分片按区间切。下方标注每种的代表产品。

1. 取模:最直觉的写法和它的灾难

node = hash(key) % N

写起来一行,问题全出在 N 变化的时刻。算一笔具体账:

3 台扩到 4 台,一个 key 不用搬家的条件是
hash % 3 == hash % 4,只有约 1/4 的 key 满足
-> 扩容一次,约 3/4 的数据要跨机器搬迁
缓存场景: 搬迁期间这 3/4 全部未命中,等价于自造一次雪崩
存储场景: 全量搬迁的 IO 风暴 + 期间读写路由的两难

对照 Java 的老朋友:HashMap 扩容也是全量 rehash,单机内存里搬引用没什么,跨机器搬数据就是事故。
哈希表 里”扩容成本”那节,在分布式尺度上放大成了核心矛盾。
所以取模只适合 N 长期不变的场景,而”长期不变”恰恰是分布式系统里最奢侈的假设。

2. 一致性哈希:把扰动限制在相邻段

2.1 环的构造和查找

把哈希空间首尾相接成环(0 到 2^32 - 1)
节点按 hash(节点标识) 站上环
key 从 hash(key) 的位置顺时针走,遇到的第一个节点就是归属

用 Java 写路由查找就是一棵 TreeMap:

代码块JAVA · 4 行收起展开
TreeMap<Long, Node> ring = new TreeMap<>();
// 查找: 取大于等于 keyHash 的第一个节点,没有则绕回环首
Map.Entry<Long, Node> e = ring.ceilingEntry(keyHash);
Node target = (e != null) ? e.getValue() : ring.firstEntry().getValue();

增删节点的影响范围立刻变了:

加节点 X: 只有 X 逆时针方向到前一个节点之间的 key 迁给 X
删节点 Y: Y 的 key 顺延给顺时针的下一个节点
其余 key 纹丝不动,扰动从 3/4 降到约 1/N

2.2 虚拟节点:修数据倾斜

裸版的问题:节点少时环被切得极不均匀,某台可能扛半个环;挂一台时它的负载整个压给顺时针邻居,可能引发连环压垮。修补是虚拟节点:

每台物理机放 M 个分身上环(hash(“节点A#1”)、hash(“节点A#2”)…)
M 通常几十到几百(NGINX 一致性哈希每权重 160 个点)
key 先归分身,分身再归物理机
均匀度靠大数定律堆出来;挂一台时它的负载被打散给所有人

一致性哈希的主场是无中心的缓存路由和负载均衡:NGINX upstream 的 hash consistent、RPC 框架的一致性哈希负载均衡、各种客户端分片的老 Redis 方案。
它的短板:key 到节点的关系隐式藏在哈希函数里,想人为指定某段数据去某台机器很别扭,观测”哪个 key 在哪台”也不直观。这就引出第三种写法。

3. 槽位映射:Redis Cluster 的显式中间层

3.1 两段式路由

Redis Cluster 在 key 和节点之间插了一层固定编号的槽(slot):

slot = CRC16(key) % 16384 key 到槽: 固定函数,永不变,谁都能本地算
slot -> 节点 槽到节点: 一张显式的表,扩缩容只改表

拆开的好处:迁移的单位从”不可名状的哈希段”变成”看得见摸得着的槽”。迁移 5 号槽从 A 到 B,只动这个槽里的 key,其余 16383 个槽无感,进度可观测、可暂停、可回滚。这是上一章之后又一次”加一层间接换灵活性”,下一章会给这个思想立传。

为什么是 16384:节点间心跳包要携带”我负责哪些槽”的位图,16384 位 = 2 KB,65536 位就是 8 KB,心跳太肥;而集群设计上限约 1000 个主节点,16384 个槽足够切细。数字是工程折中,思想是”槽数固定地远大于节点数,让迁移粒度足够细”。

hash tag 是唯一的干预手段:key 里带花括号时,只对第一个非空花括号内的内容算槽,{user1000}:orders{user1000}:cart 必然同槽,为跨 key 操作留通道。

槽这个思想有一整个家族,认出来就能互相借直觉:一致性哈希的虚拟节点(物理机的”槽”,几百个分身让迁移和均匀都以分身为粒度)、Kafka 的 partition(相对 broker 而言就是槽,分区数固定地多于机器数,扩容靠 reassignment 把分区搬去新 broker,翻译表在 controller 手里)、ES 的 shard(同理,但数量定死不可改,是把”槽数”这个自由度提前锁死的版本)。
共同配方一句话:中间编号的数量固定且远多于节点数,“数据到编号”用死函数,“编号到节点”用活表,迁移和扩容全部落在活表上。

3.2 MOVED、ASK 和 smart client

  • MOVED:访问错了节点,回复 “MOVED 866 10.0.0.2:6379”
    含义: 这个槽已经永久归那边了
    客户端应更新本地槽位表,以后这个槽直接去新地址
  • ASK:槽正在迁移中(源节点标 MIGRATING、目标标 IMPORTING),
    源节点上找不到的 key 回 “ASK”
    含义: 就这一次去目标节点问(要先发 ASKING 命令),
    槽位表不更新,因为迁移还没完成
  • 迁移的实际动作:逐个 key 用 MIGRATE 命令原子搬运,
    搬完了才把槽正式指给目标节点,此后才是 MOVED
代码块JAVA · 2 行收起展开
smart client(JedisCluster、Lettuce)启动时拉全量槽位表缓存在本地,路由计算零网络开销;收到 MOVED 或拓扑刷新事件时更新表。
代价是集群模式的功能约束:多 key 命令、事务、Lua 脚本都要求所有 key 同槽(不同槽直接报错,用 hash tag 规避);只有 db0。

客户端实现细节在 客户端与命令管线

4. MySQL 分库分表:分片思想在关系库上的落地

关系库分片的特殊难度在于它的查询能力太丰富,分片会砍掉其中一大截。这一节把”拆什么、按什么拆、拆完剩下的麻烦”全部摆开。

4.1 四个象限先分清

垂直分库: 按业务拆(订单库、用户库、商品库),微服务标配
垂直分表: 按列拆,大字段/冷字段独立出去,让热表更瘦
(回想第 2 章: 行越小,一页装的行越多,缓存越有效)
水平分库: 按行拆到多个库(多个实例),扛写入和连接数
水平分表: 按行拆到同库多张表,只解决单表过大,不解决单机瓶颈
垂直解决”宽和耦合”,水平解决”行数和吞吐”。
“单表两千万考虑拆”的出处是第 2 章 B+ 树三层的算术,
但真正的判断标准是 B+ 树层高和业务的延迟预算,不是死数字。

4.2 分片键:整个方案的命门

水平拆的所有痛点源自一件事:路由函数只认分片键。选择标准三条:

  1. 绝大多数查询天然携带它(C 端几乎总是 user_id)
  2. 分布均匀(别用状态、日期这种聚堆的字段)
  3. 不可变(改了分片键等于要跨片搬这行数据)

不带分片键的查询只剩三条路,成本递增:

冗余一份按其他键组织的数据(订单按 user_id 分片,
再冗余一份按 merchant_id 的,双写维护)
建映射表(先查”订单号 -> user_id”的小表,再带键路由)
广播查询 scatter-gather(全分片都问一遍再聚合,兜底手段)

4.3 被砍掉的能力和补法

  • 跨片 join:基本放弃。补法按优先级:
    全局表(字典表这类小表每个库放一份)
    绑定表(订单和订单明细按同一个键分片,join 天然落在同库)
    字段冗余(订单里直接存商家名,接受最终一致)
    应用层组装(各查各的,内存里拼)
  • 跨片分页:深分页是重灾区。
    取第 100 页 10 条,每个分片都要取前 1000 条汇总重排,
    越翻越贵。补法: 禁跳页(只给”下一页”,用上一页末尾值做游标,
    每个分片只取 10 条);或把复杂检索整个搬去 ES
  • 跨片事务:本地事务失效。补法进分布式事务的地界
    (消息最终一致 / TCC / XA,此处只立指针不展开)
  • 跨片聚合:count/sum 各分片算完应用层合并,或直接上宽表/数仓

4.4 全局唯一 ID:自增主键的替补选拔

各分片自增必然撞号,替补方案逐个过:

UUID: 唯一性没问题,但 36 字符太长(回想第 2 章: 主键长则扇出小),
且完全乱序(回想第 2 章: 乱序插入页分裂),基本出局
雪花 snowflake: 64 位 = 1 符号位 + 41 位毫秒时间戳

  • 10 位机器 ID + 12 位序列号(单机每毫秒 4096 个)
    趋势递增(对 B+ 树友好)、本地生成无网络开销
    命门: 时钟回拨。机器时间被 NTP 往回调,可能发出重复 ID,
    常见处置: 回拨小则等待追平,回拨大则拒绝服务并告警
    号段模式: 数据库里一张表管”当前发到哪了”,
    应用一次领一段(比如 1000 个)在内存慢慢发,用完再领
    数据库压力降三个量级,双 buffer 预取可平滑续段
    Redis incr: 集中式自增,简单直接,可用性押在 Redis 上
    (实现见 mid - 全局唯一ID & 登录校验)

4.5 扩容:为什么推荐成倍扩

取模路由下 2 库扩 3 库,约 2/3 数据要动;
2 库扩 4 库(模数翻倍): hash % 2 == 0 的那半,
在 % 4 下只会是 0 或 2,数据只在”老库和它的新搭档”之间挪,
恰好一半数据移动,且可以先把新库做成老库的从库、
复制追平后改路由、再删各自不要的另一半,全程在线
通用的双写迁移四步(不依赖倍数关系):
双写(新老同时写)-> 存量搬迁 -> 校验比对 -> 切读、停老写

4.6 中间件的两个位置

JDBC 层(ShardingSphere-JDBC): 依赖包嵌进应用,
改写 SQL、合并结果都在应用进程内。无额外一跳,
但路由逻辑随应用部署,升级要全体发版
代理层(MyCat、ShardingSphere-Proxy): 独立进程伪装成一个 MySQL,
应用无感。集中管控,但多一跳延迟,代理自身要做高可用

这道”内嵌还是代理”的选择题,下一章会作为间接层位置的标准案例重新出现。完整展开在 分库分表 · 运维篇

5. Kafka 与 ES:分片作为并行度的单位

5.1 Kafka partition

Kafka 分区同时决定路由、顺序性和并行度:

  • 路由:消息有 key 时使用 murmur2(key) % 分区数,同一个 key 必然进入同一分区;没有 key 时,Kafka 2.4 以前使用轮询,之后改用粘性分区,攒满一批再切换分区,为批处理让路。
  • 顺序性:Kafka 只保证分区内严格有序,不保证分区之间的全局顺序。“同一用户的消息必须保序”通常意味着用 user_id 作为 key,让路由函数同时承载业务语义。
  • 并行度:同一消费组内,一个分区同时只能由一个消费者持有,因此消费并行度上限就是分区数,分区数本身也是容量规划参数。
  • 管理成本:分区并非越多越好。每个分区都对应一组段文件和文件句柄、一份副本同步开销以及一次故障转移工作量;达到数千分区规模后,controller 的故障恢复时间会明显拉长。

5.2 Elasticsearch shard

路由: shard = hash(_routing) % number_of_primary_shards
_routing 默认是文档 id,可指定(比如按租户路由避免广播查询)
主分片数建索引时定死,改数只能 reindex 重建。
为什么敢定死: 因为它没有槽位中间层,路由函数里直接除以分片数
(对照 Redis Cluster 用 16384 个槽提前切细。ES 的分片是完整的
Lucene 索引,物理成本远大于一个槽标签,两家取舍各有道理)
每个主分片配副本分片,查询主副皆可扛,写只走主

6. 副本和分片正交:标准户型图

把上一章和本章拼起来,就是所有分布式存储的标准户型:

数据 —分片—> N 份,解决容量和写吞吐
每个分片 —复制—> 1 主 + M 从,解决可用和读吞吐

  • Redis Cluster:16384 槽分给 N 个主,每主挂若干从(槽随主从切换整体漂移)
  • Kafka:topic 切 N 个 partition,每个 1 leader + followers(ISR 管同步)
  • ES:索引切 N 个 primary,每个配 replica
  • MySQL:分库分表后每个库再做主从 + 上一章的高可用方案

看新系统先画这张图:横轴数分片(容量),纵轴数副本(可用),两轴一拉,架构骨架就有了。

7. 热点:哈希救不了的那类倾斜

哈希只保证 key 数量均匀,不保证访问热度均匀。一个顶流明星的主页、一个秒杀商品的库存,单 key 的流量就能打爆它所在的那一个分片。分片对此无解(它再均匀也得有一台扛这个 key),补救全在分片之外:

  • 读热点:本地缓存兜前面(第 3 章 Caffeine 的位置),
    多级缓存让热 key 根本到不了存储层
  • 写热点:拆 key 加盐。库存 100 拆成 stock:{1..10} 各 10 个,
    随机挑一个扣,聚合时求和
    (眼熟吗: LongAdder 把一个热点 long 拆成 Cell 数组分散累加,
    同一个思想,单机版和分布式版)
  • 探测:热点是动态的,靠访问统计实时发现,再触发上述手段

8. 联系实际:分片方案的检查清单

  1. 分片键是什么?多少比例的查询天然带它?不带的走哪条路?
  2. 扩容要搬多少数据?搬迁期间读写怎么路由?可以在线吗?
  3. 热点 key 出现时,哪一层兜?
  4. 跨片 join / 分页 / 事务各用什么补法,业务能不能改造成单片操作?
  5. 全局 ID 方案定了吗?时钟回拨处理了吗?
  6. 路由表(槽位表/映射配置)存在哪,客户端怎么感知变更?

9. 学完本章你能解决什么问题

  1. 取模扩容为什么约 3/4 的 key 要搬家,这笔账怎么算?
  2. 一致性哈希的查找为什么是一次 ceilingEntry,虚拟节点在修什么?
  3. 槽位映射比一致性哈希多买到了什么,16384 怎么来的?
  4. MOVED 和 ASK 的语义差异是什么,迁移中的 key 怎么访问?
  5. 分片键的三条选择标准是什么,不带键的查询有哪三条路?
  6. 深分页为什么越翻越贵,游标法怎么把它变成每片只取一页?
  7. 雪花 ID 的 64 位怎么分的,时钟回拨为什么是命门?
  8. 成倍扩容为什么只动一半数据,还能全程在线?
  9. Kafka 分区数为什么是容量规划的一部分,多了有什么代价?
  10. 写热点的拆 key 加盐和 LongAdder 是什么关系?

核心一句话:分片就是设计 route(key) 这一个函数,四种写法在”实现简单、扩容平滑、可控可观测”三角里各占一角;分片键决定了你保留哪些查询能力;分片管容量、复制管可用、热点靠缓存和拆 key,三件事各归各位。

延伸阅读