慢查询相关
一般出现慢查询的情况如下:
- 聚合查询
- 多表查询
- 表数据量过大查询
- 深度分页查询
表象:页面加载过慢、接口压测响应时间过长(超过1s)
定位慢查询的方式
开源工具定位
Arthas: 使用命令的方式来监控Sql的执行情况,可以监控已经上线的项目
Prometheus、Skywalking:Skywalking类似于nacos那种有个控制台的追踪界面来查看接口的执行情况,其中包含sql语句的执行情况,耗时多久
MySQL自带慢日志查询
仅在调试阶段开启,该功能会对MySQL性能有一定损耗
慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志如果要开启慢查询日志,需要在MySQL的配置文件(/etc/my.cnf)中配置如下信息:
# 开启MySQL慢日志查询开关
slow_query_log=1
# 设置慢日志的时间为2秒,SQL语句执行时间超过2秒,就会视为慢查询,记录慢查询日志long_query_time=2配置完毕之后,通过以下指令重新启动MySQL服务器进行测试,查看慢日志文件中记录的信息/var/lib/mysql/localhost-slow.log.
# Time: 2023-03-15T15:21:55.178101Z
# User@Host: root[root] @ localhost [::l] Id:# Query_time: 45.472697
Lock_time: 0.003903 Rows_sent: 10000000
use db01;
SET timestamp=1678893715;select * from tb_sku;分析SQL执行慢的原因
可以采用EXPLAIN 或者 DESC命令获取 MySQL执行 SELECT语句的状态信息
# 直接在select语句之前加上关键字explain/ desc
EXPLAIN SELECT 字段列表 FROM 表名 WHERE 条件;显示示例如下:

各个字段的信息如下:
- possible_key:当前sql可能会使用到的索引
- key:当前sql实际命中的索引
- key_len: 索引占用的大小
- Extra:额外的优化建议
通过字段Key与key_len来查看是否可能命中索引
| Extra | 含义 |
|---|---|
| Using where; Using Index | 查找使用了索引,需要的数据都在索引列中能找到,不需要回表查询数据 |
| Using index condition | 查找使用了索引,但是需要回表查询数据 |
发生 Using index condition 的情况,说明索引使用有优化空间, 可以对索引进行优化
图表中第五列type的信息表示的是sql的链接类型:
type 这条sql的连接的类型,性能由好到差为NULL、system、const、eq_ref、ref、range、index、all
这几种连接类型出现在 EXPLAIN 的 type 列,表示 MySQL 访问表的方式,性能从好到差如下:
- NULL:查询不需要访问表或索引,直接由优化器在优化阶段得出结果(如
SELECT 1、通过索引直接完成的聚合)。 - system:表只有一行记录(系统表),这是
const的特殊情况。 - const:通过
PRIMARY KEY或UNIQUE索引与常量比较,最多匹配一行,速度极快。 - eq_ref:关联查询时,对前表的每一行,后表使用
PRIMARY KEY或UNIQUE索引进行等值匹配,每行只匹配后表中的一行。 - ref:使用非唯一索引或索引的最左前缀进行等值匹配,可能匹配多行。
- range:索引范围扫描,对索引进行
=、<>、>、<、BETWEEN、IN等范围操作,返回匹配范围内的行。 - index:扫描整个索引树(如覆盖索引但需全扫),通常比
ALL快,因为索引文件较小。 - ALL:全表扫描,性能最差,需要遍历整张表找到匹配行。
一般如果Sql的类型是index 或者 all 就表示当前Sql语句性能较差需要优化。
分析Sql语句慢的原因
以采用MySQL自带的分析工具 EXPLAIN 为例:
- 通过key和key_len检查是否命中了索引|(索引|本身存在是否有失效的情况)
- 通过type字段查看sql是否有进一步的优化空间,是否存在全索引I扫描或全盘扫描
- 通过extra建议判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复
索引相关
什么是索引
- 索引(index)是帮助MySQL高效获取数据的数据结构(有序)
- 提高数据检索的效率,降低数据库的IO成本(不需要全表扫描)
- 通过索引列对数据进行排序,降低数据排序的成本,降低了CPU的消耗
1. 按数据结构分
- B+树索引:最常用,支持范围查询和排序,InnoDB 和 MyISAM 默认使用。
- 哈希索引:只支持等值查询,Memory 引擎支持,InnoDB 的自适应哈希索引自动使用。
- 全文索引:用于文本搜索,基于倒排索引。
- 空间索引(R树):用于地理空间数据。
2. 按物理存储分(InnoDB)
- 聚集索引:叶子节点存整行数据,一张表只能有一个(通常是主键)。
- 非聚集索引(二级索引):叶子节点存索引列+主键值,需要回表。
3. 按逻辑功能/约束分
- 主键索引:唯一且非空,属于聚集索引。
- 唯一索引:值必须唯一,允许 NULL。
- 普通索引:无唯一性约束,纯粹加速查询。
- 前缀索引:只对列值的前几个字符建索引,节省空间。
4. 按列数分
- 单列索引:仅包含一个列。
- 联合索引(复合索引):包含多个列,遵循最左前缀原则。
联合索引
联合索引的核心作用,遵循最左前缀原则来加速筛选
最左前缀原则,指的是在使用联合索引时,查询条件必须从索引的最左边列开始匹配,并且不能跳过中间的列,索引才能被有效利用。
可以这样理解
联合索引的排序结构就类似于一个嵌套的字典或多级分组:
- 先按
A排序/分组 - 在每个
A组内部,再按B排序/分组 - 在每个
(A, B)组内部,再按C排序
所以查询时必须先确定外层(A),才能在内层(B、C)中高效定位。跳过外层直接查内层,就等于失去整体的有序性,索引也就失效了。
这个“嵌套”的理解非常直观,和 B+ 树的实际存储结构是一致的。
举个例子
假设有一个联合索引:(A, B, C),相当于按 A 排序,A 相同再按 B 排序,B 相同再按 C 排序。
| 查询条件 | 是否用到索引 | 说明 |
|---|---|---|
WHERE A = 1 | ✅ 用到了 | 匹配最左列 A |
WHERE A = 1 AND B = 2 | ✅ 用到了 | 连续匹配 A 和 B |
WHERE A = 1 AND B = 2 AND C = 3 | ✅ 全部用到 | 完整匹配索引 |
WHERE B = 2 | ❌ 用不到 | 没有从最左列 A 开始 |
WHERE A = 1 AND C = 3 | ⚠️ 只用到 A | 跳过了中间的 B,C 部分无法使用 |
WHERE C = 3 | ❌ 用不到 | 最左列 A 缺失 |
本质原因
B+ 树是按照 (A, B, C) 的顺序从左到右排序的。
- A 优先排序,所以没有 A 的条件,B 列在全局上是无序的,无法二分查找。
- A 确定之后,B 才有序;A 和 B 都确定后,C 才有序。
因此,跳过前导列,后面的列在全局上就变成了无序,索引无法使用。
特殊情况:范围查询
一旦对某一列使用了范围查询(>、<、BETWEEN、LIKE 'xx%'),它右边的列就无法再走索引了。
WHERE A = 1 AND B > 2 AND C = 3
只有 A 和 B 走索引,C 不走,因为 B 是范围查询,破坏了有序性。
小结
联合索引像“楼层导航”,必须先确定 A(第一层),再找 B(第二层),最后找 C(第三层)。不能跳层,也不能在某一层使用范围查询后还指望后面的层继续精准导航。
索引的底层数据结构
MySQL的InnoDB引擎采用的B+树的数据结构来存储索引
- 阶数更多,路径更短
- 磁盘读写代价B+树更低,非叶子节点只存储指针,叶子阶段存储数据
- B+树便于扫库和区间查询,叶子节点是一个双向链表
B+树索引
- 二叉树缺点:顺序插入时,会形成一个链表,查询性能大大降低。大数据量情况下,层级较深,检索速度慢。
- 红黑树:虽然能保持平衡但是仍然是二叉树,每个节点只能有两个分支,大数据量情况下,层级较深,检索速度慢。
- B-Tree(B树):每个节点可以有多个指针,所以整体来说层级是较浅的,大数据量的情况下搜索的时候自然比二叉树和红黑树要短
- B+Tree(B加树):在B树的基础上,非叶子节点只存储指针不存储数据,数据都在叶子节点中,同时相邻叶子节点都包含相互指向的指针。
- B树与B+树对比: ①:磁盘读写代价B+树更低;②:查询效率B+树更加稳定;③:B+树便于扫库和区间查询
所以使用B+树来进行索引的构建,我们先来看B-Tree,B树的结构:
B-Tree(多路平衡查找树)
以一颗最大度数(max-degree)为5(5阶)的b-tree为例(每个节点最多存储4个key,5个指针):

B-Tree在进行存储时,数据小的在左边,大的在右边,当当前的数据数量超过最大度数-1时,在B-Tree中最大度数指的是每个节点的指针数量,每个节点最大数据数量为最大度数减一,此案例中为4个,当数据为五个时,最中间的数据会向上分裂,以保证符合最大度数为5的规则。
B+Tree
MySQL索引数据结构对经典的B+Tree进行了优化。在原B+Tree的基础上,增加一个指向相邻叶子节点的链表指针,就形成了带有顺序指针的B+Tree,提高区间访问的性能。

Hash索引
哈希索引就是采用一定的hash算法,将键值换算成新的hash值,映射到对应的槽位上,然后存储在hash表中。 如果两个(或多个)键值,映射到一个相同的槽位上,他们就产生了hash冲突(也称为hash碰撞),可以通过链表来解决。
理解有一部分是对的,但有几个关键点存在误解。
1. 计算哈希的对象,是索引列的值,而不是整行数据
哈希索引不会对“整行数据”计算哈希值。它只针对你创建索引的那个字段(或几个字段) 的值来计算哈希。
比如在 name 列上建哈希索引,只会对每个 name 值(如 'Alice')做哈希,不会把这一行所有列都搅在一起算。
2. 桶内存放的不是“该行的哈希值”
桶里实际存放的是:
- 索引列的原始值(用于冲突时比较,如
'Alice') - 指向实际数据行的指针(如行号、页地址等)
如果只存哈希值,发生冲突时根本无法区分哪个是你要的记录,因为不同键的哈希值可能相同,必须用原始值比对。
有些实现会同时存哈希值和原始键,但绝不可能只存哈希值。
3. 查询时不是“匹配哈希值,再反向计算出列值”
哈希是单向的,不可逆,你无法从哈希值反推出原值。
正确查询过程是:
- 用你要查的键(比如
'Alice')计算哈希值,定位到桶。 - 在桶内的链表(溢出链)里,逐条比较存储的原始键值(
'Alice'和桶里的'Alice'做字符串比较)。 - 匹配成功后,通过该条目里的指针去读取完整数据行。
也就是说,匹配阶段是直接用原始列值进行比较,而不是比对哈希值后再“反向计算”。
4. 你的理解中正确的部分
- 冲突时用链表链在后面 —— 这确实是链地址法的思想,数据库里常表现为溢出页链表。
- 等值查询很快 —— 是的,通过哈希直接定位桶,时间复杂度 O(1),非常适合
WHERE name = 'Alice'这种精确匹配。但不支持范围查询(>、BETWEEN)。
总结一下正确流程(以 name 列为例):
- 插入:
对'Alice'做哈希 → 映射到桶 3 → 在桶 3 的链表中存入('Alice', 指向该行数据的指针)。 - 查询
WHERE name = 'Alice':
对'Alice'做哈希 → 定位桶 3 → 在链表中遍历,对每个条目检查存储的原始值是否等于'Alice'→ 匹配后通过指针取出整行数据。
整个过程没有对哈希值本身的匹配,也没有反向计算。
哈希索引的构建过程主要分为以下几步:
确定哈希函数
选择一个能将索引键均匀分布到不同桶中的哈希函数,减少冲突。初始化桶结构
在存储空间(内存或磁盘)中创建一定数量的桶(Bucket),每个桶可以看作一个存储单元,通常包含一个链表或数组,用于存放指向实际数据记录的指针(或记录本身)。插入索引项
对表中的每条记录,用其索引列的值计算哈希值,再通过模运算(hash(key) % 桶数)映射到对应的桶编号。然后将该记录的指针存入对应桶中。处理冲突
当不同键映射到同一桶时,采用链地址法:每个桶维护一个链表(或溢出块链),冲突的项依次链接在后面。数据库实现中常使用溢出页来存储超出桶容量的记录。动态扩容(可选)
随着数据量增长,如果桶的填充度过高,会触发桶分裂和重哈希。常见方式有可扩展哈希(分裂特定桶,使用全局/局部深度)或线性哈希,动态增加桶数,并将部分记录重新分配到新桶。
最终形成一个通过哈希值直接定位桶,再在桶内进行小范围顺序查找的结构,适用于等值查询,但不支持范围查询。
Hash索引特点:
1.Hash索引只能用于对等比较(=,in),不持范围查询(between,>,<,…) 2.无法利用索引完成排序操作 3.查询效率高,通常只需要一次检索就可以了(不出现哈希碰撞的前提下),效率通常要高于B+tree索引
聚集/簇(cu)索引与非聚集/簇(cu)索引(二级索引)
| 分类 | 含义 | 特点 |
|---|---|---|
| 聚集索引 | 将数据与索引放到一起,索引结构的叶子结点保存了行数据 | 必须有,而且只有一个 |
| 二级索引(非聚集索引) | 将数据与索引分开存储,索引结构的叶子结点关联的是对应的主键(索引与主键相关联) | 可以存在多个 |
聚集索引的选取规则: ➢ 如果存在主键,主键索引就是聚集索引。 ➢ 如果不存在主键,将使用第一个唯一(UNIQUE)索引作为聚集索引。 ➢ 如果表没有主键,或没有合适的唯一索引,则InnoDB会自动生成一个rowid作为隐藏的聚集索引
索引及回表查询流程
完全正确,你的理解非常到位。
一个具体例子帮你巩固
假设有一张表:
CREATE TABLE user (
id INT PRIMARY KEY, -- 主键
name VARCHAR(50),
age INT,
KEY idx_name (name) -- name 列上的二级索引
);表中有数据:
| id (主键) | name | age |
|---|---|---|
| 1 | 张三 | 20 |
| 2 | 李四 | 25 |
| 3 | 王五 | 22 |
idx_name 二级索引的 B+ 树叶子节点存储的内容大致是:
[name列的值] → [对应的主键值]
"张三" → 1
"李四" → 2
"王五" → 3你问到点上了,B+树本质上是一棵按照索引列的值从小到大排列的多路搜索树,查找的过程就是不断地和节点的“分界值”比较大小,逐层缩小范围,最终定位到叶子节点。
以 name 列的二级索引为例,一步步拆解在B+树中查找 "张三" 的完整过程。
0. 前提:字符串怎么比大小?
在 MySQL 中,字符串的比较规则由**字符集和排序规则(Collation)**决定。
比如 utf8mb4_general_ci,它会按照字典序比较,忽略大小写。对于中文,通常按 UTF-8 编码的字节序比较,也就是按拼音大致排序。
实际比较时,MySQL 会把字符串转成对应的二进制序列再逐字节比较。
简单说:“李四”比“张三”小,因为“李”字的编码值小于“张”字。
1. B+树的结构(以索引 name 为例)
内部节点(Internal Node)
┌─────────────────────┐
│ k1 │ k2 │ k3 │ ← 这些是索引列的值,作为“分界键”
│ P1 │ P2 │ P3 │ P4 ← 指向子节点的指针
└─────────────────────┘- 每个内部节点包含多个键值(比如
"李四"、"王五")和指向下一层节点的指针。 - 对于某个键
k,其左边的指针指向所有≤ k的子节点,右边的指针指向> k的子节点。 - 叶子节点包含完整的索引数据(在这里是
name值 + 对应主键),且叶子节点之间通过双向链表连接,保证有序。
2. 查找 "张三" 的具体流程
假设这棵 B+树的根节点有 3 个分界键:"陈二"、"李四"、"王五"。
第一步:从根节点开始
在根节点的内部,键值按顺序排列:
[ "陈二" | "李四" | "王五" ]现在用 "张三" 跟这些键比较大小:
"张三"和"陈二"比较:"张" > "陈",继续往右看。"张三"和"李四"比较:"张" > "李",继续往右看。"张三"和"王五"比较:"张" < "王",找到了范围!
因为 "张三" 的值在 "李四" 和 "王五" 之间,所以应该沿着这两个键之间的指针(通常是指向 "李四" 键右边的指针,或者 "王五" 键左边的指针)去下一层节点。
实际 MySQL 内部节点页格式中,每个键对应一个子节点指针,且最后一个指针指向大于最大键值的记录。所以确切说:键
"李四"对应一个指针指向所有键 ≤"李四"的子节点;而"王五"对应的指针指向键在("李四", "王五"]范围的子节点。因为我们要找"张三",它落入("李四", "王五"]范围,所以跟随"王五"键左边的指针继续查找。
第二步:进入下一层内部节点
假设下个节点包含的键为:"刘七"、"孙八"。
再次比较:
"张三"vs"刘七":"张" > "刘",继续。"张三"vs"孙八":"张" < "孙",落入("刘七", "孙八"]区间。
于是沿着 "孙八" 键左边的指针,进入叶子节点层。
第三步:到达叶子节点
叶子节点不再是索引键和指针,而是一条条记录:
[ "刘七" → pk=7 ] [ "张三" → pk=1 ] [ "张三" → pk=9 ] [ "孙八" → pk=8 ] ...叶子节点本身也是按键有序排列的。在叶子节点内部,同样可以用二分查找快速定位第一个匹配的 "张三"。因为可能存在多个同名 "张三",它们会连续存放。找到第一个后,可以直接顺序读取后面的记录,直到值不再等于 "张三"。
3. 整个查找过程的时间复杂度
- 树的高度通常在 3~4 层,意味着只需要 3~4 次磁盘 I/O 就能定位到叶子节点。
- 每个节点内部使用二分查找,速度极快。
- 叶子节点可能还包含一些顺序扫描,但范围很小。
这就是 B+树作为索引结构高效的原因:大量数据被有序组织,每次查询只需遍历很少的节点。
4. 总结图示(简化版)
根节点: [ 陈二, 李四, 王五 ]
↓ ↓ ↓
内节点: ... [刘七, 孙八] ...
↓
叶子节点: ... [刘七] [张三][张三] [孙八] ...
↑
找到!所以,"张三" 在 B+树上的位置,就是通过不断和内部节点的分界键比较大小,逐层缩小搜索区间来确定的。字符串比较用的是 MySQL 设定的排序规则,对中文就是按编码或拼音顺序自然比较。
回表查询的完整流程
执行 SELECT * FROM user WHERE name = '张三':
在二级索引
idx_name中查找
通过 B+ 树定位到"张三"对应的记录,拿到主键值1。回表
拿着主键值1,去聚集索引(主键索引)的 B+ 树中查找,因为聚集索引的叶子节点存着整行数据,于是读出(1, 张三, 20)这一整行。
什么时候不需要回表?
如果查询的列都在二级索引中,就不需要回表,称为覆盖索引。
比如 SELECT id, name FROM user WHERE name = '张三':
id是主键,name是索引列,它们都已在二级索引的叶子节点中。- 直接从二级索引拿到主键值
1和"张三"就返回,不再回表,效率更高。
所以你总结的流程完全正确:二级索引 = 索引列 + 主键值 → 拿主键去聚集索引捞整行数据(回表)。
MySQL的超大分页处理
在数据量比较大时,如果进行limit分页查询,在查询时,越往后,分页查询效率越低。
mysql> select * from tb_sku limit 0,10;
10 rows in set (0.00 sec) // 耗时零秒
mysql> select * from tb_sku limit 9000000,10;
10 rows in set (11.05 sec) // 耗时十一秒因为,当在进行分页查询时,如果执行limit9000000,10,此时需要MySQL排序前9000010记录,仅仅返回9000000-9000010的记录,其他记录丢弃,查询排序的代价非常大。
优化思路:
一般分页查询时,通过创建 覆盖索引能够比较好地提高性能,可以通过覆盖索引加子查询形式进行优化
select * from tb_sku t,
(select id from tb_sku order by id limit 9000000,10) a where t.id = a.id;思路是: 依据select id from tb_sku order by id limit 9000000,10进行覆盖索引查询,查出所需要的id集合,速度较快,然后再依据子查询,t.id=a.id的方式做一个联表查询得到结果(优化后一般为六秒七秒多)
索引创建的原则有哪些
- 针对于数据量较大,且查询比较频繁的表建立索引1。单表超过10万数据(增加用户体验)
- 针对于常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引。
- 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。
- 如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。
- 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提查询效率。
- 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率。
- 如果索引I列不能存储NULL值,请在创建表时使用NOTNULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。
索引数量限度的判断标准
索引不是越多越好。限度可以从下面几个实用角度判断:
- 写操作成本:索引越多,INSERT/UPDATE/DELETE 都要同步维护索引,写入性能会下降。如果一张表写入频率远高于查询频率,索引就该尽量精简。
- 磁盘和内存空间:每个索引都占用存储空间,且 MySQL 会尽量把索引缓存到内存(Buffer Pool),索引太多会把真正有用的热数据挤出去。
- 优化器选择负担:索引太多,MySQL 优化器在选择执行计划时也有更大的选择成本,可能会选错索引,反而更慢。
- 看慢查询日志和实际业务 SQL:只为真正高频、慢的查询创建索引,避免无用的"预判索引"。用 EXPLAIN 检查执行计划,确认索引是否真的被用到。
- 查冗余索引,定期清理:比如已经有了联合索引 (A,B),再单独建 (A) 就是冗余的;或者某个索引从来没被使用过(可以通过 MySQL 的索引监控查看)。
一句话总结:索引够用就好,按最频繁、最慢的查询来建,定期清理无用和重复的索引
索引失效的情况
违反联合索引的最左前缀匹配原则
违反最左前缀法则
如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始,并且不跳过索引中的列。匹配最左前缀法则,走索引:
在联合索引中(索引包含多个字段A,B,C),只要索引包含的字段,在查询时使用了范围查询,该字段右侧的索引字段便不能命中索引。
查询条件为
where A = 1 and B > 2 and C = 2由于B字段使用范围查询,C字段便不能命中索引。若是
where A > 1 and B = 3 and C = 2则后面的B,C索引都会失效
索引列上不能使用运算操作。
字符串不加单引号,可能造成索引失效例如字段status类型为string,
查询时使用
where status = '0'索引正常,若是
where status = 0索引失效由于,在查询时,没有对字符串加单引号,MySQL的查询优化器,会自动的进行类型转换,造成索引失效。
模糊查询可能造成索引失效。以%开头的Like模糊查询,索引失效。如果仅仅是尾部模糊匹配,索引不会失效。如果是头部模糊匹配,索引失效
SQL的优化
表的设计优化
- 比如设置合适的数值(tinyintint bigint),要根据实际情况选择
- 比如设置合适的字符串类型(char和varchar)char定长效率高,Varchar可变长度,效率稍低
- 主要是依据要存储的数据进行选择对应合适的数据类型,避免分配太多无用的空间
索引优化
参考优化创建原则和索引失效
SQL语句优化
SELECT语句务必指明字段名称(避免直接使用select,否则索引大多都是回表查询,写明字段可以充分发挥覆盖索引的价值*)
SQL语句要避免造成索引失效的写法
尽量用union all代替union,union会多一次过滤,效率低避免在where子句中对字段进行表达式操作
union all作用是把两条语句查询的结果合并到一起展示,即便有重复的也会一并展示
union则是把语句查询结果合并到一起并过滤掉重复的数据,多一次过滤效率稍低
- sql
select *from t_user where id > 2 union all | union select *from t_user where id < 5
Join优化能用inner join 就不用left join right join,如必须使用一定要以小表为驱动,
内连接会对两个表进行优化,优先把小表放到外边,把大表放到里边。left join 或 right join,不会重新调整顺序
主从复制、读写分离
如果数据库的使用场景读的操作比较多的时候,为了避免写的操作所造成的性能影响可以采用读写分离的架构。
读写分离解决的是,数据库的写入,影响了查询的效率。
搭建读写分离的主库和从库,写操作只访问主库节点进行写的操作,写完后把数据同步到从库,读操作直接访问从节点只进行读。

事务相关
事务是一组操作的集合,它是一个不可分割的工作单位,事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作要么同时成功,要么同时失败。避免数据错乱。
事务特性
- 原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败。
- 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
- 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
- 持久性(Durabiity):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。
事务隔离
并发事务造成的问题
| 问题 | 描述 |
|---|---|
| 脏读 | 一个事务读到另一个事务还没有提交的数据 |
| 不可重复读 | 一个事务先后读取同一条记录,两次读取结果不一致 |
| 幻读 | 事务按条件查询数据,没有对应的数据行,但是插入数据时发现数据已经存在,出现幻觉一样(前提是解决了不可重复读的问题) |
事务隔离机制解决并发事务问题
好的,我们以一个转账系统为例,从最差的“裸奔”状态开始,逐级递进,看看每个级别解决了什么,又留下了什么坑。
第一层:读未提交(Read Uncommitted)→ 出现脏读
- 场景:事务A给张三转账1000元,还没提交,事务B此时查询张三的余额,看到了已增加的钱。
- 问题(脏读):事务A操作时出错,回滚了,1000元没转成。事务B读到的是“假”数据,基于这个数据做了错误决策。
- 解决:不能再读未提交了,升级。
第二层:读已提交(Read Committed)→ 解决脏读,出现不可重复读
- 升级:规定只能读已提交的数据,事务B不会再看到未提交的1000元。脏读解决。
- 新问题(不可重复读):事务B在一个操作中需要两次查询张三余额。第一次查是1000元。这时事务A给张三转了500元并提交。事务B第二次查询,余额变成了1500元。
- 后果:同一个事务B内,两次读取同一行数据,结果却不同,就像照镜子,第一次是张三,第二次变成李四。对需要数据一致性快照的场景(如生成账单)来说,这是灾难。
- 解决:读已提交不够,需要更强的内部一致性,升级。
第三层:可重复读(Repeatable Read)→ 解决不可重复读,出现幻读
升级:事务B开始时会创建一个快照,之后所有读操作都基于这个快照。即使事务A修改并提交了数据,事务B读到的始终是自己快照里的1000元。不可重复读解决。
新问题(幻读):快照只锁住了“已有行”的数据,没锁住“新行”。事务B第一次查询“所有余额大于500的用户”,返回10条。此时事务A新增了一个余额800的用户并提交。事务B再次执行同一条查询,返回11条,凭空“幻”出了一行。
注意:MySQL的InnoDB通过间隙锁在很大程度上解决了幻读,但概念上,纯快照读仍可能存在。
间隙锁,锁的不是某一行数据,而是两条记录之间的“空隙”。
为什么需要间隙锁?
幻读的本质是:当前事务读了一次,另一个事务在中间插入了一条新数据并提交,导致当前事务再读一次时,多出了之前不存在的行。
要防止插入新行,光锁住现有行是不够的,必须把“新行可能插进来的位置”也锁住。这个位置就是间隙。
举个例子
假设表中有 id 列,现有数据:
id: 1, 5, 10这些记录把整列数据分成了几个区间(间隙):
(-∞, 1) (1, 5) (5, 10) (10, +∞)当你执行
SELECT * FROM table WHERE id > 3 AND id < 8 FOR UPDATE时:- 行锁:锁住 id=5 这一行,别人不能修改或删除它。
- 间隙锁:锁住
(1, 5)和(5, 10)两个间隙,别人不能在这些间隙中插入新数据(比如无法插入 id=4 或 id=7)。
这样,你再次执行相同的范围查询时,结果行数不会变,幻读就被阻止了。
一句话总结
行锁锁住的是现有记录,间隙锁锁住的是记录之间的空白区间。 把记录和间隙一起锁住(Next-Key Lock),就能防止别人在你查询范围内插入新数据,从而解决幻读。
解决:要绝对安全,只能升级到最高级别。
第四层:串行化(Serializable)→ 解决所有问题,但性能大跌
- 升级:所有事务必须排队执行,一个接一个,就像只有一个柜台,所有顾客必须排成一队。
- 效果:彻底杜绝了脏读、不可重复读、幻读,因为同时只会有一个事务在操作数据。
- 代价:系统并发能力极差,就像把所有并行通道炸成独木桥。
最后总结成一句话:Read Uncommitted(裸奔)→ Read Committed(只看见提交的,但前后可能不一致)→ Repeatable Read(事务内一致,但可能有幻觉)→ Serializable(完全排队,绝对安全但最慢)。MySQL默认站在Repeatable Read这一层,是并发性能和数据一致性的黄金平衡点。
MySQL默认是可重复读,解决脏读与不可重复读,但是解决不了幻读,不过通过间隙锁可以解决幻读问题
undo log 和 redo log的区别
两个分别是不同类型的日志文件
- 缓冲池(buffer pool):主内存中的一个区域,里面可以缓存磁盘上经常操作的真实数据,在执行增删改查操作时,先操作缓冲池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),以一定频率刷新到磁盘,从而减少磁盘IO,加快处理速度
- 数据页(page):是InnoDB存储引擎磁盘管理的最小单元,每个页的大小默认为16KB。页中存储的是行数据
redo log(重做日志)—— 保证持久性
作用:保证事务一旦提交,数据就永远不会丢,即使数据库立刻宕机。
为什么需要它:InnoDB 操作数据时,不是直接改磁盘中的数据文件,而是先改内存中的数据页。如果每次修改都直接进行磁盘IO,性能极差。但只改内存,万一宕机,未存入盘的修改就丢了。redo log 解决了这个矛盾。
做了什么:事务执行时,InnoDB 会把“对哪个表空间、哪个页、哪个偏移量做了什么修改”记录成 redo log,并且先写到磁盘上的日志文件。即使数据页还没同步到磁盘,只要 redo log 已持久化,宕机重启后就能“重做”这些操作,恢复数据。
关键机制:这就是著名的 WAL(Write-Ahead Logging,预写日志)——先写日志,再改数据页。redo log 是顺序写,速度极快。
这里的WAL指的是:把“事务提交时立刻去磁盘各处执行随机写”这个慢操作,替换成“提交时先把指令顺序记到日志里(极快)”,真正的数据修改交给后台异步、批量、闲时去处理。这时可以执行其他操作,让这个读取日志文件内容写到磁盘的步骤异步处理
这就是 WAL 的核心思想:用顺序日志的极速写入,换取事务提交的即时响应,把随机写的代价推迟并合并执行。
一句话比喻:redo log 就像工地的施工日志。每做完一步,先在日志上记下来(持久化),即使突然断电,复工时照着日志重做一遍,工程不会丢。
redolog流程
事务提交时,先把操作顺序写入 redo log 并存到磁盘(保证持久性),数据修改已留在内存,后续直接使用内存数据,磁盘上的数据文件稍后由后台异步更新(前面先写操作到日志文件并存入磁盘,操作时直接使用内存中改好的数据,后续磁盘自己再进行数据更新/同步,这是WAL的思想)。redo log 只在崩溃恢复时才被使用。
undo log(事务回滚)—— 保证一致和原子性
回滚日志,用于记录数据被修改前的信息。
作用包含两个:提供回滚 和 MVCC(多版本并发控制)。undolog和redolog记录物理日志不一样,它是逻辑日志。
- 可以认为当delete一条记录时,undolog中会记录一条对应的insert记录,反之亦然,
- 当update一条记录时,它记录一条对应相反的update记录。当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。
总结
- redolog:记录的是数据页的物理变化,服务宕机可用来同步数据
- undolog:记录的是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据
- redolog保证了事务的持久性,undolog保证了事务的原子性和一致性
事务的隔离性如何保证
锁
排他锁(如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁)update,insert执行时都会执行排他锁
MVCC
全称 Multi-Version Concurrency Control,多版本并发控制(多个事务并发执行)。指维护一个数据的多个版本,使得读写操作没有冲突
MVCC的具体实现主要依赖于数据库中的隐式字段,undo log日志,readView
隐藏字段
| 隐藏字段 | 含义 |
|---|---|
| DB_TRX_ID | 最近修改事务ID,记录插入这条记录或最后一次修改该记录的事务ID。ID默认从一开始,执行一次事务便加一 |
| DB_ROLL_PTR | 回滚指针,指向这条记录的上一个版本,用于配合undolog,指向上一个版本。 |
| DB_ROW_ID | 隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段。 |
undo log 日志
回滚日志,在insert、update、delete的时候产生的便于数据回滚的日志。
- 当insert的时候,产生的undolog日志只在回滚时需要,在事务提交后,可被立即删除。
- 而update、delete的时候,产生的undolog日志不仅在回滚时需要,mvcc版本访问也需要,不会立即被删除
MVCC实现原理
依据undo log版本链实现对各个数据版本的维护。

流程如下:
- 最初DB_TRX_ID为零,DB_ROLL_PTR为空
- 执行事务2之后,DB_TRX_ID = 1,DB_ROLL_PTR指向地址0x00001,就是undo log中保存的事务2修改之前的数据值,也就是指向事务2执行之前的旧版本数据地址为0x00001
- 同理执行事务3之后,undo log中记录事务3执行之前的旧版本数据地址为0x00002,DB_TRX_ID = 2,默认指向上一次执行的事务ID值,DB_ROLL_PTR = 0x00002,指向事务3执行之前的旧数据
- 事务4同上。
readview
ReadView(读视图)是快照读SQL执行时MVCC提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id。
当前读
读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们日常的操作,如:select…lock in share mode(共享锁),select…for update、update、insert、delete(排他锁)都是一种当前读。
完整流程
执行当前读(如
SELECT ... FOR UPDATE):- 读取该行的最新已提交版本(不管这个版本是别人刚提交的,还是更早的)。
- 立即对该行加锁(排他锁或共享锁)。
其他事务尝试修改:
- 如果它们要修改同一行,需要申请排他锁,由于你已持有锁,它们会被阻塞,必须排队等待。
- 多个事务可以同时持有同一行的共享锁,大家一起读,互不影响。
- 只要有任何一个事务持有共享锁,其他事务的修改操作(需要排他锁)就会被阻塞,直到所有共享锁释放。
- 同样,如果有一个事务持有排他锁,其他事务无论是读(申请共享锁)还是写(申请排他锁),都会被阻塞。
总结:
当前事务用共享锁读取数据 → 允许其他事务也来读 → 但阻止其他事务来改 → 想改的事务会被阻塞,直到共享锁释放。
你的事务继续运行:
- 可能执行其他 SQL,甚至多次读取同一行,只要你还没提交,锁就一直存在,读到的数据也不会变(其他事务无法进来改)。
你的事务提交(COMMIT)或回滚(ROLLBACK):
- 锁被释放。
- 此时,那些被阻塞的“其他事务”才有机会获得锁,然后读取你提交后的最新数据,再进行修改。
当前读 = 读取最新已提交数据 + 加锁阻止并发修改。 它永远不会主动去读“旧数据”,而是通过锁机制,要么读到别人已提交的最新结果,要么自己读完后锁住不让别人改。
执行 SELECT ... FOR UPDATE → 读取数据 → 加锁 → 数据返回给客户端 → 锁继续持有 → 事务 COMMIT/ROLLBACK → 释放锁 ✅
InnoDB 的行锁是事务级别的,不是语句级别的。锁从加锁的那一刻起,一直持续到事务结束(COMMIT/ROLLBACK),而不是读完数据就释放
快照读
简单的select(不加锁)就是快照读,快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读。
- Read Committed:每次select,都生成一个快照读。
- RepeatableRead:开启事务后第一个select语句才是快照读的地方。
快照读的原理
InnoDB 通过 MVCC(多版本并发控制) 实现快照读:
- 每一行数据都有两个隐藏列:
trx_id(最近修改它的事务ID)和roll_pointer(指向 undo log 的指针)。 - undo log 里保留着数据修改前的旧版本,多个旧版本串成一条版本链。
- 事务在开始时(RR)或每次查询时(RC)会生成一个 ReadView,用来判断版本链中哪些版本对当前事务可见。
- 快照读就顺着版本链,找到第一个在 ReadView 中可见的版本,读的就是它。
不同隔离级别下的行为
| 隔离级别 | 快照读的行为 |
|---|---|
| 读已提交(RC) | 每次查询都生成新的 ReadView,所以每次快照读都能看到其他事务已提交的最新修改 |
| 可重复读(RR) | 事务开始时生成一个 ReadView,事务内后续所有快照读都复用这个 ReadView,所以即使其他事务已提交修改,也看不到,保证可重复读 |
ReadView四个核心字段
| 字段 | 含义 |
|---|---|
| m_ids | 当前活跃事务的ID集合 |
| min_trx_id | 最小活跃事务ID |
| max_trx_id | 预分配事务ID,当前最大事务ID+1 |
| creator_trx_id | ReadView创建者的事务ID |

上图是四个并发执行的事务,横向代表同一时间内各个事务的执行状态。
- 当事务5进行读数据时,他就会创建一个ReadView,所以creator_trx_id = 5
- 事务5执行第一次读时,只有事务2已经提交了,所以m_ids为3,4,5,min_trx_id = 3
- 此时最大的事务ID为5,所以max_trx_id = 6。
不同事务对于创建出的ReadView的访问规则
RC读已提交隔离级别下,事务中执行快照读生成的Readview

MVCC流程
MVCC(多版本并发控制)执行流程总结
1. 前置基础:隐藏列与 undo log 版本链
- InnoDB 每行数据有两个隐藏列:
DB_TRX_ID(最近修改该行的事务 ID)、DB_ROLL_PTR(指向 undo log 的指针)。 - 当对一行数据进行更新时,会先将旧版本数据拷贝到 undo log,再将新数据写入数据页,新行的
DB_ROLL_PTR指向 undo log 中的旧版本,形成一条版本链(最新版本在最前面,旧版本依次往后)。
2. ReadView(读视图)的生成
快照读执行时,事务会生成一个
ReadView,包含四个关键信息:m_ids:当前系统中活跃(未提交)的事务 ID 列表min_trx_id:活跃列表中最小的事务 IDmax_trx_id:系统下一个将要分配的事务 ID(即当前最大事务 ID + 1)creator_trx_id:当前事务自己的 ID
RC(读已提交):每次查询都生成新的 ReadView
RR(可重复读):只在整个事务开始时生成一次 ReadView
3. 可见性判断(顺着版本链逐个判断)
拿到一行数据的最新版本,用它的 DB_TRX_ID 与 ReadView 比较:
- 若
DB_TRX_ID == creator_trx_id→ 可见(自己改的) - 若
DB_TRX_ID < min_trx_id→ 可见(修改它的事务在 ReadView 创建前已提交) - 若
DB_TRX_ID >= max_trx_id→ 不可见(修改它的事务在 ReadView 创建后才开始) - 若
min_trx_id <= DB_TRX_ID < max_trx_id:- 检查
DB_TRX_ID是否在m_ids中
→ 在:事务未提交,不可见
→ 不在:事务已提交,可见
- 检查
如果当前版本不可见,就通过 DB_ROLL_PTR 找到 undo log 中的前一个版本,重复上述判断,直到找到可见版本为止。
4. 两种读的路径区别
- 快照读(普通 SELECT):走上述 MVCC 版本链,不加锁,读到的是符合隔离级别的历史版本。
- 当前读(SELECT...FOR UPDATE、UPDATE、DELETE):不走版本链,直接读取最新已提交版本,并加锁。
一句话总结
MVCC 就是通过 undo log 保留数据历史版本、利用 ReadView 判定版本可见性,让快照读在不上锁的情况下,也能读到符合事务隔离级别的一致性数据视图。
主从同步原理

MySQL主从复制的核心就是二进制日志,主从同步只是分担访问压力,读写分开访问。
二进制日志(BINLOG)记录了所有的DDL(数据定义语言)语句和DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句。
复制分成三步:
- Master主库在事务提交时,会把数据变更记录在二进制日志文件 Binlog 中。
- 从库读取主库的二进制日志文件Binlog,写入到从库的中继日志 Relay Log。
- slave重做中继日志中的事件,将改变反映它自己的数据。
分库分表
主从同步只是分担访问压力,读写分开访问。要解决存储压力,优化读写速度则需要采用分库分表
分库分表的时机:
- 前提,项目业务数据逐渐增多业务发展比较迅速
- 优化已解决不了性能问题(主从读写分离、查询索引)
- 1O瓶颈(磁盘IO、网络IO)、CPU瓶颈(聚合查询、连接数太多)
垂直拆分
垂直分库:以表为依据,依据业务将不同表拆分到不同库中
- 按业务对数据分级管理、维护、监控、扩展
- 在高并发下,提高磁盘IO和数据量连接数
垂直分表:以字段为依据,根据字段属性将不同字段拆分到不同表中。
- 冷热数据分离
- 减少IO过渡争抢(例如description描述字段一般比较长,从磁盘查询时IO耗费大,将该字段分离出去,需要了再去查询),两表互不影响
水平拆分
水平分库:一个库的数据拆分到多个库中
- 解决了单库大数量,高并发的性能瓶颈问题
- 提高了系统的稳定性和可用性
水平分表:一个表的数据拆分到多个表中
- 优化单一表数据量过大而产生的性能问题;
- 避免IO争抢并减少锁表的几率;
依据取模的方式找到需要访问的数据库或者数据表
取模公式
分片键(一般是数值型的ID) % 数据库/表的总数量 = 余数具体执行时
写入:
收到
user_id = 1005→ 计算1005 % 4 = 1→ 路由到库1 → 写入
查询:
查询
user_id = 1005→ 同样1005 % 4 = 1→ 去库1查 → 返回结果
举例
假设按 user_id 分 4 个库(库0、库1、库2、库3):
| user_id | 计算 | 余数 | 落入 |
|---|---|---|---|
| 1001 | 1001 % 4 = 1 | 1 | 库1 |
| 1002 | 1002 % 4 = 2 | 2 | 库2 |
| 1003 | 1003 % 4 = 3 | 3 | 库3 |
| 1004 | 1004 % 4 = 0 | 0 | 库0 |
分片键的选择
取模的分片键通常选:
- 自增主键(如
id) - 业务主键(如
user_id、order_id) - 必须是查询条件中频繁出现的字段,否则每次查询都不知道去哪个库,需要全库扫描
取模分片的优缺点
| 优点 | 缺点 |
|---|---|
| 数据分布均匀 | 扩容困难:加新库时,总数变了,几乎所有数据的取模结果都变了,需要大规模数据迁移 |
| 实现简单 | 分片键之外的字段查询,需要扫所有库 |
扩容的问题一般用一致性哈希来解决。
一致性哈希
一致性哈希就是为了解决取模分片在扩容/缩容时几乎全部数据都要迁移这个痛点而设计的。
1. 取模分片为什么扩容困难?
假设原先 4 个库,现在扩容到5个库
扩容前:1004 % 4 = 0 → 库0
扩容后:1004 % 5 = 4 → 库4 ← 变了!需要迁移!实际上,加一个节点后,受影响的数据比例大约是 N / (N+1)(N 是原节点数),4 变 5 意味着大约 80% 的数据要迁移,这在生产环境简直是灾难。
一致性哈希的核心思想
不再对节点数取模,而是对整个哈希空间取模,把节点和数据都映射到一个环上。
哈希环
- 想象一个圆环,从
0到2³² - 1(约 43 亿)首尾相连。 - 每个节点(库/表)通过哈希函数算出它在环上的位置。
- 每个数据(通过分片键的哈希)也算出它在环上的位置。
- 数据的归属规则:从数据的位置出发,顺时针找到第一个节点,这个节点就是数据归属的库/表。
图解示例
假设哈希环范围是 0 ~ 100,3 个节点:
节点A:hash("库A") = 15
节点B:hash("库B") = 45
节点C:hash("库C") = 80
它们在环上: 0 ... A(15) ... B(45) ... C(80) ... 100(即0)数据 user_id = 1001:
hash(1001) = 30 → 顺时针找第一个节点 → 节点B(45)
所以 user_id=1001 去库B数据 user_id = 1002:
hash(1002) = 70 → 顺时针 → 节点C(80)
所以 user_id=1002 去库C数据 user_id = 1003:
hash(1003) = 90 → 顺时针,经过100绕回0 → 节点A(15)
所以 user_id=1003 去库A4. 扩容时的影响
现在新增节点D,hash("库D") = 60:
text
环变成: A(15) ... B(45) ... D(60) ... C(80) ... 100
受影响的数据:
- hash=30 → 顺时针 → 还是B(45),不变
- hash=70 → 顺时针 → 变成D(60) ← 只有这条被迁移!
- hash=90 → 顺时针 → 还是C(80),不变只有处于 B(45) 到 D(60) 之间的数据才会从 C 迁移到 D,影响范围极小。
扩容 1 个节点,只需要迁移约 1 / (N+1) 的数据,而不是取模方案的 N / (N+1)。
5. 两个问题与优化
问题一:节点分布不均(数据倾斜,某些节点撑死,某些节点饿死)
如果节点在环上分布不均匀,比如 A、B 挤在一起,A 和 C 之间空了一大段,那么大量数据都会落在 C 上,C 就变成热点。
解决:虚拟节点
- 每个物理节点在环上映射出多个虚拟节点(比如每个物理节点对应 150 个虚拟节点)。
- 这些虚拟节点均匀散布在环上,数据就能均匀分配到各物理节点。
问题二:节点上下线时的缓存击穿
原有节点突然下线,大量请求涌向它的顺时针下一个节点,可能压垮它。
解决:缓存预热 + 逐渐放量 + 多级缓存
分库分表产生的技术调整
分库分表后会采用集群的节点部署方式,一个表或者一个库的数据可能会在多个节点上,要考虑分布式事务问题,考虑跨节点查询的问题
stata解决分布式事务一致性问题
跨节点关联查询
跨节点分页、排序函数
主键避重