4、乐观锁与悲观锁:做市库存一致性的两把钥匙
做市系统里,库存数据的一致性是个老生常谈的问题。说白了,就是多个线程或进程同时操作同一个库存记录时,怎么保证不出乱子。
我做了这么多年量化系统,见过太多因为库存不一致导致的亏损案例。有一次,一个同事的做市策略因为库存计数错误,连续开了双倍仓位,那叫一个惨烈。从那以后,我对库存一致性方案就特别较真。
今天咱们聊聊两种主流方案:乐观锁和悲观锁。它们各有各的脾气,选对了,系统稳如泰山;选错了,性能崩给你看。
4.1 乐观锁:相信世界是美好的
乐观锁的思路很简单:我默认不会有人跟我抢数据。更新的时候才检查一下,看数据有没有被别人改过。
这种方案适合读多写少的场景。做市系统里,大部分时间都在查询行情和计算报价,真正写库存的次数其实不多。嗯,这时候乐观锁就很合适。
4.1.1 版本号机制
这是最常用的实现方式。给每条库存记录加一个版本号字段,每次更新时版本号+1。
我习惯这样设计表结构:
CREATE TABLE inventory (
symbol VARCHAR(20) PRIMARY KEY,
quantity DECIMAL(20,8) NOT NULL,
version INT NOT NULL DEFAULT 0,
update_time TIMESTAMP
);
更新时的SQL长这样:
UPDATE inventory
SET quantity = quantity - 100,
version = version + 1
WHERE symbol = 'BTCUSDT'
AND version = 5;
关键点在于 WHERE version = 5。如果此时有其他线程已经把版本号改成了6,那这条更新就会影响0行。程序检测到影响行数为0,就知道冲突了,需要重试。
核心逻辑:先查出版本号,更新时带上这个版本号。版本号变了,说明有人捷足先登了。
4.1.2 CAS(Compare And Swap)
CAS是另一种乐观锁实现,更底层一些。它直接比较内存中的值,如果没变就更新,变了就失败。
在Java里,AtomicInteger 就是基于CAS实现的。做市系统里,我经常用它来维护内存中的库存计数器:
public class InventoryCounter {
private AtomicLong quantity = new AtomicLong(1000);
public boolean tryDecrease(long delta) {
while (true) {
long current = quantity.get();
long newValue = current - delta;
if (newValue < 0) {
return false; // 库存不足
}
if (quantity.compareAndSet(current, newValue)) {
return true; // 更新成功
}
// CAS失败,自旋重试
}
}
}
这里有个坑:ABA问题。就是值从A变成B又变回A,CAS会认为没变过。我曾在项目中遇到过这个问题,后来用版本号+时间戳的组合才解决。
避坑指南:我曾经在内存库存中使用纯CAS,结果因为ABA问题导致库存计数偏差。后来加了一个单调递增的版本号字段,才彻底解决。
4.2 悲观锁:先锁住再说
悲观锁的思路相反:我默认一定会有人跟我抢。所以在操作数据之前,先把它锁住,别人想碰都碰不了。
这种方案适合写操作频繁的场景。比如做市系统在盘口剧烈波动时,每秒可能有几百次库存更新,这时候悲观锁反而更靠谱。
4.2.1 行锁
行锁只锁住你要操作的那一行数据。MySQL的InnoDB引擎支持行锁,用起来很简单:
-- 开启事务
START TRANSACTION;
-- 锁定指定行
SELECT quantity FROM inventory
WHERE symbol = 'BTCUSDT'
FOR UPDATE;
-- 执行更新
UPDATE inventory
SET quantity = quantity - 100
WHERE symbol = 'BTCUSDT';
-- 提交事务
COMMIT;
FOR UPDATE 就是行锁的关键。其他事务想操作同一行时,会被阻塞,直到当前事务提交或回滚。
我个人建议:行锁一定要走索引。否则MySQL会升级成表锁,那性能就惨了。我在项目中就见过因为没加索引,导致整个表被锁住的案例。
4.2.2 表锁
表锁就是锁住整张表。简单粗暴,但并发能力极差。
LOCK TABLES inventory WRITE;
UPDATE inventory SET quantity = quantity - 100 WHERE symbol = 'BTCUSDT';
UNLOCK TABLES;
说实话,做市系统里我几乎不用表锁。除非是凌晨做数据迁移或者批量修复库存时,才会临时用一下。
小技巧:如果非要用表锁,记得设置超时时间。我曾经见过一个表锁没释放,导致整个系统hang住半小时的惨案。
4.3 选型对比:什么时候用哪个?
这个问题没有标准答案,但我可以分享一些经验。
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 并发冲突概率 | 低(适合读多写少) | 高(适合写多读少) |
| 性能开销 | 低(无锁开销) | 高(加锁、解锁、上下文切换) |
| 死锁风险 | 无 | 有(需要超时机制) |
| 实现复杂度 | 中等(需要处理重试逻辑) | 低(数据库原生支持) |
| 适用场景 | 内存库存、低频更新 | 数据库库存、高频更新 |
我的选型原则很简单:
- 内存中的库存计数器:用乐观锁(CAS+版本号)
- 数据库中的库存记录:用悲观锁(行锁)
- 跨进程的库存同步:用分布式锁(后面章节会讲)
4.4 实战:一个完整的库存扣减方案
下面是我在一个做市项目中实际用过的方案。它结合了乐观锁和悲观锁的优点。
public class InventoryService {
// 内存库存(乐观锁)
private ConcurrentHashMap<String, AtomicLong> memoryInventory;
// 数据库库存(悲观锁)
private JdbcTemplate jdbcTemplate;
public boolean decreaseInventory(String symbol, long delta) {
// 第一步:扣减内存库存(乐观锁,快速失败)
AtomicLong counter = memoryInventory.get(symbol);
if (counter == null) return false;
long current = counter.get();
if (current < delta) return false;
if (!counter.compareAndSet(current, current - delta)) {
return false; // CAS失败,说明有并发冲突
}
// 第二步:同步到数据库(悲观锁,保证最终一致性)
try {
jdbcTemplate.update(
"UPDATE inventory SET quantity = quantity - ? WHERE symbol = ?",
delta, symbol
);
return true;
} catch (Exception e) {
// 数据库失败,回滚内存库存
counter.addAndGet(delta);
log.error("数据库更新失败,已回滚内存库存", e);
return false;
}
}
}
这个方案的好处是:
- 内存操作快,大部分请求在第一步就完成了
- 数据库作为最终保障,保证数据不丢失
- 内存回滚机制,防止数据不一致
核心思想:用乐观锁处理高频低冲突的场景,用悲观锁处理低频高可靠的场景。两者结合,既保证了性能,又保证了数据一致性。
4.5 知识体系图
下面这张图总结了本章的核心逻辑:
这张图把乐观锁和悲观锁的脉络理清楚了。你想想看,做市系统里大部分库存操作都是读多写少,所以乐观锁用得更多一些。但关键时刻,悲观锁能给你兜底。
我的建议:不要死磕一种方案。根据业务场景灵活组合,才是架构师的功力所在。
公众号:蓝海数据掘金营,微信deep3321