【6】数据库事务与锁机制详解(附并发结算案例)

打算从事务隔离级别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_IDtrx_id

  1. 如果trx_id < min_trx_id:该版本是已提交事务修改的,可见
  2. 如果trx_id > max_trx_id:该版本是未来事务修改的,不可见
  3. 如果min_trx_id ≤ trx_id ≤ max_trx_id:若trx_idm_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 锁的锁定范围(图文示例)

假设表agentid索引值为: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的预算池),用唯一索引精准查询,只会加记录锁,不会加间隙锁,兼顾一致性和并发性能。

步骤

  1. 开启事务。
  2. SELECT ... FOR UPDATE加排他锁读取预算池余额(锁住该行)。
  3. 判断余额是否≥结算金额,若足够则扣减,否则回滚。
  4. 提交事务(释放锁)。

逻辑实现

(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. 读取预算池余额和版本号。
  2. 判断余额≥结算金额后,扣减余额并更新版本号(仅当版本号匹配时生效)。
  3. 若更新失败(版本号已被修改,说明有并发操作),重试或返回失败。

代码实现

(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. 方案对比与优化建议

方案 优点 缺点 适用场景
悲观锁 一致性强,无冲突重试 并发写时锁等待,性能低 高并发写(如金融结算)
乐观锁 性能高,无锁等待 冲突时需重试,一致性稍弱 高并发读、低并发写

优化建议

  1. 若预算池按代理商分表,可使用行锁 + 分库分表提升并发。
  2. 悲观锁场景下,尽量用唯一索引精准查询,避免间隙锁导致的锁冲突。
  3. 乐观锁场景下,设置合理的重试次数,避免无限重试。
  4. 对超大型预算池,可引入分布式锁(如 Redis)配合数据库锁,解决跨库并发问题。

六、总结

  1. 事务隔离级别:MySQL 默认可重复读,通过 MVCC 实现,兼顾一致性和并发。
  2. MVCC:通过版本链和 Read View 实现 "读不加锁",是 InnoDB 并发的核心。
  3. 间隙锁:解决可重复读下的幻读,仅在范围查询 / 非唯一索引查询时触发,需注意锁冲突。
  4. 并发结算防超额 :高并发写用悲观锁(记录锁 + FOR UPDATE) ,高并发读用乐观锁(版本号),核心是保证 "读 - 改 - 写" 的原子性。
相关推荐
合方圆~小文2 小时前
4G定焦球机摄像头综合介绍产品指南
数据结构·数据库·人工智能
zxrhhm2 小时前
数据库中的COALESCE函数用于返回参数列表中第一个非NULL值,若所有参数均为NULL则返回NULL
数据库·postgresql·oracle
小学鸡!2 小时前
DBeaver连接InfluxDB数据库
数据库
running up2 小时前
MyBatis 核心知识点与实战
数据库·oracle·mybatis
薛不痒2 小时前
MySQL中使用SQL语言
数据库·sql·mysql
五阿哥永琪3 小时前
SQL中的函数--开窗函数
大数据·数据库·sql
为什么不问问神奇的海螺呢丶3 小时前
Oracle 数据库对象导出脚本-含创建语句
数据库·oracle
码农阿豪3 小时前
告别兼容焦虑:电科金仓 KES 如何把 Oracle 的 PL/SQL 和 JSON 业务“接住”
数据库·sql·oracle·json·金仓数据库
曹牧3 小时前
Oracle SQL 中,& 字符
数据库·sql·oracle