乐观锁与悲观锁:两种截然不同的哲学

说到数据一致性,锁是个绕不开的话题。我个人习惯把锁分为两类:乐观锁和悲观锁。名字挺有意思的,一个乐观,一个悲观。

悲观锁的想法是:「肯定有人跟我抢,我先锁上再说。」

乐观锁的想法是:「应该没人跟我抢,完事了再检查一下。」

这两种思路,在游戏服务器里都有用武之地。选哪个,得看场景。

乐观锁的实现方式

乐观锁不真的「锁」数据。它靠的是版本号或者CAS(Compare And Swap)来保证一致性。

版本号机制

这个最常用。每条数据带一个版本号,每次更新时检查版本号是否匹配。

-- 玩家更新金币
UPDATE player_bag 
SET gold = gold - 100, version = version + 1 
WHERE player_id = 1001 AND version = 5

如果版本号对不上,更新影响行数为0。这时候你就知道,有人在你之前改了数据。

我的经验:版本号字段一定要用整型,别用时间戳。时间戳在高并发下可能重复,我踩过这个坑。

CAS机制

CAS更底层一些。它比较当前值和期望值,如果一致就更新。

// 伪代码示例
bool CAS(int* addr, int expected, int new_value) {
    if (*addr == expected) {
        *addr = new_value;
        return true;
    }
    return false;
}

在游戏里,CAS常用于内存中的计数器。比如在线人数统计,用CAS比加锁快得多。

注意:CAS有ABA问题。就是值从A变成B又变回A,CAS会认为没变过。加个版本号就能解决。

悲观锁的实现方式

悲观锁就是真的锁。数据库层面有行锁和表锁两种。

行锁

只锁住你要操作的那一行。其他行不受影响。

-- 开启事务
BEGIN;

-- 锁定玩家背包的某一行
SELECT * FROM player_bag 
WHERE player_id = 1001 AND item_id = 5001 
FOR UPDATE;

-- 执行更新
UPDATE player_bag SET count = count - 1 
WHERE player_id = 1001 AND item_id = 5001;

-- 提交事务
COMMIT;

行锁的粒度小,并发高。但要注意,如果没走索引,行锁会升级成表锁。

关键点:行锁一定要走索引。我曾经见过一个项目,where条件没加索引,结果行锁变表锁,整个拍卖行卡死。

表锁

锁住整张表。简单粗暴,但并发极低。

LOCK TABLES player_bag WRITE;

-- 执行批量操作
UPDATE player_bag SET count = count + 1 WHERE item_id = 5001;

UNLOCK TABLES;

表锁一般只在维护、批量更新时用。线上业务很少用,除非你确定并发量极低。

场景选型对比

不同场景,选型完全不同。我整理了一个表格,方便你对比。

场景 推荐方案 原因
玩家背包 乐观锁(版本号) 背包操作频繁,但冲突少。乐观锁性能好
拍卖行竞价 悲观锁(行锁) 竞价时冲突概率高,行锁能保证公平
公会改名 悲观锁(行锁) 改名操作少,但必须保证唯一性
排行榜分数更新 乐观锁(CAS) 高并发写,冲突少,CAS性能最优
邮件系统批量发送 悲观锁(表锁) 批量操作,表锁简单可靠

玩家背包:乐观锁的典型场景

背包操作很频繁。玩家捡东西、用道具、卖装备,每秒可能有几千次操作。但冲突概率其实很低——两个玩家同时操作同一个道具的概率很小。

用悲观锁的话,每次操作都要加锁、解锁,性能损耗很大。乐观锁就合适多了。

// 玩家使用道具
public boolean useItem(long playerId, long itemId, int count) {
    int retry = 0;
    while (retry < 3) {
        // 读取当前数据
        BagItem item = bagDao.selectByPlayerAndItem(playerId, itemId);
        
        // 业务检查
        if (item.getCount() < count) {
            return false;
        }
        
        // 乐观锁更新
        int rows = bagDao.updateCountWithVersion(
            playerId, itemId, 
            item.getCount() - count, 
            item.getVersion()
        );
        
        if (rows > 0) {
            return true;
        }
        
        // 更新失败,重试
        retry++;
    }
    
    // 重试三次还失败,记录日志
    logger.warn("useItem failed after retry, playerId={}, itemId={}", playerId, itemId);
    return false;
}
避坑指南:我曾经遇到过一个问题——重试次数设成了10次。结果某个道具被多人抢购时,数据库连接池被打满了。后来改成3次重试,配合消息队列异步处理,问题就解决了。

拍卖行:悲观锁的典型场景

拍卖行竞价就不一样了。最后几秒,可能几十个人同时出价。冲突概率极高。

用乐观锁的话,大部分请求都会失败重试。重试多了,反而比悲观锁还慢。

// 拍卖行竞价
public boolean bid(long auctionId, long playerId, long price) {
    // 开启事务
    Transaction tx = beginTransaction();
    try {
        // 悲观锁锁定拍卖行记录
        Auction auction = auctionDao.selectForUpdate(auctionId);
        
        // 检查价格
        if (price <= auction.getCurrentPrice()) {
            tx.rollback();
            return false;
        }
        
        // 更新价格
        auctionDao.updatePrice(auctionId, price, playerId);
        
        tx.commit();
        return true;
    } catch (Exception e) {
        tx.rollback();
        throw e;
    }
}

这里用 SELECT ... FOR UPDATE 锁住行,其他竞价请求只能排队等。虽然慢一点,但保证了公平性。

注意:悲观锁一定要设置超时时间。不然某个事务卡住了,后面的请求全堵死。我见过一个线上事故,就是因为事务没提交,拍卖行停了5分钟。

选型总结

说白了,选乐观锁还是悲观锁,看两个指标:

  • 冲突概率:冲突高用悲观锁,冲突低用乐观锁
  • 并发量:并发高用乐观锁,并发低用悲观锁

你想想看,背包操作冲突低、并发高,乐观锁合适。拍卖行竞价冲突高、并发相对低,悲观锁合适。

嗯,这里要注意一点:没有银弹。我见过有人非要用乐观锁做拍卖行,结果重试逻辑写得很复杂,性能还不如悲观锁。也见过有人用悲观锁做背包,数据库连接池天天报警。

选型不是选「最好的」,而是选「最合适的」。