☰
Redis setIfAbsent实战:从幂等标记到分布式锁
2026/10/14 9:46:44 网站建设 项目流程

1. 一个重复回调问题,让我开始正视setIfAbsent()

我接到过这样一个问题:后端服务的支付回调明明只成功一次,负责对账的同一订单却插入了两条重复数据。查日志发现是消息队列把同一个回调投递了三次,业务代码没有做幂等。方案可以用数据库唯一索引,但那个核心库加字段要走流程,团队也不想为了一个回调加索引。于是我试着在 Redis 里用setIfAbsent先“占坑”,发现确实能挡重复,但随后也踩出了一串问题。

从那一刻开始,我决定把RedisForValueService.setIfAbsent()这套东西彻底吃透。它绝不是一句“Redis 里有个命令叫 SETNX”这么简单,真正落地时涉及序列化选择、TTL 设计、返回值的空指针防护、锁的安全释放、以及和业务场景的边界判断。这篇文章不打算讲入门语法,而是把我在实际项目里用到、踩到、验证过的东西都写出来。

1.1 为什么本地 Map 和数据库唯一索引都不够顺手

如果你的服务只有一个实例,用ConcurrentHashMap.putIfAbsent做幂等去重完全够用。但一旦服务实例数大于一,哪怕只是两个节点,本地缓存就立刻失效:A 实例收到回调占了一个坑,B 实例不知道,照样把同一订单处理了一遍。容器调度导致实例频繁重启的时候,本地 Map 里面的状态还会丢,这就更不能靠它做跨节点互斥了。

数据库唯一索引是另一条可靠路线,但它的问题是成本和前提。你要么在表里加一个业务唯一键字段,要么把订单号做成分片键配合唯一约束。对一个高速回调场景来说,每次处理都先打一次数据库、再靠唯一索引冲突来兜底,连接压力和慢日志都是额外的负担。Redis 的setIfAbsent是“网络一次原子操作”,它在所有实例之间共用同一个判断口径,性能比打库高一个量级,而且天然自带过期能力,非常适合做前置拦截层。

我并不是说数据库唯一索引可以不用。严谨的支付对账场景,数据库唯一约束依然应该作为最终兜底;Redis 负责挡掉绝大多数并发重复,数据库负责处理极端情况下的漏网之鱼。两层职责不同,不冲突。但如果你只想快速让业务不再重复处理,用RedisForValueService.setIfAbsent()撑起第一道闸门,往往是成本最低的选择。

1.2 setIfAbsent()和putIfAbsent()是两件事

不少 Java 开发者看到这个名字,会下意识跟ConcurrentHashMap.putIfAbsent类比。表面上确实是“不存在才写入,存在就不写”,但背后的语义完全是两回事。

ConcurrentHashMap.putIfAbsent是进程内操作,作用范围仅限当前 JVM。它的原子性由 JVM 的锁机制保证,你最多能保证一个进程里只有一个线程能写入成功。而 Redis 的setIfAbsent是跨进程、跨节点的原子操作,所有客户端都连到同一个 Redis 上去做“存在性检查 + 写入”,判断和执行之间不会被其他命令插队。

生活化一点理解:本地 Map 的 putIfAbsent 就像是在自己家墙上贴一张“已占位”的纸条,只有家里人看得到;Redis 的 setIfAbsent 则是把纸条贴到小区大门口的统一公告栏上,任何一栋楼的住户都能看到同一个状态。如果你的业务只需要本机单线程知道某件事做过,用本地 Map;只要涉及多个服务节点协同,就必须用 Redis 这种外部存储来立规矩。

2. setIfAbsent()背后的Redis原语,没搞清楚会踩大坑

2.1 SETNX的历史遗留:没有过期时间的原子命令是不完整的

Redis 早期提供了SETNX命令,全称是SET if Not eXists,意思是“key 不存在时才设置”。这个命令本身是原子的,但它有一个非常明显的短板:不支持和过期时间一起设置。

如果你的操作分两步:第一步SETNX key value,第二步EXPIRE key 30,那这两步之间进程如果崩溃,或者网络异常,第二步可能根本没执行,key 就会永久留在 Redis 里。这种问题在测试环境可能永远不出现,一到线上发布窗口、偶发进程被 kill 时就会爆发。

后来 Redis 从 2.6.12 版本开始,给SET命令扩展了 NX、XX、EX、PX 这些选项,官方也明确建议用SET key value NX EX seconds来替代单独使用 SETNX。这样一来,“判断 key 不存在”和“设置过期时间”就在同一条命令里完成,原子性有了,TTL 也有了。

理解这几个原语的区别,对你判断问题很有帮助:

命令形态行为是否原子可带过期时间
SETNX key value不存在才写入是不能
SET key value NX不存在才写入是通过 EX/PX 选项
SET key value XX存在才写入是通过 EX/PX 选项
SET key value EX 30无论存在与否都写入是是

2.2 Spring Data Redis里的setIfAbsent到底执行了什么

在 Spring Data Redis 里,redisTemplate.opsForValue().setIfAbsent(key, value)底层映射的正是SET key value NX。如果你调用的是带超时的重载方法,比如setIfAbsent(key, value, Duration.ofSeconds(30)),它会把 NX 和 EX/PX 一并在同一个 SET 命令里发给 Redis,保证原子性。

这里有一个细节值得注意:ValueOperations.setIfAbsent的返回值是Boolean,不是基本类型 boolean。在实际业务代码中,要么用Boolean.TRUE.equals(result)判空,要么就做好自动拆箱可能抛 NPE 的预防。我见过有人在代码里直接if (setIfAbsent(...)),心里默认它一定返回 true 或 false,一旦 Redis 连接异常时方法返回 null,线上直接告警。

另外,setIfAbsent(key, value)和setIfAbsent(key, value, ttl)是两个不同签名。前者没有设置过期时间,相当于把 key 永久留在 Redis;后者会按照你传入的 TTL 自动过期。很多排障场景之所以变成“排查好久”,就是对这两个签名的差别没有敏感度。

2.3 返回值背后还有两个容易被忽略的行为

第一,无论setIfAbsent是否成功,它都不会覆盖原值。也就是说,key 已经存在时,第二次调用返回 false,原 value 保持不变。这一点对幂等设计很重要:你可以把第一次写入的 value 当作“处理中”的状态标记,后续请求看到 false 后如果需要了解当前处理状态,再单独get一次。

第二,返回 false 并不代表有什么异常,它只代表“key 已经存在”。这意味着,如果你把setIfAbsent当作锁来用,false 的正确处理方式通常是“让当前请求稍后重试或直接获取旧数据”,而不是把 false 当成 Redis 故障去报错。我在接手一些代码时,经常看到有人拿到 false 就抛异常,结果 Redis 一切正常,业务却因为“锁被人持有”而不断报错,这就是语义理解不到位。

3. 封装RedisForValueService,我拍板的三个设计

3.1 一律使用StringRedisTemplate,别让序列化器毁掉可观测性

Spring Data Redis 的RedisTemplate默认用的是 Jdk 序列化。这个选择在大多数内部缓存场景里没大问题,但一旦你的 key 会被 redis-cli、日志系统或被其他语言的服务读取,默认序列化的 key 会变成一串以\xac\xed\x00\x05开头的乱码,排查问题非常痛苦。

所以我封装RedisForValueService时,第一件事就是把模板固定为StringRedisTemplate。key 和 value 都按 String 处理,在命令行里看到的就是原始可读内容,对账、抓包、人工清理都方便得多。如果你的业务值本身需要存对象,我也不建议把对象序列化完全依靠 Redis 模板来做,宁可先把对象转成 JSON 字符串,再交给这个 Service 存。

这个决定的另外一个好处是:锁场景里,value 通常是一个 UUID 或时间戳字符串,用 StringRedisTemplate 存取,能让后续基于 Lua 脚本的“比对后删除”操作保持字节级一致。如果换成 Jackson 或 JDK 序列化,同样的逻辑值存进去是带类型信息或引号的内容,和脚本里传入的原始字符串对不上,解锁永远失败。

3.2 带TTL和不带TTL的方法必须分开,且参数校验

封装方法时,最忌讳的是把“可带可不带过期时间”揉成一个方法。我的RedisForValueService里保留了无 TTL 版本,但强制要求业务方明确知道自己在做什么;真正高频使用的,是带 TTL 的重载方法。TTL 参数本身要做合法性校验,否则传一个负数进去,Redis 端行为会变得很怪。

这是我最终确定的简化实现:

public class RedisForValueService { private final StringRedisTemplate redisTemplate; public RedisForValueService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public boolean setIfAbsent(String key, String value) { return Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(key, value)); } public boolean setIfAbsent(String key, String value, Duration ttl) { if (ttl == null || ttl.isZero() || ttl.isNegative()) { throw new IllegalArgumentException("ttl must be positive"); } return Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(key, value, ttl)); } }

别小看Boolean.TRUE.equals(...)这个写法。它一方面避免 NPE,另一方面把null安全地转换为 false。很多团队踩过这个坑:Redis 端偶发超时返回 null,业务把 null 当 false 处理后,在幂等场景里表现为“睡了一会儿后全部请求都认为重复”,非常诡异。

3.3 key统一加前缀与埋点统计

封装一层 Service 的价值,不只是少写几行代码,更重要的是把 key 的命名规范和风险控制统一起来。我通常在RedisForValueService内部定义一个KEY_PREFIX常量,然后所有读写操作都在前面拼上业务前缀。这样既避免业务之间的 key 互相污染,也为后续做统计、灰度切换、清理脏数据留下了空间。

同时,我建议在 Service 里对setIfAbsent的成功和失败次数做简单计数。可以用 Redis 自身的INCR做统计,也可以只在本地内存里记一个计数器。统计的意义在于,上线后你可以快速判断某个去重场景的“拦截率”是否符合预期。如果成功率是 100%,说明你的 key 设计可能有问题,或者根本没有重复流量到达;如果失败率异常飙升,也要能第一时间定位是哪一类业务 key 在打架。

4. 高频场景实战:幂等回执与缓存防击穿

4.1 回执幂等:先“占坑”再处理,占坑即拒绝

消息队列的投递语义通常是最少一次,也就是说,重复消息一定会来。处理回调、对账、积分发放这类薄弱幂等场景,最直接的做法就是在处理业务之前先setIfAbsent一个处理标记。能设置成功的请求继续执行,没设置成功的请求直接忽略。

一个典型的调用逻辑是这样的:

String dedupKey = "biz:callback:order:" + orderId; if (!redisForValueService.setIfAbsent(dedupKey, "processing", Duration.ofMinutes(5))) { log.warn("duplicate callback ignored, orderId={}", orderId); return; } try { updateOrderStatusAndWriteAccount(orderId); } catch (Exception ex) { redisTemplate.delete(dedupKey); throw ex; }

关键点有三个。第一,dedupKey必须由业务唯一标识拼成,比如订单号、回调流水号,不能用一个全局常量。第二,TTL 要覆盖整个业务的最长处理时间,但不是越大越好,否则处理失败的补偿操作要等很久才能重新尝试。第三,业务异常时要把占坑 key 删掉,否则这次失败后同一消息重试也被幂等挡掉,单子就永远卡住了。

如果你需要更严格的“失败后可重放”,可以把 value 从简单的"processing"改成状态标记,比如processing、success、failed,然后在重试逻辑里先读 value 再判断。但注意,这一步已经脱离了setIfAbsent的原子语义,需要额外设计状态流转规则。

4.2 缓存防击穿:setIfAbsent当重建闸门

缓存防击穿的经典场景是:某个热点 key 过期的一瞬间,大量请求同时打到数据库。最常见的做法是用一把“缓存重建锁”,让第一个请求去查数据库并回填缓存,其他请求先走旧缓存或者短暂的等待。

用setIfAbsent做重建闸门时,要注意区分两个 key:一个是真正的业务缓存 key,另一个是“重建中”的栅栏 key。业务缓存 key 存的是真实数据,栅栏 key 只用来控制并发。

String cacheKey = "cache:resource:" + resourceId; String gateKey = "gate:resource:" + resourceId; boolean first = redisForValueService.setIfAbsent(gateKey, "1", Duration.ofSeconds(10)); if (!first) { return readOldCacheOrDefault(cacheKey); } try { Data data = loadFromDb(resourceId); cache.put(cacheKey, data); return data; } finally { redisTemplate.delete(gateKey); }

这个方案能挡住绝大多数并发击穿,但有一个边界要想清楚:拿到 false 的请求此时读到的是旧缓存,甚至读不到数据。如果业务允许短暂返回默认值,这个方案没问题;如果每个请求都必须拿到真实数据,那么拿不到重建权的请求应该“短暂等待后重读缓存”,而不是直接放弃。

4.3 明确边界:库存扣减类场景别指望setIfAbsent

setIfAbsent只解决“key 不存在才写入”的问题,它不做数值的加减,也不做条件扣减。有些人想当然地用它来防超卖:每个扣库存请求先 setIfAbsent 一个锁,拿到锁后读库存、减库存、写回。这套流程本身没错,但真正的原子扣减仍然要靠DECR、DECRBY或者 Lua 脚本来完成,setIfAbsent在这里只充当了互斥工具,而不是扣减工具。

如果你只是为了防止同一个用户重复领取优惠券、防止同一个设备重复参与活动,setIfAbsent这种“一次性占坑”非常适合。一旦涉及余额、库存、次数这类需要预扣的资源型数据,就必须回到 Redis 的数值原子命令,或者用 Lua 把检查、扣减、校验放在同一个脚本里执行。选错工具,往往是你觉得已经防住了,局部并发一高就出大问题。

5. 用它实现分布式锁,setIfAbsent只是上半场

5.1 锁的value必须是随机token,否则解锁就是定时炸弹

很多人知道用setIfAbsent加锁,但 value 随手写死一个"1"或"lock"。这就留下了一个很危险的解锁竞态:线程 A 持有锁,业务执行时间超过 TTL,锁自动过期;线程 B 用相同 key 再加锁成功;这时 A 执行完毕,如果它用delete(key)释放锁,会把 B 的锁直接删掉。结果是两个线程同时认为自己持锁,临界区被并发进入。

解决方法是把锁的 value 设为一个每次加锁时随机生成的唯一标识,一般是 UUID。释放锁之前,先检查当前 Redis 里的 value 是不是自己当初写入的那个,只有匹配时才删除。

加锁代码:

String token = UUID.randomUUID().toString(); boolean locked = redisForValueService.setIfAbsent("lock:order:" + orderId, token, Duration.ofSeconds(30)); if (!locked) { return false; } try { doBusiness(orderId); } finally { unlock("lock:order:" + orderId, token); }

5.2 解锁要用Lua脚本,平凡“先查再删”没有原子性

有了 token 之后,如果你还是先把锁读出来、用 Java 判断相等、再调用 delete,那“判断”和“删除”之间依然可能被其他线程插足。正确的做法是把这两步放到 Lua 脚本里,让 Redis 服务端一次性执行完。

我在RedisForValueService里维护一个解锁脚本,逻辑很简单:如果当前 key 的值等于传入的 token,则删除;否则返回 0。

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>( "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) " + "else return 0 end", Long.class); public boolean unlock(String key, String token) { Long result = redisTemplate.execute( UNLOCK_SCRIPT, List.of(key), token); return Long.valueOf(1L).equals(result); }

脚本返回值 1 表示确实由自己释放了锁,返回 0 表示锁已不存在或值不匹配。这个返回值是有用的:你可以根据它判断是否发生了 TTL 过期导致的锁提前失效。如果频繁出现解锁返回 0,就要考虑放宽 TTL 或者引入续期机制了。

5.3 TTL定多少,以及“看门狗”续期的实现思路

分布式锁的 TTL 设置是一个典型权衡。设得太短,业务还没执行完锁就过期,其他请求趁虚而入;设得太长,一旦持有锁的节点宕机,其他请求要等很久才能拿到锁。我一般的经验是:先调出业务执行时间的 P99,把 TTL 设置为这个值的 3 到 5 倍,同时做好解锁返回 0 的监控。这样既不频繁续期,也不会因为偶发慢请求导致锁被提前释放。

对于真正的长任务,你不应该无限加大 TTL,而是应该实现续期。思路是加锁成功后启动一个后台定时任务,每隔 TTL 的三分之一时间就执行一次EXPIRE,刷新过期时间;业务执行完主动取消这个定时任务,同时释放锁。如果节点宕机,定时任务随之停止,锁会自动过期,不会永久卡死。

这套“看门狗”思路听起来简单,落地时也要注意资源泄漏。定时任务必须和业务持有锁的生命周期绑定,释放锁时立即取消,并且最好用线程池而不是每次临时new一个线程。用成熟库可以省掉这些细节,但如果不方便引入依赖,自己实现也完全可行。

6. 真实项目里踩过的坑,和并发验证中的发现

6.1 一次没设TTL的“永久锁”事故

有段时间,某个定时任务在加锁时只调了不带过期时间的setIfAbsent(key, value)。当时写代码的人觉得任务很短,执行完会主动删锁,不需要 TTL。结果一次发版窗口里,任务进程被强制停止,finally里的删除逻辑根本没机会执行,这个锁 key 就成了永久 key。

下一次任务启动时,setIfAbsent返回 false,它以为锁被别人持有,直接退出了。因为日志只打了“获取锁失败”,没有把 key 打印出来,团队花了很久才意识到是上一次残留的锁没清掉。最终人工连接 Redis 删了那个 key,任务才恢复。

这次事故之后,我把RedisForValueService的编码规范改成了:所有锁操作必须走带 TTL 的重载方法,无 TTL 的setIfAbsent只允许用于幂等标记场景,并且在代码评审里明确禁止拿它做锁。如果你团队里还有其他人在用 Redis 加锁,这条规则越早立起来越好。

6.2 序列化不一致导致解锁脚本永远失败

有一天,解锁脚本的返回结果突然从 1 变成 0,去查日志发现 token 明明是自己写入的,但 Lua 脚本里比对不相等。用 redis-cli 连上去看,发现 key 里面存的值根本不是预期格式。

当时用的RedisTemplate配置了 Jackson 序列化,UUID.toString()写入后被序列化成了带引号的 JSON 字符串,例如"6f5a3b0e-...",而 Lua 脚本的ARGV[1]传入的是没有引号的原始 UUID。Redis 字符串比较是字节级精确比较,一个带引号一个不带引号,怎么看都不相等。

这个问题最直接的解法,就是锁相关的一切 value 全部用StringRedisTemplate操作,保证写入和比对的是同一个字符串。如果你想在setIfAbsent里存对象,建议业务对象单独设计可读序列化格式,不要和锁的 token 混用。序列化问题往往是分布式锁最隐蔽的坑,因为它只在特定配置下才会出现。

6.3 并发验证:为什么正确结果是“成功1次失败N次”

为了验证这个封装的可靠性,我用多线程模拟了并发场景:10 个线程同时对同一个 key 调用setIfAbsent,最后统计返回值。运行结果符合预期,只有 1 个线程返回 true,其余 9 个返回 false,Redis 中 key 的值也是第一个线程写入的。

但接下来发现了一个更隐蔽的问题:那 9 个返回 false 的线程,如果直接在业务代码里“失败则返回”,客户端那边可能什么都没有拿到。比如三个请求同时进来,只有一个去查了数据库,另外两个拿到 false 后直接返回空结果,用户界面就会出现一次偶发白屏。真正稳妥的做法是:拿到 false 的请求不要静默放弃,而是返回“请稍后重试”,或者等待几十毫秒后重新读缓存。setIfAbsent只是控制并发入口,不代表业务结果会自动兜住。

6.4 主从切换与数据丢失,把期望管理好

最后说一个容易被忽略的问题。Redis 主从架构下,SET key value NX这个命令写入后,需要异步复制到从节点。如果你的锁要求极高的一致性,比如同一资源在同一时刻绝对不能并发处理,那么主节点写完后立刻宕机、复制还没完成的情况下,这个 key 在从节点上可能不存在,新的请求就会趁虚而入。

这不是 Bug,而是 Redis 异步复制的固有行为。我不建议在普通业务场景里为了这一点去上 RedLock 那一套复杂方案,因为多数场景的业务本身对极短时间的“双执行”是可容忍的。关键是提前想清楚:你的业务到底能不能容忍这种概率极低的锁失效?如果不能,就要换存储或换方案;如果能,就大大方方用 Redis 锁,并做好监控和补偿。

7. 最后的实操建议:怎么把这套能力沉淀下来

如果你也想在自己的项目里做一个类似的RedisForValueService,我建议按下面这几个约束来收口。

第一,Service 里不要只写一个 setIfAbsent 方法,要把“无 TTL 的幂等标记”和“带 TTL 的锁操作”从方法名和参数上区分开。无 TTL 版本加注释标注“慎用”,带 TTL 版本校验参数,避免负数或零值。

第二,锁的加锁和解锁拆成独立方法,不要在业务方法里拼 RedisTemplate 代码。解锁统一走 Lua 脚本,value 必须是调用方传入的 token,不让业务自己实现“先查再删”。

第三,key 统一加业务前缀,并沉淀到一个常量类或枚举里。不要散落在各个 Service 中,否则事后清理脏数据、做全局 cache 巡检的时候会非常痛苦。

第四,至少写三个场景的测试用例:第二次调用setIfAbsent必须返回 false;带 TTL 的 key 到期后必须消失;Lua 解锁时 token 不匹配不能删除。

我最后想说的是:分布式缓存的这类基础方法,看起来越简单,越值得把每一步的边界想清楚。序列化、TTL、原子性、释放方式,任何一个环节出了问题,线上都是比支付回调重复还要麻烦的事故。你如果能把这篇文章里的经验带回去,至少不会再因为一条裸的setIfAbsent而失眠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询