MySQL 学习记录(二):索引、EXPLAIN、事务与锁

上一篇整理了表结构、增删改查、多表连接和常用函数。这一篇继续整理索引、执行计划、事务、隔离级别和锁。

这些内容单看定义比较散,放到 SQL 执行过程中就容易区分:

索引:减少查询需要检查的数据量
EXPLAIN:查看 MySQL 准备怎样执行 SQL
事务:保证一组操作的一致性
锁:处理多个事务同时读写数据的问题

一、索引是什么

没有索引时,MySQL 可能需要逐行检查数据。

例如:

SELECT *
FROM sys_user
WHERE username = 'zhangsan';

如果 username 没有索引,数据量较大时可能进行全表扫描。

创建索引:

CREATE INDEX idx_user_username
ON sys_user(username);

再查询相同用户名时,MySQL 可以通过索引缩小查找范围。

索引可以理解成单独维护的一套数据结构。创建索引后,数据库不仅要保存表数据,还要保存索引数据。

所以索引不是免费的:

  • 索引占用磁盘空间;

  • 插入数据时需要维护索引;

  • 修改索引字段时需要更新索引;

  • 删除数据时也要调整索引;

  • 索引太多会增加写操作成本。

创建索引前需要先看实际查询,而不是给每个字段都加索引。


二、InnoDB 为什么使用 B+Tree

InnoDB 中常见索引使用 B+Tree 结构。

B+Tree 的几个特点:

  • 数据按照索引键值保持有序;

  • 非叶子节点主要保存索引信息;

  • 叶子节点保存数据或主键值;

  • 叶子节点之间通过指针连接;

  • 树的高度通常比较低。

数据库数据主要保存在磁盘中。一次磁盘页读取的成本比内存计算高,因此索引结构需要尽量减少磁盘访问次数。

B+Tree 一个节点可以保存较多键值,树的分支较多,高度较低。查询单条数据时,不需要从根节点向下经过很多层。

叶子节点保持有序,也适合范围查询:

SELECT *
FROM sys_user
WHERE id BETWEEN 1000 AND 2000;

找到范围起点后,可以沿着叶子节点继续读取后续数据。


三、聚簇索引和二级索引

InnoDB 表的数据和主键索引存放在一起,这个索引一般称为聚簇索引。

假设表结构如下:

CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL,
    phone VARCHAR(20),
    status TINYINT NOT NULL
) ENGINE = InnoDB;

主键索引的叶子节点中保存完整行数据。

通过主键查询:

SELECT *
FROM sys_user
WHERE id = 100;

可以在主键索引中直接找到这一行。

给用户名创建普通索引:

CREATE INDEX idx_user_username
ON sys_user(username);

这个普通索引属于二级索引。其叶子节点通常保存:

username + 主键 id

执行:

SELECT *
FROM sys_user
WHERE username = 'zhangsan';

可能经过两个步骤:

  1. idx_user_username 中找到对应主键;

  2. 根据主键到聚簇索引中读取完整行。

第二步通常称为回表。


四、回表和覆盖索引

查询:

SELECT id, username
FROM sys_user
WHERE username = 'zhangsan';

普通索引 idx_user_username 中已经包含 username 和主键 id,不需要再读取完整行。这种情况属于覆盖索引。

如果查询:

SELECT id, username, phone
FROM sys_user
WHERE username = 'zhangsan';

而索引中没有 phone,MySQL 通常需要根据主键回表查询。

可以建立联合索引:

CREATE INDEX idx_user_username_phone
ON sys_user(username, phone);

对于下面的查询:

SELECT id, username, phone
FROM sys_user
WHERE username = 'zhangsan';

需要的字段都能从索引中取得,可能不再回表。

覆盖索引可以减少读取次数,但不能把接口需要的所有字段全部塞进索引。联合索引字段越多,占用空间越大,维护成本也越高。


五、常见索引类型

1. 主键索引

PRIMARY KEY (id)

主键值唯一并且不能为 NULL。一张表只能有一个主键。

2. 唯一索引

CREATE UNIQUE INDEX uk_user_phone
ON sys_user(phone);

唯一索引除了提高查询效率,还能保证数据不重复。

如果手机号必须唯一,只在 Java 中查询是否存在不够。并发请求可能同时通过检查,数据库唯一约束才是最后一道限制。

3. 普通索引

CREATE INDEX idx_user_status
ON sys_user(status);

普通索引允许重复值。

4. 联合索引

CREATE INDEX idx_user_status_time
ON sys_user(status, create_time);

一个索引包含多个字段。

5. 全文索引

全文索引用于较长文本的关键词搜索。普通 B+Tree 索引不适合:

LIKE '%关键字%'

中文全文检索还涉及分词。数据量和搜索要求较高时,也可能使用 Elasticsearch 等专门的搜索工具。


六、联合索引和最左前缀

创建联合索引:

CREATE INDEX idx_user_status_time
ON sys_user(status, create_time);

这个索引首先按照 status 排序,状态相同时再按照 create_time 排序。

下面的查询能够使用联合索引的前缀:

SELECT *
FROM sys_user
WHERE status = 1;
SELECT *
FROM sys_user
WHERE status = 1
  AND create_time >= '2026-01-01 00:00:00';

只查询第二个字段时:

SELECT *
FROM sys_user
WHERE create_time >= '2026-01-01 00:00:00';

通常不能充分利用这个联合索引,因为缺少最左侧的 status

最左前缀指的是联合索引字段的排列,不是 SQL 中条件书写顺序。

下面两条 SQL 对优化器来说通常没有本质区别:

WHERE status = 1
  AND create_time >= '2026-01-01'
WHERE create_time >= '2026-01-01'
  AND status = 1

重要的是索引定义顺序:

(status, create_time)

不是 WHERE 后面的书写顺序。


七、范围条件对联合索引的影响

创建索引:

CREATE INDEX idx_user_status_time_id
ON sys_user(status, create_time, id);

查询:

SELECT *
FROM sys_user
WHERE status = 1
  AND create_time >= '2026-01-01'
  AND id = 100;

status 是等值条件,create_time 是范围条件。

在传统的最左前缀说明中,范围条件右侧的字段通常不能继续用于缩小索引扫描范围。MySQL 的版本、索引条件下推和优化器策略会影响实际结果,所以不能只靠规则判断。

最稳妥的方法是执行:

EXPLAIN
SELECT *
FROM sys_user
WHERE status = 1
  AND create_time >= '2026-01-01'
  AND id = 100;

再结合:

key
key_len
rows
Extra

判断实际使用了哪些索引部分。


八、哪些情况可能导致索引没有按预期使用

1. 在索引字段上使用函数

索引:

CREATE INDEX idx_user_create_time
ON sys_user(create_time);

查询:

SELECT *
FROM sys_user
WHERE DATE(create_time) = '2026-08-20';

对字段使用 DATE() 后,普通索引可能不能直接用于定位。

可以改成范围:

SELECT *
FROM sys_user
WHERE create_time >= '2026-08-20 00:00:00'
  AND create_time <  '2026-08-21 00:00:00';

2. 前导模糊查询

WHERE username LIKE '%zhang%'

开头是 % 时,MySQL 无法从 B+Tree 的有序前缀开始定位。

下面的查询更容易使用索引:

WHERE username LIKE 'zhang%'

3. 隐式类型转换

假设 phoneVARCHAR

WHERE phone = 13800000001

查询值没有加引号,MySQL 可能进行类型转换。

应该写成:

WHERE phone = '13800000001'

字段类型和查询参数类型应保持一致。

4. 联合索引缺少最左字段

索引:

(status, create_time)

查询只有:

WHERE create_time >= '2026-01-01'

不能充分使用这个联合索引。

5. 查询返回的数据比例太高

即使存在索引,MySQL 也可能选择全表扫描。

例如 status 只有两个值,并且大部分记录的状态都是 1

SELECT *
FROM sys_user
WHERE status = 1;

如果查询要返回整张表的大部分数据,走二级索引后再大量回表,不一定比全表扫描快。

优化器会根据统计信息估算成本,而不是看到索引就一定使用。

6. OR 两侧索引条件不同

WHERE username = 'zhangsan'
   OR email = 'zhangsan@example.com'

如果一个字段有索引,另一个没有,执行计划可能与预期不同。

不能简单记成“OR 一定导致索引失效”。MySQL 也可能使用索引合并,具体仍然要看 EXPLAIN


九、查看索引

查看表中已有索引:

SHOW INDEX FROM sys_user;

创建普通索引:

CREATE INDEX idx_user_username
ON sys_user(username);

创建唯一索引:

CREATE UNIQUE INDEX uk_user_phone
ON sys_user(phone);

删除索引:

DROP INDEX idx_user_username
ON sys_user;

查看建表语句:

SHOW CREATE TABLE sys_user;

建索引前应先检查已有索引,避免创建重复或包含关系明显的索引。

例如已经有:

(status, create_time)

又单独创建:

(status)

后一个索引可能是冗余的,因为联合索引已经能支持以 status 为最左前缀的查询。

但是否删除仍要结合索引大小、查询频率和执行计划判断。


十、EXPLAIN 的基本使用

在查询语句前添加 EXPLAIN

EXPLAIN
SELECT *
FROM sys_user
WHERE username = 'zhangsan';

常见输出列包括:

id
select_type
table
partitions
type
possible_keys
key
key_len
ref
rows
filtered
Extra

不需要一开始把所有列都记住。排查普通单表查询时,可以先看:

type
possible_keys
key
rows
filtered
Extra

十一、type

type 表示 MySQL 访问数据的大致方式。

常见值从较好到较差可以记为:

system
const
eq_ref
ref
range
index
ALL

这只是大致顺序,不代表看到某个类型就能直接判断 SQL 好坏。

const

通过主键或唯一索引等值查询,最多匹配一行:

SELECT *
FROM sys_user
WHERE id = 1;

eq_ref

多表连接时,被连接表通过主键或唯一索引匹配一行。

ref

使用普通索引进行等值查询,可能匹配多行:

SELECT *
FROM sys_user
WHERE status = 1;

range

使用索引进行范围扫描:

SELECT *
FROM sys_user
WHERE id BETWEEN 100 AND 200;

index

扫描整个索引。

它不等于高效,只是扫描的是索引结构,而不是直接扫描整张数据表。

ALL

全表扫描。

小表出现 ALL 不一定需要处理。数据量较大、查询频率较高、过滤比例较小时,才需要重点检查。


十二、possible_keys 和 key

possible_keys 表示优化器认为可能使用的索引。

key 表示最终实际选择的索引。

可能出现:

possible_keys: idx_user_status
key: NULL

这不一定表示优化器出错。可能是因为:

  • 表中数据很少;

  • 条件返回的数据太多;

  • 使用索引后需要大量回表;

  • 优化器估算全表扫描成本更低;

  • 统计信息不准确。

不能只看有没有索引,还要看最终是否使用,以及为什么没有使用。


十三、key_len

key_len 表示执行计划预计使用的索引键长度。

联合索引中,可以通过 key_len 辅助判断使用到了多少个字段。

但长度计算与字段类型、字符集、是否允许 NULL 等因素有关,不适合只背固定数字。

实际排查时可以做对比:

EXPLAIN
SELECT *
FROM sys_user
WHERE status = 1;

再执行:

EXPLAIN
SELECT *
FROM sys_user
WHERE status = 1
  AND create_time >= '2026-01-01';

观察两个执行计划中的 key_len 是否变化。


十四、rows 和 filtered

rows 是优化器估算需要检查的行数,不是 SQL 真正执行后精确扫描的行数。

通常情况下,rows 越小,说明预计需要检查的数据越少。

filtered 表示经过表条件过滤后,预计保留的数据比例。

例如:

rows = 10000
filtered = 10

可以粗略理解为读取约 10000 行后,预计有 10% 能通过当前表的过滤条件。

这些值来自统计信息和成本估算,不能当成真实运行结果。

MySQL 8.0 可以使用:

EXPLAIN ANALYZE
SELECT *
FROM sys_user
WHERE status = 1;

它会真正执行查询,并给出实际执行时间、实际行数和循环次数。因为查询会执行,分析更新或大查询时需要谨慎。


十五、Extra

Extra 会显示一些附加执行信息。

Using index

表示查询需要的列可以直接从索引获取,通常是覆盖索引。

Using where

读取数据后还需要按照 WHERE 条件过滤。

它不代表 SQL 一定很慢,普通查询中经常出现。

Using filesort

MySQL 需要额外排序,不能直接利用当前索引顺序得到结果。

ORDER BY create_time DESC

不一定真的使用磁盘文件排序,也可能在内存中完成。名字保留了历史叫法。

Using temporary

查询过程中使用了临时表,常见于某些 GROUP BYDISTINCT 或排序场景。

Using index condition

表示使用了索引条件下推。部分条件在存储引擎读取索引时提前过滤,可以减少回表。

看到 Using filesortUsing temporary 时,需要结合数据量和查询频率判断,不是出现后就一定要修改 SQL。


十六、用 EXPLAIN 对比一条查询

创建表:

CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL,
    status TINYINT NOT NULL,
    create_time DATETIME NOT NULL
);

查询:

EXPLAIN
SELECT id, username, create_time
FROM sys_user
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20;

如果没有索引,可能出现:

type: ALL
key: NULL
Extra: Using where; Using filesort

根据查询条件和排序建立联合索引:

CREATE INDEX idx_user_status_time
ON sys_user(status, create_time);

再次执行相同的 EXPLAIN

可能看到:

key: idx_user_status_time
type: ref

排序也可能利用索引顺序。

这里不能预先保证执行计划一定变成某个固定结果。表的数据量、字段分布、查询列和 MySQL 版本都会影响优化器选择。

正确流程是:

记录原执行计划
创建或调整索引
重新执行 EXPLAIN
对比 key、rows 和 Extra
再测试实际耗时

十七、索引字段顺序怎么确定

假设常见查询是:

SELECT
    id,
    order_no,
    total_amount
FROM rental_order
WHERE user_id = ?
  AND status = ?
ORDER BY create_time DESC
LIMIT 20;

可以考虑:

CREATE INDEX idx_order_user_status_time
ON rental_order(
    user_id,
    status,
    create_time
);

考虑顺序时主要看:

  • 哪些条件总是出现;

  • 哪些是等值查询;

  • 哪些是范围查询;

  • 是否需要排序;

  • 字段区分度;

  • 查询返回哪些列;

  • 其他 SQL 能否复用这个索引。

没有一条固定规则能代替实际执行计划。

“区分度高的字段一定放前面”也不是绝对的。业务查询通常先按照固定前缀过滤时,索引顺序仍然要服务于完整查询模式。


十八、事务的基本使用

事务用于把多条 SQL 作为一个整体执行。

START TRANSACTION;

UPDATE account
SET balance = balance - 100
WHERE id = 1;

UPDATE account
SET balance = balance + 100
WHERE id = 2;

COMMIT;

如果第二条 SQL 失败,可以回滚:

ROLLBACK;

自动提交状态:

SELECT @@autocommit;

关闭当前会话自动提交:

SET autocommit = 0;

手动事务中,如果既没有 COMMIT,也没有 ROLLBACK,事务可能持续占用锁和连接。


十九、事务的 ACID

原子性 Atomicity

事务中的操作要么全部成功,要么全部失败。

转账时不能只扣款,不加款。

一致性 Consistency

事务执行前后,数据需要满足约束和业务规则。

例如总金额不能无故变化,外键和唯一约束不能被破坏。

一致性是最终目标,原子性、隔离性和持久性共同帮助实现一致性。

隔离性 Isolation

多个事务同时执行时,彼此之间的操作需要受到隔离。

持久性 Durability

事务提交后,结果需要被持久保存。即使数据库进程异常退出,已提交数据也应该能够恢复。


二十、并发事务中的问题

1. 脏读

一个事务读取到另一个事务尚未提交的数据。

事务 A:

UPDATE account
SET balance = 0
WHERE id = 1;

事务 A 还没有提交。

事务 B 此时读取到余额为 0。随后事务 A 回滚,事务 B 之前读取的值就是脏数据。

2. 不可重复读

同一事务内,两次读取同一行,结果不同。

事务 A 第一次查询余额为 1000

事务 B 修改余额为 500 并提交。

事务 A 再次查询得到 500

3. 幻读

同一事务中,按照同一条件查询,返回的记录数量发生变化。

事务 A:

SELECT *
FROM sys_user
WHERE status = 1;

事务 B 插入一条 status = 1 的数据并提交。

事务 A 再执行相同条件查询,可能看到新增记录。


二十一、事务隔离级别

查看当前隔离级别:

SELECT @@transaction_isolation;

MySQL 常见隔离级别有四种。

READ UNCOMMITTED

读未提交。

可以读取其他事务尚未提交的数据,可能出现脏读。

READ COMMITTED

读已提交。

只能读取其他事务已经提交的数据,可以避免脏读。但同一事务内重复读取,结果可能变化。

REPEATABLE READ

可重复读。

InnoDB 默认隔离级别通常是可重复读。同一事务中普通快照读能够保持一致视图。

SERIALIZABLE

串行化。

隔离程度最高,并发能力最低。事务之间更接近串行执行。

设置当前会话隔离级别:

SET SESSION TRANSACTION ISOLATION LEVEL
READ COMMITTED;

设置全局级别会影响之后新建的连接,需要谨慎。

隔离级别越高,并发性能不一定越好。实际系统需要在一致性和并发能力之间选择。


二十二、快照读和当前读

普通查询通常属于快照读:

SELECT *
FROM sys_user
WHERE id = 1;

快照读通过 MVCC 读取符合当前一致性视图的数据,不一定读取最新版本。

带锁查询属于当前读:

SELECT *
FROM sys_user
WHERE id = 1
FOR UPDATE;
SELECT *
FROM sys_user
WHERE id = 1
FOR SHARE;

UPDATEDELETEINSERT 也属于当前读,需要基于当前数据版本进行操作。

FOR UPDATE 会对查询到的数据加锁,必须放在事务中使用才有实际意义。


二十三、MVCC

MVCC 是多版本并发控制。

InnoDB 会为记录维护事务相关信息,并通过 Undo Log 保存旧版本数据。普通查询可以按照事务的一致性视图读取适合的版本,不需要所有读操作都阻塞写操作。

MVCC 主要服务于快照读。

下面的查询通常通过 MVCC 获取一致性数据:

SELECT *
FROM sys_user
WHERE id = 1;

下面的查询要求读取当前版本并加锁:

SELECT *
FROM sys_user
WHERE id = 1
FOR UPDATE;

MVCC 的详细可见性判断涉及事务 ID、Read View 和 Undo Log,这部分可以单独继续整理。


二十四、行锁

InnoDB 支持行级锁。

事务 A:

START TRANSACTION;

UPDATE sys_user
SET status = 0
WHERE id = 1;

在事务 A 提交或回滚前,事务 B 修改同一行时可能需要等待:

UPDATE sys_user
SET status = 1
WHERE id = 1;

如果事务 B 修改的是另一行:

UPDATE sys_user
SET status = 1
WHERE id = 2;

通常可以继续执行。

行锁实际加在索引记录上。查询条件没有合适索引时,扫描和加锁范围可能比预期更大。

因此索引不仅影响查询速度,也会影响并发更新中的锁范围。


二十五、间隙锁和临键锁

在 InnoDB 可重复读隔离级别下,范围查询进行当前读时,可能涉及间隙锁或临键锁。

例如:

SELECT *
FROM sys_user
WHERE id BETWEEN 10 AND 20
FOR UPDATE;

除了锁住已有记录,还可能锁住相关索引区间,防止其他事务在范围内插入数据。

临键锁可以理解为:

记录锁 + 间隙锁

这部分行为与隔离级别、查询条件、索引类型以及是否唯一查询有关。

遇到“不同主键的插入为什么也被阻塞”时,需要检查:

  • 当前隔离级别;

  • SQL 是否是范围查询;

  • 查询使用了哪个索引;

  • 是否存在间隙锁;

  • 事务是否长时间未提交。


二十六、悲观锁

悲观锁假设并发冲突可能发生,所以操作前先锁定数据。

START TRANSACTION;

SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;

UPDATE account
SET balance = balance - 100
WHERE id = 1;

COMMIT;

在事务提交前,其他事务对同一行的冲突更新需要等待。

Java 中的典型流程:

@Transactional
public void deduct(Long accountId,
                   BigDecimal amount) {

    Account account =
            accountMapper.selectByIdForUpdate(
                    accountId
            );

    if (account.getBalance()
            .compareTo(amount) < 0) {
        throw new BusinessException(
                "余额不足"
        );
    }

    accountMapper.deduct(
            accountId,
            amount
    );
}

悲观锁适合冲突较多、操作时间较短且必须严格串行的场景。

事务中不能执行耗时较长的外部请求,否则会长时间占用数据库锁。


二十七、乐观锁

乐观锁不提前锁定数据,而是在更新时检查数据是否仍然是读取时的版本。

表中增加版本号:

ALTER TABLE account
ADD COLUMN version INT NOT NULL DEFAULT 0;

读取:

SELECT id, balance, version
FROM account
WHERE id = 1;

假设读取到:

balance = 1000
version = 3

更新时带上原版本号:

UPDATE account
SET balance = 900,
    version = version + 1
WHERE id = 1
  AND version = 3;

如果受影响行数是 1,说明更新成功。

如果是 0,说明数据已经被其他事务修改,需要重新读取、重试或提示冲突。

乐观锁适合冲突较少的场景。它没有消除冲突,只是通过版本条件检测冲突。


二十八、避免超卖的条件更新

扣减库存时,可以直接把业务条件放入 SQL:

UPDATE product
SET stock = stock - 1
WHERE id = 100
  AND stock > 0;

然后检查受影响行数:

1:扣减成功
0:库存不足或商品不存在

相比先查询库存再更新:

SELECT stock
判断 stock > 0
UPDATE stock

条件更新减少了查询和更新之间的并发空隙。

如果业务流程还涉及订单、支付等多张表,仍然需要继续处理事务、幂等和失败补偿。


二十九、死锁

事务 A:

先锁定记录 1
再等待记录 2

事务 B:

先锁定记录 2
再等待记录 1

两个事务相互等待,形成死锁。

InnoDB 检测到死锁后,会选择回滚其中一个事务,让另一个事务继续执行。

查看最近一次死锁信息:

SHOW ENGINE INNODB STATUS;

减少死锁的方法:

  • 多个事务按照相同顺序访问数据;

  • 缩短事务执行时间;

  • 不在事务中执行不必要的远程调用;

  • 为查询条件建立合适索引;

  • 一次事务不要修改过多数据;

  • 对死锁失败进行有限次数重试。

死锁不能保证完全消失,重点是减少发生概率,并让应用能够处理回滚。


三十、慢查询日志

查看是否开启慢查询:

SHOW VARIABLES LIKE 'slow_query_log';

查看慢查询时间阈值:

SHOW VARIABLES LIKE 'long_query_time';

查看慢查询日志文件:

SHOW VARIABLES LIKE 'slow_query_log_file';

慢查询日志记录执行时间超过阈值的 SQL。

排查流程可以按下面进行:

找到慢 SQL
    ↓
确认参数和执行频率
    ↓
执行 EXPLAIN 或 EXPLAIN ANALYZE
    ↓
检查索引、扫描行数和额外排序
    ↓
修改 SQL 或索引
    ↓
重新测试

不能只看到一条 SQL 执行 500 毫秒就立即增加索引。还需要确认:

  • SQL 一天执行一次还是每秒执行几百次;

  • 返回了多少数据;

  • 是否存在网络和对象转换耗时;

  • 当前测试数据量是否接近实际环境;

  • 新索引会不会增加大量写入成本。


三十一、ORDER BY 和索引

查询:

SELECT
    id,
    username,
    create_time
FROM sys_user
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20;

建立联合索引:

CREATE INDEX idx_user_status_time
ON sys_user(status, create_time);

status 使用等值条件,后面的 create_time 可以用于有序读取。

但下面的情况可能无法完全利用索引排序:

ORDER BY username, create_time

索引字段顺序和排序要求不匹配。

排序方向、范围条件、多表查询和查询列都会影响执行计划。是否出现 Using filesort 应以实际 EXPLAIN 为准。


三十二、GROUP BY 和临时表

查询每种状态的用户数量:

SELECT
    status,
    COUNT(*) AS user_count
FROM sys_user
GROUP BY status;

如果 status 有合适索引,MySQL 可能利用索引顺序完成分组。

更复杂的分组:

SELECT
    DATE(create_time) AS create_date,
    COUNT(*) AS user_count
FROM sys_user
GROUP BY DATE(create_time);

对字段使用函数后,可能需要临时表或额外排序。

可以考虑增加生成列:

ALTER TABLE sys_user
ADD COLUMN create_date DATE
GENERATED ALWAYS AS (
    DATE(create_time)
) STORED;

再根据实际查询为生成列建立索引。

是否值得这样做,要看统计查询的频率和数据量。低频后台报表不一定需要为了避免临时表增加表结构复杂度。


三十三、深分页

普通分页:

SELECT *
FROM sys_user
ORDER BY id
LIMIT 100000, 20;

MySQL 需要找到并跳过前面的 100000 条,再返回 20 条。偏移量越大,执行成本通常越高。

如果按主键连续翻页,可以记录上一页最后一个 ID:

SELECT *
FROM sys_user
WHERE id > 100000
ORDER BY id
LIMIT 20;

这种方式也叫游标式分页或基于最后值的分页。

它适合:

  • 下一页加载;

  • 时间线;

  • 数据导出;

  • 不要求直接跳到任意页。

如果必须跳到第 5000 页,仍然需要结合业务重新考虑分页方式。

还可以先通过覆盖索引找到主键,再回表:

SELECT u.*
FROM sys_user AS u
INNER JOIN (
    SELECT id
    FROM sys_user
    ORDER BY id
    LIMIT 100000, 20
) AS temp
ON temp.id = u.id;

是否更快需要实际测试,不能只根据写法判断。


三十四、避免 N+1 查询

先查询用户列表:

SELECT *
FROM sys_user
LIMIT 20;

然后循环每个用户查询订单:

SELECT *
FROM rental_order
WHERE user_id = ?;

20 个用户会额外执行 20 条 SQL,一共 21 条。这类情况通常称为 N+1 查询。

可以批量查询:

SELECT *
FROM rental_order
WHERE user_id IN (
    1, 2, 3, 4, 5
);

再在 Java 中按照 user_id 分组。

也可以根据返回结构使用连接查询:

SELECT
    u.id,
    u.username,
    o.id AS order_id,
    o.order_no
FROM sys_user AS u
LEFT JOIN rental_order AS o
    ON o.user_id = u.id
WHERE u.id IN (
    1, 2, 3, 4, 5
);

使用连接时要注意一对多关系会产生重复的用户行,分页也不能直接套在展开后的结果上。


三十五、大批量更新和删除

一次更新大量数据:

UPDATE sys_user
SET status = 0
WHERE create_time < '2020-01-01';

可能带来:

  • 大事务;

  • 大量 Undo Log;

  • 长时间持有锁;

  • 主从复制延迟;

  • 回滚时间很长。

可以根据主键分批处理:

UPDATE sys_user
SET status = 0
WHERE id > 0
  AND id <= 10000
  AND create_time < '2020-01-01';

下一批:

UPDATE sys_user
SET status = 0
WHERE id > 10000
  AND id <= 20000
  AND create_time < '2020-01-01';

分批大小需要根据实际环境调整。执行前要备份或确认恢复方案,并观察锁等待、磁盘和复制状态。


三十六、索引不是越多越好

假设一张表有这些索引:

(username)
(username, status)
(username, status, create_time)

前两个索引可能被后面的联合索引覆盖部分能力,但不能只看字段前缀就直接删除。

还要检查:

  • 每个索引支持哪些查询;

  • 更长的联合索引是否明显增大;

  • 查询是否需要覆盖索引;

  • 写操作是否频繁;

  • 优化器实际选择哪个索引。

可以查看索引信息:

SHOW INDEX FROM sys_user;

索引设计应该服务于具体查询,不是服务于字段本身。

比较适合建立索引的字段通常具有以下特点:

  • 经常出现在 WHERE 中;

  • 经常用于表连接;

  • 经常参与排序或分组;

  • 区分度较高;

  • 查询频率较高。

不适合单独建立索引的情况包括:

  • 表中数据很少;

  • 字段很少用于查询;

  • 字段频繁修改;

  • 字段值重复比例很高;

  • 查询通常返回大部分数据。

这些仍然只是判断方向,最终需要执行计划和实际测试。


三十七、一次完整的 SQL 排查顺序

遇到接口查询慢时,可以先把问题缩小到 SQL。

第一步:拿到真实 SQL

需要包括真实参数,不只看 Mapper 或 Wrapper 代码。

例如:

SELECT
    id,
    username,
    phone,
    create_time
FROM sys_user
WHERE status = 1
  AND username LIKE '张%'
ORDER BY create_time DESC
LIMIT 20;

第二步:确认返回数据量

先执行:

SELECT COUNT(*)
FROM sys_user
WHERE status = 1
  AND username LIKE '张%';

第三步:查看执行计划

EXPLAIN
SELECT
    id,
    username,
    phone,
    create_time
FROM sys_user
WHERE status = 1
  AND username LIKE '张%'
ORDER BY create_time DESC
LIMIT 20;

主要查看:

type
key
rows
filtered
Extra

第四步:检查已有索引

SHOW INDEX FROM sys_user;

第五步:根据查询设计索引

可能考虑:

CREATE INDEX idx_user_status_name_time
ON sys_user(
    status,
    username,
    create_time
);

username LIKE '张%' 属于范围形式,后面的排序字段是否能继续利用,需要看实际执行计划。

第六步:重新测试

增加索引后再次执行 EXPLAIN,同时测试真实耗时。

第七步:检查副作用

确认:

  • 插入和更新是否变慢;

  • 索引占用空间是否可接受;

  • 是否与已有索引重复;

  • 其他主要 SQL 是否受到影响。


三十八、这一部分需要记住的内容

索引部分:

主键索引
二级索引
回表
覆盖索引
联合索引
最左前缀
索引失效或未被选择的情况

执行计划部分:

type
possible_keys
key
key_len
rows
filtered
Extra

事务部分:

ACID
隔离级别
脏读
不可重复读
幻读
MVCC

锁部分:

行锁
间隙锁
临键锁
悲观锁
乐观锁
死锁

目前比较重要的不是记住所有名词,而是形成固定的排查方式:

先确认 SQL 和参数
再看 EXPLAIN
检查索引是否合适
修改后重新验证
事务问题检查隔离级别和锁
并发修改检查受影响行数

MySQL 会根据统计信息和成本选择执行计划,所以很多规则都不能写成“只要这样就一定走索引”。

索引是否生效、排序是否使用索引、查询是否发生回表,最后都要以实际执行计划为准。

文章标签: MySQL、索引、EXPLAIN、事务、锁、SQL优化、学习笔记

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐