Skip to content

数据分布:决定数据放哪里,也决定以后怎么搬 ​

给订单表加几台机器,难点不只是把数据均匀分开。一次按用户查询是否还只访问一个分片?一个大客户能不能拖垮整台机器?扩容时,新旧路由同时存在,谁还有权接收写入?分片、副本和再均衡必须一起设计。

本文从分片键开始,解释一致性哈希与虚拟节点,再区分复制拓扑,最后走一遍带代次检查的迁移流程。程序只模拟一个值从旧分片迁到新分片的受控过程,实际验证提前切换和过期路由会被拒绝;它不代表任何数据库的迁移协议。

先分清分片、副本和路由 ​

分片把不同数据交给不同所有者,主要解决容量与负载分摊;副本为同一批数据保留多份,主要服务于容错和读取;路由告诉客户端某个键当前属于哪个分片,以及该分片可以访问哪些副本。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    C[请求:tenant_id=42] --> M[路由元数据:键到逻辑分片]
    M --> S[逻辑分片 7]
    S --> P[主副本:节点 A]
    P --> F1[从副本:节点 B]
    P --> F2[从副本:节点 C]
    X[其他逻辑分片] --> D[节点 D 等]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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 之后是否改变
20B,位置 50B,位置 50否
60C,位置 85D,位置 70是
90A,位置 10,绕回A,位置 10,绕回否

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A["A:10"] --> B["B:50"] --> D["新增 D:70"] --> C["C:85"] --> A
    K["键 60"] -. 顺时针首个节点 .-> D
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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: 观察与回退窗口结束后清理旧数据
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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]

好的分布方案,不仅能说清今天的键在哪,还能说清扩容、故障和迁移中谁有权接写,以及系统如何拒绝来自旧世界的请求。

依据与验证范围 ​

  1. Google:Bigtable 原论文入口:有序行键、tablet 和分布结构。
  2. DeCandia 等:Dynamo,SOSP 2007,§4:一致性哈希、虚拟节点、副本放置、版本与恢复。
  3. Karger 等:Consistent Hashing and Random Trees:一致性哈希原始工作。
  4. PostgreSQL 17 Warm Standby,Synchronous Replication:异步和同步复制及等待级别。
  5. MongoDB Reshard a Collection:重分片流程和切换约束。
  6. 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.2

main.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")
	}
}

保留各书来源与许可 · 示例运行条件以正文为准