乐观锁与悲观锁:两种截然不同的哲学
说到数据一致性,锁是个绕不开的话题。我个人习惯把锁分为两类:乐观锁和悲观锁。名字挺有意思的,一个乐观,一个悲观。
悲观锁的想法是:「肯定有人跟我抢,我先锁上再说。」
乐观锁的想法是:「应该没人跟我抢,完事了再检查一下。」
这两种思路,在游戏服务器里都有用武之地。选哪个,得看场景。
乐观锁的实现方式
乐观锁不真的「锁」数据。它靠的是版本号或者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比加锁快得多。
悲观锁的实现方式
悲观锁就是真的锁。数据库层面有行锁和表锁两种。
行锁
只锁住你要操作的那一行。其他行不受影响。
-- 开启事务
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;
}
拍卖行:悲观锁的典型场景
拍卖行竞价就不一样了。最后几秒,可能几十个人同时出价。冲突概率极高。
用乐观锁的话,大部分请求都会失败重试。重试多了,反而比悲观锁还慢。
// 拍卖行竞价
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 锁住行,其他竞价请求只能排队等。虽然慢一点,但保证了公平性。
选型总结
说白了,选乐观锁还是悲观锁,看两个指标:
- 冲突概率:冲突高用悲观锁,冲突低用乐观锁
- 并发量:并发高用乐观锁,并发低用悲观锁
你想想看,背包操作冲突低、并发高,乐观锁合适。拍卖行竞价冲突高、并发相对低,悲观锁合适。
嗯,这里要注意一点:没有银弹。我见过有人非要用乐观锁做拍卖行,结果重试逻辑写得很复杂,性能还不如悲观锁。也见过有人用悲观锁做背包,数据库连接池天天报警。
选型不是选「最好的」,而是选「最合适的」。