数据库悲观锁实现分布式锁
事故背景
2024年"双十一"预售期间凌晨0点30分,我们订单服务的数据库连接池突然耗尽,所有新请求全部超时。
DBA 给出的数据:连接池最大 200 个连接,其中 198 个处于 Sending data 或 Locked 状态,锁定时间最长的已经超过 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;
}
【架构权衡】
悲观锁应该作为乐观锁的降级兜底,而不是主力方案:
五、主键选择的陷阱
用 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,请先确保那列有唯一索引。否则行锁会退化为全表锁,所有并发请求都会被这个"锁"串行化。
六、生产避坑清单
- 锁内禁止任何远程调用:任何 IO 操作(HTTP、Redis、消息队列)都会放大连接占用时间
- 事务要设超时:防止慢查询长时间占用连接
- 统一加锁顺序:多表锁场景下,所有事务必须按相同顺序加锁,避免死锁
- 检查索引是否命中:EXPLAIN 你的 FOR UPDATE 语句,确认是否真的只锁了目标行
- 锁等待超时要设短:
innodb_lock_wait_timeout 建议设为 5 秒,最长不超过 10 秒
- 监控锁等待事件: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 实现一个高性能的分布式锁。