数据库扩展是系统设计面试绕不开的话题。本文系统梳理读写分离、分库分表、一致性哈希、分布式事务 的核心原理与工程实现,并给出面试中的取舍框架。
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) 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(...) return db_replica.query(...)
方案二:等待复制追上
1 2 3 4 5 def create_post (content ): master_pos = db_master.insert("posts" , content=content) db_replica.wait_for_sync(master_pos, timeout=1.0 ) return db_replica.query(...)
方案三:关键读走 Master(最简单)
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 hashlibfrom bisect import bisect_rightclass ConsistentHash : def __init__ (self, nodes=[], replicas=150 ): self.replicas = replicas self.ring = {} self.sorted_keys = [] 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' )
一致性哈希的特点 :
增减节点时,平均只需迁移 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 : order = order_service.create(order_id, user_id) completed_steps.append(('order' , order_id)) inventory_service.deduct(product_id, 1 ) completed_steps.append(('inventory' , product_id)) 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(): order = db.insert("orders" , user_id=user_id, product_id=product_id) db.insert("outbox_messages" , event="ORDER_CREATED" , data=order.to_dict())
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(快但最终一致) 异步解耦场景 → 消息事务 + 幂等消费
面试核心 :数据库扩展题不要直接说”分库分表”,先说单机能撑多少、读写分离能解决什么、什么时候才需要分片——这种分层递进的思路才是面试官想看到的。