打算从事务隔离级别 、MVCC 原理 、间隙锁 三个核心知识点入手,再结合代理商并发结算防超额支付的实际场景,拆解锁的选择与解决方案。
一、事务基础:ACID 特性
在聊隔离级别前,先明确事务的核心特性ACID,这是理解后续内容的基础:
- 原子性(Atomicity):事务是不可分割的最小单位,要么全执行,要么全回滚(比如银行转账,扣钱和加钱必须同时成功)。
- 一致性(Consistency):事务执行前后,数据库的完整性约束不变(比如转账后总金额不变)。
- 隔离性(Isolation):多个事务并发执行时,彼此互不干扰(核心,后续隔离级别就是讲这个)。
- 持久性(Durability):事务提交后,修改永久保存到数据库,不会因崩溃丢失。
二、事务隔离级别
多个事务并发执行时,若不做隔离,会出现脏读、不可重复读、幻读 三类问题。为了解决这些问题,SQL 标准定义了4 种隔离级别,隔离强度从低到高,并发性能从高到低。
1. 并发问题定义
先搞懂三个核心问题:
| 问题类型 | 通俗解释 |
|---|---|
| 脏读 | 读到其他事务未提交的脏数据(比如看到别人转账中未提交的金额)。 |
| 不可重复读 | 同一事务内,多次读同一数据,结果不一致(别人中途修改并提交了数据)。 |
| 幻读 | 同一事务内,多次执行同一查询,结果集行数变了(别人中途插入 / 删除了数据)。 |
2. 四种隔离级别
SQL 标准定义的 4 种隔离级别,以及各自能解决的并发问题,用阶梯图表示隔离强度:

各隔离级别具体表现:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 适用场景 |
|---|---|---|---|---|
| 读未提交 | ✅ | ✅ | ✅ | 极少用(如实时日志统计) |
| 读已提交 | ❌ | ✅ | ✅ | 多数互联网场景(如电商) |
| 可重复读 | ❌ | ❌ | ❌ | MySQL 默认(金融 / 支付) |
| 串行化 | ❌ | ❌ | ❌ | 高一致性(如银行核心) |
3. MySQL 中隔离级别的操作(代码)
(1)查看当前隔离级别
sql
-- MySQL 8.0+
SELECT @@transaction_isolation;
-- MySQL 5.7
SELECT @@tx_isolation;
默认返回:REPEATABLE-READ(可重复读)。
2)修改隔离级别
sql
-- 会话级(仅当前连接有效)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 全局级(重启后生效)
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
4. 隔离级别演示(以 "不可重复读" 为例)
场景:两个事务同时读取同一条数据,事务 A 中途修改并提交。
| 事务 A(修改数据) | 事务 B(读取数据) | 读已提交隔离级别 | 可重复读隔离级别 |
|---|---|---|---|
| BEGIN; | BEGIN; | - | - |
| - | SELECT balance FROM user WHERE id=1;(结果:100) | 100 | 100 |
| UPDATE user SET balance=200 WHERE id=1; COMMIT; | - | - | - |
| - | SELECT balance FROM user WHERE id=1; | 200(不可重复读) | 100(可重复读) |
| - | COMMIT; | - | - |
原理 :可重复读级别下,事务 B 会在启动时生成一个快照,后续读取都基于该快照,直到事务提交。
三、MVCC 原理:多版本并发控制
MVCC(Multi-Version Concurrency Control)是 InnoDB 实现可重复读 和读已提交 隔离级别的核心技术,简单说就是为数据维护多个版本,不同事务看到不同版本,从而实现 "读不加锁,读写不冲突"。
1. MVCC 的核心组件
MVCC 依赖三个关键组件:undo log(回滚日志) 、版本链 、Read View(读视图)。
(1)undo log 与版本链
当执行INSERT/UPDATE/DELETE时,InnoDB 会生成 undo log,用于事务回滚,同时会为数据行维护一个版本链:
- 每行数据除了存储实际值,还包含隐藏列:
DB_TRX_ID(修改该数据的事务 ID)、DB_ROLL_PTR(指向 undo log 的指针)。 - 每次修改数据,都会生成一个新的版本,并通过
DB_ROLL_PTR指向旧版本,形成版本链。
版本链示意图:
数据行(当前版本)
├─ DB_TRX_ID: 103(修改事务ID)
├─ DB_ROLL_PTR: 指向版本2
├─ balance: 200
└─ 其他字段...
版本2(undo log中)
├─ DB_TRX_ID: 102
├─ DB_ROLL_PTR: 指向版本1
├─ balance: 100
└─ 其他字段...
版本1(undo log中)
├─ DB_TRX_ID: 101
├─ DB_ROLL_PTR: NULL(最早版本)
├─ balance: 50
└─ 其他字段...
(2)Read View(读视图)

Read View 是事务执行查询时生成的可见性规则,包含四个核心参数:
m_ids:当前活跃的事务 ID 列表。min_trx_id:活跃事务中最小的 ID。max_trx_id:下一个要分配的事务 ID。creator_trx_id:生成 Read View 的事务 ID。
可见性判断规则 :对于版本链中的某个版本,其DB_TRX_ID为trx_id:
- 如果
trx_id < min_trx_id:该版本是已提交事务修改的,可见。 - 如果
trx_id > max_trx_id:该版本是未来事务修改的,不可见。 - 如果
min_trx_id ≤ trx_id ≤ max_trx_id:若trx_id在m_ids中(事务未提交),不可见 ;否则可见。
2. 不同隔离级别下的 MVCC 表现
- 读已提交(RC) :每次执行
SELECT时重新生成 Read View,所以能看到其他事务刚提交的版本(会出现不可重复读)。 - 可重复读(RR) :仅在事务第一次执行
SELECT时生成一次 Read View,后续读取复用该视图(保证可重复读)。
3. MVCC 的优势
- 读操作(SELECT)不加锁,提升并发性能。
- 读写不冲突,写操作只锁当前版本,读操作读历史版本。
四、间隙锁(Next-Key Locks)

间隙锁是 InnoDB 在可重复读 隔离级别下为解决幻读 引入的锁机制,属于行锁的一种,全称是Next-Key Locks(记录锁 + 间隙锁)。
1. InnoDB 行锁的分类
| 锁类型 | 作用范围 | 通俗解释 |
|---|---|---|
| 记录锁(Record Lock) | 单条记录(索引行) | 锁住具体的一行数据(比如WHERE id=1)。 |
| 间隙锁(Gap Lock) | 索引之间的间隙 | 锁住两个索引之间的空白区域(比如 id=1 和 id=3 之间)。 |
| Next-Key 锁 | 记录锁 + 间隙锁 | 锁住当前记录 + 前面的间隙(默认的行锁方式)。 |
2. 间隙锁的触发条件
- 隔离级别为可重复读(RR)(MySQL 默认)。
- 使用范围查询 (如
>、<、BETWEEN)或非唯一索引查询。 - 若用唯一索引精准查询单条记录,只会加记录锁,不会加间隙锁。
3. Next-Key 锁的锁定范围(图文示例)
假设表agent的id索引值为:1、3、5、7,执行SELECT * FROM agent WHERE id > 3 FOR UPDATE;(加排他锁)。
锁定范围示意图:
索引行:1 ── 间隙(1,3) ── 3 ── 间隙(3,5) ── 5 ── 间隙(5,7) ── 7 ── 间隙(7,+∞)
↑ ↑ ↑
└── Next-Key锁锁定范围 ───────────┘
(记录锁3 + 间隙(3,5) + 记录锁5 + 间隙(5,7) + 记录锁7 + 间隙(7,+∞))
此时,无法在id=3之后的间隙插入数据(如 id=4、6),从而防止幻读。
4. 间隙锁的作用
- 解决幻读:阻止其他事务在间隙中插入数据,保证同一事务多次查询的结果集一致。
- 注意:间隙锁会增加锁冲突,降低并发性能,所以若非必要,避免在高并发场景使用范围查询加锁。
五、实际案例:代理商并发结算防超额支付
1. 场景
某平台有一个总预算池 ,多个代理商同时发起结算请求,需要从预算池中扣减结算金额,要求最终支付总额不能超过预算池的可用余额。
核心痛点:并发场景下,多个事务同时读取可用余额,判断 "余额≥结算金额" 后扣减,可能导致最终扣减总额超过预算(比如预算 1000,两个代理商各结算 800,都判断余额足够,最终扣减 1600)。
2. 分析
并发下的 **"读 - 改 - 写"** 操作非原子性,是导致超额支付的根本原因:
事务A:读余额=1000 → 判断≥800 → 扣减800(未提交)
事务B:读余额=1000 → 判断≥800 → 扣减800(未提交)
事务A提交,事务B提交 → 最终余额=-600(超额)
3. 解决方案:锁的选择
针对该场景,有两种主流解决方案:悲观锁方案 和乐观锁方案,需根据并发量选择。
方案一:悲观锁(高并发写场景)
核心思路 :通过行锁 + 事务,将 "读 - 改 - 写" 操作变成原子性,阻止其他事务并发修改预算池数据。
- 锁选择:记录锁(唯一索引精准查询) + 排他锁(FOR UPDATE)。
- 原因:预算池是单条记录(如
id=1的预算池),用唯一索引精准查询,只会加记录锁,不会加间隙锁,兼顾一致性和并发性能。
步骤:
- 开启事务。
- 用
SELECT ... FOR UPDATE加排他锁读取预算池余额(锁住该行)。 - 判断余额是否≥结算金额,若足够则扣减,否则回滚。
- 提交事务(释放锁)。
逻辑实现:
(1)预算池表结构
sql
CREATE TABLE `budget_pool` (
`id` int PRIMARY KEY AUTO_INCREMENT,
`total_amount` decimal(18,2) NOT NULL COMMENT '总预算',
`available_amount` decimal(18,2) NOT NULL COMMENT '可用余额',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 初始化数据:总预算1000,可用余额1000
INSERT INTO `budget_pool` (`total_amount`, `available_amount`) VALUES (1000.00, 1000.00);
(2)MySQL 事务 + 悲观锁代码
sql
-- 开启事务
BEGIN;
-- 加排他锁读取可用余额(唯一索引id=1,仅加记录锁)
SELECT available_amount FROM budget_pool WHERE id=1 FOR UPDATE;
-- 假设结算金额为800,判断并扣减(这里用SQL模拟,实际在应用层判断)
UPDATE budget_pool
SET available_amount = available_amount - 800
WHERE id=1 AND available_amount >= 800;
-- 检查影响行数,若为0则回滚(余额不足)
-- 应用层判断:如果ROW_COUNT() == 0 → ROLLBACK; 否则 → COMMIT;
COMMIT;
(3)Java 代码
java
@Service
public class AgentSettleService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Transactional(rollbackFor = Exception.class)
public boolean settle(String agentId, BigDecimal amount) {
// 1. 加排他锁读取可用余额
BigDecimal availableAmount = jdbcTemplate.queryForObject(
"SELECT available_amount FROM budget_pool WHERE id=1 FOR UPDATE",
BigDecimal.class
);
// 2. 判断余额是否足够
if (availableAmount.compareTo(amount) < 0) {
throw new RuntimeException("预算不足,结算失败");
}
// 3. 扣减余额
int affectedRows = jdbcTemplate.update(
"UPDATE budget_pool SET available_amount = available_amount - ? WHERE id=1",
amount
);
return affectedRows > 0;
}
}
方案二:乐观锁(高并发读、低并发写场景)
核心思路 :不主动加锁,通过版本号 或时间戳实现 "读 - 改 - 写" 的原子性,冲突时重试。
- 锁选择:无物理锁,通过版本号实现逻辑锁。
- 原因:并发写少,冲突概率低,避免悲观锁的性能损耗。
步骤:
- 读取预算池余额和版本号。
- 判断余额≥结算金额后,扣减余额并更新版本号(仅当版本号匹配时生效)。
- 若更新失败(版本号已被修改,说明有并发操作),重试或返回失败。
代码实现:
(1)修改表结构,增加版本号
sql
ALTER TABLE `budget_pool` ADD COLUMN `version` int DEFAULT 0 COMMENT '版本号';
(2)MySQL + 乐观锁代码
java
@Service
public class AgentSettleService {
@Autowired
private JdbcTemplate jdbcTemplate;
// 最大重试次数
private static final int MAX_RETRY = 3;
public boolean settleWithOptimisticLock(String agentId, BigDecimal amount) {
int retry = 0;
while (retry < MAX_RETRY) {
// 1. 读取余额和版本号
Map<String, Object> result = jdbcTemplate.queryForMap(
"SELECT available_amount, version FROM budget_pool WHERE id=1"
);
BigDecimal availableAmount = (BigDecimal) result.get("available_amount");
int version = (int) result.get("version");
// 2. 判断余额是否足够
if (availableAmount.compareTo(amount) < 0) {
return false;
}
// 3. 扣减余额(版本号匹配才更新)
int affectedRows = jdbcTemplate.update(
"UPDATE budget_pool SET available_amount = available_amount - ?, version = version + 1 WHERE id=1 AND version = ?",
amount, version
);
// 4. 更新成功则返回,失败则重试
if (affectedRows > 0) {
return true;
}
retry++;
}
// 重试多次失败,返回冲突
return false;
}
}
4. 方案对比与优化建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 悲观锁 | 一致性强,无冲突重试 | 并发写时锁等待,性能低 | 高并发写(如金融结算) |
| 乐观锁 | 性能高,无锁等待 | 冲突时需重试,一致性稍弱 | 高并发读、低并发写 |
优化建议:
- 若预算池按代理商分表,可使用行锁 + 分库分表提升并发。
- 悲观锁场景下,尽量用唯一索引精准查询,避免间隙锁导致的锁冲突。
- 乐观锁场景下,设置合理的重试次数,避免无限重试。
- 对超大型预算池,可引入分布式锁(如 Redis)配合数据库锁,解决跨库并发问题。
六、总结
- 事务隔离级别:MySQL 默认可重复读,通过 MVCC 实现,兼顾一致性和并发。
- MVCC:通过版本链和 Read View 实现 "读不加锁",是 InnoDB 并发的核心。
- 间隙锁:解决可重复读下的幻读,仅在范围查询 / 非唯一索引查询时触发,需注意锁冲突。
- 并发结算防超额 :高并发写用悲观锁(记录锁 + FOR UPDATE) ,高并发读用乐观锁(版本号),核心是保证 "读 - 改 - 写" 的原子性。