MySQL 优化经常被总结成一些口诀:联合索引遵守最左前缀、不要在索引列上使用函数、事务越短越好。这些口诀有用,但只背口诀很容易误判。
真正值得长期记住的是一套分析方法:SQL 扫描了多少记录、访问了多少页、持有了哪些锁、占用了多久连接,以及失败后如何恢复。
本文以 MySQL 8 和 InnoDB 为主,整理表设计、索引、执行计划、事务、锁、连接池、大表变更和排障经验。示例只表达通用模式,不代表所有业务都应该照抄。
核心结论
- 索引不是越多越好。 每个索引都会消耗磁盘、Buffer Pool、写放大和维护成本。
- 不存在可靠的“索引失效清单”。
IS NULL、BETWEEN、OR、!=都可能使用索引,最终由查询形状、数据分布、统计信息和成本模型决定。 - 联合索引应该围绕完整查询设计。 同时考虑过滤、排序、分页、回表和其他查询的复用,不要只看单列选择性。
- 普通
SELECT在常见隔离级别下通常是快照读,不加记录锁。SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE和DELETE才是典型锁定读或写操作。 - InnoDB 锁的是扫描到的索引记录和范围,不是 SQL 文本里最终返回的几行。 索引不合适,锁范围就可能远大于结果集。
- 死锁是并发数据库的正常现象。 应减少发生概率,并让应用可以安全地重试整个事务。
- 连接池不是越大越好。 它只是在应用侧排队,不能让数据库凭空增加 CPU、I/O 和锁处理能力。
- Online DDL 不等于零影响。 即使允许并发 DML,仍可能争用元数据锁、I/O、临时空间,并增加 Checkpoint 压力和复制延迟。
- 单表行数没有统一红线。 查询模式、行宽、索引、冷热分布、DDL 窗口和恢复时间比一个固定数字更重要。
- 优化必须建立在测量上。 先看慢日志、执行计划和实际行数,再修改 SQL 或索引。
InnoDB 的存储模型
为什么是 B+ Tree
InnoDB 以页为基本单位组织数据,索引通常使用 B+ Tree。B+ Tree 适合数据库,不只是因为“减少磁盘 I/O”,还因为它同时满足:
- 非叶子节点主要保存键和子节点指针,扇出较大,树高较低;
- 叶子节点按键有序,适合范围扫描和排序;
- 从根到叶子的路径长度稳定;
- 页可以被 Buffer Pool 缓存,热点查询经常不需要真实磁盘读取;
- 节点分裂和合并的成本可以被增量写入摊销。
SSD 已经改变了随机 I/O 成本,但没有改变“少访问页通常更快”这件事。索引优化的本质仍然是用更小、更有序的数据结构缩小扫描范围。
聚簇索引
每个 InnoDB 表都有一个聚簇索引,叶子节点直接保存整行数据:
- 显式定义主键时,主键就是聚簇索引;
- 没有主键时,InnoDB 选择第一个所有列均为
NOT NULL的唯一索引; - 两者都没有时,InnoDB 生成隐藏的行 ID。
生产表应显式定义主键。依赖隐藏主键会让数据迁移、复制、排障和二级索引成本都变得不透明。
二级索引与回表
二级索引叶子节点保存“二级索引列 + 主键值”。通过二级索引查询非索引列时,InnoDB 先找到主键,再回到聚簇索引读取整行,这就是回表。
因此,主键会被复制到每一个二级索引中。过长的字符串主键或随机 UUID 不只影响主键索引,也会放大所有二级索引、缓存和写入成本。
主键通常应满足:
- 短;
- 唯一;
- 不可变;
- 写入顺序尽量稳定;
- 不包含业务上可能变化的含义。
自增 ID 写入局部性好,但分布式生成和数据合并需要额外设计;随机 ID 易于跨节点生成,却可能导致页分裂和更差的缓存局部性。没有一种方案适合所有系统,关键是知道代价在哪里。
表结构设计
下面是一张用于示例的通用任务表:
CREATE TABLE task_record (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
request_id VARCHAR(64) NOT NULL,
status TINYINT UNSIGNED NOT NULL,
amount DECIMAL(30, 8) NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_tenant_request (tenant_id, request_id),
KEY idx_tenant_status_created_id
(tenant_id, status, created_at DESC, id DESC)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_bin;
数据类型
- 整数选择能覆盖未来容量的最小类型,不要为了省几个字节留下很快溢出的字段。
- 金额使用
DECIMAL或定义清楚比例的整数;Java 侧使用BigDecimal,不要使用float或double。 VARCHAR长度表达业务上限,而不是习惯性全部写成最大值。TEXT、BLOB和大 JSON 不应塞进高频列表的热行;必要时拆到扩展表或对象存储。- 状态字段要定义稳定编码。Java 枚举的
ordinal()会随声明顺序变化,不适合作为持久化协议。 - 时间需要明确时区、精度和语义。
created_at、updated_at是事件时间还是数据库写入时间,不能含糊。
NULL 不是索引失效
InnoDB 的 B-Tree 索引可以保存和检索 NULL,IS NULL 也可以使用索引。真正需要考虑的是字段语义:
NULL表示未知、缺失或不适用,不应和空字符串、零混用;- 可为空字段会增加应用分支和三值逻辑;
- 唯一索引通常允许出现多个
NULL,不要误以为它能约束“只能有一个空值”; - 当字段业务上一定存在时,优先使用
NOT NULL和合理默认值。
字符集与排序规则
统一使用 utf8mb4。排序规则决定比较、排序、唯一性和索引行为:是否区分大小写、重音和尾部空格,都可能影响业务结果。
跨表 JOIN 的字符集或排序规则不一致,会导致隐式转换、索引利用困难甚至直接报错。选择排序规则时先明确业务语义,不要因为某个模板默认这样写就永久继承。
JSON 的边界
JSON 适合变化快、读取整体为主的扩展属性,不适合替代关系模型。频繁作为过滤、排序、JOIN 或唯一约束的字段,应该成为普通列。
必须查询 JSON 内部字段时,可以使用生成列或函数索引,但仍要验证表达式完全匹配、统计信息可靠,并为 Schema 演进准备迁移方案。
索引设计
从查询开始,不从字段开始
索引服务于查询,而不是字段。设计前先写清楚:
SELECT id, request_id, status, created_at
FROM task_record
WHERE tenant_id = ?
AND status = ?
AND created_at < ?
ORDER BY created_at DESC, id DESC
LIMIT 50;
这条查询的主要访问路径是:租户等值过滤、状态等值过滤、时间范围、稳定倒序和小批量返回。因此索引 (tenant_id, status, created_at DESC, id DESC) 比四个单列索引更贴合查询。
最后的 id 很重要:不同记录可能有相同的 created_at,没有唯一 Tie-breaker,翻页时可能重复或遗漏。
最左前缀
联合索引 (a, b, c) 可以直接支持以 (a)、(a, b)、(a, b, c) 为查找前缀的查询。只有 b 或 (b, c) 时,通常不能像完整左前缀那样定位范围。
但“违反最左前缀就绝对不用索引”也不准确。优化器可能选择全索引扫描、Skip Scan 或其他访问方式。关键不是执行计划里是否出现索引名字,而是扫描量和总代价是否合理。
联合索引列顺序
一个实用顺序是:
- 高频、稳定的等值过滤列;
- 范围过滤或排序列;
- 保证排序稳定的唯一列;
- 少量、窄且高频读取的投影列,用于覆盖索引。
“选择性最高的列永远放最前面”是不完整的规则。联合索引还要服务租户隔离、排序、分页和多条查询。多个等值条件之间的顺序,常常应由完整工作负载决定。
范围条件之后的列,通常不能继续缩小同一个连续查找区间,但仍可能用于 Index Condition Pushdown、排序或覆盖。不要只凭口诀判断,要看执行计划。
覆盖索引
如果查询需要的列全部可以从某个索引得到,就能避免回表。覆盖索引对高频小查询很有效,但不应该把大量大字段都塞进索引:
- 索引越宽,单页容纳的记录越少;
- Buffer Pool 命中率下降;
- 写入、更新和页分裂成本增加;
- 同一查询的收益可能伤害全表的写性能。
覆盖索引是空间换时间,而且这个空间会持续产生写放大。
唯一索引也是并发控制
“先查询是否存在,再插入”存在竞态。两个事务可以同时查到不存在,然后都执行插入。数据库唯一约束才是最终防线。
典型做法是让 (tenant_id, request_id) 成为唯一键,应用捕获重复键并返回已有结果。Redis 幂等 Key 或分布式锁可以减少冲突,但不能替代唯一约束。
不要重复建索引
存在 (a, b, c) 后,单列索引 (a) 经常是冗余的;但 (b) 并不冗余,因为它不是左前缀。是否删除还要考虑:
- 更短索引是否被高频覆盖查询使用;
- 唯一性是否不同;
- 排序方向和前缀长度是否不同;
- 外键或执行计划是否依赖;
- 删除后写性能能改善多少。
MySQL 8 的 Invisible Index 可以先隐藏索引、观察执行计划与性能,再决定是否删除。不过隐藏并不消除维护成本,它只是降低试错风险。
索引的常见代价
- 每次
INSERT都要写所有相关索引; - 更新索引列相当于删除旧项并插入新项;
- 删除记录后还需要 Purge 回收;
- 索引过多会增加优化器选择成本;
- 低价值索引占用 Buffer Pool,挤出真正的热点页;
- 大索引会拉长备份、恢复、复制和 DDL 时间。
“索引失效”应该怎样理解
更准确的问题不是“索引有没有失效”,而是:
- 优化器能否使用某个索引定位范围?
- 使用索引是否比全表扫描便宜?
- 索引只是用于扫描,还是也消除了排序、回表或临时表?
- 估算行数与实际行数是否接近?
| 条件 | 可能的行为 | 真正要检查的内容 |
|---|---|---|
col IS NULL |
可以使用索引 | NULL 比例与扫描行数 |
col BETWEEN ? AND ? |
可以做范围扫描 | 范围宽度与回表数量 |
col != ?、NOT IN (...) |
可能做范围或索引扫描 | 命中比例是否太高 |
a = ? OR b = ? |
可能使用 Index Merge | 联合查询是否值得专用索引 |
name LIKE 'abc%' |
通常可以做前缀范围扫描 | 排序规则与参数类型 |
name LIKE '%abc' |
普通 B-Tree 难以定位 | 是否应使用全文检索或反向索引 |
DATE(created_at) = ? |
普通列索引难以直接定位 | 改写范围或使用函数索引 |
| 字符串列和数字比较 | 可能发生隐式转换 | 参数类型、转换方向、执行计划 |
| 低选择性状态列 | 可能全表扫描 | 是否与租户、时间组合 |
ORDER BY |
可能利用索引,也可能排序 | 过滤、方向和索引顺序是否匹配 |
把函数放到常量一侧
下面的写法对列逐行计算,普通 created_at 索引难以直接用于定位:
WHERE DATE(created_at) = '2026-09-08'
通常应改写为半开区间:
WHERE created_at >= '2026-09-08 00:00:00'
AND created_at < '2026-09-09 00:00:00'
半开区间不会在毫秒、微秒精度变化时漏掉边界数据。
隐式类型转换
如果 request_id 是字符串,应用却绑定成数字,MySQL 可能需要转换列值后比较,导致索引访问路径恶化。JOIN 两侧字段的类型、长度、Unsigned 属性、字符集和排序规则也应一致。
最稳妥的做法不是背转换规则,而是让参数类型从 API、Java、JDBC 到数据库列保持一致,并用真实参数查看执行计划。
优化器主动不用索引
当查询需要读取表中很大比例的数据时,顺序扫描可能比“扫描二级索引 + 大量随机回表”更便宜。这个比例没有固定阈值,会随着行宽、索引宽度、缓存命中、磁盘、统计信息和查询列变化。
FORCE INDEX 可能暂时绕过错误计划,但它会把今天的数据分布固化成明天的假设。优先修复统计信息、SQL 或索引设计;确实使用 Hint 时,要有注释、监控和撤销条件。
用 EXPLAIN 分析,而不是猜
EXPLAIN 与 EXPLAIN ANALYZE
EXPLAIN FORMAT=TREE
SELECT id, request_id, status, created_at
FROM task_record
WHERE tenant_id = 42
AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 50;
EXPLAIN 展示优化器计划和估算。EXPLAIN ANALYZE 会真正执行语句,并补充实际耗时、返回行数和循环次数:
EXPLAIN ANALYZE
SELECT id, request_id, status, created_at
FROM task_record
WHERE tenant_id = 42
AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 50;
因为 EXPLAIN ANALYZE 会执行 SQL,生产环境只应对确认安全、影响可控的只读查询使用,并遵守超时与权限限制。
重点看什么
- 访问方式是常量、点查、范围、索引扫描还是全表扫描;
- 候选索引有哪些、最终选择了哪个;必要时用 Optimizer Trace 分析未选择原因;
- 估算
rows与实际行数相差多少; filtered后还剩多少记录;- 每个节点的
actual time、rows和loops; - 是否发生额外排序、临时表、物化或重复回表;
LIMIT 50之前实际扫描了多少行;- 最慢的是扫描、JOIN、排序还是聚合。
执行计划出现 Using index 不代表查询一定快,全表覆盖索引扫描仍可能很贵;出现 Using filesort 也不代表一定慢,对几十行内存排序可能比维护一个宽索引更划算。
统计信息
优化器依赖统计信息估算基数。数据分布剧烈变化、批量导入或删除后,估算可能偏离实际。可以检查持久化统计、直方图和 ANALYZE TABLE,但不要把刷新统计当作固定运维仪式。
优化必须使用接近生产的数据量和分布。空测试库里“走索引”,不能证明亿级表上会选择同一计划。
查询、分页与批处理
不要习惯性 SELECT *
只读取需要的列可以减少网络、反序列化、内存和回表成本,也让覆盖索引成为可能。大字段应在详情接口按需读取,不要进入高频列表。
避免 N+1
先查 100 条记录,再循环执行 100 次明细查询,是最常见的数据库放大器之一。优先批量查询:
- 一次取得主记录;
- 提取关联 ID 并去重;
- 使用有界
IN批量查询; - 在内存中按 ID 分组组装。
IN 也不是越长越好。过大的 SQL 会增加解析、网络、优化和执行成本,应根据数据量分批,必要时使用临时表或数据导入方案。
游标分页
深分页:
SELECT id, request_id, created_at
FROM task_record
WHERE tenant_id = ?
ORDER BY created_at DESC, id DESC
LIMIT 100000, 50;
即使使用索引,MySQL 仍要跳过前面大量记录。更稳定的是 Keyset Pagination:
SELECT id, request_id, created_at
FROM task_record
WHERE tenant_id = ?
AND (
created_at < ?
OR (created_at = ? AND id < ?)
)
ORDER BY created_at DESC, id DESC
LIMIT 50;
对应索引至少需要 (tenant_id, created_at DESC, id DESC)。游标携带完整排序键,才能在时间重复时保持稳定。
Keyset Pagination 不适合直接跳到任意页,但多数无限滚动、后台扫描和批量修复并不需要这个能力。
COUNT(*) 不是免费的
InnoDB 不维护可以直接返回的精确总行数。精确 COUNT(*) 仍要扫描满足条件的记录或索引。列表页如果每次都计算一个昂贵总数,分页查询本身再快也没用。
可以根据产品语义选择:
- 小范围索引计数;
- 只返回
hasMore; - 缓存或异步计算近似值;
- 离线汇总;
- 明确告诉用户数字是估算。
批量写入
- 批量大小要有上限,避免超大事务、SQL 包、Redo 和 Binlog;
- 大范围更新先按主键游标选出一小批,再精确更新;
- 每批提交后记录检查点,失败可以恢复;
- 回填任务应限速,并监控主库负载和复制延迟;
- 不要用
OFFSET扫描持续变化的大表,主键游标更可靠; - 需要重复执行时,写入必须幂等。
事务与 MVCC
四种隔离级别
| 隔离级别 | 一致性读特点 | 常见代价 |
|---|---|---|
| READ UNCOMMITTED | 可能读到未提交数据 | 很少用于业务系统 |
| READ COMMITTED | 每次一致性读创建新快照 | 同一事务两次读取可能不同 |
| REPEATABLE READ | 通常复用首次一致性读建立的快照 | Undo 保留更久,锁定读仍看当前版本 |
| SERIALIZABLE | 关闭 autocommit 时,普通读取会转为共享锁定读;开启 autocommit 时可以保持一致性非锁定读 | 并发能力通常最低 |
MySQL InnoDB 默认是 REPEATABLE READ。默认值不等于所有系统的最佳选择:READ COMMITTED 可以减少部分范围锁影响,REPEATABLE READ 则提供更稳定的事务内快照。应根据业务语义和锁冲突实测选择。
快照读与当前读
普通 SELECT 在 READ COMMITTED 和 REPEATABLE READ 下通常使用 MVCC 快照,不给读取记录加锁。它通过 Undo 版本链重建事务可见的数据。
下面这些属于当前读,会读取较新的可用版本并参与锁定:
SELECT ... FOR UPDATE;SELECT ... FOR SHARE;UPDATE;DELETE;- 某些约束检查和特殊语句。
同一事务里先做快照读、再做锁定读,可能看到不同版本。不要把两种读取方式混在一起后,仍假设整个事务只存在一张静态快照。
Redo、Undo 与 Binlog
- Redo Log 记录页修改,用于崩溃恢复和持久性;
- Undo Log 保存旧版本,用于事务回滚与 MVCC;
- Binlog 位于 Server 层,用于复制、增量恢复和数据订阅。
长时间保持旧快照的事务会迫使 InnoDB 保留更多 Undo、阻碍 Purge;包含大量写入的长事务还会增加回滚、故障恢复和复制应用成本。即使事务没有持有很多行锁,也可能给系统留下很长的历史版本链。
事务边界
事务应只覆盖必须原子完成的数据库操作:
- 不在事务内等待用户输入;
- 不在事务内调用不可控的外部 HTTP/RPC;
- 不在事务内做大文件处理或长时间计算;
- 不把整个 Service 类默认包进事务;
- 写方法显式声明事务,查询按一致性需要选择;
- 明确异常是否触发回滚;
- 保证注解调用真正经过 Spring 代理,避免同类内部调用失效。
本地数据库事务无法自动覆盖消息队列和第三方服务。需要“写库 + 发消息”时,可以使用 Transactional Outbox:业务数据和 Outbox 记录在同一事务提交,再异步发布并由幂等消费者处理。
InnoDB 锁与 Metadata Lock
常见锁类型
- Record Lock:锁定索引记录;
- Gap Lock:锁定索引记录之间、之前或之后的间隙,主要阻止插入;
- Next-Key Lock:Record Lock 与前方 Gap Lock 的组合;
- Intention Lock:表级意向锁,协调表锁与行锁;
- Auto-Increment Lock:与自增值分配有关;
- Metadata Lock:由 MySQL Server 层管理,用于保护表定义;普通查询也会持有,DDL 需要与它协调。
意向锁通常由 InnoDB 自动管理,业务无需手工申请。线上更常见的问题是范围锁、缺少索引导致的大范围扫描,以及长事务阻塞 DDL。
锁加在索引访问路径上
锁定读、UPDATE 和 DELETE 通常会锁住执行过程中扫描到的索引记录。WHERE 条件最终过滤掉的行,也可能已经在扫描阶段被锁定。
如果没有合适索引而不得不扫描全表,几乎所有记录都可能被锁住,效果接近表级阻塞。此时“SQL 最终只更新一行”并不能说明锁范围只有一行。
使用二级索引做排他锁定时,InnoDB 还需要锁定对应的聚簇索引记录。因此分析锁冲突时,要同时看二级索引和主键访问。
唯一等值查询与范围查询
使用完整唯一索引精确找到一条已有记录时,通常只需要 Record Lock。非唯一索引、只使用唯一索引的一部分,或范围条件,可能产生 Gap Lock 或 Next-Key Lock。
在 REPEATABLE READ 下,下面的锁定读可能阻止其他事务向目标范围插入:
START TRANSACTION;
SELECT id
FROM task_record
WHERE tenant_id = 42
AND status = 0
AND created_at < '2026-09-08 00:00:00'
ORDER BY created_at DESC, id DESC
LIMIT 10
FOR UPDATE;
具体锁范围取决于所选索引、边界值、数据是否存在和隔离级别。不要只看 WHERE 文本推断,应结合执行计划和锁监控验证。
READ COMMITTED 会减少许多普通范围搜索中的 Gap Lock,但唯一性检查、外键检查等场景仍可能使用间隙相关锁。切换隔离级别不是“一键消灭死锁”。
FOR UPDATE、FOR SHARE、NOWAIT 与 SKIP LOCKED
FOR UPDATE用于即将修改的记录;FOR SHARE用于需要阻止并发修改、但允许其他共享读取的场景;NOWAIT无法立即取得锁时直接失败;SKIP LOCKED跳过已锁记录,适合多个 Worker 竞争领取独立任务。
SKIP LOCKED 返回的是一个不完整视图,不能用于要求精确一致结果的普通业务查询。它适合工作队列,是因为“暂时跳过这条任务”本身就是允许的语义。
Metadata Lock
一个很短的 ALTER TABLE 也可能长时间等待,因为前面有未提交事务持有 Metadata Lock。更糟糕的是,等待中的 DDL 还可能阻塞后续新查询,形成队列。
执行 DDL 前应检查长事务、空闲事务和当前锁等待。应用要设置合理超时,避免连接开启事务后长时间闲置。
Metadata Lock 不会出现在 InnoDB 数据锁表中,应通过专用表查看:
SELECT * FROM performance_schema.metadata_locks;
死锁与锁等待
死锁并不等于数据库坏了
典型死锁:
Transaction A: locks row 1 → waits for row 2
Transaction B: locks row 2 → waits for row 1
InnoDB 检测到环后会选择一个事务回滚,让其他事务继续。应用必须能够识别死锁错误,并安全地重试整个事务,不能只重试最后一条 SQL。
死锁重试需要:
- 操作具备幂等性;
- 次数有上限;
- 使用短暂退避和随机抖动;
- 每次从事务开始重新读取状态;
- 超过上限后记录上下文并告警。
减少死锁
- 所有代码按一致顺序更新表和记录;
- 事务尽量短,及时提交;
- 锁定查询使用正确索引,缩小扫描范围;
- 大批量更新拆成小批次;
- 避免事务中等待外部依赖;
- 热点记录考虑条件更新、队列化或拆分;
- 删除不用的索引,减少单行写入需要修改的索引记录;
- 不要把提高锁等待超时当成根因修复。
降低隔离级别可能减少部分锁冲突,但写操作仍然会死锁。死锁的核心是资源获取顺序和锁范围,不只是隔离级别。
如何排查
最近一次 InnoDB 死锁可以从下面的命令查看:
SHOW ENGINE INNODB STATUS;
当前 InnoDB 数据锁和等待关系可以从 Performance Schema 或 sys Schema 查看:
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM sys.innodb_lock_waits;
排查时记录:
- 两个事务各自执行的完整 SQL 和参数;
- 已持有和正在等待的索引锁;
- 事务开始时间与最后一条语句;
- 执行计划和扫描行数;
- 是否总以相同顺序访问记录;
- 是否存在批量操作、外部调用或人为停顿。
锁等待超时和死锁不同:死锁形成环,通常能被立即检测;锁等待可能只是单向等待,直到持锁事务提交或达到超时。
常用并发写模式
条件更新
不要先查询剩余额度,再在应用中计算后更新。可以让数据库原子判断:
UPDATE resource_quota
SET remaining = remaining - ?,
version = version + 1
WHERE id = ?
AND remaining >= ?;
检查受影响行数:1 表示成功,0 表示条件不满足或记录不存在。它通常比“查询 + 锁定 + 更新”更短,也更不容易发生丢失更新。
乐观锁
UPDATE task_record
SET status = ?,
version = version + 1,
updated_at = ?
WHERE id = ?
AND version = ?;
乐观锁适合冲突较少、失败可以重新读取和重试的场景。热点记录冲突很高时,乐观锁会造成大量失败和重试,可能不如串行队列或重新拆分数据模型。
悲观锁与状态机
需要读取当前状态后执行复杂校验时,可以使用 SELECT ... FOR UPDATE,但应:
- 用主键或完整唯一键精确定位;
- 加锁后再次校验状态;
- 在同一个短事务内完成更新;
- 远程副作用放到提交之后;
- 用唯一键或状态流转规则防止重复执行。
幂等插入
请求 ID、事件 ID 或业务唯一键应落到唯一索引。冲突时应区分:
- 同一个请求的安全重放:返回原结果;
- 不同请求误用了同一业务键:拒绝并告警;
- 历史失败是否允许恢复:由状态机决定。
不要无条件使用 INSERT IGNORE 吞掉所有错误,它还可能忽略本应暴露的数据问题。ON DUPLICATE KEY UPDATE 也要确认不会误触发其他唯一键。
连接池
HikariCP 等连接池解决的是复用连接和应用侧排队,不是数据库扩容。
关键参数
maximumPoolSize:单实例最大连接数;connectionTimeout:获取连接最长等待时间;idleTimeout:空闲连接回收时间;maxLifetime:连接最大生命周期;keepaliveTime:必要时保持连接活性;validationTimeout:验证连接的时间上限。
不要配置无限连接等待。下游已经拥塞时,让请求无限堆积只会耗尽 Web 线程并扩大故障。
maxLifetime 应短于数据库、代理或网络设备回收连接的时间,并加入适当抖动,避免所有实例同时重建连接。
连接池大小
估算时至少考虑:
总数据库连接 = 实例数 × 每实例连接池上限 + 管理与后台连接
这个总数必须小于数据库可承受的有效并发,而不只是小于 max_connections。大量正在等待锁或争用 CPU 的连接不会提高吞吐。
监控连接池的活跃数、空闲数、等待线程、获取耗时、超时和连接使用时长。连接泄漏通常表现为活跃连接持续不归还,而不是突然出现一个明显异常。
连接归还池之前,事务状态、隔离级别、只读标志和 Session 变量必须被正确恢复。不要假设每次拿到的都是一条“全新连接”。
主从复制与读写分离
读写分离提高读取能力,也引入复制延迟。下面这些查询通常需要读主库或使用会话级读写一致性策略:
- 写入后的立即确认;
- 幂等判断;
- 状态机下一步决策;
- 安全与权限变更后的校验;
- 依赖最新余额、库存或配额的操作。
历史列表、报表和允许短暂陈旧的页面更适合读从库。
不要把从库“查不到”立即解释成数据不存在,它也可能还没复制过来。重试从库不一定有用,因为每次都可能落到同样落后的副本。
复杂报表放到从库也不是没有成本。大查询会占用 CPU、I/O 和 Buffer Pool,导致复制线程追赶变慢。在线只读副本和分析副本最好按负载隔离。
故障切换后要验证事务是否真正提交、客户端是否刷新拓扑、旧主是否发生脑裂,以及只读实例是否错误承接写流量。
DDL 与 Schema 迁移
Online DDL 仍然需要敬畏
MySQL 8 的 DDL 算法主要包括:
INSTANT:主要修改元数据,通常最快;INPLACE:尽量避免完整拷贝,但可能重建数据;COPY:创建新表并复制,代价通常最大。
不同版本、字段类型、索引和表结构支持程度不同。关键变更可以显式指定期望算法,让不满足条件时直接失败,而不是悄悄退化为更重操作:
ALTER TABLE task_record
ADD COLUMN priority TINYINT UNSIGNED NOT NULL DEFAULT 0,
ALGORITHM = INSTANT;
创建普通二级索引通常可以使用 In-place 并允许并发 DML,但仍需依据当前版本与表定义验证:
ALTER TABLE task_record
ADD INDEX idx_tenant_priority (tenant_id, priority),
ALGORITHM = INPLACE,
LOCK = NONE;
“允许并发写”不代表没有风险。排序建索引期间会读取大量数据、写临时排序文件、产生脏页并触发刷盘和 Checkpoint;并发 DML 还会写入 Online DDL 临时日志,并照常生成 Redo。整个过程可能增加响应延迟、磁盘使用和复制延迟;开始与结束阶段还要取得 Metadata Lock。
Expand and Contract
兼容迁移的一般流程:
- Expand:先添加新字段、表或索引,旧版本仍可运行;
- 兼容写入:新代码双写或写入兼容格式;
- 回填:按主键小批量推进,可暂停、可恢复;
- 切换读取:用开关灰度切到新结构;
- 停止旧写:确认没有旧实例和旧消费者;
- Contract:最后删除旧字段、索引和兼容代码。
数据库回滚往往比代码回滚困难,因此 Schema 默认应该向前、向后都兼容一段时间。
大表变更检查
- 表与索引大小;
- 峰值写入量和 DDL 预计时长;
- 临时空间与磁盘余量;
- 长事务和 Metadata Lock;
- Redo、Undo 和复制延迟;
- 是否重建聚簇索引;
- 失败后的清理与重试;
- 监控、停止条件和回滚路径;
- 备份是否真实可恢复。
列在表里的物理顺序通常没有业务价值,不要为了“看起来整齐”触发更重的 DDL。一次合并多个 ALTER 可以减少重复扫描,也可能让原本轻量的操作被迫采用更重算法,需要按实际版本验证,不能机械套规则。
极大表可能需要 gh-ost、pt-online-schema-change 或数据库平台提供的在线变更能力,但要先理解它们对触发器、外键、Binlog、复制拓扑和故障切换的要求。
大表、分区与分库分表
单表多少行需要拆
没有统一答案。判断指标应该包括:
- 核心查询的 P95/P99 延迟和扫描量;
- 索引是否还能装下热点工作集;
- 写入与页分裂压力;
- DDL、备份和恢复窗口;
- 历史数据是否能够归档;
- 是否存在明确的数据生命周期;
- 单机容量与未来增长速度。
几亿行表的主键点查仍可能很快,一张几百万行但缺少正确索引的表也可能很慢。先优化访问路径、归档冷数据和拆分大字段,再考虑物理分片。
分区表
分区适合按时间管理生命周期、快速删除旧分区,以及查询能稳定携带分区键的场景。它不是自动性能优化:
- 没有分区裁剪时仍会访问多个分区;
- 每个分区仍需要正确索引;
- 唯一键通常必须包含分区列;
- 分区过多会增加元数据和优化成本;
- 只按主键扫描但不带分区条件,可能无法裁剪历史分区。
先通过执行计划确认 partitions 和实际扫描范围,再判断分区是否带来收益。
分库分表
分库分表是容量与吞吐手段,不是普通 SQL 优化工具。启用前先回答:
- 分片键是否出现在绝大多数核心查询中;
- 热点用户或租户会不会让某个分片过热;
- 跨分片排序、分页、聚合和 JOIN 如何处理;
- 全局唯一 ID 如何生成;
- 扩容重分片如何双写、校验和切换;
- 后台任务与数据修复如何精确路由;
- 事务和唯一约束的边界变成了什么。
无法携带分片键的查询会扇出到所有节点。随着分片数量增长,它会从一次查询变成一次小型分布式任务。
Java、MyBatis 与数据库边界
分层
建议保持:
Controller → Service → Repository → Mapper → MySQL
Service 表达业务和事务语义,Repository 统一数据访问,Mapper 负责 SQL 映射。Service 直接散落 Mapper 调用,后续很难统一读写路由、批量访问、缓存失效和审计。
参数化查询
MyBatis 的值参数使用 #{},由 PreparedStatement 绑定。${} 是文本替换,不能接收原始用户输入。
表名、列名和排序方向不能通过 ? 占位符绑定。确实需要动态标识符时,在 Java 代码中把用户选项映射到固定白名单,再传给 SQL。不要把“只允许字母”当成足够的授权与语义校验。
ORM 不会自动优化 SQL
MyBatis-Plus 的 Wrapper 可以减少 CRUD 样板代码,但同样可能产生:
- 没有索引的过滤;
- 无界列表;
- N+1;
- 过宽的
SELECT; - 条件顺序看似漂亮、访问路径却很差的 SQL。
简单操作优先使用框架已有方法;复杂 JOIN、批处理和需要 Hint 的查询显式写 SQL,并说明索引假设。
数据库测试
自定义 SQL、事务、锁、JSON、排序规则和分片逻辑应使用真实 MySQL 版本做集成测试。H2 适合快速测试简单 CRUD,但它的方言、锁和执行计划不能证明 MySQL 行为。
Testcontainers 可以为测试启动一次性 MySQL。测试要自己准备数据,覆盖:
- 空数据与边界值;
- 重复键和并发写;
- 事务回滚;
- 时间与小数精度;
- 分页稳定性;
- 实际查询计划依赖的索引;
- Schema 迁移从旧版本升级。
监控与容量
数据库指标
至少监控:
- QPS、提交、回滚与错误率;
- 查询 P50、P95、P99;
- 活跃连接、运行线程和连接拒绝;
- Buffer Pool 命中、脏页与刷新压力;
- Redo 生成与 Checkpoint 压力;
- 行锁等待时间、锁等待数量和死锁;
- Undo History List 与长事务;
- 磁盘 IOPS、吞吐、延迟和剩余空间;
- 主从复制延迟与复制错误;
- 临时表、排序和全表扫描趋势。
单个指标很少能说明问题。例如 Buffer Pool 命中率很高,仍可能有一条热点 SQL 扫描大量缓存页,把 CPU 打满。
应用指标
- 连接池获取等待和超时;
- 按 SQL Digest 聚合的调用量、错误和耗时;
- 每次请求的数据库调用次数;
- 批处理扫描、更新、跳过和失败数量;
- 死锁重试与最终失败;
- 读主库、读从库的比例;
- 关键查询返回的最老数据年龄。
指标标签不要放用户 ID、请求 ID 或完整 SQL。使用有限的查询名、结果和错误类别,详细参数留在经过脱敏的日志或 Trace 中。
慢查询治理
慢日志和 Performance Schema 应按 SQL Digest 聚合,先处理“总耗时最大”的查询,不要只盯单次最慢:
总数据库成本 ≈ 单次耗时 × 调用次数
一条 20 毫秒、每秒执行数千次的 SQL,通常比一天一次的 10 秒报表更值得优先优化。
线上排障流程
1. 先确认现象
- 是单条 SQL 变慢,还是所有数据库请求都变慢;
- 是读取、写入、建连还是提交慢;
- 是平均延迟上升,还是 P99 尾延迟;
- 是否与发布、DDL、流量、备份或故障切换同时发生;
- 主库、从库和某个分片是否表现一致。
2. 找到真实 SQL 与参数
同一个 SQL 模板在不同参数下可能有完全不同的选择性。排障需要 SQL Digest,也需要经过脱敏的代表性参数、返回行数和调用频率。
3. 查看执行计划
比较估算与实际:
- 是否换了索引;
- 是否统计信息失真;
- 数据分布是否倾斜;
- 是否扫描、回表或排序过多;
- 是否因为返回列变多失去覆盖索引;
- 是否因为参数类型变化发生隐式转换。
4. 检查等待
查询“正在做什么”还不够,还要看“正在等什么”:锁、Metadata Lock、连接、磁盘、Redo、CPU、网络还是复制。
5. 选择最小风险处置
- 先限流或关闭非关键入口;
- 暂停批处理、报表、回填或 DDL;
- 必要时终止明确的阻塞会话,但先确认事务影响;
- 回滚有问题的 SQL 或路由;
- 再考虑加索引、改 Schema 或扩容。
不要在故障中直接执行未经验证的大表索引创建。一个为了救火启动的 DDL,可能成为新的故障源。
6. 修复后防复发
- 添加查询、锁等待和容量监控;
- 保存故障时的执行计划与数据分布;
- 为关键 SQL 增加集成或性能回归测试;
- 给 DDL、回填和故障处置补充 Runbook;
- 记录触发条件、停止条件和恢复步骤。
安全与数据保护
- 数据库账号采用最小权限,应用账号不应拥有任意 DDL 或管理权限;
- 读写账号分离,报表和在线流量按资源隔离;
- JDBC 使用 TLS 并校验证书;
- 凭据来自 Secret Manager、KMS 或 Vault,不写进代码和镜像;
- 值参数全部使用预编译绑定;
- 动态表名、列名和排序方向采用服务端白名单;
- 密码使用 Argon2 或 BCrypt 哈希,不能可逆加密;
- 必须检索的敏感字段设计 Tokenization、Hash 辅助列或受控索引;
- 备份、快照和 Binlog 同样属于敏感数据,需要加密、访问控制和保留策略;
- 日志和慢查询样本必须脱敏,不记录 Token、密钥和完整个人数据;
- 生产数据不能随意复制到本地或共享测试环境;
- 定期验证备份恢复,而不是只检查备份任务显示成功。
字段加密会影响等值查询、排序、范围查询和唯一约束,应在建模阶段设计,不能上线前简单“给字段套一层加密”。
常见误区
- “
NULL不进入索引”——错误,IS NULL可以使用 B-Tree 索引。 - “
BETWEEN一定使索引失效”——错误,它通常可以形成范围扫描。 - “看到
key不为空就说明 SQL 很快”——错误,还要看实际扫描、回表、排序和循环次数。 - “联合索引永远按选择性从高到低排列”——不完整,还要考虑查询前缀、排序和分页。
- “普通 SELECT 会加 Next-Key Lock”——在常见隔离级别下,普通一致性读通常不加记录锁。
- “行锁只锁最终返回的行”——错误,锁范围取决于访问路径中扫描到的索引记录。
- “改成 READ COMMITTED 就没有死锁”——错误,写操作和锁顺序仍会产生死锁。
- “连接池调大可以提高吞吐”——只有数据库还有余量时才可能成立。
- “Online DDL 对线上没有影响”——它仍可能消耗大量资源并等待 Metadata Lock。
- “表超过两千万行必须分表”——没有统一阈值,应由容量和访问模式决定。
- “分区表自动让查询变快”——只有发生有效裁剪并有正确索引时才可能受益。
- “加缓存可以解决所有慢 SQL”——缓存会引入一致性和失效问题,根因仍可能存在。
上线前检查清单
表结构
- 有短、稳定、显式的主键
- 字段类型、Unsigned、字符集和排序规则语义一致
- 金额和时间的精度、时区、舍入规则明确
- 热行中没有不必要的大字段
- 唯一性由数据库唯一约束兜底
查询与索引
- 索引围绕完整查询设计,不是给每列分别加索引
- 核心 SQL 使用代表性数据执行过
EXPLAIN ANALYZE - 估算行数与实际行数没有严重偏差
- 没有 N+1、无界列表、深 OFFSET 和不必要的
SELECT * - 动态排序字段经过白名单映射
- 冗余索引和写放大已经评估
事务与锁
- 事务范围短,不包含远程调用和长计算
- 并发写使用条件更新、版本号、唯一键或精确锁定
- 多表、多行更新顺序一致
- 死锁能够重试整个事务且有次数上限
- 锁定查询使用正确索引并验证过锁范围
- 应用不存在长期空闲未提交事务
连接与复制
- 连接获取、查询和事务都有有限超时
- 全集群连接总量不超过数据库有效承载能力
- 连接池等待、泄漏和超时有监控
- 写后读、幂等和状态决策明确走主库
- 复制延迟、错误和故障切换经过演练
DDL 与恢复
- 明确 DDL 算法、锁级别、耗时和磁盘需求
- 检查长事务与 Metadata Lock
- 大表回填可限速、暂停、恢复和重复执行
- Schema 变更遵循 Expand and Contract
- 发布、验证、停止和回滚步骤写入 Runbook
- 备份完成过真实恢复验证
参考资料
- MySQL 8.4:聚簇索引与二级索引
- MySQL 8.4:联合索引
- MySQL 8.4:索引优化
- MySQL 8.4:EXPLAIN 与 EXPLAIN ANALYZE
- MySQL 8.4:IS NULL 优化
- MySQL 8.4:事务隔离级别
- MySQL 8.4:InnoDB 各类语句的加锁行为
- MySQL 8.4:Metadata Lock
- MySQL 8.4:减少和处理死锁
- MySQL 8.4:Online DDL
- MySQL 8.0:Sorted Index Build
最后
MySQL 使用经验最终可以浓缩成四句话:
- 用索引减少需要扫描的数据;
- 用短事务减少持锁时间;
- 用边界和监控控制资源;
- 用可恢复的迁移与任务应对变化。
不要迷信某个固定行数、某条索引口诀或某个神奇参数。把 SQL、数据分布、执行计划、锁和资源等待放在一起看,数据库问题通常会从“玄学”变成可以验证的工程问题。