你是不是也遇到过这种诡异现场:同一笔订单的支付回调被第三方平台连推三次,库存居然被扣了两次;或者表单只是双击了一下提交按钮,数据库里就多出两条一模一样的记录。很多人第一反应是加锁,用 synchronized 锁自己进程里的代码,结果服务一上多节点,锁就形同虚设了。
真正能扛住多实例、多线程并发场景的,我这些年用得最多也最顺手的一套组合拳就是Redis setnx + UUID。setnx 负责在 Redis 上抢占一个“只有我能通过”的互斥标记,UUID 则充当这个标记的持有者身份,两者搭配刚好解决分布式环境下的互斥、防重、防误删问题。
这套方案不依赖额外中间件,不引入复杂框架,只要业务里已经有 Redis 就能直接落地。特别适合中小团队、独立开发者、以及那些并发量没有夸张到需要上 ZooKeeper 的重型业务。下面我把这套组合从原理到实战完整拆开讲清楚,包括我在生产环境踩过的那些坑。
1. 从一次“并发事故”说起:到底哪里漏了锁
1.1 真实场景:库存扣减、回调重试、重复提交
我最早用上 setnx + UUID,是因为一个库存扣减事故。那是电商项目的秒杀模块,服务端逻辑很简单:收到下单请求后,查库存 -> 判断库存是否充足 -> 扣减库存 -> 生成订单。单机部署的时候一切正常,后来为了平稳支撑流量,服务从 1 个节点扩到了 3 个节点,噩梦就来了。
多个线程同时经过 Nginx 被分发到不同节点,它们同时查库存,都发现还剩下 1 件,然后同时执行扣减,最后库存变成负数。更可怕的是订单表里出现了两条完全相同的记录,因为两个请求都认为自己买到了最后一件商品。这就是典型的读-改-写竞态问题,单机的 synchronized 无能为力,因为锁只在单个 JVM 内生效,3 个节点需要的是跨进程的全局锁。
类似的问题在支付场景里更隐蔽。第三方支付平台为了保证通知可靠送达,会按照间隔重试机制反复推送回调消息,比如 15 秒后重试、1 分钟后重试、15 分钟后重试。如果业务系统没有做幂等控制,每收到一次回调就更新一次订单状态、加一次账户余额,用户账户余额就会翻倍增加,这种事故上线一次就能让公司赔到怀疑人生。
1.2 单机锁的边界与分布式锁的困境
先理清楚一个概念:synchronized、ReentrantLock这类 JVM 锁,锁住的是当前进程内的线程,它们通过 Monitor 机制保证的是同一 JVM 内的线程互斥。一旦服务拆成多个实例部署,每个实例都有自己独立的 JVM,进程 A 的锁根本管不住进程 B 里的线程。
有人可能会说,那我用数据库的行锁不就行了?SELECT ... FOR UPDATE确实可以跨进程互斥,但问题也明显:锁由数据库连接持有,长时间事务会占着连接不释放,高并发下数据库连接池很容易被打满;而且这种方案会把压力全部引到数据库上,数据库通常是整个系统的瓶颈所在,不应该让它承担这种高频的锁竞争。
分布式锁需要的是一个所有进程都能访问到的“公共裁判员”,Redis 就是现成的选择。它的SETNX命令天然具备“没键就写入,有键就拒绝”的语义,原子性由 Redis 服务端保证,多个客户端并发执行时不会出现中间状态。再加上 Redis 本身就是为高并发读写设计的,性能远超数据库锁方案。
但只有 setnx 还是不够。如果所有客户端都用同一个固定字符串作为锁的 value,解锁的时候就会出大问题,这个后面详细讲。给锁加上 UUID 作为唯一持有者标识,正是这套方案真正的精髓所在。
2. 核心武器拆解:SETNX 的前世今生与 UUID 的妙用
2.1 SETNX 命令到底在做什么
SETNX全称是SET if Not eXists,语义非常直白:如果 key 不存在,就设置 key 并返回 1;如果 key 已经存在,不进行任何操作并返回 0。
# 第一次执行,key 不存在,设置成功,返回 1 SETNX lock:order:10001 "request-id-aaa" (integer) 1 # 第二次执行,key 已存在,设置失败,返回 0 SETNX lock:order:10001 "request-id-bbb" (integer) 0在 Redis 2.6.12 以前,使用 SETNX 加锁存在一个致命问题:整个加锁过程需要分两步完成,先SETNX设置锁,再用EXPIRE给锁设置过期时间。假如执行完第一步之后、还没执行第二步的时候,进程突然崩溃或 Redis 出现异常,这个锁就永远不会过期,变成一把死锁,后面的请求全部被挡在外面。
好在 Redis 2.6.12 之后官方对SET命令做了扩展,支持NX、EX、PX等选项,把“设置值 + 判定不存在 + 设置过期时间”合并成了一个原子操作:
# NX 表示不存在才设置,EX 表示过期时间,单位为秒 SET lock:order:10001 "request-id-aaa" NX EX 30 # 等价写法,PX 表示过期时间,单位为毫秒 SET lock:order:10001 "request-id-aaa" NX PX 30000返回OK表示拿到锁,返回nil表示没拿到锁。这里的原子性至关重要,它从根上杜绝了“设置锁成功后还没来得及设置过期时间就宕机”导致的死锁问题。现在无论是 Jedis、Lettuce、Redisson 还是各种语言 SDK,底层都已经封装好了这条命令,我们调用setIfAbsent(key, value, timeout, unit)就是在执行原子性的 setnx 加锁操作。
2.2 为什么锁的 value 要用 UUID
这就是我反复强调的重点。很多初学分布式锁的人会写出这样的代码:加锁时 value 写死成字符串"locked",解锁时直接用DEL lock:order:10001。表面看没毛病,但实际运行一段时间就会出现一种极其隐蔽的 bug。
想象一下这个时间线:
- 线程 A 通过 setnx 拿到锁,value 是固定的
"locked",业务处理比较慢,超过了锁的过期时间 30 秒 - 锁自动过期了,线程 B 通过 setnx 拿到锁,value 同样是
"locked" - 线程 A 的业务终于执行完了,执行
DEL lock:order:10001 - 线程 B 手里的锁被线程 A 删掉了,此时线程 C 又可以通过 setnx 拿到锁
- 线程 B 和线程 C 同时执行业务,互斥失效,并发事故重演
问题出在:锁的 value 无法区分持有者是谁,所以任何线程都可以删掉别人的锁。解决办法就是给每次加锁操作生成一个唯一的 UUID作为 value,把它当作“身份证”。解锁之前先取出当前锁的 value 和自己的 UUID 比对,是自己的锁才删除,不是自己的直接放弃。
String requestId = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);UUID 由时间戳、机器标识、随机数等多重因素生成,碰撞概率低到可以忽略不计。实际项目中我用过雪花 ID、用当前线程 ID + 随机数拼接,本质上都可以,只要保证在全局范围内差不多唯一就行。但如果项目里没有现成的 ID 生成器,直接UUID.randomUUID()就是最干净的选择,不依赖任何外部组件。
3. 落地实操:基于 setnx + UUID 的分布式锁完整实现
3.1 加锁:一行代码完成原子操作
先看一个完整的 Java 实现,使用 Spring Boot 的StringRedisTemplate,这是最主流的写法:
@Autowired private StringRedisTemplate redisTemplate; public boolean tryLock(String lockKey, String requestId, long expireSeconds) { // setIfAbsent 对应 Redis 的 SET key value NX EX seconds return Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS) ); }这里我特意强调使用StringRedisTemplate而不是普通的RedisTemplate,因为RedisTemplate默认使用 JDK 序列化,写入 Redis 的 value 会带着二进制序列化头,后面用 UUID 字符串去比对的时候永远对不上,这个坑我踩过,后面排查章节会细说。
加锁失败的线程怎么办?两种策略:一种是直接放弃,返回“操作失败,请重试”,适合秒杀、抢购这类不需要等待的场景;另一种是自旋重试,用一个循环加短暂 sleep 反复尝试获取锁,适合需要保证任务最终执行的场景,比如定时任务。自旋重试要注意控制最大尝试次数和间隔时间,避免无效请求把 Redis 打垮。
3.2 解锁:Lua 脚本保证原子性
解锁环节很多人会偷懒,写成“先 GET 后 DEL”的普通代码:
if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); }这段代码存在一个竞态窗口。Java 代码中GET和DELETE是两个独立的 Redis 命令,中间隔着网络往返和 Java 代码执行间隙。假设线程 A 执行完 GET,发现 value 确实是自己的 UUID,但在执行 DELETE 之前锁恰好过期了,线程 B 拿到了锁,线程 A 的 DELETE 依然会把线程 B 的锁删掉。误删问题绕了一圈又回来了。
正确的做法是把校验和删除放到同一个 Lua 脚本里,让 Redis 保证整个脚本的原子执行:
-- redis:del_if_equals.lua -- KEYS[1] 是锁的 key,ARGV[1] 是当前线程持有的 UUID if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end对应的 Java 调用:
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 boolean unlock(String lockKey, String requestId) { Long result = redisTemplate.execute( new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class), List.of(lockKey), requestId ); return Long.valueOf(1L).equals(result); }Lua 脚本在 Redis 中执行时,整个脚本不会被其他命令插入,所以“判断 value 是否是我的”和“删除 key”这两个动作必然是同时完成或同时不完成的,从根本上去掉了竞态窗口。
3.3 防误删的三种写法对比
我把常见的三种解锁方案整理成一张表,方便直观对比:
| 方案 | 实现方式 | 是否防误删 | 问题 |
|---|---|---|---|
| 方案 A | 固定 value +DEL key | 否 | 任何线程都能删锁,误删概率极高 |
| 方案 B | GET 比较 UUID +DEL key | 部分 | 两步非原子,存在竞态窗口 |
| 方案 C | Lua 脚本比较 UUID +DEL key | 是 | 无竞态,逻辑正确性最高 |
方案 C 是生产环境的标准做法。可能在低并发、纯内部接口调用的场景下,方案 B 的窗口期几乎不会出现,但它就是一个定时炸弹,谁也不知道哪次 Full GC、哪次网络抖动就会放大这个窗口。分布式锁本身就是用来防并发问题的,不能在锁的收尾环节自己再制造一个并发问题。
3.4 多种语言实现参考
不同技术栈的原理完全一样,这里再补两个常见的语言版本。
Python 版本,使用 redis-py:
import redis import uuid r = redis.Redis(host='127.0.0.1', port=6379, db=0) def acquire_lock(lock_key, expire_seconds=30): request_id = str(uuid.uuid4()) # SET key value NX EX seconds ok = r.set(lock_key, request_id, nx=True, ex=expire_seconds) return ok, request_id def release_lock(lock_key, request_id): # Lua 脚本保证“比对 + 删除”原子执行 script = """ if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end """ return r.eval(script, 1, lock_key, request_id) == 1Go 版本,使用 go-redis:
import ( "context" "github.com/redis/go-redis/v9" "github.com/google/uuid" "time" ) // AcquireLock 加锁成功返回 lockToken,失败返回空字符串 func AcquireLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) string { token := uuid.New().String() ok, err := rdb.SetNX(ctx, key, token, ttl).Result() if err != nil || !ok { return "" } return token } // ReleaseLock 使用 Lua 脚本解锁 func ReleaseLock(ctx context.Context, rdb *redis.Client, key, token string) error { script := ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end ` return rdb.Eval(ctx, script, []string{key}, token).Err() }不管用哪种语言,核心就是四件事:加锁用SET NX EX原子操作、锁标识用 UUID、解锁用 Lua 脚本做校验删除、业务逻辑包在 try-finally 结构中确保最后一定释放锁。
4. 业务拓展:setnx + UUID 做幂等防重与唯一单号
4.1 幂等键设计:UUID 当请求的唯一身份证
分布式锁只是 setnx + UUID 的一种用法,实际上这套组合还有一个非常高频的场景:接口幂等防重。
什么是幂等?同一个请求无论被提交一次还是十次,对系统产生的影响都只有一次。放到前面支付回调解的例子来说,第三方支付平台会按固定策略重试推送回调,业务接口必须保证:第一条回调来了,正常处理;后续重试的回调来了,不做重复处理。
实现思路很简单。客户端在发起请求时生成一个 UUID 放在请求头里作为requestId,服务端收到请求后,先执行SET requestId "1" NX EX 60。如果设置成功,说明这是第一次收到这个请求,放行执行后续业务;如果设置失败,说明这个请求已经处理过,直接返回之前的处理结果或一个标识“重复请求”。
String requestId = request.getHeader("X-Request-Id"); // 客户端传入 if (requestId == null || requestId.isEmpty()) { requestId = UUID.randomUUID().toString(); } Boolean first = redisTemplate.opsForValue() .setIfAbsent("idem:" + requestId, "1", 60, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(first)) { // 重复请求,直接返回 return Result.repeat(); } // 正常执行业务逻辑这里 UUID 的价值就体现得很充分:客户端不用请求服务端生成唯一编号(省一次网络开销),也不会像自增 ID 那样在不同的服务实例上有生成冲突,更不需要依赖消息队列的顺序保证。
4.2 幂等防重和分布式锁的异同
顺便把这两个概念对比清楚,很多读者会把它们搞混。
分布式锁的精髓是“同一时刻只能有一个线程执行”,强调的是并发互斥。A、B 两个请求同时到达,A 拿到锁执行,B 必须等待或失败,锁释放后 B 才能进入。
幂等防重的精髓是“同一个标识只能成功处理一次”,强调的是时间维度上的去重。A、B 两个请求是同一笔订单的两次回调,它们不是并发到达的,而是先后到达的,系统要识别出它们其实是一回事。
两者本质共用了一套“互斥 + 唯一标识”的机制,差别主要在于锁的生命周期和粒度。分布式锁通常根据业务执行时间设置较短的过期时间,执行完立刻释放;幂等键通常要覆盖整个重试窗口,比如设置 5 分钟甚至 24 小时,确保所有重试请求到达时都还处于有效期内。
实际项目里这两个经常组合使用。比如下单接口,先用 UUID 幂等键挡住重复提交,再用 setnx 分布式锁保护库存扣减这个临界区,两层配合,既挡重试,又防并发。
5. 容易踩的坑与高频报错排查实录
5.1 锁提前过期导致业务没锁住,怎么破
这是 setnx + UUID 方案最大的短板:锁的过期时间到了,但业务还没执行完。锁一过期,其他线程就能拿锁进来,前一个线程的业务还在跑,临界区保护名存实亡。
应对思路有三个。第一,给锁设置一个合理的、偏长的过期时间。前提是你对业务的最长执行时间有清晰预期,比如批量导出任务,估算最慢可能 20 秒,就把过期时间设成 30 秒到 60 秒,留足余量。第二,业务内部主动续期,写一个守护线程定时检查:如果业务还没结束,就用 Lua 脚本给锁续期,相当于给锁“续命”。第三,引入 Redisson 这类成熟框架,它内置了“看门狗”机制,默认加锁后会自动续期,业务结束前锁不会提前释放。
对于大多数不追求极致性能的业务,我建议先做好第一条和第二条,最简单的方式是把过期时间设得足够大,同时在 finally 块里确保释放锁。分布式锁的过期时间宁可大一点,也不要因为设置太小导致并发穿透。
5.2 误删他人锁的根源与脚本原子性
前面原理部分讲过了,再给一个非常具体的排查案例。某天线上突然出现重复扣款订单,日志里看到两个线程都执行了临界区代码,但明明加锁了。查到最后发现,项目里解锁代码用的是先 GET 再 DEL 的两步写法,一个执行慢的线程把自己的锁等过期了,另一个线程进来拿到锁,第一个线程业务结束执行 DEL,把第二个线程的锁删了。
修复方案就是用 Lua 脚本把“值比对”和“key 删除”做成一个原子操作。这再次印证了:分布式锁的错误绝大多数不是加锁出错,而是解锁环节出错。
5.3 Redis 主从切换导致锁丢失的争议
围绕 Redis 分布式锁最大的争议之一是主从架构下的锁丢失问题。Redis 主从复制默认是异步的,客户端在主节点写入锁 key 成功后返回,但这个 key 还没同步到从节点,主节点就挂了,哨兵把某个从节点提升为新主节点。此时新主节点上没有这把锁的数据,其他客户端就能再次加锁成功,互斥失效。
对这个问题,Redis 官方提出了 RedLock 算法:向多个独立的 Redis 节点依次加锁,超过半数成功才算加锁成功。但 RedLock 本身争议也很大,多位分布式系统专家都指出过它在某些时钟场景下依然不安全。
以我的实际经验,对于绝大多数业务系统,不需要走到 RedLock 这一步。除非你面临的条件非常苛刻,Redis 节点故障概率高、同时并发量巨大、锁失效会引发严重资损事故。这种情况下更应该考虑引入 Redisson,它支持 RedLock 封装,还支持故障转移时的锁保护策略。普通项目老老实实用单节点 Redis 加哨兵高可用就够了,锁的过期时间保守设置,出问题的概率远比想象中低。
5.4 RedisCommandTimeoutException 超时问题的排查思路
很多新手第一次在生产环境跑这个方案,会遇到这么一行报错:
io.lettuce.core.RedisCommandTimeoutException: Command timed out after 10 second(s)注意这次报错的是 Redis 客户端命令超时,不是业务锁逻辑本身的错误。常见原因无非以下几种:
第一,网络抖动或 Redis 服务端阻塞。Redis 是单线程模型,如果有一条慢查询(比如KEYS *命令扫描全库、大集合的SMEMBERS)卡住了,后续所有命令都会排队等待,超过客户端超时阈值就抛异常。排查时先redis-cli -h 127.0.0.1 -p 6379 ping看连通性,再用SLOWLOG GET 10查看慢查询记录。
第二,Lettuce 客户端默认超时设置不合理。Spring Boot 2.x 默认使用 Lettuce 客户端,底层基于 Netty,默认超时时间不一定适合你的场景。在配置里显式调优:
spring: data: redis: timeout: 5s lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 shutdown-timeout: 100ms第三,连接池被打满。并发量大而连接池上限设得小,客户端线程排队等连接超过超时时间。可以调大max-active,同时检查业务代码有没有及时释放连接。Lettuce 本身是线程安全的,正常情况下一个连接实例能并发处理很多请求,但如果开启了共享连接配置不当,也可能出现连接饥饿。
第四,大 key 的删除操作导致 Redis 阻塞。删除一个包含数百万元素的 hash 或 list 时,DEL命令是同步阻塞的,期间所有请求都会卡住。正确的做法是使用UNLINK命令异步删除,或者对大 key 分批裁剪。
5.5 RedisTemplate 序列化导致 UUID 比对失败的坑
一个非常隐蔽的坑:Spring Boot 项目里同时注入RedisTemplate<String, String>,然后写入字符串 UUID,GET 出来比对的时候发现值不一样,锁删除永远失败。
原因在于默认的RedisTemplate使用 JDK 序列化器,写入 Redis 的实际内容是经过序列化处理后的二进制数据,而不是纯字符串。你存进去的是"d5a9b7f2-8c31-4e43-a1d1-55c9c4e1a2b3",存到 Redis 里的可能是一长串带类名信息的二进制内容,再 GET 出来自然对不上。
解决方案就是统一使用StringRedisTemplate,它内部已经帮我们把 key 和 value 的序列化器都配置成了StringRedisSerializer,存进去的是普通字符串,取出来也是普通字符串。如果你的项目必须使用自定义的RedisTemplate,记得显式设置序列化器:
@Bean public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, String> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); template.setKeySerializer(stringRedisSerializer); template.setValueSerializer(stringRedisSerializer); template.setHashKeySerializer(stringRedisSerializer); template.setHashValueSerializer(stringRedisSerializer); template.afterPropertiesSet(); return template; }这个坑最让人头疼的是它“偶尔正常、偶尔异常”,并发高的时候误删锁的 bug 才会被放大,排查成本很高。
6. 我在生产环境用这套方案的习惯和几个建议
这套 setnx + UUID 组合我用了很多年,虽然 Redisson 这类框架已经能提供更完善的开箱即用能力,但很多场景下我依然倾向于手写这套轻量方案。原因很简单,依赖最少、逻辑透明、出了问题能一眼看穿。
在锁的 key 命名上,我习惯统一用lock:前缀加业务域再加具体业务标识,比如lock:order:10001。这样在 Redis 里一眼就能看到当前有哪些锁,排查死锁问题时非常方便。过期时间我一般按业务最慢耗时的 3 倍来设置,宁可让锁多占一会儿,也不能让它在业务结束前失效。加锁失败的线程,我倾向直接返回失败结果,不做大量自旋,因为自旋会放大对 Redis 的请求压力。
幂等键的 key 我习惯用idem:前缀,和锁明确区分开。过期时间根据重试窗口来定,一般至少覆盖第三方平台的最大重试间隔。如果对接的支付通道重试策略是 15 分钟,就把幂等键过期时间设成 30 分钟以上。
最后再分享一个我踩过坑后养成的习惯:加锁和解锁的日志必须打印出来,包含 key 和 UUID。线上出问题回查日志时,你可以完整还原哪个线程在什么时间拿到了锁、什么时间释放了锁、是否出现了误删。这套方案的逻辑本身不复杂,真正难的是在复杂链路里快速定位问题,而完善的日志是所有排查手段的基础,比任何监控面板都直接。