事务
银行为了存储人们的账户信息会建立一个account表:
sql
CREATE TABLE account(
id INT NOT NULL AUTO_INCREMENT COMMENT'自增id',
name VARCHAR(100) COMMENT '客户名称',
balance INT COMMENT '余额',
PRIMARY KEY(id)
) Engine=InnoDB CHARSET=utf8;
假设现在有两个人a和b,a要向b进行转账,在现实世界中a向ATM机输入了b的银行卡号和转账金额,然后按下确认,那么在数据库这里相当于执行了下面两条语句:
sql
UPDATE account SET balance = balance - 10 WHERE id = 1;
UPDATE account SET balance = balance + 10 WHERE id = 2;
想象一种极端情况,上述两条语句执行完一条后突然断电了,把a的钱扣了但是没有把钱给b转过去;
此外,在对某个页面进行读写访问时,都是先将这个页面加载到Buffer Pool中,之后如果修改了某个页面,也不会立即将修改同步到磁盘,而只是将这个修改了的页面加到Buffer Pool的flush链表中,在之后的某个时间点才会刷新到磁盘。那么如果在这个时间点之前,系统崩溃了,或者在这个时间点刷新磁盘时,系统崩溃了,钱还是到不了b这里。
也就是说,我们如何能保证某些数据库的操作能够符合现实世界中转换的规则?设计数据库的人给出了事务的四大特性:
事务四大特性ACID
能够保证ACID的一个或多个数据库操作被称为一个事务。
原子性(Atomicity):操作必须是一个不可分割的操作,要么全部成功,要么全部失败,不存在中间的状态。
一个现实世界的不可分割的操作,对应到数据库这里可能就是若干条不同的操作,数据库的一个操作也可能分割成若干步骤(比如先修改缓存页,后刷盘),任何一个时间点出错(数据库本身错误、操作系统错误、甚至断电)都可能让数据库操作执行失败。
隔离性(Isolation):多个并发执行的事务相互隔离,不能互相干扰。
假设a向b同时进行两次转账操作(在两台ATM机上同时进行),分别设这两次转账操作为T1、T2,理论上在现实世界中这两个操作是没有关系的,应该是先执行T1再执行T2,或者先T2后T1:

但是在数据库中,T1和T2的操作可能会交替执行:

可能会导致最后转账的钱数发生问题,从而让银行亏钱。
一致性(Consistency):事务执行后,数据库必须从一个一致(合法)的状态变到另一个一致(合法)的状态,满足所有预设规则。也就是说数据库中的数据,必须遵守现实世界中的业务逻辑约束。这是事务的终极目标。
一致性是应用系统与数据库系统共同维护的结果。数据库通过保证原子性、隔离性和持久性,为一致性提供基础保障;而应用程序则需要编写正确的事务逻辑,确保业务规则得以遵守 。
持久性(Durability) :一旦事务成功提交,它对数据库所做的更改就是永久性的 。即使之后系统发生崩溃、断电等故障,已提交的数据也不会丢失 。
重做日志(Redo Log):实现持久性 (Durability)
我们前面所说的问题:在对某个页面进行读写访问时,都是先将这个页面加载到Buffer Pool中,之后如果修改了某个页面,也不会立即将修改同步到磁盘,而只是将这个修改了的页面加到Buffer Pool的flush链表中,在之后的某个时间点才会刷新到磁盘。
根据持久性的定义,对于一个已经提交的事务,即使后面系统发生了崩溃,这个事务对数据库的修改也不能丢失。但是我们如果只在内存的Buffer Pool里修改了页面,假设事务提交后突然发生了故障导致内存中的数据都没了,那么这个事务对数据库的修改也全部丢失。
所以,对于这些已经提交的事务,即使后面系统崩溃导致修改丧失,我们也希望在重启后能够恢复过来,我们只需要将每次的修改记录一下就好,比如某个事务将系统表空间中的第100号页面中偏移量为1000处的那个字节的值1改为2,我们记录到日志中(因此看出,Redo Log是物理逻辑日志,记录的是对某个数据页的物理修改内容):

于是我们会使用Redo Log,其本质上就是记录了一下事务对数据库进行了哪些修改。
MySQL 的 InnoDB 存储引擎通过 redo log(重做日志) 机制来保证事务的持久性(Durability) ,即确保已提交事务的修改不会因系统崩溃而丢失。其核心在于 Write-Ahead Logging (WAL) 技术:任何数据页的修改,必须在修改本身刷盘之前,先将其相关的 redo 日志持久化到磁盘。
- redo log 是追加写入 (顺序写入),只需写到文件末尾,大大减少了磁盘寻道时间,写入速度极快。为了解决磁盘速度慢的问题,写入redo log时引入了log buffer。
- Log Buffer(日志缓冲区)是数据库系统中的一块关键内存区域,主要用来临时存储即将写入磁盘日志文件的数据。当发生数据变更(如INSERT、UPDATE)时,这些改动会首先被记录为重做日志(Redo Log)。这些日志并非直接写入磁盘,而是先被放入Log Buffer这个"蓄水池"中。当满足特定条件时,"蓄水池"的闸门会打开,将积攒的日志一次性、顺序地写入磁盘的Redo Log文件里 。这个"特定条件"主要由
innodb_flush_log_at_trx_commit参数控制。 - redo log 是物理逻辑日志 ,它记录的是对某个数据页的物理修改内容,例如"在表空间A、页号B、偏移C处写入数据D"。这种格式使得恢复效率非常高。
- Checkpoint:redo log 文件是固定大小的,一组文件(如
ib_logfile0,ib_logfile1)循环写入。当日志写满后,会回溯到开头覆盖旧的日志记录。但只能覆盖那些对应的修改已经被持久化到数据文件的日志。这个持久化的位置称为 Checkpoint。
崩溃恢复:
-
确定恢复的起点和终点
- 寻找起点:检查点(Checkpoint),可以理解为在这个时间点之前,所有已提交事务对数据页的修改都已经从内存(Buffer Pool)刷新到了磁盘数据文件 中。因此,检查点之前的 redo 日志对应的修改已经被持久化 ,不需要再恢复。这个检查点记录对应的LSN(Log Sequence Number,日志序列号)就是恢复的起点。检查点机制极大地缩短了恢复时需要扫描的日志量。
- 寻找终点:日志文件的末尾。恢复的终点 就是当前的 redo log 文件末尾。恢复进程会从起点开始,顺序向前扫描 redo log 文件,直到文件末尾,确保所有日志记录都被处理。
-
扫描与应用日志(前滚阶段):恢复进程会重新执行(Replay) 从起点到终点之间的所有 redo 日志记录,无论其对应的事务最终是提交了还是中止了。这个"重做"的过程是幂等的,意味着即使某个数据页的修改在崩溃前已经部分写入磁盘,重复应用 redo 日志也不会导致数据错误。
-
处理未完成的事务(回滚阶段):在重做所有日志后,数据库在数据页层面恢复到了崩溃瞬间的状态,但可能包含未提交事务的修改。// 恢复进程会根据日志信息,识别出在系统崩溃时尚未提交 (没有
COMMIT记录)或正在回滚 的事务。对于这些事务,系统会使用 Undo Log 来撤销(Rollback) 它们已经对数据页所做的任何修改,从而保证事务的原子性(Atomicity)------要么全部完成,要么全部不完成。 -
LSN(日志序列号):LSN 是一个持续递增的序列号,唯一标识每一条 redo 日志。它不仅用于追踪日志写入位置,数据页的页头也会记录最新修改该页的 LSN。在恢复时,如果数据页的 LSN 大于等于 redo 日志的 LSN,说明修改已落盘,可以跳过该日志的重做,这进一步优化了恢复效率。
Undo Log(回滚日志):实现原子性(Atomicity)与MVCC
Undo Log(回滚日志)是 InnoDB 存储引擎中实现事务原子性 和多版本并发控制 的关键组件。它的核心思想是:在修改任何数据之前,先"留底",把旧数据备份下来,如果事务需要回滚,就可以利用这份"底稿"将数据恢复原状。
Undo Log 的核心作用主要体现在以下两个方面:
-
实现事务回滚(保证原子性)
这是 Undo Log 最直接的作用。当事务需要回滚(例如执行
ROLLBACK或系统发生故障)时,InnoDB 会读取该事务对应的 Undo Log,执行相反的逆操作来撤销更改,将数据恢复到事务开始前的状态。具体操作如下:-
INSERT 的逆操作是 DELETE。
-
DELETE 的逆操作是 INSERT(将整行数据插回去)。
-
UPDATE 的逆操作是另一个 UPDATE(将值改回旧值)。
-
-
实现多版本并发控制(MVCC)
这是 Undo Log 在现代数据库中非常重要且高频的作用。MVCC 使得读操作(如普通的
SELECT)不会阻塞写操作,写操作也不会阻塞读操作。Undo Log 保存了数据行的历史版本,当某个事务需要读取一行数据时,如果这行数据正在被其他事务修改或该版本对当前事务不可见,InnoDB 就会通过回滚指针在 Undo Log 链表中寻找符合当前事务可见性的历史版本数据(快照读),从而避免了加锁等待。
当事务中某条SQL执行失败时,MySQL就需要回滚事务中其他执行成功的SQL,此时就会找到这个事务在Undo-log中生成的反SQL,然后将库中的数据改回事务发生前的样子。
但是,我们说一条写SQL执行前,会生成对应的反SQL记录在Undo-log中,但实际上并不会生成反SQL,而是数据修改前的原始状态(旧数据镜像)。说它生成"反SQL"是一种为了方便理解而进行的简化比喻,但并非其技术实现本质。
那么Undo Log到底记录了什么?
Undo Log 记录的是数据行在修改前的具体值。例如:
-
对于
UPDATE 操作:它记录的是被更新字段在修改之前的值。 -
对于
DELETE 操作:它记录的是被删除行的完整内容。 -
对于
INSERT 操作:它记录的是新插入行的主键信息,以便回滚时能精准定位并删除该行。
于是,一个事务需要回滚时,本质上并不会以执行反SQL的模式还原数据,而是直接将roll_ptr回滚指针指向的Undo记录,从xx.ibdata共享表数据文件中拷贝到xx.ibd表数据文件,覆盖掉原本改动过的数据。
当执行 INSERT、UPDATE或 DELETE操作时,InnoDB 在真正修改数据页之前,会先记录对应的 Undo Log。
-
对于
INSERT,Undo Log 会记录新插入行的主键信息 。回滚时只需根据这个主键执行DELETE即可。 -
对于
UPDATE 或DELETE,Undo Log 会记录被修改数据修改前的状态(旧值) ,包括主键、变更的列及其旧值等。回滚时需要通过一个反向的UPDATE将数据还原。
这些日志首先被写入内存中的 Undo Log Buffer ,随后在事务提交前或根据需要被刷写到磁盘上的 Undo Tablespaces(独立表空间)中进行持久化。
当事务执行失败或显式发出 ROLLBACK命令时,InnoDB 会启动回滚流程。
-
系统会找到该事务生成的所有 Undo Log 记录。
-
然后从最后一条操作开始,逆向执行这些记录所代表的操作,逐步将数据恢复到事务开始前的状态。这个过程是逻辑上的恢复,并非物理页面的简单回退。
锁(Locking) 和 多版本并发控制(MVCC)实现隔离性(Isolation)
事务隔离级别
事务并发会产生什么问题?
当多个事务并发执行时,如果不对它们进行任何隔离控制,就会引发一系列数据一致性问题。这些问题按照严重程度,从轻到重 ,主要可以归结为以下四类:脏读、不可重复读、幻读 ,以及一个在标准之外但非常重要的更新丢失。
1. 脏读
-
问题描述 :一个事务读到了另一个未提交事务修改过的数据。
-
发生场景 :事务A修改了数据,但尚未提交。此时事务B读取了这条被事务A修改过的数据。之后事务A因某种原因回滚 了操作,那么事务B刚才读到的数据就是一条从未正式存在过的"脏数据"。
2. 不可重复读
-
问题描述 :在同一个事务中,两次读取同一行数据,得到的结果不一致。
-
发生场景 :事务B第一次读取一条数据后,事务A提交了更新操作 修改了这条数据。事务B再次读取该数据时,发现值已经改变。重点在于修改了已存在的数据行。
3. 幻读
-
问题描述 :在同一个事务中,两次执行相同的条件查询 ,返回的结果集行数不一致。
-
发生场景 :事务B第一次按某个条件查询后,事务A提交了插入或删除操作 ,新增或删除了符合事务B查询条件的记录。事务B再次执行相同查询时,发现多出或少了一些行,如同出现了"幻觉"。重点在于新增或删除了数据行。
事务隔离级别有哪些?怎么实现的?
读未提交
数据库事务隔离级别中最基础的一种,它核心特点就是允许一个事务读取另一个尚未提交的事务修改过的数据。
读未提交的实现方式非常直接,可以概括为 "不设防"的读取 。在读未提交级别,数据库引擎在处理读请求时,跳过了这些一致性检查步骤。当执行一个SELECT查询时,它不会去检查它要读取的数据行是否被其他事务加锁,也不会去创建一个数据快照,而是直接读取数据页上当前最新的数据版本,无论这个版本是由哪个事务创建的,也无论该事务是否已提交。
读已提交
保证了一个事务只能读取到其他事务已经提交的数据,有效避免了脏读(Dirty Read)问题。这意味着你不会在事务中看到别人修改到一半、可能还会被撤销的数据。
现代数据库(如 MySQL 的 InnoDB 引擎)主要通过 多版本并发控制(MVCC) 机制来实现读已提交隔离级别。其核心在于 Read View(读视图或一致性视图)的生成时机 。在读已提交隔离级别下,事务在执行每一条 SELECT语句时,都会立即生成一个新的 Read View 。这个 Read View 可以理解为数据库在当前这个瞬间 的一个"快照",它记录了此刻所有已提交事务的状态。通过这个快照,系统可以判断数据行的哪个版本对当前事务是可见的。
可重复读
(Repeatable Read)确保在同一个事务中,无论多少次读取同一条数据,结果都始终一致。这主要得益于多版本并发控制(MVCC) 和**一致性读视图(Consistency Read View)** 的协同工作。
数据版本链与隐藏字段
InnoDB存储引擎会为每行数据维护两个隐藏字段:
-
DB_TRX_ID:最近一次修改该行数据的事务ID。 -
DB_ROLL_PTR:回滚指针,指向该行数据上一个版本在Undo Log中的地址。每次对数据进行更新时,都会将旧数据作为一条记录存入Undo Log,并通过回滚指针串联起来,形成一条数据版本链。这使得系统可以根据需要追溯任何历史版本。
一致性读视图(Read View)
这是实现可重复读的关键。在事务开启后执行第一条查询语句时 ,InnoDB会为这个事务生成一个一致性读视图(Read View) 。这个视图一旦创建,在该事务的整个生命周期内都不会改变,相当于为事务"定格"了数据库在那一刻的数据状态。
Read View主要包含以下信息:
-
活跃事务ID数组:记录生成Read View时,所有未提交的活跃事务的ID。
-
低水位(Up Limit ID):取活跃事务ID数组中最小的事务ID。
-
高水位(Low Limit ID):取当前系统已分配的最大事务ID加1。
版本可见性规则
当事务要读取一行数据时,会从最新的版本开始,依次遍历版本链,并根据以下规则判断哪个版本对该事务可见:
-
如果数据版本的
DB_TRX_ID小于Read View的低水位 ,说明该版本在事务开始时已提交 ,可见。 -
如果
DB_TRX_ID大于等于Read View的高水位 ,说明该版本在事务开始后才创建 ,不可见,需要继续查找更早的版本。 -
如果
DB_TRX_ID介于低水位和高水位之间,则检查该ID是否在活跃事务ID数组中:-
若在,说明该版本在事务开始时还未提交 ,不可见。
-
若不在,说明该版本在事务开始时已提交 ,可见。
-
值得注意的是,MVCC的"快照"主要针对普通的SELECT查询(称为快照读 )。而对于写操作 (UPDATE, DELETE, INSERT)或者加了锁的读操作(如SELECT ... FOR UPDATE),InnoDB使用的是当前读(Current Read)。
当前读 会读取数据行的最新已提交版本,并会对其加锁,以防止其他事务同时修改,确保数据逻辑的正确性。例如,一个事务先执行快照读,然后基于读到的值进行更新,这个更新操作会去读取最新的数据版本,如果在此期间数据已被其他事务修改,当前事务可能需要等待锁,或者根据最新值进行计算。
串行化
串行化是数据库事务隔离级别中最严格的一级,它通过强制事务排序,确保并发执行的一组事务,其最终结果与这些事务按照某种顺序一个接一个地串行执行的结果完全相同。这是保证数据一致性的最高标准,可以避免所有因并发引起的数据异常问题。
数据库系统主要通过两种技术路径来实现串行化隔离级别:
数据库系统主要通过两种技术路径来实现串行化隔离级别:
1. 基于锁的机制
这是最经典的方法。其核心思想是通过加锁来确保事务在访问数据时的独占性,从而消除冲突。
-
锁的类型:
-
共享锁:用于读操作。多个事务可以同时持有同一数据的共享锁。
-
排他锁:用于写操作。一个事务持有排他锁后,其他事务无法再获取该数据的任何锁。
-
-
锁的粒度 :串行化级别下,锁的粒度可能会变大。如果查询无法有效使用索引(例如全表扫描或使用非索引字段的条件查询),数据库可能会直接锁住整张表,而不是某几行,这虽然保证了绝对安全,但严重限制了并发性。
-
两段锁协议:这是保证可串行化调度的充分条件。事务分为两个阶段:
-
扩展阶段:事务可以不断获得新锁,但不能释放任何锁。
-
收缩阶段:事务可以释放锁,但不能申请任何新锁。
这个协议确保了事务不会在读取数据后过早释放锁,从而防止其他事务修改这些数据。
-
2. 多版本并发控制
MVCC 是一种更现代、性能更好的实现方式,被如 PostgreSQL 等数据库采用。其核心思想是为每个数据项维护多个版本,让读写操作互不阻塞。
-
工作原理 :每个事务在开始时会被赋予一个唯一的时间戳或事务ID。当读取数据时,数据库会根据事务的ID,只提供在该事务开始之前就已经提交的数据版本。这样,即使其他事务正在修改数据,当前事务也只会看到一个稳定的历史快照。
-
串行化快照隔离:这是对MVCC的增强。它会动态检测并发事务之间是否可能存在"写偏斜"等违反串行化顺序的情况。如果检测到潜在冲突,会选择中止其中一个事务,从而保证最终结果与串行执行等价。
读已提交和可重复读有什么区别?

核心区别:MVCC与Read View的生成时机
两者通常都通过多版本并发控制(MVCC) 实现,其核心差异在于 Read View(读视图/一致性视图)的生成时机
-
读已提交 :在此级别下,每一次 执行普通的
SELECT查询语句时,都会生成一个新的Read View 。这个新的ReadView会包含到当前这一刻为止所有已提交的事务。因此,每次查询都能看到其他事务最新提交的结果 。 -
可重复读 :在此级别下,事务在第一次 执行普通的
SELECT查询时,会生成一个Read View,并且这个Read View会在整个事务的生命周期内被复用。后续所有查询都是基于这个"历史快照"进行的,因此其他事务的提交对当前事务是不可见的,从而保证了可重复读 。
(
我们把数据库比作一个热闹的广场 ,数据的修改就像广场上人来人往。你的"事务"就是一次观察 。Read View(读视图)就是你观察时使用的特殊相机,它决定了你能看到广场上的哪些人(数据)。
场景一:读已提交(Read Committed)的观察方式
"每次查询都生成新的 Read View" = 每次观察都重新拍一张即时照片
在这种模式下:
-
每次举起相机 :每当你在事务中执行一次
SELECT查询,就相当于立刻拿起相机,重新拍一张广场的即时快照。 -
看到最新景象 :这张新照片会捕捉到此时此刻广场上所有已经站定的人(已提交的数据)。如果有人刚刚离开或刚刚到来但还没站稳(其他事务未提交的修改),你看不到。
-
结果可能不同:如果你在事务中隔一分钟再拍一次,照片里的人物景象很可能就变了。因为在这期间,可能有人走了(数据被删除/更新并提交),也可能有新的人来了(插入了新数据并提交)。
通俗总结(读已提交) :你事务里的每次查询 ,看到的都是到那一刻为止的最新"已确定"景象 。所以同一事务内,多次看同一地方,看到的画面可能不一样(这就是不可重复读)。
场景二:可重复读(Repeatable Read)的观察方式
"第一次查询生成 Read View 并复用" = 事务开始时拍一张照片,之后一直看这张照片
在这种模式下:
-
只拍一次照片 :在你的事务中,第一次 执行
SELECT查询时,你会拍下一张广场的静态照片。 -
一直看这张照片 :在整个事务后续的所有
SELECT查询中,你不再重新拍照 ,而是一直端详最初拍的这张静态照片。 -
景象始终不变 :无论广场上实际发生了多么大的变化(其他事务不断提交修改),由于你始终在看同一张老照片,你看到的人物景象从头到尾都一模一样。
通俗总结(可重复读) :你事务里的所有查询 ,看到的都是事务开始第一次查询时的那个"历史瞬间"景象 。所以同一事务内,无论你看多少次,画面都绝对一致(这就解决了不可重复读)。
)
正是由于上述机制的不同,导致了它们能防止的并发问题也不同。
-
不可重复读
-
读已提交:允许发生。事务内两次读取同一数据,可能因中间有其他事务提交修改而得到不同结果 。
-
可重复读:防止发生。因为始终读取事务开始时的快照,所以同一数据在事务内多次读取结果一致 。
-
-
幻读
-
读已提交:允许发生。事务内两次执行相同的范围查询,可能因中间有其他事务插入或删除符合条件的数据而得到不同的结果集 。
-
可重复读:情况复杂 。按照SQL标准,可重复读是允许幻读的。但MySQL的InnoDB引擎通过间隙锁(Gap Lock)和临键锁(Next-Key Lock)机制,在很大程度上防止了幻读的发生 。这是MySQL实现的一个增强。
-
MVCC是什么?解决了什么问题?实现原理?
可以把 MVCC 想象成一个高效的文档协同编辑系统。当有人编辑文档时,系统会保存一个新的版本,而不是直接在原稿上修改。这样,其他正在阅读文档的人仍然可以看到他们打开时的那个稳定版本,不受正在进行的编辑影响。

MVCC 的实现主要依赖于三大核心组件协同工作:
1. 隐藏字段与数据版本链
数据库每行数据除了我们定义的列,还有几个重要的隐藏字段:
-
DB_TRX_ID :一个6字节的字段,记录最后修改(包括INSERT或UPDATE)该行数据的事务ID 。
-
DB_ROLL_PTR :一个7字节的回滚指针 ,指向该行数据上一个版本在Undo Log中的存储位置 。
-
DB_ROW_ID:一个6字节的隐藏自增ID,如果表没有主键,InnoDB会用它生成聚簇索引 。
每当有事务更新某行数据时,InnoDB并不会直接覆盖原数据,而是会:
-
将该行数据当前的状态复制到Undo Log中,形成一个旧版本记录。
-
用新数据更新当前行 ,并将当前行的
DB_TRX_ID设置为当前事务的ID。 -
将当前行的
DB_ROLL_PTR指向刚刚存入Undo Log的那个旧版本记录。
这样,对同一行数据的多次修改,会通过 DB_ROLL_PTR指针在Undo Log中形成一条版本链,链首是最新版本,链尾是最旧版本 。
2. Undo Log
Undo Log(回滚日志)是MVCC的"时光机",它存储了数据行的所有历史版本 。当需要回滚事务或者为快照读提供历史数据时,系统就通过回滚指针遍历这个版本链,找到合适的数据版本。
3. Read View(读视图)
Read View是判断数据版本对当前事务是否可见的关键 。当事务执行快照读(普通的SELECT查询)时,会根据当前的隔离级别生成一个Read View,它相当于一个"过滤器",主要包含以下信息
-
m_ids :生成Read View那一刻,系统中所有活跃(未提交)事务的ID列表。
-
min_trx_id :
m_ids中最小的事务ID。 -
max_trx_id:生成Read View时,系统将要分配给下一个事务的ID。
-
creator_trx_id:创建这个Read View的事务自身的ID。
有了数据版本链和Read View,当一个事务要读取某行数据时,它会从最新的版本开始,依次遍历版本链,并根据一套严格的规则来判断每个版本是否对自己可见 .
-
如果该数据版本的
DB_TRX_ID等于creator_trx_id,说明这个版本是当前事务自己修改的,可见。 -
如果该数据版本的
DB_TRX_ID小于min_trx_id,说明修改该版本的事务在当前Read View创建前就已经提交了,可见。 -
如果该数据版本的
DB_TRX_ID大于等于max_trx_id,说明修改该版本的事务是在当前Read View创建后才开始的,不可见。需要沿着版本链继续找更早的版本。 -
如果该数据版本的
DB_TRX_ID在min_trx_id和max_trx_id之间,则检查DB_TRX_ID是否在m_ids(活跃事务列表)中:-
如果在,说明修改该版本的事务在生成Read View时还未提交,不可见。
-
如果不在,说明该事务已提交,可见。
-
如果当前版本不可见,就通过回滚指针找到上一个版本,重复上述判断,直到找到一个可见的版本或遍历完整个版本链。
MVCC 在不同的事务隔离级别下行为有所不同,主要体现在 Read View 的生成时机上:

为什么说可重复读缓解了幻读问题但没有彻底解决?
MySQL的InnoDB引擎在可重复读(Repeatable Read) 隔离级别下,通过两种强大的机制几乎解决了幻读 ,但在某些非常特殊的边界场景下 ,理论上仍然可能出现幻读。因此,严谨的答案是:它极大地缓解了幻读,但并未实现100%的绝对解决。
InnoDB主要依靠以下两种技术来对抗幻读:
-
MVCC:为快照读提供一致性视图
MVCC使得普通的
SELECT查询(快照读)不需要加锁。在可重复读隔离级别下,事务在第一次执行快照读时生成一个一致性视图(Read View),这个视图在整个事务期间都会使用。因此,即使其他事务插入并提交了新数据,当前事务也无法看到,从而保证了多次快照读结果集的一致性。 -
Next-Key Lock:为当前读加上物理锁
对于会改变数据的操作(当前读),InnoDB使用Next-Key Lock(临键锁)。它不仅是给查询命中的已有记录加锁(行锁),还会给这些记录之间的"间隙"加锁(间隙锁)。这样可以防止其他事务在锁定范围内插入新数据,从而从物理层面避免了当前读时的幻读。
尽管有上述强大的机制,但在某些特定操作序列下,幻读仍可能发生。
场景一:通过更新操作"触发"的幻读
这是最经典的边界场景。
-
时刻T1 :事务A执行
SELECT * FROM users WHERE id = 5,查询结果为空。 -
时刻T2 :事务B插入一条
id = 5的记录并提交。 -
时刻T3 :事务A执行
UPDATE users SET name = 'abc' WHERE id = 5。此时,这个更新操作是一个当前读,它能读到事务B新提交的记录。更关键的是,一旦事务A更新了这条记录,该记录的版本链会发生变化,其事务ID会与事务A关联。 -
时刻T4 :事务A再次执行
SELECT * FROM users WHERE id = 5(快照读)。这时,由于这条记录已经被事务A自己修改过,根据MVCC的可见性规则,它对事务A变为可见。于是,事务A在同一个事务内,两次相同的查询得到了不同的结果集,幻读发生。
场景二:混合使用快照读与当前读
-
时刻T1 :事务A先执行快照读:
SELECT * FROM accounts WHERE id > 100,返回3条记录。 -
时刻T2 :事务B插入一条
id=200的记录并提交。 -
时刻T3 :事务A执行当前读:
SELECT * FROM accounts WHERE id > 100 FOR UPDATE。**由于当前读总是获取最新已提交的数据,它会看到事务B插入的新记录,因此返回4条记录。**这与T1时刻的快照读结果相比,就发生了幻读。
日志
事务的持久性是怎么实现的?
事务的持久性主要是通过 重做日志(Redo Log) 实现的。redo log 是物理逻辑日志 ,它记录的是对某个数据页的物理修改内容,例如"在表空间A、页号B、偏移C处写入数据D"。这种格式使得恢复效率非常高。
其核心在于 Write-Ahead Logging (WAL) 技术:任何数据页的修改,必须在修改本身刷盘之前,先将其相关的 redo 日志持久化到磁盘。
-
redo log 是追加写入 (顺序写入),只需写到文件末尾,大大减少了磁盘寻道时间,写入速度极快。为了解决磁盘速度慢的问题,写入redo log时引入了log buffer。
-
Redo Log 包括内存中的 重做日志缓冲(redo log buffer) 和磁盘上的 重做日志文件(redo log file) 。事务提交时,根据
innodb_flush_log_at_trx_commit参数的设置,日志会从缓冲区经历以下阶段:-
写入操作系统缓存 :日志先从
redo log buffer写入到操作系统的文件系统缓存(os buffer)。 -
刷盘(fsync) :最后通过
fsync()系统调用将数据真正持久化到硬盘 。、
-
-
innodb_flush_log_at_trx_commit参数至关重要 :
= 1(默认,最安全):事务提交时,同步将 redo log buffer 内容刷到磁盘(调用 fsync)。这是保证严格持久性的推荐设置。
= 0(性能优先,不安全):事务提交时,日志每秒一次批量写入磁盘并刷盘。MySQL 崩溃可能丢失约 1 秒的数据。
= 2(折中):事务提交时,日志写入操作系统缓存,每秒一次刷盘。操作系统崩溃可能丢失约 1 秒的数据。
-
Log Buffer(日志缓冲区)是数据库系统中的一块关键内存区域,主要用来临时存储即将写入磁盘日志文件的数据。当发生数据变更(如INSERT、UPDATE)时,这些改动会首先被记录为重做日志(Redo Log)。这些日志并非直接写入磁盘,而是先被放入Log Buffer这个"蓄水池"中。当满足特定条件时,"蓄水池"的闸门会打开,将积攒的日志一次性、顺序地写入磁盘的Redo Log文件里 。这个"特定条件"主要由
innodb_flush_log_at_trx_commit参数控制。 -
Checkpoint:redo log 文件是固定大小的,一组文件(如
ib_logfile0,ib_logfile1)循环写入。当日志写满后,会回溯到开头覆盖旧的日志记录。但只能覆盖那些对应的修改已经被持久化到数据文件的日志。这个持久化的位置称为 Checkpoint。 -
两阶段提交(2PC)
当启用二进制日志(binlog)时,MySQL 使用两阶段提交来协调 Redo Log 和 Binlog,确保两者逻辑一致 。
-
Prepare 阶段 :将 Redo Log 写入磁盘,并标记为
PREPARE状态。 -
Commit 阶段 :将 Binlog 写入磁盘后,再将 Redo Log 标记为
COMMIT状态。 -
崩溃恢复时,数据库会检查:如果 Redo Log 是
PREPARE状态但 Binlog 不完整,则回滚事务。如果 Redo Log 是PREPARE状态且 Binlog 完整,则提交事务,利用 Redo Log 重做 。
-
事务的原子性是怎么实现的?
Undo Log(回滚日志)是实现原子性的核心。在事务修改任何数据之前,数据库会先将数据修改前的原始状态(旧值)记录到 Undo Log 中。
-
记录内容 :对于
UPDATE操作,会记录被修改字段的旧值;对于INSERT操作,会记录新插入数据的主键;对于DELETE操作,则会记录被删除行的完整内容。 -
日志持久化 :对 Undo Log 本身的修改也会被记录到 Redo Log(重做日志)中,以确保 Undo Log 的持久性,这常被称为 "Redo for Undo"。
当事务需要回滚时,InnoDB 引擎会执行以下操作:
-
定位记录:找到该事务对应的所有 Undo Log 记录。
-
应用旧值 :并不是生成或执行SQL ,而是直接根据 Undo Log 中保存的旧数据版本,在存储引擎层面写回到当前的数据页中,覆盖掉修改后的新数据。这个过程更像是一个"撤销"操作,将数据页直接恢复到事务开始前的状态。
bin log和redo log有什么区别?
1. 层级与通用性
-
Binlog 是 MySQL 数据库本身(Server层)产生的日志,不管你的表使用 InnoDB、MyISAM 还是其他存储引擎,只要数据库有数据变更,就会记录 binlog。这保证了在各种引擎环境下,主从复制等功能的一致性 。
-
Redo Log 是 InnoDB 引擎(存储引擎层)为了满足事务特性(特别是持久性)而自己开发的。如果使用 MyISAM 引擎,就没有 redo log 的概念,这也是 MyISAM 不支持崩溃安全的原因 。
2. 日志内容与形式
-
Binlog (逻辑日志) :它记录的是操作的"意图"。例如,一条修改数据的SQL语句
UPDATE user SET name = '张三' WHERE id = 1;的 SQL,在STATEMENT格式下,binlog 就原样记录这条 SQL;在ROW格式下,它会记录id=1这行数据修改前和修改后的所有字段值。它不关心数据存储在磁盘的哪个位置 。 -
Redo Log (物理日志):它记录的是"具体动作"。它不关心你执行的是什么 SQL,只记录"在表空间 A、页号 B、偏移量 C 的位置,将数据更新为 D"这样的物理信息。这种记录方式使得恢复效率极高
3. 设计目标与用途
-
Binlog 的核心目标是"复制与归档"。它像一个尽职的档案管理员,完整记录所有数据变更的历史,用于:
-
主从复制:从库(Slave)通过重放主库(Master)的 binlog 来同步数据 。
-
数据恢复:结合全量备份,可以将数据库恢复到过去的任意时间点 。
-
-
Redo Log 的核心目标是"崩溃恢复" 。它像一个飞机上的"黑匣子",用于保证 Crash-Safe 能力。即当数据库异常重启时,能够确保所有已提交的事务(Commit)的数据不丢失,而未提交的事务(Rollback)的数据会被回滚。这是实现事务 ACID 特性中**持久性(Durability)** 的基石 。
4. 写入方式不同
- binlog是追加写,写满一个文件,就创建一个新的文件继续写,不会覆盖以前的日志,保存的是全量的日志。
- redolog是循环写,日志空间大小是固定,全部写满就从头开始,保存未被刷入磁盘的脏页日志。它使用固定大小的文件组。有两个关键指针:
write pos表示当前写入的位置,checkpoint表示已经刷入磁盘的数据位置。write pos追赶上checkpoint时,需要推动checkpoint前进(即强制刷一些脏页到磁盘),然后才能继续写入。这种设计极大地减少了数据库直接进行随机磁盘 I/O 的次数,是 WAL(Write-Ahead Logging)预写式日志 技术的核心,对提升数据库写入性能至关重要。
为什么需要redo log?
主要是为了解决两个核心问题:保证数据不丢失(持久性) 和 提升数据库写入性能。
保证事务的持久性
这是 redo log 最根本的使命。事务的持久性要求:一旦事务提交,它对数据的修改就是永久的,即使发生系统崩溃,数据也不能丢失。
-
没有 redo log 的问题:
-
事务修改数据时,通常是先修改在内存中的数据页(在 Buffer Pool 中),这个被修改但还没写回磁盘的页称为"脏页"。
-
数据库并不会在每次事务提交后都立即将"脏页"同步写回磁盘,因为这样性能极差。
-
如果在脏页写回磁盘之前系统崩溃,那么内存中已提交的数据修改就会永久丢失,违反了持久性。
-
redo log 的解决方案 :预写式日志
-
当发生数据修改时,先不急着修改磁盘上的数据文件 ,而是先将"在某个数据页的某个位置做了什么修改"这个操作顺序追加到 redo log 文件中。
-
事务提交时,必须确保该事务对应的所有 redo log 都已经成功写入磁盘 (通过配置
innodb_flush_log_at_trx_commit=1来保证)。 -
这样,即使此时内存中的脏页还没有写回磁盘就发生了崩溃,在数据库重启后,依然可以通过重放 redo log 中的记录,将已提交的事务修改重新应用到数据文件上,从而保证数据不丢失 。
为什么需要两阶段提交?
两阶段提交(2PC)主要是为了解决在分布式系统或涉及多个独立组件的场景下,如何确保一个事务的所有操作要么全部成功,要么全部失败,从而维护数据的原子性 和一致性 。在MySQL中,最典型的应用就是协调重做日志(redo log) 和**二进制日志(binlog)** 的写入。
没有两阶段提交会怎样?
在没有两阶段提交的情况下,如果独立地先后写入redo log和binlog,一旦在两者之间发生系统崩溃,就会导致数据不一致。具体来说,主要有两种致命的"半成功"状态:

这两种情况都破坏了数据的一致性,而两阶段提交正是通过将一个事务的提交过程分为两个阶段来解决这个问题的。
两阶段提交的过程是怎样的?

内部 XA 的核心目标是解决 Binlog(用于复制和点恢复)和 Redo Log(用于崩溃恢复)这两个独立日志系统的原子性问题,即保证它们"同生共死"。
Prepare阶段(准备阶段)
这个阶段可以理解为事务的预提交,为最终的提交做好一切准备但暂不最终确认。
-
InnoDB准备:
-
将回滚段(undo segment)状态设置为
TRX_UNDO_PREPARED -
将事务状态标记为
PREPARED(已准备) -
将redo log写入日志缓冲区
-
-
关键点 :在此阶段,事务已经做好了提交的所有准备工作,但还没有最终提交。如果系统在此阶段崩溃,恢复时会回滚该事务。
Commit阶段(提交阶段)
这是最终确认阶段,确保两个日志都成功记录后才完成提交。此阶段又细分为三个子阶段以提高并发性能。
-
Flush阶段:
-
将binlog从缓存写入文件系统(但可能尚未刷到物理磁盘)
-
如果是组提交的组长事务,会负责将一组事务的binlog一起写入
-
-
Sync阶段:
-
根据
sync_binlog参数设置,决定是否将binlog刷盘(fsync) -
如果
sync_binlog=1,则每次事务提交都会强制刷盘,最安全但性能开销最大
-
-
Commit阶段:
-
将redo log的状态从
PREPARED改为COMMITTED -
清理undo信息,释放事务占用的锁资源
-
崩溃恢复如何依赖 2PC
内部 XA 的 2PC 设计使得 MySQL 在异常崩溃后能够可靠地恢复。重启时,MySQL 会扫描所有处于 PREPARE状态的事务(这些事务的 Redo Log 已落盘),然后去检查 Binlog:
-
如果事务的 XID 存在于 Binlog 中 :说明该事务在崩溃前已经完成了 Binlog 写入。恢复程序会执行 REDO 操作,在 InnoDB 层重新提交该事务。
-
如果事务的 XID 不在 Binlog 中 :说明该事务在 Binlog 写入前就崩溃了。恢复程序会执行 UNDO 操作,利用 Undo Log 回滚该事务。
这套机制确保了:**只要 Binlog 写成功,事务最终一定会被提交;反之,事务一定会被回滚。** 这正是 Crash-Safe 能力的来源。
数据库备份
复制技术(Replication)或 InnoDB Cluster 只负责业务的可用性,保障数据安全除了线上的副本数据库,我们还要构建一个完整的离线备份体系。这样即使线上数据库被全部破坏,用户也可以从离线备份恢复出数据。
所以,第一步要做好:线上数据库与离线备份系统的权限隔离。
也就是说,可以访问线上数据库权限的同学一定不能访问离线备份系统,反之亦然。否则,如果两边的数据都遭受破坏,依然无法恢复数据。
而对于 MySQL 数据库来说,数据库备份分为全量备份、增量备份。
全量备份
指备份当前时间点数据库中的所有数据,根据备份内容的不同,全量备份可以分为逻辑备份、物理备份两种方式。
- 逻辑备份
指备份数据库的逻辑内容,就是每张表中的内容通过 INSERT 语句的形式进行备份。
MySQL 官方提供的逻辑备份工具有 mysqldump 和 mysqlpump。通过 mysqldump 进行备份,可以使用以下 SQL 语句:
bash
mysqldump -A --single-transaction > backup.sql
上面的命令就是通过 mysqldump 进行全量的逻辑备份:
- 参数 -A 表示备份所有数据库;
- 参数 --single-transaction 表示进行一致性的备份。
文件 backup.sql 本质就是一个文本文件,里面记录的就是一条条 SQL 语句,而这就是我们说的逻辑备份。要恢复逻辑备份非常简单,就是执行文件中的 SQL 语句,这时可以使用下面的 SQL:
bash
mysql < backup.sql
虽然 mysqldump 简单易用,但因为它备份是单线程进行的,所以速度会比较慢,于是 MySQL 推出了 mysqlpump 工具。命令 mysqlpump 的使用几乎与 mysqldump 一模一样,唯一不同的是它可以设置备份的线程数。
不过在真正的线上生产环境中,我并不推荐你使用 mysqlpump, 因为当备份并发线程数超过 1 时,它不能构建一个一致性的备份。
另外,mysqlpump 的备份多线程是基于多个表的并行备份,如果数据库中存在一个超级大表,那么对于这个表的备份依然还是单线程的。那么有没有一种基于记录级别的并行备份,且支持一致性的逻辑备份工具呢?
有的,那就是开源的 mydumper 工具,地址:https://github.com/maxbube/mydumper。mydumper 的强大之处在于:
- 支持一致性的备份;
- 可以根据表中的记录进行分片,从而进行多线程的备份;
- 对于恢复操作,也可以是多线程的备份;
- 可以指定单个表进行多线程的恢复。
即便对于一张大表,也可以以 8 个线程,按照每次 10000 条记录的方式进行备份,这样大大提升了备份的性能。
- 物理备份
当然,逻辑备份虽然好,但是它所需要的时间比较长,因为本质上逻辑备份就是进行 INSERT ... SELECT ... 的操作。
而物理备份直接备份数据库的物理表空间文件和重做日志,不用通过逻辑的 SELECT 取出数据。所以物理备份的速度,通常是比逻辑备份快的,恢复速度也比较快。
但它不如 mydumper 的是,物理备份只能恢复整个实例的数据,而不能按指定表进行恢复。MySQL 8.0 的物理备份工具可以选择官方的 Clone Plugin。
不过,物理备份实现机制较逻辑备份复制很多,需要深入了解 MySQL 数据库内核的实现,我强烈建议使用 MySQL 官方的物理备份工具,开源第三方物理备份工具只作为一些场景的辅助手段。
增量备份
前面我们学习的逻辑备份、物理备份都是全量备份,也就是对整个数据库进行备份。然而,数据库中的数据不断变化,我们不可能每时每分对数据库进行增量的备份。
所以,我们需要通过"全量备份 + 增量备份"的方式,构建完整的备份策略。增量备份就是对日志文件进行备份,在 MySQL 数据库中就是二进制日志文件。
因为二进制日志保存了对数据库所有变更的修改,所以"全量备份 + 增量备份",就可以实现基于时间点的恢复(point in time recovery),也就是"通过全量 + 增量备份"可以恢复到任意时间点。
全量备份时会记录这个备份对应的时间点位,一般是某个 GTID 位置,增量备份可以在这个点位后重放日志,这样就能实现基于时间点的恢复。
如果二进制日志存在一些删库的操作,可以跳过这些点,然后接着重放后续二进制日志,这样就能对极端删库场景进行灾难恢复了。
增量备份就是使用之前了解的 mysqlbinlog,但这次额外加上了参数 --read-from-remote-server,表示可以从远程某个 MySQL 上拉取二进制日志,这个远程 MySQL 就是由参数 --host 指定。
参数 --raw 表示根据二进制的方式进行拉取,参数 --stop-never 表示永远不要停止,即一直拉取一直保存,参数 binlog.000001 表示从这个文件开始拉取。
MySQL 增量备份的本质是通过 mysqlbinlog 模拟一个 slave 从服务器,然后主服务器不断将二进制日志推送给从服务器,利用之前介绍的复制技术,实现数据库的增量备份。
备份策略
在掌握全量备份、增量备份的知识点后,我们就能构建自己的备份策略了。
首先,我们要设置全量备份的频率,因为全量备份比较大,所以建议设置 1 周 1 次全量备份,实时增量备份的频率。这样最坏的情况就是要恢复 7 天前的一个全备,然后通过 7 天的增量备份恢复。
对于备份文件,也需要进行备份。我们不能认为备份文件的存储介质不会损坏。所以,至少在 2 个机房的不同存储服务器上存储备份文件,即备份文件至少需要 2 个副本。至于备份文件的保存期限,取决于每个公司自己的要求(比如有的公司要求永久保存,有的公司要求保留至少近 3 个月的备份文件)。
所有的这些备份策略,都需要自己的备份系统进行调度,这个并没有什么特别好的开源项目,需要根据自己的业务需求,定制开发。
备份文件的检查
在我的眼中,备份系统非常关键,并不亚于线上的高可用系统。
在 18 讲中,我们讲到线上主从复制的高可用架构,还需要进行主从之间的数据核对,用来确保数据是真实一致的。
同样,对于备份文件,也需要进行校验,才能确保备份文件的正确的,当真的发生灾难时,可通过备份文件进行恢复。因此,备份系统还需要一个备份文件的校验功能。
备份文件校验的大致逻辑是恢复全部文件,接着通过增量备份进行恢复,然后将恢复的 MySQL实例连上线上的 MySQL 服务器作为从服务器,然后再次进行数据核对。
索引
什么是索引?为什么索引能加速查询?
索引是数据库中一种用于加速数据检索的数据结构,其核心作用类似于书籍的目录,可以帮助数据库系统快速定位到所需数据,而无需扫描整个表。
索引之所以能提升查询速度,在于它在插入时对数据进行了排序(显而易见,它的缺点是影响插入或者更新的性能)。
索引能够显著提升查询速度,主要基于以下两个关键点:
-
减少数据扫描范围 :没有索引时,数据库必须进行全表扫描 ,即逐行检查所有数据。索引通过创建有序的数据结构(如B-Tree),使得数据库可以像使用二分查找一样快速跳过大量不相关的数据,直接定位到目标区域。
-
减少磁盘I/O操作:数据通常存储在磁盘上,而磁盘I/O是数据库操作的主要性能瓶颈。索引本身是一种相对小巧的数据结构,数据库可以先将索引加载到内存中。通过索引查找数据位置后,只需进行极少次的磁盘I/O即可读取目标数据,从而极大提升效率。
索引的数据结构是什么?

B+树和B树有什么区别?
-
B树所有节点(包括非叶子节点)都可能存储数据指针或数据本身;B+树仅叶子节点存储数据指针或数据本身,非叶子节点仅作为索引
-
B树叶子节点之间没有链接 ,是独立的;B+树所有叶子节点通过双向链表连接,形成一个有序序列
-
B树查询性能不稳定 ,可能在任意节点命中,路径长度不一;B+树查询性能稳定,任何查询都必须到达叶子节点,路径长度相同
-
B树范围查询效率 较低,需要进行复杂的中序遍历;B+树极高,通过叶子节点链表顺序扫描即可
-
B树空间利用率相对较低,因为每个节点都存储数据指针;B+树更高,非叶子节点更小,单次磁盘I/O可加载更多索引
B+树和红黑树有什么区别?
-
B+树仅叶子节点 存储数据或数据指针,非叶子节点纯为索引;红黑树所有节点都存储数据
-
B+树叶子节点间通过指针串联成有序链表;红黑树叶子节点(NIL节点)不存储数据,且无链表连接
-
B+树形矮胖 ,磁盘I/O次数极少 ,适合磁盘存储;红黑树树形相对高瘦 ,内存访问效率高,适合内存操作
-
B+树范围查询效率 极高 ,通过叶子节点链表顺序扫描即可;红黑树较低,需要进行复杂的中序遍历
为什么采用B+树作为索引数据结构?
在数据库系统中,尤其是 MySQL 的 InnoDB 存储引擎中,选择 B+树 作为索引的核心数据结构,是一个经过深思熟虑的经典决策。这主要是因为它能非常高效地应对数据库面临的核心瓶颈:磁盘 I/O,并完美适配常见的查询模式。
MySQL的索引和数据都是存在硬盘中,而硬盘是一个非常缓慢的设备,我们在查数据时最好要在尽可能少的磁盘I/O操作内完成。磁盘I/O(特别是随机I/O)的速度比内存操作慢几个数量级,因此减少磁盘I/O次数是提升数据库性能的关键。
1. 极致优化磁盘I/O:矮胖才是王道
-
矮胖结构:B+树是一种多路平衡查找树。它的每个节点(通常大小设置为磁盘页的整数倍,如16KB)可以容纳非常多的键(比如几百个),这使得整棵树的高度非常低。即使对于数亿条记录,B+树的高度也通常维持在3-4层。这意味着查找任何一条数据,最多只需要3-4次磁盘I/O。
-
对比高瘦结构:相比之下,红黑树是二叉树,每个节点只能存一个键。对于同样规模的数据,树高会非常惊人(可能超过20层),导致I/O次数无法接受。
2. 无敌的范围查询:链表功不可没
范围查询(如 WHERE id BETWEEN 100 AND 200)是数据库非常频繁的操作。
-
天然有序链表 :B+树的所有数据行都存储在叶子节点上 ,并且所有叶子节点通过双向指针连接成一个有序链表。
-
高效遍历:当进行范围查询时,系统只需找到范围的起始点,然后顺着叶子节点的链表向后扫描即可,非常高效。而B树的数据散落在各个节点,进行范围查询需要在树的中间节点间来回跳跃和回溯,效率要低得多。
3. 更高的空间利用率与缓存友好性
-
索引与数据分离 :B+树的非叶子节点只存储键(索引)和指向子节点的指针,不存储实际的数据行。这使得非叶子节点非常小。
-
更好的缓存效果:由于非叶子节点只存索引,在内存有限的情况下,可以将更多的非叶子节点缓存到内存中。这进一步减少了磁盘I/O,因为很多查找只需在内存中比较索引,最终只需一次磁盘I/O读取数据页即可。
4. 稳定的全表扫描与排序
由于所有数据都在叶子节点,且链表有序,对B+树索引列进行全表扫描(如 COUNT(*))或排序(ORDER BY)也非常高效,只需遍历叶子节点链表即可,无需访问整个树结构。
为何不选其他结构?
-
哈希索引 :虽然等值查询(
=)是O(1)复杂度,但它无法支持范围查询、排序和模糊查询,这在业务系统中是致命的。因此,它只适用于一些特定的键值存储场景。 -
红黑树 :如前所述,其树高过高,会导致大量的磁盘I/O,完全不适合磁盘存储的数据库索引场景。
-
B树 :B树是B+树的前身,它的每个节点都存储数据。这导致在相同数据量下,B树的树高通常比B+树高 ,因为每个节点能存储的键更少。同时,它的范围查询性能远逊于B+树。
堆表和索引组织表有什么区别?分别应用场景是什么?
堆表和索引组织表是数据库底层两种核心的数据存储结构。
堆表中的数据无序存放, 数据的排序完全依赖于索引(Oracle、Microsoft SQL Server、PostgreSQL 早期默认支持的数据存储都是堆表结构)。


堆表的组织结构中,数据和索引分开存储 。索引是排序后的数据,而堆表中的数据是无序的,索引的叶子节点存放了数据在堆表中的物理地址,当堆表的数据发生改变,且位置发生了变更,所有索引中的地址都要更新,这非常影响性能,特别是对于 OLTP 业务。
堆表查询:主键查询 :需先查主键索引,再根据指针定位数据,通常慢于IOT;二级索引查询 :直接存储数据行的物理地址,回表效率可能更高;范围查询:效率较低,因为数据物理上无序存放
而索引组织表,数据根据主键排序存放在索引中,主键索引也叫聚集索引(Clustered Index)。在索引组织表中,数据即索引,索引即数据。MySQL InnoDB 存储引擎就是这样的数据组织方式;Oracle、Microsoft SQL Server 后期也推出了支持索引组织表的存储方式。但是,PostgreSQL 数据库因为只支持堆表存储,不适合 OLTP 的访问特性,虽然它后期对堆表有一定的优化,但本质是通过空间换时间,对海量并发的 OLTP 业务支持依然存在局限性。



索引组织表查询:主键查询 :效率极高,可直接在聚簇索引的B+树中定位到数据本身;二级索引查询 :叶子节点存储的是主键值 ,需要二次查询(回表),可能更慢;范围查询:性能优异,因为数据按主键物理有序
InnoDB 存储引擎的数据是根据主键索引排序存储的,除了主键索引外,其他的索引都称之为二级索引(Secondeary Index), 或非聚集索引(None Clustered Index)。
二级索引也是一颗 B+ 树索引,但它和主键索引不同的是叶子节点存放的是索引键值、主键值。通过二级索引 idx_name 只能定位主键值,需要额外再通过主键索引进行查询,才能得到最终的结果。这种"二级索引通过主键索引进行再一次查询"的操作叫作"回表"。

索引组织表这样的二级索引设计有一个非常大的好处:若记录发生了修改,则其他索引无须进行维护,除非记录的主键发生了修改。与堆表的索引实现对比着看,你会发现索引组织表在存在大量变更的场景下,性能优势会非常明显,因为大部分情况下都不需要维护其他二级索引。
堆表中的索引都是二级索引,哪怕是主键索引也是二级索引,也就是说它没有聚集索引,每次索引查询都要回表。同时,堆表中的记录全部存放在数据文件中,并且无序存放,这对互联网海量并发的 OLTP 业务来说,堆表的实现的确"过时"了。
主键在设计时可以选择比较顺序的方式,比如自增整型,自增的 UUID 等,所以主键索引的排序效率和插入性能相对较高。二级索引就不一样了,它可能是比较顺序插入,也可能是完全随机的插入。
通常业务是无法要求用户注册的昵称是顺序的,所以索引 idx_name 的插入是随机的, 性能开销相对较大;另外用户昵称通常可更新,但业务为了性能考虑,可以限制单个用户每天、甚至是每年昵称更新的次数,比如每天更新一次,每年更新三次。
而用户注册时间是比较顺序的,所以索引 idx_register_date 的性能开销相对较小, 另外用户注册时间一旦插入后也不会更新,只是用于标识一个注册时间。
联合索引的结构是怎样的?
组合索引(Compound Index)是指由多个列所组合而成的 B+树索引,这和我们之前介绍的B+ 树索引的原理完全一样,只是之前是对一个列排序,现在是对多个列排序。
组合索引既可以是主键索引,也可以是二级索引,下图显示的是一个二级组合索引:

从上图可以看到,组合索引只是排序的键值从 1 个变成了多个,本质还是一颗 B+ 树索引。但是你一定要意识到(a,b)和(b,a)这样的组合索引,其排序结果是完全不一样的。而索引的字段变多了,设计上更容易出问题,如:

假设我们定义一个联合索引idx_name_age:INDEX idx_name_age (name, age)。排序规则为:先按 name排序,name相同再按 age排序。
sql
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
-- ... 其他字段
INDEX idx_name_age (name, age) -- 联合索引
);
表中包含以下样本数据:

它的B+树结构是这样的:

-
排序规则 :正如表格所述,索引条目严格按照
name排序,name相同时再按age排序。因此,"张三"的所有记录排在"李四"之前,并且对于同是"张三"的记录,会按年龄20和25排序。 -
非叶子节点(如根节点) :它们不存储实际数据,只存储索引键(即
(name, age)的组合) 和指向子节点的指针,起到路由导航的作用。例如,要查找"李四"的数据,会定位到节点B。 -
叶子节点:这是所有数据行的索引条目所在。每个条目存储两部分核心信息:
-
联合索引的键值 :即
(name, age)这个组合。 -
对应数据行的主键值
id:这是InnoDB二级索引的特点。叶子节点并不存储整行数据 ,要获取其他字段(如email),需要回表查询 ,即通过这个id值回到主键索引(聚簇索引)中查找完整的行数据。
-
-
叶子节点链表 :所有叶子节点通过双向指针连接成一个有序链表。这个结构对于范围查询 非常高效。例如,查询
name BETWEEN '李四' AND '王五',只需找到起始叶子页2,然后沿着链表扫描到页3即可,无需返回根节点。
了解结构后,我们来看几种典型查询如何利用这颗索引树:
-
精确匹配【索引生效】 :
WHERE name = '张三' AND age = 25-
过程:从根节点开始,依次比较,定位到第一条
name='张三'的记录。然后在"张三"的范围内,二分查找age=25的条目,最终找到(张三,25)-102。由于查询了*,需要回表 ,用主键id=102回主键索引取出整行数据。 -
效率:极高。索引被完全利用。
-
-
部分匹配【索引生效】 :
WHERE name = '李四'-
过程:利用索引快速定位到所有
name='李四'的索引条目(叶子页2)。 -
效率:高 。索引的最左前缀 生效。找到索引条目后,需要根据每个条目上的主键
id(103, 104)回表获取完整数据。
-
-
缺少最左列【索引失效/部分生效】 :
WHERE age = 28-
过程:由于
name是索引的第一列,而age在索引中是局部有序 (仅在相同name内有序)、全局无序 的。数据库无法利用索引树的有序性快速定位,只能全索引扫描(按顺序遍历整个索引树)。 -
效率:低 。在MySQL 8.0之前,这类查询无法有效使用该索引。但从MySQL 8.0开始,引入了索引跳跃扫描 优化,在某些特定场景下(例如
name的不同值很少)可能生效,但其效率通常不如最左前缀匹配。
-
如何利用联合索引提升查询性能?
核心策略一:严格遵守最左前缀法则
这是使用联合索引的黄金法则 。它直接源于B+树的排序方式:先按第一列排序,第一列相同再按第二列排序。
-
查询条件中必须包含联合索引的最左列。
-
例如,对于索引
INDEX (last_name, first_name):-
WHERE last_name = 'Smith'(✅ 使用索引) -
WHERE last_name = 'Smith' AND first_name = 'John'(✅ 完美使用索引) -
WHERE last_name = 'Smith' AND first_name LIKE 'J%'(✅ 使用索引)
-
-
反面教材 ❌:
-
跳过最左列,直接使用后续列。
-
例如,对于索引
INDEX (last_name, first_name):-
WHERE first_name = 'John'(❌ 索引失效 。因为first_name在索引中是局部有序的,没有last_name作为前提,数据库只能全索引扫描。) -
WHERE first_name = 'John' AND last_name = 'Smith'(⚠️ 注意 :这个查询可能会使用索引 ,因为现代数据库的查询优化器足够智能,会调整条件的顺序以匹配索引。但为了代码清晰和可预测性,最佳实践是主动将索引列放在前面。)
-
核心策略二:避免索引列上的计算和函数操作
在索引列上使用函数或计算,会使数据库无法识别索引列本身的原始值,从而导致索引失效。
-
最佳实践 ✅:
-
WHERE order_date = '2023-10-01'(✅ 使用索引) -
WHERE status = 1(✅ 使用索引)
-
-
反面教材 ❌:
-
WHERE YEAR(order_date) = 2023(❌ 索引失效 。数据库需要先对order_date的每一行应用YEAR()函数,然后再比较,无法利用索引的有序性。) -
WHERE amount * 2 > 100(❌ 索引失效。) -
解决方案:重写SQL,将计算移到等式的另一侧。
- 将
WHERE YEAR(order_date) = 2023重写为WHERE order_date >= '2023-01-01' AND order_date < '2024-01-01'(✅ 范围查询,可以使用索引)
- 将
-
核心策略三:巧妙处理范围查询
范围查询(>, <, BETWEEN, LIKE '%')会导致它后面的索引列失效。
-
最佳实践 ✅:
-
把等值查询 的列尽量放在前面,范围查询的列放在最后。
-
例如,对于索引
INDEX (dept, age, salary):-
WHERE dept = 'IT' AND age = 30 AND salary > 5000(✅dept和age用于精确查找,快速定位到数据范围,salary用于过滤。) -
WHERE dept = 'IT' AND age > 30(✅ 使用了索引的(dept, age)两列)
-
-
-
反面教材 ❌:
WHERE dept > 'IT' AND age = 30(⚠️ 仅部分使用索引 。dept是范围查询,导致后面的age无法再使用索引进行精确筛选。数据库只能先找出所有dept > 'IT'的记录,然后在这些记录中扫描age = 30的条目。)
核心策略四:利用覆盖索引减少回表
这是联合索引能带来的巨大性能红利。如果一个索引包含了查询所需要的所有字段,数据库就无需"回表"去读取数据行。
-
最佳实践 ✅:
-
创建包含所有查询字段的联合索引。
-
例如,有一个高频查询:
SELECT dept, age, COUNT(*) FROM employees GROUP BY dept, age; -
为这个查询创建索引:
INDEX (dept, age)。 -
效果 :数据库只需要扫描索引本身就能得到
dept和age的数据,无需访问数据表,速度极快。
-
-
反面教材 ❌:
-
SELECT * FROM employees WHERE dept = 'IT' AND age = 30; -
即使有索引
(dept, age),但由于查询了*(所有列),数据库在索引中找到主键ID后,仍然需要回表 查询完整数据行。如果这个查询非常频繁,可以考虑将常用列也加入索引,如(dept, age, name, salary),但需权衡索引变大的代价。
-
核心策略五:利用索引优化排序和分组
ORDER BY和 GROUP BY子句如果可以利用索引的有序性,就可以避免昂贵的文件排序。
-
最佳实践 ✅:
-
确保
ORDER BY/GROUP BY的列顺序和联合索引的列顺序一致。 -
例如,有索引
INDEX (city, birthday):-
... ORDER BY city, birthday(✅ 利用索引排序,无需额外操作) -
... GROUP BY city, birthday(✅ 利用索引分组)
-
-
-
反面教材 ❌:
-
... ORDER BY birthday(❌ 不满足最左前缀,无法利用索引排序) -
... ORDER BY city DESC, birthday ASC(⚠️ 混合排序方向可能导致无法充分利用索引,需检查执行计划) -
... ORDER BY city, name(❌ 如果索引是(city, birthday),则name列破坏了索引的有序性)
-
有哪些索引失效的场景?为什么会失效?
1. 以通配符开头的 LIKE查询(左模糊或左右模糊匹配)
-
场景 :
WHERE name LIKE '%abc',WHERE name LIKE '%abc%'。 -
根本原因:破坏字符串前缀有序性
B+树字符串索引是按照字符串的从头开始的字符顺序 排列的。
LIKE 'abc%'可以利用索引,因为它有确定的前缀"abc",可以进行范围查找。LIKE '%abc'搜索的是"以abc结尾"的字符串,这个条件在基于前缀构建的索引树中是完全无序的,只能全索引扫描。

假设我们要查询name字段前缀为「林」的数据,也就是namelike '林%',扫描索引的过程:
- 首节点查询比较:林这个字的拼音大小比首节点的第一个索引值中的陈字大,但是比首节点的第二个索引值中的周字小,所以选择去节点2继续查询;
- 节点2查询比较:节点2的第一个索引值中的陈字的拼音大小比林字小,所以继续看下一个索引值,发现节点2有与林字前缀匹配的索引值,于是就往叶子节点查询,即叶子节点4;
- 节点4查询比较:节点4的第一个索引值的前缀符合林字,于是就读取该行数据,接着继续往右匹配,直到匹配不到前缀为林的索引值。
如果使用 name like '%林'方式来查询,因为查询的结果可能是「陈林、张林、周林」等之类的,所以不知道从哪个索引值开始比较,于是就只能通过全表扫描的方式来查询。
2. 在索引列上做计算或使用函数
场景:
-
WHERE YEAR(create_time) = 2023(❌) -
WHERE amount * 2 > 100(❌) -
WHERE SUBSTRING(name, 1, 3) = 'abc'(❌)
根本原因:使索引数据"失真"
索引中存储的是列原始值 。当你使用函数或计算时,优化器无法将转换后的条件(YEAR(create_time))与索引中的原始值(create_time)直接匹配。
-
优化器无法在B+树中搜索
YEAR(create_time)=2023的记录,因为它不知道哪些原始值满足这个条件。 -
它必须对表中每一行的索引列值都应用这个函数,然后才能比较。这种操作的成本(全索引扫描)远高于直接使用索引进行二分查找,因此优化器会选择失效索引。
换言之,索引只有列的原始值,而不是经过函数计算过的值。不过从MySQL 8.0开始,索引特性中增加了函数索引,即可以针对函数计算后的值建立一个索引,也就是说该索引存的是函数计算后的值,所以可以通过扫描索引来查数据。
3. 隐式类型转换
-
场景 :
id列为字符串类型(VARCHAR),但查询写为WHERE id = 100(整数)。 -
根本原因:等价于在列上使用函数
为了让比较能够进行,数据库需要将字符串类型的
id转换为数字,或者将数字100转换为字符串。这取决于规则:-
如果规则是将索引列
id转换为数字 ,则查询变为WHERE CAST(id AS SIGNED) = 100。这等同于在索引列上使用了函数,导致索引失效。 -
如果规则是将查询值100转换为字符串
'100',则索引可能有效。 -
最佳实践:始终保证查询条件的类型与列定义的类型一致。
-
4. 违反最左前缀法则
-
场景 :对于联合索引
INDEX (a, b, c),查询条件不包含最左列a。-
WHERE b = 1(❌) -
WHERE b = 1 AND c = 2(❌) -
WHERE a = 1 AND c = 2(⚠️ 部分失效:仅能利用到列a,c无法用于索引筛选)
-
根本原因:破坏全局有序性
B+树索引的结构是先按a排序,a相同再按b排序,b相同再按c排序。这就像一本电话簿,先按姓氏排序,同姓氏下再按名字排序。
-
如果你跳过姓氏(
a)直接找名字(b),那么名字在整本电话簿中是全局无序的。你无法快速定位,只能一页一页地扫描(全索引扫描)。 -
对于
a=1 AND c=2,你可以利用a=1快速找到所有姓"张"的人,但在这个范围内,c是无序的,你无法利用索引快速找到名叫"三"的人,只能遍历所有姓"张"的人。
关于a=1 AND c=2的情况,MySQL的不同版本处理方式也不一样:
- MySQL 5.5的话,前面的a会走索引,在联合索引找到主键值后,开始回表,到主键索引读取所有a=1的数据行,Server层从存储引擎层获取数据行后,再从Server层对比c字段的值。
- MySQL 5.6之后出了一个"索引下推"的功能,可以在存储引擎层进行索引遍历过程中,对索引包括的字段先做判断,直接过滤掉不满足条件的记录,再返回给Server层。
有索引下推的话,利用索引最左前缀 a=1扫描,跳过缺失的 b列 ,获取所有 a=1的索引条目。在存储引擎层 ,直接利用索引条目中的 c列值预先过滤 c=2的条件 。仅对满足 a=1 AND c=2的索引条目进行回表操作。
5. 使用 OR连接条件(非全覆盖)
-
场景 :
WHERE a = 1 OR b = 2,且表上只有索引(a),没有索引(b)。(or前面是索引列,后面不是) -
根本原因:成本过高与合并操作
-
无法组合索引 :数据库通常无法将两个独立的单列索引(一个在a上,一个在b上)高效地合并起来处理
OR操作。 -
成本估算:优化器会估算两种方案的成本:
-
方案A(使用索引) :用索引
(a)查a=1的结果集R1;全表扫描查b=2的结果集R2;最后对R1和R2进行合并去重。这个过程涉及多次随机I/O和合并操作,成本可能很高。 -
方案B(全表扫描) :直接顺序扫描整个表,一次性过滤出满足
a=1 OR b=2的记录。
-
-
当优化器估算出方案B的成本更低时,就会选择全表扫描,导致索引失效。
-
6. 使用 <>、NOT IN、!=
-
场景 :
WHERE status <> 1,WHERE id NOT IN (1, 2, 3)。 -
根本原因:选择性与成本
-
低选择性 :
<>和NOT IN通常意味着要排除少量数据,返回大部分数据。例如,如果status只有1和2两种状态,status <> 1会返回近50%的数据。 -
访问方式 :使用索引查找"不等于1"的数据,需要查找所有不等于1的索引项,这些项在B+树中是分散存储的,会产生大量的随机I/O。
-
成本对比:优化器认为,这种分散的随机I/O访问成本,可能高于顺序扫描整个表的成本,因此选择全表扫描。
-
7.索引列本身的数据特性
-
场景:索引列数据区分度极低(如"性别"列),或表数据量非常小。
-
根本原因:优化器的成本计算
优化器不只是看有没有索引,而是选择成本最低的执行计划。
-
低区分度:例如,性别列只有'男','女'两个值。即使使用了索引,也会命中大约一半的数据,然后需要回表查询。这个成本可能已经接近甚至超过直接全表扫描的成本。
-
小表:对于数据量极小的表(如配置表),加载索引页再到数据页的随机I/O成本,可能高于直接加载整个表到内存的顺序I/O成本。
-
总结
索引失效的根源可以归结为两大点:
-
技术性失效 :破坏了B+树的有序性,使得快速二分查找无法进行(如最左前缀、函数计算、通配符开头)。
-
策略性失效 :优化器基于成本估算 ,主动选择更高效的全表扫描(如数据区分度低、
NOT IN查询、表数据量小)。
MySQL数据库中的优化器是怎么执行的?根据什么标准选择索引的?
在关系型数据库中,B+ 树索引只是存储的一种数据结构,具体怎么使用,还要依赖数据库的优化器,优化器决定了具体某一索引的选择,也就是常说的执行计划。
而优化器的选择是基于成本(cost),哪个索引的成本越低,优先使用哪个索引。
MySQL 数据库由 Server 层和 Engine 层组成:
- Server 层有 SQL 分析器、SQL优化器、SQL 执行器,用于负责 SQL 语句的具体执行过程;
- Engine 层负责存储具体的数据,如最常使用的 InnoDB 存储引擎,还有用于在内存中存储临时结果集的 TempTable 引擎。
SQL 优化器会分析所有可能的执行计划,选择成本最低的执行,这种优化器称之为:CBO(Cost-based Optimizer,基于成本的优化器)。
在 MySQL中,一条 SQL 的计算成本计算如下所示:
总成本 = I/O 成本 (从磁盘读取数据的成本) + CPU 成本 (内存中数据处理的开销)
其中,CPU Cost 表示计算的开销,比如索引键值的比较、记录值的比较、结果集的排序......这些操作都在 Server 层完成;
IO Cost 表示引擎层 IO 的开销,MySQL 8.0 可以通过区分一张表的数据是否在内存中,分别计算读取内存 IO 开销以及读取磁盘 IO 的开销。
| 成本因子 | 描述 | 计算公式(简化) | 对索引选择的影响 |
|---|---|---|---|
| 1. 扫描数据量 | 需要读取多少数据。 | 数据页数量 * 单页I/O成本 |
核心因素。优化器倾向于选择需要扫描更少数据页的路径。 |
| 2. 数据过滤性 | WHERE条件能过滤掉多少数据。 |
满足条件的行数 / 总行数(选择度) |
选择度越高(过滤掉的数据越多),使用索引的成本就越低。 |
| 3. 回表成本 | 通过二级索引找到主键后,再去主键索引取数据的成本。 | 预估需要回表的行数 * 单次随机I/O成本 |
回表是昂贵的随机I/O。如果需要回表的行数太多,优化器可能直接选择全表扫描(顺序I/O)。 |
| 4. 排序与临时表 | ORDER BY、GROUP BY、DISTINCT等操作是否需额外排序或创建临时表。 |
排序行数 * 排序成本因子 |
如果索引本身有序,可避免排序,大幅降低此项成本。 |
| 5. 索引维护成本 | 使用索引本身的开销(在索引B+树中导航的成本)。 | 索引深度 * 成本常数 |
通常远低于I/O成本,不是主要决策因素。 |
B+ 树索引通常要建立在高选择性的字段或字段组合上,如性别、订单 ID、日期等,因为这样每个字段值大多并不相同。
但是对于性别这样的字段,其值只有男和女两种,哪怕记录数再多,也只有两种值,这是低选择性的字段,因此无须在性别字段上创建索引。
但在有些低选择性的列上,是有必要创建索引的。比如电商的核心业务表 orders,其有字段 o_orderstatus,表示当前的状态。
在电商业务中会有一个这样的逻辑:即会定期扫描字段 o_orderstatus 为支付中的订单,然后强制让其关闭,从而释放库存,给其他有需求的买家进行购买。
但字段 o_orderstatus 的状态是有限的,一般仅为已完成、支付中、超时已关闭这几种。
通常订单状态绝大部分都是已完成,只有绝少部分因为系统故障原因,会在 15 分钟后还没有完成订单,**因此订单状态是存在数据倾斜的。**这时,虽然订单状态是低选择性的,但是由于其有数据倾斜,且我们只是从索引查询少量数据,因此可以对订单状态创建索引。
由于字段 o_orderstatus 仅有三个值,分别为 'O'、'P'、'F'。但 MySQL 并不知道这三个列的分布情况,认为这三个值是平均分布的,但其实是这三个值存在严重倾斜。因此,优化器会认为订单状态为 P 的订单占用 1⁄3 的数据,使用全表扫描,避免二级索引回表的效率会更高。
然而,由于数据倾斜,订单状态为 P 的数据非常少,根据索引 idx_orderstatus 查询的效率会更高。这种情况下,我们可以利用 MySQL 8.0 的直方图功能,创建一个直方图,让优化器知道数据的分布,从而更好地选择执行计划。直方图的创建命令如下所示:
sql
ANALYZE TABLE orders;
UPDATE HISTOGRAM ON o_orderstatus;
在创建完直方图后,MySQL会收集到字段 o_orderstatus 的数值分布。
建立索引有什么优点和缺点?
引入索引机制后,能够给数据库带来的优势很明显:
1. 极大提升数据检索速度
这是索引最核心的价值。索引就像一本书的目录,可以让你快速定位到所需内容,而无需逐页翻阅(全表扫描)。
-
原理 :没有索引,数据库必须进行全表扫描,即读取整个表的每一行数据,时间复杂度为O(N)。有了索引,数据库可以利用B+树等数据结构,以近似O(log N)的时间复杂度快速定位数据。
-
场景 :
SELECT语句中的WHERE、JOIN ON条件。
2. 确保数据的唯一性
唯一索引可以强制表中某列(或列组合)的值是唯一的。
-
原理:数据库在插入或更新数据时,会检查唯一索引,防止重复值出现。
-
场景:用户手机号、身份证号、商品SKU等需要唯一的字段。
3. 加速表之间的连接
当进行多表关联查询时,如果连接条件列上有索引,会极大提高查询效率。
- 原理 :例如
A JOIN B ON A.id = B.a_id,如果在B.a_id上有索引,数据库可以快速找到B表中与A表记录匹配的行,而不是对B表进行全扫描。
4. 优化排序和分组操作
如果 ORDER BY或 GROUP BY的字段顺序与某个索引的顺序一致,数据库可以直接按索引的顺序读取数据,避免昂贵的文件排序。
- 原理 :索引本身就是有序的。查询
ORDER BY create_time DESC,如果create_time上有索引,数据库可以直接按倒序读取索引条目,无需在内存中临时排序。
索引的缺点(为什么不能乱建索引?)
1. 降低数据写入速度
这是创建索引最大的代价。每次对数据的增、删、改,都需要更新相应的索引。
- 原理:插入一行数据,不仅要写入数据行,还要向每个相关的索引B+树中插入新的键值,并维护树的结构平衡(可能引发页分裂、页合并)。一张表上的索引越多,写入开销就越大。
2. 占用额外的磁盘空间
索引是独立的数据结构,需要占用额外的存储空间。
- 原理:一个索引大致相当于一张小表。如果表数据很大,索引占用的空间可能非常可观。这增加了存储成本,也可能导致更频繁的磁盘I/O。
如何正确地使用索引?
第一阶段:索引设计策略
1. 为高频查询设计索引
原则:索引不是越多越好,而是越"准"越好。优先为最频繁、对性能要求最高的查询创建索引。
- 实操:通过数据库的慢查询日志(Slow Query Log)或性能监控工具,找出执行频率高、耗时长、对用户体验影响大的SQL语句,针对性地为它们设计索引。
2. 选择高区分度的列作为索引前缀
原则:索引列的选择性(区分度)越高,过滤掉的数据就越多,索引效率越高。
-
计算公式 :
区分度 = COUNT(DISTINCT column_name) / COUNT(*)。这个值越接近1,区分度越好。 -
实操 :在创建联合索引时,把区分度高的列放在左边。例如,
(user_id, status)通常比(status, user_id)更好,因为user_id的区分度远高于status。
3. 联合索引的列顺序至关重要
原则 :联合索引的顺序应遵循 = 等值查询列在前,范围查询列在后 的原则。
-
实操 :对于查询
WHERE a=1 AND b>2 AND c=3,最优的联合索引是(a, c, b)。因为:-
a=1是等值查询,放在最左。 -
c=3是等值查询,放在第二。 -
b>2是范围查询,放在最后,它后面的索引列会失效。
-
第二阶段:索引创建的最佳实践
4. 避免过度索引
原则:每个索引都会增加写操作(INSERT、UPDATE、DELETE)的负担和存储空间。需要权衡读写比例。
- 实操 :定期审查索引,删除从未被使用或使用频率极低的"僵尸索引"。在MySQL中,可通过
sys.schema_unused_indexes视图查询。
5. 善用覆盖索引
原则:如果索引包含了查询所需的所有字段,数据库可以直接从索引中获取数据,无需回表,性能极高。
- 实操 :针对核心查询,创建覆盖索引。例如,对于查询
SELECT name, age FROM users WHERE city='Beijing',可以创建索引(city, name, age)。执行此查询时,数据库只需扫描索引,无需访问数据行。
6. 为排序和分组创建索引
原则 :ORDER BY和 GROUP BY子句可以利用索引的有序性来避免昂贵的文件排序(Filesort)。
- 实操 :如果常见查询包含
ORDER BY create_time DESC,那么在create_time上创建索引会极大提升性能。对于联合索引,确保ORDER BY的列顺序与索引列顺序一致。
第三阶段:SQL编写规范(避免索引失效)
7. 满足最左前缀法则
这是使用联合索引的铁律。
-
正确示例 :索引
(a, b, c),查询WHERE a=1 AND b=2能充分利用索引。 -
错误示例 :
WHERE b=2或WHERE c=3无法使用该索引。
8. 避免在索引列上使用函数或计算
-
错误写法 :
WHERE YEAR(create_time) = 2023(索引失效) -
正确写法 :
WHERE create_time >= '2023-01-01' AND create_time < '2024-01-01'(索引有效)
9. 谨慎使用 OR、!=、NOT IN
这些操作符容易导致索引失效,尽量用其他方式重写。
OR优化 :WHERE a=1 OR b=2可尝试改为WHERE a=1 UNION ALL WHERE b=2(前提是a、b分别有索引)。
10. 注意模糊查询的通配符位置
-
高效 :
LIKE 'abc%'(前缀匹配,可以使用索引) -
低效 :
LIKE '%abc'(后缀匹配,索引失效)
锁
MySQL有哪些锁?
| 锁的维度 | 锁类型 | 核心特点与作用 |
|---|---|---|
| 按粒度分 | 表级锁 | 锁定整张表,粒度大,并发度低。如 LOCK TABLES ... READ/WRITE和元数据锁(MDL)。 |
| 行级锁 | 锁定某一行或行范围,粒度小,并发度高。是 InnoDB 引擎的核心特性。 | |
| 页级锁 | 锁定一页数据(如 16KB),粒度介于表锁和行锁之间,仅被 BDB 存储引擎支持。 | |
| 按模式/功能分 | **共享锁 (S Lock)** | 又称"读锁",允许其他事务读,但不允许写。通过 SELECT ... LOCK IN SHARE MODE加锁。 |
| **排他锁 (X Lock)** | 又称"写锁",不允许其他事务读(指加锁读)和写。INSERT, UPDATE, DELETE及 SELECT ... FOR UPDATE会加此锁。 |
|
| **意向锁 (Intention Lock)** | 表级锁,用于快速判断表内是否有行被锁定。分为意向共享锁(IS)和意向排他锁(IX)。 | |
| 行级锁的算法 | **记录锁 (Record Lock)** | 锁定索引中的一条具体记录。 |
| **间隙锁 (Gap Lock)** | 锁定一个索引记录区间,但不包括记录本身,防止其他事务在区间内"插入"新记录,从而防止幻读。 | |
| **临键锁 (Next-Key Lock)** | 记录锁 + 间隙锁 的组合,锁定一个前开后闭的区间。它是 InnoDB 在 **可重复读(RR)** 隔离级别下默认的行锁算法。 | |
| **插入意向锁 (Insert Intention Lock)** | 一种特殊的间隙锁,表示事务打算在某个间隙中插入记录。多个插入事务如果插入的位置不冲突,则不会互相等待。 |
1. 表级锁:简单但影响广
表级锁主要包含两种:
-
普通表锁 :通过
LOCK TABLES命令显式加锁。写锁(X)会阻塞其他所有读写操作,读锁(S)会阻塞写操作但不阻塞读操作。 -
元数据锁 (MDL) :这是 MySQL 自动加上的锁。当对一个表做增删改查(DML)操作时,加 MDL 读锁 ;当修改表结构(DDL)时,加 MDL 写锁。MDL 读锁之间不互斥,但读写锁、写写锁之间互斥。这可以防止在查询过程中表结构被修改。
2. 行级锁:InnoDB 高并发的基石
行级锁是 InnoDB 引擎的精髓,其效果与查询是否使用索引密切相关。
-
核心前提 :InnoDB 的行锁是 加在索引上的 。如果查询条件没有使用索引,InnoDB 将无法精准定位到行,从而导致 行锁升级为表锁(实际是通过对所有索引项加临键锁实现)。
-
算法演进:在不同的隔离级别下,InnoDB 使用的锁算法不同。
-
在 **读已提交(RC)** 级别,通常只使用记录锁。
-
在 **可重复读(RR)** 级别,为了彻底解决幻读问题,会使用临键锁或间隙锁。
-
3. 意向锁:协调不同粒度的锁
意向锁是表级锁,它的存在是为了解决一个效率问题:如果事务 A 对某一行加了行锁,之后事务 B 想给整个表加表锁,数据库如何快速判断能否加锁?
-
工作机制 :事务在给一行数据加共享锁(S)前,必须先取得该表的 意向共享锁(IS) ;在给一行数据加排他锁(X)前,必须先取得该表的 意向排他锁(IX)。
-
价值:有了意向锁,事务 B 在申请表锁时,只需查看该表上是否有与自己冲突的意向锁即可,而无需逐行检查是否有行锁,大大提升了效率。

对表结构进行修改会加什么锁?
对表结构进行修改(DDL语句),如 ALTER TABLE,主要涉及的是 元数据锁。
元数据锁 是MySQL在Server层实现的一种表级锁,它的主要目的是保证表结构(元数据)的一致性,确保在查询或修改表数据时,表结构不会被另一个会话随意更改,从而避免出现数据不一致或查询错误。
MDL锁的兼容性规则
| 当前持有的MDL锁 | 新请求:MDL读锁 | 新请求:MDL写锁 |
|---|---|---|
| MDL读锁 | 兼容(允许多个会话同时读) | 不兼容(写锁必须等待所有读锁释放) |
| MDL写锁 | 不兼容(读锁必须等待写锁释放) | 不兼容(写锁必须等待前一个写锁释放) |
工作流程解读:
-
会话1 :启动一个事务(
BEGIN)并执行SELECT * FROM users WHERE ...。该会话会自动获取到users表的 MDL读锁。只要事务不结束(提交或回滚),这个读锁就会一直持有。 -
会话2 :执行
ALTER TABLE users ADD COLUMN ...。它需要获取users表的 MDL写锁 。但由于此时会话1正持有MDL读锁,根据兼容性规则,写锁请求必须排队等待。 -
关键问题 :在会话2的MDL写锁请求在等待期间,它会阻塞后续所有新的MDL锁请求(包括读锁和写锁)。这是因为MDL锁的设计确保了写锁的优先级,防止写锁被后续的读锁"饿死"。
-
后果 :此时如果有会话3 执行
SELECT * FROM users ...,它的MDL读锁请求会被会话2的写锁请求阻塞。导致所有后续的查询操作全部挂起 ,表现为系统"卡死"。如果会话1的事务是一个运行时间很长的查询或一个被遗忘的未提交事务,就可能引发系统雪崩。
对表结构修改主要加的是 MDL写锁 。它的核心风险在于一个未提交的事务(持有MDL读锁)就可能导致后续所有DDL操作阻塞,进而可能引发连锁反应,阻塞所有后续查询,导致服务不可用。
如何避免和解决MDL锁问题?
-
使用 Online DDL:
MySQL 5.6及以上版本支持Online DDL。对于很多常见的DDL操作(如
ADD INDEX、ADD COLUMN等),可以使用ALGORITHM=INPLACE, LOCK=NONE选项,使得在DDL期间不阻塞并发DML操作(SELECT, INSERT, UPDATE, DELETE)。sqlALTER TABLE users ADD COLUMN nickname VARCHAR(100), ALGORITHM=INPLACE, LOCK=NONE; -
在业务低峰期执行DDL。
-
监控长事务 :定期检查
information_schema.innodb_trx表,确保没有长时间未提交的事务。 -
设置锁等待超时 :使用
lock_wait_timeout参数,避免DDL操作无限期等待。 -
使用第三方工具 :对于不支持Online DDL的操作或MySQL旧版本,可以使用
pt-online-schema-change(Percona Toolkit)等工具,通过创建影子表的方式在线修改,最大程度减少锁的影响。
MyISAM存储引擎有行级锁吗?
MyISAM 存储引擎没有行级锁 ,它只支持表级锁。这是 MyISAM 与 InnoDB 的一个核心区别。
MyISAM 的表级锁机制
由于 MyISAM 不支持行级锁,任何针对数据的操作都会对整张表进行加锁。
-
读操作 :执行
SELECT查询时,MySQL 会自动为涉及的表加上表共享读锁。多个会话可以同时获取同一张表的读锁,进行查询操作 。 -
写操作 :执行
UPDATE、DELETE、INSERT等更新操作时,MySQL 会自动为涉及的表加上表独占写锁。一旦某张表上有了写锁,其他会话的所有读写操作都会被阻塞,直到写锁被释放 。
简单来说,在 MyISAM 中,读操作之间不互斥,但读写、写读、写写操作之间是互斥的。
这种设计带来了其特有的优缺点:
-
优点:
-
实现简单:管理开销小,加锁速度快 。
-
不会死锁:因为锁的粒度是整个表,不会出现循环等待资源而死锁的情况 。
-
-
缺点:
- 并发性能低:锁的粒度太大。一个会话在进行写操作时,会阻塞其他所有会话对该表的任何读写操作,在高并发写入或读写混合的场景下,性能较差 。
行级锁有哪些?
| 锁类型 | 英文名 | 功能简介 | 常见触发场景 |
|---|---|---|---|
| 记录锁 | Record Lock | 锁定索引中的一条具体记录。 | 对存在的记录进行精确匹配(如 id = 1)。(**读已提交(READ COMMITTED)** 隔离级别下没有间隙锁,只有记录锁。) |
| 间隙锁 | Gap Lock | 锁定一个索引记录之间的范围 ,但不包括记录本身。 | 在可重复读级别下,使用范围查询或查询不存在的记录。 |
| 临键锁 | Next-Key Lock | 记录锁 + 间隙锁 的组合,锁定一个范围并且包括记录本身。 | 可重复读隔离级别下进行范围查询(默认加锁方式)。 |
| 插入意向锁 | Insert Intention Lock | 一种特殊的间隙锁 ,表示事务打算在某个间隙插入新记录。 | 执行 INSERT操作时。 |
1. 记录锁:精确锁定单条记录
记录锁是最直观的行级锁,它直接锁定索引中的一条特定记录。
-
工作机制 :当SQL语句通过索引精确查找到一条已存在的记录时(例如
SELECT * FROM users WHERE id = 10 FOR UPDATE;),InnoDB就会在该索引项上加记录锁X。 -
主要目的:防止其他事务修改(UPDATE、DELETE)或加排他锁读取这条被锁定的记录。
2. 间隙锁:防止幻读的卫士
间隙锁是MySQL在**可重复读(REPEATABLE READ)** 隔离级别下防止幻读的关键机制。
-
工作机制 :它锁定的是一个左开右开 的区间。例如,如果表中已有id为5和10的记录,执行
SELECT * FROM users WHERE id BETWEEN 6 AND 9 FOR UPDATE;可能会锁定(5, 10)这个区间,阻止其他事务插入id为6、7、8、9的新记录。 -
兼容性:间隙锁与间隙锁之间是兼容的。多个事务可以同时对同一个间隙加间隙锁,因为它们的目标都是防止插入,并不冲突。
3. 临键锁:默认的锁策略
临键锁是InnoDB在可重复读隔离级别下默认的行锁算法 ,它是记录锁和间隙锁的组合,锁定一个左开右闭的区间。
-
工作机制与退化:临键锁的设计很智能,它在保证防止幻读的同时,也会在特定条件下"退化"以提升并发度:
-
退化为记录锁 :当使用唯一索引精确查询一条存在 的记录时(如
id = 5)。 -
退化为间隙锁 :当查询一条不存在 的记录时(如
id = 7,但7不存在)。
-
4. 插入意向锁:为插入而生的特殊锁
插入意向锁是一种特殊的间隙锁,它并不"锁定"以阻止操作,而是信号灯,表明一个事务想在某个间隙中插入新记录。
- 工作机制:多个事务只要插入的位置不冲突(例如,一个想插入id=7,另一个想插入id=8),它们可以同时持有对同一间隙的插入意向锁,而不会互相等待。它的主要作用是优化插入操作的并发性能。
核心要点与最佳实践
-
锁是基于索引的 :这是最重要的原则。只有通过索引条件检索数据,InnoDB才会使用行级锁,否则会升级为表锁。因此,为查询条件建立合适的索引是保证并发性能的基础。
-
隔离级别的影响 :读已提交(READ COMMITTED) 隔离级别下没有间隙锁,只有记录锁。而**可重复读(REPEATABLE READ)** 级别下才有间隙锁和临键锁来防止幻读。
-
监控锁状态 :可以使用
SELECT * FROM performance_schema.data_locks;语句来查看当前的锁信息,这对于分析和解决锁等待或死锁问题非常有帮助。
Innodb引擎是怎么加行级锁的?
锁的基本模式
InnoDB 的行级锁有两种基本模式,决定了事务之间的互动关系 :
| 锁模式 | 简称 | 功能 |
|---|---|---|
| 共享锁 | S锁 | 允许事务读取一行数据。其他事务可以继续加 S锁,但不能加 X锁。 |
| 排他锁 | X锁 | 允许事务更新或删除一行数据。会阻塞其他事务对该行的任何 S锁 或 X锁 请求。 |
兼容性:S锁与S锁兼容,但S锁与X锁、X锁与X锁互斥 。
行级锁的算法(锁的粒度)
InnoDB 通过三种锁算法实现不同粒度的控制,这也是其高效防幻读的关键 :
| 锁算法 | 锁定范围 | 主要作用 | 示例 |
|---|---|---|---|
| 记录锁 | 单个索引条目。 | 确保指定的行不被修改。 | 锁定 id = 5这一行。 |
| 间隙锁 | 索引条目之间的区间(开区间)。 | 防止在区间内插入新行,解决幻读。 | 锁定 (5, 10),防止插入 id=6,7,8,9。 |
| 临键锁 | 记录锁 + 间隙锁,锁定一个左开右闭的区间。 | InnoDB 在 可重复读(RR) 隔离级别下的默认算法。 | 锁定 (5, 10],防止插入id=6-9,并锁住id=10。 |
重要提示:临键锁是默认算法,但在特定条件下会"优化"为更小粒度的锁 。
-
优化为记录锁 :当使用唯一索引 进行等值查询 且记录存在 时。例如
SELECT * FROM users WHERE id = 10 FOR UPDATE;,只会锁住 id=10 这一行 。 -
优化为间隙锁 :当查询的记录不存在 时。例如
SELECT * FROM users WHERE id = 7 FOR UPDATE;(id=7不存在),会锁住 (5, 10) 这个间隙,防止其他事务插入 id=7 。
加锁的前提:意向锁
在给一行数据加行级锁(S锁或X锁)之前 ,InnoDB 会先自动在表级别 设置一种称为意向锁的表锁 。
-
意向共享锁(IS):表示事务准备在表中的某些行上设置 S锁。
-
意向排他锁(IX):表示事务准备在表中的某些行上设置 X锁。
意向锁的主要作用是快速判断表内是否有行被锁定。例如,当一个事务想给整个表加表锁时,如果发现表上已经有另一个事务的 IX锁,就知道表里有行正被修改,从而无需扫描每一行即可快速进入等待状态,提升了效率 。
结合开头的流程图,加锁过程如下:
-
意向锁阶段 :事务在执行写操作(UPDATE, DELETE, INSERT)或带锁读(SELECT ... FOR UPDATE)时,会先获取表的IX锁 。
-
索引遍历 :根据 WHERE 条件,沿 B+ 树索引定位数据。行锁是加在索引上的,即使表没有显式索引,InnoDB 也会使用隐藏的聚簇索引 。
-
应用默认锁 :在索引遍历过程中,所有被访问到的 索引项,默认都会加上 Next-Key Lock,锁定一个左开右闭的区间 。
-
锁优化:根据具体的查询条件(是否唯一索引、记录是否存在等),Next-Key Lock 可能会优化为 Record Lock 或 Gap Lock 。
唯一索引(主键索引)等值查询
当我们用唯一索引进行等值查询的时候,查询的记录存不存在,加锁的规则也会不同:
- 当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的next-keylock会退化成「记录锁 」。
- SELECT * FROM users WHERE id = 10 FOR UPDATE;,如果 id=10的记录存在,InnoDB只需在这条具体的记录上加 记录锁(Record Lock)。这是因为唯一性保证了只有这一条记录会被锁定。
- 当查询的记录是「不存在」的,在索引树找到第一条大于该查询记录的记录后,将该记录的索引中的next-keylock会退化成「间隙锁 」。锁住记录所在的区间,防止插入
- SELECT * FROM users WHERE id = 5 FOR UPDATE;,但 id=5的记录不存在。为了阻止其他事务插入 id=5的记录(防止幻读),InnoDB会在 id=5应该存在的区间上加 间隙锁(Gap Lock)。假设表中已有 id=1和 id=10的记录,那么间隙锁会锁住 (1, 10)这个开区间。
唯一索引(主键索引)范围查询
范围查询是临键锁的典型应用场景。
-
例如 :
SELECT * FROM users WHERE id > 5 AND id < 15 FOR UPDATE;。 -
加锁过程 :InnoDB会从
id=5之后的第一条记录开始,到id=15之前的记录为止,对扫描到的每一条索引记录 都加上 临键锁(Next-Key Lock) 。临键锁是记录锁(锁住当前记录) 和 间隙锁(锁住当前记录之前的间隙) 的组合,形成一个左开右闭的区间,如(5, 10]。这种机制确保了在查询范围内无法插入新记录,从而解决了幻读问题。
非唯一索引的查询
非唯一索引因为存在重复值,加锁逻辑更为复杂,核心是为了防止在相同索引值之间插入新数据。
-
等值查询(记录存在) :例如
SELECT * FROM users WHERE age = 20 FOR UPDATE;,age是非唯一索引。InnoDB会在age索引树上,为所有age=20的索引项加上 临键锁。同时,它还会回表,到主键索引上为这些记录对应的主键记录加 记录锁。 -
范围查询:逻辑与唯一索引范围查询类似,对扫描到的非唯一索引项加临键锁,并回表对主键记录加记录锁。
无索引的查询
这是最需要避免的情况。
-
例如 :
SELECT * FROM users WHERE name = 'Alice' FOR UPDATE;,而name列没有索引。 -
后果 :由于无法通过索引快速定位数据行,InnoDB无法使用行级锁 。为了保证数据一致性,它会被迫升级为表级锁(对每一个索引都会加临键锁)。这会严重降低数据库的并发性能,因为任何一个这样的查询都会阻塞整个表的写操作。
在线上加索引,会发生什么?
线上加索引最常遇到的问题就是锁表 ,导致业务操作被阻塞。其核心原因是 **元数据锁(MDL, Metadata Lock)** 的竞争。
-
长事务是"头号杀手" :如果你在执行
ALTER TABLE添加索引时,当前表上存在一个长时间运行但未提交的事务 (例如,一个慢查询,或者一个开了事务忘了提交/回滚的会话),这个事务会一直持有该表的MDL读锁。你的DDL操作需要获取MDL写锁,就会被阻塞。更严重的是,由于MDL锁的机制,这个写锁请求会阻塞之后所有新的MDL读锁请求,导致后续所有对该表的查询和更新操作全部被挂起,业务可能瞬间"卡死"。 -
Online DDL并非完全无锁 :以MySQL的InnoDB为例,即使使用
ALGORITHM=INPLACE模式,在操作的准备阶段 和最终的提交阶段 ,仍然需要短暂地获取排他MDL锁。如果恰好在那个瞬间有未提交的事务,同样会引发短暂阻塞。
即使成功避免了锁表,添加索引的过程本身也会消耗大量系统资源,可能影响同一数据库实例上的其他业务。
-
CPU、内存和I/O压力 :构建索引需要读取全表数据,进行排序,并写入新的索引页。这个过程会大量占用数据库的CPU周期、内存缓冲池和磁盘I/O带宽。如果服务器资源已经相对紧张,就可能拖慢其他正在运行的查询。
-
主从延迟风险 :在主从复制架构下,在主库上执行DDL操作会写入二进制日志(binlog)。从库需要重放这些日志来添加索引。如果主库是大表,从库性能稍弱,或者从库本身也有读请求,就很容易造成主从复制延迟。
对于大数据表,添加索引操作可能持续数十分钟甚至数小时。长时间操作增加了意外发生的风险。
-
操作中断的后果:如果操作进程因网络超时、客户端工具断开等因素意外终止,数据库需要回滚已经完成的部分工作。这个回滚过程可能和正向操作一样耗时,甚至更久。
-
空间管理:添加索引需要额外的磁盘空间。在执行操作前,务必确保表空间和临时目录有足够的空间裕量,避免因空间不足导致操作失败。
如何安全地在线上给表加索引?
在线上给表加索引是一项需要谨慎操作的任务,处理不当可能导致服务中断。其核心在于选择合适的方案,以在保证数据一致性 的前提下,最小化对在线业务的影响。
以下表格对比了三种主流的方案,根据自身情况快速选择:
| 方案 | 核心原理 | 优点 | 缺点 / 适用场景 |
|---|---|---|---|
| MySQL 原生 Online DDL | 数据库内部完成,采用ALGORITHM=INPLACE(原地重建)或ALGORITHM=INSTANT(仅改元数据,8.0+)算法,仅在开始和结束时短暂申请元数据锁(MDL)。 |
1. 无需额外工具 ,语法简单。 2. 对应用无侵入性 。 3. 多数常见操作(如加二级索引)支持良好。 | 1. 并非所有DDL都支持在线 (如修改列数据类型通常需要锁表)。 2. 大表操作时仍会消耗较多CPU和I/O资源 。 适用 :中小型表,或确认支持ALGORITHM=INPLACE/INSTANT且LOCK=NONE的操作。 |
| pt-online-schema-change (pt-osc) | 通过**创建触发器(Trigger)** 自动将原表的增量数据变更同步到影子表。 | 1. 几乎完全避免锁表 ,业务影响极小。 2. 有回滚机制,数据安全有保障。 | 1. 必须要有主键 。 2. 触发器会增加主库负载 ,在高并发写入场景需谨慎。 3. 需要额外安装工具 。 适用:大型表,且数据库实例性能余量充足,允许触发器开销。 |
| gh-ost | 通过模拟从库并解析binlog 来获取增量数据变更,异步应用到影子表。无需在原表上创建触发器。 | 1. 对主库负载影响更小 ,避免触发器性能瓶颈。 2. 提供丰富的控制参数(如暂停、限流),可控性极强。 | 1. 设置和操作相对复杂。 2. 基于binlog复制,需要关注主从延迟情况。 适用 :极高并发的写入场景,或对主库性能稳定性要求极严苛的生产环境。 |
MySQL Online DDL
Online DDL 是 MySQL 官方自 5.6 版本起推出的原生功能,旨在通过不同的算法减少 DDL 操作期间的锁表时间。
-
工作原理与算法
-
COPY算法:最传统的方式。MySQL 会创建一个临时表,拷贝原表数据并应用DDL,最后替换原表。此过程需要锁表,影响业务,不推荐在线使用。
-
INPLACE算法:直接在原表上进行修改,无需重建整表。在操作过程中,允许并发DML操作。但在某些情况下(如重建聚簇索引)仍需重建表。
-
INSTANT算法:MySQL 8.0 引入。对于加列等操作,仅需修改元数据,无需复制数据,速度极快,是首选。
-
-
如何使用
在执行 DDL 语句时,可以使用
ALGORITHM和LOCK子句来控制行为。
sql
-- 在线添加索引(推荐写法)
ALTER TABLE `your_table` ADD INDEX `idx_email` (`email`), ALGORITHM=INPLACE, LOCK=NONE;
-- 在MySQL 8.0中秒加列
ALTER TABLE `your_table` ADD COLUMN `new_col` INT DEFAULT 0, ALGORITHM=INSTANT;
最佳实践 :始终显式指定 ALGORITHM和 LOCK,并进行预检查
sql
-- 先使用NO_WAIT或WAIT选项测试
ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE, NO_WAIT;
Online DDL执行阶段大致可分为三个阶段:初始化、执行和提交
Initialization阶段
此阶段会使用MDL读锁,禁止其他并发线程修改表结构。服务器将考虑存储引擎能力、语句中指定的操作以及用户指定的ALGORITHM 和 LOCK选项,确定操作期间允许的并发数
Execution阶段
此阶段分为两个步骤 Prepared and Executed;此阶段是否需要MDL写锁取决于Initialization阶段评估的因素 ,如果需要MDL写锁的话,仅在Prepared过程会短暂的使用MDL写锁。其中最耗时的是Excuted过程。
Commit Table Definition阶段
此阶段会将MDL读锁升级到MDL写锁 ,此阶段一般较快,因此独占锁的时间也较短
用新的表定义替换旧的表定义(如果rebuild table)
pt-online-schema-change (pt-osc)
pt-osc 通过一种更通用和安全的方式来实现真正的在线变更,其核心思路是"影子表"+"触发器同步"
工作原理
-
创建影子表 :创建一个与原表结构相同的新表(
_your_table_new),并对其应用DDL变更。 -
创建触发器 :在原表上创建三个触发器(INSERT, UPDATE, DELETE),当原表有数据变更时,触发器会同步这些变更到影子表。
-
拷贝数据 :将原表的数据分块拷贝到影子表。
-
原子切换:当数据同步完成后,通过原子性的重命名操作交换原表和影子表的表名。
-
清理:删除旧表(默认)和触发器。
gh-ost
gh-ost 由 GitHub 开发,采用了一种不同于 pt-osc 的创新设计,通过解析二进制日志来同步数据变更,彻底避免了触发器的使用。
-
工作原理
-
gh-ost 会模拟一个从库,连接到数据库并读取二进制日志事件。
-
在主库上创建一个"幽灵表"(ghost table),并应用DDL变更。
-
从原表将数据分块读取,并应用到幽灵表。同时,持续解析二进制日志,将原表上的增量变更应用到幽灵表。
-
在最后阶段,通过原子性的切换,将原表和幽灵表进行交换。
-
无论选择哪种方案,遵循以下步骤都能极大提升操作的安全性:
-
事前准备与检查
-
备份数据:操作前务必对目标表进行备份。
-
评估操作 :查阅官方文档,确认您的MySQL版本和具体的ALTER TABLE操作是否支持所需的Online DDL算法。可以先使用
ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE, NO_WAIT;进行测试,如果语句执行失败,则说明当前无法以不锁表的方式完成操作。 -
检查长事务 :执行前,查询
information_schema.INNODB_TRX表,确保没有长时间运行未提交的事务。一个未提交的事务可能持有元数据锁,阻塞DDL操作甚至导致后续所有查询被阻塞。
-
-
选择执行窗口
- 务必选择在业务流量最低的时段(如深夜或凌晨)执行。这能最大程度减少万一出现问题时的受影响范围。
-
执行与监控
-
明确指定参数 :如果使用原生Online DDL,强烈建议在SQL中显式指定算法和锁策略,例如
ALTER TABLE your_table ADD INDEX idx_name (name), ALGORITHM=INPLACE, LOCK=NONE;。不要让数据库自行选择。 -
监控系统资源:操作期间,密切监控数据库的CPU、内存、磁盘I/O以及锁等待状态。如果使用第三方工具,它们通常会有进度提示。
-
MySQL死锁是怎么发生的?
MySQL 中的死锁是指两个或更多的事务在执行过程中,因争夺资源而陷入一种相互等待的循环。如果没有外部干预,这些事务都无法继续执行下去。理解死锁的关键在于抓住其发生的核心条件和典型场景。
死锁四大发生的必要条件:
| 必要条件 | 技术描述 | 生活比喻 |
|---|---|---|
| 互斥条件 | 一个资源(如一行数据)一次只能被一个事务独占使用(如排他锁)。 | 独木桥一次只能允许一个人通过。 |
| 请求与保持条件 | 事务在持有至少一个资源的同时,又请求新的资源,并且在等待新资源时不会释放已持有的资源 。 | 你左手拿着手机不放手,同时伸出右手想去拿朋友的平板电脑。 |
| 不可剥夺条件 | 事务已获得的资源,在未使用完之前,不能被其他事务强行剥夺 。 | 你正在用的笔,别人不能直接从你手上抢走。 |
| 循环等待条件 | 事务之间形成了一个头尾相接的循环等待链,每个事务都在等待下一个事务释放资源 。 | 你等朋友还钱,朋友在等他家人还钱,而他家人却在等你还钱。 |
间隙锁(Gap Lock)冲突
在**可重复读(RR)** 隔离级别下,MySQL会使用间隙锁来防止幻读,这引入了更复杂的死锁可能。
-
MySQL的规则 :在执行范围查询或查询不存在的记录时,InnoDB不仅会锁住已有的记录(记录锁),还会锁住索引项之间的"间隙"(Gap Lock),防止其他事务在这个范围内插入新数据。间隙锁与间隙锁之间是兼容的,但会阻塞插入意向锁。
-
如何导致死锁:
-
场景:表中已有id=5和id=10的记录。两个事务都试图在(5,10)这个间隙内插入数据。
-
发生过程:
-
事务A执行
SELECT * FROM table WHERE id BETWEEN 6 AND 9 FOR UPDATE;。它会在(5,10)这个区间上加间隙锁。 -
事务B也执行相同的查询,同样想在(5,10)间插入,它也会获得一个间隙锁(因为间隙锁兼容,所以不会阻塞)。
-
现在,事务A尝试插入id=7,它需要获取一个插入意向锁 。但插入意向锁与事务B持有的间隙锁是冲突的,所以事务A等待。
-
同时,事务B尝试插入id=8,它也需要获取插入意向锁,但这与事务A持有的间隙锁冲突,于是事务B也等待。
-
-
结果:形成了循环等待,死锁发生。
加锁顺序不一致
这是最常见、最经典的死锁场景,直接源于MySQL的行级锁机制。
-
MySQL的规则 :InnoDB的行锁是在需要的时候才加上的,并且要等到事务结束时才统一释放(两阶段锁协议)。
-
如何导致死锁:假设有两个事务,都需要更新两行数据(行1和行2)。
-
事务A 的执行顺序:
UPDATE table SET ... WHERE id = 1;->UPDATE table SET ... WHERE id = 2; -
事务B 的执行顺序:
UPDATE table SET ... WHERE id = 2;->UPDATE table SET ... WHERE id = 1; -
发生过程:
-
事务A锁住了id=1的行。
-
事务B锁住了id=2的行。
-
事务A尝试请求id=2的锁,但该锁已被事务B持有,于是事务A等待。
-
事务B尝试请求id=1的锁,但该锁已被事务A持有,于是事务B等待。
-
-
结果:两个事务互相等待对方释放自己需要的资源,MySQL的死锁检测机制(通常几秒内)会发现这个循环等待,并选择回滚其中一个事务(通常是影响行数较少的那个)来解除死锁。
-
唯一键冲突
在插入或更新有唯一约束的列时,MySQL的锁行为会比较特殊。
-
MySQL的规则:在插入一条新记录前,InnoDB需要检查唯一性。这个检查过程包括一种"猜测"性的加锁,它可能会在唯一索引上锁定一个不存在的记录所在的位置(类似于间隙锁),以确保在事务提交前不会有其他事务插入重复值。
-
如何导致死锁:
-
场景:两个事务并发插入同一条唯一键记录(例如,username='alice')。
-
发生过程:
-
事务A和事务B都尝试插入username='alice'。
-
它们会尝试在唯一索引树上相同的位置加锁。虽然记录尚不存在,但MySQL会通过间隙锁 或插入意向锁来锁定这个"空位"。
-
如果其中一个事务先成功插入了(但未提交),它持有了该行的排他锁。另一个事务的插入会因重复键错误而失败,但在失败前,它可能已经持有了一个共享模式的锁(S锁)来执行重复性检查。
-
如果此时第一个事务需要回滚,或者有复杂的锁竞争,就可能形成事务A等待事务B,同时事务B又在等待事务A的情况。
-
-
怎么排查死锁问题?
第1步:捕获死锁信息
- 查看最近一次死锁详情
这是最直接的方法。在 MySQL 命令行中执行:
sql
SHOW ENGINE INNODB STATUS
在输出结果中,找到 **LATEST DETECTED DEADLOCK** 这一节,这里包含了最近一次死锁的完整信息。
- 开启全量死锁日志记录(推荐用于生产环境)
SHOW ENGINE INNODB STATUS只能看到最近一次死锁。为了长期监控,需要开启参数,将死锁信息记录到错误日志中。
sql
-- 动态开启(重启后失效)
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 或在 my.cnf 中永久开启
[mysqld]
innodb_print_all_deadlocks=ON
第2步:解读死锁日志
死锁日志是分析的关键,它可能看起来复杂,但结构清晰。你需要关注以下几个核心部分:
sql
LATEST DETECTED DEADLOCK
------------------------
2023-10-26 14:20:00 0x7f8c12345678
*** (1) TRANSACTION: // 事务1的信息
TRANSACTION 123456, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1 // 使用了1张表,有1个表锁
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 100, OS thread handle 123456, query id 1000 192.168.1.100 user1 updating
UPDATE accounts SET balance = balance - 100 WHERE id = 1 // 事务1正在执行的SQL
*** (1) HOLDS THE LOCK(S): // 事务1当前持有的锁
RECORD LOCKS space id 100 page no 10 n bits 72 index PRIMARY of table `test`.`accounts` trx id 123456 lock_mode X locks rec but not gap // 持有id=1的排他记录锁(X)
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: // 事务1正在等待的锁
RECORD LOCKS space id 100 page no 11 n bits 72 index PRIMARY of table `test`.`accounts` trx id 123456 lock_mode X locks rec but not gap waiting // 等待id=2的排他记录锁(X)
Record lock, heap no 2 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
*** (2) TRANSACTION: // 事务2的信息
TRANSACTION 123457, ACTIVE 8 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 101, OS thread handle 123457, query id 1001 192.168.1.100 user2 updating
UPDATE accounts SET balance = balance + 50 WHERE id = 2 // 事务2正在执行的SQL
*** (2) HOLDS THE LOCK(S): // 事务2当前持有的锁
RECORD LOCKS space id 100 page no 11 n bits 72 index PRIMARY of table `test`.`accounts` trx id 123457 lock_mode X locks rec but not gap // 持有id=2的排他记录锁(X)
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: // 事务2正在等待的锁
RECORD LOCKS space id 100 page no 10 n bits 72 index PRIMARY of table `test`.`accounts` trx id 123457 lock_mode X locks rec but not gap waiting // 等待id=1的排他记录锁(X)
*** WE ROLL BACK TRANSACTION (2) // MySQL 选择回滚了事务2来解除死锁
解读关键点:
识别事务与SQL:找到 TRANSACTION部分,看清每个事务正在执行什么SQL语句。
分析锁关系:这是核心!
- HOLDS THE LOCK(S):事务已经持有的锁。
- WAITING FOR THIS LOCK TO BE GRANTED:事务正在等待的锁。
假设我们有一个账户表 accounts,其中包含 id=1和 id=2的两条记录。两个并发事务(T1 和 T2)执行的操作序列如下表所示:
| 时间点 | 事务 1 (T1) | 事务 2 (T2) | 锁持有与等待状态分析 |
|---|---|---|---|
| T1 | BEGIN; |
事务T1开始。 | |
| T2 | BEGIN; |
事务T2开始。 | |
| T3 | UPDATE accounts ... WHERE id = 1; |
**T1 成功获取了 id=1这条记录上的排他锁(X锁)** 。 |
|
| T4 | UPDATE accounts ... WHERE id = 2; |
**T2 成功获取了 id=2这条记录上的排他锁(X锁)** 。此时,两个事务相安无事。 |
|
| T5 | UPDATE accounts ... WHERE id = 2; |
T1 试图获取 id=2记录的X锁。 但该锁正被 T2 持有。因此,T1 进入等待状态,等待 T2 释放锁。 |
|
| T6 | UPDATE accounts ... WHERE id = 1; |
T2 试图获取 id=1记录的X锁。 但该锁正被 T1 持有。因此,T2 也进入等待状态,等待 T1 释放锁。 |
画出等待关系图:基于上例:

事务1:持有 id=1的锁,等待 id=2的锁。
事务2:持有 id=2的锁,等待 id=1的锁。
结论:形成了 事务1 -> 事务2 -> 事务1 的循环等待。
MySQL 的 InnoDB 存储引擎内置了死锁检测机制 。当它发现这种循环等待(在内部表现为等待图 wait-for graph 中出现回路)时,会立即介入。为了打破死锁,它会选择其中一个事务作为牺牲者(victim) ,将其回滚(并返回 ERROR 1213 (40001): Deadlock found错误),从而释放该事务持有的所有锁。这样,另一个等待中的事务就能顺利获得所需的锁并继续执行了。
所以第三步:定位问题根源
根据日志中的锁关系,判断死锁类型:
| 死锁类型 | 日志特征 | 解决方案方向 |
|---|---|---|
| 加锁顺序不一致 | 事务以相反顺序访问多行数据(如A先锁1后等2,B先锁2后等1)。 | 统一资源访问顺序(如始终按ID升序操作)。 |
| 间隙锁冲突 | 日志中出现 locks gap before rec、insert intention waiting等。常见于范围查询或插入操作。 |
优化查询,避免不必要的范围查询;或考虑使用读已提交隔离级别。 |
| 索引使用不当 | WHERE条件中的列无索引,导致锁升级。事务可能持有大量记录锁。 |
为查询条件添加合适的索引。 |
| 唯一键冲突 | 发生在并发插入相同唯一键值时,日志可能显示 lock_mode X locks gap before rec insert intention。 |
应用层做唯一性校验,或使用 INSERT ... ON DUPLICATE KEY UPDATE。 |
第4步:实施解决方案
1. 优化应用逻辑
-
强制统一的资源访问顺序 :这是解决顺序型死锁最有效的方法。在代码规范中约定,对于多个需要更新的对象,始终按照固定的顺序进行访问(例如,总是按主键ID由小到大顺序处理)。
-
缩短事务时间:尽快提交事务,避免在事务中包含远程调用、文件IO等耗时操作。
-
实现重试机制:在应用程序中捕获死锁异常(错误码 1213),并进行有限次数的重试(如3次),重试前加入短暂的随机延迟。
2. 优化数据库设计
-
创建合适的索引 :确保
UPDATE/DELETE的WHERE条件使用了索引,避免全表扫描导致锁升级。 -
考虑降低隔离级别 :如果业务允许,将隔离级别从 可重复读(REPEATABLE-READ) 降至 读已提交(READ-COMMITTED),可以避免间隙锁,从而消除一大类死锁。但需评估幻读(Phantom Read)的风险。
怎样避免死锁?
应用层设计与编码实践
许多最有效的死锁避免措施需要在应用程序中实现。
-
强制统一的访问顺序 :这是最重要的一条原则。确保所有需要更新多个资源(如多行数据或多张表)的事务,都按照一个预定义的、相同的顺序 去访问它们。例如,约定总是先更新
id较小的账户,再更新id较大的账户。这能从根源上避免事务之间形成"循环等待" 。 -
保持事务短小精悍:尽量缩短事务的执行时间,并尽快提交。避免在事务内执行远程HTTP调用、处理大型文件或进行复杂的业务逻辑计算。小事务持有锁的时间短,与其他事务发生冲突的概率自然大大降低 。
-
实现重试机制 :既然死锁无法完全避免,你的应用就应该具备"容错"能力。当捕获到死锁错误(MySQL错误码为
1213)时,可以让事务等待一个短暂且随机的时间(如100-300毫秒)后自动重试,通常重试2-3次即可 。
数据库操作与表结构优化
合理的数据库设计和SQL写法能显著减少锁的竞争。
-
创建合适的索引 :确保你的
UPDATE和DELETE语句的WHERE条件都使用了有效的索引。没有索引的查询会导致全表扫描,从而锁定大量不需要的记录,甚至升级为表锁,极大增加死锁风险 。 -
避免长事务:及时提交事务,避免一个数据库连接长时间占用锁资源。对于一些需要长时间处理数据的任务,可以考虑将其拆分为多个更小、更快的事务来完成 。
-
考虑使用乐观锁:在冲突不非常频繁的场景下,乐观锁是避免死锁的一个好方法。它不通过数据库锁实现,而是通过数据版本号或时间戳来判断数据是否被其他事务修改过,从而减少锁争用 。
MySQL服务器配置调整
通过调整数据库参数,可以更好地应对死锁。
-
设置合理的锁等待超时 :通过
innodb_lock_wait_timeout参数(默认50秒)设置一个事务等待锁的最长时间。超时后事务会自动回滚,避免无限期等待。在高并发系统中,可以将其设置为一个较低的值(如10-30秒)。 -
启用详细死锁日志记录 :将
innodb_print_all_deadlocks参数设置为ON,这样所有死锁的详细信息都会被记录到MySQL的错误日志中。这是后续分析和优化的重要依据 。 -
评估隔离级别 :可重复读(Repeatable Read) 隔离级别下存在的间隙锁 是导致复杂死锁的一个常见原因。如果业务允许,将隔离级别降至读已提交(Read Committed),可以避免间隙锁,从而减少一类死锁场景,但需评估幻读风险 。
MySQL如何实现乐观锁?
在MySQL中实现乐观锁,是一种在读取数据时不加锁,而是在更新数据时检查数据是否被其他事务修改过的并发控制方法。它特别适合读多写少的场景。
使用版本号(最常用)
这是最普适的实现方式,你需要先在表中添加一个专门的版本控制字段。
1. 修改表结构
在你的数据表中增加一个整型字段(如 version)来记录版本号。
sql
ALTER TABLE `your_table` ADD COLUMN `version` INT DEFAULT 0;
2. 核心操作流程
关键在于更新操作时的 WHERE条件,必须同时指定主键和旧的版本号。
-
读取数据:在修改数据前,先查询出当前数据和对应的版本号。
-
更新数据:更新时,将版本号作为条件之一。如果版本号匹配,则更新数据并将版本号加1;如果不匹配(数据已被其他事务修改),则更新操作不会影响任何行。
sql
-- 1. 读取数据,获取当前版本号 (假设为 1)
SELECT `id`, `data_field`, `version` FROM `your_table` WHERE `id` = 123;
-- 2. 在应用中进行业务计算...
-- 3. 更新数据,并检查版本号
UPDATE `your_table`
SET `data_field` = 'new_value',
`version` = `version` + 1
WHERE `id` = 123
AND `version` = 1; -- 这里必须是最初读取的版本号
检查结果 :通过判断 UPDATE语句的受影响行数来确定是否更新成功。如果行数为0,意味着版本号不匹配,更新失败,此时你需要根据业务逻辑决定重试或直接报错。
使用时间戳
你也可以利用现有的时间戳字段(如 update_time)来实现乐观锁,其逻辑与版本号类似。
操作流程
-
读取数据:查询数据时,同时获取时间戳。
-
更新数据:更新时,验证时间戳是否未改变。
sql
-- 1. 读取数据,获取当前时间戳
SELECT `id`, `data_field`, `update_time` FROM `your_table` WHERE `id` = 123;
-- 2. 在应用中进行业务计算...
-- 3. 更新数据,并检查时间戳是否未变
UPDATE `your_table`
SET `data_field` = 'new_value',
`update_time` = NOW()
WHERE `id` = 123
AND `update_time` = '2023-10-26 10:00:00'; -- 这里必须是最初读取的时间戳
这种方式的注意事项是,要确保时间戳的精度足够高(如MySQL的 DATETIME或 TIMESTAMP类型),以避免在高并发下因时间粒度较粗而导致误判。
基于业务字段的条件判断
在某些特定业务场景下,你可以直接使用有业务含义的字段作为乐观锁的判断条件。
一个典型的例子是商品库存扣减。更新时,需要确保库存大于0,这种条件过滤方式也属于乐观锁的一种实践。
sql
UPDATE `products`
SET `stock` = `stock` - 1
WHERE `id` = 1001
AND `stock` > 0;
MySQL如何实现悲观锁?
悲观锁通过SQL的**SELECT ... FOR UPDATE** 语句实现。这条语句会为查询到的记录加上排他锁,其他事务在此期间无法修改或加锁这些记录,直到当前事务提交或回滚 。
sql
SELECT * FROM table_name WHERE conditions FOR UPDATE;
实现步骤详解
-
开启事务并关闭自动提交
使用悲观锁必须在事务内进行。首先需要开启事务,并确保MySQL的
autocommit模式已关闭(set autocommit=0;) -
使用
FOR UPDATE加锁在事务中,使用
SELECT ... FOR UPDATE语句查询目标数据并加锁 。sqlSTART TRANSACTION; SELECT * FROM products WHERE product_id = 123 FOR UPDATE;此时,product_id为123的记录已被锁定。
-
执行数据操作
在锁保护下,安全地进行数据修改操作 。
sqlUPDATE products SET stock = stock - 1 WHERE product_id = 123; -
提交或回滚事务
操作完成后,提交事务以释放锁。如果发生错误,则回滚事务 。
sqlCOMMIT; -- 提交事务,释放锁 -- 或 ROLLBACK; -- 回滚事务,释放锁
关键注意事项
-
索引是行锁的前提
InnoDB引擎的行级锁依赖于索引。如果
WHERE条件中的列没有索引,FOR UPDATE查询会退化为表级锁 ,锁定整个表,严重影响并发性能 。因此,务必确保查询条件使用了索引。 -
明确锁的范围
-
明确指定主键 :InnoDB会对指定主键的记录加行级锁 。
-
无索引或范围条件 :如果查询条件不能通过索引明确记录,InnoDB会锁住整个表或一个范围,需特别注意 。
-
-
及时提交事务
悲观锁会阻塞其他事务,长时间持有锁会降低系统并发能力。因此,加锁后应尽快完成操作并提交事务,避免在锁持有期间进行耗时操作 。
适用场景
-
悲观锁 :悲观锁适用于数据争用激烈 、并发冲突概率高 ,且需要强数据一致性的场景,例如库存扣减、账户余额操作等 。
-
乐观锁 :假设冲突很少发生,在更新时才检测冲突。优点 是并发性能高,不会产生锁等待;缺点 是在冲突频繁时,重试成本高。适用于读多写少的场景,如文章点赞、配置信息更新等。
SQL调优
如何分析一条SQL语句是否走了索引?
核心分析方法:使用 EXPLAIN
MySQL 提供了 EXPLAIN命令,它可以展示优化器选择的执行计划。这是分析索引使用情况的首选工具。
基本用法:
sql
EXPLAIN SELECT * FROM users WHERE email = 'user@example.com';
解读 EXPLAIN 结果的关键字段:
| 字段 | 说明 | 解读技巧 |
|---|---|---|
| type | 访问类型,表示MySQL决定如何查找数据。 | 这是判断索引使用效率的核心指标 。从好到坏:system> const> eq_ref> ref> range> index> ALL。 |
| possible_keys | 查询可能使用的索引列表。 | 如果为空,说明没有合适的索引。 |
| key | 查询实际使用的索引。 | 如果为 NULL,说明没有使用索引。 |
| key_len | 使用的索引的长度(字节数)。 | 可用于判断联合索引中实际使用了哪几部分。 |
| rows | 预估需要扫描的行数。 | 值越小越好,表示索引筛选能力强。 |
| Extra | 额外信息,非常关键。 | 常见值:Using index(覆盖索引,极佳)、Using where(在索引后过滤)、Using filesort(需额外排序,需优化)、Using temporary(需临时表,需优化)。 |
场景一:索引被有效使用(理想情况)
- type为 ref或 range:表示使用了索引进行等值或范围查找。
- key显示索引名称:如 idx_user_id。
- rows值很小:如 1, 表示通过索引快速定位到了少量数据。
- Extra为 Using index或空:Using index代表覆盖索引,性能最佳。
场景二:全表扫描(需优化)
- type为 ALL:这是最需要警惕的信号,表示进行了全表扫描。
- key为 NULL:没有使用任何索引。
- rows值非常大:接近表的总行数。
- 解决方法:为 name字段添加索引。但注意,LIKE '%John%'即使有索引也可能失效,通常只有 LIKE 'John%'才能有效利用索引。
场景三:全索引扫描(需评估)
- type为 index:遍历了整个索引树。虽然比全表扫描(ALL)快,但对于大表仍需优化。
- key显示索引名称:使用了某个二级索引(因为二级索引通常比聚簇索引小)。
- Extra为 Using index:说明查询所需数据全在索引中,无需回表,此时效率尚可。
解释下explain命令的type、key、extra这三个字段的输出含义
type字段直接反映了查询的访问数据的方式,性能从优到劣大致排序如下 :
| 类型 | 含义与场景 | 举例 |
|---|---|---|
**system** |
表只有一行数据(系统表),是 const的特例。最快。 |
查询系统表,或衍生表(derived table)只有一行数据。 |
**const** |
通过主键(Primary Key)或唯一索引(Unique Index)进行等值查询,最多返回一条记录。速度极快,因为只读一次。 | SELECT * FROM users WHERE id = 1;(id是主键) |
**eq_ref** |
多表连接(JOIN) 时,对于前表的每一行,后表只能通过主键或唯一索引找到一条匹配记录。是JOIN查询中效率最高的。 | SELECT * FROM a JOIN b ON a.id = b.id;(b.id是主键/唯一索引) |
**ref** |
使用非唯一性索引进行等值查询,或者只使用了唯一索引的"最左前缀"部分。可能返回多条匹配记录。 | SELECT * FROM users WHERE name = 'Alice';(name上有普通索引) |
**range** |
利用索引检索给定范围内的行 。常见于 BETWEEN、>、<、IN()等操作。 |
SELECT * FROM users WHERE id > 10 AND id < 20; |
**index** |
全索引扫描。遍历整个索引树来获取数据。虽然避免了全表扫描,但当索引很大时效率依然不高。 | 查询的列都包含在某个索引中(覆盖索引),但需要扫描整个索引。 |
**ALL** |
全表扫描 。性能最差,需要扫描整张表来找到匹配的行。必须通过增加索引或优化SQL来避免。 | SELECT * FROM users WHERE age = 30;(age列上没有索引) |
理解 key字段及相关字段
- key:表示查询实际使用的索引。如果为 NULL,则说明查询没有使用索引,这通常是性能瓶颈的信号 。
- possible_keys:表示查询可能使用的索引。这个列表是在分析查询的早期阶段生成的。如果此列为空,说明缺乏合适的索引,需要考虑添加索引 。
- key_len:表示索引中使用的字节数。此值可以帮你判断联合索引到底有多少部分被实际使用了。长度越短,说明使用的索引前缀越短 。
Extra字段提供了大量关于SQL执行细节的信息,以下是需要特别关注的几个值 :
| 信息 | 含义与影响 | 优化建议 |
|---|---|---|
**Using index** |
覆盖索引。查询的列完全包含在某个索引中,无需回表查询数据行,性能极佳。 | 理想情况,说明索引设计良好。 |
**Using where** |
表示在从存储引擎拿到数据后,MySQL服务器层还需要使用 WHERE子句中的条件进行过滤。 |
如果 type是 ALL或 index且出现此提示,说明索引筛选不够高效。 |
**Using filesort** |
需要额外的排序操作 。MySQL无法利用索引顺序直接返回结果,需要在内存或磁盘上进行排序,CPU密集型操作,需优化。 | 尝试为 ORDER BY或 GROUP BY的字段创建合适的索引。 |
**Using temporary** |
需要创建临时表来保存中间结果 。常见于 GROUP BY、DISTINCT、UNION等操作。应尽量避免,因为创建临时表开销很大。 |
优化查询结构,或为 GROUP BY/DISTINCT的字段建立索引。 |
**Using index condition** |
索引条件下推 。MySQL会在从索引中检索出数据后,先利用 WHERE条件中的其他索引列进行过滤,然后再回表,这可以减少不必要的回表次数,是优化手段。 |
正向优化,通常无需干预。 |
如何定位一条慢SQL语句?
方法一:使用慢查询日志(最直接有效)
这是定位慢SQL最基本、最有效的方法。慢查询日志会记录所有执行时间超过指定阈值的SQL语句。
sql
-- 查看慢查询日志配置
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'min_examined_row_limit';
-- 如果未开启,临时开启(重启后失效)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2; -- 设置慢查询阈值为2秒
SET GLOBAL slow_query_log_file = '/path/to/your/slow.log';
2. 分析慢查询日志
日志内容包含SQL执行时间、锁定时间、扫描行数等关键信息。
sql
# Time: 2023-10-26T10:00:00.123456Z
# User@Host: user[db] @localhost []
# Query_time: 5.123456 Lock_time: 0.001000 Rows_sent: 10 Rows_examined: 100000
SET timestamp=1698312000;
SELECT * FROM orders WHERE status = 'pending' AND create_time < '2023-10-01';
3. 使用工具分析慢日志
直接阅读日志文件效率低,推荐使用专业工具:
sql
# 使用mysqldumpslow(MySQL自带)
mysqldumpslow -s t /var/lib/mysql/slow.log
# 使用pt-query-digest(功能更强大)
pt-query-digest /var/lib/mysql/slow.log > slow_report.txt
工具会帮你统计出执行最频繁、总耗时最长的SQL,优先优化这些"罪魁祸首"。
如何对慢查询SQL语句进行优化?
索引优化
- 为WHERE条件列和JOIN关联列创建索引
这是最基本的原则。索引应该建在查询条件中的列上。
sql
-- 优化前:全表扫描
SELECT * FROM users WHERE age > 25 AND city = 'Beijing';
-- 优化:为条件列添加索引
ALTER TABLE users ADD INDEX idx_age_city (age, city);
-- 注意:联合索引的顺序很重要,通常把区分度高的列放在前面
- 避免索引失效的写法
即使创建了索引,错误的写法也会导致索引失效:
sql
-- 场景1: LIKE 前导通配符
-- 失效原因: B+树索引基于字符串的前缀排序,前导通配符(如'%abc')使前缀模糊,无法利用索引的有序性。
-- 错误写法:
SELECT * FROM products WHERE name LIKE '%apple';
-- 优化写法: 尽量避免前导通配符,如果必须使用,考虑使用后缀通配符('apple%')或使用全文索引。
SELECT * FROM products WHERE name LIKE 'apple%';
sql
-- 场景2: 对索引列使用函数或计算
-- 失效原因: 索引存储的是列的原值,对列进行函数运算或计算后,数据库无法利用索引的有序性进行查找。
-- 错误写法:
SELECT * FROM orders WHERE YEAR(order_date) = 2023;
SELECT * FROM products WHERE price + 10 > 100;
-- 优化写法: 将操作移至等号右侧,避免对索引列进行计算。
SELECT * FROM orders
WHERE order_date >= '2023-01-01'
AND order_date < '2024-01-01';
SELECT * FROM products WHERE price > 90;
sql
-- 场景3: 隐式类型转换
-- 失效原因: 当比较的两个数据类型不一致时,数据库会对索引列进行隐式类型转换,相当于在列上使用了转换函数。
-- 错误写法: 假设 phone 字段是 VARCHAR 类型
SELECT * FROM users WHERE phone = 13800138000;
-- 优化写法: 确保比较的两个值类型一致。
SELECT * FROM users WHERE phone = '13800138000';
sql
-- 场景4: 违反最左前缀原则
-- 失效原因: 复合索引(a, b, c)是按照 a, b, c 的顺序排序的,如果查询条件不包含最左列 a,则无法利用索引的有序性。
-- 错误写法: 索引为 (a, b, c)
SELECT * FROM table WHERE b = 2 AND c = 3;
-- 优化写法: 查询条件必须包含复合索引的最左列 a。
SELECT * FROM table WHERE a = 1 AND b = 2 AND c = 3;
sql
-- 场景5: 使用 OR 连接非全部索引的条件
-- 失效原因: 当 OR 连接的多个条件中,并非所有条件都能使用索引时,优化器可能会选择全表扫描。
-- 错误写法: 假设 a 列有索引,b 列无索引
SELECT * FROM table WHERE a = 1 OR b = 2;
-- 优化写法: 使用 UNION ALL 将查询拆开,并确保每个子查询都能使用索引。或者为 b 列添加索引。
SELECT * FROM table WHERE a = 1
UNION ALL
SELECT * FROM table WHERE b = 2; -- 注意:如果 b 无索引,第二个查询仍然是全表扫描,所以需要为 b 加索引
sql
-- 场景6: 使用不等于操作符
-- 失效原因: 不等于(!= 或 <>)和 NOT IN 通常需要筛选出大部分数据,优化器可能认为全表扫描比回表查询更高效。
-- 错误写法:
SELECT * FROM orders WHERE status != 'completed';
SELECT * FROM products WHERE category NOT IN (1, 2, 3);
-- 优化写法: 重写为肯定查询,或者使用范围查询。如果数据量不大,可以尝试使用索引。
-- 例如,如果 status 为 'completed' 的记录很少,可以改为:
SELECT * FROM orders WHERE status = 'pending' OR status = 'processing';
-- 如果 category 不在 (1,2,3) 的记录很少,可以改为:
SELECT * FROM products WHERE category > 3 OR category < 1;
sql
-- 场景7: 索引选择性过低
-- 失效原因: 当索引列不同值很少(例如性别只有两种)时,优化器可能认为使用索引的效率不如全表扫描。
-- 错误写法: 为性别列单独创建索引
SELECT * FROM users WHERE gender = 'M';
-- 优化写法: 避免为低选择性列单独创建索引,可以将其作为复合索引的后缀列,以提高整个索引的选择性。
-- 例如,创建索引 (gender, age),然后查询改为:
SELECT * FROM users WHERE gender = 'M' AND age > 20;
sql
-- 场景8: 使用 IS NULL 或 IS NOT NULL
-- 失效原因: 早期版本中,对 NULL 值的查询优化不佳。即使新版本有改善,但 IS NOT NULL 仍然容易导致全表扫描。
-- 错误写法:
SELECT * FROM users WHERE email IS NULL;
-- 优化写法: 设计表时,尽量将字段设置为 NOT NULL 并设置默认值。如果必须使用,可以考虑使用联合索引技巧。
-- 例如,创建索引 (email, name),然后查询改为:
SELECT * FROM users WHERE email IS NULL AND name = 'some_name'; -- 利用联合索引
- 避免深度分页
使用 LIMIT offset, size进行深部分页时(例如查询第5000页,每页20条),MySQL需要先扫描前 offset + size条记录(即5000 * 20=100000条),然后丢弃前offset条,只返回最后的size条。数据库实际需要读取和丢弃的数据量非常大 ,导致性能随着偏移量(offset)的增加而线性下降,在数据量巨大时,查询会变得极其缓慢甚至可能拖垮数据库。
解决方案:游标分页
游标分页的核心思想是记录上一页最后一条数据的位置,作为查询下一页的起点 ,从而避免使用 OFFSET。
sql
-- 第一页查询
SELECT * FROM orders ORDER BY id ASC LIMIT 20;
-- 假设上一页最后一条记录的id是上一页查询结果中最后一条记录的id值,例如 100000
-- 那么下一页查询则为
SELECT * FROM orders WHERE id > 100000 ORDER BY id ASC LIMIT 20;
游标分页的特点与最佳实践
| 特性 | 说明 |
|---|---|
| 优点 | 高性能 :避免OFFSET扫描,深度翻页性能稳定。 适合无限滚动:非常适合移动端"加载更多"场景。 |
| 缺点 | 不能随机跳页 :只能顺序翻页(上一页/下一页)。 游标稳定性:要求排序字段唯一且顺序稳定,结果集在分页过程中如有增删,可能导致数据重复或丢失。 |
| 最佳实践 | 使用唯一、顺序的字段作为游标 :如自增主键、时间戳等。 确保索引覆盖 :为WHERE和ORDER BY涉及的列创建索引。 考虑使用覆盖索引:若查询字段都在索引中,可避免回表,性能更佳。 |
重写查询
- 避免SELECT* :只查询需要的字段,减少网络传输和数据加载开销。
sql
-- 不推荐
SELECT * FROM users WHERE ...;
-- 推荐
SELECT id, name, email FROM users WHERE ...;
- 连表查询时尽量不要关联太多表
当SQL语句中包含了多层嵌套的JOIN时,查询会变得异常复杂,优化器也难以生成最佳的执行计划。一旦关联太多的表,就会导致执行效率变慢,执行时间变长,原因如下:
- 数据量会随表数量呈直线性增长,数据量越大检索效率越低。
- 当关联的表数量过多时,无法控制好索引的匹配,涉及的表越多,索引不可控风险越大。
先过滤,后关联:尽量在JOIN之前,通过子查询或临时表的方式,先利用WHERE条件将每个表的数据过滤到最小,再用这些过滤后的结果集进行关联。例如:
sql
-- 优化前:直接关联后过滤
SELECT * FROM big_table b
JOIN other_table o ON b.id = o.big_id
WHERE b.create_time > '2023-01-01';
-- 优化后:先过滤大表,再关联
SELECT * FROM (SELECT * FROM big_table WHERE create_time > '2023-01-01') b
JOIN other_table o ON b.id = o.big_id;
- 小表驱动大表
理解"小表驱动大表"的原则,关键在于明白数据库执行JOIN操作的过程。它通常采用嵌套循环的方式:先从一个表(驱动表)中取出一条记录,然后去另一个表(被驱动表)中查找匹配的记录。
所谓的以小驱大即是指用小的数据集去驱动大的数据集,说简单一点就是先查小表,再用小表的结果去大表中检索数据。 这个原则的核心在于减少外层循环的次数。假设表A有100条数据,表B有10万条数据。如果让A作为驱动表,数据库只需循环100次,每次通过高效的索引去B表查找。反之,如果让B作为驱动表,则需要循环10万次,即使每次去A表查找很快,巨大的循环次数也会导致性能急剧下降。
如何判断"小表" :这里的"小"并不仅指表的物理行数,更多是指经过WHERE条件过滤后,最终参与JOIN的结果集大小。有时一个大表经过强有力的条件筛选后,返回的数据量可能远小于一个未加过滤的小表。
其实在MySQL的优化器也会有驱动表的优化,当执行多表联查时,MySQL的关联算法为Nest Loop Join,该算法会依照驱动表的结果集作为循环基础数据,然后通过该结果集中一条条数据,作为过滤条件去下一个表中查询数据,最后合并结果得到最终数据集,MySQL优化器选择驱动表的逻辑如下:
- ①如果指定了连接条件,满足查询条件的小数据表作为驱动表。
- ②如果未指定连接条件,数据总行数少的表作为驱动表。
如果在做连表查询时,你不清楚具体用谁作为驱动表,哪张表去join哪张表,这时可以交给MySQL优化器自己选择,但有时候优化器不一定能够选择正确,可能因统计信息不准等原因判断失误。你可以使用 EXPLAIN命令查看执行计划,第一行出现的表通常就是驱动表。