
数据库悲观锁与乐观锁:从原理到实战,我的踩坑与选型心得
在并发编程的世界里,数据一致性是我们永恒的挑战。记得我第一次负责一个电商库存扣减功能时,就曾天真地以为一个简单的 `UPDATE` 语句就能搞定,结果在高并发测试下,库存出现了令人头疼的负数。那一刻,我深刻认识到锁机制的重要性。今天,我想和大家深入聊聊数据库领域中两种核心的并发控制策略:悲观锁与乐观锁。它们不是银弹,各有各的脾气,用对了场景事半功倍,用错了就是灾难。
一、 悲观锁:先“锁”为敬,防患于未然
悲观锁,顾名思义,持一种悲观的态度。它默认认为并发冲突一定会发生,因此在操作数据之前,会先将其锁住,确保在自己操作期间,其他事务无法修改此数据,直到事务结束才释放锁。这是一种“独占”的思想,很像“我吃饭时,整张桌子都是我的,你们得等着”。
实现原理: 在关系型数据库中,悲观锁通常通过数据库的锁机制实现,最经典的就是 `SELECT ... FOR UPDATE` 语句。这条语句会在事务中为选中的行加上排他锁(X锁)。
实战代码示例(以MySQL/PostgreSQL为例):
-- 开启事务
BEGIN;
-- 1. 使用悲观锁查询并锁定商品库存行(id=1)
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 2. 在应用层判断库存是否大于0
-- 假设我们查得 stock = 5
-- 3. 扣减库存
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- 提交事务,释放锁
COMMIT;
我的踩坑提示:
- 死锁风险: 如果多个事务以不同的顺序锁定资源,极易形成死锁。务必保证业务中锁定资源的顺序一致。
- 性能开销: 加锁本身有开销,且会阻塞其他等待锁的事务,大幅降低系统的并发吞吐量。我曾在一个读多写少的场景滥用悲观锁,导致接口响应时间直线上升。
- 锁的范围: 务必明确锁定的行,如果WHERE条件不走索引,可能会升级为表锁,那将是性能的“核灾难”。
二、 乐观锁:乐观自信,事后校验
乐观锁则持乐观态度,它假设并发冲突不常发生。因此,它不会在读取数据时加锁,而是在更新数据时,检查在此期间数据是否被其他事务修改过。如果被修改过,则放弃本次更新(通常通过重试机制)。这就像“我相信大家会排队,但结账时我会核对一下商品是不是我最初拿的那个”。
实现原理: 最常见的实现方式是使用版本号(version)或时间戳(timestamp)字段。读取数据时,同时获取版本号;更新数据时,将版本号作为更新条件,并递增版本号。
实战代码示例:
-- 首先,表中需要增加一个 version 字段
-- ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- 1. 读取数据,获取当前库存和版本号
SELECT stock, version FROM products WHERE id = 1;
-- 假设返回:stock=5, version=10
-- 2. 在应用层计算新库存,并准备更新
-- new_stock = 5 - 1 = 4
-- new_version = 10 + 1 = 11
-- 3. 执行更新,以版本号作为条件
UPDATE products
SET stock = 4, version = 11
WHERE id = 1 AND version = 10;
-- 4. 检查更新影响的行数
-- 如果 affected_rows = 1, 说明更新成功,没有冲突。
-- 如果 affected_rows = 0, 说明在此期间数据已被他人修改,版本号不匹配,更新失败。
在应用层(如Java Spring中),我们通常会这样处理:
// 伪代码,展示乐观锁重试机制
@Transactional
public boolean deductStock(Long productId) {
for (int retry = 0; retry < 3; retry++) { // 设置重试次数
Product product = productDao.selectById(productId);
if (product.getStock() <= 0) {
return false; // 库存不足
}
int newStock = product.getStock() - 1;
int newVersion = product.getVersion() + 1;
int updated = productDao.updateStockAndVersion(productId, newStock, product.getVersion(), newVersion);
if (updated == 1) {
return true; // 更新成功
}
// 更新失败,循环重试
log.warn("乐观锁冲突,第{}次重试", retry + 1);
}
throw new RuntimeException("扣减库存失败,并发冲突过于频繁");
}
我的踩坑提示:
- ABA问题: 版本号从10变成11又变回10,对于只认数值的乐观锁来说,它察觉不到中间的变化。在数值敏感的场景(如余额),可以使用递增版本号或更精细的检查(如检查所有字段的哈希值)。
- 自旋开销: 在高冲突场景下,频繁的更新失败和重试会消耗大量CPU资源,得不偿失。我曾在一个秒杀场景初期使用乐观锁,结果大部分请求都在循环重试,最终失败。
- 业务补偿: 更新失败后,不能简单抛错给用户。需要友好的提示(如“库存紧张,请稍后再试”)或将其放入队列稍后处理。
三、 核心对比与选型场景:我的经验之谈
理解了原理和实现,如何选择呢?这张对比表概括了核心差异:
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心思想 | “先取锁,再访问” | “先访问,更新前校验” |
| 并发性 | 低(阻塞等待) | 高(无阻塞) |
| 开销 | 锁管理、上下文切换开销大 | 冲突检测与重试开销(冲突少时极小) |
| 适用场景 | 写多读少,冲突频率高,临界区操作复杂 | 读多写少,冲突频率低,响应速度要求高 |
| 实现层面 | 数据库原生支持(行锁、表锁) | 业务逻辑实现(版本号) |
我的场景选择指南:
- 请选择悲观锁: 当你的业务是“强一致性”优先,且数据冲突是常态时。例如:
1. 金融核心账务系统的余额扣减,一分钱都不能错,宁愿慢一点。
2. 对单个资源进行长时间、多步骤的复杂操作(如审核流程),中途不允许干扰。 - 请选择乐观锁: 当你的业务追求高吞吐,且冲突是小概率事件时。例如:
1. 大多数Web应用的更新操作,如更新个人资料、发表评论。
2. 读远大于写的场景,如新闻站点的文章浏览量统计(`UPDATE views = views + 1`)。
3. 分布式环境下,实现轻量级的并发控制,避免复杂的分布式锁。 - 一种混合策略: 在“秒杀”这类极端场景下,我现在的做法是:在网关或缓存层用原子操作(如Redis的DECR)进行粗粒度拦截,将极少数请求放行到数据库层,数据库层再使用悲观锁或更精细的乐观锁做最终一致性保证。 这相当于在高速路口先限流,保证进入市区的车流有序。
总结一下,悲观锁像是一个严谨的守卫,任何时候都紧握盾牌;乐观锁则像一个灵活的跑者,相信赛道通畅,只在冲线时确认一下。没有绝对的好坏,只有是否适合。希望我这些带“伤疤”的经验,能帮助你在下次设计系统时,做出更从容的选择。记住,理解业务并发模型,是选择锁策略的第一步。

评论(0)