引言

“并发是计算机科学中最难的问题之一,因为它涉及时间、顺序和不确定性。” —— Leslie Lamport

在前两篇文章中,我们了解了MySQL如何通过索引实现快速查询,如何通过WAL日志保证数据持久化。但还有一个核心问题没有解决:

如何在高并发场景下保证数据一致性?

想象这样的场景:

双11零点,100万用户同时抢购一件库存只有10个的商品
每个用户都执行:
1. 读取库存 → 10
2. 判断库存足够 → 是
3. 扣减库存 → 库存 - 1
4. 创建订单

结果:卖出了100万件,但库存只扣了10个 💥

这就是并发控制的核心难题:如何让多个并发事务互不干扰,同时保证数据一致性?

今天,我们从第一性原理出发,深度剖析MySQL的并发控制机制:

无控制 → 锁机制 → MVCC → 隔离级别 → 死锁处理
混乱     串行化   读写分离  灵活平衡   自动恢复
❌       ⚠️       ✅       ✅        ✅

我们还将手写MVCC核心逻辑,彻底理解MySQL如何实现读写不阻塞


一、问题的起点:并发导致的数据混乱

让我们从一个最经典的并发问题开始:电商库存扣减

1.1 场景:秒杀商品超卖问题

需求:

商品:iPhone 16 Pro Max(库存10件)
活动:双11零点秒杀,原价9999元,秒杀价1元
预期:10个用户抢到,其余用户提示"已抢完"

无并发控制的实现:

/**
 * 秒杀服务(无并发控制)
 */
@Service
public class SeckillService {

    @Autowired
    private ProductMapper productMapper;

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 秒杀下单(存在并发问题)
     */
    public boolean seckill(Long productId, Long userId) {
        // 1. 读取库存
        Product product = productMapper.selectById(productId);
        int stock = product.getStock();

        // 2. 判断库存是否足够
        if (stock <= 0) {
            return false;  // 库存不足
        }

        // 3. 扣减库存
        product.setStock(stock - 1);
        productMapper.updateById(product);

        // 4. 创建订单
        Order order = new Order();
        order.setUserId(userId);
        order.setProductId(productId);
        order.setAmount(1.00);  // 秒杀价1元
        orderMapper.insert(order);

        return true;
    }
}

并发测试:

public class SeckillServiceTest {

    @Test
    public void testConcurrency() throws Exception {
        SeckillService service = new SeckillService();
        Long productId = 1L;  // iPhone库存10件

        // 模拟1000个用户并发抢购
        CountDownLatch latch = new CountDownLatch(1000);
        AtomicInteger successCount = new AtomicInteger(0);

        for (int i = 0; i < 1000; i++) {
            final int userId = i;
            new Thread(() -> {
                try {
                    latch.countDown();
                    latch.await();  // 所有线程同时开始

                    boolean success = service.seckill(productId, (long) userId);
                    if (success) {
                        successCount.incrementAndGet();
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }

        Thread.sleep(5000);  // 等待所有线程完成

        System.out.println("成功下单人数: " + successCount.get());
        System.out.println("最终库存: " + productMapper.selectById(productId).getStock());

        // 预期结果:
        // 成功下单人数: 10
        // 最终库存: 0

        // 实际结果:
        // 成功下单人数: 856  ❌ 超卖846件
        // 最终库存: -846     ❌ 库存为负数
    }
}

问题分析:

时间线:
T1: 用户A读取库存=10
T2: 用户B读取库存=10(读到了A未提交的数据)
T3: 用户C读取库存=10
...
T1000: 用户Z读取库存=10(1000个用户都读到库存=10)

T1001: 用户A扣减库存 → 库存=9
T1002: 用户B扣减库存 → 库存=9(覆盖了A的修改)
T1003: 用户C扣减库存 → 库存=9(覆盖了B的修改)
...

最终:
  卖出了856件(实际库存只有10件)
  库存变成-846(负数库存)

问题:
  1. 脏读:读到了其他事务未提交的数据
  2. 更新丢失:后面的事务覆盖了前面的修改
  3. 库存为负:没有原子性保证

并发问题的本质:

多个事务并发执行时,会出现以下问题:

1. 脏读(Dirty Read):
   事务A读到了事务B未提交的数据
   如果B回滚,A读到的就是脏数据

2. 不可重复读(Non-Repeatable Read):
   事务A两次读取同一行,结果不一致
   因为事务B在中间修改并提交了数据

3. 幻读(Phantom Read):
   事务A两次执行范围查询,结果数量不一致
   因为事务B在中间插入了新数据

4. 更新丢失(Lost Update):
   事务A和B同时修改同一行,后提交的覆盖了先提交的
   导致A的修改丢失

二、解决方案演进:从锁到MVCC

为了解决并发问题,我们需要引入并发控制机制

2.1 方案1:悲观锁(读写互斥,性能差)

核心思想:操作数据前先加锁,其他事务等待

/**
 * 秒杀服务(悲观锁)
 */
@Service
public class SeckillServiceWithPessimisticLock {

    @Autowired
    private ProductMapper productMapper;

    /**
     * 秒杀下单(使用FOR UPDATE加锁)
     */
    @Transactional
    public boolean seckill(Long productId, Long userId) {
        // 1. 加排他锁读取库存
        Product product = productMapper.selectByIdForUpdate(productId);
        int stock = product.getStock();

        // 2. 判断库存
        if (stock <= 0) {
            return false;
        }

        // 3. 扣减库存
        product.setStock(stock - 1);
        productMapper.updateById(product);

        // 4. 创建订单
        Order order = new Order();
        order.setUserId(userId);
        order.setProductId(productId);
        order.setAmount(1.00);
        orderMapper.insert(order);

        return true;
    }
}

SQL实现:

-- ProductMapper.selectByIdForUpdate()
BEGIN;

-- 加排他锁(其他事务无法读写)
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 此时库存=10

-- 扣减库存
UPDATE products SET stock = stock - 1 WHERE id = 1;

COMMIT;  -- 释放锁

执行流程:

事务A: SELECT ... FOR UPDATE  -- 加锁成功,读到库存=10
事务B: SELECT ... FOR UPDATE  -- 等待A释放锁(阻塞)
事务C: SELECT ... FOR UPDATE  -- 等待A释放锁(阻塞)
...
事务A: UPDATE stock = 9       -- 更新成功
事务A: COMMIT                 -- 释放锁
事务B: 获取锁,读到库存=9       -- 继续执行
事务B: UPDATE stock = 8
事务B: COMMIT
...

结果:
  成功下单人数: 10  ✅
  最终库存: 0       ✅

性能测试:

@Test
public void testPessimisticLock() throws Exception {
    // 1000个并发请求
    long start = System.currentTimeMillis();

    // ... 并发测试代码 ...

    long duration = System.currentTimeMillis() - start;
    System.out.println("总耗时: " + duration + "ms");
    // 结果: 约5000ms

    // 分析:
    // 1000个请求串行执行
    // 每个请求约5ms
    // 总耗时 = 1000 × 5ms = 5000ms
}

悲观锁的优劣:

维度悲观锁说明
数据一致性✅ 强一致串行化执行,不会超卖
并发性能❌ 很差读写互斥,1000个请求需要5秒
死锁风险❌ 高两个事务互相等待
适用场景⚠️ 写多读少如秒杀、抢红包

死锁案例:

-- 事务A
BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE;  -- 锁住商品1
SELECT * FROM products WHERE id = 2 FOR UPDATE;  -- 等待商品2的锁

-- 事务B
BEGIN;
SELECT * FROM products WHERE id = 2 FOR UPDATE;  -- 锁住商品2
SELECT * FROM products WHERE id = 1 FOR UPDATE;  -- 等待商品1的锁

-- 死锁! A等B,B等A

结论:悲观锁虽然保证了一致性,但性能太差,不适合高并发场景。


2.2 方案2:乐观锁(无锁,但需重试)

核心思想:不加锁,通过版本号判断数据是否被修改

-- 增加version字段
ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 0;
/**
 * 秒杀服务(乐观锁)
 */
@Service
public class SeckillServiceWithOptimisticLock {

    /**
     * 秒杀下单(乐观锁 + 重试)
     */
    public boolean seckill(Long productId, Long userId) {
        int maxRetry = 3;  // 最大重试次数

        for (int i = 0; i < maxRetry; i++) {
            // 1. 读取库存和版本号
            Product product = productMapper.selectById(productId);
            int stock = product.getStock();
            int version = product.getVersion();

            // 2. 判断库存
            if (stock <= 0) {
                return false;
            }

            // 3. 乐观锁更新(检查版本号)
            int updatedRows = productMapper.updateStockWithVersion(
                productId,
                stock - 1,
                version + 1,
                version  // WHERE version = ?
            );

            // 4. 更新成功,创建订单
            if (updatedRows > 0) {
                Order order = new Order();
                order.setUserId(userId);
                order.setProductId(productId);
                order.setAmount(1.00);
                orderMapper.insert(order);
                return true;
            }

            // 5. 更新失败,重试
            System.out.println("版本冲突,重试第" + (i + 1) + "次");
        }

        return false;  // 重试3次仍失败
    }
}

SQL实现:

-- ProductMapper.updateStockWithVersion()
UPDATE products
SET stock = ?, version = ?
WHERE id = ? AND version = ?;

-- 执行流程:
-- 事务A: UPDATE ... WHERE version = 5  → 成功,version变为6,affected_rows=1
-- 事务B: UPDATE ... WHERE version = 5  → 失败,version已经是6,affected_rows=0
-- 事务B: 重试,读取version=6,再次UPDATE → 成功

性能测试:

@Test
public void testOptimisticLock() throws Exception {
    // 1000个并发请求
    long start = System.currentTimeMillis();

    AtomicInteger retryCount = new AtomicInteger(0);

    // ... 并发测试代码 ...

    long duration = System.currentTimeMillis() - start;
    System.out.println("总耗时: " + duration + "ms");
    System.out.println("总重试次数: " + retryCount.get());

    // 结果:
    // 总耗时: 800ms  ✅ 比悲观锁快6倍
    // 总重试次数: 2456  ⚠️ 平均每个请求重试2.5次
}

乐观锁的优劣:

维度乐观锁说明
数据一致性✅ 最终一致不会超卖
并发性能✅ 好读不加锁,800ms vs 5000ms
重试次数❌ 多高并发下重试次数多,用户体验差
适用场景✅ 读多写少如博客点赞、浏览量

结论:乐观锁性能好,但高并发下重试次数多,用户体验差。


2.3 方案3:MVCC(读写不阻塞,MySQL默认方案)

核心思想:多版本并发控制,读取历史版本,写入最新版本

MVCC的核心原理:
1. 每行数据存储多个版本
2. 读事务读取历史版本(快照读)
3. 写事务写入最新版本(当前读)
4. 读写互不阻塞

版本链:
  当前版本: stock=10, DB_TRX_ID=100
     ↓ DB_ROLL_PTR
  历史版本1: stock=11, DB_TRX_ID=99
     ↓ DB_ROLL_PTR
  历史版本2: stock=12, DB_TRX_ID=98
     ↓
  NULL

MVCC的实现机制:

-- 每行数据有3个隐藏列
CREATE TABLE products (
    id BIGINT PRIMARY KEY,
    stock INT,
    -- 隐藏列(MySQL自动维护)
    DB_TRX_ID BIGINT,      -- 最后修改该行的事务ID
    DB_ROLL_PTR BIGINT,    -- 指向undo log中的历史版本
    DB_ROW_ID BIGINT       -- 行ID(如果没有主键)
);

快照读 vs 当前读:

-- 快照读(不加锁,读历史版本)
SELECT * FROM products WHERE id = 1;

-- 当前读(加锁,读最新版本)
SELECT * FROM products WHERE id = 1 FOR UPDATE;       -- 加写锁
SELECT * FROM products WHERE id = 1 LOCK IN SHARE MODE;  -- 加读锁
UPDATE products SET stock = stock - 1 WHERE id = 1;   -- 加写锁
DELETE FROM products WHERE id = 1;                    -- 加写锁

MVCC的秒杀实现:

/**
 * 秒杀服务(MVCC)
 */
@Service
public class SeckillServiceWithMVCC {

    /**
     * 秒杀下单(利用MVCC的当前读)
     */
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public boolean seckill(Long productId, Long userId) {
        // 1. 当前读(加锁,读最新版本)
        Product product = productMapper.selectByIdForUpdate(productId);
        int stock = product.getStock();

        // 2. 判断库存
        if (stock <= 0) {
            return false;
        }

        // 3. 扣减库存(原子操作)
        int updatedRows = productMapper.decreaseStock(productId, 1);
        if (updatedRows == 0) {
            return false;
        }

        // 4. 创建订单
        Order order = new Order();
        order.setUserId(userId);
        order.setProductId(productId);
        order.setAmount(1.00);
        orderMapper.insert(order);

        return true;
    }
}

SQL优化(避免加锁):

-- 方案1: 直接在UPDATE中判断库存
UPDATE products
SET stock = stock - 1
WHERE id = ? AND stock > 0;

-- 如果affected_rows=0,说明库存不足
-- 无需SELECT FOR UPDATE,减少锁等待

-- 方案2: 使用CAS(Compare And Swap)
UPDATE products
SET stock = stock - 1
WHERE id = ? AND stock = ?;

-- 需要先读取库存,然后在UPDATE中判断库存是否变化

性能测试:

@Test
public void testMVCC() throws Exception {
    // 1000个并发请求
    long start = System.currentTimeMillis();

    // ... 并发测试代码 ...

    long duration = System.currentTimeMillis() - start;
    System.out.println("总耗时: " + duration + "ms");

    // 结果:
    // 方案1(SELECT FOR UPDATE + UPDATE): 约1000ms
    // 方案2(直接UPDATE): 约200ms  ✅ 性能最好

    // 分析:
    // 方案2无需SELECT,减少了50%的SQL执行
    // 无需加行锁,其他事务可以并发执行
}

MVCC的优劣:

维度MVCC说明
数据一致性✅ 可重复读不会脏读、不可重复读
并发性能✅ 优秀读写不阻塞,200ms vs 5000ms
锁冲突✅ 少只有写写冲突,读写无冲突
幻读⚠️ 部分解决需要配合Next-Key Lock
适用场景✅ 通用MySQL默认方案

结论:MVCC是MySQL的默认方案,读写不阻塞,性能优秀,适合绝大多数场景。


三、MVCC深度剖析:读写不阻塞的秘密

MVCC是MySQL并发控制的核心,我们深入剖析其实现原理。

3.1 MVCC的数据结构:版本链

每行数据的完整结构:

行数据(物理存储):
┌─────────────────────────────────────────────────────────────────┐
│ id | stock | DB_TRX_ID | DB_ROLL_PTR | DB_ROW_ID | name | ... │
│ 1  | 10    | 100       | 0x1A2B3C    | 1         | ...  | ... │
└─────────────────────────────────────────────────────────────────┘
           ↓                 ↓
        最新值          指向undo log

undo log(历史版本链):
当前版本: stock=10, DB_TRX_ID=100
   ↓ DB_ROLL_PTR
版本1: stock=11, DB_TRX_ID=99
   ↓ DB_ROLL_PTR
版本2: stock=12, DB_TRX_ID=98
   ↓ DB_ROLL_PTR
版本3: stock=13, DB_TRX_ID=97
   ↓
NULL

版本链的生成过程:

-- 初始状态
stock=13, DB_TRX_ID=97

-- 事务98修改
BEGIN;  -- 事务ID=98
UPDATE products SET stock = 12 WHERE id = 1;
COMMIT;

-- 生成undo log:
-- 当前版本: stock=12, DB_TRX_ID=98, DB_ROLL_PTR→版本1
-- 版本1: stock=13, DB_TRX_ID=97

-- 事务99修改
BEGIN;  -- 事务ID=99
UPDATE products SET stock = 11 WHERE id = 1;
COMMIT;

-- 生成undo log:
-- 当前版本: stock=11, DB_TRX_ID=99, DB_ROLL_PTR→版本1
-- 版本1: stock=12, DB_TRX_ID=98, DB_ROLL_PTR→版本2
-- 版本2: stock=13, DB_TRX_ID=97

3.2 MVCC的核心:ReadView(一致性视图)

ReadView的定义:

ReadView是事务开始时生成的一致性视图,记录当前活跃事务的ID列表

数据结构:
┌──────────────────────────────────────────┐
│ ReadView                                  │
├──────────────────────────────────────────┤
│ m_ids: [101, 102, 103]  # 活跃事务ID列表  │
│ min_trx_id: 101         # 最小活跃事务ID  │
│ max_trx_id: 104         # 下一个事务ID    │
│ creator_trx_id: 100     # 当前事务ID      │
└──────────────────────────────────────────┘

可见性判断算法:

/**
 * MVCC可见性判断(简化版)
 */
public class MVCCVisibility {

    static class ReadView {
        List<Long> m_ids;        // 活跃事务ID列表
        long min_trx_id;         // 最小活跃事务ID
        long max_trx_id;         // 下一个事务ID
        long creator_trx_id;     // 当前事务ID
    }

    /**
     * 判断版本是否可见
     */
    public static boolean isVisible(long trx_id, ReadView view) {
        // 规则1: 如果是当前事务的修改,可见
        if (trx_id == view.creator_trx_id) {
            return true;
        }

        // 规则2: 如果版本在ReadView生成前已提交,可见
        if (trx_id < view.min_trx_id) {
            return true;
        }

        // 规则3: 如果版本在ReadView生成后才开始,不可见
        if (trx_id >= view.max_trx_id) {
            return false;
        }

        // 规则4: 如果版本在活跃事务列表中,不可见
        if (view.m_ids.contains(trx_id)) {
            return false;
        }

        // 规则5: 其他情况,已提交,可见
        return true;
    }

    /**
     * 查找可见版本
     */
    public static Row findVisibleVersion(Row current, ReadView view) {
        Row version = current;

        // 沿着版本链查找
        while (version != null) {
            if (isVisible(version.DB_TRX_ID, view)) {
                return version;  // 找到可见版本
            }
            // 沿着DB_ROLL_PTR找下一个版本
            version = version.nextVersion;
        }

        return null;  // 无可见版本
    }
}

可见性判断示例:

版本链:
  当前版本: stock=10, DB_TRX_ID=105
     ↓
  版本1: stock=11, DB_TRX_ID=103
     ↓
  版本2: stock=12, DB_TRX_ID=102
     ↓
  版本3: stock=13, DB_TRX_ID=99

事务A的ReadView:
  m_ids: [103, 104]
  min_trx_id: 103
  max_trx_id: 105
  creator_trx_id: 100

可见性判断:
  版本1(trx_id=105):
    105 >= 105(max_trx_id) → 不可见(未来事务)

  版本1(trx_id=103):
    103 in [103, 104](m_ids) → 不可见(活跃事务)

  版本2(trx_id=102):
    102 < 103(min_trx_id) → 可见(已提交事务) ✅

结果:事务A读到stock=12

3.3 MVCC的两种ReadView生成策略

READ COMMITTED(读已提交):

策略:每次SELECT都生成新的ReadView

示例:
事务A:
  BEGIN;
  SELECT stock FROM products WHERE id = 1;  -- 生成ReadView1,读到stock=10

事务B:
  BEGIN;
  UPDATE products SET stock = 5 WHERE id = 1;
  COMMIT;  -- 提交

事务A:
  SELECT stock FROM products WHERE id = 1;  -- 生成ReadView2,读到stock=5 ✅

结果:
  事务A两次读取结果不一致(不可重复读)
  但每次都读到已提交的最新数据

REPEATABLE READ(可重复读):

策略:事务开始时生成ReadView,整个事务复用

示例:
事务A:
  BEGIN;  -- 生成ReadView(只生成一次)
  SELECT stock FROM products WHERE id = 1;  -- 读到stock=10

事务B:
  BEGIN;
  UPDATE products SET stock = 5 WHERE id = 1;
  COMMIT;  -- 提交

事务A:
  SELECT stock FROM products WHERE id = 1;  -- 仍然读到stock=10 ✅

结果:
  事务A两次读取结果一致(可重复读)
  但读不到事务B的修改(快照隔离)

两种策略的对比:

隔离级别ReadView生成时机可重复读读到最新数据适用场景
READ COMMITTED每次SELECT允许不可重复读
REPEATABLE READ事务开始需要一致性快照

MySQL默认:REPEATABLE READ


3.4 MVCC的局限:幻读问题

什么是幻读?

定义:
  同一事务中,两次范围查询的结果数量不一致
  因为其他事务在中间插入了新数据

示例:
事务A:
  BEGIN;
  SELECT COUNT(*) FROM orders WHERE user_id = 123;  -- 结果:5行

事务B:
  BEGIN;
  INSERT INTO orders (user_id, amount) VALUES (123, 100);
  COMMIT;

事务A:
  SELECT COUNT(*) FROM orders WHERE user_id = 123;  -- 结果:6行 ❌

问题:
  两次查询结果不一致,出现了"幻影"行

MVCC能否解决幻读?

快照读(MVCC):
  SELECT COUNT(*) FROM orders WHERE user_id = 123;
  ✅ 读取快照版本,不会看到新插入的行
  ✅ 可以避免幻读

当前读(加锁):
  SELECT COUNT(*) FROM orders WHERE user_id = 123 FOR UPDATE;
  ❌ 读取最新版本,会看到新插入的行
  ❌ 无法避免幻读(需要Next-Key Lock)

解决方案:Next-Key Lock(间隙锁)

-- 当前读会加Next-Key Lock,锁住范围和间隙
SELECT * FROM orders WHERE user_id BETWEEN 100 AND 200 FOR UPDATE;

锁定范围:
  ├─ 记录锁:锁住user_id=100~200的所有行
  └─ 间隙锁:锁住(100, 200)之间的间隙

效果:
  其他事务无法在这个范围内插入新行
  避免幻读

Next-Key Lock的详细说明见第4节。


四、MySQL的锁体系:从表锁到行锁

除了MVCC,MySQL还有完整的锁机制来保证并发安全。

4.1 锁的分类:多个维度

按锁的粒度分类:

┌──────────────────────────────────────┐
│ 全局锁(FTWRL)                       │
│   └─ FLUSH TABLES WITH READ LOCK     │
│      整个数据库只读,用于全库备份      │
└──────────────────────────────────────┘
          ↓
┌──────────────────────────────────────┐
│ 表级锁                                │
│   ├─ 表锁(LOCK TABLES)              │
│   ├─ 元数据锁(MDL Lock)             │
│   └─ 意向锁(IS/IX Lock)             │
└──────────────────────────────────────┘
          ↓
┌──────────────────────────────────────┐
│ 行级锁(InnoDB专属)                  │
│   ├─ 记录锁(Record Lock)            │
│   ├─ 间隙锁(Gap Lock)               │
│   └─ Next-Key Lock(临键锁)          │
└──────────────────────────────────────┘

按锁的模式分类:

共享锁(S Lock,Shared Lock):
  ├─ 读锁,多个事务可以同时持有
  ├─ SELECT ... LOCK IN SHARE MODE
  └─ 兼容:S-S兼容,S-X不兼容

排他锁(X Lock,Exclusive Lock):
  ├─ 写锁,只有一个事务可以持有
  ├─ SELECT ... FOR UPDATE
  ├─ UPDATE、DELETE、INSERT
  └─ 兼容:X-S不兼容,X-X不兼容

锁的兼容性矩阵:

S锁X锁
S锁兼容不兼容
X锁不兼容不兼容

4.2 表级锁详解

1. 表锁(LOCK TABLES)

-- 加表级读锁
LOCK TABLES products READ;
SELECT * FROM products;  -- 允许
UPDATE products SET stock = 10;  -- 报错:表已加读锁
UNLOCK TABLES;

-- 加表级写锁
LOCK TABLES products WRITE;
SELECT * FROM products;  -- 允许
UPDATE products SET stock = 10;  -- 允许
-- 其他事务无法读写(阻塞)
UNLOCK TABLES;

特点:
   实现简单
   粒度太大,并发性能差
   容易死锁

2. 元数据锁(MDL Lock)

-- MDL锁自动加,用于保护表结构

事务A:
  BEGIN;
  SELECT * FROM products;  -- 自动加MDL读锁

事务B:
  ALTER TABLE products ADD COLUMN name VARCHAR(50);  -- 等待MDL写锁,阻塞

事务A:
  COMMIT;  -- 释放MDL读锁

事务B:
  -- 获取MDL写锁,执行ALTER TABLE

作用:
  防止DDL和DML并发执行导致数据不一致

3. 意向锁(Intention Lock)

意向锁的作用:
  表级锁和行级锁的协调机制

问题:
  事务A对某一行加了X锁
  事务B想对整个表加X锁
  如何快速判断是否冲突?

传统方案:
  遍历所有行,检查是否有行锁
  性能差:O(n)

意向锁方案:
  事务A加行锁前,先加表级意向锁
  事务B检查表级锁时,只需检查意向锁
  性能好:O(1)

意向锁类型:
  IS锁Intention Shared Lock:表示事务打算加S行锁
  IX锁Intention Exclusive Lock:表示事务打算加X行锁

兼容性:
  IS与ISIXS兼容
  IX与ISIX兼容
  S与IS兼容
  X与所有锁不兼容

4.3 行级锁详解

1. 记录锁(Record Lock)

-- 锁住索引记录
SELECT * FROM products WHERE id = 1 FOR UPDATE;

锁定范围:
  只锁id=1这一行的索引记录

示例:
┌────────────────────────────────────┐
 主键索引                            
├────────────────────────────────────┤
 id=1  [X锁]  被锁住                
 id=2                                
 id=3                                
└────────────────────────────────────┘

其他事务:
  SELECT * FROM products WHERE id = 2;  -- 允许
  UPDATE products SET stock = 10 WHERE id = 1;  -- 阻塞

2. 间隙锁(Gap Lock)

-- 锁住索引之间的间隙,防止插入
SELECT * FROM products WHERE id BETWEEN 10 AND 20 FOR UPDATE;

锁定范围(假设存在id=10,15,20:
  (10, 15)  -- 间隙1
  (15, 20)  -- 间隙2

示例:
┌────────────────────────────────────┐
 主键索引                            
├────────────────────────────────────┤
 id=10 [X锁]                         
    间隙1 [Gap锁]  被锁住          
 id=15 [X锁]                         
    间隙2 [Gap锁]  被锁住          
 id=20 [X锁]                         
└────────────────────────────────────┘

其他事务:
  INSERT INTO products (id, stock) VALUES (12, 100);  -- 阻塞(在间隙1中)
  INSERT INTO products (id, stock) VALUES (5, 100);   -- 允许(不在间隙中)
  UPDATE products SET stock = 10 WHERE id = 10;  -- 阻塞(记录被锁)

作用:
  防止幻读

3. Next-Key Lock(临键锁)

-- 记录锁 + 间隙锁
SELECT * FROM products WHERE id >= 10 AND id <= 20 FOR UPDATE;

锁定范围:
  (5, 10]   -- Next-Key Lock 1
  (10, 15]  -- Next-Key Lock 2
  (15, 20]  -- Next-Key Lock 3
  (20, 25)  -- 间隙锁

InnoDB默认使用Next-Key Lock,既锁住记录,又锁住间隙

示例:
┌────────────────────────────────────┐
 主键索引                            
├────────────────────────────────────┤
 id=5                                
    (5, 10] [Next-Key锁]           
 id=10 [X锁]                         
    (10, 15] [Next-Key锁]          
 id=15 [X锁]                         
    (15, 20] [Next-Key锁]          
 id=20 [X锁]                         
    (20, 25) [Gap锁]               
 id=25                               
└────────────────────────────────────┘

其他事务:
  INSERT INTO products (id, stock) VALUES (8, 100);   -- 阻塞
  INSERT INTO products (id, stock) VALUES (12, 100);  -- 阻塞
  INSERT INTO products (id, stock) VALUES (22, 100);  -- 阻塞
  INSERT INTO products (id, stock) VALUES (30, 100);  -- 允许

Next-Key Lock的退化:

-- 场景1:唯一索引等值查询(记录存在)
SELECT * FROM products WHERE id = 10 FOR UPDATE;

:
  退化为记录锁(只锁id=10,不锁间隙)

-- 场景2:唯一索引等值查询(记录不存在)
SELECT * FROM products WHERE id = 12 FOR UPDATE;

:
  退化为间隙锁(只锁(10, 15),不锁记录)

-- 场景3:非唯一索引
SELECT * FROM products WHERE stock = 100 FOR UPDATE;

:
  使用Next-Key Lock(锁住所有stock=100的记录及其间隙)

4.4 死锁:检测与避免

什么是死锁?

定义:
  两个或多个事务互相等待对方持有的锁,导致永久阻塞

经典案例:
事务A:
  BEGIN;
  UPDATE products SET stock = 10 WHERE id = 1;  -- 锁住id=1
  UPDATE products SET stock = 10 WHERE id = 2;  -- 等待id=2的锁

事务B:
  BEGIN;
  UPDATE products SET stock = 10 WHERE id = 2;  -- 锁住id=2
  UPDATE products SET stock = 10 WHERE id = 1;  -- 等待id=1的锁

结果:
  A等待B释放id=2的锁
  B等待A释放id=1的锁
  互相等待,永久阻塞 ❌

MySQL的死锁检测:

InnoDB有自动死锁检测机制:

1. 检测算法:
   维护一个等待图(Wait-for Graph)
   如果发现环,说明有死锁

2. 死锁处理:
   选择一个事务回滚(代价最小的)
   其他事务继续执行

3. 参数配置:
   innodb_deadlock_detect=ON  # 开启死锁检测(默认开启)
   innodb_lock_wait_timeout=50  # 锁等待超时时间(秒)

查看死锁日志:

-- 查看最近一次死锁
SHOW ENGINE INNODB STATUS\G

输出:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-11-03 16:00:00 0x7f8e8c001700
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 1, OS thread handle 123456, query id 100 localhost root updating
UPDATE products SET stock = 10 WHERE id = 2

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 0 page no 3 n bits 72 index PRIMARY of table `test`.`products` trx id 12345 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 8; hex 0000000000000001; asc         ;;  -- id=1

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 3 n bits 72 index PRIMARY of table `test`.`products` trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 8; hex 0000000000000002; asc         ;;  -- id=2

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 2, OS thread handle 789012, query id 101 localhost root updating
UPDATE products SET stock = 10 WHERE id = 1

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 0 page no 3 n bits 72 index PRIMARY of table `test`.`products` trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 8; hex 0000000000000002; asc         ;;  -- id=2

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 3 n bits 72 index PRIMARY of table `test`.`products` trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 8; hex 0000000000000001; asc         ;;  -- id=1

*** WE ROLL BACK TRANSACTION (1)  -- 回滚事务1

避免死锁的最佳实践:

1. 事务尽量小,减少持锁时间
   ❌ BEGIN; 执行大量业务逻辑; COMMIT;
   ✅ BEGIN; 只执行必要的SQL; COMMIT;

2. 按相同顺序访问资源
   ❌ 事务A:锁1→锁2, 事务B:锁2→锁1
   ✅ 事务A:锁1→锁2, 事务B:锁1→锁2

3. 使用索引避免锁定大量行
   ❌ SELECT * FROM products WHERE stock > 0 FOR UPDATE;  -- 全表扫描
   ✅ SELECT * FROM products WHERE id = 1 FOR UPDATE;  -- 索引查询

4. 降低隔离级别
   REPEATABLE READ → READ COMMITTED
   减少间隙锁,降低死锁概率

5. 使用乐观锁代替悲观锁
   UPDATE ... WHERE version = ?

6. 分库分表,减少单表锁冲突

五、事务的ACID特性实现原理

我们已经理解了MVCC和锁机制,现在回到事务的本质:ACID特性如何实现?

5.1 原子性(Atomicity):undo log

定义:事务中的所有操作,要么全部成功,要么全部失败

实现机制:undo log(回滚日志)

-- 转账事务
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;  -- A-100
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;  -- B+100
COMMIT;

undo log记录:
  undo log 1: UPDATE accounts SET balance = balance + 100 WHERE user_id = 1;  -- 反向操作
  undo log 2: UPDATE accounts SET balance = balance - 100 WHERE user_id = 2;  -- 反向操作

如果事务失败:
  ROLLBACK;
   执行undo log 2B账户-100
   执行undo log 1A账户+100
   数据回滚到事务开始前的状态

undo log的存储:

undo log存储在undo表空间中:
  ibdata1(系统表空间)
  undo_001、undo_002(独立undo表空间,MySQL 8.0+)

每行数据通过DB_ROLL_PTR指向undo log,形成版本链

5.2 一致性(Consistency):约束 + 事务

定义:事务执行前后,数据库从一个一致性状态转换到另一个一致性状态

实现机制:数据库约束 + 事务逻辑

-- 约束保证
CREATE TABLE accounts (
    user_id BIGINT PRIMARY KEY,
    balance DECIMAL(10,2) NOT NULL CHECK (balance >= 0),  -- 余额不能为负
    CONSTRAINT chk_balance CHECK (balance >= 0)
);

-- 事务逻辑保证
BEGIN;
-- 检查A账户余额
SELECT balance FROM accounts WHERE user_id = 1;  -- 余额=100

-- 转账金额不能超过余额
IF balance < 100 THEN
    ROLLBACK;  -- 回滚
END IF;

-- 转账
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;

COMMIT;

一致性约束:
  ├─ 转账前:A余额=100, B余额=50, 总额=150
  ├─ 转账中:A余额=0, B余额=150, 总额=150
  └─ 转账后:A余额=0, B余额=150, 总额=150
  总额始终=150(一致性)

5.3 隔离性(Isolation):MVCC + 锁

定义:并发执行的事务之间相互隔离,互不干扰

实现机制:MVCC + 锁

四种隔离级别:

1. READ UNCOMMITTED(读未提交):
   实现:不加锁,读最新版本
   问题:脏读、不可重复读、幻读

2. READ COMMITTED(读已提交):
   实现:MVCC,每次SELECT生成ReadView
   问题:不可重复读、幻读

3. REPEATABLE READ(可重复读,InnoDB默认):
   实现:MVCC,事务开始生成ReadView
   问题:幻读(部分解决)

4. SERIALIZABLE(串行化):
   实现:所有SELECT加锁
   问题:性能差

隔离级别对比:

隔离级别脏读不可重复读幻读实现机制性能
READ UNCOMMITTED无锁最高
READ COMMITTEDMVCC(每次生成ReadView)
REPEATABLE READ⚠️MVCC + Next-Key Lock
SERIALIZABLE锁(串行化)最低

5.4 持久性(Durability):redo log

定义:事务一旦提交,数据就永久保存,即使系统崩溃也不会丢失

实现机制:redo log(重做日志)

-- 转账事务
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;
COMMIT;  -- 提交

提交流程:
  1. 修改Buffer Pool中的数据页(内存)
  2. redo log到磁盘(顺序写,快)
  3. 返回客户端:提交成功
  4. 后台异步刷脏页到数据文件(随机写,慢)

如果在第4步之前崩溃:
  1. 重启MySQL
  2. 读取redo log
  3. 重放redo log中的操作
  4. 恢复到崩溃前的状态

redo log的刷盘策略:

参数:innodb_flush_log_at_trx_commit

值=0(性能最好,可靠性最差):
  每秒刷盘1次
  可能丢失1秒数据

值=1(性能最差,可靠性最好):
  每次事务提交都刷盘
  不丢数据,推荐金融系统使用

值=2(性能和可靠性折中):
  每次提交写到OS缓存,每秒刷盘
  OS崩溃可能丢失1秒数据,MySQL崩溃不丢数据

ACID总结:

特性实现机制核心作用
原子性undo log回滚事务
一致性约束 + 事务逻辑保证数据符合业务规则
隔离性MVCC + 锁并发事务互不干扰
持久性redo log崩溃恢复

六、实战案例:秒杀系统的并发优化

综合运用MVCC和锁,我们来设计一个高性能的秒杀系统。

6.1 需求分析

场景:
  商品:iPhone 16 Pro Max(库存10000件)
  并发:100万人同时抢购
  要求:
    1. 不能超卖
    2. 响应时间<100ms
    3. 吞吐量>10000 QPS

6.2 方案对比

方案1:SELECT FOR UPDATE(悲观锁)

@Transactional
public boolean seckill(Long productId, Long userId) {
    // 加排他锁
    Product product = productMapper.selectByIdForUpdate(productId);
    if (product.getStock() <= 0) {
        return false;
    }

    // 扣减库存
    product.setStock(product.getStock() - 1);
    productMapper.updateById(product);

    // 创建订单
    orderMapper.insert(order);
    return true;
}

性能测试:
  并发:1000 QPS
  响应时间:500ms
  问题:读写互斥,性能差

方案2:直接UPDATE(无锁)

public boolean seckill(Long productId, Long userId) {
    // 直接扣减库存
    int updatedRows = productMapper.decreaseStock(productId, 1);
    if (updatedRows == 0) {
        return false;
    }

    // 创建订单
    orderMapper.insert(order);
    return true;
}

// SQL
UPDATE products
SET stock = stock - 1
WHERE id = ? AND stock > 0;

性能测试:
  并发:15000 QPS  
  响应时间:50ms   
  问题:无

方案3:Redis预扣库存 + 异步下单

public boolean seckill(Long productId, Long userId) {
    String key = "stock:" + productId;

    // 1. Redis原子扣减库存
    Long stock = redisTemplate.opsForValue().decrement(key);
    if (stock < 0) {
        // 库存不足,恢复库存
        redisTemplate.opsForValue().increment(key);
        return false;
    }

    // 2. 异步创建订单(发送MQ消息)
    OrderMessage message = new OrderMessage(userId, productId);
    rabbitTemplate.convertAndSend("order.queue", message);

    return true;
}

// MQ消费者(异步扣减MySQL库存)
@RabbitListener(queues = "order.queue")
public void handleOrder(OrderMessage message) {
    // 扣减MySQL库存
    productMapper.decreaseStock(message.getProductId(), 1);

    // 创建订单
    orderMapper.insert(order);
}

性能测试:
  并发:50000 QPS   ✅✅✅
  响应时间:10ms    ✅✅✅
  问题:需要Redis和MQ

方案对比:

方案QPS响应时间复杂度推荐场景
SELECT FOR UPDATE1000500ms并发低
直接UPDATE1500050ms并发中等
Redis+MQ5000010ms高并发秒杀

七、总结与最佳实践

通过本文,我们从第一性原理出发,深度剖析了MySQL的并发控制机制:

问题:电商超卖
  ↓
方案演进:
  无控制 → 悲观锁 → 乐观锁 → MVCC
  超卖    串行化   重试多   读写分离 ✅
  ↓
核心机制:
  MVCC:版本链 + ReadView + 可见性判断
  锁:表锁 → 行锁 → 间隙锁 → Next-Key Lock
  ↓
ACID实现:
  原子性:undo log
  一致性:约束 + 事务逻辑
  隔离性:MVCC + 锁
  持久性:redo log

并发控制的最佳实践:

1. 优先使用MVCC(快照读)
   ✅ SELECT * FROM orders WHERE user_id = 123;
   ❌ SELECT * FROM orders WHERE user_id = 123 FOR UPDATE;

2. 写操作使用直接UPDATE
   ✅ UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0;
   ❌ SELECT FOR UPDATE + UPDATE

3. 事务尽量小
   ✅ BEGIN; UPDATE; INSERT; COMMIT;
   ❌ BEGIN; 大量业务逻辑; COMMIT;

4. 按相同顺序访问资源
   ✅ 事务A:锁1→锁2, 事务B:锁1→锁2
   ❌ 事务A:锁1→锁2, 事务B:锁2→锁1

5. 使用合适的隔离级别
   ✅ 一般业务:READ COMMITTED
   ✅ 需要一致性快照:REPEATABLE READ
   ❌ 不要用SERIALIZABLE(性能差)

6. 高并发场景使用Redis
   ✅ Redis DECR原子扣减库存
   ✅ MQ异步下单

下一篇预告:

在下一篇文章《查询优化:从执行计划到性能调优》中,我们将深度剖析:

  • SQL执行流程:解析→优化→执行
  • EXPLAIN详解:type、key、rows、Extra
  • 索引优化:覆盖索引、索引下推、MRR
  • JOIN优化:Nested Loop、Hash Join、BKA
  • 慢查询优化:从10秒到10ms

敬请期待!


字数统计:约17,000字

阅读时间:约85分钟

难度等级:⭐⭐⭐⭐⭐ (高级)

适合人群:

  • 后端开发工程师
  • 数据库工程师
  • 架构师
  • 对MySQL并发控制感兴趣的技术爱好者

参考资料:

  • 《高性能MySQL(第4版)》- Baron Schwartz, 第1章《MySQL架构》
  • 《MySQL技术内幕:InnoDB存储引擎(第2版)》- 姜承尧, 第6章《锁》
  • MySQL 8.0 Reference Manual - InnoDB Locking
  • 《数据库系统概念(第7版)》- Abraham Silberschatz, 第14章《事务》

版权声明: 本文采用 CC BY-NC-SA 4.0 许可协议。 转载请注明出处。