系统设计面试:数据库分片与分布式存储(分库分表、一致性哈希、读写分离)

数据库扩展是系统设计面试绕不开的话题。本文系统梳理读写分离、分库分表、一致性哈希、分布式事务的核心原理与工程实现,并给出面试中的取舍框架。


1. 数据库扩展的演进路径

面试时先讲演进,体现系统性思维:

1
2
3
4
5
6
7
8
9
10
11
阶段一:单机 MySQL
→ 遇到读压力 → 读写分离(主从复制)

阶段二:读写分离
→ 遇到写压力/数据量 → 分库分表

阶段三:分库分表
→ 遇到跨分片查询/分布式事务 → 引入中间件/分布式数据库

阶段四:分布式数据库(TiDB / CockroachDB / Spanner)
→ 自动分片、强一致、水平扩展

经验法则

  • 单表 < 500万行 → 索引优化 + 查询优化
  • 单表 500万-1亿行 → 读写分离 + 分区表
  • 单表 > 1亿行 → 分库分表
  • 写 QPS > 10万 → 分库

2. 读写分离

2.1 基本原理

1
2
3
4
5
6
7
8
9
10
                    ┌─────────┐
写请求 ────────────► │ Master │
└────┬─────┘
│ binlog 异步复制
┌───────────┼───────────┐
▼ ▼ ▼
Replica1 Replica2 Replica3
▲ ▲ ▲
└───────────┴───────────┘
读请求(负载均衡)

2.2 读写分离的代价:复制延迟

异步复制通常有 1-100ms 延迟,导致”刚写完立刻读不到”:

1
2
用户发帖 → 写入 Master(成功)
用户刷新 → 读 Replica(复制还没到)→ 看不到自己的帖子

解决方案

方案一:写后读一致性(Read-Your-Writes)

1
2
3
4
5
6
7
8
9
def create_post(user_id, content):
db_master.insert("posts", content=content)
# 后续该用户的读请求路由到 Master(持续 1s)
session['force_master_until'] = time.time() + 1.0

def get_posts(user_id):
if time.time() < session.get('force_master_until', 0):
return db_master.query(...) # 读 Master
return db_replica.query(...) # 读 Replica

方案二:等待复制追上

1
2
3
4
5
def create_post(content):
master_pos = db_master.insert("posts", content=content)
# 等待某个 Replica 的 binlog 位置超过 master_pos
db_replica.wait_for_sync(master_pos, timeout=1.0)
return db_replica.query(...)

方案三:关键读走 Master(最简单)

1
2
# 对延迟敏感的操作(用户个人数据、支付状态)直接读 Master
# 对延迟不敏感的操作(排行榜、推荐)读 Replica

3. 分库分表

3.1 垂直拆分 vs 水平拆分

垂直拆分(按业务拆库):

1
2
3
4
5
6
7
8
9
10
11
单体数据库
├─ user 表
├─ order 表
├─ product 表
└─ payment 表

→ 拆分后:
user_db:user 表
order_db:order 表
product_db:product 表
payment_db:payment 表

优点:业务解耦,各库独立扩展;缺点:跨库 JOIN 消失,需要应用层 JOIN。

水平拆分(按数据行拆分,Scale Out):

1
2
3
4
5
6
orders 表 1亿行
→ 按 user_id % 4 拆成 4 个分片:
orders_0:user_id % 4 == 0 的数据
orders_1:user_id % 4 == 1 的数据
orders_2:user_id % 4 == 2 的数据
orders_3:user_id % 4 == 3 的数据

3.2 分片键(Shard Key)选择

黄金法则:分片键决定了查询路由,选择不当会导致热点或大量跨分片查询。

分片键 优点 缺点
用户 ID 同一用户数据在同一分片,查询高效 大 V 用户可能成热点
时间 历史数据归档方便 写热点(最新时间段压力集中)
业务 ID(订单ID) 分布均匀 按用户查询需要跨分片
地区/城市 符合数据本地化法规 分布不均(北上广用户多)

实践建议:按用户 ID 哈希(最常用),用雪花算法生成 ID(内嵌时间+机器+序列号,天然有序)。

3.3 分片路由实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SHARD_COUNT = 16

class ShardRouter:
def get_shard(self, user_id: int) -> str:
shard_id = user_id % SHARD_COUNT
return f"db_{shard_id}"

def query_user_orders(self, user_id: int):
db = self.get_shard(user_id)
return db.query(f"SELECT * FROM orders WHERE user_id = ?", user_id)

def query_all_orders_in_range(self, start_time, end_time):
# 跨分片查询:需要查所有分片然后合并
results = []
for i in range(SHARD_COUNT):
db = f"db_{i}"
results.extend(db.query(
"SELECT * FROM orders WHERE created_at BETWEEN ? AND ?",
start_time, end_time
))
return sorted(results, key=lambda x: x.created_at) # 应用层排序

3.4 分片扩容(最难的问题)

从 4 分片扩到 8 分片时,数据需要迁移:

1
2
3
4
5
原分片:user_id % 4
新分片:user_id % 8

user_id=5:原来在 shard_1(5%4=1),迁移后在 shard_5(5%8=5)
→ 约 50% 的数据需要迁移!

一致性哈希解决扩容问题(见下节)

停机迁移方案(小规模):

1
2
3
4
5
1. 维护公告停机
2. 旧分片导出数据
3. 按新规则重新导入
4. 更新路由配置
5. 恢复服务

在线双写迁移方案(零停机):

1
2
3
4
阶段一:双写(写新旧两个分片)+ 读旧分片
阶段二:后台迁移历史数据,验证一致性
阶段三:读切换到新分片(可灰度)
阶段四:停止写旧分片,旧分片下线

4. 一致性哈希

4.1 为什么需要一致性哈希?

传统取模分片(node = hash(key) % N)的问题:

1
2
3
3个节点时:key "user:123" → hash → % 3 = 1 → Node1
扩容到4个节点:key "user:123" → hash → % 4 = 3 → Node3
→ 几乎所有 key 的归属节点都变了,需要全量迁移!

4.2 一致性哈希原理

1
2
3
4
5
6
7
8
9
10
11
12
13
哈希环(0 ~ 2^32-1,首尾相连):

0
/
Node3 Node1
\ /
X
/ \
Node2 (空)
\
2^32-1

key 的归属:沿顺时针方向找到第一个节点

虚拟节点(Virtual Nodes)解决数据不均匀问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import hashlib
from bisect import bisect_right

class ConsistentHash:
def __init__(self, nodes=[], replicas=150):
self.replicas = replicas # 每个物理节点对应150个虚拟节点
self.ring = {} # hash值 → 节点名
self.sorted_keys = [] # 有序的hash值列表

for node in nodes:
self.add_node(node)

def add_node(self, node):
for i in range(self.replicas):
key = self._hash(f"{node}:{i}")
self.ring[key] = node
self.sorted_keys.append(key)
self.sorted_keys.sort()

def remove_node(self, node):
for i in range(self.replicas):
key = self._hash(f"{node}:{i}")
del self.ring[key]
self.sorted_keys.remove(key)

def get_node(self, data_key):
if not self.ring:
return None
hash_val = self._hash(data_key)
# 顺时针找第一个节点
idx = bisect_right(self.sorted_keys, hash_val) % len(self.sorted_keys)
return self.ring[self.sorted_keys[idx]]

def _hash(self, key):
return int(hashlib.md5(key.encode()).hexdigest(), 16)

# 扩容测试
ch = ConsistentHash(['Node1', 'Node2', 'Node3'])
ch.add_node('Node4')
# 添加 Node4 后,只有约 25% 的 key 需要迁移(而非传统取模的 75%)

一致性哈希的特点

  • 增减节点时,平均只需迁移 1/N 的数据(N 为节点数)
  • 虚拟节点保证数据分布均匀

适用场景:Redis Cluster、分布式缓存、CDN 节点选择、对象存储分片


5. 分布式事务

5.1 分布式事务的难点

1
2
3
4
5
跨分片的事务场景:
用户 A(shard_1)转账给用户 B(shard_3)

要求:A 的余额减少 AND B 的余额增加,必须原子发生
问题:两个操作在不同的数据库实例上,如何保证原子性?

5.2 两阶段提交(2PC)

1
2
3
4
5
6
7
Phase 1 - Prepare:
协调者 → 参与者1:准备好了吗?→ Yes
协调者 → 参与者2:准备好了吗?→ Yes

Phase 2 - Commit:
协调者 → 参与者1:提交!
协调者 → 参与者2:提交!

缺陷

  • 性能低:两轮网络 RTT,所有参与者持锁等待
  • 协调者单点:协调者宕机可能导致参与者永久持锁(阻塞)
  • 网络分区:Phase 2 时网络断开,参与者不知道是提交还是回滚

适用场景:强一致要求、事务量不大的金融场景(如数据库内置 XA 事务)。

5.3 Saga 模式(推荐)

将分布式事务拆分为一系列本地事务,每个步骤失败时执行补偿操作:

1
2
3
4
5
6
7
8
订单流程(Saga):
Step 1: 创建订单 → 补偿:取消订单
Step 2: 扣减库存 → 补偿:恢复库存
Step 3: 扣款 → 补偿:退款
Step 4: 发货 → 补偿:召回(或客服处理)

正常:Step1 → Step2 → Step3 → Step4 → 完成
失败:Step1 → Step2 → Step3 失败 → 补偿Step2 → 补偿Step1 → 回滚完成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class OrderSaga:
def execute(self, order_id, user_id, product_id, amount):
completed_steps = []
try:
# Step 1
order = order_service.create(order_id, user_id)
completed_steps.append(('order', order_id))

# Step 2
inventory_service.deduct(product_id, 1)
completed_steps.append(('inventory', product_id))

# Step 3
payment_service.charge(user_id, amount)
completed_steps.append(('payment', user_id))

return "SUCCESS"

except Exception as e:
# 按逆序执行补偿
for step, resource_id in reversed(completed_steps):
self.compensate(step, resource_id)
raise

def compensate(self, step, resource_id):
if step == 'order':
order_service.cancel(resource_id)
elif step == 'inventory':
inventory_service.restore(resource_id, 1)
elif step == 'payment':
payment_service.refund(resource_id)

Saga 的两种实现

  • 编排式(Orchestration):中央协调器驱动各步骤(如上面代码,Saga Orchestrator)
  • 协同式(Choreography):各服务通过事件互相触发,无中央协调器(更松耦合,但调试复杂)

5.4 分布式事务选型

方案 一致性 性能 复杂度 适用场景
2PC(XA) 强一致 金融核心,低频事务
TCC 最终一致 需要细粒度控制
Saga 最终一致 微服务业务流程
消息事务 最终一致 异步场景,允许延迟

消息事务(本地事务表)

1
2
3
4
5
6
7
8
def create_order_with_message(user_id, product_id):
with db.transaction():
# 1. 本地事务:写订单 + 写消息表(原子)
order = db.insert("orders", user_id=user_id, product_id=product_id)
db.insert("outbox_messages", event="ORDER_CREATED", data=order.to_dict())

# 2. 后台任务读 outbox 表,发送消息(至少一次语义,消费方做幂等)
# outbox_publisher.publish() ← 异步执行

6. 面试常见追问

Q: 分表后如何处理全局唯一 ID?

A: 不能用数据库自增 ID(各分片自增会重复)。方案:① 雪花算法(Snowflake,64位:时间戳+机器ID+序列号);② 数据库号段模式(批量申请 ID 段,如 1-1000、1001-2000);③ UUID(简单但无序,索引效率差)。推荐雪花算法:趋势递增、全局唯一、无中心化依赖。

Q: 如何做跨分片的分页查询?

A: 全局分页的标准解法——将所有分片的 top N 拉取到应用层归并排序,取前 N 条。代价是 O(分片数 × N) 的数据传输。优化:禁止深度翻页(只允许前 N 页),或用游标分页(基于 ID 而非 OFFSET,避免深翻页问题)。

Q: 什么时候不应该分库分表?

A: 过早分片是大忌。分片引入了:跨分片查询复杂度、分布式事务、运维复杂度增加。在单机 MySQL 优化空间(索引/查询优化/读写分离)耗尽前,不要分片。现代 SSD MySQL 单机能支撑数千万行 + 每秒万级 QPS,很多场景足够。


总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
数据库扩展演进:
单机 → 读写分离(解决读压力)→ 分库分表(解决写压力+数据量)→ 分布式数据库

分片键原则:
高基数(不重复)+ 均匀分布 + 查询覆盖(避免跨分片)

扩容策略:
传统取模 → 大量迁移
一致性哈希 → 只迁移 1/N 数据

分布式事务:
强一致需求 → 2PC(慢但强)
微服务流程 → Saga(快但最终一致)
异步解耦场景 → 消息事务 + 幂等消费

面试核心:数据库扩展题不要直接说”分库分表”,先说单机能撑多少、读写分离能解决什么、什么时候才需要分片——这种分层递进的思路才是面试官想看到的。