缓存机制和数据冷热分离解决的是”绕开数据库”和”缩小热库体积”,但只要缓存未命中或热库数据量持续增长,请求终将回到数据库。数据库往往是高并发系统中最后一块硬瓶颈——它处理的是有状态的持久化操作,既不能无限横向扩展,又不能轻易牺牲一致性。
本篇沿着”先优化单机、再读写分离、再垂直拆分、再水平分片”的路线讲清每一步的做法与代价,以及何时该考虑 NewSQL / 分布式数据库作为替代路径。
TL;DR
- 能不扩就不扩:索引、覆盖查询、慢查询治理往往能将 P99 降低一个数量级;分库分表是最后手段,引入的工程复杂度将长期拖累迭代速度。
- B+ 树索引是 MySQL InnoDB 的核心数据结构;联合索引遵循最左前缀;覆盖索引消除回表;
EXPLAIN是排查执行计划最重要的工具。 - 读写分离把读流量卸载到从库,代价是复制延迟(通常 < 100ms,峰值可达秒级);“写后读”一致性需显式处理(强制读主、会话粘滞或等待位点)。
- 垂直拆分:按业务边界分库、把大字段(TEXT/BLOB)剥离独立表,降低单表 I/O 宽度。
- 水平分片:选好分片键是成功的 80%;Hash 分片均匀但扩容痛;Range 分片扩容简单但有热点;一致性哈希折中两者。
- 分片带来的新问题清单:全局唯一 ID、跨分片查询/聚合、跨分片事务(尽量避免)、深翻页、扩容迁移——每一项都有独立的工程成本。
- 中间件(ShardingSphere、Vitess)可让分片对业务代码透明,但引入新的运维复杂度,不是银弹。
- NewSQL(TiDB、CockroachDB)在 OLTP 场景下提供接近 MySQL 的接口 + 原生分布式,适合不想自己运维分片逻辑的团队。
- 反模式:过早分片、分片键选错导致热点、用分布式事务掩盖设计缺陷——任何一个都会让系统雪上加霜。
Table of contents
Open Table of contents
1. 为什么数据库是最后的瓶颈
一个典型的高性能架构演进路径:应用层无状态化 → 缓存层分担读压力 → 冷热分离缩减热库体积 → 数据库仍然是最终瓶颈。原因在于:
- 有状态:持久化数据必须落盘,磁盘 I/O 是不可消除的物理成本;
- 一致性约束:ACID 事务、行锁、MVCC 都需要协调机制,天然难以水平并行;
- 单机上限明确:CPU、内存、网络带宽、磁盘 IOPS 均有物理上限,无法像无状态应用层那样加机器就线性扩容。
扩展阶梯——先把低阶手段压榨干净,再考虑更高的阶:
| 阶段 | 方案 | 主要收益 | 主要代价 |
|---|---|---|---|
| 0 | 单机 + 索引 / SQL 优化 | 无额外运维成本;效果往往最显著 | 收益上限取决于原始代码质量 |
| 1 | 读写分离(主从复制) | 读流量线性扩展 | 复制延迟;从库切换运维 |
| 2 | 垂直拆分(分库 / 分表) | 业务解耦;单库压力下降 | 跨库事务;联合查询消失 |
| 3 | 水平分片(分库分表) | 数据量 / 写吞吐线性扩展 | 分片键绑定;运维极复杂 |
| 4 | NewSQL / 分布式数据库 | 原生分布式;弹性扩容 | 资源成本高;运维体系不同 |
一句话:越靠后的阶段,收益越大、代价越高——先把低阶手段压榨干净,再考虑上一层。
数据库扩展阶梯:单机优化 → 主从读写分离 → 垂直拆分 → 水平分片 → NewSQL 从左到右每一级代价急剧上升,先把低阶手段压榨干净再往上爬。
2. 单机优化优先:索引与执行计划
2.1 B+ 树与索引结构
InnoDB 默认使用 B+ 树作为索引数据结构:
- 叶节点存完整数据行(聚簇索引)或存主键值(二级索引),所有叶节点通过双向链表相连,范围扫描本质是顺序读;
- 树高通常 3–4 层,单次查询 3–4 次 I/O 即可定位到目标行;
- 二级索引查询若不能覆盖所需列,需额外回聚簇索引取完整行——这一步叫回表。
| 索引类型 | 叶节点存放内容 | 查询路径 |
|---|---|---|
| 聚簇索引(主键) | 完整行数据 | 直接读取,无回表 |
| 二级索引 | 索引列 + 主键值 | 查索引叶节点,再用主键回聚簇索引(回表) |
| 覆盖索引 | 索引列包含查询所需全部列 | 从索引直接返回,无需回表 |
2.2 联合索引与最左前缀
联合索引 (a, b, c) 的命中遵循最左前缀原则:
-- 能走索引:命中 a
SELECT * FROM t WHERE a = 1;
-- 能走索引:命中 (a, b)
SELECT * FROM t WHERE a = 1 AND b = 2;
-- 能走索引:a 等值 + b 范围(范围列之后的索引列失效)
SELECT * FROM t WHERE a = 1 AND b > 2;
-- 不走索引:跳过 a
SELECT * FROM t WHERE b = 2;
-- 不走索引:跳过 a 和 b
SELECT * FROM t WHERE c = 3;
设计原则:等值查询的列排前面;范围查询的列排最后;区分度(Cardinality)高的列排前面;避免在索引列上套函数(WHERE DATE(created_at) = '2026-07-01' 会使索引失效,改为范围条件)。
2.3 覆盖索引消除回表
若二级索引叶节点包含查询所需的全部列,MySQL 直接从索引返回结果,无需回聚簇索引:
-- 表:orders(id, user_id, status, amount, created_at)
-- 查询:按用户查最近订单的状态(不需要 amount)
SELECT status FROM orders
WHERE user_id = 123
ORDER BY created_at DESC LIMIT 10;
-- 覆盖索引设计:把 status 纳入索引,消除回表
CREATE INDEX idx_user_created_status
ON orders (user_id, created_at, status);
-- user_id 等值 → 命中最左前缀
-- created_at 排序 → 索引天然有序,无 filesort
-- status 在叶节点 → 无需回表
2.4 EXPLAIN 关键列解读
EXPLAIN 是定位慢查询的首要工具,重点关注:
| 列名 | 重点关注点 |
|---|---|
type | 执行方式;ALL(全表扫描)是最坏情况;ref、range、const 是合格水位 |
key | 实际使用的索引;NULL 表示未走索引 |
rows | 估算扫描行数;越大代价越高 |
Extra | Using filesort = 额外排序;Using temporary = 临时表;Using index = 覆盖索引(好) |
EXPLAIN SELECT status FROM orders
WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10\G
-- 目标结果:
-- type: ref
-- key: idx_user_created_status
-- Extra: Using index
2.5 慢查询治理
开启慢查询日志(slow_query_log = ON,long_query_time = 1),结合 Percona pt-query-digest 聚合分析。常见根因:
- 缺索引或索引失效(索引列套函数、隐式类型转换、
!=/NOT IN/LIKE '%prefix'); SELECT* 读取多余列,带来额外 I/O 且无法走覆盖索引;- N+1 查询(循环内单行查询,改为批量
IN或JOIN); - 深翻页(
LIMIT 100000, 20),改为游标分页:WHERE id > :last_id LIMIT 20。
3. 读写分离
3.1 主从复制原理
MySQL 异步主从复制流程:
- 主库将写操作记录到 Binlog;
- 从库 IO Thread 拉取 Binlog,存入 Relay Log;
- 从库 SQL Thread 回放 Relay Log,更新数据。
半同步复制(Semi-Sync):主库等至少一个从库确认写入 Relay Log 后再返回 ACK,降低数据丢失窗口,写延迟上升约 1 RTT。生产中建议 Primary-Replica 至少用半同步,避免主库宕机时数据丢失。
读写分离架构:应用层写主库,binlog 复制到多个从库,读请求路由到从库 主库承接所有写流量,从库 ×N 分摊读流量;复制延迟是所有一致性问题的根源。
3.2 读写路由方式
| 方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 应用层路由 | 代码中显式区分主从数据源(如 Spring @Transactional(readOnly=true) 切从库) | 灵活,控制精确 | 侵入业务代码;易漏配 |
| ShardingSphere-JDBC | JDBC 层拦截,自动识别读写 SQL 并路由 | Java 无需代理节点 | Java 生态绑定 |
| 代理层路由 | ShardingSphere-Proxy、ProxySQL 解析 SQL 自动路由 | 语言无关;对应用透明 | 额外 RTT;代理是新的单点 |
3.3 复制延迟与「写后读」一致性
主从复制天然存在延迟(通常 < 100ms,写入高峰可达秒级)。典型问题:用户刚提交订单,立刻跳转订单列表——若路由到从库,可能读不到刚写入的记录,用户看到空列表。
三种解法:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 强制读主 | 写操作后在会话级标记一段时间走主库 | 最简单可靠;主库压力会上升 |
| 会话粘滞 | 同一用户写操作后一段时间内,读请求全部路由主库,TTL 后切回 | 适合单用户一致性要求高的场景 |
| 等待位点(GTID) | 写操作返回 Binlog 位点,读时等待从库达到该位点再返回(MySQL 8.0 WAIT_FOR_EXECUTED_GTID_SET) | 延迟最低但实现复杂 |
一句话:读写分离换来的是读吞吐,付的是延迟窗口内的一致性——强制读主是最直接的补偿,但不要让它悄悄变成默认行为,否则主库压力不降反升。
3.4 连接池提醒:池大小是经验法则,不是定理
读写分离后每个数据源(主库 + 每个从库)都要独立配置连接池,池不是越大越好——连接数超过数据库能有效并行处理的上限,只会在库端排队并放大上下文切换开销。HikariCP 常被引用的经验公式 连接数 ≈ CPU 核数 × 2 + 有效磁盘数(CPU*2 + 磁盘)是一条经验法则(rule of thumb),不是定理:它源于机械磁盘时代对排队论的粗略近似,SSD/NVMe、云数据库的连接上限、事务持有时长、是否读写分离都会让最优值明显偏移。真正的池大小应以压测下的 P99 与数据库端 Threads_running、锁等待、连接等待时长为准,而非套公式。
4. 垂直拆分
4.1 按业务分库
将不同业务域的表拆到独立数据库实例,实现业务级隔离:
monolith_db(单体库)
├── users
├── orders / order_items
├── products / categories
└── payments / refunds
→ 拆分后:
user_db → users(写少读多,扩读副本)
order_db → orders, order_items(写多,用 SSD 实例)
product_db → products, categories(读多,加本地缓存)
payment_db → payments, refunds(强一致,高可用主从)
收益:各业务域独立扩容、故障隔离;DBA 可针对不同业务特点调优参数。
代价:跨库事务变为分布式事务;跨库 JOIN 消失,需在应用层拼装;服务间通信增加。
4.2 大字段剥离
单表中存在大字段(TEXT、MEDIUMBLOB)时,每次全表扫描都需要读取这些大字段,Buffer Pool 被大量”无效”页占用,命中率下降。将大字段拆离到独立表:
-- 拆前:全表扫 products 时每行都搬运 description + thumbnail
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(200),
price DECIMAL(10,2),
description TEXT, -- 每行可达数 KB
thumbnail MEDIUMBLOB -- 更大
);
-- 拆后:主表只含查询热路径字段
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(200),
price DECIMAL(10,2)
);
CREATE TABLE product_details (
product_id BIGINT PRIMARY KEY,
description TEXT,
thumbnail MEDIUMBLOB
);
效果:主表行宽缩小,索引页和数据页能容纳更多行,Buffer Pool 命中率显著提升;大字段只在需要展示详情时才单独查询。
5. 水平分片(分库分表)
垂直拆分解决业务耦合,但单个业务的数据量持续增长时,单表仍会触顶——千万行之后写入变慢、索引占用内存增大、DDL 变更耗时极长。水平分片把同一张表的数据按某个维度拆散到多个分片(Shard)。
5.1 分片键选择
分片键是整个分片方案的灵魂,选错比不分片更糟糕:
- 选业务查询最高频的维度:订单系统以
user_id为分片键,大部分查询(我的订单)天然只打一个分片; - 避免单调递增导致写热点:以时间戳、自增 ID 做分片键时,所有写入都集中在最新分片;
- 一旦确定几乎无法变更:分片键绑定了数据分布,变更需全量迁移,设计期必须充分论证;
- Cardinality 足够高:取值需足够分散,避免大量行集中在少数分片(如以性别分片,只有两个分片,必有热点)。
5.2 分片策略对比
| 策略 | 原理 | 数据均匀性 | 扩容方式 | 热点风险 | 适用场景 |
|---|---|---|---|---|---|
| Range 分片 | 按值范围切(id 0–999 万 → 分片 0) | 较差 | 新增分片接收新范围,旧数据无需迁移 | 高(写入集中在最新范围) | 日志、账单等按时间范围查询为主 |
| Hash 分片 | shard = hash(key) % N | 均匀 | 扩容 N→2N 时约 50% 数据需迁移 | 低 | 大多数 OLTP 业务 |
| 一致性哈希 | 哈希环 + 虚节点,节点增减只影响相邻数据 | 较均匀 | 扩容只迁移约 1/N 数据 | 低 | 数据量持续增长、需频繁扩容 |
实践建议:OLTP 订单/用户类场景首选 Hash 分片;日志、审计类按时间查询多的场景可用 Range 分片;预期频繁扩容时用一致性哈希。
5.3 路由计算示例
// Hash 分片路由(16 库 × 16 表 = 256 个逻辑分片)
private static final int DB_COUNT = 16, TABLE_PER_DB = 16;
public ShardTarget route(long shardKey) {
// 关键:先对分片键散列,再让 dbIndex 与 tableIndex 从散列值的不同部分
// 独立取模。不要写成 tableIndex = key / DB_COUNT —— 那样两级路由被同一个
// 除法耦合起来,分片键低位有规律(如自增 ID、时间戳)时极易倾斜。
long h = fmix64(shardKey); // 散列打散,消除低位规律
int dbIndex = (int)(h % DB_COUNT); // 落到哪个库
int tableIndex = (int)((h / DB_COUNT) % TABLE_PER_DB); // 库内落到哪张表
return new ShardTarget("order_db_" + dbIndex, "orders_" + tableIndex);
}
// 64-bit 混淆(MurmurHash3 的 fmix64),把分片键均匀打散到高低位
private static long fmix64(long z) {
z = (z ^ (z >>> 33)) * 0xff51afd7ed558ccdL;
z = (z ^ (z >>> 33)) * 0xc4ceb9fe1a85ec53L;
return (z ^ (z >>> 33)) & Long.MAX_VALUE; // 取非负
}
两点必须注意:
- 库、表数量都取 2 的幂次:翻倍扩容时路由重算最规整(
% 16→% 32,约半数数据需迁移)。 **dbIndex与tableIndex独立计算**:分别取散列值的低位段与高位段,避免”库均匀但库内表倾斜”或反之;散列步骤不可省,直接对原始 ID 取模会把 ID 本身的分布规律(自增、时间前缀)带进分片。
分库分表路由流程:分片键提取 → hash/range 计算 → 路由到对应分片 分片键一旦确定几乎无法变更;跨分片查询需广播所有分片并在应用层聚合。
6. 分片带来的新问题
6.1 全局唯一 ID
分片后各库自增 ID 会冲突,必须引入全局唯一 ID 方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| UUID | 随机 128-bit | 无中心节点;生成本地化 | 无序,B+ 树随机插入性能差;36 字节占用大 |
| 雪花算法(Snowflake) | 时间戳(41bit) + 机器ID(10bit) + 序列号(12bit) → 64-bit long | 趋势递增;高性能;适合数据库主键 | 依赖机器时钟,时钟回拨可能重复 |
| 号段模式 | DB 中预分配号段,应用内存批量消费 | 简单可靠;无时钟依赖 | DB 是弱中心点(主从高可用兜底);号段耗尽时有短暂延迟 |
| Redis INCR | Redis 原子自增 | 有序;性能高 | Redis 故障时若无持久化,重启后 ID 可能重复 |
推荐:雪花算法是互联网场景首选(美团 Leaf-Snowflake、百度 UidGenerator 均在此基础上改进了时钟回拨处理);号段模式适合对 ID 格式有业务要求的场景。
6.2 跨分片查询与聚合
当查询条件不包含分片键时,必须向所有分片广播查询并在应用层聚合:
// 按 status 查询(非分片键),需广播所有分片
List<CompletableFuture<List<Order>>> futures = allShards.stream()
.map(shard -> CompletableFuture.supplyAsync(
() -> shard.query("SELECT * FROM orders WHERE status = ?", status),
executor
))
.collect(Collectors.toList());
List<Order> result = futures.stream()
.flatMap(f -> f.join().stream())
.sorted(Comparator.comparing(Order::getCreatedAt).reversed())
.limit(pageSize)
.collect(Collectors.toList());
跨分片 ORDER BY + LIMIT 的深翻页问题:OFFSET 10000 LIMIT 20 需每个分片各取 10020 条,汇总后再截取——随 OFFSET 增大,性能急剧下降。改用游标分页(WHERE id > :last_id)或对历史数据异步化(提交任务、后台分页、结果推送)。
跨分片聚合统计(COUNT、SUM、AVG)的精确值需广播所有分片,高频调用时代价极高。建议将统计类查询卸载到 OLAP 引擎(ClickHouse、Doris),与 OLTP 分片完全隔离。
6.3 跨分片事务
跨分片事务是分库分表最大的坑:单机 ACID 跨分片后需要两阶段提交(2PC)或 SAGA 补偿事务,复杂度和故障率大幅上升,且 2PC 在参与方宕机时会进入阻塞状态。
核心原则:尽最大努力把同一事务的数据放在同一分片。若用 user_id 做分片键,同一用户的订单、订单明细、支付记录都以 user_id 路由到同一分片,则大部分用户级别的事务不会跨分片。
对于无法避免的跨分片写入(如扣减全局库存),用最终一致性替代分布式事务:消息队列 + 幂等消费,失败时重试,比 2PC 更适合互联网场景。
6.4 扩容与数据迁移
Hash 取模分片扩容(N → 2N)时约 50% 数据需迁移。双写迁移是生产中最安全的方式:
阶段 1(准备):新旧分片集群同时接收写入(双写),新分片开始积累增量数据
阶段 2(迁存量):后台任务扫描旧分片,按新路由规则将存量数据写入新分片
阶段 3(校验):对迁移完成的范围做行数与 checksum 比对,确认无丢失
阶段 4(切流量):灰度将读流量切到新分片,观察无异常后停止双写,下线旧分片
一致性哈希的优势:新增节点只需从相邻节点迁移约 1/N 的数据,扩容成本远低于 Hash 取模方案,适合需要频繁弹性扩容的场景。
7. 分片中间件
| 中间件 | 部署方式 | 核心特点 | 适用场景 |
|---|---|---|---|
| ShardingSphere-JDBC | Java 库,嵌入应用进程 | 无额外网络开销;路由在进程内完成 | Java 技术栈首选 |
| ShardingSphere-Proxy | 独立代理,兼容 MySQL 协议 | 语言无关;引入额外 RTT | 多语言团队或历史遗留系统 |
| MyCat | 独立代理 | 国内老牌方案 | 仅存在历史包袱时考虑;新项目不推荐 |
| Vitess | 独立代理 + Operator | YouTube 开源;云原生;连接池管理强 | K8s 环境;大规模 MySQL 集群 |
何时不上分库分表:
- 单表 < 1000 万行,且近 6 个月月增 < 500 万行;
- 业务查询有大量跨分片场景(统计、宽表关联),分片后查询性能反而更差;
- 团队没有能长期 owner 分片方案全生命周期(设计 → 上线 → 扩容 → DDL 变更)的工程师。
8. NewSQL / 分布式数据库
若不想自己维护分库分表的复杂逻辑,NewSQL 提供了另一条路径:
| 产品 | 协议兼容 | 核心优势 | 主要注意点 |
|---|---|---|---|
| TiDB | MySQL | HTAP(OLTP + OLAP 一体);自动分片 | 同等硬件下单点写吞吐低于 MySQL;运维体系与 MySQL 不同 |
| CockroachDB | PostgreSQL | 全球分布式;强一致 | 国内部署生态弱;Geo-Partition 配置复杂 |
| PolarDB | MySQL / PG | 云原生;存算分离;一写多读 | 阿里云厂商绑定 |
| Aurora | MySQL / PG | 全托管;Serverless 模式 | AWS 厂商绑定;跨 AZ 写延迟 |
TiDB 的取舍:TiDB 通过 Region(Raft 组)自动完成数据分片与副本管理,对应用层暴露标准 SQL 接口,无需手写路由逻辑。代价是:Raft 协议额外 RTT 导致写吞吐低于单机 MySQL;TiKV(存储)+ TiDB(计算)分层部署成本高;MVCC 实现与 MySQL 有差异,偶有 SQL 兼容性问题。
选择标准:运维自建分库分表方案 vs 运维 TiDB 集群——看哪种更适合团队的能力与组织结构,两者都不简单。对于新项目且团队有云数据库运维经验,TiDB / PolarDB 可以显著降低业务侧复杂度。
9. 反模式与小结
9.1 常见反模式
反模式一:过早分片
单表 500 万行就开始分库分表——此时加联合索引、升级机器配置、加读副本足以应对。分片引入的工程复杂度将长期拖累迭代速度,且一旦上线几乎不可回头。
反模式二:分片键选错导致热点
以 created_at 做 Hash 分片键——时间戳单调递增,Hash 后看似均匀,但如果业务总是查”最近数据”,查询仍然集中在存放最新时间段的少数分片上,形成查询热点。以业务 ID(user_id、merchant_id)为分片键,查询天然聚焦单分片,比时间字段分片更均匀。
反模式三:用分布式事务掩盖设计问题
跨分片写入频繁 → 引入 2PC 分布式事务 → 延迟上升、故障率上升 → 继续叠加补偿逻辑。根因往往是分片键选错导致本该在同一分片的数据分散了,应重新审视分片键设计,而不是在错误的分片方案上叠加复杂的事务协议。
反模式四:跨分片广播统计作为实时接口
每次 API 调用都向所有分片广播 SELECT COUNT(*) FROM orders WHERE status = 'PENDING'——分片数越多,查询延迟越不可控;结果汇总还需在应用层做 reduce。统计类查询应走异步链路(T+N 分钟级别的统计)或独立 OLAP 系统,与 OLTP 主链路隔离。
9.2 小结
数据库扩展的最优解从来不是”跳到最复杂的那层”,而是沿阶梯逐级爬升、按需止步:
- 先把索引做对:
EXPLAIN核心查询,消灭全表扫描;覆盖索引消除回表;游标分页消灭深翻页; - 读流量用读副本扛:读写分离是性价比最高的扩展手段,主从延迟用强制读主或会话粘滞补偿;
- 写流量超出单机上限时才考虑水平分片:分片一旦启动就很难回头,分片键的选择决定未来 3–5 年的架构演进空间;
- 跨分片事务能避则避:选好分片键让同一事务的数据天然落在同一分片;必须跨分片时用最终一致性 + 幂等消费,而非 2PC;
- NewSQL 是分库分表的替代,不是升级:TiDB 能把分片复杂度从业务侧转移到基础设施侧,代价是运维体系的替换。
结合缓存机制(减少回源)、数据冷热分离(缩小热库体积)与批处理与请求合并(降低数据库调用频次),数据库层的扩展是整个高性能系统设计闭环中代价最高、决策最难回头的一环——越早做对,越省事。