索引与事务排障
数据库问题排查里,最常见也最容易互相影响的两类问题就是索引问题和事务问题。查询慢不一定只是 SQL 写得差,也可能是索引设计不合理;锁等待不一定只是数据库性能差,也可能是事务边界设计有问题。
索引设计基础
索引解决什么问题
索引的核心价值是减少扫描范围,让数据库能更快定位目标数据。可以把它理解成书的目录:
- 没有目录时:需要一页一页翻(全表扫描)
- 有目录时:可以更快找到目标位置(索引定位)
在数据库里,索引最主要影响的是:
| 影响维度 | 无索引情况 | 有索引情况 | 性能差距 |
|---|---|---|---|
| 查询速度 | 全表扫描,逐行比较 | 通过索引快速定位 | 可能差 100-1000 倍 |
| 排序效率 | 需要文件排序(filesort) | 利用索引有序性直接返回 | 避免临时表和额外排序 |
| 范围检索 | 全表扫描后过滤 | 索引范围扫描 | 扫描行数大幅减少 |
| 回表成本 | 无回表(直接全表扫) | 需要回表查询完整数据 | 取决于覆盖索引 |
索引的数据结构
MySQL InnoDB 使用 B+ 树作为索引结构:
特点:
1. 非叶子节点只存储键值和指针,不存储数据
2. 叶子节点存储所有数据,并形成有序链表
3. 叶子节点之间通过双向链表连接,便于范围查询
4. 树高度通常 2-4 层,即可支持千万级数据
优势:
- 单次磁盘 I/O 可读取一个页(16KB),减少 I/O 次数
- 范围查询效率高(叶子节点有序链表)
- 排序性能好(索引本身有序)聚簇索引 vs 二级索引:
| 对比项 | 聚簇索引 | 二级索引 |
|---|---|---|
| 叶子节点 | 存储完整行数据 | 存储索引列 + 主键值 |
| 数量 | 每张表仅一个 | 可有多个 |
| 查询方式 | 直接返回数据 | 需要回表查询(通过主键) |
| 适用场景 | 主键查询 | 非主键列查询 |
常见索引设计原则
1. 主键、唯一索引、普通索引的选择
-- 主键索引: 强制唯一,聚簇索引
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(100) NOT NULL,
username VARCHAR(50) NOT NULL,
UNIQUE KEY uk_email (email), -- 唯一索引: 业务唯一约束
KEY idx_username (username) -- 普通索引: 加速查询
);
-- 选择建议:
-- 1. 主键: 自增整数(避免页分裂) 或 业务唯一键(如用户ID)
-- 2. 唯一索引: 业务唯一约束(email、手机号、订单号)
-- 3. 普通索引: 频繁查询条件(username、状态、创建时间)反例 - 错误的索引设计:
-- × 错误: 所有字段都加索引
CREATE TABLE bad_example (
id BIGINT PRIMARY KEY,
col1 VARCHAR(50),
col2 VARCHAR(50),
col3 VARCHAR(50),
col4 VARCHAR(50),
KEY idx_col1 (col1), -- 写入性能下降,维护成本高
KEY idx_col2 (col2),
KEY idx_col3 (col3),
KEY idx_col4 (col4)
);
-- √ 正确: 只为查询条件加索引
CREATE TABLE good_example (
id BIGINT PRIMARY KEY,
col1 VARCHAR(50),
col2 VARCHAR(50),
col3 VARCHAR(50),
col4 VARCHAR(50),
KEY idx_query_conditions (col1, col2) -- 复合查询条件
);2. 联合索引的最左前缀原则
联合索引按照定义顺序构建,查询时必须从最左列开始匹配:
-- 创建联合索引
CREATE INDEX idx_status_created_at ON order_info (status, created_at);
-- √ 能用到索引的查询
SELECT * FROM order_info WHERE status = 1; -- 最左列
SELECT * FROM order_info WHERE status = 1 AND created_at > '2025-01-01'; -- 全部列
SELECT * FROM order_info WHERE status = 1 ORDER BY created_at; -- 排序也能用到
-- × 用不到索引的查询
SELECT * FROM order_info WHERE created_at > '2025-01-01'; -- 跳过最左列
SELECT * FROM order_info WHERE status + 1 = 1; -- 对索引列做运算最左前缀原理图解:
索引结构: (status, created_at)
索引键值:
(1, '2025-01-01')
(1, '2025-01-02')
(1, '2025-01-03')
(2, '2025-01-01')
(2, '2025-01-02')
查询 WHERE status = 1:
→ 快速定位到第一个 status=1 的节点
→ 顺序扫描后续 status=1 的节点
查询 WHERE created_at > '2025-01-01':
→ 无法快速定位(索引先按 status 排序)
→ 只能全索引扫描3. 高基数 vs 低基数列的选择
-- × 错误: 低基数字段做前导列
CREATE INDEX idx_gender ON users (gender); -- gender 只有 M/F/NULL 三种值
-- 查询: SELECT * FROM users WHERE gender = 'M';
-- 问题: 即使命中索引,也需要扫描约 50% 的行,不如全表扫描
-- √ 正确: 高基数字段做前导列
CREATE INDEX idx_gender_user_id ON users (gender, user_id);
-- 或者直接用高基数字段
CREATE INDEX idx_email ON users (email); -- email 基数接近行数
-- 判断标准:
-- 高基数: DISTINCT 值数量 > 行数 * 10% → 适合做索引
-- 低基数: DISTINCT 值数量 < 行数 * 10% → 不适合单独做索引示例 - 查看列的基数:
-- 查看表的基数统计
SELECT
TABLE_NAME,
COLUMN_NAME,
CARDINALITY,
TABLE_ROWS,
ROUND(CARDINALITY / TABLE_ROWS * 100, 2) AS selectivity_percent
FROM information_schema.STATISTICS s
JOIN information_schema.TABLES t ON s.TABLE_NAME = t.TABLE_NAME
WHERE s.TABLE_SCHEMA = 'your_database'
AND s.TABLE_NAME = 'users'
AND s.INDEX_NAME = 'PRIMARY'
ORDER BY selectivity_percent DESC;
-- 基数越高,选择性越好,索引效果越好4. 覆盖索引减少回表
-- 表结构
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
status TINYINT,
amount DECIMAL(10,2),
created_at DATETIME,
KEY idx_user_status (user_id, status)
);
-- × 需要回表
SELECT * FROM orders WHERE user_id = 1001 AND status = 1;
-- 执行流程:
-- 1. 通过 idx_user_status 定位到主键 id
-- 2. 回表查询完整行数据(额外 I/O)
-- √ 覆盖索引(无需回表)
SELECT id, user_id, status FROM orders
WHERE user_id = 1001 AND status = 1;
-- 执行流程:
-- 1. 通过 idx_user_status 直接返回数据
-- 2. 无需回表,性能更好
-- √ 创建覆盖索引
CREATE INDEX idx_user_status_amount ON orders (user_id, status, amount);
SELECT user_id, status, amount FROM orders
WHERE user_id = 1001 AND status = 1; -- 无需回表5. 索引不是越多越好
-- 索引的代价:
-- 1. 写入成本: INSERT/UPDATE/DELETE 需要维护索引
-- 2. 存储成本: 索引占用磁盘空间
-- 3. 优化器成本: 选择索引时需要评估更多选项
-- × 过度索引
CREATE TABLE over_indexed (
id BIGINT PRIMARY KEY,
a VARCHAR(50),
b VARCHAR(50),
c VARCHAR(50),
d VARCHAR(50),
KEY idx_a (a),
KEY idx_b (b),
KEY idx_c (c),
KEY idx_d (d),
KEY idx_ab (a, b),
KEY idx_bc (b, c)
-- 写入性能严重下降
);
-- √ 合理索引
CREATE TABLE well_indexed (
id BIGINT PRIMARY KEY,
a VARCHAR(50),
b VARCHAR(50),
c VARCHAR(50),
d VARCHAR(50),
KEY idx_query_pattern (a, b) -- 根据查询模式设计
);
-- 建议: 单表索引数量控制在 5 个以内联合索引为什么要关注列顺序
联合索引的列顺序直接影响索引的利用率。考虑以下示例:
-- 场景 1: 按状态查询 + 按创建时间排序
CREATE INDEX idx_status_created_at ON order_info (status, created_at);
-- 查询
SELECT id, status, created_at
FROM order_info
WHERE status = 1
ORDER BY created_at DESC
LIMIT 10;
-- 执行流程:
-- 1. 通过索引定位 status=1 的第一条记录
-- 2. 按索引顺序(已按 created_at 排序)直接返回前 10 条
-- 3. 无需 filesort,性能极佳
-- 场景 2: 如果查询模式不匹配
-- 查询
SELECT * FROM order_info WHERE created_at > '2025-01-01';
-- 问题: 跳过 status 列,索引失效,全表扫描
-- 解决: 创建另一个索引
CREATE INDEX idx_created_at ON order_info (created_at);索引列顺序设计方法:
-- 步骤 1: 分析查询模式
-- 慢查询日志中提取 WHERE 条件
-- 常见查询: WHERE user_id = ? AND status = ? ORDER BY created_at
-- 步骤 2: 确定列顺序
-- 等值条件列 → 范围条件列 → 排序列
-- user_id(等值) → status(等值) → created_at(排序/范围)
-- 步骤 3: 创建索引
CREATE INDEX idx_user_status_created ON orders (user_id, status, created_at);
-- 验证索引效果
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND status = 1
ORDER BY created_at DESC
LIMIT 10;
-- 关注:
-- type: ref(使用索引查找)
-- key: idx_user_status_created(使用的索引)
-- Extra: Using index(覆盖索引,无 filesort)常见索引失效场景
查询写法导致失效
1. 对索引列做函数运算
-- 表结构
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
created_at DATETIME,
KEY idx_created_at (created_at)
);
-- × 索引失效: 对列使用函数
SELECT * FROM users WHERE DATE(created_at) = '2025-01-01';
-- 原因: 索引存储的是原始值,函数运算后无法使用索引
-- √ 索引生效: 等价改写
SELECT * FROM users
WHERE created_at >= '2025-01-01 00:00:00'
AND created_at < '2025-01-02 00:00:00';
-- × 索引失效: 隐式类型转换
SELECT * FROM users WHERE name = 123; -- name 是 VARCHAR,传入数字
-- MySQL 会转换为: WHERE CAST(name AS SIGNED) = 123
-- 索引失效
-- √ 索引生效: 类型匹配
SELECT * FROM users WHERE name = '123';
-- × 索引失效: 计算运算
SELECT * FROM users WHERE id + 1 = 1001;
-- √ 索引生效: 等价改写
SELECT * FROM users WHERE id = 1000;2. 前导模糊匹配
-- 表结构
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
KEY idx_name (name)
);
-- × 索引失效: 前导模糊
SELECT * FROM products WHERE name LIKE '%手机%';
SELECT * FROM products WHERE name LIKE '%手机';
-- 原因: 前导通配符导致无法利用索引有序性
-- √ 索引生效: 后导模糊
SELECT * FROM products WHERE name LIKE '手机%';
-- 原因: 可以定位到"手"开头的位置,顺序扫描
-- 如果必须前导模糊,考虑:
-- 1. 全文索引(FULLTEXT)
CREATE FULLTEXT INDEX idx_name_fulltext ON products(name);
SELECT * FROM products WHERE MATCH(name) AGAINST('手机' IN BOOLEAN MODE);
-- 2. Elasticsearch 等搜索引擎
-- 3. 前缀索引(仅限特定场景)
CREATE INDEX idx_name_prefix ON products(name(20));3. OR 条件导致索引失效
-- 表结构
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
status TINYINT,
KEY idx_user_id (user_id),
KEY idx_status (status)
);
-- × 索引失效: OR 连接不同索引列
SELECT * FROM orders WHERE user_id = 1001 OR status = 1;
-- MySQL 5.6 及之前: 无法同时使用两个索引
-- MySQL 5.7+: 可能使用 index merge,但性能不稳定
-- √ 优化方案 1: UNION ALL
SELECT * FROM orders WHERE user_id = 1001
UNION ALL
SELECT * FROM orders WHERE status = 1 AND user_id != 1001;
-- √ 优化方案 2: 创建联合索引(如果查询频繁)
CREATE INDEX idx_user_status ON orders (user_id, status);
SELECT * FROM orders WHERE user_id = 1001 OR status = 1;
-- 注意: 仍然可能不使用联合索引,需要测试
-- √ OR 同一索引列(索引生效)
SELECT * FROM orders WHERE user_id = 1001 OR user_id = 1002;
-- 可使用 idx_user_id4. 不匹配联合索引顺序
-- 表结构
CREATE INDEX idx_abc ON t (a, b, c);
-- √ 索引生效
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
WHERE a = 1 AND c = 3 -- 部分生效(a 列)
-- × 索引失效
WHERE b = 2 -- 跳过最左列
WHERE c = 3 -- 跳过最左列
WHERE b = 2 AND c = 3 -- 跳过最左列
-- 范围查询后的列失效
WHERE a = 1 AND b > 2 AND c = 3 -- a,b 生效,c 失效
-- 原因: b 是范围查询,后续列 c 无法利用索引有序性不是所有"命中了索引"都代表查询就快
即使走了索引,也仍然可能慢,常见原因包括:
-- 场景 1: 扫描行数仍然很大
-- 表: 100 万行数据,gender 索引(低基数)
CREATE INDEX idx_gender ON users (gender); -- M/F/NULL
SELECT * FROM users WHERE gender = 'M';
-- 执行计划: type=ref, key=idx_gender, rows=500000
-- 问题: 即使使用索引,仍需扫描 50 万行
-- 优化: 删除低基数索引,使用全表扫描反而更快
-- 场景 2: 回表次数太多
-- 表: 100 万行数据
CREATE INDEX idx_status ON orders (status);
SELECT * FROM orders WHERE status = 1; -- 假设 status=1 有 10 万行
-- 执行流程:
-- 1. 通过索引定位到 10 万个主键
-- 2. 回表查询 10 万次(随机 I/O)
-- 3. 性能比全表扫描还差
-- 优化: 使用覆盖索引
CREATE INDEX idx_status_user_amount ON orders (status, user_id, amount);
SELECT status, user_id, amount FROM orders WHERE status = 1; -- 无需回表
-- 场景 3: 排序没有借助索引
CREATE INDEX idx_user_id ON orders (user_id);
SELECT * FROM orders WHERE user_id = 1001 ORDER BY created_at DESC LIMIT 10;
-- 执行计划 Extra: Using filesort
-- 问题: 索引中无 created_at,需要额外排序
-- 优化: 创建联合索引
CREATE INDEX idx_user_created ON orders (user_id, created_at);
-- 执行计划 Extra: 无 filesort
-- 场景 4: 需要临时表
SELECT status, COUNT(*) FROM orders GROUP BY status;
-- 执行计划 Extra: Using temporary
-- 优化: 创建索引
CREATE INDEX idx_status ON orders (status);
-- 执行计划 Extra: Using index(覆盖索引,无需临时表)排查方法 - 使用 EXPLAIN ANALYZE:
-- MySQL 8.0+ 支持 EXPLAIN ANALYZE,显示实际执行时间
EXPLAIN ANALYZE
SELECT * FROM orders WHERE status = 1;
-- 输出示例:
-> Filter: (orders.status = 1) (cost=10000 rows=100000) (actual time=0.1..100 rows=100000 loops=1)
-> Table scan on orders (cost=10000 rows=1000000) (actual time=0.1..50 rows=1000000 loops=1)
-- 关注:
-- 1. estimated rows vs actual rows: 估算是否准确
-- 2. actual time: 实际执行时间
-- 3. 是否全表扫描(应该用索引的地方)事务与锁等待
事务隔离级别
隔离级别详解
常见事务隔离级别从低到高包括:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 锁范围 | 并发性能 |
|---|---|---|---|---|---|
| 读未提交(READ UNCOMMITTED) | √ 可能 | √ 可能 | √ 可能 | 最小 | 最高 |
| 读已提交(READ COMMITTED) | × 不可能 | √ 可能 | √ 可能 | 行锁 | 高 |
| 可重复读(REPEATABLE READ) | × 不可能 | × 不可能 | √ 可能* | 行锁+间隙锁 | 中 |
| 串行化(SERIALIZABLE) | × 不可能 | × 不可能 | × 不可能 | 表锁 | 最低 |
注:MySQL InnoDB 在 RR 级别通过 MVCC + 间隙锁解决了大部分幻读问题。
隔离级别对并发的影响:
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- MySQL 5.7: SELECT @@tx_isolation;
-- 设置隔离级别(会话级别)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置隔离级别(全局级别)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 示例: 读已提交 vs 可重复读
-- 时间线对比:
-- 会话 A
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 返回 1000
-- 会话 B
START TRANSACTION;
UPDATE accounts SET balance = 800 WHERE id = 1;
COMMIT;
-- 会话 A(读已提交)
SELECT balance FROM accounts WHERE id = 1; -- 返回 800(读到已提交的数据)
-- 会话 A(可重复读)
SELECT balance FROM accounts WHERE id = 1; -- 返回 1000(读到事务开始时的快照)各隔离级别的实现原理
-- 1. 读未提交(RU)
-- 实现: 不加锁,直接读取最新数据(包括未提交的)
-- 问题: 脏读,几乎不使用
-- 2. 读已提交(RC)
-- 实现: MVCC,每次查询生成新的 Read View
-- Read View: 记录当前活跃事务 ID,判断数据可见性
-- 优点: 避免脏读
-- 问题: 不可重复读(两次查询结果不同)
-- 3. 可重复读(RR) - MySQL 默认
-- 实现: MVCC,事务开始时生成 Read View,整个事务期间复用
-- 优点: 避免脏读、不可重复读
-- 额外机制: 间隙锁(Gap Lock)防止幻读
-- 4. 串行化(Serializable)
-- 实现: 所有读操作加共享锁,写操作加排他锁
-- 优点: 完全避免并发问题
-- 缺点: 并发性能极差,很少使用为什么长事务危险
长事务的问题不只是"执行时间长",更重要的是它会:
1. 长时间占用锁
-- 场景: 长事务持有锁,阻塞其他事务
-- 事务 A(长事务)
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 执行复杂业务逻辑,耗时 30 秒...
-- 事务未提交,持续持有 id=1 的行锁
-- 事务 B(被阻塞)
START TRANSACTION;
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- 等待锁,超时失败
-- ERROR 1205 (HY000): Lock wait timeout exceeded
-- 排查: 查看锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
SELECT * FROM information_schema.INNODB_TRX WHERE trx_id = '阻塞事务ID';
-- 排查: 查看长事务
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC;2. 增大回滚成本
-- 长事务产生大量 undo log
-- 事务 A
START TRANSACTION;
INSERT INTO large_table SELECT * FROM source_table; -- 插入 100 万行
-- 执行复杂逻辑...
ROLLBACK; -- 需要回滚 100 万行,可能耗时数分钟
-- undo log 空间不足
-- ERROR 1114 (HY000): The table 'large_table' is full
-- (实际是 undo log 空间不足)
-- 监控 undo log 使用情况
SHOW ENGINE INNODB STATUS\G
-- 关注:
-- History list length: 清理线程待处理的 undo log 数量
-- 如果持续增长,说明有长事务未提交3. 拖慢其他事务
-- 长事务导致 MVCC 版本链过长
-- 事务 A(长事务)
START TRANSACTION; -- trx_id = 100
-- 事务 B(更新同一行)
UPDATE users SET name = 'B' WHERE id = 1; -- 产生新版本,记录 trx_id = 101
COMMIT;
-- 事务 C(更新同一行)
UPDATE users SET name = 'C' WHERE id = 1; -- 产生新版本,记录 trx_id = 102
COMMIT;
-- 事务 D(更新同一行)
UPDATE users SET name = 'D' WHERE id = 1; -- 产生新版本,记录 trx_id = 103
COMMIT;
-- 事务 A(查询)
SELECT * FROM users WHERE id = 1;
-- 需要遍历版本链: D → C → B → A(原始版本)
-- 性能下降,版本链越长越慢
-- 监控:
-- History list length 越长,查询性能越差4. 放大死锁概率
-- 场景: 长事务增大死锁概率
-- 事务 A(长事务)
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 持有 id=1 的锁
-- 执行复杂逻辑,耗时 10 秒...
-- 事务 B(另一个事务)
START TRANSACTION;
UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- 持有 id=2 的锁
UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- 等待 id=1 的锁
-- 事务 A(继续执行)
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 等待 id=2 的锁
-- 死锁! MySQL 检测到死锁,回滚其中一个事务
-- ERROR 1213 (40001): Deadlock found when trying to get lock
-- 避免死锁:
-- 1. 事务尽量短小
-- 2. 按固定顺序访问资源(id 升序)
-- 3. 减少锁持有时间显式加锁要谨慎
像下面这种 SQL:
SELECT * FROM order_info WHERE id = 1001 FOR UPDATE;它会尝试对命中的记录加排他锁。适合需要强一致修改的场景,但如果使用不当会导致严重问题:
1. 查询条件不精准导致锁范围扩大
-- 表结构
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(50),
status TINYINT,
amount DECIMAL(10,2),
KEY idx_order_no (order_no)
);
-- × 危险: 无索引条件
SELECT * FROM orders WHERE status = 1 FOR UPDATE;
-- 问题: status 无索引,锁住所有行(全表扫描 + 全表锁)
-- × 危险: 索引条件但不精准
SELECT * FROM orders WHERE order_no LIKE 'ORD%' FOR UPDATE;
-- 问题: 锁住所有匹配的行,可能很多
-- √ 安全: 精准条件
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
SELECT * FROM orders WHERE order_no = 'ORD20250101001' FOR UPDATE;2. 事务持续时间过长
-- × 危险: 长事务 + FOR UPDATE
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 加锁
-- 执行远程调用(耗时 5 秒)
CALL external_service();
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- 问题: 锁持有时间 = 查询 + 远程调用 + 更新 = 5+ 秒
-- 期间其他事务全部阻塞
-- √ 优化: 缩短锁持有时间
-- 1. 先执行远程调用
CALL external_service();
-- 2. 最后再加锁和更新
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- 锁持有时间: 仅更新操作,毫秒级3. 查询未命中索引
-- × 危险: 未命中索引,锁全表
CREATE TABLE products (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
stock INT,
KEY idx_name (name)
);
-- 事务 A
START TRANSACTION;
SELECT * FROM products WHERE name = '不存在的商品' FOR UPDATE;
-- 虽然 name 有索引,但未命中,锁住所有间隙(间隙锁)
-- 事务 B
INSERT INTO products VALUES (1, '新商品', 100);
-- 被阻塞! 因为间隙锁阻止插入
-- √ 优化: 使用主键或唯一索引
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 锁范围精确,仅锁住 id=1 的行FOR UPDATE 使用建议
-- √ 推荐用法:
-- 1. 必须使用主键或唯一索引
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 2. 事务尽量短小
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT; -- 快速提交
-- 3. 考虑乐观锁替代
-- 不使用 FOR UPDATE
SELECT version, stock FROM products WHERE id = 1;
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = ?; -- 乐观锁
-- 4. 使用 NOWAIT 或 SKIP LOCKED(MySQL 8.0+)
SELECT * FROM orders WHERE id = 1001 FOR UPDATE NOWAIT;
-- 如果行被锁住,立即报错,不等待
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE SKIP LOCKED;
-- 跳过被锁住的行,只锁未被锁住的行慢 SQL 排查流程
第一步:先确认问题 SQL
1. 慢查询日志
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL long_query_time = 1; -- 超过 1 秒记录
SET GLOBAL log_queries_not_using_indexes = ON; -- 记录未使用索引的查询
-- 查看配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- 使用 mysqldumpslow 分析慢日志
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
-- -s t: 按查询时间排序
-- -t 10: 显示前 10 条2. 应用日志中的耗时 SQL
// Spring Boot 配置: 打印慢 SQL
spring:
datasource:
hikari:
leak-detection-threshold: 60000 # 连接泄漏检测
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
generate_statistics: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
org.hibernate.stat: DEBUG
// 或使用 P6Spy 监控
// pom.xml
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>
// spy.properties
driverlist=com.mysql.cj.jdbc.Driver
logMessageFormat=com.p6spy.engine.spy.appender.SingleLineFormat
appender=com.p6spy.engine.spy.appender.StdoutLogger
outagedetection=true
outagedetectioninterval=1 # 超过 1 秒打印堆栈3. APM / 监控系统
-- 使用 performance_schema(MySQL 5.7+)
-- 开启性能监控
UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME LIKE '%statement/%';
UPDATE performance_schema.setup_consumers
SET ENABLED = 'YES'
WHERE NAME LIKE '%events_statements%';
-- 查询慢 SQL
SELECT
DIGEST_TEXT AS sql_text,
COUNT_STAR AS exec_count,
AVG_TIMER_WAIT / 1000000000 AS avg_latency_ms,
MAX_TIMER_WAIT / 1000000000 AS max_latency_ms,
SUM_ROWS_EXAMINED AS total_rows_examined,
SUM_ROWS_SENT AS total_rows_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY AVG_TIMER_WAIT DESC
LIMIT 10;
-- 使用 sys schema(MySQL 5.7+)
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile LIMIT 10;
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;第二步:用 EXPLAIN 看执行计划
重点关注这些字段:
EXPLAIN SELECT * FROM orders WHERE user_id = 1001;
-- 关键字段解读:
-- 1. id: 查询标识符
-- 相同 id: 从上往下执行
-- 不同 id: 子查询,id 越大越先执行
-- 2. select_type: 查询类型
-- SIMPLE: 简单查询(无子查询、UNION)
-- PRIMARY: 最外层查询
-- SUBQUERY: 子查询
-- DERIVED: 派生表(FROM 子句中的子查询)
-- 3. type: 访问类型(性能从好到坏)
-- system: 单行系统表
-- const: 主键或唯一索引常量查询
-- eq_ref: 主键或唯一索引关联查询
-- ref: 非唯一索引等值查询
-- range: 索引范围扫描
-- index: 索引全扫描
-- ALL: 全表扫描(最差)
-- 4. possible_keys: 可能使用的索引
-- 显示查询涉及的字段上有哪些索引
-- 5. key: 实际使用的索引
-- NULL: 未使用索引
-- 6. key_len: 使用的索引长度
-- 越短越好(但也要足够)
-- 7. rows: 预估扫描行数
-- 越少越好
-- 8. Extra: 额外信息
-- Using index: 覆盖索引
-- Using where: 服务器层过滤
-- Using filesort: 文件排序(需优化)
-- Using temporary: 使用临时表(需优化)EXPLAIN 实战示例:
-- 示例 1: 全表扫描
EXPLAIN SELECT * FROM orders WHERE DATE(created_at) = '2025-01-01';
-- type: ALL
-- key: NULL
-- rows: 1000000
-- Extra: Using where
-- 问题: 对索引列做函数运算,索引失效
-- 优化:
EXPLAIN SELECT * FROM orders
WHERE created_at >= '2025-01-01 00:00:00'
AND created_at < '2025-01-02 00:00:00';
-- type: range
-- key: idx_created_at
-- rows: 1000
-- Extra: Using index condition
-- 示例 2: 索引选择错误
CREATE INDEX idx_user_id ON orders (user_id);
CREATE INDEX idx_status ON orders (status);
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 OR status = 1;
-- type: index_merge
-- key: idx_user_id, idx_status
-- rows: 500000
-- Extra: Using union(idx_user_id, idx_status); Using where
-- 问题: 扫描行数过多,性能不稳定
-- 优化:
EXPLAIN
SELECT * FROM orders WHERE user_id = 1001
UNION ALL
SELECT * FROM orders WHERE status = 1 AND user_id != 1001;
-- 两个查询都使用索引,性能更稳定
-- 示例 3: filesort
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 ORDER BY created_at DESC;
-- type: ref
-- key: idx_user_id
-- Extra: Using filesort
-- 问题: 排序未使用索引
-- 优化: 创建联合索引
CREATE INDEX idx_user_created ON orders (user_id, created_at);
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 ORDER BY created_at DESC;
-- type: ref
-- key: idx_user_created
-- Extra: 无 filesort
-- MySQL 8.0+: 使用 EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 1001;
-- 输出示例:
-> Index lookup on orders using idx_user_id (user_id=1001) (cost=10 rows=100) (actual time=0.1..1 rows=100 loops=1)
-- 显示实际执行时间、行数、循环次数第三步:确认慢的根因
常见根因通常包括:
1. 缺少合适索引
-- 慢 SQL
SELECT * FROM orders WHERE user_id = 1001 AND status = 1;
-- EXPLAIN
-- type: ALL
-- key: NULL
-- rows: 1000000
-- 解决: 创建索引
CREATE INDEX idx_user_status ON orders (user_id, status);
-- EXPLAIN
-- type: ref
-- key: idx_user_status
-- rows: 102. SQL 条件写法导致索引失效
-- 慢 SQL
SELECT * FROM orders WHERE user_id + 1 = 1001;
-- EXPLAIN
-- type: ALL
-- key: NULL
-- 解决: 改写条件
SELECT * FROM orders WHERE user_id = 1000;
-- EXPLAIN
-- type: ref
-- key: idx_user_id3. 结果集太大
-- 慢 SQL
SELECT * FROM large_table LIMIT 1000000, 10;
-- 问题: 偏移量过大,需要扫描前 1000010 行
-- 解决方案 1: 使用覆盖索引 + 延迟关联
SELECT t.* FROM large_table t
INNER JOIN (SELECT id FROM large_table LIMIT 1000000, 10) tmp
ON t.id = tmp.id;
-- 解决方案 2: 记录上次查询的最大 ID
SELECT * FROM large_table WHERE id > 1000000 LIMIT 10;4. 排序或分组代价过高
-- 慢 SQL
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id;
-- EXPLAIN
-- Extra: Using temporary; Using filesort
-- 解决: 创建索引
CREATE INDEX idx_user_id ON orders (user_id);
-- EXPLAIN
-- Extra: Using index5. 锁等待导致执行时间被拉长
-- 慢 SQL(本身不慢,但在等待锁)
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 排查:
-- 1. 查看当前锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
-- 2. 查看持锁事务
SELECT * FROM information_schema.INNODB_TRX
WHERE trx_id IN (
SELECT requesting_trx_id FROM information_schema.INNODB_LOCK_WAITS
);
-- 3. 查看持锁 SQL
SELECT * FROM performance_schema.events_statements_current
WHERE THREAD_ID IN (
SELECT THREAD_ID FROM performance_schema.threads
WHERE PROCESSLIST_ID = 持锁事务的trx_mysql_thread_id
);第四步:区分"索引问题"还是"事务问题"
有些 SQL 慢,不是它本身扫描慢,而是它在等待别的事务释放锁。这时如果只盯索引,很容易走偏。
因此排查时要同步看:
-- 1. 当前 SQL 的执行计划
EXPLAIN SELECT * FROM orders WHERE id = 1001;
-- 2. 当前会话是否在等待锁
SELECT * FROM information_schema.INNODB_LOCK_WAITS
WHERE requesting_trx_id = 当前事务ID;
-- 或使用 sys schema
SELECT * FROM sys.innodb_lock_waits;
-- 3. 其他事务是否持有锁且长时间未提交
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC;
-- 4. 查看完整锁等待链
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query,
b.trx_started AS blocking_started
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;
-- 5. 终止长事务
KILL 持锁事务的trx_mysql_thread_id;锁等待和死锁排查
常见排查入口
1. SHOW ENGINE INNODB STATUS
SHOW ENGINE INNODB STATUS\G
-- 关注 LATEST DETECTED DEADLOCK 部分:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-01-01 12:00:00 0x7f8b8c0b4700
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 10, OS thread handle 140236892501760, query id 100 localhost root updating
UPDATE accounts SET balance = balance - 100 WHERE id = 1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `test`.`accounts`
trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 11, OS thread handle 140236892501760, query id 101 localhost root updating
UPDATE accounts SET balance = balance + 50 WHERE id = 2
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 72 index PRIMARY of table `test`.`accounts`
trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
-- 分析:
-- 事务 1 等待 id=1 的锁
-- 事务 2 持有 id=1 的锁,等待 id=2 的锁
-- 形成死锁2. SHOW PROCESSLIST
SHOW FULL PROCESSLIST;
-- 输出示例:
Id: 10
User: root
Host: localhost:54321
db: test
Command: Query
Time: 30
State: Waiting for table metadata lock
Info: UPDATE orders SET status = 1 WHERE id = 1001
-- 关注:
-- Time: 执行时间
-- State: 状态
-- - Waiting for table metadata lock: 等待元数据锁
-- - Waiting for table level lock: 等待表锁
-- - Sending data: 查询执行中
-- - Statistics: 计算统计信息
-- - Locked: 被锁住(MyISAM)3. information_schema.innodb_trx
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds,
trx_mysql_thread_id,
trx_query,
trx_rows_locked,
trx_lock_structs
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC;
-- 关注:
-- trx_state: RUNNING, LOCK WAIT, ROLLING BACK
-- running_seconds: 运行时间
-- trx_rows_locked: 锁定的行数
-- trx_query: 正在执行的 SQL4. performance_schema 锁等待视图
-- MySQL 5.7+ 使用 sys schema
SELECT * FROM sys.innodb_lock_waits;
-- 输出:
-- wait_age: 等待时长
-- locked_table: 被锁表
-- locked_index: 被锁索引
-- locked_type: 锁类型
-- waiting_query: 等待的查询
-- blocking_query: 阻塞的查询
-- MySQL 8.0+ 使用 performance_schema.data_locks
SELECT
OBJECT_NAME,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
THREAD_ID,
PROCESSLIST_ID
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'test' AND OBJECT_NAME = 'orders';遇到锁等待时优先看什么
1. 哪个事务持锁最久
-- 查看长事务
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.INNODB_TRX
WHERE trx_state = 'RUNNING'
ORDER BY trx_started ASC
LIMIT 10;
-- 如果有事务运行超过 30 秒,重点关注
-- 可能是长事务导致锁等待2. 持锁 SQL 是什么
-- 查看持锁事务的 SQL
SELECT
t.trx_id,
t.trx_mysql_thread_id,
t.trx_query AS current_query,
e.SQL_TEXT AS last_executed_sql,
e.TIMER_WAIT / 1000000000000 AS execution_time_sec
FROM information_schema.INNODB_TRX t
LEFT JOIN performance_schema.events_statements_current e
ON e.THREAD_ID = (
SELECT THREAD_ID
FROM performance_schema.threads
WHERE PROCESSLIST_ID = t.trx_mysql_thread_id
)
ORDER BY t.trx_started ASC;
-- 注意: trx_query 可能为空(事务空闲中)
-- 需要查看 last_executed_sql3. 等待事务是否命中了索引
-- 查看等待事务的执行计划
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 FOR UPDATE;
-- 如果 type=ALL,说明未命中索引,锁全表
-- 如果 type=ref,说明命中索引,锁范围较小4. 是否存在长事务或批量更新
-- 查看事务运行时间
SELECT
trx_id,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds,
trx_rows_modified,
trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC;
-- running_seconds > 30: 长事务
-- trx_rows_modified > 10000: 批量更新
-- 都可能导致锁等待5. 是否有不必要的 FOR UPDATE
-- 查看当前执行的 SQL
SELECT
PROCESSLIST_ID,
PROCESSLIST_USER,
PROCESSLIST_DB,
PROCESSLIST_STATE,
PROCESSLIST_INFO
FROM performance_schema.threads
WHERE PROCESSLIST_COMMAND = 'Query'
AND PROCESSLIST_INFO LIKE '%FOR UPDATE%';
-- 如果有大量 FOR UPDATE,检查是否必要
-- 可考虑乐观锁替代死锁并不一定是坏事
死锁说明数据库检测到了循环等待,并主动终止其中一个事务。它本质上是一种保护机制。真正要解决的是:
1. 为什么会形成循环锁依赖
-- 死锁场景 1: 更新顺序不一致
-- 事务 A
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 持有 id=1 的锁
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 等待 id=2 的锁
-- 事务 B
START TRANSACTION;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- 持有 id=2 的锁
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- 等待 id=1 的锁
-- 死锁! A 等 B,B 等 A
-- 解决: 按固定顺序更新(如 id 升序)
-- 事务 A 和 B 都先更新 id=1,再更新 id=22. 是否存在更新顺序不一致
-- 代码示例: 转账逻辑
// × 错误: 更新顺序不一致
public void transfer(Long fromId, Long toId, BigDecimal amount) {
jdbcTemplate.update("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromId);
jdbcTemplate.update("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toId);
}
// 转账 A->B: 先锁 A,再锁 B
// 转账 B->A: 先锁 B,再锁 A
// 并发时死锁!
// √ 正确: 按固定顺序更新
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Long first = Math.min(fromId, toId);
Long second = Math.max(fromId, toId);
jdbcTemplate.update("UPDATE accounts SET balance = balance - ? WHERE id = ?",
first.equals(fromId) ? amount : amount.negate(), first);
jdbcTemplate.update("UPDATE accounts SET balance = balance + ? WHERE id = ?",
first.equals(fromId) ? amount : amount.negate(), second);
}3. 是否锁范围过大
-- 死锁场景 2: 间隙锁冲突
-- 事务 A
SELECT * FROM orders WHERE id > 100 FOR UPDATE; -- 锁住 id>100 的所有间隙
-- 事务 B
INSERT INTO orders VALUES (101, ...); -- 等待间隙锁
-- 事务 A
INSERT INTO orders VALUES (102, ...); -- 等待事务 B 的插入意向锁
-- 死锁!
-- 解决: 缩小锁范围
SELECT * FROM orders WHERE id = 101 FOR UPDATE;死锁排查完整流程
-- 1. 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G
-- 搜索 "LATEST DETECTED DEADLOCK"
-- 2. 查看死锁涉及的事务
-- 从 LATEST DETECTED DEADLOCK 中提取事务 ID
-- 3. 查看死锁涉及的 SQL
-- 从 LATEST DETECTED DEADLOCK 中提取 SQL
-- 4. 分析死锁原因:
-- - 更新顺序不一致?
-- - 锁范围过大?
-- - 索引失效导致全表锁?
-- - 长事务?
-- 5. 解决方案:
-- - 统一资源访问顺序
-- - 精准查询条件,命中索引
-- - 缩短事务时间
-- - 使用乐观锁替代悲观锁开发中的实践建议
索引设计最佳实践
-- 1. 索引设计要跟随查询场景
-- × 错误: 所有字段都加索引
CREATE INDEX idx_all ON orders (user_id, status, created_at, amount);
-- √ 正确: 根据查询模式设计
-- 查询 1: WHERE user_id = ? AND status = ?
-- 查询 2: WHERE user_id = ? ORDER BY created_at
CREATE INDEX idx_user_status ON orders (user_id, status);
CREATE INDEX idx_user_created ON orders (user_id, created_at);
-- 2. 优先使用覆盖索引
-- × 错误: 需要回表
SELECT * FROM orders WHERE user_id = 1001;
-- √ 正确: 覆盖索引
SELECT id, user_id, status FROM orders WHERE user_id = 1001;
-- 3. 避免索引冗余
-- × 错误: 索引冗余
CREATE INDEX idx_user_id ON orders (user_id);
CREATE INDEX idx_user_status ON orders (user_id, status); -- idx_user_id 冗余
-- √ 正确: 只保留联合索引
CREATE INDEX idx_user_status ON orders (user_id, status);
-- 4. 定期分析索引使用情况
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME,
ROWS_READ,
ROWS_INSERTED,
ROWS_UPDATED,
ROWS_DELETED
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'your_database'
AND INDEX_NAME IS NOT NULL
ORDER BY ROWS_READ DESC;事务设计最佳实践
-- 1. 事务尽量短小
-- × 错误: 事务包含远程调用
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 远程调用第三方支付接口(耗时 5 秒)
COMMIT;
-- √ 正确: 先调用远程接口,最后再开事务
-- 远程调用第三方支付接口
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- 2. 避免事务嵌套
-- × 错误: 事务嵌套
START TRANSACTION;
START TRANSACTION; -- 不生效,仍在第一个事务中
COMMIT;
COMMIT;
-- √ 正确: 使用传播行为(Spring)
@Transactional(propagation = Propagation.REQUIRES_NEW)
-- 3. 只读操作不加事务(或加只读事务)
-- × 错误: 写事务包含读操作
START TRANSACTION;
SELECT * FROM orders WHERE id = 1001; -- 读操作
UPDATE orders SET status = 1 WHERE id = 1001;
COMMIT;
-- √ 正确: 读写分离
SELECT * FROM orders WHERE id = 1001; -- 无事务
START TRANSACTION;
UPDATE orders SET status = 1 WHERE id = 1001;
COMMIT;
-- √ 或使用只读事务(如果需要一致性读)
START TRANSACTION READ ONLY;
SELECT * FROM orders WHERE id = 1001;
COMMIT;
-- 4. 设置合理的隔离级别
-- 大多数场景: READ COMMITTED
-- 需要一致性读: REPEATABLE READ
-- 极端场景: SERIALIZABLE(谨慎使用)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;更新操作最佳实践
-- 1. 更新必须命中索引
-- × 错误: 未命中索引,锁全表
UPDATE orders SET status = 1 WHERE DATE(created_at) = '2025-01-01';
-- √ 正确: 命中索引,锁范围小
UPDATE orders SET status = 1
WHERE created_at >= '2025-01-01' AND created_at < '2025-01-02';
-- 2. 批量更新分批执行
-- × 错误: 大批量更新
UPDATE orders SET status = 1 WHERE status = 0; -- 更新 100 万行
-- √ 正确: 分批更新
UPDATE orders SET status = 1 WHERE status = 0 LIMIT 1000;
-- 循环执行,每次 1000 行
-- 3. 避免 SELECT FOR UPDATE
-- × 错误: 悲观锁
SELECT * FROM products WHERE id = 1 FOR UPDATE;
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
-- √ 正确: 乐观锁
SELECT id, stock, version FROM products WHERE id = 1;
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = ?;
-- 检查影响行数,为 0 则重试常见误区
误区 1: 索引越多越好
-- × 错误认知: 所有查询字段都加索引
CREATE INDEX idx_a ON t (a);
CREATE INDEX idx_b ON t (b);
CREATE INDEX idx_c ON t (c);
CREATE INDEX idx_d ON t (d);
-- 实际问题:
-- 1. 写入性能下降: 每次 INSERT/UPDATE/DELETE 都要维护索引
-- 2. 存储成本增加: 索引占用磁盘空间
-- 3. 优化器困惑: 需要评估更多索引选项
-- 4. 维护成本增加: 索引越多,调优越复杂
-- √ 正确做法:
-- 1. 分析慢查询日志,只为慢查询加索引
-- 2. 使用覆盖索引,减少索引数量
-- 3. 定期检查索引使用情况,删除无用索引
SELECT
INDEX_NAME,
COUNT(*) AS usage_count
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'your_database'
GROUP BY INDEX_NAME
ORDER BY usage_count ASC;
-- usage_count = 0: 未使用的索引,考虑删除误区 2: 看到锁等待就提高隔离级别
-- × 错误认知: 锁等待是因为隔离级别太低
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 实际问题:
-- 1. 隔离级别提高,并发性能下降
-- 2. 可能并未解决根本问题(长事务、锁范围过大)
-- √ 正确做法:
-- 1. 先排查锁等待原因(长事务? 锁范围过大?)
-- 2. 优化事务设计(缩短事务时间)
-- 3. 优化查询(命中索引,缩小锁范围)
-- 4. 最后才考虑调整隔离级别误区 3: 只查 SHOW PROCESSLIST,不看锁等待链
-- × 错误认知: SHOW PROCESSLIST 能看到所有问题
SHOW PROCESSLIST;
-- 只能看到当前正在执行的 SQL,看不到锁等待关系
-- √ 正确做法: 使用 sys.innodb_lock_waits
SELECT
wait_age_secs,
locked_table,
waiting_query,
blocking_query
FROM sys.innodb_lock_waits;
-- 能看到:
-- 1. 谁在等待(waiting_query)
-- 2. 谁阻塞(blocking_query)
-- 3. 等待多久(wait_age_secs)
-- 4. 锁的表和索引(locked_table)误区 4: 只用 EXPLAIN,不结合慢日志和真实事务现场
-- × 错误认知: EXPLAIN 显示使用索引,就认为没问题
EXPLAIN SELECT * FROM orders WHERE user_id = 1001;
-- type: ref, key: idx_user_id
-- 认为没问题,但实际可能:
-- 1. 索引选择错误(优化器选择错误)
-- 2. 等待锁(事务问题,不是索引问题)
-- 3. 磁盘 I/O 问题(数据不在缓存)
-- √ 正确做法: 结合多维度排查
-- 1. EXPLAIN: 查看执行计划
-- 2. EXPLAIN ANALYZE: 查看实际执行时间(MySQL 8.0+)
-- 3. 慢查询日志: 确认是否真的慢
-- 4. SHOW ENGINE INNODB STATUS: 查看锁等待
-- 5. performance_schema: 查看资源消耗实战案例
案例 1: 慢查询优化
问题描述: 订单查询接口响应时间超过 5 秒
-- 慢 SQL
SELECT * FROM orders
WHERE user_id = 1001
AND status IN (1, 2, 3)
ORDER BY created_at DESC
LIMIT 20;
-- 表结构
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
status TINYINT,
amount DECIMAL(10,2),
created_at DATETIME,
KEY idx_user_id (user_id),
KEY idx_status (status)
);
-- 排查步骤:
-- 1. EXPLAIN
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND status IN (1, 2, 3)
ORDER BY created_at DESC LIMIT 20;
-- 结果:
-- type: ref
-- key: idx_user_id
-- rows: 100000
-- Extra: Using where; Using filesort
-- 问题:
-- - 扫描行数太多(10 万行)
-- - filesort(排序未使用索引)
-- 2. 分析
-- user_id 索引命中,但 status 条件和排序未用到
-- 需要创建联合索引
-- 3. 优化方案 1: 创建联合索引
CREATE INDEX idx_user_status_created ON orders (user_id, status, created_at);
-- EXPLAIN
-- type: range
-- key: idx_user_status_created
-- rows: 100
-- Extra: Using index condition
-- 优化后: 扫描行数从 10 万降到 100,无 filesort
-- 4. 优化方案 2: 覆盖索引(如果只需要部分字段)
SELECT id, user_id, status, created_at FROM orders
WHERE user_id = 1001 AND status IN (1, 2, 3)
ORDER BY created_at DESC LIMIT 20;
-- Extra: Using index
-- 无需回表,性能更佳案例 2: 锁等待问题
问题描述: 订单更新接口超时
-- 报错 SQL
UPDATE orders SET status = 2 WHERE id = 1001;
-- ERROR 1205 (HY000): Lock wait timeout exceeded
-- 排查步骤:
-- 1. 查看当前事务
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC;
-- 结果:
-- trx_id=1001, running_seconds=120, trx_query=NULL
-- 问题: 有长事务运行 120 秒
-- 2. 查看锁等待
SELECT * FROM sys.innodb_lock_waits;
-- 结果:
-- waiting_query: UPDATE orders SET status = 2 WHERE id = 1001
-- blocking_query: NULL(事务空闲)
-- 3. 查看阻塞事务的最后 SQL
SELECT
t.trx_id,
e.SQL_TEXT AS last_sql,
e.TIMER_WAIT / 1000000000000 AS execution_time_sec
FROM information_schema.INNODB_TRX t
LEFT JOIN performance_schema.events_statements_current e
ON e.THREAD_ID = (
SELECT THREAD_ID
FROM performance_schema.threads
WHERE PROCESSLIST_ID = t.trx_mysql_thread_id
)
WHERE t.trx_id = 1001;
-- 结果:
-- last_sql: SELECT * FROM orders WHERE user_id = 1001 FOR UPDATE
-- 4. 分析
-- 长事务持有 id=1001 的锁,但未提交
-- 可能是代码逻辑问题(事务未正确关闭)
-- 5. 解决
-- 方案 1: 终止长事务
KILL 持锁事务的trx_mysql_thread_id;
-- 方案 2: 检查代码,确保事务正确关闭
-- 可能是:
-- - try-catch 中未回滚
-- - 事务方法抛出未捕获异常
-- - 事务超时设置过长
-- 代码示例:
@Transactional(timeout = 30) // 设置超时时间
public void updateOrder(Long orderId) {
Order order = orderMapper.selectById(orderId);
order.setStatus(2);
orderMapper.updateById(order);
}案例 3: 死锁问题
问题描述: 转账接口偶发死锁
-- 死锁场景
-- 事务 A
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 事务 B
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- 死锁! A 等 B,B 等 A
-- 排查步骤:
-- 1. 查看死锁日志
SHOW ENGINE INNODB STATUS\G
-- 搜索 "LATEST DETECTED DEADLOCK"
-- 2. 分析
-- 两个事务更新顺序不一致:
-- A: 先 id=1,再 id=2
-- B: 先 id=2,再 id=1
-- 3. 解决: 统一更新顺序
-- 代码示例:
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 按 id 升序更新
Long first = Math.min(fromId, toId);
Long second = Math.max(fromId, toId);
// 先更新 id 小的
Account firstAccount = accountMapper.selectById(first);
if (first.equals(fromId)) {
firstAccount.setBalance(firstAccount.getBalance().subtract(amount));
} else {
firstAccount.setBalance(firstAccount.getBalance().add(amount));
}
accountMapper.updateById(firstAccount);
// 再更新 id 大的
Account secondAccount = accountMapper.selectById(second);
if (second.equals(fromId)) {
secondAccount.setBalance(secondAccount.getBalance().subtract(amount));
} else {
secondAccount.setBalance(secondAccount.getBalance().add(amount));
}
accountMapper.updateById(secondAccount);
}
-- 优化后: 所有转账都按 id 升序更新,不会死锁面试要点
基础问题
-
为什么联合索引要关注最左前缀?
- 联合索引按列顺序构建,查询必须从最左列开始匹配
- 索引利用率与列顺序直接相关
- 跳过最左列会导致索引失效
-
为什么长事务容易导致锁等待?
- 长时间占用锁,阻塞其他事务
- 增加 MVCC 版本链长度,拖慢查询
- 放大死锁概率
-
如何定位慢查询?
- 慢查询日志: 定位慢 SQL
- EXPLAIN: 分析执行计划
- EXPLAIN ANALYZE: 查看实际执行时间
- performance_schema: 查看资源消耗
- SHOW ENGINE INNODB STATUS: 查看锁等待
-
为什么有时 SQL 走了索引还是慢?
- 扫描行数仍然很大(低基数索引)
- 回表次数太多
- 排序未使用索引(filesort)
- 需要临时表
- 等待锁(事务问题,不是索引问题)
进阶问题
-
索引下推(ICP)是什么?
- MySQL 5.6+ 优化: 将索引条件过滤下推到存储引擎
- 减少回表次数
- 示例:
WHERE name LIKE '张%' AND age = 20 - 无 ICP: 存储引擎返回所有 "张" 开头的行,服务器层过滤 age
- 有 ICP: 存储引擎直接过滤 age,减少回表
-
什么是间隙锁? 什么时候会加间隙锁?
- 锁住索引记录之间的间隙
- RR 隔离级别下防止幻读
- 触发场景:
WHERE id > 100 FOR UPDATE - 注意: 间隙锁可能阻塞插入,导致死锁
-
如何解决深度分页问题?
- 方案 1: 覆盖索引 + 延迟关联
- 方案 2: 记录上次查询的最大 ID
- 方案 3: 使用游标(不支持跳页)
-
乐观锁 vs 悲观锁?
- 悲观锁:
SELECT FOR UPDATE,性能差,但一致性保证强 - 乐观锁:
version字段 + CAS,性能好,但需要重试逻辑 - 冲突少: 乐观锁
- 冲突多: 悲观锁
- 悲观锁:
实战问题
-
如何优化一个慢查询?
- 步骤 1: 确认慢的原因(EXPLAIN, EXPLAIN ANALYZE)
- 步骤 2: 区分索引问题还是事务问题
- 步骤 3: 索引问题: 加索引、改写 SQL、使用覆盖索引
- 步骤 4: 事务问题: 缩短事务时间、优化锁范围
- 步骤 5: 验证优化效果
-
生产环境遇到锁等待怎么办?
- 步骤 1: 查看长事务(
information_schema.INNODB_TRX) - 步骤 2: 查看锁等待(
sys.innodb_lock_waits) - 步骤 3: 确定持锁事务是否可以终止
- 步骤 4: 终止长事务(
KILL) - 步骤 5: 排查根本原因(代码逻辑、事务边界)
- 步骤 6: 优化代码,防止再次发生
- 步骤 1: 查看长事务(
总结
索引与事务排障是数据库性能优化的核心技能。关键要点:
- 索引设计: 跟随查询模式,使用联合索引、覆盖索引,避免索引冗余
- 索引失效: 避免函数运算、前导模糊、类型转换、OR 不当使用
- 事务设计: 事务尽量短小,避免长事务、事务嵌套
- 锁等待排查: 查看长事务、锁等待链、持锁 SQL
- 死锁排查: 分析更新顺序、锁范围,统一资源访问顺序
排查工具速查表:
| 问题类型 | 排查工具 | 关键指标 |
|---|---|---|
| 慢查询 | 慢查询日志、EXPLAIN、EXPLAIN ANALYZE | 扫描行数、执行时间、filesort、temporary |
| 锁等待 | sys.innodb_lock_waits、information_schema.INNODB_TRX | 等待时间、持锁事务、持锁 SQL |
| 死锁 | SHOW ENGINE INNODB STATUS | LATEST DETECTED DEADLOCK、事务 ID、SQL |
| 索引使用 | performance_schema.table_io_waits_summary_by_index_usage | ROWS_READ、使用次数 |
优化流程:
- 确认问题 SQL(慢日志、APM)
- 分析执行计划(EXPLAIN、EXPLAIN ANALYZE)
- 区分索引问题还是事务问题
- 针对性优化(加索引、改写 SQL、缩短事务、优化锁范围)
- 验证效果(对比优化前后性能)
版本差异(MySQL 5.7 → 8.0/8.4)
| 特性 | 旧版(本文编写时,MySQL 5.7) | 当前(MySQL 8.0/8.4 LTS) |
|---|---|---|
| 默认字符集 | utf8(需显式配置 utf8mb4) | utf8mb4(MySQL 8.0 起默认) |
| 索引 | 普通 B+Tree | 降序索引、隐藏索引、函数索引(8.0+) |
| SQL 能力 | 常规查询 | 递归 CTE、窗口函数(8.0+) |
| 版本策略 | 5.7 | 8.0(主流)/ 8.4 LTS / 9.x(创新版) |
| Java 驱动 | mysql-connector-java 5.x/8.0 | mysql-connector-j 8.x/9.x |
本文基于 MySQL 5.7 编写,核心概念(索引、事务、锁、MVCC、InnoDB)在 8.0/8.4 中依然适用;8.0 的默认字符集、隐藏索引与 SQL 增强(CTE/窗口函数)是升级后的主要差异。