分库、分表与分片:分布式数据库架构详解¶
分库分表与分布式架构 一句话口诀
黄金法则:没碰到真正的性能瓶颈就不要分——先索引、再读写分离、再缓存,最后才是分库分表。
分片键 > 分库数量——选错分片键的代价比分库数不够大得多。
跨分片事务能避则避——业务设计优先让同一事务落在同一分片。
中间件 / 代理 / 云原生三选一:Sharding-JDBC(客户端分片) / MyCat(代理分片) / Vitess、PolarDB-X(云原生)。
扩容要事前设计——一致性哈希 + 预分片预留路由空间,不要等到单库写爆才想扩容。
📖 边界声明:本文聚焦 "分库分表的架构设计与技术选型",以下主题请见对应专题:
- 单库事务的隔离级别、MVCC、锁机制 → 事务与并发控制
- 主从复制原理、Binlog、GTID、CDC 数据同步 → Binlog与主从复制
- 高可用架构与读写分离实现 → 高可用架构方案
- 全文检索 / 跨分片聚合查询 → Elasticsearch 专题
1. 类比:分库分表像连锁超市扩店¶
想象你开了一家超市,生意越来越好、一家店装不下那么多商品与顾客——你的扩张之路其实就是"单库 → 分表 → 分库 → 分片"的真实投影:
| 超市扩张阶段 | 数据库阶段 | 核心动作 |
|---|---|---|
| 一家店把货架分区(食品区 / 日用区 / 生鲜区) | 分表(Partitioning,同库内拆多表) | 表大了拆子表,单库能扛就不分库 |
| 一家店分楼上楼下仓库(A-M 顾客去1楼,N-Z 去2楼) | 垂直分表(按列拆:热列 vs 冷列) | 冷热分离,加速常用查询 |
| 把不同业务分到不同独立门店(药房 / 生鲜店 / 百货店) | 垂直分库(按业务拆库:订单库 / 用户库 / 商品库) | 业务隔离,压力分散 |
| 开连锁分店,每家店卖同样的货但服务一片区域 | 水平分库 + 分片(Sharding:按用户ID哈希分片) | 扛住海量数据与并发 |
| 顾客需要"我在哪家店"的导航(分店路由) | 分片键 + 中间件 / 代理 | 路由逻辑决定分片质量 |
| 跨店搞"全市大促"算总销量 | 跨分片聚合 / 分布式事务 | 最难做、最慢、最贵 |
| 新开一家店时把老店一半顾客迁过来 | 分片扩容 / 数据迁移 | 一致性哈希可减少搬迁量 |
一句话:分库分表的本质是"让数据服从业务的物理分布"——分得好,单机瓶颈变多机并行;分错了,每一条跨分片查询都是血泪。本文每一节都在回答一个问题——"什么时候分、怎么分、分完之后怎么解决后遗症"。
2. 核心概念精确定义¶
1. 分表(Table Partitioning)¶
定义:将一张大表拆分成多个结构相同的小表,每个小表存储部分数据
特点:
- 在同一个数据库实例内操作
- 表结构完全相同
- 通过某种规则分散数据
- 目的是解决单表过大问题
示例:
-- 原用户表
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
email VARCHAR(100)
);
-- 分表后(按ID取模)
CREATE TABLE users_0 ( ... ); -- id % 4 = 0
CREATE TABLE users_1 ( ... ); -- id % 4 = 1
CREATE TABLE users_2 ( ... ); -- id % 4 = 2
CREATE TABLE users_3 ( ... ); -- id % 4 = 3
2. 分库(Database Sharding)¶
定义:将数据分布到不同的数据库实例中
特点:
- 涉及多个数据库实例
- 可以是同一数据库服务器的不同数据库,也可以是不同服务器
- 通常按业务模块划分
- 目的是解决单库性能瓶颈和资源限制
示例:
用户库 (user_db)
├── users
├── user_profiles
└── user_addresses
订单库 (order_db)
├── orders
├── order_items
└── payments
商品库 (product_db)
├── products
├── categories
└── inventory
3. 分片(Sharding)¶
定义:数据分布的抽象概念,指将数据拆分到不同存储单元的过程
特点:
- 是一个抽象的技术概念
- 分表和分库都是分片的具体实现方式
- 强调数据分布的策略和算法
- 核心是数据路由和定位
关系总结:
- 分片是总体概念
- 分表是分片在单库内的实现
- 分库是分片在多库间的实现
- 可以组合使用:先分库,再在库内分表
📖 术语家族:分片(Sharding)
字面义:Shard = "碎片",Sharding = "切碎片",指将完整数据集拆分为若干独立存储单元的过程。 在数据库领域的含义:依据某个分片键(Sharding Key)将数据按规则分散到不同存储单元(表 / 库 / 节点),索引、查询、事务的语义边界都收缩到单个分片内。 同家族成员:
| 成员 | 含义 | 与原类概念的关系 |
|---|---|---|
Sharding | 分片(总体概念) | 抽象层,不绑定具体形态 |
Horizontal Sharding | 水平分片 | 按行拆分,分表 / 分库都是水平分片 |
Vertical Sharding | 垂直分片 | 按列 / 业务模块拆分,类似微服务拆分 |
Partitioning | 分区 | MySQL 原生语法(PARTITION BY),单表内部分片 |
Sharding Key | 分片键 | 决定数据归属哪个分片的路由字段 |
Hash Sharding | 哈希分片 | 分布均匀,扩容难 |
Range Sharding | 范围分片 | 范围查询友好,易热点 |
Consistent Hashing | 一致性哈希 | 哈希分片的扩容冗余方案 |
命名规律:
*Sharding= 按某种维度切碎片的策略;*Key= 决定切片走向的路由字段;*Partitioning= 数据库内部的原生分片机制。
"分片 = 策略 + 路由键 + 落地机制"三件套,缺一不可。
3. 为什么要进行数据拆分?¶
单库单表的性能瓶颈¶
- 数据量过大:
- 单表超过1000万行时,B+树索引深度增加
- 查询性能显著下降,索引维护成本高
- 并发瓶颈:
- 高并发下大量操作集中在单表
- 锁竞争导致性能下降,连接数受限
- 资源限制:
- 单机硬件资源有限(CPU、内存、磁盘IO)
- 备份恢复时间过长,影响业务连续性
- 业务扩展:
- 无法满足业务快速增长需求
- 缺乏水平扩展能力
拆分的好处¶
✅ 性能提升:单个表/库数据量减少,查询更快
✅ 并发增强:操作分散到多个表/库,减少锁竞争
✅ 资源优化:更好地利用多机资源
✅ 维护方便:可以分表/库进行备份优化
✅ 扩展性强:支持水平扩展,适应业务增长
拆分的代价¶
❌ 复杂度增加:需要处理数据路由和分布式事务
❌ 开发成本:业务代码需要适配分片逻辑
❌ 运维复杂:需要管理多个数据库和表
❌ 查询限制:跨分片查询性能较差
4. 拆分时机与原则¶
什么时候需要考虑拆分?¶
硬性指标(建议阈值):
- 单表数据量 > 1000万行
- 单表数据大小 > 50GB
- 单库QPS > 5000
- 单库连接数 > 1000
业务指标:
- 业务增长迅速,预计很快达到阈值
- 有明确的业务分片维度(如按用户、地域、时间)
- 对可用性和扩展性要求高
拆分原则¶
- 先优化,后拆分:
- 先尝试索引优化、查询优化
- 再考虑读写分离、缓存
- 最后才进行数据拆分
- 最小化影响:
- 尽量保持业务代码无感知
- 使用中间件隐藏拆分细节
- 可扩展性:
- 设计要支持未来继续扩容
- 避免一次性过度拆分
- 数据均衡:
- 选择合适的分片键
- 避免数据倾斜问题
- 业务导向:
- 根据业务查询模式设计拆分方案
- 优先保证核心业务的性能
5. 分片策略详解¶
1. 哈希分片(最常用)¶
优点:数据分布均匀,避免热点 缺点:扩容时需要数据迁移 适用场景:用户ID、订单ID等离散值
2. 范围分片¶
// 基于创建时间的范围分片
if (createTime < "2024-01-01") {
tableName = "orders_2023";
} else if (createTime < "2025-01-01") {
tableName = "orders_2024";
} else {
tableName = "orders_2025";
}
优点:易于按时间范围查询,便于数据归档 缺点:可能产生数据倾斜(新数据集中) 适用场景:时间序列数据,日志数据
3. 一致性哈希(重点)¶
核心问题:普通哈希分片 hash(key) % N,当 N 从 4 扩到 5 时,几乎所有 key 的归属都会变,搬迁量逼近 100%。
解决思路:把哈希空间(0 ~ 2^32 - 1)想象成一个环,节点和数据都落在环上,数据顺时针找到的第一个节点就是归属节点。扩容时只有一小段环的数据需要搬迁。
flowchart LR
subgraph ring["哈希环(2^32 节点)"]
direction TB
N1["Node A<br>hash=100"]
N2["Node B<br>hash=800"]
N3["Node C<br>hash=1500"]
N4["Node D<br>hash=2500"]
K1["key1<br>hash=300"] -->|顺时针| N2
K2["key2<br>hash=1200"] -->|顺时针| N3
K3["key3<br>hash=2000"] -->|顺时针| N4
K4["key4<br>hash=3000<br>绕回"] -->|顺时针| N1
end 虚拟节点(Virtual Node)机制:
直接用物理节点上环会导致数据倾斜——3 个节点把环切成 3 段,段长不均匀。解决方案是每个物理节点复制出 N 个虚拟节点(通常 150~200 个)散布在环上,请求先落到虚拟节点,再映射回物理节点。
// Redis Cluster 式伪代码(真实实现见 Ketama / ShardingSphere)
for (PhysicalNode node : physicalNodes) {
for (int i = 0; i < VIRTUAL_COUNT; i++) {
String virtualKey = node.getName() + "#VN" + i;
long hash = MurmurHash3.hash(virtualKey);
ring.put(hash, node); // TreeMap 按 hash 值排序
}
}
// 查询:顺时针找第一个 >= hash(key) 的虚拟节点
Node locate(String key) {
long h = MurmurHash3.hash(key);
Map.Entry<Long, Node> e = ring.ceilingEntry(h);
return e != null ? e.getValue() : ring.firstEntry().getValue(); // 绕回
}
扩容搬迁量计算:
| 方案 | N→N+1 搬迁比例 | 原因 |
|---|---|---|
普通哈希 % N | ≈ (N/(N+1)) ≈ 80%+ | 绝大多数 key 的模运算结果都会变 |
| 一致性哈希(无虚拟节点) | 1/(N+1) ≈ 20%(N=4 时) | 只有新节点那一段环的数据需要搬 |
| 一致性哈希 + 虚拟节点 | 1/(N+1),且分布更均匀 | 虚拟节点打散后,新节点均匀从老节点"割肉" |
适用场景:Redis Cluster 的 16384 槽位本质是一致性哈希变种;Memcached 的 Ketama;Cassandra 的 Token Ring;ShardingSphere 的 ConsistentHashingShardingAlgorithm。
4. 地理位置分片¶
按用户所在地区进行分片,适合地域性强的业务。
5. 业务属性分片¶
按业务属性如商户ID、商品类目等进行分片。
6. 分库分表带来的挑战与解决方案¶
1. 分布式事务¶
问题:跨库事务如何保证一致性?单库事务靠 InnoDB 的 Redo + Undo + MVCC 就能做到 ACID,跨多个 MySQL 实例后,网络不可靠 + 无统一协调者使得"要么全部提交要么全部回滚"变成一个分布式系统难题。
📖 单库内的事务 ACID 实现(MVCC / Redo / Undo / 锁清单)详见 事务与并发控制;本节仅讨论跨分片事务的架构策略,两者解决的是完全不同层的问题。
📖 术语家族:分布式事务协议
字面义:分布式事务 = 跨越 ≥ 2 个独立资源管理器(RM,通常是数据库实例)的事务;协议 = 协调这些 RM 达成"全部提交 / 全部回滚"一致决议的规则集合。 在分片架构中的角色:按"一致性强度 / 性能代价 / 业务侵入度"三维度权衡,衍生出刚性(强一致、低吞吐)与柔性(最终一致、高吞吐)两大流派。刚性协议由 JTA/XA 等规范驱动,柔性协议由业务补偿或消息最终一致性驱动。 同家族成员:
| 成员 | 流派 | 一致性 | 性能 | 业务侵入 | 代表实现 |
|---|---|---|---|---|---|
2PC(Two-Phase Commit) | 刚性 | 强一致 | 低(同步阻塞) | 无 | MySQL XA、Atomikos、Narayana |
3PC(Three-Phase Commit) | 刚性 | 强一致 | 更低(多一轮) | 无 | 理论方案,工业极少用 |
XA | 刚性规范 | 强一致 | 低 | 无 | JTA(javax.transaction.xa.XAResource) |
TCC(Try-Confirm-Cancel) | 柔性补偿 | 最终一致 | 中 | 高(业务拆三段) | Seata TCC 模式、Hmily、ByteTCC |
Saga | 柔性补偿 | 最终一致 | 高 | 中(每步写补偿) | Seata Saga 模式、Eventuate Tram |
本地消息表 | 柔性消息 | 最终一致 | 高 | 中(多一张消息表) | 自研 + MQ |
事务消息(Transactional Message) | 柔性消息 | 最终一致 | 高 | 低 | RocketMQ 半消息、Kafka 事务 |
最大努力通知 | 柔性消息 | 最终一致 | 最高 | 低 | 对账补偿 |
AT(Automatic Transaction) | 柔性(Seata 特色) | 最终一致 | 较高 | 极低(SQL 解析自动生成反向 SQL) | Seata AT 模式 |
命名规律:
*PC/XA= 刚性两阶段族(协调者 + RM,同步阻塞);TCC/Saga= 柔性补偿族(业务层拆分 + 反向操作);*消息表/事务消息= 柔性消息族(持久化 + 重试 + 幂等);AT= Seata 自动补偿族(框架代拆分)。
"协议 = 协调者 + 资源管理器 + 决议规则"三件套,选型时先问"业务能否接受最终一致"——能接受就走柔性(性能数倍于刚性),不能接受就上 XA 或 Seata AT。
1.1 刚性方案:2PC(两阶段提交)¶
流程:协调者(TM)与多个 RM 通过两轮 RPC 达成决议:
sequenceDiagram
participant TM as 事务管理器 TM
participant RM1 as 分片1 RM
participant RM2 as 分片2 RM
Note over TM,RM2: Phase 1 - Prepare(投票阶段)
TM->>RM1: prepare(写 undo/redo,加锁,不提交)
TM->>RM2: prepare
RM1-->>TM: yes
RM2-->>TM: yes
Note over TM,RM2: Phase 2 - Commit(决议阶段)
TM->>RM1: commit
TM->>RM2: commit
RM1-->>TM: done(释放锁)
RM2-->>TM: done MySQL 源码依据:storage/innobase/trx/trx0trx.cc::trx_prepare_low() 实现 XA prepare,handler/ha_innodb.cc::innobase_xa_prepare() 是 SQL 层入口;JTA 通过 XAResource.prepare() / XAResource.commit(xid, false) 调用到 MySQL。
致命缺陷:
- 协调者单点:TM 在 Phase 2 崩溃,RM 处于"prepared 悬挂态",锁无法释放(MySQL XA 事务挂住后必须 DBA 介入
XA RECOVER+XA COMMIT/ROLLBACK手动清理); - 同步阻塞:Phase 1 到 Phase 2 之间所有 RM 持锁等待,分片越多阻塞窗口越长;
- 数据不一致窗口:TM 发送 commit 后若部分 RM 收到、部分未收到,没有自动补偿机制。
1.2 柔性方案:TCC(Try-Confirm-Cancel)¶
流程:业务层把操作拆成 try(预留资源)+ confirm(实际提交)+ cancel(补偿回滚)三段:
sequenceDiagram
participant App as 业务发起方
participant Acc as 账户服务(分片A)
participant Stock as 库存服务(分片B)
Note over App,Stock: Try 阶段 - 预留资源
App->>Acc: try 冻结100元
App->>Stock: try 冻结1件库存
Acc-->>App: ok
Stock-->>App: ok
Note over App,Stock: Confirm 阶段 - 实际扣减(全部成功才进入)
App->>Acc: confirm 扣减已冻结100元
App->>Stock: confirm 扣减已冻结1件
Acc-->>App: ok
Stock-->>App: ok
Note over App,Stock: 异常分支 - 任一 try 失败则全部 cancel
App->>Acc: cancel 解冻
App->>Stock: cancel 解冻 核心要素:
Try:预留资源(如冻结余额、预占库存),必须持久化预留状态;Confirm:实际提交(解冻后扣减),必须幂等(允许重试);Cancel:补偿回滚(解除预留),必须幂等且能处理"空回滚"(try未执行就收到cancel)。
Seata TCC 源码依据:io.seata.rm.tcc.TCCResourceManager 管理 TCC 资源,@TwoPhaseBusinessAction 注解标记 try 方法并声明 commitMethod / rollbackMethod,io.seata.rm.tcc.interceptor.TccActionInterceptor 拦截调用并注册分支事务到 TC(Transaction Coordinator)。
业务侵入代价:每个 RPC 接口要拆成 3 个方法 + 持久化预留表 + 幂等表 + 空回滚/悬挂防护——接入成本极高,适合金融核心链路。
1.3 柔性方案:本地消息表(最常用)¶
核心思想:把"业务操作 + 发消息"放进同一个本地事务,消息表和业务表在同一个 MySQL 实例,天然 ACID;消息由后台 job 扫描并投递到 MQ,下游消费者幂等处理。
sequenceDiagram
participant Biz as 订单服务(分片A)
participant DB_A as 本地库A(订单表+消息表)
participant MQ as 消息队列
participant Sub as 积分服务(分片B)
participant DB_B as 本地库B
Note over Biz,DB_A: Step 1 - 本地事务(原子写)
Biz->>DB_A: BEGIN
Biz->>DB_A: INSERT orders (...)
Biz->>DB_A: INSERT msg_outbox (status=INIT)
Biz->>DB_A: COMMIT
Note over DB_A,MQ: Step 2 - 后台 job 扫表投递
loop 定时扫描 status=INIT 的消息
DB_A->>MQ: send message
MQ-->>DB_A: ack
DB_A->>DB_A: UPDATE msg_outbox SET status=SENT
end
Note over MQ,DB_B: Step 3 - 下游幂等消费
MQ->>Sub: deliver message
Sub->>DB_B: BEGIN
Sub->>DB_B: SELECT msg_consumed WHERE msg_id=? (幂等检查)
Sub->>DB_B: UPDATE 积分 + INSERT msg_consumed
Sub->>DB_B: COMMIT
Sub-->>MQ: ack 关键设计:
- 消息表
msg_outbox与业务表在同一个数据库——保证"业务写成功 ⇔ 消息写成功"的原子性; - 后台 job 负责扫表 + 重试投递(至少一次语义);
- 下游幂等表
msg_consumed按msg_id去重——保证"消息重复投递不会重复消费"。
vs 事务消息(RocketMQ 半消息):RocketMQ 把消息表"内置"到 MQ Broker 里,通过"半消息 + 回查"机制实现同样效果,业务代码少写一张表;代价是强绑定 RocketMQ,且回查接口必须实现得幂等。
业务场景建议:
- 强一致 + 低频(金融结算、账务过账)→ XA 或 Seata AT;
- 最终一致 + 高频(订单/库存/积分联动)→ 本地消息表 或 RocketMQ 事务消息;
- 业务可拆三段 + 实时性要求高(支付冻结-扣款)→ TCC;
- 长流程状态机(旅行预订:机票+酒店+租车)→ Saga。
2. 跨库查询¶
问题:如何实现跨分片的聚合查询?
解决方案:
- 业务层聚合:查询多个分片,应用层合并结果
- 使用中间件:如Sharding-JDBC自动路由和聚合
- 建立汇总表:预聚合数据,支持统计查询
- 使用搜索引擎:Elasticsearch处理复杂查询
- 联邦查询:某些数据库支持跨库查询
3. 全局ID生成¶
问题:如何保证分片环境下的ID唯一性?分片后自增主键失效(各分片独立自增会冲突),必须引入全局唯一 ID 生成方案。
📖 术语家族:分布式 ID 生成器
字面义:Distributed ID Generator = 在多节点环境下生成全局唯一、趋势递增 ID 的组件。 在分片架构中的角色:取代单库自增主键,保证唯一性(unique) + 趋势递增(monotonic) + 高性能(低延迟、高 TPS) + 高可用(无单点)。不同方案在这四个维度做取舍。 同家族成员:
| 成员 | 原理 | 唯一性 | 趋势递增 | 性能 | 典型坑 |
|---|---|---|---|---|---|
UUID (v4) | 随机 128 位 | ✅ 全球唯一 | ❌ 完全无序 | ⭐⭐⭐⭐⭐ | B+树页分裂、索引膨胀 |
Snowflake | 时间戳+机器ID+序列号 | ✅ 集群内 | ✅ 毫秒级递增 | ⭐⭐⭐⭐⭐ | 时钟回拨、机器ID分配 |
DB Segment(号段模式) | 数据库批量取号段到内存 | ✅ | ✅ | ⭐⭐⭐⭐ | DB 单点、号段浪费 |
Leaf-Segment(美团) | DB 号段 + 双 Buffer 预加载 | ✅ | ✅ | ⭐⭐⭐⭐ | DB 单点(可主备) |
Leaf-Snowflake | Snowflake + ZooKeeper 管理 workerId | ✅ | ✅ | ⭐⭐⭐⭐⭐ | 强依赖 ZK |
Redis INCR | Redis 原子自增 | ✅ | ✅ | ⭐⭐⭐⭐ | Redis 宕机、持久化丢号 |
UidGenerator(百度) | 改良 Snowflake + RingBuffer 预生成 | ✅ | ✅ | ⭐⭐⭐⭐⭐ | 仅 Java 生态 |
TiDB AUTO_RANDOM | 打散高位 + 低位自增 | ✅ | ❌(故意打散) | ⭐⭐⭐⭐⭐ | 仅 TiDB |
命名规律:
Snowflake派 = 64 位位域切分(时间+机器+序列),各家在"位数分配 + 时钟回拨处理 + workerId 管理"上做变种;Segment派 = 中心化 DB 发号 + 客户端缓存,靠批量摊薄 DB 压力;UUID派 = 去中心化,牺牲有序性换来零依赖;Leaf-*/UidGenerator-*/*Flake三类前后缀一出现,就知道属于哪派。
选型口诀:"怕时钟回拨用 Leaf-Segment;怕 DB 单点用 Snowflake;啥都不怕求省事用 Leaf-Snowflake"。
Snowflake 算法:64 位位域详解¶
Twitter 2010 年开源,至今仍是分布式 ID 的事实标准。64 位 long 切成 4 段:
0 00000000 00000000 00000000 00000000 00000000 0 00000 00000 000000000000
↑ ↑ ↑ ↑
│ │─────────── 41 位时间戳 ───────────│ │─ 10位机器ID ─│─ 12位序列号 ─│
│ (毫秒级,可用 69 年) (最多 1024 台)(单机每毫秒 4096)
│
符号位(固定 0,保证 ID 为正数)
| 段 | 位数 | 取值范围 | 作用 |
|---|---|---|---|
| 符号位 | 1 | 固定 0 | 保证 ID 为正数 |
| 时间戳 | 41 | 2^41 - 1 毫秒 ≈ 69 年 | 保证趋势递增,相对某个起点时间(epoch)计算 |
| 机器 ID | 10 | 0 ~ 1023 | 数据中心 ID(5位) + 机器 ID(5位),标识生成节点 |
| 序列号 | 12 | 0 ~ 4095 | 同一毫秒内的自增序列,保证同机单毫秒唯一 |
理论 TPS:单机 4096 × 1000 = 409.6 万/秒;集群 409.6 万 × 1024 ≈ 41 亿/秒。
核心生成逻辑(Twitter 原版伪代码):
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// ⚠️ 时钟回拨检测——最大的坑
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards!");
}
// 同一毫秒内,序列号递增
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 0xFFF; // 12位,掩码 4095
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp); // 本毫秒用尽,自旋等下一毫秒
}
} else {
sequence = 0;
}
lastTimestamp = timestamp;
// 拼接 4 段
return ((timestamp - EPOCH) << 22) // 时间戳左移 22 位(10 + 12)
| (workerId << 12) // 机器ID 左移 12 位
| sequence; // 序列号不移
}
Leaf-Snowflake:美团对时钟回拨的解法¶
原版 Snowflake 遇到时钟回拨(NTP 同步、运维误操作)只能抛异常停机。Leaf 用 ZooKeeper 持久化最新时间戳来解决:
flowchart TB
A["Leaf 启动"] --> B{"从 ZK 读取<br>本机上次最大时间戳"}
B --> C{"当前机器时间<br>≥ ZK 记录?"}
C -->|是| D["正常生成 ID"]
C -->|否| E["回拨幅度小<br>(≤ 5ms)"]
E -->|等待追上| D
C -->|否| F["回拨幅度大<br>(> 5ms)"]
F -->|直接报警下线| G["人工介入<br>避免重号"]
D --> H["每 3s 上报<br>当前时间戳到 ZK"] 源码关键类(com.sankuai.inf.leaf.snowflake.*):
SnowflakeIDGenImpl:ID 生成主逻辑,get()方法对应上图算法SnowflakeZookeeperHolder:workerId 分配 + 时间戳校验workerid/SnowflakeIDGenImpl#init():启动时自检时钟合法性
Leaf-Segment:DB 号段 + 双 Buffer¶
对不敏感时钟回拨或无 ZK 基础设施的场景,Leaf 还提供号段模式:DB 里维护 max_id / step,每次批量取 1000 个 ID 到内存,用完再取下一段。双 Buffer 预加载(当前段用了 10% 就异步加载下一段)避免取号瞬间的毛刺。
-- Leaf 号段表结构
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) PRIMARY KEY, -- 业务线
max_id BIGINT DEFAULT 1, -- 当前最大 ID
step INT DEFAULT 1000, -- 步长(批量大小)
update_time TIMESTAMP
);
-- 取号段:原子 UPDATE + 返回新范围
UPDATE leaf_alloc SET max_id = max_id + step WHERE biz_tag = 'order';
SELECT max_id, step FROM leaf_alloc WHERE biz_tag = 'order';
-- 客户端拿到 [max_id - step, max_id) 这段 ID 池
4. 数据迁移与扩容¶
问题:如何平滑扩容?
📖 扩容双写方案底层依赖的 Binlog 数据同步 / CDC(Change Data Capture) 原理详见 Binlog与主从复制;本节仅从架构层面讨论扩容策略。
解决方案:
- 双写方案:新旧集群同时写入,逐步迁移
- 数据同步工具:使用CDC工具同步数据
- 在线扩容:支持不停机扩容的方案
- 一致性哈希:减少扩容时的数据迁移量
7. 常用中间件与框架¶
📖 术语家族:分片中间件
字面义:Middleware = 应用与数据库之间的中间层,负责将逻辑表 / 逻辑库映射到物理表 / 物理库。 在分片架构中的角色:封装 SQL 解析 + 路由计算 + 结果集聚合 + 分布式事务等通用能力,对业务尽量透明。根据 实现位置(客户端库 vs 独立代理)与 运行形态(进程内 vs 独立服务 vs Sidecar)分为三派。 同家族成员:
| 成员 | 流派 | 技术特征 | 适用场景 |
|---|---|---|---|
Sharding-JDBC(ShardingSphere-JDBC) | 客户端分片 | Java JAR,进程内加载,无代理开销 | Java 栈、性能敏感 |
MyCat / ShardingSphere-Proxy | 代理分片 | 独立服务,伪装成 MySQL | 多语言栈、需运维简化 |
Vitess | 云原生分片 | Kubernetes 官方推荐,YouTube 开源 | 容器化、自动扩容 |
PolarDB-X / TDSQL | 云厂商分布式 DB | 托管方案,内置分片 + 强一致 | 云上业务、省运维 |
TDDL | 平台型中间件 | 阿里早期方案,JDBC 层分片 | 已被 ShardingSphere 取代 |
命名规律:
*-JDBC = 客户端分片(进程内);
*Cat / *-Proxy = 代理分片(独立服务);
*itess / *-X = 云原生 / 托管一体化。
记住 "客户端 / 代理 / 云原生" 三派分类,新冒出来的产品都能对号入座。
1. Sharding-JDBC(推荐)¶
- 类型:客户端分片,无代理
- 优点:对应用透明,性能好,轻量级
- 缺点:需要应用集成
源码级工作流程(ShardingSphere 5.x):
flowchart LR
SQL["原始 SQL<br>SELECT * FROM orders<br>WHERE user_id=123"] --> P["SQLParserEngine<br>SQL 解析"]
P --> R["ShardingRouteEngine<br>路由计算"]
R --> X["SQLRewriteEngine<br>SQL 改写"]
X --> E["ShardingExecuteEngine<br>并行执行"]
E --> M["MergeEngine<br>结果归并"]
M --> RS["最终 ResultSet"] 关键接口族(org.apache.shardingsphere.sharding.api.sharding.*):
| 接口 | 作用 | 典型实现 |
|---|---|---|
ShardingAlgorithm | 分片算法顶层接口 | 下面 4 族继承 |
StandardShardingAlgorithm | 标准单键分片 | HashShardingAlgorithm / IntervalShardingAlgorithm / ClassBasedShardingAlgorithm |
ComplexKeysShardingAlgorithm | 多键组合分片 | 业务自定义 |
HintShardingAlgorithm | 强制路由(Hint) | HintShardingAlgorithm |
KeyGenerateAlgorithm | 分布式主键生成 | SnowflakeKeyGenerateAlgorithm / UUIDKeyGenerateAlgorithm |
ConsistentHashingShardingAlgorithm | 一致性哈希分片 | 虚拟节点实现在 VirtualNodeRing |
# Spring Boot 配置示例
spring:
shardingsphere:
datasource:
names: ds0,ds1
ds0: ...
ds1: ...
rules:
sharding:
tables:
orders:
actual-data-nodes: ds$->{0..1}.orders_$->{0..3}
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: order-sharding
sharding-algorithms:
order-sharding:
type: INLINE
props:
algorithm-expression: orders_$->{user_id % 4}
2. MyCat¶
- 类型:代理层分片
- 优点:功能丰富,对应用完全透明
- 缺点:学习成本较高,有单点风险
3. Vitess¶
- 类型:Kubernetes原生
- 优点:适合云原生环境,自动化程度高
- 缺点:运维复杂,定制性较差
4. 其他方案¶
- 业务层自己实现:灵活但开发成本高
- 数据库原生支持:如MySQL分区表(有限制)
- 云数据库方案:如AWS Aurora、阿里云PolarDB
8. 实战案例:电商订单系统分库分表¶
业务场景¶
- 日订单量:100万+
- 历史订单:5亿+
- 主要查询:按用户查询、按时间范围查询
分片设计¶
// 分库:按用户ID取模分4个库
int dbIndex = userId % 4; // db0, db1, db2, db3
// 分表:每个库内按订单时间分12个月表
String month = createTime.format("yyyyMM");
String tableName = "orders_" + month;
// 最终表名:orders_202401(在db0中)
查询路由策略¶
- 精确查询:直接路由到具体分片
- 按用户查询:
WHERE user_id = ?(需查12个表) - 用户时间范围:
WHERE user_id = ? AND create_time BETWEEN ? AND ?
设计考量¶
- 分片键选择:用户ID + 时间,符合主要查询模式
- 数据分布:用户订单均匀分布,时间序列便于管理
- 扩展性:支持增加库数量和时间分片粒度
- 查询支持:优先保证核心业务的查询性能
9. 查询支持分析¶
支持的查询场景 ✅¶
- 精确查询:
WHERE user_id = ? AND create_time = ? - 按用户查询:
WHERE user_id = ?(需查12个表) - 用户时间范围:
WHERE user_id = ? AND create_time BETWEEN ? AND ?
复杂查询场景 ❌¶
- 纯时间范围:
WHERE create_time BETWEEN ? AND ?(需查48个表) - 多条件组合:
WHERE status = 'paid' AND amount > 100 - 聚合统计:
SELECT COUNT(*) FROM orders WHERE create_time > ?
解决方案 🛠️¶
- 中间件支持:Sharding-JDBC等自动处理
- 汇总表:预聚合统计信息
- 搜索引擎:Elasticsearch处理复杂查询
- 业务设计:避免需要跨分片查询的接口
10. 不理解底层会踩的坑¶
分库分表是一把双刃剑——用得好扛下百亿数据,用不好每一个跨分片查询都是血泪。以下 5 个坑来自真实线上事故,每一个都源于对底层机制的理解不透。
坑 1:分片键选错——order_id 哈希分片后按 user_id 查询¶
现象:订单表按 order_id 哈希分到 16 个分片,业务最频繁的查询却是"查某用户所有订单",每次都要扫全部 16 个分片再在应用层合并,延迟从 10ms 飙到 300ms。
根因:分片键选择没有对齐读写比最高的查询模式。order_id 与 user_id 之间没有函数关系,路由层拿不到分片线索,只能广播查询(Broadcast Query,ShardingSphere 术语叫"全库表路由")。
正解:
- 方案 A(推荐):分片键改为
user_id,order_id生成时绑定 user_id 前缀(即"基因法"——把user_id的哈希值嵌入order_id低位),这样按order_id查询也能反解出分片。 - 方案 B:建
user_id → order_id的全局索引表(Global Index),查询先路由到索引表拿order_id,再路由到订单表。
// ❌ 坑:order_id 完全无规律
long orderId = snowflake.nextId();
// ✅ 基因法:order_id 低 4 位 = user_id % 16,同用户订单落到同分片
long geneBit = userId % 16;
long orderId = (snowflake.nextId() & ~0xFL) | geneBit;
坑 2:一致性哈希没开虚拟节点——扩容后数据分布严重倾斜¶
现象:8 个物理节点扩到 9 个,按"朴素一致性哈希"在圆环上均分,结果新节点只分到 ~3% 的数据,老节点有的扛 20%、有的扛 5%,QPS 倾斜到 CPU 100%。
根因:朴素一致性哈希把节点哈希一次放到环上,节点少时哈希值在环上分布极不均匀——8 个节点实际可能有的分到 1/16 环弧、有的分到 1/4 环弧,数据自然倾斜。
正解:每个物理节点映射成 150~500 个虚拟节点(virtual node),虚拟节点在环上大量分布,方差按 1/√N 收敛。Redis Cluster 的 Hash Slot(16384 槽)、Ketama 算法(默认 160 虚拟节点/物理节点)都是这个思路。
// Ketama 经典参数:每节点 160 个虚拟节点
for (PhysicalNode node : physicalNodes) {
for (int i = 0; i < 160; i++) {
long hash = MD5(node.ip + "-" + i);
ring.put(hash, node); // 150+ 次在环上均匀撒点
}
}
坑 3:跨分片 JOIN 先写再改——业务从"10ms 查询"退化为"3 秒聚合"¶
现象:用户表分片键 user_id,订单表分片键 order_id,业务 SQL 写了 SELECT u.name, o.amount FROM users u JOIN orders o ON u.id = o.user_id WHERE u.level = 'VIP'。中间件拆解后变成笛卡尔积广播——每个用户分片 × 每个订单分片 = N² 次子查询。
根因:跨分片 JOIN 的本质是分布式 JOIN 算法(Broadcast Join / Shuffle Join / Colocated Join 三种)。中间件拿到任意两张表的 JOIN 时,如果无法推断"同一 join key 落在同一分片",就退化为最慢的 Shuffle。
正解:
- Colocated Join(绑定表):把有 JOIN 关系的表用同一分片键分片——
orders.user_id和users.id都按 user_id % 16,ShardingSphere 识别为BindingTable后 JOIN 不拆。 - Broadcast Table(广播表):字典表(省市、类目)在每个分片复制一份,JOIN 直接本地进行。
- 实在无法对齐:改为应用层两阶段查询或走 Elasticsearch 做宽表。
坑 4:Snowflake 时钟回拨——同一毫秒生成重复 ID 导致主键冲突¶
现象:NTP 同步 / 虚拟机漂移后系统时钟倒退 200ms,Snowflake 服务在这 200ms 内生成的 ID 与历史 ID 位域完全重合,主键冲突风暴。
根因:Snowflake 64 位 = 1 符号 | 41 时间戳 | 10 机器 ID | 12 序列号,其"唯一性"完全依赖时间戳单调递增。时钟回拨直接击穿这个前提。
正解(从轻到重四个档):
- 小幅回拨(< 5ms):
Thread.sleep(回拨时长),等时钟追上再发号 - 中幅回拨(5~1000ms):切换备用机器 ID 位(美团 Leaf-Snowflake 的 workerId 动态分配)
- 大幅回拨(> 1s):拒绝服务并告警,让运维介入确认
- 根本解决:用百度 UidGenerator 或 Leaf-Segment(号段模式),不依赖机器时钟,依赖数据库预分配号段
坑 5:双写扩容漏了 Binlog 延迟——新老集群数据短暂不一致¶
现象:扩容方案采用"双写新老集群 + 全量迁移 + 增量 Binlog 同步 + 切流",某一天切流后对账发现新集群比老集群少了 3 秒内的数据,用户反馈订单消失。
根因:双写切换时没考虑 Binlog 消费端的延迟。即使应用层已双写,老集群的存量变更还在通过 CDC 工具(Canal/Debezium)往新集群同步,切流那一刻若 CDC 存在 lag,新集群就少了这部分增量。
正解:
- 切流前强制校验
Seconds_Behind_Master = 0与 CDC 位点追上 - 切流窗口采用"双写 + 双读校验":切流后一段时间内同时查新老两份数据做对账
- 有条件时直接采用 Vitess 的
VReplication,它把"全量 + 增量 + 切流"做成原子操作
📖 Binlog / CDC 延迟机制详见 Binlog与主从复制,本文不展开。
11. 常见问题¶
Q1:分库、分表、分片有什么区别?
分表是单库内拆表,分库是多库间分布数据,分片是数据分布的抽象概念。分表和分库都是分片的具体实现方式。
Q2:如何选择分片键?
选择查询频率高、数据分布均匀的字段,避免数据倾斜。要结合业务查询模式设计。
Q3:分库分表后如何保证分布式事务?
根据业务场景选择:强一致性用 2PC/TCC,最终一致性用消息队列,或者尽量避免跨分片事务。
📖 单库事务的 ACID 实现原理(MVCC、Redo/Undo 协作、锁机制)详见 事务与并发控制;跨分片事务与单库事务是两个不同层次的问题,前者解决网络不可靠 + 多节点协调,后者解决单机存储引擎内部的崩溃恢复。
Q4:如何实现跨分片查询?
- 业务层聚合多次查询结果;
- 使用中间件自动路由;
- 建立汇总表;
- 使用搜索引擎。
Q5:分库分表有哪些常见的坑?
数据倾斜、跨分片查询性能差、分布式事务复杂、运维成本高、扩容困难。
Q6:ShardingSphere 如何把一条逻辑 SQL 拆成多条物理 SQL?从源码角度说完整链路?
四步分工:
SQLParserEngine把 SQL 解析为SQLStatement抽象语法树 →ShardingRouteEngine提取分片值并匹配ShardingRule计算出目标数据源 + 物理表 →SQLRewriteEngine改写逻辑表名为物理表名(必要时补偿LIMIT改写、补充分片列、派生列)→SQLExecutorEngine通过线程池并行执行多分片 SQL → 最后MergeEngine做连接式 / 排序 / 分组 / 聚合四种结果集合并。关键点:归并分"流式归并"(排序场景,仅读部分即可返回)和"内存归并"(聚合场景,需全量加载)两类。
Q7:Snowflake 遇到时钟回拨为什么会出问题?工业界怎么解?
Snowflake 的唯一性建立在时间戳单调递增的前提上(位域 = 1符号 + 41时间戳 + 10机器 + 12序列),一旦回拨,新生成 ID 的时间戳位可能与历史 ID 完全重合。美团 Leaf-Snowflake 的解法是:启动时把 workerId 注册到 ZooKeeper 并定期上报本机时钟,下次启动校验"本次启动时间 > 上次上报时间";运行期小幅回拨
sleep等待,大幅回拨直接拒绝服务并告警。更彻底的方案是用 Leaf-Segment 号段模式完全摆脱时钟依赖。
12. 总结与建议¶
技术选型建议¶
- 中小项目:先优化,必要时使用分表
- 大型项目:使用Sharding-JDBC等中间件
- 超大规模:考虑Vitess或云数据库方案
- 混合方案:分库分表 + 搜索引擎 + 数据仓库
最佳实践¶
- 循序渐进:不要过早过度设计
- 监控预警:建立完善的监控体系
- 自动化运维:使用工具简化管理
- 容灾备份:设计完善的灾备方案
黄金法则¶
如果没有遇到真正的性能瓶颈,不要过早进行分库分表。先尝试索引优化、读写分离、缓存等其他优化手段,只有当这些手段都无法满足需求时,才考虑分库分表。
13. 加强记忆版口诀¶
读完前面 12 节内容之后,建议把下面 7 条口诀抄到脑子里——每条都是对某个章节核心机制的最高压缩,面试与架构评审时能顺口喷出即视作理解到位。
分库分表七字诀(面试/评审速查)
① 能不分就不分 先索引、再读写分离、再缓存,硬性阈值(单表 >1000 万行 / >50 GB / 单库 QPS >5000 / 连接数 >1000)未触及之前,任何分库分表都是"为了分而分"。— §4
② 分片键大于分片数 分片键选错的代价(热点、跨分片、无法扩容)远远大于分库数少一倍。查询模式决定分片键,不是拍脑袋或者"用主键"。— §5 / §11.1
③ 一致性哈希必配虚拟节点 裸环(每节点 1 点)数据倾斜最高可达 2~3 倍;每节点 150~200 虚拟节点是 Dynamo/Cassandra/Redis 的工业共识,扩容搬迁量 ≈ 1/(N+1)。— §5.3 / §11.2
④ Snowflake 时钟回拨必须兜底 NTP 跳变 + 41 位时间戳差值 = ID 重复的炸弹。Leaf-snowflake 用"ZK 上报历史时间戳 + 启动期比对"兜底,业务方案至少要做"回拨 ≤ 5ms 自旋,>5ms 抛异常"。— §6.3 / §11.4
⑤ 跨分片事务能避就避 2PC 同步阻塞 + 协调者单点、TCC 业务侵入、Saga 弱隔离、本地消息表最终一致——没有银弹。业务设计优先让同一事务落在同一分片。— §6.1 / §11.3
⑥ 跨分片 JOIN 是反模式 查 N 个分片 → 应用层笛卡尔积 → 内存 OOM。绑定表(同分片键)、广播表(小字典全分片冗余)、异构索引(ES/汇总表)三件套解决 95% 场景。— §9 / §11.3
⑦ 扩容前双写,切流后观察,有问题能回滚 双写方案的三大雷:双写时序不一致、Binlog 追数未对齐、路由切流后旧库删不干净。一致性哈希 + 预分片 + CDC 工具链是标准范式。— §6.4 / §11.5
分库分表是解决大数据量问题的有效手段,但也是一把双刃剑。在设计时需要明确业务需求,选择合适的技术方案,并准备好应对分布式带来的复杂性。