一次线上事故让我彻底搞懂了SETNX的坑
先说个真实的故事。去年双十一前夕,我们系统出了一次诡异的故障——用户下单后优惠券怎么也领不了,重复点击好几次都提示"领取失败"。最后排查出来的原因让整个组的人都沉默了:一个Redis命令,SETNX。
你以为SETNX就是"不存在就设置"这么简单?呵,太天真了。
什么是SETNX?
SETNX是SET if Not eXists的缩写,字面意思很好理解:当key不存在时才设置值。Redis官方的说法是"Set a key if it doesn't exist",返回值是1表示设置成功,0表示key已经存在。
听起来很美好对吧?分布式锁最常见的实现就是靠它:
SETNX lock_key unique_value
EXPIRE lock_key 30
但问题就出在这个组合上。从你执行SETNX到执行EXPIRE之间,如果你的进程突然挂了,或者这段代码所在的服务发生了OOM,这把锁就永远不会被释放。因为SETNX成功后没有自动过期,而EXPIRE还没执行。
原子性问题:一个看似无解的困境
这个原子性问题折磨了无数Redis用户。后来官方出了一个带过期时间的SET命令:
SET lock_key unique_value NX PX 30000
NX表示不存在才设置,PX表示过期时间以毫秒为单位。这样SET和过期时间就在一条命令里完成了,原子性问题解决。
但是等等,这篇文章不是为了教你写分布式锁的——这类教程网上多如牛毛。我想说的是另一个坑:SETNX的返回值,你真的用对了吗?
那个让我失眠一整晚的bug
回到开头那个双十一的事故。简化后的代码大概是这个样子:
public String claimCoupon(String userId, String couponId) {
String key = "coupon:" + couponId + ":claimed";
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, userId, Duration.ofMinutes(30));
if (Boolean.FALSE.equals(result)) {
return "您已经领过这张券了";
}
// 领取逻辑...
return "领取成功";
}
这段代码看起来完全没问题,对吧?setIfAbsent就是SETNX的封装,返回false说明key已经存在,即用户已经领过了。
但双十一当天,有大量用户反馈:明明没有领过券,为什么提示我已经领过了?
布尔值陷阱:null才是真正的凶手
问题出在Duration.ofMinutes(30)上。这是RedisTemplate的一个特性——当你设置了过期时间,但key已经存在时,setIfAbsent会返回null,而不是false。
也就是说:
// 第一次调用:key不存在,设置成功,返回true
setIfAbsent(key, userId, Duration.ofMinutes(30)) → true
// 第二次调用:key存在,但返回null而不是false!
setIfAbsent(key, userId, Duration.ofMinutes(30)) → null
而我们的代码是:
if (Boolean.FALSE.equals(result))
当result是null时,Boolean.FALSE.equals(null)返回false,所以不会进入"已经领取"的逻辑,而是继续往下执行——然后撞上了数据库的唯一约束,报错。
所以用户看到的是"领取失败",而不是"您已经领过这张券了"。
正确的判断方式
这个问题有两种解法。
第一种:直接判断是否为null
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, userId, Duration.ofMinutes(30));
if (result == null) {
return "您已经领过这张券了";
}
if (!result) {
return "您已经领过这张券了";
}
// 领取逻辑...
但这种写法有点丑,而且容易遗漏。更好的方式是:
第二种:使用Objects.requireNonNull
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, userId, Duration.ofMinutes(30));
if (Boolean.FALSE.equals(result)) {
return "您已经领过这张券了";
}
// 领取逻辑...
等等,这不就是我原来的代码吗?
别急,原来的问题是result可能是null。但如果你仔细看Boolean.FALSE.equals(null)的返回值——它是false。所以如果进入了if分支,说明result确实是false;如果没进入,要么是true要么是null。
真正的问题在于:null不代表key已存在,它只代表操作没执行。在没有过期时间的情况下,setIfAbsent返回null只可能是异常情况。
为什么Spring-data-redis要这么设计?
我后来翻了Spring-data-redis的源码,发现setIfAbsent底层调用的是Redis的SET命令。当你不带过期时间使用SETNX时,返回值是简单的integer(0或1)。但当你带了过期时间,Redis返回的是OK字符串——因为SET在成功时返回OK。
Spring把OK映射成了true,把失败映射成了false,但当key已存在且你设置了过期时间时,它返回null。这是个让人无语的设计选择,但在Spring的设计哲学里,null通常意味着"操作未执行或异常"。
经验总结
几个血的教训:
- 永远不要相信返回值是Boolean的Redis操作会返回null——在某些边界情况下它就是会。
- 带过期时间的SETNX一定要用SET key value NX PX ms——这是官方推荐的做法。
- 判断成功不要用result == true,而要用Boolean.TRUE.equals(result)——这样null会返回false,而不是true。
- 分布式锁的value要用唯一值——释放锁时要校验value,防止误删别人的锁。正确做法是:
if (redis.get(key).equals(value)) { redis.del(key); }
最后说两句
技术这玩意儿就是这样,一个看似简单的命令,背后可能藏着无数的坑。你以为你懂了的知识,往往在你最得意的时候给你一记耳光。
双十一那天晚上,我对着屏幕上的bug发呆了十分钟,最后是一个刚来三个月的应届生指着null说"这里不对劲"。有时候经验反而成了盲点,而新人没有历史包袱的脑子,反而能一眼看到问题。
所以别太相信自己的"经验",也别看不起任何一行"简单"的代码。生产环境中,最简单的代码往往死的最惨。
共勉。