
不知道你有没有遇到过这种场景线上订单模块突然收到一堆告警用户连续点了两下“提交订单”结果数据库里出现了两条一模一样的数据。产品经理一脸懵地看着你你说“我加个判断不就行了”但如果只是简单地先get判断再set两个请求同时进来的时候判断都通过了该重复还是重复。这时候Redis 的一个原子操作setIfAbsent()就能派上用场。这句话对应的正是 Redis 里的SETNX语义——不存在才写入存在就什么都不做。在很多项目里它会被封装成一个叫RedisForValueService之类的服务类专门处理分布式锁、接口幂等、缓存防击穿这些“只允许成功一次”的场景。这篇文章就围绕RedisForValueService.setIfAbsent()这个方法把原理、三种典型用法、工程化封装里容易踩的序列化和超时坑以及我排查过的几个生产事故一起讲清楚。适合手里正在写分布式锁、做接口幂等或者被缓存穿透搞到头秃的后端开发朋友参考。1. 先搞清楚setIfAbsent到底做了什么事1.1 SETNX的前世今生一个命令解决并发写入竞态Redis 的SETNX是“SET if Not eXists”的缩写翻译过来就是“不存在才设置”。在 Redis 2.6.12 版本之前setnx是一个独立命令后来官方推荐直接使用SET key value NX EX seconds这种方式因为一条命令就能同时指定“不存在才写”和“过期时间”。Spring Data Redis 里我们常用的setIfAbsent(key, value, timeout, unit)底层就是封装了这条命令。你可能会问我手动先get再set不也能达到同样的效果吗关键区别在于原子性。Redis 的命令执行是单线程模型一条命令从接收到执行完毕中间不会插入其他命令。SETNX本身就是一步操作多个客户端同时调用时Redis 会一个一个执行天然没有竞态窗口。而“先查后写”是两步操作两个请求同时读到“key 不存在”然后同时写竞态就出现了。这跟我之前在订单场景里遇到的问题一模一样判断逻辑写在代码里并发一上来就穿帮。值得一提的是Redis 里的 String 类型并不只是用来存字符串setnx这个语义让它天然适合做“互斥标记”。所谓锁本质上就是一个“只有一个人能成功写入的 key”谁抢到了这个 key谁就拥有了执行权限。这也是为什么所有分布式锁的最简版本都是从一个setIfAbsent开始的。1.2 简单到不行的参数坑却一点不少setIfAbsent的方法签名通常长这样Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit);key互斥标记比如lock:order:1001、idempotent:pay:123456。value建议放一个唯一标识比如UUID或者请求号不能写死一个固定字符串。timeout和unit过期时间这参数最容易被忽略但恰恰是事故高发点。先说value。如果你存的是固定值1那么在释放锁的时候会有个隐患线程 A 加锁后业务执行时间太长key 已经过期线程 B 加锁成功这时候线程 A 执行完直接delete(key)删掉的是 B 的锁。所以释放之前必须校验 value 是不是自己的这个后面讲释放锁的 Lua 脚本时再细说。再说返回值。这个方法返回的是Boolean注意不是基本类型boolean。有些同学直接if (redisTemplate.opsForValue().setIfAbsent(...))看着没问题但在某些客户端实现里网络异常时可能返回null直接拆箱就会抛NullPointerException。我习惯写成Boolean success redisForValueService.setIfAbsent(key, value, 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(success)) { // 抢锁成功 }这样写即使返回null也只会走到“抢锁失败”的分支不会崩。还有一个新手容易混的点setIfAbsent是“不存在才写”setIfPresent是“存在才写”两者正好相反。还有一个set方法是无条件覆盖。用的时候看清楚方法名别在幂等控制里用了set那就等于把判断逻辑扔了。2. 三个高频场景我都是怎么用的2.1 分布式锁有手就会的版本但不建议直接上生产先给一个最简分布式锁的代码骨架String lockKey lock:order:1001; String requestId UUID.randomUUID().toString(); boolean locked redisForValueService.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后重试); } try { // 真正的业务逻辑比如扣库存、创建订单 doBusiness(); } finally { // 释放锁只有 value 是自己的才删 redisForValueService.releaseLock(lockKey, requestId); }释放锁不能直接delete必须用 Lua 脚本保证“判断 删除”是原子的if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end我把这个脚本放在releaseLock方法内部执行避免了上面说的“误删他人锁”问题。这个版本虽然能用但有几个毛病不可重入、不支持自动续期、没有公平性。如果只是一般性的防重复并发它够用如果是资金相关的高并发场景后面还是得考虑 Redisson 这种成熟方案。这里也提醒一句Redis 锁依赖 Redis 本身的可用性如果 Redis 宕机且没有开启合适的持久化策略比如 AOF 每秒刷盘重启后锁数据可能丢失极端情况下锁会失效。如果业务容忍度极低那就需要引入 RedLock 之类多节点方案或者干脆用数据库乐观锁兜底。但 RedLock 本身也有争议设计上并不简单。2.2 接口幂等防止重复提交分布式锁更偏“并发互斥”而幂等控制更多是“同一次请求只处理一次”。比如支付回调、表单重复提交这种场景特别适合用setIfAbsent做幂等标记。我的做法是前端提交时生成一个全局唯一的请求号后端收到请求后以idempotent:{userId}:{bizNo}作为 key调用setIfAbsent写入一个标记。如果写入成功说明这是第一次来的请求放行执行如果写入失败说明这个请求号已经处理过直接返回“重复提交”。String idempotentKey idempotent:pay: request.getBizNo(); boolean firstRequest redisForValueService.setIfAbsent(idempotentKey, 1, 60, TimeUnit.SECONDS); if (!firstRequest) { return Result.error(重复请求请勿多次提交); }这里的过期时间需要注意不能太短否则第一次请求还没处理完key 就过期了用户重试时又能进来也不能太长否则占用的内存会一直积压。我的经验是根据业务最长处理时间定通常是几分钟到几小时同时设置一个定时清理任务把超过业务周期的幂等 key 删掉。还有一点经验幂等标记的写入时机和业务处理结果要打通。如果业务执行失败并返回给用户“下单失败”那这个幂等 key 应该及时删除否则用户换手机号重试还是提示重复体验会特别糟糕。2.3 缓存击穿与“只重建一次”的缓存很多后端同学都知道缓存穿透、缓存击穿、缓存雪崩三个词但经常会混。简单区分一下穿透查询一个肯定不存在的 key请求直接打到数据库。击穿某个热点 key 刚好过期大量请求同时打到数据库。雪崩大量 key 在同一时间过期流量全部压到数据库。setIfAbsent在防击穿上的用法是“重建锁”。当发现缓存里没有数据时不是所有请求都去查库而是先抢一把“重建锁”只有抢到锁的请求才去数据库查询并回填缓存其他请求短暂自旋等待或者直接返回上一次缓存的数据。String cacheKey product:detail:1001; String value cacheService.get(cacheKey); if (value null) { String lockKey rebuild:lock: cacheKey; String requestId UUID.randomUUID().toString(); boolean locked redisForValueService.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (locked) { try { // 二次检查缓存防止抢到锁时前面的人已经重建完 value cacheService.get(cacheKey); if (value null) { value queryFromDatabase(cacheKey); cacheService.set(cacheKey, value, 300, TimeUnit.SECONDS); } } finally { releaseLock(lockKey, requestId); } } else { // 没抢到锁可以先自旋重试几次或者返回兜底数据 Thread.sleep(50); value cacheService.get(cacheKey); } }这里有个细节抢到锁之后进到try里面一定要再做一次缓存查询也就是“双重检查”。因为可能在你抢锁之前另一个线程已经重建完缓存并释放了锁你抢到的是一把空锁如果不检查就会白白查一次库。类似的模式在单机 JVM 的Double Check Lock里也有思路是相通的。3. 工程化封装RedisForValueService的实操记录3.1 一个能直接用的封装类项目里我一般不会让业务代码直接操作RedisTemplate而是封装一个RedisForValueService把所有跟setIfAbsent相关的方法收敛起来方便统一管理过期时间、序列化策略和 Lua 脚本。下面是一个简化版但能跑的实现Service public class RedisForValueService { private final StringRedisTemplate redisTemplate; private static final String UNLOCK_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public RedisForValueService(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public boolean setIfAbsent(String key, String value, long timeout, TimeUnit unit) { Boolean result redisTemplate.opsForValue() .setIfAbsent(key, value, timeout, unit); return Boolean.TRUE.equals(result); } public boolean releaseLock(String key, String requestId) { DefaultRedisScriptLong script new DefaultRedisScript(UNLOCK_SCRIPT, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(key), requestId); return Long.valueOf(1L).equals(result); } }注意我这里用的是StringRedisTemplate而不是默认的RedisTemplate。为什么因为默认的RedisTemplate用的是JdkSerializationRedisSerializerkey 和 value 都会被 Java 序列化写进去的东西肉眼根本看不懂而且跨语言、跨服务调用时很容易出问题。StringRedisTemplate的 key 和 value 都以字符串方式读写配合 Redis Desktop Manager 之类的客户端查看时至少你能一眼看出存的是什么。封装类的命名、方法粒度可以根据业务扩展比如加上setIfAbsentWithRandomExpire用来防缓存雪崩加上getAndDelete用来处理一次性令牌。核心原则是底层 Redis 命令的细节不要暴露给业务方业务方只需要关心业务语义。3.2 序列化为什么value老是变成“乱码”这是我在排查另一个项目时遇到的典型问题。业务方反馈说“分布式锁没效果”结果一看 Redis 客户端key 长这样\xac\xed\x00\x05t\x00\x0elock:order:1001value 也是一堆乱码。原因就是RedisTemplate默认的序列化器是 JDK 序列化把字符串 key 序列化成了二进制字节而负责删除锁的代码里用的是StringRedisTemplate两者的 key 在 Redis 里根本对不上锁自然永远删不掉后续请求也永远抢不到锁。解决方案说起来很简单全链路统一序列化器。要么全部用StringRedisTemplate要么在自定义RedisTemplate时显式指定StringRedisSerializerRedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(connectionFactory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet();如果 value 是对象我会用 JSON 序列化而不是 JDK 序列化。JSON 的好处是可读、跨语言、不用兼容 Java 的Serializable接口。坏处是对象里有LocalDateTime这类类型时Jackson 需要额外注册 JavaTimeModule否则序列化会报错。为了避免这类麻烦用StringRedisTemplate存 JSON 字符串是最省心的。排查序列化问题时最快的办法是用可视化客户端看一眼 key 的前缀。正常的字符串 key 直接可见带\xac\xed开头的基本都是 JDK 序列化没跑。这个细节可以在测试环境提前发现别等到上了生产再抓狂。3.3 超时时间与自动续期两个不能省的设计setIfAbsent的第三个参数timeout是双刃剑。设得太短业务还没执行完锁就自己过期了另一个线程乘虚而入设得太长如果拿到锁的线程崩溃了其他线程要等很久才能拿到锁。我一般在设置前会先评估接口的最长耗时然后在这个基础上乘 2 到 3 做缓冲。比如订单接口正常情况下 200ms 内完成最慢不超过 2 秒那我设置 5 秒就已经比较稳。如果业务里调了外部接口、批量处理或者定时任务时间需要按真实情况压测后调整。但“按经验设置超时”毕竟治标不治本更稳的方案是给锁续期。Redisson 里的 WatchDog 机制本质上就是后台线程每隔一段时间检查锁是否接近过期如果仍然持有就把过期时间往后推。手写这个逻辑也不复杂加锁成功后起一个守护线程每 10 秒执行一次expire(key, 30, TimeUnit.SECONDS)业务结束后在finally里取消守护线程并释放锁。这里有个容易忽略的坑守护线程的续期操作本身也要求当前线程确实还持有锁。如果业务超时太久key 已经被别人占用了续期操作等于在给别人续命。所以严谨的做法是续期前也要用 Lua 判断 value 是否仍然是自己持有。类似地Spring 的 Scheduled或线程池线程做续期时要考虑线程上下文别把续期任务丢在全局线程池里不清理会导致任务堆积。4. 实战中的常见问题与排查记录4.1 问题一锁明明加了为什么还是重复执行这是我在排查一个“抢购活动超卖”问题时遇到的。代码里确实加了setIfAbsent压测却还是出现了超卖。最后定位出几个原因。第一个是锁粒度问题。业务方把锁 key 写成了lock:product所有商品共用一把锁虽然锁生效了但并发吞吐被压得很低于是后来被人改成了lock:product:{id}。结果改的时候把{id}拼错了不同请求落到不同 key 上等于没锁。第二个是 MySQL 事务和 Redis 锁的时序问题。很多同学把Transactional和锁写在同一个方法里setIfAbsent成功后立即进入业务逻辑但事务提交发生在方法返回之后。也就是说Redis 锁释放那一刻数据库事务可能还没提交。另一个线程抢到锁后查到的还是旧数据于是继续重复执行。解决办法是把 Redis 锁放到事务方法外面或者使用TransactionSynchronizationManager在事务真正提交后再释放锁。第三个是 Redis 主从切换。如果使用了主从架构且加锁写的是主节点还没有同步到从节点时主节点挂了从节点顶上后发现锁 key 不存在其他线程就能再次加锁。这个问题的严格解是 RedLock 或者引入共识算法但多数业务场景下把锁的可靠性跟数据库的唯一索引兜底结合起来性价比更高。排查这类问题我一般先在 Redis 客户端里看锁 key 的 TTL 和 value确认是不是同一把锁再去看业务日志里的加锁时间点和事务提交时间点判断是不是“先释放锁后提交事务”最后再检查主从节点的数据确认有没有丢 key。4.2 问题二io.lettuce.core.RedisCommandTimeoutException很多 Spring Boot 项目用的是 Lettuce 客户端网上也常看到类似io.lettuce.core.RedisCommandTimeoutException: Command timed out after 10 second(s)的报错。出现这个报错不等于setIfAbsent本身有问题而是 Redis 整体响应变慢了。我遇到过几种典型的根因。第一种是网络抖动或 Redis 服务端出现慢查询。比如有人用KEYS *命令在线上遍历 keyRedis 单线程要处理完全遍历才能响应新命令所有操作全部排队setIfAbsent自然超时。排查方法是开启 Redis 的慢日志看CONFIG GET slowlog-log-slower-than把超过 100ms 的慢命令找出来。第二种是大 key 操作。如果一个 key 里存了几 MB 的数据执行get或set时网络传输耗时很长同一条连接上的其他命令也会被阻塞。Lettuce 是异步 NIO 模型多个线程共享连接虽然 IO 不会像 BIO 那样“卡死”但大 key 的序列化、传输、反序列化时间依然会拖垮整体响应。第三种是连接池耗尽。Lettuce 默认情况下是共享连接的但有些场景下会配置成连接池模式如果最大连接数设得很小高并发时拿不到连接就会表现为超时。通过监控 Redis 的连接数和命令耗时基本都能定位到具体是哪一类问题。我自己的习惯是不要一见到超时就把timeout调到 30 秒。这只是一种掩盖手段真正要做的是把慢命令找出来、把大 key 拆掉、把连接池参数调合理。4.3 问题三重启后锁和幂等标记不生效有次发布版本后业务方反馈说“所有请求都提示重复提交”连第一次请求都进不来。查了很久发现问题出在 Redis 持久化和重启上。服务发布重启时Redis 也触发了重启由于持久化策略配置的是默认 RDB 快照最近一段时间写入的幂等 key 在重启后丢失了。这个场景其实分两种一是 Redis 主动重启key 丢失属于持久化策略问题二是 key 本身到了过期时间属于正常的过期清理。排查时先看TTL剩余时间再看 Redis 的日志和lastsave时间基本能区分。如果要避免重启丢 key可以开启 AOF 持久化并配置appendfsync everysec。但注意 AOF 也有性能开销尤其写密集的场景下磁盘 IO 和主线程刷盘策略需要权衡。另外幂等这种业务标记不应该只依赖 Redis 一层的可靠性更稳妥的做法是同时给数据库加唯一索引Redis 丢了数据库兜底还能拦住重复请求。4.4 高频问题速查表现象可能原因排查方向两个请求都抢到了锁锁 key 不统一或主从切换丢 key检查 key 拼写、Redis 持久化与主从同步状态锁释放后还是有问题事务未提交就释放锁用 TransactionSynchronizationManager 延迟释放value 在客户端看到乱码序列化器不统一统一 StringRedisSerializer / JSON反复出现命令超时慢查询、大 key、连接池耗尽Redis slowlog、bigkey 扫描、连接数监控Redis 重启后幂等失效RDB 快照丢数据AOF 未开启开启 AOF数据库加唯一索引兜底TTL 未到但锁提前失效expire 被覆盖或淘汰策略触发检查配置项评估 maxmemory-policy这张表不是教条真实排查时往往多个原因叠加在一起最好按“客户端状态 - Redis 服务端状态 - 业务代码时序”的顺序逐层排查。最后分享几个我踩过坑之后留下来的习惯一个是加锁代码里的try块范围尽量包住完整业务逻辑但不要把setIfAbsent本身放在try外面就不管了万一抢锁失败抛异常接口要能返回一个清晰的提示而不是让调用方看着 500 发呆。另一个是给所有 Redis key 统一加业务前缀比如lock:、idempotent:、rebuild:排查问题时能靠前缀快速过滤。还有一个是老生常谈但值得再提一次的教训能不用delete(key)直接释放锁就不要用少了 value 校验那一步早晚会在某个深夜给自己挖坑。setIfAbsent只是一个基础原语真正的可靠性还是靠使用它的人把边界想清楚。