
你是不是也遇到过这种诡异现场同一笔订单的支付回调被第三方平台连推三次库存居然被扣了两次或者表单只是双击了一下提交按钮数据库里就多出两条一模一样的记录。很多人第一反应是加锁用 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] 是锁的 keyARGV[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否任何线程都能删锁误删概率极高方案 BGET 比较 UUID DEL key部分两步非原子存在竞态窗口方案 CLua 脚本比较 UUID DEL key是无竞态逻辑正确性最高方案 C 是生产环境的标准做法。可能在低并发、纯内部接口调用的场景下方案 B 的窗口期几乎不会出现但它就是一个定时炸弹谁也不知道哪次 Full GC、哪次网络抖动就会放大这个窗口。分布式锁本身就是用来防并发问题的不能在锁的收尾环节自己再制造一个并发问题。3.4 多种语言实现参考不同技术栈的原理完全一样这里再补两个常见的语言版本。Python 版本使用 redis-pyimport redis import uuid r redis.Redis(host127.0.0.1, port6379, db0) def acquire_lock(lock_key, expire_seconds30): request_id str(uuid.uuid4()) # SET key value NX EX seconds ok r.set(lock_key, request_id, nxTrue, exexpire_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-redisimport ( 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 项目里同时注入RedisTemplateString, String然后写入字符串 UUIDGET 出来比对的时候发现值不一样锁删除永远失败。原因在于默认的RedisTemplate使用 JDK 序列化器写入 Redis 的实际内容是经过序列化处理后的二进制数据而不是纯字符串。你存进去的是d5a9b7f2-8c31-4e43-a1d1-55c9c4e1a2b3存到 Redis 里的可能是一长串带类名信息的二进制内容再 GET 出来自然对不上。解决方案就是统一使用StringRedisTemplate它内部已经帮我们把 key 和 value 的序列化器都配置成了StringRedisSerializer存进去的是普通字符串取出来也是普通字符串。如果你的项目必须使用自定义的RedisTemplate记得显式设置序列化器Bean public RedisTemplateString, String redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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。线上出问题回查日志时你可以完整还原哪个线程在什么时间拿到了锁、什么时间释放了锁、是否出现了误删。这套方案的逻辑本身不复杂真正难的是在复杂链路里快速定位问题而完善的日志是所有排查手段的基础比任何监控面板都直接。