课程:B站大学
记录学习极客时间团队MySQL45讲,进阶数据分析和数据处理
MySQL普通索引和唯一索引
- 为什么表数据删掉一半,表文件大小不变?
-
- 一、问题背景
- 二、核心前提
-
- [2.1 InnoDB 表由两部分组成](#2.1 InnoDB 表由两部分组成)
- [2.2 关键参数:`innodb_file_per_table`](#2.2 关键参数:
innodb_file_per_table)
- [三、为什么 DELETE 不释放磁盘空间?](#三、为什么 DELETE 不释放磁盘空间?)
-
- [3.1 记录级复用](#3.1 记录级复用)
- [3.2 页级复用](#3.2 页级复用)
- [3.3 哪些操作会产生"空洞"?](#3.3 哪些操作会产生"空洞"?)
- 四、正确解法:重建表
-
- [4.1 核心思路](#4.1 核心思路)
- [4.2 MySQL 5.5 及之前:非 Online(锁表)](#4.2 MySQL 5.5 及之前:非 Online(锁表))
- [4.3 MySQL 5.6+:Online DDL(推荐)](#4.3 MySQL 5.6+:Online DDL(推荐))
- [五、容易混淆:Online vs Inplace](#五、容易混淆:Online vs Inplace)
- mysql中的count(*)这么慢,我该怎么办?
-
- [一、为什么 InnoDB 的 count(*) 这么慢?](#一、为什么 InnoDB 的 count(*) 这么慢?)
-
- [1.1 引擎实现差异(核心概念)](#1.1 引擎实现差异(核心概念))
- [1.2 根本原因:MVCC(多版本并发控制)](#1.2 根本原因:MVCC(多版本并发控制))
- [1.3 InnoDB 其实已经"尽力优化"了](#1.3 InnoDB 其实已经"尽力优化"了)
- [1.4 `show table status` 能用吗?](#1.4
show table status能用吗?)
- 二、业务层计数方案对比
-
- [方案一:Redis 计数(常见但坑多)](#方案一:Redis 计数(常见但坑多))
- [方案二:MySQL 计数表(正解)](#方案二:MySQL 计数表(正解))
- [四、不同 count 用法性能排行(必背)](#四、不同 count 用法性能排行(必背))
- [mysql中的order by 是怎么工作的?](#mysql中的order by 是怎么工作的?)
-
- 一、问题背景
- 二、前置知识:索引结构
- 三、算法一:全字段排序(默认)
-
- [3.1 执行流程](#3.1 执行流程)
- [3.2 内存不够怎么办?](#3.2 内存不够怎么办?)
- [四、算法二:rowid 排序(单行太大时)](#四、算法二:rowid 排序(单行太大时))
-
- [4.1 为什么会切换算法?](#4.1 为什么会切换算法?)
- [4.2 执行流程变化](#4.2 执行流程变化)
- [4.3 两种算法对比](#4.3 两种算法对比)
- [五、终极优化:让 order by 根本不排序](#五、终极优化:让 order by 根本不排序)
-
- [5.1 方案一:联合索引(消除排序)](#5.1 方案一:联合索引(消除排序))
- [5.2 方案二:覆盖索引(连回表都省了)](#5.2 方案二:覆盖索引(连回表都省了))
- [5.3 三种方案一览](#5.3 三种方案一览)
- 六、如何验证排序算法?(测试必会)
- 实践是检验真理的唯一标准
为什么表数据删掉一半,表文件大小不变?
一、问题背景
经常有同学问:
我的数据库占用空间太大,把一个最大的表删掉了一半数据,怎么表文件的大小还是没变?
答案就藏在 InnoDB 的存储机制里。
二、核心前提
2.1 InnoDB 表由两部分组成
| 组成部分 | 存储位置 | 占用空间 |
|---|---|---|
| 表结构定义 | .frm 文件(8.0 前)/ 系统数据表(8.0+) |
极小 |
| 表数据 | .ibd 文件 |
主要空间 |
本文主要讨论表数据的空间回收。
2.2 关键参数:innodb_file_per_table
| 参数值 | 行为 | 删表后空间是否回收 |
|---|---|---|
OFF |
数据放共享表空间 | ❌ 不回收 |
ON |
每表独立 .ibd 文件 |
✅ drop table 直接删文件 |
- 从 MySQL 5.6.6 开始默认值就是
ON - 强烈建议保持 ON,否则表删了空间也拿不回来
三、为什么 DELETE 不释放磁盘空间?
一句话总结:DELETE 只是打标记,不是真删除,磁盘文件不会缩小。
3.1 记录级复用
InnoDB 删除一条记录(如 R4),只是把它标记为"可复用":
- 后续插入 ID 在 300~600 之间的记录 → 可以复用这个位置 ✅
- 后续插入 ID=800 的记录 → 无法复用这个位置 ❌
文件大小不变,只是内部多了一个"空洞"
3.2 页级复用
当一个数据页上所有记录都被删除时,整个页可以被复用给任何新数据。
但同样:磁盘文件不会缩小。
3.3 哪些操作会产生"空洞"?
| 操作 | 产生空洞的原因 |
|---|---|
| DELETE | 记录/页标记为可复用,但文件不缩 |
| 随机插入 | 数据页满 → 页分裂 → 旧页末尾留空洞 |
| UPDATE 索引列 | 等价于"删旧值 + 插新值" |
经过大量增删改的表,几乎一定有空洞,文件只增不减。
四、正确解法:重建表
4.1 核心思路
旧表A(有空洞) → 按主键顺序读出 → 写入新表B(无空洞) → 替换A
新表 B 的主键索引更紧凑,数据页利用率更高,自然就瘦下来了。
4.2 MySQL 5.5 及之前:非 Online(锁表)
流程:
- Server 层创建临时表
tmp_table - 从表 A 逐行读出数据插入临时表
- 改名替换,删除旧表
sql
ALTER TABLE A ENGINE=InnoDB;
缺点 :整个过程中表 A 不能有增删改,会阻塞业务。
4.3 MySQL 5.6+:Online DDL(推荐)
引入了两个关键机制:
- 临时文件
tmp_file:InnoDB 内部创建,不通过 Server 层 - Row Log:记录重建期间所有的增删改操作,最后追加上去
流程:
- 扫描表 A 主键的所有数据页
- 生成 B+ 树写入临时文件
- 期间所有对 A 的 DML 记入 Row Log
- 数据拷贝完成后,把 Row Log 应用到临时文件
- 用临时文件替换 A 的
.ibd文件
优势:
| 对比项 | 5.5 非Online | 5.6+ Online DDL |
|---|---|---|
| 拷贝数据期间能否DML | ❌ 不能 | ✅ 能 |
| 锁类型 | MDL 写锁全程持有 | 短暂写锁 → 退化读锁 |
| 对业务影响 | 大 | 极小 |
💡 启动时会获取 MDL 写锁,但拷贝数据前就退化成读锁,只为阻止其他线程同时做 DDL,不阻塞增删改。
五、容易混淆:Online vs Inplace
| 概念 | 含义 | 是否占临时空间 |
|---|---|---|
| Inplace | 操作在 InnoDB 内部完成,Server 层不建临时表 | 是(tmp_file) |
| Online | 操作期间允许 DML | 视情况 |
两者关系:
- ✅ Online DDL 一定是 Inplace 的
- ❌ Inplace 不一定 是 Online 的(如加全文索引
FULLTEXT、空间索引SPATIAL)
1TB 的表,磁盘只剩 200G,做 Inplace DDL 也会失败------因为 tmp_file 也要占空间!
mysql中的count(*)这么慢,我该怎么办?
一、为什么 InnoDB 的 count(*) 这么慢?
1.1 引擎实现差异(核心概念)
| 引擎 | count(*) 实现 |
|---|---|
| MyISAM | 总行数直接存在磁盘上,拿来就用,效率极高 |
| InnoDB | 必须一行一行从引擎读出来,累加计数 |
MyISAM 快,但不支持事务、不支持崩溃恢复、并发能力差,生产环境基本没人用了。
1.2 根本原因:MVCC(多版本并发控制)
InnoDB 默认隔离级别是 可重复读(RR),同一时刻不同事务看到的行数可能不一样。
看这个经典例子(假设表 t 有 10000 条记录,三个会话并行执行):
| 时刻 | 会话A | 会话B | 会话C |
|---|---|---|---|
| T1 | begin; |
||
| T2 | select count(*) → 10000 |
||
| T3 | insert 一行 |
insert 一行(独立语句) |
|
| T4 | begin; |
||
| T5 | select count(*) → 10002 |
select count(*) → 10001 |
|
| T6 | select count(*) → 10000 |
三个事务同时查,结果却各不相同!
如下图所示,三个会话在最后一个时刻同时查询,但因各自事务的可见性不同,得到了不同的行数:

原因 :InnoDB 必须判断「这一行对当前事务是否可见」,所以只能逐行扫描 + 可见性判断,无法像 MyISAM 一样直接返回一个数。
1.3 InnoDB 其实已经"尽力优化"了
InnoDB 是索引组织表:
- 主键索引:叶子节点 = 整行数据(体积大)
- 普通索引:叶子节点 = 主键值(体积小很多)
优化器会自动选最小的索引树遍历,逻辑不变,扫描数据量最少。
⚠️ 但再怎么优化,本质还是"全索引扫描",大表就是慢。
1.4 show table status 能用吗?
sql
show table status like 't';
输出里的 TABLE_ROWS:
- ✅ 返回快
- ❌ 是采样估算的
- ❌ 官方误差:40% ~ 50%
做分页"共多少页"都不敢用,更别说核心统计了。
二、业务层计数方案对比
方案一:Redis 计数(常见但坑多)
逻辑很简单:
- 插入一行 → Redis +1
- 删除一行 → Redis -1
坑1:丢更新
Redis 重启 / 持久化异常 → 计数和库里对不上,只能全表 count(*) 补救。
坑2:逻辑不精确(重点!)
即使 Redis 正常,也有时序问题。页面要同时显示"总数"和"最近100条记录":
场景A:先插数据,再更新 Redis
| 时刻 | 会话A(写) | 会话B(读页面) |
|---|---|---|
| T2 | 插入一行数据 R | |
| T3 | 读 Redis(旧值)+ 查最近100条(含新数据R)❌ | |
| T4 | Redis 计数 +1 |
场景B:先更新 Redis,再插数据
| 时刻 | 会话A(写) | 会话B(读页面) |
|---|---|---|
| T2 | Redis 计数 +1 | |
| T3 | 读 Redis(新值)+ 查最近100条(还没R)❌ | |
| T4 | 插入一行数据 R |
如下图所示,无论哪种顺序,都会出现数据不一致:

两种顺序都不对!因为 Redis + MySQL 是两个独立存储,不支持分布式事务,无法拿到一致性视图。
方案二:MySQL 计数表(正解)
单独建一张计数表:
sql
CREATE TABLE count_table (
table_name varchar(32) PRIMARY KEY,
row_count int NOT NULL DEFAULT 0
) ENGINE=InnoDB;
利用事务解决一致性问题
| 时刻 | 会话A | 会话B |
|---|---|---|
| T2 | begin; 计数表 +1 |
|
| T3 | begin; 读计数表 + 查最近100条; commit; |
|
| T4 | 插入业务数据; commit; |


✅ 会话B在T3时刻:
- 看不到未提交的 count+1(隔离性保证)
- 也看不到未提交的新数据
- 总数和列表永远逻辑一致!
💡 以子之矛攻子之盾:用 InnoDB 的事务特性,解决 InnoDB 带来的计数问题。
四、不同 count 用法性能排行(必背)
先记住一句话:count() 是"参数不为 NULL 就累加"
| 写法 | 引擎行为 | 性能 |
|---|---|---|
count(字段) |
读字段值,判断是否为NULL(允许null还要多判断一次) | 🐢 最慢 |
count(主键id) |
读id返回给server,判断非空累加 | 中 |
count(1) |
不读字段,直接放个"1"累加 | 快 |
count(*) |
专门优化,不取值,只按行累加 | ✅ 最快/等同count(1) |
性能差异详解
sql
-- ❌ 最慢:要读字段值,还可能要判断NULL
SELECT count(username) FROM t;
-- 🐢 较慢:要读主键id返回给server层
SELECT count(id) FROM t;
-- ⚡ 快:InnoDB不取值,server层自己填"1"
SELECT count(1) FROM t;
-- ✅ 最快/推荐:专门优化,不取值不判断
SELECT count(*) FROM t;
三个核心原则:
- server 层要什么就给什么 ------ count(字段) 就把字段值传上去
- InnoDB 只给必要的值 ------ count(1) 不取值,比 count(id) 快
- 优化器只优化了 count(*) ------ 其他"显而易见"的优化并没有做
官方推荐
sql
-- 无脑用这个,别再写 count(id) 了
SELECT count(*) FROM t;
按照效率排序:count(字段) < count(主键id) < count(1) ≈ count(*)
- 先插业务数据,再更新计数表(减少计数表行锁持有时间)
- 事务中把更新操作放到最后,减少锁等待
mysql中的order by 是怎么工作的?
一、问题背景
开发中一定会遇到这样的需求:
查询城市是"杭州"的所有人名字,按姓名排序,返回前1000个人的姓名、年龄。
SQL 很好写:
sql
SELECT city, name, age FROM t WHERE city='杭州' ORDER BY name LIMIT 1000;
但你知道这条语句到底是怎么执行的吗?排序是在内存还是磁盘?能不能不排序?
二、前置知识:索引结构
假设表结构如下,city 字段有二级索引:
sql
CREATE TABLE t (
id INT PRIMARY KEY,
city VARCHAR(16) NOT NULL,
name VARCHAR(16) NOT NULL,
age INT NOT NULL,
addr VARCHAR(128),
KEY idx_city (city)
) ENGINE=InnoDB;
执行 EXPLAIN 会看到关键提示:
Using filesort ------ 表示需要排序,MySQL 会给每个线程分配一块内存叫
sort_buffer。
city 索引的结构示意(满足 city='杭州' 的是 ID_X 到 ID_Y 这一区间):

三、算法一:全字段排序(默认)
3.1 执行流程
- 初始化
sort_buffer,放入name, city, age三个字段 - 从 city 索引找到第一个满足
city='杭州'的主键 id(ID_X) - 回主键索引取出整行,取 name/city/age 存入 sort_buffer
- 取下一个主键 id,重复 3~4,直到不满足条件(ID_Y)
- 对 sort_buffer 中的数据按 name 快速排序
- 取前 1000 行返回客户端
流程图如下:

3.2 内存不够怎么办?
关键参数 sort_buffer_size:MySQL 为排序开辟的内存大小。
| 数据量 vs sort_buffer_size | 行为 |
|---|---|
| 数据量 < sort_buffer_size | ✅ 内存中完成排序 |
| 数据量 > sort_buffer_size | ❌ 需要磁盘临时文件辅助(归并排序) |
如何确认是否用了临时文件?用 OPTIMIZER_TRACE:
sql
SET optimizer_trace='enabled=on';
SELECT city, name, age FROM t WHERE city='杭州' ORDER BY name LIMIT 1000;
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
关注三个字段:
| 字段 | 含义 |
|---|---|
number_of_tmp_files |
临时文件数,0=全内存排序 |
examined_rows |
参与排序的行数 |
sort_mode |
排序模式,如 <sort_key, packed_additional_fields> |
sort_mode中的packed_additional_fields表示对字符串做了紧凑处理------按实际长度分配空间,而不是按定义长度。
四、算法二:rowid 排序(单行太大时)
4.1 为什么会切换算法?
全字段排序有个隐患:如果查询返回的字段很多、单行很大,sort_buffer 能放下的行数就很少,要分成大量临时文件,排序性能急剧下降。
MySQL 用参数 max_length_for_sort_data 控制单行阈值(单位:字节)。
sql
SET max_length_for_sort_data = 16;
当单行长度超过这个值时,MySQL 切换为 rowid 排序。
4.2 执行流程变化
sort_buffer 里只放排序字段(name)+ 主键(id),流程变成:
- sort_buffer 只放
name和id - 从 city 索引取主键 → 回表取 name/id 存入 sort_buffer
- 对 sort_buffer 按 name 排序
- 遍历排序结果,取前 1000 行,再按 id 回表取 city/name/age 返回
流程图:

4.3 两种算法对比
| 对比项 | 全字段排序 | rowid 排序 |
|---|---|---|
| sort_buffer 内容 | 所有查询字段 | 仅排序字段 + id |
| 排序数据量 | 大 | 小(临时文件更少) |
| 回表次数 | 1次(取数据) | 2次(取数据 + 取结果) |
| 适用场景 | 字段少、行小 | 字段多、行大 |
💡 MySQL 的设计思想:内存够就多利用内存,减少磁盘访问。InnoDB 表默认优先全字段排序,因为 rowid 排序的二次回表代价更高。
五、终极优化:让 order by 根本不排序
上面两种算法都要排序。但排序成本高,能不能干脆不排?
可以! 只要保证从索引上取出来的数据天然有序,就不需要排序了。
5.1 方案一:联合索引(消除排序)
建一个 (city, name) 联合索引:
sql
ALTER TABLE t ADD INDEX idx_city_name (city, name);
在这个索引里,city 相同的记录按 name 递增排列,取出来就是有序的!
联合索引结构:

新流程:不需要临时表、不需要排序,边读边返回,读到 1000 条直接结束。
sql
-- EXPLAIN 结果中 Extra 没有 Using filesort 了!
-- 而且只需要扫描 1000 次,而不是 4000 次
5.2 方案二:覆盖索引(连回表都省了)
如果建 (city, name, age) 联合索引:
sql
ALTER TABLE t ADD INDEX idx_city_name_age (city, name, age);
索引上已经有查询需要的全部字段 ,连主键索引都不用回,Extra 显示 Using index,性能最佳。
sql
-- Extra: Using index ← 覆盖索引,全程最快
5.3 三种方案一览
| 方案 | 是否排序 | 是否回表 | 扫描行数 | 性能 |
|---|---|---|---|---|
| 无联合索引 | ✅ 要排序 | 1次回表 | 4000 | 🐢 最慢 |
| (city,name) 联合索引 | ❌ 不排序 | 1次回表 | 1000 | ⚡ 快 |
| (city,name,age) 覆盖索引 | ❌ 不排序 | 0次回表 | 1000 | 🚀 最快 |
六、如何验证排序算法?(测试必会)
sql
-- 1. 开启 optimizer_trace
SET optimizer_trace='enabled=on';
-- 2. 执行目标 SQL
SELECT city, name, age FROM t WHERE city='杭州' ORDER BY name LIMIT 1000;
-- 3. 查看执行细节
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
-- 4. 对比 sort_buffer 占用
SHOW VARIABLES LIKE 'sort_buffer_size';
SHOW VARIABLES LIKE 'max_length_for_sort_data';
关注 OPTIMIZER_TRACE 中的关键字段:
| 字段 | 你看什么 |
|---|---|
sort_mode |
<sort_key, packed_additional_fields> = 全字段;<sort_key, rowid> = rowid |
number_of_tmp_files |
0 = 内存排序;>0 = 用了磁盘临时文件 |
examined_rows |
实际参与排序的行数 |
order by 的排序成本很高:默认全字段排序,行太大切 rowid 排序,最优解是用联合索引让数据天然有序,直接不排。
实践是检验真理的唯一标准
