主题
数据分布:决定数据放哪里,也决定以后怎么搬
给订单表加几台机器,难点不只是把数据均匀分开。一次按用户查询是否还只访问一个分片?一个大客户能不能拖垮整台机器?扩容时,新旧路由同时存在,谁还有权接收写入?分片、副本和再均衡必须一起设计。
本文从分片键开始,解释一致性哈希与虚拟节点,再区分复制拓扑,最后走一遍带代次检查的迁移流程。程序只模拟一个值从旧分片迁到新分片的受控过程,实际验证提前切换和过期路由会被拒绝;它不代表任何数据库的迁移协议。
先分清分片、副本和路由
分片把不同数据交给不同所有者,主要解决容量与负载分摊;副本为同一批数据保留多份,主要服务于容错和读取;路由告诉客户端某个键当前属于哪个分片,以及该分片可以访问哪些副本。
正在绘制图示…
查看图源代码
flowchart LR
C[请求:tenant_id=42] --> M[路由元数据:键到逻辑分片]
M --> S[逻辑分片 7]
S --> P[主副本:节点 A]
P --> F1[从副本:节点 B]
P --> F2[从副本:节点 C]
X[其他逻辑分片] --> D[节点 D 等]
“三副本”不能回答“分了多少片”,也不能独自保证强一致。三个异步副本仍可能读到不同版本;单个大分片复制三份后,每份仍然很大。路由目录本身也需要版本、缓存更新和故障处理,否则数据已经搬走,客户端仍会找旧地址。
| 先要回答的问题 | 影响的设计 |
|---|---|
| 常见查询以哪个字段定位? | 分片键及跨片扇出 |
| 数据和请求分布是否偏斜? | 热点拆分、独立大租户、缓存策略 |
| 是否大量按范围扫描? | 范围分片或保留有序键的结构 |
| 单个业务事务涉及哪些数据? | 共置策略与跨片事务成本 |
| 数据怎么扩容迁走? | 逻辑分片数量、目录映射与迁移状态机 |
分片键选错,后面的路由算法救不了查询
| 策略 | 数据怎么分 | 适合的访问方式 | 常见代价 |
|---|---|---|---|
| 哈希分片 | hash(key) 映射到分片 | 已知键的点查,键分布有较好离散性 | 范围扫描通常要跨片;热点单键仍在一处 |
| 范围分片 | 按键区间划分,例如日期或用户 ID 区间 | 有序遍历、范围过滤、批量删除历史数据 | 递增键可能把新写集中在最后一个区间 |
| 按租户/业务目录分片 | 目录维护租户到分片映射 | 一次事务和查询多在同一租户内 | 大租户偏斜、目录查询与变更成本 |
| 复合/分层策略 | 先按租户,再按哈希或时间细分 | 既需局部性,又有少量特别大的租户 | 路由和跨子分片查询更复杂 |
Bigtable 把有序行键空间拆成 tablet,是范围划分的经典实例;Dynamo 使用一致性哈希组织分布,则体现了另一种偏向键访问的设计。[1][2]
以用户订单为例,按 user_id 分片能让“查看某用户最近订单”集中在一个地方,却不一定方便“按全站创建时间扫描所有订单”。后者可能要并行查询多个分片,再合并排序和分页。只给每个分片各取 20 条,并不能自然得到全局正确的第 20 页。
分片键还决定事务边界。把一个商户的订单和余额放到同一分片,可能减少常见跨片事务,但跨商户结算仍然跨片。不能靠共置解决所有查询;应明确主要工作负载,并为报表、搜索建立专用读取模型,而不是要求一个分片方案同时优化所有方向。
分片数最好与物理节点数解耦。固定一定数量的逻辑分片,再把逻辑分片分配到物理节点,可以在扩容时移动部分分片;直接 hash(key) % 当前机器数,机器数变化会让大量键的目标跟着变化。具体逻辑分片数量应根据元数据、文件、复制和调度成本决定,并非越多越好。
一致性哈希:成员变化时,尽量少改键的位置
经典一致性哈希把键和节点映射到同一个环,沿约定方向找到第一个节点作为所有者。新增一个节点时,主要接管它在环上的前驱与自己之间那段键,而不是重算全体取模分组。这个性质来自一致性哈希的原始工作;实际系统还会加入副本选择和再均衡策略。[3]
下面是人为构造的数字例子,数字是环上位置,不是实测哈希值。节点 A、B、C 分别在 10、50、85;查找方向为数字增大,越过末尾回到 0。
| 键的位置 | 扩容前所有者 | 加入位置 70 的 D 之后 | 是否改变 |
|---|---|---|---|
| 20 | B,位置 50 | B,位置 50 | 否 |
| 60 | C,位置 85 | D,位置 70 | 是 |
| 90 | A,位置 10,绕回 | A,位置 10,绕回 | 否 |
正在绘制图示…
查看图源代码
flowchart LR
A["A:10"] --> B["B:50"] --> D["新增 D:70"] --> C["C:85"] --> A
K["键 60"] -. 顺时针首个节点 .-> D
图解释的是路由映射变化。键 60 的旧数据不会因为计算结果换成 D 就自动出现在 D 上。数据复制、并发写入处理、校验和所有权切换,仍然要由迁移协议完成。
虚拟节点解决的是区间分配粒度
一个物理节点只在环上放一个点,随机位置可能切出很不均匀的区间。虚拟节点让一个物理节点对应多个位置,分散它负责的区间;不同容量的机器也可以分配不同数量或权重的虚拟位置。Dynamo 论文进一步讨论了分区与节点分配策略的取舍。[2]
虚拟节点通常能改善大量键的统计分布,并让迁移粒度更细,但不是没有热点的保证:
- 一个键占了大部分流量,无论它映射到哪个虚拟节点,仍然落在同一个负责者。
- 键数量均匀,不代表字节数、读写成本、压缩后大小均匀。
- 虚拟节点过多会增加路由、复制任务、文件和运维成本。
副本放置也不能简单选“环上后面三个虚拟节点”,因为它们可能属于同一台物理机器。需要跳过同一物理节点,并按故障目标分散到不同机架或可用区。否则三份数据可能在一次宿主机或机架故障中一起消失。
副本同步:收到、落盘、应用,确认的是哪一步?
一次写入沿复制链路经过不同阶段。复制确认可能表示收到网络消息,也可能表示日志写入存储、刷到持久介质,或已经应用到可读状态。必须读取产品的准确语义。[4]
| 方式 | 客户端成功前通常等待什么 | 收益 | 代价或窗口 |
|---|---|---|---|
| 异步复制 | 主副本完成指定本地写入条件 | 延迟低,不必等待远端副本 | 主故障时,未复制部分可能丢失或需要找回 |
| 同步复制 | 满足配置要求的副本确认 | 缩小相应故障模型下的数据丢失窗口 | 延迟包含远端工作;可用副本不足时可能等待 |
| 半同步或产品特定确认 | 等待某个中间确认条件 | 在延迟和耐久性之间折中 | 名称不统一,必须核对确认层级与降级行为 |
PostgreSQL 的同步复制就区分 remote_write、on、remote_apply 等等待语义:等待写入操作系统、远端持久化和远端应用,不是一个承诺。[4] 这里只用它说明读文档应追问到哪一层,不把它的选项套到其他数据库。
复制因子增加,通常提高某些故障场景下的冗余,也增加网络、磁盘和恢复工作。成功写入的耐久性仍取决于确认策略、故障相关性和恢复方式;有三份异步副本不等于能承诺 RPO=0。
单主、多主、无主,冲突发生的位置不同
| 拓扑 | 写入怎样进入 | 主要难点 |
|---|---|---|
| 单主复制 | 一个分片的写入由 leader 排序 | 换主不丢已确认记录;旧主拒写;主成为吞吐或地理延迟中心 |
| 多主复制 | 多个地点都能接受写入,再互相传播 | 并发冲突、唯一约束、删除与更新冲突、应用合并 |
| 无主/leaderless 风格 | 客户端或协调者访问多个副本,按读写条件汇总 | 版本、quorum 语义、修复、成员视图和并发写入 |
在简单固定副本集合内,R + W > N 意味着一次读集合与一次已成功写集合有交集。但这个算术不独自证明线性一致:还要知道交集副本返回哪个版本、并发写如何排序、失败写如何处理、是否用了 sloppy quorum,以及成员是否正在变化。Dynamo 为可用性使用的机制必须连同其冲突处理和恢复设计阅读,不能摘一个公式变成通用保证。[2]
多主场景里,“最后写入胜出”还要回答“最后”怎么判。用未经约束的物理时间裁决,时钟更快的机器可能覆盖实际更晚发生的修改。冲突可检测不等于冲突已解决;购物车合并、库存扣减、唯一用户名,需要不同的业务规则。
再均衡:不能只把路由指向空的新机器
一次在线迁移至少涉及数据快照、增量追赶、写入所有权和路由版本。MongoDB 的 resharding、Vitess 的 resharding 都把复制与流量切换安排为明确流程,但两者实现细节不同。[5][6]
下面给出一种容易讲清安全边界的设计:先复制,短暂停写旧主,追上确定水位,再切换。 它牺牲一段写可用性,以换取较简单的所有权交接;不是所有生产系统都必须采用停写方案。
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Copying
Copying --> CatchingUp: 一致快照完成并保留对应日志起点
CatchingUp --> Frozen: 禁止源端继续接受该代次写入
Frozen --> Ready: 追到最终水位且校验通过
Ready --> ServingNew: 持久化路由代次切换,目标开始接写
ServingNew --> Cleanup: 观察与回退窗口结束后清理旧数据
| 阶段 | 应记录的状态 | 继续下一步前的条件 |
|---|---|---|
| 复制快照 | 迁移 ID、源/目标、快照边界、起始日志位点 | 快照对应的增量起点明确;复制可恢复 |
| 追增量 | 已应用位点、源端产生位点 | 不遗漏、不重复破坏状态;删除也被捕获 |
| 冻结写入 | 源端实际生效的写栅栏与最终水位 | 不能只宣布冻结,源端必须拒绝迟到写 |
| 切换所有权 | 新 generation/epoch 与权威路由记录 | 目标追上最终水位;只有一个有效写入所有者 |
| 观察与清理 | 校验结果、旧路由命中、回退能力 | 不再依赖旧副本,备份及保留窗口满足要求 |
“增量队列暂时为空”不等于追赶完成:源端还可能有未采集的已提交日志,或还有在途写入。需要比较同一坐标系下的确定水位,并明确它与快照的关系。删除事件也需要传过去,否则迁移后被删数据会复活。
双写能减少某些停写窗口,但增加了两个写入结果不一致、乱序及重试的复杂度。若先改源再改目标,中间失败仍有缺口;应保留可重放日志、版本及幂等机制,不能把“两个 API 都调用了”当成完成迁移。
用 generation 拒绝旧路由
模型中源分片处于 generation 7,目标将使用 generation 8。复制初始值 10 后,源端又写成 11,所以快照落后:
text
source/target before catchup: 11 10
early cutover: migration not ready先冻结源端,再追赶到值 11 和对应位点,才能允许目标接写。然后分别向旧源发送旧代次请求、向新目标发送旧代次请求:
go
fmt.Println("old source:", m.Source.Write(7, 99))
fmt.Println("old generation to target:", m.Target.Write(7, 99))
must(m.Target.Write(8, 12))
fmt.Println("new owner:", m.Target.Value, "generation:", m.Target.Generation)实际输出:
text
old source: stale route
old generation to target: stale route
new owner: 12 generation: 8旧源拒绝,是因为 Writable=false;目标拒绝,是因为 generation 不匹配。客户端收到过期路由错误后应重新获取路由,并按请求幂等规则决定是否重试。代次检查只覆盖写入路径;如果业务要求读也不能回退,读取路由及副本版本同样要受到约束。
模型的 Freeze、CatchUp、Cutover 在一个进程里按展示顺序执行。真实系统需要把控制器进度持久化,在重启后恢复,并让分散节点对写栅栏和所有权交接达成可靠约束。这个模型没有实现跨节点原子切换,也不保证任意顺序、任意重复调用各方法都安全;例如不能在切换后随意重新执行旧快照覆盖目标。
迁移需要的往往是余量,而不只是更多机器
再均衡会同时消耗源端读取、目标写入、复制网络、日志保留和校验资源。在旧集群已经满载时才开始搬,迁移可能让在线业务更慢;业务变慢又导致积压增长,源端日志更难追完。
可以提前估算:数据量除以可持续迁移速率,给出复制时间的粗下界;再加上持续新增写入、压缩、校验与失败恢复的余量。这个估算不能替代实测,因为瓶颈可能从网络变成磁盘或 CPU。限速和并发应依据前台延迟、源端复制滞后及容量告警动态调整。
| 观察量 | 它帮助回答的问题 |
|---|---|
| 每分片字节数、请求数、读写延迟 | 不均匀来自容量还是热点流量? |
| 增量产生速率与消费速率 | 追赶能否收敛,还是差距持续扩大? |
| 最老未复制日志年龄、日志保留余量 | 目标中断后还能从位点续传吗? |
| 源端旧代次拒绝次数 | 哪些客户端仍缓存过期路由? |
| 行数/摘要/关键业务对账差异 | 快照、增量和删除是否真正对齐? |
| 目标可用空间及故障后的余量 | 迁移中再坏一个节点是否仍能恢复? |
切换后再切回旧机器,也是一场新的迁移。如果新目标已经接受写入,旧源就落后了;必须反向复制或重新同步到可用水位,不能只恢复旧路由。Vitess 等系统把反向复制和回退窗口作为明确操作能力,使用时要核对具体条件。[6]
好的分布方案,不仅能说清今天的键在哪,还能说清扩容、故障和迁移中谁有权接写,以及系统如何拒绝来自旧世界的请求。
依据与验证范围
- Google:Bigtable 原论文入口:有序行键、tablet 和分布结构。
- DeCandia 等:Dynamo,SOSP 2007,§4:一致性哈希、虚拟节点、副本放置、版本与恢复。
- Karger 等:Consistent Hashing and Random Trees:一致性哈希原始工作。
- PostgreSQL 17 Warm Standby,Synchronous Replication:异步和同步复制及等待级别。
- MongoDB Reshard a Collection:重分片流程和切换约束。
- Vitess 22.0 Resharding:VReplication、切换与反向复制。固定版文档作机制例子,不声称本次运行或推荐最新版本。
Go 1.27.2、Linux/amd64,本地运行、普通测试、race 和 vet 全部通过。测试只覆盖一个值、一个源/目标、指定顺序的迁移模型:未冻结拒绝切换、目标落后拒绝切换、追平后成功、旧路由拒写、新路由成功。没有数据库、持久化、真实日志流、多个迁移控制器或集群验证;未做哈希均匀性/迁移速度基准,环上的数字例子是人为构造的说明。
完整可复现实验
把以下三个文件放在同一空目录。只依赖标准库,不需要外部服务。
sh
go run .
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...go.mod
mod
module example.com/distributed/distribution
go 1.27.2main.go
代码块 · 63 行 · 点击收起 / 展开
go
package main
import (
"errors"
"fmt"
)
type Shard struct {
Generation int
Writable bool
Value, Offset int
}
func (s *Shard) Write(generation, value int) error {
if !s.Writable || generation != s.Generation {
return errors.New("stale route")
}
s.Value = value
s.Offset++
return nil
}
type Migration struct {
Source, Target Shard
Phase string
}
func NewMigration() *Migration {
return &Migration{Source: Shard{Generation: 7, Writable: true, Value: 10, Offset: 1}, Phase: "copy"}
}
func (m *Migration) Snapshot() {
m.Target = Shard{Generation: 8, Value: m.Source.Value, Offset: m.Source.Offset}
m.Phase = "catchup"
}
func (m *Migration) Freeze() { m.Source.Writable = false; m.Phase = "frozen" }
func (m *Migration) CatchUp() { m.Target.Value = m.Source.Value; m.Target.Offset = m.Source.Offset }
func (m *Migration) Cutover() error {
if m.Phase != "frozen" || m.Source.Writable || m.Target.Offset != m.Source.Offset || m.Target.Value != m.Source.Value {
return errors.New("migration not ready")
}
m.Target.Writable = true
m.Phase = "serving"
return nil
}
func main() {
m := NewMigration()
m.Snapshot()
must(m.Source.Write(7, 11))
fmt.Println("source/target before catchup:", m.Source.Value, m.Target.Value)
fmt.Println("early cutover:", m.Cutover())
m.Freeze()
m.CatchUp()
must(m.Cutover())
fmt.Println("old source:", m.Source.Write(7, 99))
fmt.Println("old generation to target:", m.Target.Write(7, 99))
must(m.Target.Write(8, 12))
fmt.Println("new owner:", m.Target.Value, "generation:", m.Target.Generation)
}
func must(e error) {
if e != nil {
panic(e)
}
}main_test.go
go
package main
import "testing"
func TestMigration(t *testing.T) {
m := NewMigration()
m.Snapshot()
if m.Source.Write(7, 11) != nil {
t.Fatal("source unavailable too early")
}
if m.Cutover() == nil {
t.Fatal("cut over unfenced source")
}
m.Freeze()
if m.Cutover() == nil {
t.Fatal("cut over stale target")
}
m.CatchUp()
if m.Cutover() != nil {
t.Fatal("ready cutover rejected")
}
if m.Source.Write(7, 99) == nil || m.Target.Write(7, 99) == nil {
t.Fatal("accepted stale route")
}
if m.Target.Write(8, 12) != nil || m.Target.Value != 12 {
t.Fatal("new generation failed")
}
}