Skip to content
Charles Shao
Go back

数据库性能与扩展:索引、读写分离与分库分表

views

缓存机制数据冷热分离解决的是”绕开数据库”和”缩小热库体积”,但只要缓存未命中或热库数据量持续增长,请求终将回到数据库。数据库往往是高并发系统中最后一块硬瓶颈——它处理的是有状态的持久化操作,既不能无限横向扩展,又不能轻易牺牲一致性。

本篇沿着”先优化单机、再读写分离、再垂直拆分、再水平分片”的路线讲清每一步的做法与代价,以及何时该考虑 NewSQL / 分布式数据库作为替代路径。

TL;DR

Table of contents

Open Table of contents

1. 为什么数据库是最后的瓶颈

一个典型的高性能架构演进路径:应用层无状态化 → 缓存层分担读压力 → 冷热分离缩减热库体积 → 数据库仍然是最终瓶颈。原因在于:

扩展阶梯——先把低阶手段压榨干净,再考虑更高的阶:

阶段方案主要收益主要代价
0单机 + 索引 / SQL 优化无额外运维成本;效果往往最显著收益上限取决于原始代码质量
1读写分离(主从复制)读流量线性扩展复制延迟;从库切换运维
2垂直拆分(分库 / 分表)业务解耦;单库压力下降跨库事务;联合查询消失
3水平分片(分库分表)数据量 / 写吞吐线性扩展分片键绑定;运维极复杂
4NewSQL / 分布式数据库原生分布式;弹性扩容资源成本高;运维体系不同

一句话:越靠后的阶段,收益越大、代价越高——先把低阶手段压榨干净,再考虑上一层。

数据库扩展阶梯:单机优化 → 主从读写分离 → 垂直拆分 → 水平分片 → NewSQL 从左到右每一级代价急剧上升,先把低阶手段压榨干净再往上爬。

2. 单机优化优先:索引与执行计划

2.1 B+ 树与索引结构

InnoDB 默认使用 B+ 树作为索引数据结构:

索引类型叶节点存放内容查询路径
聚簇索引(主键)完整行数据直接读取,无回表
二级索引索引列 + 主键值查索引叶节点,再用主键回聚簇索引(回表)
覆盖索引索引列包含查询所需全部列从索引直接返回,无需回表

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(全表扫描)是最坏情况;refrangeconst 是合格水位
key实际使用的索引;NULL 表示未走索引
rows估算扫描行数;越大代价越高
ExtraUsing 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 = ONlong_query_time = 1),结合 Percona pt-query-digest 聚合分析。常见根因

3. 读写分离

3.1 主从复制原理

MySQL 异步主从复制流程:

  1. 主库将写操作记录到 Binlog
  2. 从库 IO Thread 拉取 Binlog,存入 Relay Log
  3. 从库 SQL Thread 回放 Relay Log,更新数据。

半同步复制(Semi-Sync):主库等至少一个从库确认写入 Relay Log 后再返回 ACK,降低数据丢失窗口,写延迟上升约 1 RTT。生产中建议 Primary-Replica 至少用半同步,避免主库宕机时数据丢失。

读写分离架构:应用层写主库,binlog 复制到多个从库,读请求路由到从库 主库承接所有写流量,从库 ×N 分摊读流量;复制延迟是所有一致性问题的根源。

3.2 读写路由方式

方式做法优点缺点
应用层路由代码中显式区分主从数据源(如 Spring @Transactional(readOnly=true) 切从库)灵活,控制精确侵入业务代码;易漏配
ShardingSphere-JDBCJDBC 层拦截,自动识别读写 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 分片键选择

分片键是整个分片方案的灵魂,选错比不分片更糟糕:

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;              // 取非负
}

两点必须注意:

分库分表路由流程:分片键提取 → hash/range 计算 → 路由到对应分片 分片键一旦确定几乎无法变更;跨分片查询需广播所有分片并在应用层聚合。

6. 分片带来的新问题

6.1 全局唯一 ID

分片后各库自增 ID 会冲突,必须引入全局唯一 ID 方案:

方案原理优点缺点
UUID随机 128-bit无中心节点;生成本地化无序,B+ 树随机插入性能差;36 字节占用大
雪花算法(Snowflake)时间戳(41bit) + 机器ID(10bit) + 序列号(12bit) → 64-bit long趋势递增;高性能;适合数据库主键依赖机器时钟,时钟回拨可能重复
号段模式DB 中预分配号段,应用内存批量消费简单可靠;无时钟依赖DB 是弱中心点(主从高可用兜底);号段耗尽时有短暂延迟
Redis INCRRedis 原子自增有序;性能高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)或对历史数据异步化(提交任务、后台分页、结果推送)。

跨分片聚合统计COUNTSUMAVG)的精确值需广播所有分片,高频调用时代价极高。建议将统计类查询卸载到 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-JDBCJava 库,嵌入应用进程无额外网络开销;路由在进程内完成Java 技术栈首选
ShardingSphere-Proxy独立代理,兼容 MySQL 协议语言无关;引入额外 RTT多语言团队或历史遗留系统
MyCat独立代理国内老牌方案仅存在历史包袱时考虑;新项目不推荐
Vitess独立代理 + OperatorYouTube 开源;云原生;连接池管理强K8s 环境;大规模 MySQL 集群

何时不上分库分表

8. NewSQL / 分布式数据库

若不想自己维护分库分表的复杂逻辑,NewSQL 提供了另一条路径:

产品协议兼容核心优势主要注意点
TiDBMySQLHTAP(OLTP + OLAP 一体);自动分片同等硬件下单点写吞吐低于 MySQL;运维体系与 MySQL 不同
CockroachDBPostgreSQL全球分布式;强一致国内部署生态弱;Geo-Partition 配置复杂
PolarDBMySQL / PG云原生;存算分离;一写多读阿里云厂商绑定
AuroraMySQL / 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_idmerchant_id)为分片键,查询天然聚焦单分片,比时间字段分片更均匀。

反模式三:用分布式事务掩盖设计问题

跨分片写入频繁 → 引入 2PC 分布式事务 → 延迟上升、故障率上升 → 继续叠加补偿逻辑。根因往往是分片键选错导致本该在同一分片的数据分散了,应重新审视分片键设计,而不是在错误的分片方案上叠加复杂的事务协议。

反模式四:跨分片广播统计作为实时接口

每次 API 调用都向所有分片广播 SELECT COUNT(*) FROM orders WHERE status = 'PENDING'——分片数越多,查询延迟越不可控;结果汇总还需在应用层做 reduce。统计类查询应走异步链路(T+N 分钟级别的统计)或独立 OLAP 系统,与 OLTP 主链路隔离。

9.2 小结

数据库扩展的最优解从来不是”跳到最复杂的那层”,而是沿阶梯逐级爬升、按需止步:

结合缓存机制(减少回源)、数据冷热分离(缩小热库体积)与批处理与请求合并(降低数据库调用频次),数据库层的扩展是整个高性能系统设计闭环中代价最高、决策最难回头的一环——越早做对,越省事。


views
Share this post on:

Previous Post
CDN:就近访问、回源、缓存策略与防盗链
Next Post
数据冷热分离:边界划分、迁移与查询路由