"明明用了SETNX,为什么锁还是被超卖了?!"凌晨3点,监控报警把我从床上拽起来时,订单系统的库存数据已经乱成了一锅粥。这是一次典型的分布式锁失效事故------但当我翻出Redis日志时,却发现所有SETNX命令都返回了1(成功)。
问题的诡异之处在于:你以为的原子操作,可能根本不是原子。
1. 事故现场:订单超卖与"幽灵锁"
我们的电商促销系统在峰值期间每秒处理约2万笔订单,核心逻辑是用Redis分布式锁保证库存扣减的原子性。伪代码如下:
java
// 错误写法:典型的"以为这样就能用"的分布式锁实现
public boolean deductStock(Long itemId) {
String lockKey = "stock_lock:" + itemId;
try {
// 试图用SETNX获取锁
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (!locked) return false;
// 执行业务逻辑
int stock = stockDao.query(itemId);
if (stock <= 0) return false;
stockDao.update(itemId, stock - 1);
return true;
} finally {
redisTemplate.delete(lockKey); // 释放锁
}
}
看起来没问题?但监控显示:同一时刻有3个线程成功获取了锁并执行了库存扣减。更诡异的是,Redis的慢查询日志里根本没有并发SETNX请求------它们确实是串行执行的!
2. 根因解剖:原子性的"时间裂隙"
问题出在锁的获取与过期时间设置不是原子操作。翻看Redis源码会发现:
SETNX本身是原子的EXPIRE本身也是原子的- 但
SETNX + EXPIRE组合起来就不是原子的!
在我们的案例中,RedisTemplate的setIfAbsent方法底层实际上执行了两个命令:
arduino
SETNX lock_key 1 // 步骤1
EXPIRE lock_key 30 // 步骤2
如果在步骤1和步骤2之间Redis宕机,就会产生一把永不过期的幽灵锁。而我们的运维恰好在那天晚上做了主从切换...
3. 正确的救赎:从"自以为原子"到"真原子"
解决方案是使用Redis 2.6.12+的SET扩展参数:
java
// 正确写法:使用原生SET命令的NX+EX参数
public boolean safeDeductStock(Long itemId) {
String lockKey = "stock_lock:" + itemId;
String token = UUID.randomUUID().toString(); // 客户端唯一标识
try {
// 原子性设置锁和过期时间
boolean locked = redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) {
return connection.set(
lockKey.getBytes(),
token.getBytes(),
Expiration.seconds(30),
RedisStringCommands.SetOption.SET_IF_ABSENT
);
}
});
if (!locked) return false;
// 业务逻辑(略)
return true;
} finally {
// 确保只有锁的持有者能删除锁(Lua脚本保证原子性)
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(lockKey),
token
);
}
}
关键改进点:
- 用单条SET命令实现
NX+EX的原子操作 - 引入客户端唯一token防止误删其他线程的锁
- 用Lua脚本保证锁释放的原子性
4. 性能对比与代价
| 方案 | TPS(单节点) | 网络往返次数 | 宕机安全性 |
|---|---|---|---|
| SETNX + EXPIRE | 12,000 | 2 | 可能死锁 |
| 单SET NX+EX | 15,000 | 1 | 安全 |
| Lua脚本实现 | 9,000 | 1 | 安全 |
实测发现:即使是最安全的Lua方案,性能也比错误写法高出25%------因为减少了网络往返。
5. 分布式锁避坑清单
- 永远用SET NX+EX替代SETNX+EXPIRE:这是血泪教训
- 锁必须有唯一标识:防止线程A删了线程B的锁(我们后来发现那天晚上这种事也发生了)
- 设置合理的超时时间:太短会导致业务未完成锁就释放,太长会导致死锁
- 避免锁重入:相同客户端重复获取锁时要特别小心
- 网络分区时做好预案:Redis集群脑裂时可能出现多个客户端同时持有锁
结语
分布式系统的诡异之处就在于:所有你认为理所当然的原子性,都必须用代码明确保证。那天晚上的事故让我重新理解了Redis的"原子性"------它只在明确的单条命令层面保证,任何多命令组合都需要你亲自验证。
你在项目里还遇到过哪些"看起来原子实际不原子"的坑?欢迎在评论区分享你的战争故事。