学习目标
学完本文,你应该能够:
- 把"海量数据存储"这个大题拆成三件小事:数据分片(Sharding)、数据复制(Replication)、数据一致性(Consistency),并说清它们之间的因果关系。
- 手写哈希分片与一致性哈希的 Go 实现,指出"取模分片扩容要全量迁移"的痛点,以及一致性哈希如何用虚拟节点把迁移量压到约 1/N。
- 讲清主从复制为什么是分布式系统解决高可用的唯一手段,以及主节点故障后从节点如何接管。
- 在一致性协议里做取舍:2PC 解决什么、Raft 怎么工作、Paxos 又在解决什么,以及面试高频题"为什么选 Raft 而不是 Paxos"。
- 用"原理 + 取舍 + 例子"的结构回答面试官的一环扣一环追问,而不是背八股。
前置知识:基本网络概念(请求、延迟、故障);用过至少一种数据库或中间件(MySQL / Redis / etcd 任一即可);知道哈希函数大致是什么。
本章你会动手做的事:
- 用 Go 写一个
ConsistentHash,跑一遍"4 节点扩容到 5 节点",看看到底有多少个 key 需要迁移。 - 把"哈希取模分片"的扩容迁移量也写个程序数一遍,和一致性哈希对比。
- 写一个最小 Raft 选主状态机,理解
term和"多数派"为什么是核心。
一、考点脑图
先给整篇文章一张总览图。面试官表面问"怎么存亿级商品",实际在一条线里串起三件事:分片决定数据放哪、复制决定挂了怎么办、一致性决定多副本之间数据对不对得上。
二、从面试场景出发:亿级商品存储到底在考什么
面试官不会一上来就问"讲讲一致性哈希"。真实面试往往是这样开的:
“你是一家电商网站的架构师,原来商品数据都放在一台 MySQL 里,现在要存到亿级规模、还要能水平扩展,你怎么设计存储策略?”
你一开口说"分库分表",面试官就顺着你的回答往下挖:分片规则怎么定?节点扩容数据时迁不迁移?挂了怎么办?多副本之间数据以谁为准? 一环扣一环,把上面脑图里的三件事全串起来。
所以先建立因果链,后面每一节都是这条链上的一环:
- 单机存不下 / 扛不住 → 数据要分布到多台节点(水平扩展)。
- 数据分布到多台 → 必须解决数据分片:按什么规则把一条商品路由到固定节点。
- 单节点会挂 → 必须有数据复制(副本),主节点挂了从节点顶上,这是高可用的唯一手段。
- 有了多副本 → 必须解决数据一致性:写入主节点后,从节点什么时候能看到,能保证强一致还是只能最终一致。
- 一致性要算法保障 → 引出 2PC、Raft、Paxos 等一致性协议。
三、数据分片:哈希分片 vs 范围分片
数据分片(Sharding)就是"按规则把一条数据路由到某个存储节点",目的是降低单机读写压力。最常见的两种规则:哈希分片和范围分片。
3.1 哈希分片(取模)原理
商品表大致有这些字段:商品ID(主键)、商品名称、所属类别、商家信息。如果以 商品ID 作为分片键(sharding key),系统会对它算一个哈希值,再对节点数取模,结果就是目标分片:
hash(商品ID) % 节点数 N → 分片编号
假设 N = 4,就有节点 A / B / C / D 四个分片:
hash(商品ID) % 4 | 目标节点 |
|---|---|
| 0 | 节点 A |
| 1 | 节点 B |
| 2 | 节点 C |
| 3 | 节点 D |
下面是一段真实可运行的 Go 实现:
package main
import (
"fmt"
"hash/crc32"
)
// HashRouter 最简单的取模哈希分片路由
type HashRouter struct {
nodes []string
}
func NewHashRouter(nodes ...string) *HashRouter {
return &HashRouter{nodes: nodes}
}
// Route 根据分片键返回目标节点
func (r *HashRouter) Route(key string) string {
if len(r.nodes) == 0 {
return ""
}
h := crc32.ChecksumIEEE([]byte(key))
return r.nodes[h%uint32(len(r.nodes))]
}
func main() {
r := NewHashRouter("NodeA", "NodeB", "NodeC", "NodeD")
for _, id := range []string{"product-100", "product-101", "product-2024"} {
fmt.Printf("%s -> %s\n", id, r.Route(id))
}
}
优点:数据分布非常均匀,实现简单,路由就是一次取模。
缺点(致命):扩展性极差。分片计算直接对"节点数"取模,一旦节点数从 4 变成 5,hash(key) % 4 和 hash(key) % 5 的结果大面积不一致,几乎全量数据都要重新计算并迁移。这正是面试官要你点出的痛点。
3.2 范围分片原理
范围分片(Range Sharding)按分片键的连续区间切分,比如 商品ID 在 [0, 2^20) 落到 A,[2^20, 2^21) 落到 B。Go 里可以这样表达:
package main
import "fmt"
// RangeRouter 按商品ID的数值区间分片
type RangeRouter struct {
// 每个分片的区间上界(不含)与节点名
bounds []struct {
end uint64
node string
}
}
func NewRangeRouter() *RangeRouter {
r := &RangeRouter{}
// 每 100 万一个分片
r.bounds = []struct {
end uint64
node string
}{
{1_000_000, "NodeA"},
{2_000_000, "NodeB"},
{3_000_000, "NodeC"},
{^uint64(0), "NodeD"}, // 兜底
}
return r
}
func (r *RangeRouter) Route(id uint64) string {
for _, b := range r.bounds {
if id < b.end {
return b.node
}
}
return "NodeD"
}
func main() {
r := NewRangeRouter()
for _, id := range []uint64{50, 1_500_000, 2_500_000} {
fmt.Printf("商品 %d -> %s\n", id, r.Route(id))
}
}
优点:区间查询极快(查"ID 在 100 万到 200 万之间的商品"只需访问一个分片);扩容时只需在尾部加区间,几乎不迁移旧数据。
缺点:容易产生数据倾斜和热点——比如大促时某一类目商品 ID 集中,某个分片被打爆,其他分片很闲。
商品ID 是最稳妥的选择。四、一致性哈希:如何兼顾"均匀"与"可扩展"
哈希取模的痛点是"扩一个节点就全量迁移"。面试官顺手就会问:怎么解决哈希分片的缺点,既保持数据均匀分布,又让扩容时迁移成本最小? 答案就是一致性哈希(Consistent Hashing)。
4.1 哈希环思想
一致性哈希把存储节点和数据都映射到一个首尾相接的**哈希环(hash ring)**上:
- 节点位置:对节点的 IP / 编号做哈希,落到环上某点。
- 数据位置:对分片键(如
商品ID)做哈希,落到环上某点;然后按顺时针方向找到的第一个节点,就是它该存的地方。
下面这张环上放了四个节点 A / B / C / D,箭头表示顺时针方向:
光有 4 个节点会有两个问题:环上节点太少导致数据倾斜,且单节点宕机时它负责的那一段全压给下一个节点。工程上几乎都采用带虚拟节点(virtual node)的一致性哈希:给每个真实节点分配多个虚拟节点均匀铺在环上,数据先落到虚拟节点,再映射到真实节点。虚拟节点越多,分布越均匀。
假设虚拟节点数 = 10,10 个虚拟节点构成整个哈希空间,分配给真实节点:
| 虚拟节点 | 归属真实节点 |
|---|---|
| 0、1、2、3 | 节点 A |
| 4、5、6 | 节点 B |
| 7、8 | 节点 C |
| 9 | 节点 D |
对 商品ID 算 hash(商品ID) % 10 得到它在环上的位置,再顺时针找最近节点:0–3 → A,4–6 → B,7–8 → C,9 → D。
4.2 Go 实现(带虚拟节点)
下面这段是面试可直接手写的完整可运行版本:
package main
import (
"fmt"
"hash/crc32"
"sort"
)
// ConsistentHash 带虚拟节点的一致性哈希环
type ConsistentHash struct {
ring map[uint32]string // 哈希值 -> 真实节点
sortedKeys []uint32 // 已排序的哈希值,用于顺时针查找
virtualNodes int
}
func NewConsistentHash(virtualNodes int) *ConsistentHash {
return &ConsistentHash{
ring: make(map[uint32]string),
sortedKeys: nil,
virtualNodes: virtualNodes,
}
}
func (c *ConsistentHash) hash(key string) uint32 {
return crc32.ChecksumIEEE([]byte(key))
}
// Add 加入一个真实节点,并为它生成 virtualNodes 个虚拟节点
func (c *ConsistentHash) Add(node string) {
for i := 0; i < c.virtualNodes; i++ {
vkey := fmt.Sprintf("%s#%d", node, i)
h := c.hash(vkey)
c.ring[h] = node
c.sortedKeys = append(c.sortedKeys, h)
}
sort.Slice(c.sortedKeys, func(i, j int) bool {
return c.sortedKeys[i] < c.sortedKeys[j]
})
}
// Get 顺时针找到 key 落到的第一个节点
func (c *ConsistentHash) Get(key string) string {
if len(c.sortedKeys) == 0 {
return ""
}
h := c.hash(key)
idx := sort.Search(len(c.sortedKeys), func(i int) bool {
return c.sortedKeys[i] >= h
})
if idx == len(c.sortedKeys) {
idx = 0 // 越过环尾,绕回环首
}
return c.ring[c.sortedKeys[idx]]
}
4.3 扩容迁移分析(核心考点)
一致性哈希最妙的地方在扩容:加入新节点 NodeE 后,受影响的只有新节点在环上"前一个节点到它自己"这一段区间里的数据,其他节点的数据原封不动。
用代码实测一下迁移量——这正是面试官想听的"用数据说话":
func main() {
ch := NewConsistentHash(10)
for _, n := range []string{"NodeA", "NodeB", "NodeC", "NodeD"} {
ch.Add(n)
}
// 预置 1000 个商品
keys := make([]string, 1000)
for i := range keys {
keys[i] = fmt.Sprintf("product-%d", i)
}
before := make(map[string]string, len(keys))
for _, k := range keys {
before[k] = ch.Get(k)
}
// 扩容:加入第 5 台 NodeE
ch.Add("NodeE")
moved := 0
for _, k := range keys {
if ch.Get(k) != before[k] {
moved++
}
}
fmt.Printf("取模分片 4→5:约 80%% 的 key 需迁移\n")
fmt.Printf("一致性哈希 4→5:仅 %d/%d 个 key 迁移,约 %.1f%%\n",
moved, len(keys), float64(moved)/float64(len(keys))*100)
}
对比结论很直观:
- 取模分片:节点数 4→5,几乎所有 key 的取模结果都变了,迁移量接近 100%。
- 一致性哈希:只搬移新增节点负责的那一段,迁移量约为
1 / 新节点总数(本例约 20%),且数据越均匀、虚拟节点越多,迁移越平滑。
对应到课堂案例:新增节点后,原本落在节点 A 的 商品100、商品101 顺时针最近变成了新节点,只有它们从 A 迁到新节点,其余节点数据不变——这就是一致性哈希"兼顾均匀与可扩展"的本质。
五、数据复制:主从模式与高可用
分片解决了"数据放哪",但单节点会挂。分布式存储系统解决高可用的唯一手段就是数据复制(Replication)——同一份数据存多份副本。
最经典的是主从模式(Master-Slave):设定一个主节点(primary/master)负责读写,一个或多个从节点(replica/slave)持有副本。主节点写入后把变更同步给从节点;一旦主节点故障,从节点顶上继续提供服务,业务不中断。这就是你在 MySQL、Redis、Kafka 里反复见到的套路。
用一段 Go 把"主从 + 故障切换"的状态画出来(简化版,重在表达状态机思想):
package main
import "fmt"
type Role int
const (
Primary Role = iota
Replica
Offline
)
type Node struct {
name string
role Role
}
// failover 主节点下线,从节点中第一个接管为主
func failover(nodes []*Node) {
for _, n := range nodes {
if n.role == Primary {
fmt.Printf("[检测] 主节点 %s 心跳丢失,标记下线\n", n.name)
n.role = Offline
}
}
for _, n := range nodes {
if n.role == Replica {
n.role = Primary
fmt.Printf("[切换] %s 接管为新的主节点,继续对外服务\n", n.name)
return
}
}
fmt.Println("[告警] 没有可用从节点,服务不可用")
}
func main() {
nodes := []*Node{
{name: "NodeA", role: Primary},
{name: "NodeB", role: Replica},
{name: "NodeC", role: Replica},
}
failover(nodes)
}
输出会是:NodeA 心跳丢失 → NodeB 接管为主。这就是"副本=高可用"的落地形态。
六、数据一致性:从强一致到最终一致
有了多副本,“从节点什么时候能看到新写入"就成了问题,这就是数据一致性。一致性有强有弱:
- 强一致性(Strong):写入成功后,任意后续读都能立刻读到最新值。
- 最终一致性(Eventual):写入后不保证立即可见,但"经过一段时间后"所有副本终会一致(如 DNS、很多缓存场景)。
要保证一致性,就得靠一致性协议 / 算法:两阶段提交(2PC)、Paxos、Raft、ZooKeeper 的 ZAB 等。下面挑面试最高频的 2PC 与 Raft/Paxos 串讲。
6.1 两阶段提交(2PC):跨节点"要么全成,要么全败”
2PC 用来在多个参与者之间达成"原子提交"——典型场景是跨分片的事务。它分两个阶段:
- 准备阶段(Prepare):协调者问所有参与者"你能提交吗?",参与者锁资源并回答 Yes/No。
- 提交阶段(Commit):只要有一个说 No,全体回滚;全都 Yes,才正式提交。
package main
import "fmt"
// Participant 简化参与者
type Participant struct {
name string
ready bool // 是否准备好提交
}
// Coordinator 两阶段提交协调者
type Coordinator struct {
parts []*Participant
}
func (c *Coordinator) commit() bool {
// 阶段一:准备
fmt.Println("[阶段一] 协调者发送 Prepare")
for _, p := range c.parts {
if !p.ready {
fmt.Printf(" %s 回答 No\n", p.name)
c.rollback()
return false
}
fmt.Printf(" %s 回答 Yes\n", p.name)
}
// 阶段二:提交
fmt.Println("[阶段二] 全员 Yes,发送 Commit")
return true
}
func (c *Coordinator) rollback() {
fmt.Println("[阶段二] 存在 No,全员 Rollback")
}
func main() {
c := &Coordinator{parts: []*Participant{
{name: "分片A", ready: true},
{name: "分片B", ready: true},
}}
fmt.Println("提交结果:", c.commit())
}
2PC 的硬伤:协调者是单点(它挂了全员卡住);参与者在 Prepare 后锁资源,协调者故障会导致资源长时间锁定;且全程同步阻塞。所以它适合"强一致但规模不大"的跨库事务,撑不起超大规模存储的一致性,于是有了 Paxos / Raft 这类容错的一致性算法。
6.2 Raft:让"选主 + 日志复制"变得易懂
Raft 把共识问题拆成三个子问题:领导者选举(Leader Election)、日志复制(Log Replication)、安全性(Safety)。核心概念就两个:
- term(任期):逻辑时钟,每发生一次选举 term+1,用来识别过期信息。
- 多数派(Majority):N 个节点里,必须有
N/2 + 1个同意,决议才生效。
下面是一段最小选主状态机,把"term + 多数派"说透:
package main
import "fmt"
type Role int
const (
Follower Role = iota
Candidate
Leader
)
type Node struct {
id string
term int
role Role
votes int
}
// startElection 发起一轮选举,返回是否当选 Leader
func (n *Node) startElection(peerCount int) bool {
n.term++ // ① 任期 +1
n.role = Candidate // ② 变成候选者
n.votes = 1 // ③ 给自己投一票
// 简化:假设向其余 peer 拉票,全部同意
n.votes += peerCount
// ④ 必须拿到多数派(含自己)
needed := peerCount/2 + 1
if n.votes >= needed {
n.role = Leader
return true
}
return false
}
func main() {
n := &Node{id: "NodeA", term: 0, role: Follower}
if n.startElection(4) { // 集群共 5 节点(自己+4 peer)
fmt.Printf("%s 在 term=%d 当选 Leader\n", n.id, n.term)
}
}
peerCount=4 表示除自己外还有 4 个节点,集群共 5 个;多数派 = 4/2+1 = 3,加上自己一票共 5 票 ≥ 3,顺利当选。这就是为什么 Raft 集群通常部署奇数节点(3/5/7)——既能容忍 (N-1)/2 个节点宕机,又不会出现"两党各占一半"的平票。
6.3 Raft vs Paxos:面试深水区
课堂结尾一定会追到这个问题:"为什么很多系统选 Raft 而不是 Paxos?“它是区分"背过"和"真懂"的分水岭。
- Paxos 解决什么:在异步、节点会宕机、消息会丢的网里,让一群节点对"某个值"达成一致。它是共识算法的理论基石,但原始论文极难理解,工程落地要补大量细节(multi-Paxos、leader 等),容易出错。
- Raft 解决什么:和 Paxos 等价的一致性保证,但把问题拆成"选主 / 日志复制 / 安全"三块,可理解、可工程化。它本质是用"强 leader"换可读性——所有写都先到 leader,再由 leader 复制给 follower。
- 区别一句话:Paxos 是"理论上最严谨的共识算法”,Raft 是"工程上最好实现、最好排错的共识算法"。 etcd、Consul、TiKV 选 Raft,正是因为要在真实系统里把共识写对、写好运维。
七、自测题与动手练习
下面 5 道题用面试官 / 候选人对话呈现,重点不是背,而是用原理 + 取舍 + 例子组织答案。
hash(商品ID) % 节点数 做分片,现在要把节点从 4 台扩到 5 台,会发生什么?商品ID 的 hash % 4 和 hash % 5 结果大面积不一致,路由到的节点变了,数据得重新算一遍并搬过去。这正是哈希取模分片扩展性差的根因,也是引出一致性哈希的动机。1/新节点总数。但真实节点少会导致分布不均、单点宕机压力集中,所以引入虚拟节点:每个真实节点映射多个虚拟节点均匀铺在环上,数据先落虚拟节点再映射到真实节点,虚拟节点越多分布越均匀。动手练习(建议真做一遍):
- 把本文 §3.1 的取模分片改成"节点 4→5",写个程序数一下到底有多少 key 迁移,和 §4.3 的一致性哈希结果对比,写一段选型分析。
- 给
ConsistentHash增加Remove(node)方法,验证"下线一个节点"时迁移量同样只有约 1/N。 - 把 §6.2 的 Raft 选主改成 4 节点集群(偶数),观察平票时为什么选不出 Leader,理解"奇数节点"的由来。
八、本章小结
- 亿级商品存储 = 分片 + 复制 + 一致性三件事的串联:分片定"放哪",复制定"挂了怎么办",一致性定"副本对不对得上"。
- 哈希取模扩容要全量迁移;一致性哈希(带虚拟节点)把迁移量压到约 1/N,是面试必考的取舍点。
- 副本是分布式高可用的唯一手段,落地为主从模式;一致性用 2PC / Raft / Paxos 保障,Raft 因"可理解、可工程化"成为工程首选。
- 下一篇可以接着讲 Raft 的日志复制与冲突处理,那是把"选主"之后"数据怎么在多副本间对齐"讲透的关键一步。