数据库悲观锁实现分布式锁

事故背景

2024年"双十一"预售期间凌晨0点30分,我们订单服务的数据库连接池突然耗尽,所有新请求全部超时。

DBA 给出的数据:连接池最大 200 个连接,其中 198 个处于 Sending dataLocked 状态,锁定时间最长的已经超过 60 秒。

技术团队花了 25 分钟紧急扩容连接池、重启服务。事后复盘,罪魁祸首是一段用 SELECT FOR UPDATE 锁库存的代码——锁粒度太大了。

悲观锁在高并发下的危害比乐观锁更直接:它直接占用数据库连接,而且锁的持有时间会成倍放大连接消耗。如果锁粒度设计错误,一个慢查询就能把整个数据库拖垮。

今天这篇,我们来把数据库悲观锁的原理、陷阱和生产实践全部拆解。

一、悲观锁的原理

悲观锁的核心思想是:假设冲突一定会发生,先把数据锁住,用完再释放

FOR UPDATE 行锁

-- 开启事务
BEGIN;

-- 锁定库存行(其他事务无法修改这条数据)
SELECT stock FROM inventory WHERE product_id = 123 FOR UPDATE;

-- 业务逻辑:检查库存、扣减
UPDATE inventory SET stock = stock - 1 WHERE product_id = 123;

-- 提交事务,锁释放
COMMIT;

SELECT ... FOR UPDATE 会在 InnoDB 中对匹配的行加排他锁(X锁)。其他事务如果尝试对同一行加锁,会被阻塞,直到持有锁的事务释放。

Java 实现

@Transactional
public boolean placeOrder(Long productId, int quantity) {
    // FOR UPDATE 锁定行,不允许其他事务修改
    Inventory inv = inventoryDao.selectForUpdate(productId);
    if (inv == null || inv.getStock() < quantity) {
        return false;
    }

    // 锁内执行业务逻辑
    inv.setStock(inv.getStock() - quantity);
    inventoryDao.update(inv);

    return true;
    // 事务提交,锁自动释放
}
@Query("SELECT i FROM Inventory i WHERE i.productId = :productId")
@Lock(LockModeType.PESSIMISTIC_WRITE)
Inventory selectForUpdate(@Param("productId") Long productId);

二、InnoDB 行锁原理

很多人在面试时会背"行锁",但被追问"InnoDB 行锁到底是锁什么"就卡住了。

记录锁(Record Lock)

SELECT ... FOR UPDATE 锁的不是整张表,而是索引记录

-- product_id 是主键,FOR UPDATE 锁住的是主键索引
SELECT * FROM inventory WHERE product_id = 123 FOR UPDATE;
-- 锁住的是 product_id = 123 这条主键记录

间隙锁(Gap Lock)

当查询条件是非唯一索引范围查询时,InnoDB 会加间隙锁:

-- stock 不是唯一索引,锁住的是一个区间
SELECT * FROM inventory WHERE stock = 0 FOR UPDATE;
-- 锁住的是所有 stock = 0 附近的所有间隙,防止其他事务插入新的 stock = 0 记录

间隙锁是 InnoDB 在可重复读(RR)隔离级别下的保护机制,防止"幻读"——但这意味着一个看似简单的查询,可能锁住一大片数据。

Next-Key Lock

InnoDB 的行锁实际上是 Next-Key Lock:记录锁 + 间隙锁的组合。

-- 假设 stock 索引的值为: 0, 5, 10, 15
-- SELECT * FROM inventory WHERE stock = 5 FOR UPDATE
-- 锁住的是: (0, 5] 和 (5, 10) 两个区间

【架构权衡】

这是行锁最反直觉的地方:你以为只锁了一行,实际上可能锁了一大片

表: inventory (stock 索引: 0, 5, 10, 15, 20)

SELECT * FROM inventory WHERE stock = 5 FOR UPDATE;

实际锁住的范围:  (0, 10]  ← 包括了 stock=5 这行和它前面的间隙
而不是:         [5,5]   ← 你以为锁的

这就是为什么很多人在"锁 stock = 0 的行"时,会莫名其妙地锁定整个表。

⚠️

90% 的数据库悲观锁踩坑都出在索引选择上。用主键/唯一索引做 FOR UPDATE 是安全的,用普通索引或范围查询做 FOR UPDATE 可能导致全表锁。生产环境中,如果发现某条 SQL 锁住了大量数据,第一反应是检查 WHERE 条件是否命中了合适的索引。

三、死锁风险

悲观锁最危险的场景是多表多行锁,稍不注意就会产生死锁。

循环依赖死锁

// 线程 A: 先锁订单,再锁库存
@Transactional
public void processOrderA(Long orderId, Long productId) {
    Order order = orderDao.selectForUpdate(orderId);     // 锁住订单
    Inventory inv = inventoryDao.selectForUpdate(productId); // 锁住库存
    // ...
}

// 线程 B: 先锁库存,再锁订单
@Transactional
public void processOrderB(Long orderId, Long productId) {
    Inventory inv = inventoryDao.selectForUpdate(productId); // 锁住库存
    Order order = orderDao.selectForUpdate(orderId);         // 锁住订单(等待!)
    // ...
}

线程 A 持有订单锁等待库存锁,线程 B 持有库存锁等待订单锁——死锁。InnoDB 的死锁检测机制会在 50ms 内发现并回滚其中一个事务,但被回滚的事务会重试,再次触发死锁,形成死锁漩涡。

锁超时兜底

-- 设置锁等待超时,避免无限等待
SET innodb_lock_wait_timeout = 5; -- 默认 50 秒,太长了

-- 在事务中设置当前语句的超时
SELECT ... FOR UPDATE NOWAIT; -- 立即失败,不等待
SELECT ... FOR UPDATE LOCK_IN_SHARE_MODE; -- 共享锁,允许读但不允许写
@Transactional(timeout = 5) // 事务本身也要设超时
public void processOrder(Long orderId, Long productId) {
    try {
        Order order = orderDao.selectForUpdateNowait(orderId);
    } catch (LockTimeoutException e) {
        throw new BusinessException("系统繁忙,请稍后重试");
    }
}

四、连接池耗尽:最危险的陷阱

问题分析

悲观锁会阻塞持有数据库连接,直到事务提交。一个慢查询或者长时间锁等待,会让一个连接"卡死"几分钟。

// 危险代码:锁内调用外部服务
@Transactional
public void processOrder(Long productId) {
    Inventory inv = inventoryDao.selectForUpdate(productId); // 连接1被占用
    // 调用库存校验 API,耗时 3 秒
    boolean valid = inventoryService.validate(productId);     // 3 秒空等
    inv.setStock(inv.getStock() - 1);
    inventoryDao.update(inv);
}

如果数据库连接池只有 200 个,100 个并发请求各占用一个连接调用外部 API 3 秒,连接池在 3 秒内耗尽。所有新请求(包括非锁相关的查询)全部阻塞。

正确的做法:锁内不做任何 IO 操作

public void placeOrder(Long productId, int quantity) {
    // 1. 先用乐观锁尝试(快速路径)
    for (int i = 0; i < 3; i++) {
        Inventory inv = inventoryDao.findByProductId(productId);
        if (inv.getStock() < quantity) {
            return false;
        }
        if (inventoryDao.updateWithVersion(productId, inv.getStock() - quantity, inv.getVersion()) > 0) {
            return true;
        }
        Thread.sleep(10);
    }

    // 2. 乐观锁失败,降级到悲观锁(兜底路径)
    return pessimisticLockDeduct(productId, quantity);
}

@Transactional(timeout = 5)
public boolean pessimisticLockDeduct(Long productId, int quantity) {
    // 锁内只做数据库操作,不做任何 IO
    Inventory inv = inventoryDao.selectForUpdate(productId);
    if (inv.getStock() < quantity) {
        return false;
    }
    inv.setStock(inv.getStock() - quantity);
    inventoryDao.update(inv);
    return true;
}

【架构权衡】

悲观锁应该作为乐观锁的降级兜底,而不是主力方案:

策略适用场景性能
乐观锁(主力)90% 的普通并发场景
悲观锁(兜底)乐观锁重试超过阈值后降级
直接悲观锁高竞争热点行(库存为个位数时)差但稳定

五、主键选择的陷阱

用 UUID 做主键的坑

-- 假设 product_id 是 UUID 字符串
SELECT * FROM inventory WHERE product_id = 'abc-123-def' FOR UPDATE;
-- UUID 无序,插入时可能触发页分裂,锁竞争范围扩大

UUID 作为主键是无序的,新插入的 UUID 可能落在索引的任何位置。当 InnoDB 需要在 B+ 树中间插入记录时,会触发页分裂(Page Split),导致锁扩散到多个数据页。

用自增 ID 或雪花 ID 更安全

-- 自增 ID 主键
SELECT * FROM inventory WHERE id = 123 FOR UPDATE;
-- B+ 树顺序插入,只锁住目标记录和相邻记录

【架构权衡】

锁性能的关键不在锁本身,而在于索引的结构

  • 有序主键(自增 ID、雪花 ID):行锁精确,影响范围小
  • 无序主键(UUID):插入时页分裂,锁范围扩大,可能从行锁退化到页锁
  • 无索引查询:全表扫描,表锁
💡

如果你的表用 UUID 做主键,而且经常需要对非主键列做 FOR UPDATE,请先确保那列有唯一索引。否则行锁会退化为全表锁,所有并发请求都会被这个"锁"串行化。

六、生产避坑清单

  1. 锁内禁止任何远程调用:任何 IO 操作(HTTP、Redis、消息队列)都会放大连接占用时间
  2. 事务要设超时:防止慢查询长时间占用连接
  3. 统一加锁顺序:多表锁场景下,所有事务必须按相同顺序加锁,避免死锁
  4. 检查索引是否命中:EXPLAIN 你的 FOR UPDATE 语句,确认是否真的只锁了目标行
  5. 锁等待超时要设短innodb_lock_wait_timeout 建议设为 5 秒,最长不超过 10 秒
  6. 监控锁等待事件:MySQL 的 information_schema.innodb_lock_waits 可以实时查看锁等待关系
-- 查看当前锁等待情况
SELECT
    r.trx_id, r.trx_mysql_thread_id, r.trx_query,
    b.trx_id blocking_trx_id, b.trx_mysql_thread_id blocking_thread,
    b.trx_started blocking_started
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;

七、工程代价评估

维度评估
开发成本中等(需要处理死锁、连接池、超时)
运维成本中等(需要监控锁等待,配置合理的锁超时)
排障复杂度高(死锁、连接池耗尽的线上问题需要完整的事务链路分析)
扩展性差(单机数据库行锁有上限,分库分表后跨库锁无法实现)
性能上限低~中(锁冲突严重时会退化到串行)
📖 点击展开:MySQL 锁监控常用 SQL
-- 1. 查看当前锁持有情况
SELECT * FROM information_schema.innodb_locks;

-- 2. 查看事务等待情况
SELECT trx_id, trx_state, trx_started, trx_rows_locked,
       trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
WHERE trx_state = 'LOCK WAIT';

-- 3. 查看最近的死锁日志
SHOW ENGINE INNODB STATUS;
-- 重点看 LATEST DETECTED DEADLOCK 部分

-- 4. 查看长事务(锁的持有者可能是长事务)
SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
       trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

数据库悲观锁是分布式锁家族中最"重"的方案——它直接占用数据库连接,锁粒度控制不好就会拖垮整个数据库。它的优势是强一致性和零额外依赖,适合对数据一致性要求极高、并发量可控的场景。

但在高并发分布式环境下,数据库悲观锁的性能天花板太低了。下篇文章我们看 Redis 分布式锁——用 SETNX + Lua 实现一个高性能的分布式锁。