普通索引和唯一索引、count(*)为何慢?、order bay怎么工作?

课程: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 能用吗?)
    • 二、业务层计数方案对比
    • [四、不同 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(锁表)

流程:

  1. Server 层创建临时表 tmp_table
  2. 从表 A 逐行读出数据插入临时表
  3. 改名替换,删除旧表
sql 复制代码
ALTER TABLE A ENGINE=InnoDB;

缺点 :整个过程中表 A 不能有增删改,会阻塞业务

4.3 MySQL 5.6+:Online DDL(推荐)

引入了两个关键机制:

  • 临时文件 tmp_file:InnoDB 内部创建,不通过 Server 层
  • Row Log:记录重建期间所有的增删改操作,最后追加上去

流程:

  1. 扫描表 A 主键的所有数据页
  2. 生成 B+ 树写入临时文件
  3. 期间所有对 A 的 DML 记入 Row Log
  4. 数据拷贝完成后,把 Row Log 应用到临时文件
  5. 用临时文件替换 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;

三个核心原则

  1. server 层要什么就给什么 ------ count(字段) 就把字段值传上去
  2. InnoDB 只给必要的值 ------ count(1) 不取值,比 count(id) 快
  3. 优化器只优化了 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 执行流程

  1. 初始化 sort_buffer,放入 name, city, age 三个字段
  2. 从 city 索引找到第一个满足 city='杭州' 的主键 id(ID_X)
  3. 回主键索引取出整行,取 name/city/age 存入 sort_buffer
  4. 取下一个主键 id,重复 3~4,直到不满足条件(ID_Y)
  5. 对 sort_buffer 中的数据按 name 快速排序
  6. 取前 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),流程变成:

  1. sort_buffer 只放 nameid
  2. 从 city 索引取主键 → 回表取 name/id 存入 sort_buffer
  3. 对 sort_buffer 按 name 排序
  4. 遍历排序结果,取前 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 排序,最优解是用联合索引让数据天然有序,直接不排。


实践是检验真理的唯一标准

相关推荐
ClouGence2 小时前
2026 年 4 款数据库管理工具推荐:免费、开源、付费怎么选?
数据库·后端·开源
SelectDB2 小时前
招联金融数仓升级:Apache Doris 统一 OLAP 引擎实现降本提效实践
数据库
SelectDB2 小时前
腾讯音乐内容库 ES 替代:从 Elasticsearch 到 Apache Doris 统一搜索分析引擎实践
数据库
SelectDB2 小时前
快手湖仓一体升级:从 ClickHouse 到 Apache Doris 的湖仓分离向湖仓一体演进实践
数据库
今天AI了吗2 小时前
深度学习基础-Harness:从评估框架到工程落地
数据库·人工智能·sql·深度学习·机器学习
一叶飘零_sweeeet3 小时前
别等业务中断才补坑!RTO/RPO 核心逻辑与全场景灾备架构选型全攻略
数据库·架构·容灾备份
码农颜3 小时前
6.1.2 常⽤⽅法的问题
数据库·sql·oracle
量子炒饭大师3 小时前
MySQL 5.7 在 CentOS 7 环境安装:从清理 MariaDB 到初始化与完善配置
数据库·mysql·centos·mariadb
明志数科3 小时前
宇树科技IPO背后的产业逻辑:人形机器人从“讲故事“到“交数据“
运维·服务器·数据库
淼澄研学3 小时前
基于RAG与Milvus向量数据库的搜题长尾问题检索实操教程
数据库·milvus