当单表数据量突破千万、写入 QPS 打满单机、慢查询越来越多——你开始认真考虑分库分表。但分库分表不是银弹:跨分片 Join 怎么办?分布式事务怎么保证?分页排序怎么做?与此同时,NewSQL 数据库声称能”像分库分表一样扩展,像单机数据库一样使用”——它们做到了吗?
本章从分库分表的工程实践出发,逐层拆解拆分策略、分片键选择、分布式 ID、跨分片查询与分布式事务,再深入 NewSQL 数据库的架构设计,最终给出选型决策框架。
前置知识
- Ch13 数据分区:范围/哈希分区概念是分库分表的理论基础
- Ch14 分布式事务:跨分片事务的理论基础
- Ch15 一致性与共识:NewSQL 的共识机制依赖 Raft/Paxos
分库分表是中国互联网公司特有的工程实践——淘宝、京东、美团都在 2010 年代经历了从单库到分库分表的痛苦历程。MyCat、ShardingSphere 等中间件应运而生。与此同时,Google Spanner(2012)和 F1(2013)证明了”分布式数据库可以像单机一样用”——这催生了 TiDB(2015,PingCAP)和 CockroachDB(2015)等 NewSQL 数据库。理解这段历史,你就会明白为什么 NewSQL 被称为”分库分表的终结者”——它用分布式共识替代了中间件路由,用原生分布式事务替代了应用层补偿。
一、分库分表概述
1.1 为什么需要分库分表
单机数据库的扩展存在物理天花板——CPU 核数、内存容量、磁盘 IOPS、网络带宽都有上限。当业务增长到一定规模,分库分表是最直接的扩展手段。
分库分表不是唯一选择。在决定之前,先确认已穷尽单机优化手段:索引优化、查询优化、读写分离、缓存、数据归档。过早分库分表会引入不必要的复杂度。
1.2 垂直拆分
垂直拆分按业务维度拆分数据,分为垂直分库和垂直分表。
垂直分库将不同业务模块分散到不同数据库实例:
-- 垂直分库后:按业务域拆分-- user_db: users, user_profiles, user_settings-- order_db: orders, order_items, refunds-- product_db: products, categories, inventory-- payment_db: payments, transactions, invoices垂直分表将宽表按字段访问频率拆分:热数据(name、email)放 users_base,冷数据(avatar_url、bio、preferences)放 users_profile。
| 维度 | 垂直分库 | 垂直分表 |
|---|---|---|
| 拆分依据 | 业务域 | 字段访问频率 |
| 目标 | 业务解耦、独立扩展 | 减少行大小、提升缓存效率 |
| 跨库 Join | 需要,通过 RPC 或中间件 | 不需要,同一库内 Join |
| 事务 | 跨库事务复杂 | 单库事务,简单 |
1.3 水平拆分
水平拆分按数据行拆分,将同一张表的数据分散到多个分片,是解决单表数据量过大的核心手段。
| 决策点 | 选项 | 影响 |
|---|---|---|
| 分片键 | 用户 ID、订单 ID、时间 | 决定数据分布均匀度 |
| 分片数量 | 4、16、64、256 | 过少扩展性差,过多管理复杂 |
| 分片策略 | 范围、哈希、一致性哈希 | 决定查询路由效率与扩容难度 |
| 分片粒度 | 分表不分库 / 分库分表 | 决定资源隔离程度 |
水平拆分一旦执行,回滚成本极高。建议在分片数量上预留扩展空间(如初始 4 分片,路由逻辑支持扩展到 64),避免频繁再平衡。
二、分片策略
分片策略决定数据如何映射到分片,直接影响数据分布均匀性、查询效率和扩容难度。在 数据分区 中讨论了范围分区和哈希分区的原理,这里聚焦工程实践。
2.1 范围分片
范围分片按分片键的值域划分分片,每个分片负责一段连续范围:
# 范围分片路由逻辑RANGE_RULES = [ (1, 10_000_000, "shard_0"), (10_000_001, 50_000_000, "shard_1"), (50_000_001, 100_000_000, "shard_2"),]
def route_by_range(key: int) -> str: for start, end, shard in RANGE_RULES: if start <= key <= end: return shard raise ValueError(f"Key {key} out of range")优点:范围查询高效、扩容简单(追加新范围)。缺点:热点问题(最新数据集中在末尾分片)、数据倾斜。
2.2 哈希分片
哈希分片对分片键计算哈希值再取模映射到分片:
// 哈希分片路由逻辑public class HashSharding { private final int shardCount;
public String route(String shardKey) { int hash = murmurHash3(shardKey); int shardIndex = Math.abs(hash) % shardCount; return "shard_" + shardIndex; }
private int murmurHash3(String key) { int h = 0; for (int i = 0; i < key.length(); i++) { h = h * 31 + key.charAt(i); h ^= (h >>> 16); } return h; }}优点:数据分布均匀、热点分散。缺点:范围查询需扫描所有分片、扩容需大规模数据迁移。
2.3 一致性哈希
一致性哈希解决哈希分片扩容时数据迁移量过大的问题,将哈希值空间组织为虚拟环:
# 一致性哈希分片实现import hashlibfrom bisect import bisect
class ConsistentHashing: def __init__(self, replicas=150): self.replicas = replicas self.ring = [] self.node_map = {}
def _hash(self, key: str) -> int: return int(hashlib.md5(key.encode()).hexdigest(), 16)
def add_node(self, node: str): for i in range(self.replicas): h = self._hash(f"{node}#{i}") self.ring.append(h) self.node_map[h] = node self.ring.sort()
def route(self, key: str) -> str: h = self._hash(key) idx = bisect(self.ring, h) if idx == len(self.ring): idx = 0 return self.node_map[self.ring[idx]]2.4 查表法
查表法维护一张分片映射表,记录每条数据所属分片,是最灵活的策略:
-- 分片映射表CREATE TABLE shard_mapping ( entity_type VARCHAR(50) NOT NULL, entity_id BIGINT NOT NULL, shard_id INT NOT NULL, PRIMARY KEY (entity_type, entity_id));
-- 路由查询:先查映射表,再查目标分片SELECT shard_id FROM shard_mappingWHERE entity_type = 'order' AND entity_id = 12345;四种分片策略对比:
| 策略 | 数据均匀性 | 范围查询 | 扩容迁移量 | 灵活性 | 复杂度 |
|---|---|---|---|---|---|
| 范围分片 | 可能倾斜 | 高效 | 追加即可 | 中等 | 低 |
| 哈希分片 | 均匀 | 全分片扫描 | 大规模迁移 | 低 | 低 |
| 一致性哈希 | 均匀 | 全分片扫描 | 最小迁移 | 中等 | 中 |
| 查表法 | 可控 | 取决于映射 | 仅改映射 | 最高 | 高 |
实际项目中最常见的组合:用户 ID 用哈希分片(保证同一用户数据在同一分片),时间维度用范围分片(便于按时间查询和归档),特殊实体用查表法(灵活调整)。
三、分布式 ID 生成
分库分表后,数据库自增 ID 不再适用——不同分片可能生成相同 ID。分布式 ID 需满足:全局唯一、趋势递增、高性能、高可用。
3.1 UUID
UUID 是最简单的方案,128 位随机生成,几乎不可能冲突:
UUID.randomUUID().toString();// 输出: 550e8400-e29b-41d4-a716-446655440000| 优点 | 缺点 |
|---|---|
| 无需协调,本地生成 | 128 位过长,存储和索引效率低 |
| 实现简单 | 无序,B+ 树插入随机,页分裂频繁 |
UUID 作为 InnoDB 主键是经典性能陷阱。无序插入导致 B+ 树频繁页分裂,写入性能可能下降 30%~50%。如必须使用 UUID,考虑 UUID v7(时间戳前缀,趋势递增)。
3.2 Snowflake 雪花算法
Snowflake 是 Twitter 开源的分布式 ID 算法,生成 64 位整数 ID,兼顾唯一性和趋势递增:
/** * Snowflake ID 结构(64 位): * | 1 bit | 41 bits | 10 bits | 12 bits | * | 符号位 | 时间戳(毫秒) | 机器 ID | 序列号 | * - 41 位时间戳:约 69 年 * - 10 位机器 ID:1024 台机器 * - 12 位序列号:每毫秒 4096 个 ID */public class SnowflakeIdGenerator { private final long epoch = 1609459200000L; private final long sequenceMask = ~(-1L << 12L); private final long workerIdShift = 12L; private final long datacenterIdShift = 17L; private final long timestampShift = 22L; private final long workerId; private final long datacenterId; private long sequence = 0L; private long lastTimestamp = -1L;
public synchronized long nextId() { long timestamp = System.currentTimeMillis(); if (timestamp < lastTimestamp) throw new RuntimeException("时钟回拨,拒绝生成 ID"); if (timestamp == lastTimestamp) { sequence = (sequence + 1) & sequenceMask; if (sequence == 0) timestamp = tilNextMillis(lastTimestamp); } else { sequence = 0L; } lastTimestamp = timestamp; return ((timestamp - epoch) << timestampShift) | (datacenterId << datacenterIdShift) | (workerId << workerIdShift) | sequence; }
// tilNextMillis: 自旋等待下一毫秒}Snowflake 的关键问题是时钟回拨——系统时钟被调回可能生成重复 ID:
| 策略 | 做法 | 优缺点 |
|---|---|---|
| 拒绝服务 | 检测到回拨直接报错 | 简单可靠,但影响可用性 |
| 等待追上 | 回拨小时等待时钟追上 | 可用性好,但延迟不可控 |
| 多 worker | 启用备用 worker ID | 需要协调分配 |
3.3 号段模式
号段模式是美团 Leaf 等系统采用的方案,从中心节点批量获取 ID 段,本地消耗完后再获取下一段:
-- 号段表设计CREATE TABLE id_segment ( biz_tag VARCHAR(64) PRIMARY KEY, max_id BIGINT NOT NULL, step INT NOT NULL, version INT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
-- 获取号段(乐观锁更新)UPDATE id_segmentSET max_id = max_id + step, version = version + 1WHERE biz_tag = 'order' AND version = 0;-- 成功后,本地拥有 [1, 10000] 的 ID 段三种分布式 ID 方案对比:
| 方案 | 唯一性 | 有序性 | 性能 | 依赖 | 适用场景 |
|---|---|---|---|---|---|
| UUID | 极高 | 无序 | 极高 | 无 | 非主键、追踪 ID |
| Snowflake | 高 | 趋势递增 | 高 | 时钟 | 主键、排序场景 |
| 号段模式 | 高 | 严格递增 | 高 | 数据库 | 业务 ID、短 ID |
四、跨分片查询
分库分表后,Join、聚合、分页都可能涉及多个分片,这是最大的工程挑战。
4.1 跨分片 Join
单库中的 Join 在分库后可能变成跨库 Join,性能急剧下降:
-- 单库:简单的用户-订单 JoinSELECT u.name, o.order_no, o.amountFROM users u JOIN orders o ON u.id = o.user_idWHERE u.city = '北京';
-- 分库后:应用层 Join(推荐)-- Step 1: 查询北京用户SELECT id, name FROM user_db.users WHERE city = '北京';-- Step 2: 根据用户 ID 查询订单SELECT user_id, order_no, amountFROM order_db.orders WHERE user_id IN (1001, 1002, 1003);| Join 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 应用层 Join | 两次查询,内存中关联 | 逻辑清晰 | 网络开销大 |
| 冗余字段 | 反范式,冗余关联字段 | 查询简单 | 数据一致性维护 |
| 全局表 | 小表广播到所有分片 | Join 本地化 | 更新需同步 |
| ER 分片 | 关联数据路由到同一分片 | 避免跨分片 | 灵活性差 |
4.2 跨分片聚合
聚合查询需要两阶段处理——分片内聚合 + 合并:
# 跨分片聚合:两阶段处理(分片内聚合 + 合并)def sharded_count(table, where): results = [execute(shard, f"SELECT COUNT(*) AS c FROM {table} WHERE {where}") for shard in shards] return sum(r[0]['c'] for r in results) # 各分片 COUNT 求和
def sharded_avg(table, col, where): results = [execute(shard, f"SELECT SUM({col}) AS s, COUNT({col}) AS c FROM {table} WHERE {where}") for shard in shards] return sum(r['s'] for r in results) / sum(r['c'] for r in results)4.3 跨分片分页
跨分片分页是最棘手的问题,简单的 LIMIT offset, size 在分片环境下语义不正确:
-- 错误:每个分片 LIMIT 10, 5 然后合并 → 结果不正确-- 正确:各分片取 top N,内存归并排序-- 分片1: SELECT * FROM orders ORDER BY created_at LIMIT 0, 15-- 分片2: SELECT * FROM orders ORDER BY created_at LIMIT 0, 15-- 内存归并排序后取第 10~15 条深度分页优化方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 游标分页 | 用上一页最后一条记录的排序值作为游标 | 性能稳定 | 不能跳页 |
| 二次查询 | 第一次确定范围,第二次精确查询 | 结果精确 | 两次查询 |
| 禁止深翻页 | 限制最大 offset | 简单有效 | 用户体验受限 |
| 搜索引擎 | 同步到 Elasticsearch 分页 | 性能好 | 数据同步延迟 |
五、分布式事务
分库分表后,跨分片事务是绕不开的难题。在 分布式事务 中详细讨论了 2PC、Saga、TCC 等理论,这里聚焦工程实践。
5.1 XA 事务
XA 是最标准的分布式事务协议,基于两阶段提交:
// XA 事务示例(Java + Atomikos)UserTransaction tx = utm.getTransaction();tx.begin();// 操作分片1:扣款Connection conn1 = getShardConnection(fromUserId);conn1.createStatement().executeUpdate( "UPDATE accounts SET balance = balance - " + amount + " WHERE user_id = " + fromUserId);// 操作分片2:加款Connection conn2 = getShardConnection(toUserId);conn2.createStatement().executeUpdate( "UPDATE accounts SET balance = balance + " + amount + " WHERE user_id = " + toUserId);tx.commit(); // 两阶段提交:prepare → commit/rollbackXA 的问题:协调者单点故障、阻塞(prepare 后资源锁定直到 commit/rollback)、性能差。
5.2 Saga 模式
Saga 将长事务拆分为多个本地事务,每个本地事务有对应的补偿操作:
# Saga 模式:电商下单流程class OrderSaga: def execute(self, order): saga = SagaBuilder() \ .step("创建订单", action=lambda: self.order_service.create(order), compensate=lambda: self.order_service.cancel(order.id)) \ .step("扣减库存", action=lambda: self.inventory_service.deduct(order.sku_id, order.qty), compensate=lambda: self.inventory_service.restore(order.sku_id, order.qty)) \ .step("扣减余额", action=lambda: self.account_service.deduct(order.user_id, order.amount), compensate=lambda: self.account_service.refund(order.user_id, order.amount)) \ .build() saga.run() # 任何步骤失败,自动逆序执行补偿5.3 本地消息表
本地消息表是生产环境中最常用的最终一致性方案,利用本地事务保证消息一定被写入:
-- 本地消息表CREATE TABLE outbox_messages ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(64) NOT NULL, biz_id VARCHAR(128) NOT NULL, payload TEXT NOT NULL, status ENUM('PENDING','SENT','FAILED') DEFAULT 'PENDING', retry_count INT DEFAULT 0, max_retry INT DEFAULT 5, INDEX idx_status_created (status, created_at));
-- 业务操作 + 消息写入在同一个本地事务中BEGIN;INSERT INTO orders (user_id, amount, status) VALUES (1001, 99.9, 'CREATED');INSERT INTO outbox_messages (biz_type, biz_id, payload)VALUES ('ORDER_CREATED', 'ORD_001', '{"order_id":"ORD_001","user_id":1001,"amount":99.9}');COMMIT;三种分布式事务方案对比:
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| XA 事务 | 强一致 | 差(阻塞) | 中 | 短事务、一致性要求极高 |
| Saga | 最终一致 | 好(无锁) | 高 | 长流程、可补偿的业务 |
| 本地消息表 | 最终一致 | 好(异步) | 中 | 异步通知、跨服务调用 |
在分库分表场景下,大多数业务不需要强一致的分布式事务。通过合理设计分片键(保证关联数据在同一分片)、使用本地消息表实现最终一致性、对少数强一致场景使用 XA,可覆盖 95% 以上的需求。
六、NewSQL 数据库
NewSQL 数据库试图兼顾 NoSQL 的扩展性和关系型数据库的 ACID 事务与 SQL 接口。核心承诺:像分库分表一样水平扩展,像单机数据库一样使用 SQL。
6.1 TiDB 架构
TiDB 是 PingCAP 开发的分布式 HTAP 数据库,采用计算-存储分离的三层架构:
TiDB 的关键设计:
| 组件 | 职责 | 关键技术 |
|---|---|---|
| TiDB Server | SQL 解析、优化、执行 | 代价优化器、MPP 执行引擎 |
| PD | 元数据、时间戳、调度 | Raft 选举、TSO 时间戳 |
| TiKV | 行存储、事务 | Raft 复制、MVCC、Percolator 事务模型 |
| TiFlash | 列存储、分析加速 | 异步复制、向量化执行 |
TiDB 的事务模型基于 Google Percolator,采用乐观锁 + 两阶段提交:
-- TiDB 分布式事务示例BEGIN OPTIMISTIC;UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;COMMIT; -- 两阶段提交:Prewrite → Commit6.2 CockroachDB 架构
CockroachDB 受 Google Spanner 启发,采用无中心架构:
CockroachDB 关键特性:
- HLC(Hybrid Logical Clock):不依赖 GPS/原子钟,用混合逻辑时钟实现跨节点时间排序
- Range 分片:按主键范围自动分片,默认 64MB 一个 Range
- Leaseholder:每个 Range 有一个 Leaseholder 处理读写,避免所有请求都走 Raft
- 无中心:没有 PD 这样的协调节点,元数据分散存储
6.3 OceanBase 架构
OceanBase 是蚂蚁集团开发的分布式关系型数据库,采用 Paxos 分布式共识:
-- OceanBase:多租户资源隔离 + 兼容 MySQL 协议CREATE RESOURCE UNIT unit_app1 MAX_CPU 4, MIN_CPU 2, MEMORY_SIZE '8G';CREATE RESOURCE POOL pool_app1 UNIT = unit_app1, UNIT_NUM = 3;CREATE TENANT app1_tenant RESOURCE_POOL_LIST = ('pool_app1'), CHARSET = UTF8;
-- 支持 Hash/Range/List 分区CREATE TABLE orders (id BIGINT PRIMARY KEY, user_id BIGINT, amount DECIMAL(10,2))PARTITION BY HASH(id) PARTITIONS 16;三大 NewSQL 数据库架构对比:
| 维度 | TiDB | CockroachDB | OceanBase |
|---|---|---|---|
| 架构 | 计算存储分离(TiDB+PD+TiKV) | 无中心对等架构 | 代理层+数据层+Paxos |
| 分片 | Range 分片(Region) | Range 分片(Range) | Hash/Range/List 分区 |
| 事务模型 | Percolator(乐观+2PC) | Parallel Commits | Paxos + 2PC |
| 时钟 | TSO(PD 单点授时) | HLC(混合逻辑时钟) | TSOS(租户级时间戳) |
| 兼容性 | MySQL 协议 | PostgreSQL 协议 | MySQL / Oracle 协议 |
| HTAP | TiFlash 列存 | 有限 | 行列混存 |
七、选型决策
7.1 分库分表 vs NewSQL
7.2 决策矩阵
| 决策因素 | 分库分表 | TiDB | CockroachDB | OceanBase |
|---|---|---|---|---|
| 改造成本 | 高(应用改造大) | 低(MySQL 兼容) | 中(PG 兼容) | 低(MySQL 兼容) |
| 运维复杂度 | 高(多实例+中间件) | 中(组件多但成熟) | 低(无中心) | 中(Paxos 运维) |
| 事务一致性 | 弱(最终一致为主) | 强(Percolator) | 强(Parallel Commits) | 强(Paxos+2PC) |
| 跨分片查询 | 差(需应用层处理) | 好(自动路由) | 好(自动路由) | 好(自动路由) |
| 弹性扩展 | 差(扩容迁移量大) | 好(在线扩容) | 好(在线扩容) | 好(在线扩容) |
| 生态成熟度 | 高(ShardingSphere) | 高(国内生态好) | 中(海外生态好) | 中(蚂蚁生态) |
7.3 分库分表中间件选型
| 中间件 | 类型 | 特点 | 适用场景 |
|---|---|---|---|
| ShardingSphere-JDBC | Client 端 | 轻量、无代理 | Java 应用、中小规模 |
| ShardingSphere-Proxy | Proxy 端 | 语言无关、独立部署 | 多语言、运维友好 |
| Vitess | Proxy 端 | YouTube 出品、云原生 | Kubernetes 部署 |
| MyCat | Proxy 端 | 老牌、社区活跃 | 遗留系统 |
选型核心原则:能用单机就不分库,能分库分表就不上 NewSQL,能上 NewSQL 就不造轮子。在 一致性与共识 中讨论的 CAP 定理同样适用——你不可能同时获得完美的扩展性、一致性和简单性。
八、总结
核心要点回顾
| 主题 | 核心结论 |
|---|---|
| 分库分表 | 垂直拆分解耦业务,水平拆分解决数据量;水平拆分是最后手段,不可逆 |
| 分片策略 | 哈希分片均匀但范围查询差,范围分片反之;一致性哈希平衡扩容与均匀性 |
| 分布式 ID | Snowflake 兼顾有序与性能,号段模式适合业务 ID,UUID 仅适合非主键 |
| 跨分片查询 | 应用层 Join + 冗余字段 + 全局表组合使用;深度分页用游标替代 |
| 分布式事务 | 优先本地消息表(最终一致),少数场景用 XA(强一致),长流程用 Saga |
| NewSQL | TiDB(MySQL 生态+HTAP)、CockroachDB(PG 生态+无中心)、OceanBase(金融级) |
| 选型 | 单机优化 → 读写分离 → 分库分表 → NewSQL,逐层递进 |
从分库分表到 NewSQL 的演进路径
分库分表和 NewSQL 不是对立的,而是演进关系:
- 阶段一:单机 MySQL/PostgreSQL,做好索引和查询优化
- 阶段二:读写分离,用 数据复制 缓解读压力
- 阶段三:分库分表,用 数据分区 的策略分散数据和写入
- 阶段四:引入 NewSQL,用 一致性与共识 的算法保证分布式事务
每个阶段都有其适用场景,没有银弹。理解每个方案的原理和边界,才能在正确的阶段做出正确的选择。
本章讨论的分片策略与 数据分区 中的分区概念一脉相承,但视角不同:分区是数据库内核层面的数据组织方式,分库分表是应用层面的扩展手段,NewSQL 则试图将分区能力内置到数据库中,让应用无感知。理解这三者的关系,就理解了数据库扩展的完整演进路径。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






