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 知识体系图

下面这张图总结了本章的核心逻辑:

做市库存一致性保障方案 库存一致性锁 乐观锁 悲观锁 版本号机制 CAS机制 行锁(FOR UPDATE) 表锁(LOCK TABLES) 选型对比与实战建议 读多写少 → 乐观锁(版本号/CAS) 写多读少 → 悲观锁(行锁/表锁)

这张图把乐观锁和悲观锁的脉络理清楚了。你想想看,做市系统里大部分库存操作都是读多写少,所以乐观锁用得更多一些。但关键时刻,悲观锁能给你兜底。

我的建议:不要死磕一种方案。根据业务场景灵活组合,才是架构师的功力所在。


公众号:蓝海数据掘金营,微信deep3321