本文以 MySQL 8.x 的 InnoDB 为主要示例。目标不是背概念,而是搞清楚:并发读写时数据库到底做了什么、锁为什么出现、问题如何定位、代码应该怎么写。
1. 先建立一张完整地图
数据库并发控制主要解决两件事:
- 多条 SQL 要么全部成功,要么全部失败——这是事务。
- 多个事务同时访问相同数据时,结果仍然正确——主要依靠 MVCC 与锁。
可以先记住这组结论:
- 普通
SELECT通常通过 MVCC 读取历史版本,不会阻塞写操作。 INSERT、UPDATE、DELETE和锁定读需要加锁。- 锁的对象不等于 SQL 条件;InnoDB 实际锁的是索引记录或索引区间。
- 没有合适索引时,锁的扫描范围可能远大于最终命中的数据。
- 事务越长,占锁越久,发生阻塞和死锁的概率越高。
- 死锁无法仅靠“调整参数”彻底消灭,业务代码必须能够重试。
2. 事务到底保证了什么
事务是一组需要作为整体执行的数据库操作。
START TRANSACTION;
UPDATE account
SET balance = balance - 100
WHERE id = 1;
UPDATE account
SET balance = balance + 100
WHERE id = 2;
COMMIT;
转账不能只完成扣款或只完成入账,所以两条 SQL 必须处于同一个事务中。
2.1 ACID
原子性 Atomicity
事务中的操作要么全部成功,要么全部回滚。
InnoDB 主要借助 undo log 保存修改前的数据,用于事务回滚。
一致性 Consistency
事务执行前后,数据应满足业务规则和数据库约束。
例如转账前后总金额不变、唯一键不能重复、余额不能违反业务约束。一致性不是某一种日志单独提供的,它是原子性、隔离性、持久性、约束设计和业务代码共同作用的结果。
隔离性 Isolation
并发事务之间不能随意看到彼此尚未完成的中间状态。
隔离越强,并发异常越少,但等待和冲突通常越多。
持久性 Durability
事务一旦提交,结果在数据库崩溃后仍应恢复。
InnoDB 主要借助 redo log 实现崩溃恢复。简单区分:
- undo log:记录如何撤销,服务于回滚和 MVCC。
- redo log:记录如何重做,服务于持久化和崩溃恢复。
- binlog:MySQL Server 层的逻辑日志,主要服务于复制、恢复和数据订阅。
3. 并发事务会产生哪些问题
假设事务 A、B 同时操作相同数据,典型异常有四类。
3.1 脏读
事务 B 读到了事务 A 尚未提交的数据;如果 A 回滚,B 读到的就是不存在的状态。
3.2 不可重复读
同一事务中,两次读取同一行得到不同结果,因为其他事务在此期间提交了修改。
3.3 幻读
同一事务用相同条件查询两次,第二次出现了原本不存在的新行,像产生了“幻影”。幻读关注的是结果集中的行数变化。
3.4 丢失更新
两个事务都先读取旧值,再基于旧值计算并写回,后一次写入覆盖前一次结果。
库存初始为 10
事务 A 读取 10,计算得到 9
事务 B 读取 10,计算得到 9
事务 A 写入 9
事务 B 写入 9
最终库存为 9,但实际发生了两次扣减
不要使用“先查、在应用中计算、再覆盖”的方式扣库存。优先让数据库原子更新:
UPDATE product
SET stock = stock - 1
WHERE id = 1001
AND stock >= 1;
然后检查受影响行数:
1:扣减成功。0:库存不足或记录不存在。
4. 四种隔离级别怎么选
SQL 标准定义了四种隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 典型特点 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 隔离最弱,业务系统很少使用 |
| READ COMMITTED | 避免 | 可能 | 可能 | 每次一致性读生成新快照,常用于高并发系统 |
| REPEATABLE READ | 避免 | 避免 | InnoDB 通过 MVCC 和间隙锁处理 | MySQL InnoDB 默认级别 |
| SERIALIZABLE | 避免 | 避免 | 避免 | 并发能力最低,普通读也可能参与锁竞争 |
查看和设置当前会话隔离级别:
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
4.1 READ COMMITTED 与 REPEATABLE READ 的核心区别
对普通快照读而言:
- READ COMMITTED:每条语句通常读取语句开始时的最新已提交快照。
- REPEATABLE READ:事务内通常复用第一次一致性读建立的快照。
因此在 READ COMMITTED 下,同一事务两次普通查询可能看到其他事务刚提交的变化;在 REPEATABLE READ 下,通常仍看到原来的版本。
4.2 隔离级别不是越高越好
选择隔离级别时考虑的是正确性要求与并发成本:
- 普通互联网业务常在 READ COMMITTED 或 REPEATABLE READ 中选择。
- 如果业务已经通过原子 SQL、唯一约束、乐观锁等方式保证一致性,READ COMMITTED 往往能减少锁冲突。
- SERIALIZABLE 不应被当成“不会出错”的开关,它可能明显降低吞吐量,而且不能替代业务约束。
5. MVCC:为什么读写通常互不阻塞
MVCC,即多版本并发控制。它允许数据库保留一行数据的多个版本,让普通查询读取符合自身快照的版本,而不是必须等待最新写事务结束。
InnoDB 的 MVCC 可以从三个要素理解:
- 行记录中的隐藏事务信息。
- undo log 形成的历史版本链。
- Read View,用来判断哪个版本对当前事务可见。
5.1 快照读
普通查询一般是快照读:
SELECT * FROM account WHERE id = 1;
它通常不加行锁,而是根据 Read View 找到可见版本。
5.2 当前读
以下操作需要读取当前最新版本,并可能加锁:
SELECT * FROM account WHERE id = 1 FOR UPDATE;
SELECT * FROM account WHERE id = 1 FOR SHARE;
UPDATE account SET balance = balance - 100 WHERE id = 1;
DELETE FROM account WHERE id = 1;
关键点:同一个事务中,普通 SELECT 和 SELECT ... FOR UPDATE 可能看到不同结果,因为前者是快照读,后者是当前读。
5.3 长事务为什么危险
长事务迟迟不结束时,它可能持续持有旧快照,导致相关 undo 历史版本不能及时清理,同时长期占用锁。常见后果包括:
- undo 空间增长。
- 查询历史版本链变长。
- 锁等待增多。
- 主从复制或变更发布受到影响。
- 回滚成本变高。
所以事务中不要执行远程调用、等待用户输入、上传文件或处理大段无关逻辑。
6. 锁的分类:不要混着背
数据库锁可以从不同维度分类,同一把锁可能同时具有多个标签。
6.1 按锁粒度分类
- 全局锁:影响整个实例,常见于某些备份场景。
- 表级锁:锁住整张表或表级意向状态。
- 行级锁:锁住索引记录或索引范围,并发度最高,管理成本也更高。
6.2 按读写兼容性分类
共享锁 S Lock
允许其他事务继续加共享锁,但阻止不兼容的排他修改。
SELECT * FROM account WHERE id = 1 FOR SHARE;
排他锁 X Lock
用于修改或排他读取。其他事务不能对相同目标获得不兼容的锁。
SELECT * FROM account WHERE id = 1 FOR UPDATE;
常见兼容关系:
| 已持有 / 请求 | S | X |
|---|---|---|
| S | 兼容 | 冲突 |
| X | 冲突 | 冲突 |
6.3 意向锁
InnoDB 的意向共享锁 IS 和意向排他锁 IX 是表级标记,表达事务准备或已经在表内某些行上加对应锁。
它们主要用于快速判断表级锁与行级锁是否冲突。意向锁通常由 InnoDB 自动管理,业务开发不需要手动控制。
6.4 按锁定范围分类
Record Lock:记录锁
锁定某条索引记录。例如用唯一索引等值查询并命中记录时,通常可以精确锁住该索引记录。
Gap Lock:间隙锁
锁定索引记录之间的区间,不锁具体记录,主要防止其他事务在该区间插入新记录。
Next-Key Lock:临键锁
记录锁与间隙锁的组合,通常可理解为前开后闭区间。它既锁现有索引记录,也锁相邻间隙。
在 InnoDB 的 REPEATABLE READ 隔离级别下,范围当前读常使用 Next-Key Lock 控制幻读。
7. InnoDB 锁的其实是索引
这是理解锁问题最重要的一句话:InnoDB 的行锁建立在索引之上。
准备示例表:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status TINYINT NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
KEY idx_user_id (user_id),
KEY idx_status (status)
) ENGINE = InnoDB;
7.1 主键等值查询
SELECT * FROM orders WHERE id = 100 FOR UPDATE;
如果 id = 100 存在,通常精确锁住主键索引中的对应记录。
7.2 唯一索引查询不存在的值
SELECT * FROM user WHERE email = 'a@example.com' FOR UPDATE;
如果唯一索引中不存在该值,为阻止并发插入破坏当前读的结果,数据库可能锁住其应落入的索引间隙,而不是“什么也不锁”。
7.3 非唯一索引查询
SELECT * FROM orders WHERE user_id = 10 FOR UPDATE;
user_id 不是唯一索引,可能匹配多条记录。在 REPEATABLE READ 下,锁定范围通常包含匹配记录及相关间隙。
7.4 没有合适索引
UPDATE orders
SET status = 2
WHERE amount = 99.00;
如果 amount 没有索引,InnoDB 需要扫描大量记录并逐步判断。即使最终只更新几行,过程中涉及的锁范围也可能很大。
这就是很多“明明只改一条,为什么整张表都像卡住了”的根源。严格地说,它不一定升级成了表锁,而可能是大量索引记录被锁,效果接近锁表。
先检查执行计划:
EXPLAIN UPDATE orders
SET status = 2
WHERE amount = 99.00;
8. 锁什么时候释放
InnoDB 中,大部分事务锁在事务提交或回滚时释放,而不是某条 SQL 执行完成后立即释放。
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 此处即使 UPDATE 已执行完,相关锁通常仍然存在
COMMIT;
因此,下面这些代码结构风险很高:
开启事务
更新数据库并持有锁
调用支付、库存或消息服务
等待远程响应
继续更新数据库
提交事务
远程调用一旦超时,数据库锁会被一起拖住。应尽量把外部调用移出本地事务,并通过本地消息表、事务消息或 Saga 等机制处理跨服务一致性。
9. 悲观锁与乐观锁怎么选
9.1 悲观锁
悲观锁假设冲突很可能发生,所以先锁定再处理。
START TRANSACTION;
SELECT stock
FROM product
WHERE id = 1001
FOR UPDATE;
UPDATE product
SET stock = stock - 1
WHERE id = 1001;
COMMIT;
适合:
- 冲突概率高。
- 临界区很短。
- 操作必须基于最新值完成。
注意:FOR UPDATE 必须处在有效事务中才有实际业务意义;如果开启自动提交后语句立即提交,锁也会随即释放。
9.2 乐观锁
乐观锁不提前阻塞其他事务,而是在更新时检查数据是否仍是自己读到的版本。
SELECT id, balance, version
FROM account
WHERE id = 1;
应用得到 version = 7 后执行:
UPDATE account
SET balance = 900,
version = version + 1
WHERE id = 1
AND version = 7;
检查受影响行数:
1:更新成功。0:数据已被其他事务修改,需要重新读取并重试或提示冲突。
适合:
- 读多写少。
- 冲突概率低。
- 业务可以接受有限次数重试。
9.3 能用原子 SQL,就别急着“先查再锁”
库存扣减通常可直接写成:
UPDATE product
SET stock = stock - :quantity
WHERE id = :id
AND stock >= :quantity;
相比“查询库存 → 应用判断 → 更新库存”,一条带条件的原子更新通常更短、更安全,也减少了事务中的往返次数。
10. 死锁是怎么形成的
死锁不是“锁等得太久”,而是多个事务形成了循环等待。
事务 A 已锁住订单 1,等待订单 2
事务 B 已锁住订单 2,等待订单 1
复现示例。
会话 A:
START TRANSACTION;
UPDATE account SET balance = balance - 10 WHERE id = 1;
UPDATE account SET balance = balance + 10 WHERE id = 2;
会话 B:
START TRANSACTION;
UPDATE account SET balance = balance - 10 WHERE id = 2;
UPDATE account SET balance = balance + 10 WHERE id = 1;
如果两边交错执行,就可能产生死锁。InnoDB 检测到死锁后,会选择其中一个事务回滚,让另一个继续。
10.1 死锁与锁等待超时的区别
- 死锁:形成循环依赖,数据库检测后通常主动回滚一个事务。
- 锁等待超时:事务在等待其他事务释放锁,但未必存在环;等待超过阈值后报错。
两者处理方向不同,排查时不要混为一谈。
10.2 降低死锁概率
固定加锁顺序
转账时始终按较小账户 ID 到较大账户 ID 的顺序加锁:
SELECT id, balance
FROM account
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
仅写 ORDER BY 还不够,所有相关业务入口都要遵循同样顺序。
缩短事务
事务内只保留必要的数据库操作,不做网络请求和耗时计算。
建立正确索引
让 SQL 尽快定位目标记录,减少扫描和加锁范围。
拆分超大事务
一次更新几十万行会长期占锁、产生大量日志。可按主键分批处理,但必须重新设计批次失败后的幂等与恢复逻辑。
应用层重试
即使设计合理,死锁仍可能发生。对可安全重试的事务,捕获死锁错误后进行有限次数重试,并加入随机退避。
最多重试 3 次
第 1 次失败后等待约 50ms
第 2 次失败后等待约 100ms
第 3 次失败后返回错误并记录上下文
重试前提是操作具有幂等性,或具备防止重复提交的业务键。
11. 线上锁问题怎么查
11.1 查看当前事务
SELECT *
FROM information_schema.innodb_trx
ORDER BY trx_started;
重点关注:
- 事务开始时间。
- 当前状态。
- 正在执行的 SQL。
- 已锁记录数量。
- 是否长时间运行。
11.2 查看谁在等谁
MySQL 8.x 可以使用 Performance Schema:
SELECT
waiting_pid,
waiting_query,
blocking_pid,
blocking_query
FROM sys.innodb_lock_waits;
还可以查看更底层的数据锁:
SELECT *
FROM performance_schema.data_locks;
SELECT *
FROM performance_schema.data_lock_waits;
排查思路不是只盯着“正在等待”的 SQL,而是找到真正的阻塞者:
等待 SQL
→ 它在等哪把锁
→ 哪个事务持有这把锁
→ 阻塞事务从何时开始
→ 阻塞事务为什么没有提交
→ 相关 SQL 使用了哪个索引、锁了多大范围
11.3 查看最近一次死锁
SHOW ENGINE INNODB STATUS;
重点搜索 LATEST DETECTED DEADLOCK,关注:
- 涉及哪些事务。
- 每个事务正在执行什么 SQL。
- 已经持有什么锁。
- 正在等待什么锁。
- 哪个事务被回滚。
线上需要持续收集死锁时,可评估开启:
SET GLOBAL innodb_print_all_deadlocks = ON;
它会把所有死锁记录到错误日志。开启前应评估日志量并接入监控,不要只在问题发生后临时翻找。
11.4 检查执行计划
EXPLAIN SELECT *
FROM orders
WHERE user_id = 10
FOR UPDATE;
重点观察:
key:实际使用的索引。type:访问类型。rows:预计扫描行数。Extra:是否存在额外过滤、排序等。
注意:执行计划只能帮助判断访问路径,最终锁行为还与隔离级别、索引唯一性、查询条件、数据分布和实际执行过程有关。
11.5 不要上来就 KILL
终止连接会触发事务回滚。大事务回滚也可能耗时很久,并继续占用资源。
处理前至少确认:
- 目标连接确实是阻塞源,而不是受害者。
- 终止后业务能否重试。
- 事务回滚规模多大。
- 是否存在正在执行的 DDL、批处理或关键任务。
12. 常见错误写法
12.1 在事务中调用第三方接口
开启事务 → 更新订单 → 调支付接口 → 更新流水 → 提交
问题:第三方接口超时会直接拉长持锁时间。
改进:缩短本地事务,通过事务消息、本地消息表或状态机衔接外部流程。
12.2 先查余额,再无条件覆盖
SELECT balance FROM account WHERE id = 1;
UPDATE account SET balance = 900 WHERE id = 1;
问题:并发下容易丢失更新。
改进:使用原子增减、版本号或悲观锁。
12.3 以为加了事务注解就一定生效
在 Spring 等框架中,事务常通过代理实现。以下场景尤其需要检查:
- 同类内部方法调用绕过代理。
- 异常被捕获后没有继续抛出,导致事务正常提交。
- 异常类型不在默认回滚规则中。
- 方法运行在新线程中,事务上下文没有自动传递。
- 数据源或事务管理器选错。
- 数据库表不是支持事务的存储引擎。
不要只看代码上有没有 @Transactional,要确认实际代理调用链和最终提交行为。
12.4 用很大的 IN 一次性锁大量记录
UPDATE orders
SET status = 2
WHERE id IN (...数万个 ID...);
问题:事务日志量大、持锁时间长、回滚昂贵。
改进:按确定顺序分批更新,同时做好批次幂等和进度记录。
12.5 把锁等待超时当作根因
调大 innodb_lock_wait_timeout 只是让请求等更久,不能解决索引缺失、长事务或错误加锁顺序。超时参数是保护措施,不是优化手段。
13. DDL 也会阻塞:理解 MDL
MySQL 还有元数据锁 Metadata Lock,简称 MDL。访问表的事务会持有相应的元数据锁,防止表结构在使用过程中被并发修改。
典型事故:
- 事务 A 查询了某张表,但一直没有提交。
- 会话 B 执行
ALTER TABLE,等待 A 释放 MDL。 - 后续访问该表的请求又排在 B 后面。
- 一个未提交的小事务最终造成大量请求堆积。
所以执行线上 DDL 前要检查长事务、设置合理的锁等待策略,并使用经过验证的在线变更方案。ALGORITHM=INSTANT 或 ONLINE 也不意味着完全不需要 MDL。
14. 一套可落地的事务设计原则
原则 1:事务边界围绕业务原子性划分
不是 SQL 越多越应该放一个事务,而是必须一起成功或失败的数据库操作才进入同一事务。
原则 2:尽量晚开事务,尽量早提交
参数校验、权限判断、数据组装等能放到事务外的逻辑先完成。
原则 3:更新必须有明确、稳定的定位条件
优先使用主键或合适索引,更新前用 EXPLAIN 检查扫描范围。
原则 4:通过约束兜底,而不是只相信代码判断
例如防重复创建应设计唯一索引:
ALTER TABLE payment_order
ADD UNIQUE KEY uk_business_no (business_no);
应用层的“先查询是否存在”无法在并发下替代唯一约束。
原则 5:所有多行锁定遵循统一顺序
按主键升序或业务规定的稳定顺序加锁,避免不同入口互相反向等待。
原则 6:对死锁和瞬时冲突做有限重试
重试必须满足:
- 次数有限。
- 带退避。
- 记录关键日志。
- 操作幂等。
- 不在原事务内部直接复用已经失败的事务上下文。
原则 7:建立监控,而不是等用户反馈
至少监控:
- 活跃长事务数量与持续时间。
- 锁等待数量与持续时间。
- 死锁频率。
- 慢 SQL 与扫描行数。
- undo 历史积压。
- 数据库连接池等待情况。
15. 面试与实战最容易答错的几个问题
“行锁是不是锁一整行数据?”
不准确。InnoDB 行锁基于索引项实现,锁定范围由访问的索引、条件和隔离级别决定。
“普通 SELECT 会加锁吗?”
InnoDB 中普通一致性读通常通过 MVCC 完成,不加记录锁。但在 SERIALIZABLE、显式锁定读或某些特殊场景下不能简单套用这句话。
“REPEATABLE READ 完全没有幻读吗?”
要区分快照读和当前读。InnoDB 通过 MVCC 处理快照读的一致性,通过 Next-Key Lock 等机制控制当前读的幻读问题。不能只背一张 SQL 标准表格。
“死锁是不是数据库性能太差?”
不是。死锁首先是并发事务锁依赖形成了环。性能差可能放大事务重叠时间,但根因通常要从访问顺序、索引和事务边界中找。
“加索引一定能减少锁吗?”
通常能缩小扫描与锁定范围,但不是绝对。低选择性索引、范围查询、非唯一索引以及执行计划选错时,仍可能锁住较大范围。
“事务提交了为什么代码还报错?”
需要区分数据库提交、网络响应和应用状态。数据库可能已经提交,但客户端在收到响应前断线。涉及重试的写接口必须使用幂等键,不能仅凭客户端报错判断数据库一定没有执行。
16. 最后的实战清单
设计事务前问自己:
- 哪些操作必须一起成功或失败?
- 能否改成一条原子 SQL?
- 是否有唯一约束、非空约束等数据库兜底?
- 是否存在外部 RPC、文件操作或耗时计算?
- SQL 实际使用哪个索引?会扫描多少行?
- 多行操作的加锁顺序是否统一?
- 死锁后是否可以安全重试?
出现锁问题时按这个顺序排查:
- 找到等待事务和阻塞事务。
- 确认阻塞事务何时开始、为何未提交。
- 查看双方 SQL 与实际索引。
- 判断锁的是记录、间隙还是更大的扫描范围。
- 检查是否存在网络调用、批量处理或遗漏提交。
- 查看最近死锁日志,而不是只看当前现场。
- 从事务边界、访问顺序、索引和幂等重试四个方向修复。
一句话收尾:事务负责把一组操作变成一个整体,MVCC 提升读写并发,锁保护当前数据和范围;真正决定系统稳定性的,是短事务、准索引、统一顺序、数据库约束和可重试设计。
Comments | 0条评论