跳到主要内容
预计阅读 28 分钟

并发的交通管制 —— 锁机制与日志系统

MVCC让读写互不干扰,但当两个事务同时要修改同一行数据时,仍然需要一种机制来协调冲突。这个机制就是锁。而redo日志和undo日志则是保障事务持久性和原子性的基石。今天我们把MySQL并发控制的最后几块拼图拼完。

📋 开篇自测:你已经知道多少?

  1. MySQL中有哪几种粒度的锁?行锁和表锁各有什么优缺点?
  2. redo日志和undo日志各负责保障ACID中的哪个特性?
  3. 什么是死锁?MySQL如何检测和处理死锁?

一、redo日志——数据安全的最后防线

上一章我们说,事务提交后的修改必须是永久的(持久性)。但Buffer Pool中的脏页不会在事务提交时立刻写回磁盘——那太慢了(随机I/O、写一页16KB太大)。

那如果提交后、脏页写回前系统崩溃了呢?redo日志就是为了解决这个问题。

redo日志的工作原理

事务在执行修改操作时,InnoDB会做两件事:

  1. 修改Buffer Pool中的数据页(在内存中)
  2. 把这次修改的内容记录到redo日志缓冲区(也在内存中)

事务提交时:

  1. 把redo日志缓冲区的内容刷新到磁盘上的redo日志文件

这就是著名的**WAL(Write-Ahead Logging,先写日志)**策略:在数据页写入磁盘之前,先保证对应的日志已经写入磁盘。

事务执行:
  修改操作 → Buffer Pool(内存) + redo log buffer(内存)

事务提交:
  redo log buffer → redo log file(磁盘)  ← 这一步保证持久性
  (Buffer Pool中的脏页稍后再写回磁盘)

崩溃恢复:
  读取redo log file → 重新应用未完成的修改 → 数据恢复

为什么写redo日志比写数据页快

你可能会问:既然都是写磁盘,写redo日志和写数据页有什么区别?区别很大:

  1. redo日志是顺序写入的。磁盘的顺序写入速度是随机写入的几十倍
  2. redo日志记录的内容很小。比如”把第5号页的第100个偏移位置的值从10改成20”,只需要几十字节。而写数据页要写完整的16KB

redo日志的格式

redo日志记录的不是SQL语句,而是对数据页的物理修改。一条redo日志大致包含:

  • type:redo日志类型(有几十种)
  • space ID:表空间编号
  • page number:数据页编号
  • offset:页内偏移量
  • data:修改后的数据

redo日志的写入时机

-- 查看redo日志刷盘策略
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';

这个参数有三个取值:

行为安全性性能
1每次提交都刷盘最安全最慢
2每次提交写到OS缓存,每秒刷盘较安全较快
0每秒批量写入并刷盘最不安全最快

生产环境强烈建议设为1。 牺牲一点性能换取数据零丢失。值为0或2时,如果系统崩溃,可能丢失最多1秒的数据。

redo日志的循环写入

redo日志文件是循环使用的(以下描述适用于 MySQL 8.0.29 及更早版本;8.0.30+ 采用了新的队列式 redo 日志架构,不再使用固定数量的循环文件,而是通过 innodb_redo_log_capacity 参数控制总容量):

  redo log file 0      redo log file 1
┌──────────────┐    ┌──────────────┐
│ █████████░░░ │ →  │ ░░░░░░░░░░░░ │
│   write_pos  │    │              │
│              │    │  checkpoint  │
│              │    │              │
└──────────────┘    └──────────────┘
   █ = 已写入还没checkpoint的日志
   ░ = 可以写入的空间
  • write_pos:当前写入位置,不断往后推进
  • checkpoint:已经刷脏页到磁盘的位置

从checkpoint到write_pos之间(按圆环顺序)的日志就是”活跃日志”——已写入但还没完成刷脏页的部分。活跃区域可能跨越多个文件。例如,如果checkpoint在file 1而write_pos在file 0(已经绕了一圈),那么活跃区域就从file 1的checkpoint位置一直延伸到file 0的write_pos位置,横跨两个文件。

write_pos追上checkpoint时,说明redo日志空间满了,必须等待脏页刷盘后推进checkpoint才能继续写入。此时MySQL的性能会急剧下降。

-- MySQL 8.0.30+:查看和调整redo日志总容量(默认100MB)
SHOW VARIABLES LIKE 'innodb_redo_log_capacity';
-- 建议设置为能容纳1-2小时写入量的大小
SET GLOBAL innodb_redo_log_capacity = 2147483648;  -- 例如设为2GB

-- MySQL 8.0.29 及更早版本(这两个变量在 8.0.30+ 中已废弃)
-- SHOW VARIABLES LIKE 'innodb_log_file_size';
-- SHOW VARIABLES LIKE 'innodb_log_files_in_group';

🤔 想一想 如果把redo日志容量设得特别大,有什么好处和坏处?提示:考虑正常运行时和崩溃恢复时的场景。


二、undo日志——时间倒流的魔法

undo日志和redo日志是一对”双生兄弟”,但功能完全不同:

redo日志undo日志
保障的特性持久性(D)原子性(A)
记录的内容修改后的值修改前的值
用途崩溃后”重做”已提交的修改回滚时”撤销”未完成的修改
额外用途支持MVCC的版本链

undo日志的类型

根据操作类型,undo日志分为两种:

INSERT类型undo日志

当你插入一条新记录时,undo日志记录的是这条记录的主键值。回滚时,根据主键值把这条记录删除。

INSERT INTO users (id, name) VALUES (100, '张三');
→ undo日志:记录下 id=100
→ 回滚时:DELETE WHERE id = 100

UPDATE类型undo日志

当你修改或删除一条记录时,undo日志记录的是修改前的旧值。

UPDATE users SET name = '李四' WHERE id = 100;
→ undo日志:记录下 id=100 的 name 原来是 '张三'
→ 回滚时:UPDATE users SET name = '张三' WHERE id = 100

undo日志与MVCC

上一章讲MVCC时提到的”版本链”,就是由undo日志构成的。每个旧版本就是一条undo日志记录。

这意味着:undo日志不仅在回滚时有用,在正常读取时也有用。 只要有事务还需要看到某个历史版本,对应的undo日志就不能被删除。

这也是长事务危害的根源之一——长事务持有的ReadView可能让很久以前的undo日志都不能被清理,导致undo日志文件(undo表空间)不断膨胀。

-- 查看undo表空间的使用情况
SELECT NAME, FILE_SIZE, ALLOCATED_SIZE
FROM information_schema.INNODB_TABLESPACES
WHERE SPACE_TYPE = 'Undo';

-- 查看等待清理的undo日志量
SHOW STATUS LIKE 'Innodb_history_list_length';
-- 如果这个值持续增长,说明有长事务阻止了undo日志清理

undo日志的存储

MySQL 8.0中,undo日志存储在独立的undo表空间文件中(undo_001undo_002等)。MySQL 8.0.14+支持自动截断过大的undo表空间:

-- 开启undo表空间自动截断
SET GLOBAL innodb_undo_log_truncate = ON;

-- 设置截断阈值(默认1GB,超过就尝试截断)
SET GLOBAL innodb_max_undo_log_size = 1073741824;

⚠️ 常见误区 误区:redo日志和binlog是同一个东西。 它们完全不同。redo日志是InnoDB存储引擎层面的,记录物理修改,用于崩溃恢复。binlog是MySQL Server层面的,记录逻辑操作(SQL语句或行变化),用于主从复制和点位恢复。两者互相配合但各司其职。


三、锁的分类——MySQL的交通信号灯

MVCC解决了”读-写”冲突,但”写-写”冲突必须靠锁来解决。MySQL的锁体系比较复杂,我们分层理解。

按粒度分

表级锁(Table Lock)

锁住整张表。加锁快、冲突多、并发度低。

-- 手动加表锁
LOCK TABLES employees READ;   -- 加读锁(其他事务可读不可写)
LOCK TABLES employees WRITE;  -- 加写锁(其他事务不可读不可写)
UNLOCK TABLES;                -- 释放锁

行级锁(Row Lock)

锁住特定的行。加锁慢、冲突少、并发度高。InnoDB的核心优势之一。

行级锁是InnoDB引擎实现的,MyISAM引擎不支持行级锁。这也是为什么InnoDB取代MyISAM成为默认引擎的重要原因。

按模式分

共享锁(Shared Lock,S锁)

也叫读锁。多个事务可以同时持有同一行的S锁。

-- 手动加共享锁
SELECT * FROM employees WHERE id = 1 LOCK IN SHARE MODE;
-- MySQL 8.0的新语法
SELECT * FROM employees WHERE id = 1 FOR SHARE;

排他锁(Exclusive Lock,X锁)

也叫写锁。一个事务持有X锁时,其他事务既不能加S锁也不能加X锁。

-- 手动加排他锁
SELECT * FROM employees WHERE id = 1 FOR UPDATE;

-- INSERT、UPDATE、DELETE会自动加排他锁
UPDATE employees SET salary = 20000 WHERE id = 1;

S锁和X锁的兼容性矩阵:

S锁X锁
S锁兼容冲突
X锁冲突冲突

意向锁(Intention Lock)

意向锁是表级锁,用来快速判断”表里有没有行被锁住了”。

当事务要对某一行加行锁时,会先在表上加一个对应的意向锁:

  • 要加行级S锁 → 先在表上加意向共享锁(IS)
  • 要加行级X锁 → 先在表上加意向排他锁(IX)

为什么需要意向锁?

假设事务A锁住了表中的第100行。此时事务B想要锁住整张表。如果没有意向锁,事务B需要逐行检查有没有行锁——太慢了。有了意向锁,事务B只需要检查表上有没有IX或IS锁,就能知道表里有行被锁住了。

🤔 想一想 普通的SELECT操作在InnoDB的REPEATABLE READ级别下会加锁吗?(提示:回忆MVCC的工作方式)


四、InnoDB的行锁类型——精准锁定

InnoDB的行级锁不是简单地”锁住一行”。根据锁定范围的不同,分为三种:

Record Lock(记录锁)

锁住索引中的一条确定的记录

-- 锁住id=1的这条记录
SELECT * FROM employees WHERE id = 1 FOR UPDATE;

Gap Lock(间隙锁)

锁住索引中两条记录之间的间隙,不包含记录本身。目的是防止其他事务在这个间隙中插入新记录(防止幻读)。

假设表中有id为1、5、10的三条记录,间隙包括:

(-∞, 1)  (1, 5)  (5, 10)  (10, +∞)
-- 假设表中没有id=3的记录
-- 这条语句会在(1, 5)的间隙上加Gap Lock
SELECT * FROM employees WHERE id = 3 FOR UPDATE;
-- 其他事务无法INSERT id=2、3、4的记录

Next-Key Lock(临键锁)

Record Lock + Gap Lock的组合。锁住一条记录以及它前面的间隙。

假设有记录 1, 5, 10
Next-Key Lock锁定的范围:(-∞, 1]  (1, 5]  (5, 10]  (10, +∞)

InnoDB在REPEATABLE READ级别下默认使用Next-Key Lock。 这就是InnoDB能在RR级别下防止大部分幻读的原因。

行锁加在哪里?

一个关键概念:InnoDB的行锁是加在索引上的,不是加在数据行上的。 如果没有索引,InnoDB会退化为对主键索引的每一行都加锁,效果等同于表锁。

-- 如果department没有索引
-- 这条语句会锁住整张表!(因为要扫描所有行来找department='技术部'的记录)
UPDATE employees SET salary = 20000 WHERE department = '技术部';

-- 如果department有索引
-- 只会锁住department='技术部'的那几行及相关间隙
CREATE INDEX idx_dept ON employees(department);
UPDATE employees SET salary = 20000 WHERE department = '技术部';

这就是为什么WHERE条件列上的索引不仅影响查询性能,还影响锁的粒度。没有合适的索引,行锁退化为表锁,并发度大打折扣。

⚠️ 常见误区 误区一:行锁只锁住满足条件的行。 实际上,InnoDB使用Next-Key Lock,会锁住记录本身和前面的间隙。这可能导致看似无关的INSERT操作被阻塞。

误区二:InnoDB不需要表锁。 InnoDB确实以行锁为主,但在某些DDL操作(ALTER TABLE)和特殊情况下仍然会使用表锁。此外,上面说的意向锁也是表锁。


五、死锁——交通堵死了

当两个事务互相等待对方持有的锁时,就形成了死锁(Deadlock)

事务A                          事务B
───────                        ───────
UPDATE employees SET salary=20000
WHERE id = 1;
(锁住了id=1)
                               UPDATE employees SET salary=15000
                               WHERE id = 2;
                               (锁住了id=2)

UPDATE employees SET salary=18000
WHERE id = 2;
(等待id=2的锁...)
                               UPDATE employees SET salary=16000
                               WHERE id = 1;
                               (等待id=1的锁...)

→ 死锁!双方都在等对方释放

MySQL如何处理死锁

InnoDB有两种死锁处理策略:

方式一:等待超时

-- 查看锁等待超时时间
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 默认50秒。等了50秒还没拿到锁,事务自动回滚

这种方式的问题:50秒太长了。在高并发系统中,一个事务等50秒是不可接受的。

方式二:死锁检测(推荐)

-- 查看是否开启死锁检测
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- 默认ON

InnoDB会维护一个”等待图(Wait-For Graph)“。当检测到图中出现环路时,立刻判定为死锁。InnoDB会选择一个”代价最小”的事务进行回滚(通常选择undo日志量最少的那个事务),让另一个事务继续执行。

-- 查看最近一次死锁的信息
SHOW ENGINE INNODB STATUS\G
-- 在输出中找到 LATEST DETECTED DEADLOCK 部分

如何避免死锁

  1. 按固定顺序访问资源:所有事务都按照相同的顺序访问表和行(比如都按主键从小到大的顺序)
  2. 保持事务尽量短:长事务持有锁的时间越长,死锁的概率越高
  3. 使用合理的索引:避免行锁升级为表锁
  4. 降低隔离级别:READ COMMITTED级别下不使用Gap Lock,减少锁冲突
-- 好的做法:按固定顺序更新
-- 事务A和事务B都按id从小到大的顺序更新
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 先锁id=1
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 再锁id=2
COMMIT;

🤔 想一想 在高并发的电商秒杀场景中,大量事务同时竞争同一件商品的库存(同一行数据),会出现死锁吗?这种场景下的并发问题应该怎样解决?


六、锁的监控与诊断

在生产环境中,锁问题是性能问题的重要来源。掌握锁的监控工具很重要。

查看当前锁等待

-- 查看正在等待锁的事务
SELECT * FROM performance_schema.data_lock_waits\G

-- 查看所有持有的锁
SELECT * FROM performance_schema.data_locks\G

-- 查看正在运行的事务
SELECT * FROM information_schema.INNODB_TRX\G

查看锁等待的详细信息

-- 查看谁在等谁(MySQL 8.0+,使用 performance_schema)
SELECT
    r.trx_id AS waiting_trx_id,
    r.trx_query AS waiting_query,
    b.trx_id AS blocking_trx_id,
    b.trx_query AS blocking_query
FROM performance_schema.data_lock_waits w
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID
JOIN information_schema.INNODB_TRX r ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID;

InnoDB状态输出中的锁信息

SHOW ENGINE INNODB STATUS\G

在输出中关注以下几个部分:

  • TRANSACTIONS:当前事务列表和锁信息
  • LATEST DETECTED DEADLOCK:最近一次死锁的详细信息
  • SEMAPHORES:内部信号量等待情况

实用诊断流程

发现慢查询

检查是否在等待锁:
  SELECT * FROM performance_schema.data_lock_waits;

找到阻塞的事务:
  SELECT * FROM information_schema.INNODB_TRX;

分析阻塞事务在做什么:
  查看 trx_query、trx_started、trx_rows_locked

决策:
  - 等待阻塞事务自然完成
  - 手动KILL阻塞事务的连接
  - 优化SQL/索引减少锁粒度
-- 如果确认某个事务需要被终止
-- 先找到该事务的连接ID
SELECT trx_mysql_thread_id FROM information_schema.INNODB_TRX WHERE trx_id = '某个trx_id';

-- 终止连接
KILL 连接ID;

⚠️ 常见误区 误区:死锁是bug,不应该出现。 在高并发系统中,偶尔出现死锁是正常的。关键是确保应用层能正确处理死锁错误(错误码1213),通常做法是捕获死锁异常后自动重试事务。频繁死锁才需要关注和优化。


七、知识串联——并发控制的全景

让我们把这两章的所有概念串联起来,看看MySQL是如何在保障数据安全的同时支持高并发的:

事务的ACID特性:
  A(原子性)  ← undo日志(支持回滚)
  C(一致性)  ← A+I+D+业务逻辑
  I(隔离性)  ← MVCC(读不加锁)+ 锁(写互斥)
  D(持久性)  ← redo日志(WAL机制)

并发读写的处理:
  读-读:无冲突,直接并行
  读-写:MVCC(读历史版本,写当前版本,互不干扰)
  写-写:行锁互斥(同一行只有一个事务能修改)

锁的层次:
  表锁(意向锁IS/IX)→ 快速判断表中有无行锁
  行锁(Record Lock)→ 锁住具体记录
  间隙锁(Gap Lock)→ 防止幻读
  临键锁(Next-Key Lock)→ Record + Gap的组合

理解了这些机制,你就掌握了MySQL并发控制的完整知识体系。在实际开发中,记住以下几个原则:

  1. 事务尽量短——减少锁持有时间
  2. WHERE条件列要有索引——避免行锁退化为表锁
  3. 按固定顺序操作数据——避免死锁
  4. 生产环境innodb_flush_log_at_trx_commit=1——保证数据不丢失
  5. 监控长事务和锁等待——及早发现问题

📝 掌握度自测

  1. redo日志为什么能保证持久性?为什么写redo日志比直接写数据页快?
  2. undo日志有哪两个用途?它和MVCC的版本链是什么关系?
  3. InnoDB有哪三种行级锁?Next-Key Lock如何帮助防止幻读?
  4. 什么情况下行锁会退化为表锁?如何避免?
  5. 描述一个死锁场景,并说明如何预防。

💡 自我评估

  • 答对5题:锁和日志的核心机制已经掌握!接下来我们将学习主从复制与高可用方案
  • 答对3-4题:理论体系基本建立完成,建议在生产环境中多观察锁和事务的行为
  • 答对0-2题:锁和日志是MySQL最底层的机制,需要多读几遍。建议结合具体场景(如转账、秒杀)来理解

🔭 阶段回顾

到这里,你已经完成了MySQL单机核心知识的学习。让我们回顾一下前10章的知识体系:

  • 入门篇(1-3章):认识MySQL → 字符编码 → 数据的物理存储
  • 进阶篇(4-6章):B+树索引 → 表空间 → 查询技巧
  • 实战篇(7-8章):查询优化/EXPLAIN → Buffer Pool
  • 精通篇(9-10章):事务/MVCC → 锁/日志

这10章构成了一个完整的知识链条:数据怎么存(记录格式、数据页、表空间)→ 数据怎么快速找到(B+树索引)→ 数据怎么安全修改(事务、锁、redo日志、undo日志)→ 数据怎么高效访问(Buffer Pool、查询优化)。

接下来的两章,我们将走出单机的范畴,学习主从复制、高可用方案以及备份恢复——这些是在生产环境中运维MySQL不可或缺的技能。

购买课程解锁全部内容

让查询飞起来:MySQL 从索引到主从高可用

¥29.90