前面四篇文章,你的 SQL 已经能建表、查询、优化------但这一切都假设只有一个用户在操作数据库。真实的生产环境里,几百上千个请求同时读写同一张表才是常态。如果两个用户同时抢最后一门课的选课名额,MySQL 怎么保证不会超卖?如果一个人转账到一半系统崩了,钱会不会丢?本文深入事务、锁和 MVCC,讲清楚 MySQL 是怎么在并发环境下保证数据正确的。
事务是什么
事务(Transaction)是一组要么全部成功、要么全部失败的操作单元。 最经典的例子是转账:
sql
-- 张三转 100 元给李四
UPDATE accounts SET balance = balance - 100 WHERE name = '张三';
UPDATE accounts SET balance = balance + 100 WHERE name = '李四';
如果第一条执行完后系统崩溃了,张三的 100 元扣掉了但李四没收到------钱凭空消失了。有了事务,这两条 UPDATE 被包装成一个原子操作:
sql
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE name = '张三';
UPDATE accounts SET balance = balance + 100 WHERE name = '李四';
COMMIT;
COMMIT 之前,所有的修改对其他连接不可见、随时可以撤销。COMMIT 之后,修改永久生效。
sql
-- 如果发现搞错了,在 COMMIT 之前随时可以反悔
START TRANSACTION;
DELETE FROM students WHERE id = 1;
-- 等等,删错了!
ROLLBACK; -- 撤销,一切回到 START TRANSACTION 之前的状态
一个简单的原则:当一个业务操作需要修改多行数据、而"只改一半"是不可接受的状态时,把它包在一个事务里。 这个原则适用于绝大多数写操作------扣库存+生成订单、从A账户转出+向B账户转入、插入主表+插入关联表。
ACID 四大特性
事务的可靠性建立在四个特性上,简称 ACID。
| 特性 | 含义 | 反例 |
|---|---|---|
| 原子性 (Atomicity) | 要么全做,要么全不做 | 扣了钱没加上 |
| 一致性 (Consistency) | 事务前后,数据都满足所有约束 | 转账后总额不对、余额变成负数 |
| 隔离性 (Isolation) | 并发事务之间互不干扰 | A 读到 B 未提交的修改(脏读) |
| 持久性 (Durability) | 提交后的数据不会丢 | 机房断电后数据没了 |
其中**隔离性(Isolation)**是日常开发中最容易出问题、也是最需要理解的部分。下面重点展开。
并发带来的三个问题
当多个事务同时操作同一份数据时,如果没有任何隔离措施,会出现三种经典问题。我用"学生选课"场景来说明。
脏读
一个事务读到了另一个事务尚未提交的修改。
时间线 事务A(修改分数) 事务B(读分数)
--------------------------------------------------------
T1 UPDATE enrollments
SET score = 95
WHERE student_id = 1
AND course_id = 1;
T2 SELECT score
FROM enrollments
WHERE student_id = 1
AND course_id = 1;
→ 读到 95
T3 ROLLBACK; ← 撤销了!
事务 B 读到了 95 分,但这个值从来没有真正存在过------事务 A 回滚了,分数还是原来的。如果 B 基于这个 95 分做了后续操作(比如"奖励 90 分以上的学生"),就会造成逻辑错误。
不可重复读
在同一个事务内,两次读取同一行数据,结果不一样。
时间线 事务A(查询两次) 事务B(修改并提交)
--------------------------------------------------------
T1 SELECT score FROM enrollments
WHERE student_id = 1
AND course_id = 1;
→ 结果是 88
T2 UPDATE enrollments
SET score = 95
WHERE student_id = 1
AND course_id = 1;
COMMIT;
T3 SELECT score FROM enrollments
WHERE student_id = 1
AND course_id = 1;
→ 结果是 95 ← 和第一次不一样了!
事务 A 内部,两次同样的查询返回了不同的值。如果你的业务逻辑依赖"读到的值在事务期间不变"(比如生成报表时,同一行的数据前后对不上),这就是问题。
幻读
在同一个事务内,两次查询同一个范围的数据,第二次多出来了一些行。
时间线 事务A(统计选课人数) 事务B(插入新选课记录)
--------------------------------------------------------------
T1 SELECT COUNT(*) FROM enrollments
WHERE course_id = 1;
→ 4 人选了高等数学
T2 INSERT INTO enrollments
(student_id, course_id)
VALUES (8, 1);
COMMIT;
T3 SELECT COUNT(*) FROM enrollments
WHERE course_id = 1;
→ 5 人选了高等数学 ← 多了一行!
不可重复读是"同一行数据变了",幻读是"多出来了一些行"。它们的区别在于:不可重复读针对的是已存在的行被修改 ,幻读针对的是新插入或删除的行。
四种隔离级别
MySQL 提供四种隔离级别,每种级别解决了不同的问题。注意:下面的表格是MySQL InnoDB 引擎的实际行为。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现机制 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 不加锁,直接读最新值 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 每次读都生成新的 ReadView |
| REPEATABLE READ (默认) | 不可能 | 不可能 | 不可能(部分) | 事务开始时生成一个 ReadView + 间隙锁 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 所有 SELECT 自动加共享锁 |
MySQL 默认的 REPEATABLE READ (RR) 通过 MVCC + 间隙锁 的组合,在 InnoDB 引擎下实际上解决了幻读。其他数据库(如 PostgreSQL)的 RR 级别可能只解决不可重复读而仍有幻读问题------这是 MySQL InnoDB 的一个优势。
查看和设置隔离级别
sql
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 设置全局隔离级别(影响所有新连接)
SET GLOBAL transaction_isolation = 'REPEATABLE-READ';
-- 设置当前会话的隔离级别
SET SESSION transaction_isolation = 'READ-COMMITTED';
选哪个级别
- SERIALIZABLE:最安全,但并发性能最差。所有读操作都加锁,大量请求会排队。只用于对数据一致性要求极端的金融核心系统。
- REPEATABLE READ:MySQL 默认,兼顾安全性和性能。绝大多数业务场景的默认选择。
- READ COMMITTED:PostgreSQL 和 Oracle 的默认级别。如果系统以简单 CRUD 为主、不太需要事务内多次读的一致性,这个级别够用,并发性能比 RR 更好。
- READ UNCOMMITTED:基本只用于允许脏读的统计类场景(比如近似估算用户数),生产环境极少使用。
MVCC:MySQL 是怎么实现隔离性的
前面讲了"不可重复读被 RR 解决了",但怎么解决的?答案是 MVCC------多版本并发控制。
核心思想
MVCC 不给数据加锁,而是给每个事务一份数据的"快照"。 就像你有一个 Git 仓库,每个事务看到的是仓库在某个时间点的状态,别人后来的修改不会影响你的视图,直到你提交。
InnoDB 的实现依赖于两个隐藏列和一套版本链:
1. 两个隐藏列:
每一行数据实际存储时,InnoDB 会在行数据后面附加两个隐藏列:
DB_TRX_ID(6 字节):最后一次修改这行的事务 IDDB_ROLL_PTR(7 字节):指向 undo log 中上一个版本的指针
2. Undo Log(回滚日志):
每次修改一行数据时,InnoDB 会把修改前的旧版本记录到 undo log 中。所有 undo log 通过 DB_ROLL_PTR 形成一条版本链:
当前版本(trx_id=105) → undo log 版本1(trx_id=103) → undo log 版本2(trx_id=101)
↑ ↑ ↑
DB_ROLL_PTR ────────────→ DB_ROLL_PTR ────────────→ DB_ROLL_PTR → NULL
3. ReadView(读视图):
当事务执行一条 SELECT 时,InnoDB 会创建一个 ReadView,包含以下信息:
- 当前活跃的(未提交的)事务 ID 列表
- 最小活跃事务 ID
- 下一个要分配的事务 ID
InnoDB 根据 ReadView 从版本链中找到对本事务可见 的版本------规则是:如果一行的 DB_TRX_ID 等于本事务 ID(自己改的),可见;如果小于最小活跃事务 ID(在本事务开始前就已提交),可见;如果在活跃事务列表中(未提交),不可见。
RR 和 RC 的 ReadView 策略差异
| REPEATABLE READ | READ COMMITTED | |
|---|---|---|
| ReadView 生成时机 | 事务第一次读取时生成,整个事务期间复用 | 每次读取时都生成新的 ReadView |
| 效果 | 整个事务看到同一个快照------不可重复读被消除 | 每次读都能看到最新已提交的数据------不可重复读仍有可能 |
这就是为什么 RR 能解决不可重复读------因为它的 ReadView 只创建一次,同一事务内无论读多少次,看到的都是同一个快照。
#mermaid-svg-4o1V7tWIqBaZ7hmK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4o1V7tWIqBaZ7hmK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4o1V7tWIqBaZ7hmK .error-icon{fill:#552222;}#mermaid-svg-4o1V7tWIqBaZ7hmK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4o1V7tWIqBaZ7hmK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4o1V7tWIqBaZ7hmK .marker.cross{stroke:#333333;}#mermaid-svg-4o1V7tWIqBaZ7hmK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4o1V7tWIqBaZ7hmK p{margin:0;}#mermaid-svg-4o1V7tWIqBaZ7hmK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4o1V7tWIqBaZ7hmK .cluster-label text{fill:#333;}#mermaid-svg-4o1V7tWIqBaZ7hmK .cluster-label span{color:#333;}#mermaid-svg-4o1V7tWIqBaZ7hmK .cluster-label span p{background-color:transparent;}#mermaid-svg-4o1V7tWIqBaZ7hmK .label text,#mermaid-svg-4o1V7tWIqBaZ7hmK span{fill:#333;color:#333;}#mermaid-svg-4o1V7tWIqBaZ7hmK .node rect,#mermaid-svg-4o1V7tWIqBaZ7hmK .node circle,#mermaid-svg-4o1V7tWIqBaZ7hmK .node ellipse,#mermaid-svg-4o1V7tWIqBaZ7hmK .node polygon,#mermaid-svg-4o1V7tWIqBaZ7hmK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4o1V7tWIqBaZ7hmK .rough-node .label text,#mermaid-svg-4o1V7tWIqBaZ7hmK .node .label text,#mermaid-svg-4o1V7tWIqBaZ7hmK .image-shape .label,#mermaid-svg-4o1V7tWIqBaZ7hmK .icon-shape .label{text-anchor:middle;}#mermaid-svg-4o1V7tWIqBaZ7hmK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4o1V7tWIqBaZ7hmK .rough-node .label,#mermaid-svg-4o1V7tWIqBaZ7hmK .node .label,#mermaid-svg-4o1V7tWIqBaZ7hmK .image-shape .label,#mermaid-svg-4o1V7tWIqBaZ7hmK .icon-shape .label{text-align:center;}#mermaid-svg-4o1V7tWIqBaZ7hmK .node.clickable{cursor:pointer;}#mermaid-svg-4o1V7tWIqBaZ7hmK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4o1V7tWIqBaZ7hmK .arrowheadPath{fill:#333333;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4o1V7tWIqBaZ7hmK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4o1V7tWIqBaZ7hmK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4o1V7tWIqBaZ7hmK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4o1V7tWIqBaZ7hmK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4o1V7tWIqBaZ7hmK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4o1V7tWIqBaZ7hmK .cluster text{fill:#333;}#mermaid-svg-4o1V7tWIqBaZ7hmK .cluster span{color:#333;}#mermaid-svg-4o1V7tWIqBaZ7hmK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4o1V7tWIqBaZ7hmK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4o1V7tWIqBaZ7hmK rect.text{fill:none;stroke-width:0;}#mermaid-svg-4o1V7tWIqBaZ7hmK .icon-shape,#mermaid-svg-4o1V7tWIqBaZ7hmK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4o1V7tWIqBaZ7hmK .icon-shape p,#mermaid-svg-4o1V7tWIqBaZ7hmK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4o1V7tWIqBaZ7hmK .icon-shape .label rect,#mermaid-svg-4o1V7tWIqBaZ7hmK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4o1V7tWIqBaZ7hmK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4o1V7tWIqBaZ7hmK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4o1V7tWIqBaZ7hmK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} trx_id 在本事务开始前已提交
trx_id 是活跃事务
事务开始
分配 trx_id = 105
第一次 SELECT
创建 ReadView
判断版本可见性
根据 DB_TRX_ID + ReadView
可见:返回该版本
不可见:沿 DB_ROLL_PTR 找上一个版本
RR 级别:ReadView 保留
后续 SELECT 复用同一视图
同一事务内永远看到相同数据
一个具体例子来感受 RR 的行为。打开两个 MySQL 会话并排操作:
会话 A:
sql
SET SESSION transaction_isolation = 'REPEATABLE-READ';
START TRANSACTION;
SELECT score FROM enrollments WHERE student_id = 1 AND course_id = 1;
-- 结果:88.0
会话 B(在会话 A 的事务期间执行):
sql
UPDATE enrollments SET score = 95.0
WHERE student_id = 1 AND course_id = 1;
COMMIT;
-- 修改已提交
回到会话 A:
sql
SELECT score FROM enrollments WHERE student_id = 1 AND course_id = 1;
-- 结果:仍然是 88.0 ------ RR 快照挡住了其他事务的提交
COMMIT;
锁机制
MVCC 解决了读-写并发(事务读的时候不阻塞别人写),但当两个事务同时写同一行数据时,必须有锁来协调。
行锁 vs 表锁
- 行锁:只锁住被操作的那几行,其他行可以并行操作。InnoDB 的默认行为,并发性能好。
- 表锁:锁住整张表,其他事务不能读写。MyISAM 的默认行为,并发性能差。
InnoDB 的行锁是加在索引上的,不是加在物理行上的。如果一条 UPDATE 的 WHERE 条件没有走索引,InnoDB 会退化为表锁。
sql
-- name 列有索引 → 行锁,只锁"张三"那一行
UPDATE students SET age = 21 WHERE name = '张三';
-- name 列没有索引 → 表锁,锁住整个 students 表
UPDATE students SET age = 21 WHERE name = '张三';
当你写
UPDATE ... WHERE或DELETE ... WHERE时,确保 WHERE 条件走了索引。不索引的列做更新条件,触发的不只是慢查询------而是表级锁,整个表的写操作都会被阻塞。这个坑线上环境的故障率极高。
共享锁与排他锁
| 锁类型 | SQL 写法 | 兼容性 |
|---|---|---|
| 共享锁 (S Lock) | SELECT ... LOCK IN SHARE MODE (MySQL 8.0 前) / SELECT ... FOR SHARE |
与共享锁兼容,与排他锁冲突 |
| 排他锁 (X Lock) | SELECT ... FOR UPDATE / INSERT / UPDATE / DELETE |
与任何其他锁都冲突 |
排他锁的意思是"我要改这行,在此期间谁也不许动":
sql
START TRANSACTION;
-- 锁定张三的高等数学成绩,准备修改
SELECT * FROM enrollments
WHERE student_id = 1 AND course_id = 1
FOR UPDATE;
-- 此时其他事务不能对这行加任何锁,直到 COMMIT
UPDATE enrollments SET score = 95.0
WHERE student_id = 1 AND course_id = 1;
COMMIT;
间隙锁:为什么 MySQL 的 RR 能防幻读
间隙锁锁住的不是实际存在的行,而是行与行之间的间隙。
在 REPEATABLE READ 隔离级别下,当你执行一条范围查询(比如 WHERE student_id BETWEEN 1 AND 10)时,InnoDB 不仅锁住存在的行,还锁住这些行之间的间隙------防止其他事务在这个间隙中插入新行,从而避免了幻读。
数据行: student_id=1 [间隙] student_id=2 [间隙] student_id=4 [间隙] student_id=7
SELECT ... WHERE student_id BETWEEN 1 AND 7 FOR UPDATE;
锁住: [行1] [间隙1-2] [行2] [间隙2-4] [行4] [间隙4-7] [行7]
间隙锁的代价是降低了并发插入的性能------因为插入可能被间隙锁阻塞。如果你确定自己的业务场景不会出现幻读问题,可以将隔离级别降到 READ COMMITTED + 开启 binlog_format=ROW 来禁用间隙锁。
死锁
死锁是怎么发生的
两个事务各自持有对方需要的锁,互相等待,形成死循环:
时间线 事务A 事务B
--------------------------------------------------------------
T1 START TRANSACTION; START TRANSACTION;
T2 UPDATE enrollments UPDATE enrollments
SET score = 95 SET score = 85
WHERE student_id = 1 WHERE student_id = 2
AND course_id = 1; AND course_id = 1;
-- 锁住了(1,1)这行 -- 锁住了(2,1)这行
T3 UPDATE enrollments UPDATE enrollments
SET score = 90 SET score = 80
WHERE student_id = 2 WHERE student_id = 1
AND course_id = 1; AND course_id = 1;
-- 等待 B 释放(2,1)的锁... -- 等待 A 释放(1,1)的锁...
↑ 死锁! ↑ 死锁!
MySQL 怎么处理死锁
InnoDB 会自动检测 死锁(通过等待图 wait-for graph),并选择回滚代价最小的事务(通常是修改行数最少的那个),让它失败并释放锁。被回滚的事务收到错误:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
排查死锁
sql
-- 查看最近一次死锁的详细信息
SHOW ENGINE INNODB STATUS\G
在输出的 LATEST DETECTED DEADLOCK 部分,你可以看到是哪两个事务、在哪两条 SQL 上发生了死锁、各自持有什么锁、等待什么锁。
预防死锁的四个习惯
1. 固定加锁顺序。 所有事务以相同的顺序访问表和数据行。比如约定:先操作 students 再操作 enrollments,先锁 student_id=1 再锁 student_id=2。
2. 事务尽量短。 事务里只放必须一起执行的操作,不要把发邮件、调第三方 API 这种外部操作放进事务。一个打开几分钟的事务持有锁不放,是制造死锁的最佳配方。
3. 批量操作分批次提交。 不要在一个事务里 DELETE 或 UPDATE 几十万行数据------锁住的间隙会非常大,极易与其他事务发生冲突。
sql
-- 不好的做法
DELETE FROM logs WHERE created_at < '2020-01-01';
-- 好的做法:每次只删 1000 行,分批提交
DELETE FROM logs WHERE created_at < '2020-01-01' LIMIT 1000;
-- 重复执行直到删完,每次 COMMIT 释放锁
4. 在 RR 隔离级别下,注意间隙锁的范围。 范围查询(BETWEEN、>、<)会在 RR 下触发间隙锁。如果业务场景允许,往 RC 降级或用唯一索引做精确查询。
存储引擎
MySQL 支持多种存储引擎,每种引擎有不同的数据存储方式、锁策略和功能特性。查看自己的表用的什么引擎:
sql
SHOW TABLE STATUS WHERE Name = 'students';
-- 或
SHOW CREATE TABLE students;
InnoDB:绝对主流
MySQL 5.5 之后 InnoDB 就是默认引擎,现在占了绝大多数使用场景。它的核心优势:
- 支持事务(ACID)
- 支持行级锁------高并发写入性能好
- 支持外键------保证引用完整性
- MVCC------读写不互相阻塞
- 崩溃恢复------通过 redo log 保证持久性
MyISAM:老古董
MySQL 5.5 之前的默认引擎,现在只存在于遗留系统中。它不支持事务、不支持行锁(只有表锁)、不支持外键。它的唯一优势是------在纯读的场景下,表锁开销比行锁小,读速度可能略快。但在今天这个 SSD + 高并发的时代,这个优势微乎其微。
如果你接手了一个老项目,发现有的表用的是 MyISAM,找个机会迁移到 InnoDB。一行 SQL 的事:
ALTER TABLE xxx ENGINE=InnoDB;。除了全文索引有明显差异外,其他方面的收益远超迁移成本。
其他引擎速览
| 引擎 | 特点 | 适用场景 |
|---|---|---|
| Memory | 数据全在内存,重启丢失 | 临时表、缓存 |
| Archive | 高压缩比,只支持 INSERT 和 SELECT | 归档日志 |
| CSV | 数据存为 CSV 文件 | 数据交换 |
日常开发中,其他引擎知道存在就行,InnoDB 是唯一的默认答案。
实战:模拟并发选课
回到学生选课系统。假设"数据结构"这门课(course_id=4)有人数限制,当前 7 个学生里有 5 人选了。如果同时两个人想选,且只剩最后一个名额,会发生什么?
开两个 MySQL 会话并排操作:
会话 A:
sql
SET SESSION transaction_isolation = 'REPEATABLE-READ';
START TRANSACTION;
-- 查当前选课人数
SELECT COUNT(*) FROM enrollments WHERE course_id = 4;
-- 结果:5
-- 人数没满,插入选课记录
INSERT INTO enrollments (student_id, course_id)
VALUES (6, 4); -- 周八选了数据结构
-- 现在有 6 人选了,还剩 1 个名额
会话 B(在会话 A 提交之前执行):
sql
START TRANSACTION;
-- 查当前选课人数(RR 下看到的是快照,还是 5)
SELECT COUNT(*) FROM enrollments WHERE course_id = 4;
-- 结果:5(快照!)
-- 人数没满,也插入选课记录
INSERT INTO enrollments (student_id, course_id)
VALUES (7, 4); -- 吴九选了数据结构
-- 也成功了------RR 的快照让两个事务都以为还有名额
回到会话 A:
sql
COMMIT;
会话 B:
sql
COMMIT;
两个事务都提交了,选课人数变成了 7------而课程容量是 6。单纯依赖 SELECT COUNT(*) 然后判断再插入,在并发场景下会超卖。
解决方案:用 SELECT ... FOR UPDATE
sql
START TRANSACTION;
-- 锁定 course_id=4 的所有选课行,并锁定间隙(阻止其他事务插入新选课记录)
SELECT COUNT(*) FROM enrollments WHERE course_id = 4 FOR UPDATE;
-- 结果:5
-- 人数没满,插入
INSERT INTO enrollments (student_id, course_id) VALUES (6, 4);
COMMIT;
当会话 A 执行 SELECT ... FOR UPDATE 时,它锁住了 course_id=4 的现有行以及之间的间隙。会话 B 在试图做同样的 SELECT ... FOR UPDATE 时会被阻塞,直到 A 提交。这就保证了计数的正确性。
注意:这个方案的并发瓶颈很明显------同一门课的选课操作被串行化了。在真正高并发的选课系统里,通常会引入 Redis 做缓存计数、用消息队列异步处理,减少数据库锁竞争。这个优化不展开------留给你后面探索。
总结
事务和锁是 MySQL 在并发环境下保障数据正确性的核心机制:
- 事务 = 一组不可分割的操作 ,
COMMIT提交、ROLLBACK回滚 - ACID 中隔离性最需要深入理解------脏读、不可重复读、幻读三种并发问题的定义和区别
- MySQL 默认 REPEATABLE READ,通过 MVCC(ReadView + undo log 版本链)实现快照读,通过间隙锁防止幻读
- 行锁加在索引上,没走索引的 UPDATE/DELETE 会退化为表锁
- 死锁由 InnoDB 自动检测并回滚代价最小的事务,预防靠固定加锁顺序、短事务、分批提交
- InnoDB 是唯一选择,MyISAM 不支持事务和行锁,应迁移
SELECT ... FOR UPDATE是"先查后改"并发场景的标准解决方案,但它会降低并发度,高并发系统需要更复杂的策略
下一篇文章是本系列的最后一篇------从 SQL 终端切换到 Python 代码,用 SQLAlchemy ORM 把"学生选课系统"改写成完整的 FastAPI 项目,并对比 Java/Go/JS 三个生态的 ORM 方案。