去年我负责的一个订单服务扩大了部署规模,从单机变成三节点集群之后,测试环境一切正常,结果一上生产就出现了两个不同节点同时给同一笔订单发放优惠券的情况。查了半天日志,问题指向同一个时间点,两台机器的处理线程都执行了同一段折扣计算代码。加锁了吗?代码里明明加了,但加的是本地进程锁,三个节点各锁各的,根本锁不住人。后来我折腾了基于 Redis 的分布式锁,踩了不少坑,也最终把这套方案用到了生产环境。这篇东西就以实操为主,把从原理到落地再到排坑的完整过程写出来,适合正在选型、或者已经发现自己的锁“失灵”的同行参考。
1. 为什么单机锁锁不住多节点:一次重复调度事故的根因
先复盘一下当时的事故现场。订单服务的部署方式是三台机器同时对外提供服务,所有节点共享同一个数据库和同一套 Redis。某个整点会触发一批优惠券发放任务,这个任务本身是幂等性设计不彻底的,结果恰好在整点时刻被两台机器的定时线程同时扫到了同一批订单。线上日志显示:A 节点和 B 节点都在同一秒执行了update coupon set status=1 where user_id=?这类逻辑,两边都以为“这张券是我发的”,于是用户收到了两张一模一样的优惠券。
很多人第一反应是:代码里加锁啊。可是加的是什么锁?如果是 Java 的synchronized、ReentrantLock,或者 Python 的threading.Lock,这些锁对象的生命周期只存在于当前进程内部。三台机器各自维护各自的锁对象,A 节点加锁只锁得住 A 节点的线程,B 节点根本感知不到。这就是单机锁面对分布式环境的本质局限:锁的“裁判”在本地,而竞争者们分散在多台机器上,两边不是一个世界。
分布式锁的思路则是换一个所有节点都能访问的“公共裁判”来维护互斥状态。谁想进入临界区,先向裁判登记;登记成功的人做事,做完了通知裁判删除登记;如果登记后发现别人已登记,就等或者放弃。这个“公共裁判”可以是一张数据库表、一个文件系统目录、一个协调服务,也可以是我们今天要说的 Redis。
我用一张表对比过主流方案,方便直观感受:
| 实现方式 | 互斥原理 | 优点 | 缺点 |
|---|---|---|---|
| 数据库唯一索引 | 插入一条唯一记录,插入成功代表拿到锁 | 简单、容易理解 | 性能一般,数据库压力大,需要额外清理记录 |
| ZooKeeper 临时节点 | 客户端创建临时有序节点,序号最小者获得锁 | 可靠性高,客户端断开自动清理 | 引入额外组件,部署和运维成本高 |
| Redis SET NX | 利用“不存在才写入”的原子语义 | 性能极高,结构和命令简单 | 极端场景(主从切换)下可能丢锁 |
| etcd 租约 | 基于租约和 revision 机制 | 强一致,支持续约 | 组件较重,多数业务系统不会为此引入 |
对于大部分已经有 Redis 的团队来说,选择 Redis 其实是在“够用、快速、低额外成本”之间做个取舍。它非常适合对一致性要求没那么苛刻、但对性能和可用性要求很高的业务场景。接下来我一步一步拆解它的实现细节。
2. 三个底层命令撑起分布式锁:SETNX、过期时间和 Lua 脚本
2.1 为什么是 SETNX 而不是 SET:存在性判断的原子语义
Redis 分布式锁的核心建立在一个最简单的操作语义上:SETNX,即“如果 key 不存在,则设置成功;如果 key 已存在,则什么都不做”。这个指令天生就是“判断并写入”的原子操作。也就是说,多个客户端同时执行SETNX,最终只有一个能够成功写入,这个“唯一成功者”就是锁的持有者。
很多人刚接触时会想:用普通的SET命令,先GET判断有没有,没有再SET,不也一样吗?但这两条命令之间是有时间窗口的。假设线程 A 先GET发现锁不存在,线程 B 也在同一时刻GET发现锁不存在,然后两个线程同时去SET,双方都会认为“锁是我建立的”,互斥就失效了。分布式环境中这种竞态非常常见,绝不能靠“先查再写”来设计锁。
正确做法是用一条原子命令完成“检查 + 写入”。Redis 从 2.6.12 开始给SET命令扩展了NX(不存在才设置)、PX/EX(过期时间)等选项,相当于把SETNX + 过期时间融合进了一条命令。这也是当前实现分布式锁最推荐的做法。
2.2 过期时间是为了应付“客户端宕机”这个最坏情况
只有SETNX的话,会引出一个严重问题:如果客户端拿到锁之后,在释放锁之前突然宕机了、进程被 kill 了或者网络断开了,那这个锁对应的 key 会永远存在,其他所有客户端永远都拿不到锁。这就叫“死锁”。
解决办法是给 key 设置一个过期时间,也就是 TTL。拿到锁的客户端如果在规定时间内没有完成操作,锁就会被 Redis 自动清除,后续竞争者才有机会重新获取。这个设计思路和我们在操作系统里讲的“超时释放”很像——不是无限期信任某个客户端,而是假定“可能出故障”并设置止损线。
这里必须强调一条硬性规则:加锁和设置过期时间必须是一条命令完成。如果拆成SETNX和EXPIRE两条独立命令,仍然存在时间窗口——SETNX成功之后、EXPIRE执行之前进程崩溃,锁照样永远不释放。所以标准姿势是:
redis-cli> SET order:lock:1001 "client-uuid-xxxx" NX PX 30000NX表示“不存在才设置”,PX 30000表示“锁的生存时间 30000 毫秒(30 秒)”。如果返回OK,等于拿到了锁;如果返回(nil),说明锁已经被别人持有,需要等待或放弃。
2.3 Lua 脚本让多条命令变成不可分割的整体
SET NX PX已经解决了一半问题,另一半在释放锁的时候暴露出来。你以为释放锁就是DEL key这么简单?假设两个线程共享同一把锁,线程 A 拿到锁后执行任务,但执行时间超过了过期时间,锁被 Redis 自动清理。线程 B 这时通过SET NX PX拿到了同一把锁。线程 A 处理完业务之后,执行了DEL key,于是把线程 B 的锁也删掉了。紧接着线程 C 也成功加锁,三个线程同时进入临界区,锁完全失效。
要规避这个问题,加锁时需要为每个客户端生成一个唯一标识作为 value(比如 UUID 或者“客户端ID + 线程ID”),释放锁时先比较当前 value 是否和自己加锁时的 value 一致,只有一致才删除。这个“先比较再删除”的动作,如果拆成GET和DEL两条命令,中间同样有竞态窗口——比较完成之后、删除执行之前,锁正好过期,B 线程拿到锁,A 线程再执行DEL,又把 B 的锁删掉了。
所以释放锁必须用 Lua 脚本让“比较”和“删除”在 Redis 服务端原子地执行。标准脚本长这样:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end调用时把锁 key 传入KEYS[1],把自己持有的唯一标识传入ARGV[1]。Redis 会保证这个脚本在执行期间不会被其他命令插入,要么整个完成,要么整个不做,没有中间状态。这也解释了为什么 Redis 分布式锁的实现几乎绕不开 Lua——它是保证原子性的最后一块拼图。
3. 一份可上生产的加锁/解锁实现:从命令到完整流程
3.1 加锁:SET NX PX 的完整参数与携带身份标识
加锁这一步,我把实践中的代码拆成几个关键点来讲。以 Python 的 redis 客户端为例,加锁函数是这样写的:
import uuid import redis REDIS_CLIENT = redis.Redis(host="...", port=6379, decode_responses=True) def acquire_lock(lock_key: str, expire_ms: int = 30000): token = str(uuid.uuid4()) ok = REDIS_CLIENT.set(lock_key, token, nx=True, px=expire_ms) if ok: return token return None几个容易忽略的细节:
token必须每次加锁都重新生成,保证每个客户端持有的是“自己的身份”。不要把 token 固定写死,否则释放锁时永远匹配,误删问题依然存在。nx=True是客户端封装后的参数,底层对应SET key value NX;px=expire_ms对应过期毫秒数。- 返回值用
None或布尔值来判断加锁是否成功,而不是把ok本身当作锁标识。
3.2 解锁:先验证再删除,这个顺序不能打乱
解锁函数必须使用上一节提到的 Lua 脚本,而不是简单的delete方法。我在实际项目中是这样封装释放锁的:
UNLOCK_SCRIPT = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ def release_lock(lock_key: str, token: str): REDIS_CLIENT.eval(UNLOCK_SCRIPT, 1, lock_key, token)逻辑很简单:传入锁 key 和你加锁时返回的 token,Redis 服务端会比较当前值是否一致,一致才删除。不一致时直接返回 0,表示“锁已经不在我手里”,这种情况下千万不要再去DEL,因为那可能是别人刚拿到的锁。
3.3 一个完整的时间线:误删锁是怎么发生的
为了让大家彻底理解“为什么必须校验 value”,我画时间线说明一个经典故障:
- 12:00:00.000,线程 A 执行
SET lock:order "token-A" NX PX 30000,加锁成功。 - 12:00:29.500,线程 A 还在跑业务,锁在 12:00:30 过期,Redis 自动删除。
- 12:00:30.100,线程 B 执行
SET lock:order "token-B" NX PX 30000,加锁成功。 - 12:00:31.000,线程 A 终于执行完业务,进入释放锁环节。
- 如果此时释放逻辑直接
DEL lock:order,线程 B 的锁就被误删了。 - 12:00:31.200,线程 C 执行
SET lock:order "token-C" NX PX 30000,也加锁成功。 - 此时线程 B 和线程 C 同时进入临界区,共享资源保护彻底失效。
如果用带 value 校验的 Lua 脚本,第 5 步会先比较lock:order当前值。线程 A 持有的 token 是token-A,而 Redis 里存的已经是token-B,不相等,脚本返回 0,线程 A 发现自己已经失去锁,立即中断操作或者记录告警,线程 B 的锁安然无恙。这就是为什么社区里所有稳妥的解锁实现都是“值比较 + 删除”的原子脚本。
4. 锁的过期时间、key粒度与获取失败策略该怎么定
4.1 过期时间给多少:先算业务耗时,再留缓冲
过期时间的设置不是拍脑袋随便来的。给得太短,任务还没执行完锁就没了;给得太长,一旦持有锁的进程真的挂掉,其他客户端要白等很久才能接管。
我习惯的算法是:先对临界区代码做一次压测或采样,拿到正常情况下最长执行耗时,然后在这个基础上乘以一个系数,再叠加几秒的缓冲。比如正常执行业务最长耗时 4 秒,我会设置为4 * 5 + 10 = 30秒。5是安全系数,用来吸收 GC 停顿、网络抖动、线程调度延迟等不确定性;10秒是额外缓冲,防止某次请求异常慢导致锁提前失效。
当然,这个数字不能无脑放大。锁过期时间越大,容错能力越强,但“意外失去锁”后的影响也越大。一个折中方案是:给一个相对保守的过期时间,同时配合下一节讲的“看门狗续期”机制,让真正还需要锁的任务主动续租。这样即使进程异常退出,也不过是多存活一个续约周期,而不是无限期占着锁。
4.2 锁的粒度:key 的命名决定并发上限
锁的 key 设计非常影响系统的并发能力。最典型的反例是全局一把锁:不管处理哪笔订单,都用同一个lock:order。这样做的结果是,任何两笔不同订单的处理也会互相阻塞,系统吞吐量直接掉一个数量级。
更好的做法是引入业务维度。比如处理订单 1001 的业务使用 keylock:order:1001;处理订单 1002 的业务使用 keylock:order:1002。两把锁互相独立,互不干扰,系统可以同时处理多笔订单。这就是锁的“粒度”概念——锁定的范围越小,并发度越高。
我在项目里见过更细的用法:某些高频热点商品,单个 SKU 的库存扣减就是热点中的热点,可以继续拆分出“库存分片”,比如lock:stock:sku001:shard0、lock:stock:sku001:shard1。只要业务逻辑允许将扣减分摊到多个分片,这种设计可以极大降低单 key 上的竞争压力。要注意的是,分片粒度不能想当然拆,必须保证业务语义上两个分片互不影响,否则会出现超卖或重复计算。
4.3 获取失败策略:快速失败、阻塞重试还是异步补偿
加锁失败之后怎么处理,是很多新手容易忽视的设计点。同样一把锁,在高并发秒杀场景和后台异步任务场景,失败策略完全不同。
秒杀类场景更适合“快速失败”:拿不到锁就立即返回“当前人数过多,请稍后重试”,不让用户长时间等待。这时如果做阻塞重试,用户请求会积压在服务端,拖垮整个应用的线程池。
后台任务类场景更适合“阻塞重试 + 退避”:比如定时任务每 5 秒尝试获取一次锁,拿不到就继续等下一个周期。但轮询要有上限和退避策略,不能无脑死循环。我一般会让重试次数不超过 10 次,每次间隔从 50ms 递增到 500ms,超过次数后记录告警并退出。
还有一种思路是“异步补偿”:加锁失败后不放弃业务,而是把任务提交到 MQ,由消费者在后续某个时刻再尝试加锁处理。这种方案能显著提升系统的吞吐能力,代价是业务处理从“同步确定性”变成了“异步最终一致”,需要业务方评估可接受程度。
下表总结了三种失败策略的适用场景:
| 策略 | 适用场景 | 响应时延 | 实现复杂度 |
|---|---|---|---|
| 快速失败 | 秒杀、抢购、用户在线请求 | 低 | 低 |
| 阻塞重试 + 退避 | 定时任务、多节点抢占任务 | 中等 | 中 |
| 异步队列补偿 | 对一致性要求不高、可延后处理的逻辑 | 高(异步) | 中高 |
5. 续期、可重入、主从切换:生产中躲不开的三个进阶难题
5.1 看门狗:给长时间任务自动续期
业务执行时间超过锁过期时间这种场景,一定会在真实生产环境出现。毕竟 Redis 不会知道你这段代码需要跑多久。我把这类问题分成两种应对思路。
第一种是把过期时间设得足够长,长到业务绝对跑不完还能余出大量时间。缺点上一节说过,风险堆积在“持有者真正宕机”的时刻。
第二种是引入续期机制,业内叫“看门狗”。持有锁的客户端在锁快要过期时,自动把过期时间往后延长,直到业务完成手动释放锁。以一个 Java 伪代码为例:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); public String acquireWithWatchdog(String lockKey, long expireMillis) { String token = UUID.randomUUID().toString(); Boolean ok = redisTemplate.opsForValue() .setIfAbsent(lockKey, token, Duration.ofMillis(expireMillis)); if (Boolean.TRUE.equals(ok)) { // 每 expireMillis/3 毫秒续期一次 scheduler.scheduleAtFixedRate(() -> { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end"; redisTemplate.execute(script, List.of(lockKey), token, String.valueOf(expireMillis)); }, expireMillis / 3, expireMillis / 3, TimeUnit.MILLISECONDS); return token; } return null; }续期脚本同样用 Lua 保证原子性,并且要校验 value 还是自己的,否则可能给别人拿到的锁续期。实现续期的重点在于:续期能力必须在“锁已经释放后”停下来。也就是说,当业务主动调用releaseLock之后,要记得scheduler.shutdown或者取消定时任务,否则守护线程会一直续一把没人要的锁,白白占用 Redis 资源和线程资源。
5.2 可重入锁:要不要做,怎么做
Redis 分布式锁默认不是可重入的。所谓可重入,指的是同一个线程在持有锁的情况下再次对同一把锁加锁,不会把自己阻塞住。单机版ReentrantLock天然支持,但 Redis 分布式锁的SET NX PX语义是“无论谁,只要已存在就加锁失败”,哪怕是当前持有者也不留情面。
那需要让分布式锁支持重入吗?我的建议是:默认不需要。大多数业务场景里,锁的临界区应该保持很小、很独立,不应该出现“在持有锁的代码里再去获取同一把锁”这种设计。出现这种需求,往往意味着锁的范围划大了,或者临界区里嵌套了复杂的调用链。更合理的做法是调整锁边界,而不是实现重入。
如果某些框架层面确实需要可重入的语义,比如基础设施代码要统一封装锁的入口,那可以实现“计数版”分布式锁:加锁时如果 value 等于当前线程标识,则对该 key 的计数器加一,并刷新过期时间;解锁时计数器减一,减到零才真正删除 key。这里会用到 Lua 脚本维护一个 hash 或者 string 计数器。要注意计数器的数据类型和 TTL 刷新节奏必须协调好,否则很容易出现“计数不为零但锁已过期”的中间状态。
5.3 主从切换丢锁:RedLock 能解决,但不是银弹
Redis 分布式锁还有一个理论上的软肋:主从模式下的数据不一致。假设客户端 A 在主节点上成功加锁,但这个写入还没来得及同步到从节点,主节点就挂了。哨兵切换后,从节点被提升为新的主节点,然而它上面没有这把锁的数据。客户端 B 此时发起加锁请求,在新的主节点上也能成功写入同一个 key。这就导致两个客户端同时认为自己是锁的唯一持有者。
Redis 官网上提出过 RedLock 算法,思路是不依赖单台 Redis,而是向多个相互独立部署的 Redis 节点同时加锁,只有当超过半数节点加锁成功时,才认为整体加锁成功。某个别节点出现故障,不影响整体锁的正确性。这套算法在理论上提升了容错性,但在实际工程中也有不少争议:它要求部署多个 Redis 实例,运维成本翻倍;同时,网络分区时是否真的安全仍然受时钟漂移等问题影响。
我自己在实践中的态度是分层的。如果业务对锁的“绝对正确性”要求不是极高,比如定时任务去重、缓存重建保护这类场景,单靠SET NX PX + Lua 解锁已经够用,主从切换导致的极小概率锁丢失是可以接受的,因为业务本身有幂等设计或补偿机制。如果是对账户扣款、库存扣减这类资金和资源敏感的强一致性场景,与其花大力气堆 RedLock,不如直接用带租约和强一致语义的协调服务(比如 ZooKeeper、etcd)或者在数据库层做唯一约束,从根源上避开 Redis 在这个场景下的弱点。
6. 实测中的三个坑和我的选型判断
6.1 坑一:把远程调用、重逻辑放进了锁里
第一次完成分布式锁改造后,我发现一个现象:接口响应时间偶尔会飙到十几秒。后来定位到问题所在——我在持有锁的临界区里调了一个第三方接口获取用户信息,这个接口偶尔超时重试,把锁的持有时间放大了好几倍。锁内的逻辑越重,锁被占用的时间就越长,其他竞争者等待时间也越长,系统整体吞吐被严重拖累。
分布式锁应该只保护真正需要互斥的那一小段代码,比如检查状态、扣减库存、写关键记录。至于耗时的远程调用、文件处理、复杂计算,尽量放到锁外做。如果业务逼着你必须让多节点对同一批数据做“先读后写”的一致性保护,那也应该用上一节讲的看门狗续期,给长任务动态续租,而不是指望着“过期时间够大”来兜底。
6.2 坑二:轮询重试没有退避,把 Redis 打到了高负载
另一个印象深刻的问题是重试风暴。当时我在一个抢资源的功能里写了这样的代码:拿不到锁就while true每隔 10ms 重试,逻辑上没问题,但在几百个并发请求同时抢锁的时候,Redis 的 CPU 被打到了 80% 以上,响应延迟从 3ms 飙升到 200ms。原因很直接:大量客户端在极短时间间隔内猛烈重试同一个 key,Redis 每秒处理了大量无效的SET NX请求。
后来我改成了“固定间隔退避 + 最大重试次数”的组合:前 3 次每 50ms 一次,接下来 3 次每 100ms 一次,最多重试 10 次,超过就返回失败。这样既保证了快速响应,又降低了 Redis 端的无效请求量。更重要的是,我加了获取锁耗时的监控。如果发现绝大多数请求在首次尝试时就能成功,说明锁的粒度是合理的;如果大量请求依赖第二次、第三次重试才能成功,说明锁竞争激烈,需要考虑拆分锁粒度或者调整业务峰值流量。
6.3 我的选型判断:什么时候用简单锁,什么时候升级
抛开复杂的理论和算法讨论,我给团队定的选型标准其实很简单:
- 团队已经有 Redis,业务上可以容忍“极端条件下多实例偶发同时执行”的小概率事件,比如定时任务重复跑一次影响不大,直接用
SET NX PX + Lua 解锁,这是成本最低、维护最简单的方案。 - 业务对一致性要求高,比如涉及账务、支付、活动发放等重要状态变更,不要依赖 Redis 锁,优先选择数据库唯一索引、ZooKeeper 临时节点或 etcd 租约这类强一致方案,同时在业务层做好幂等。
- 处于两者之间,既希望性能高又希望尽量降低丢锁概率,可以引入 RedLock 或者多实例主从锁,但必须把运维成本和复杂度算进去。我实际经历下来,绝大多数业务其实到不了这一步,复杂的锁方案本身会引入新故障点。
最后再分享一个很小的经验:分布式锁上线后不要以为就结束了,一定要做“锁的健康度监控”。我维护的锁相关指标有三个——加锁失败率、拿锁等待耗时、锁释放时的校验失败次数。最后一个指标尤其重要,如果校验失败次数突然变多,说明经常有客户端持锁超时后被别人接管,这往往意味着过期时间设置不合理或者临界区耗时超标。看到这个信号,就要去优化业务代码,而不是单纯调大过期时间。分布式锁的本质不是什么高深魔法,它就是在分布式系统里约定一个公共裁判,按规则应答。规则设计得越严谨,业务跑得越安稳。