分布式锁解决什么问题
Redis 锁是很多 Java 项目都会遇到的话题。比如供应链系统里,多个服务同时扣减库存;ERP 里同一张单据不能被重复审核;定时任务在多台机器上部署,但同一批数据只能被一个节点处理。这些场景都需要某种“互斥”。
但 Redis 锁也是容易写错的地方。很多人第一版会写成 setnx lock 1,看起来能用,线上一遇到超时、宕机、重试、主从切换,就会暴露问题。分布式锁不是“抢到 key 就完事”,它要考虑 owner、过期时间、释放原子性、业务幂等和异常恢复。
这篇笔记按常见 Redis 锁类型梳理:简单 SETNX 锁、带过期时间的锁、带唯一标识和 Lua 释放的锁、可重入锁、Redisson 看门狗锁、RedLock 思路。重点还是落到项目里怎么避免锁丢失和死锁。
正确性依赖哪些条件
一个相对安全的 Redis 锁,至少要满足五个条件。
第一,加锁要原子。不能先 SETNX 再单独 EXPIRE,因为中间如果进程宕机,锁可能永不过期。
第二,锁要有唯一 owner。释放锁时必须确认锁是自己加的,不能误删别人的锁。
第三,释放要原子。检查 owner 和删除 key 要放在 Lua 脚本里执行,避免检查后锁过期、别人重新加锁、自己又误删。
第四,业务要幂等。分布式环境里很难只靠锁保证绝对一次执行,业务本身也要能处理重复请求。
第五,下游要能拒绝陈旧持有者。客户端发生长时间 GC、网络暂停或进程挂起时,锁可能已经过期并被其他客户端获得,原持有者恢复后却仍继续写数据。对正确性要求高的资源,应使用单调递增的 fencing token,让数据库或下游服务拒绝较旧 token 的写入。
实现方式与选择建议
最朴素的锁是 SETNX:
1 | SETNX order:lock:1001 1 |
它的优点是简单,缺点也明显:如果没有过期时间,持锁进程挂了就可能死锁。
稍微好一点的写法是原子设置锁和值和过期时间:
1 | SET order:lock:1001 requestId NX PX 30000 |
这里 NX 表示 key 不存在才设置,PX 30000 表示 30 秒过期,requestId 表示锁的持有者。这个写法解决了加锁和设置过期时间的原子性问题。
释放锁时不要直接:
1 | DEL order:lock:1001 |
因为你的业务可能执行超过 30 秒,锁已经过期,另一个线程重新拿到锁。如果你这时直接删除,就会删掉别人的锁。
更安全的释放方式是 Lua:
1 | if redis.call("GET", KEYS[1]) == ARGV[1] then |
这段脚本把“判断 owner”和“删除 key”放到 Redis 单线程执行里,避免中间被打断。
如果业务执行时间不确定,可以使用带看门狗机制的锁。Redisson 的思路是:加锁成功后,如果业务线程还活着,就定期延长锁过期时间;业务结束后主动释放。这样可以避免业务正常运行但锁提前过期。
不过看门狗也不是万能的。如果任务本身可能跑几分钟甚至几十分钟,我更倾向于拆小任务,或者使用任务状态表控制流程,而不是让一个 Redis 锁长时间占着。
可重入锁适合一个线程在同一调用链里重复进入同一把锁的场景。它一般会记录线程标识和重入次数。优点是符合 Java ReentrantLock 的使用习惯,缺点是实现复杂,依赖客户端框架管理。
RedLock 是一种尝试在多个 Redis 节点上获取多数锁的算法。它的目标是减少单点 Redis 故障对锁安全性的影响。但它实现和时钟假设更复杂,业务上是否需要要谨慎评估。多数普通后台系统,用单 Redis 主节点加合理业务幂等,或者直接用数据库唯一约束/状态机,反而更容易解释和维护。
需要强调的是,自动续期只能降低锁在正常执行期间提前过期的概率,不能证明持锁者始终拥有资源。只要业务跨越 Redis 和数据库两个系统,就应把数据库条件更新、唯一约束或 fencing token 作为最终写入保护。
供应链防重复处理案例
以供应链库存预占为例。用户下单后,系统要对 SKU 做库存预占,不能让两个请求同时把同一批库存扣成负数。
可以按 SKU 加锁:
1 | stock:lock:sku1001 |
流程是:
- 请求生成唯一
requestId。 - 使用
SET key requestId NX PX 30000加锁。 - 查询库存可用量。
- 写入库存流水和订单预占记录。
- 更新库存。
- 提交数据库事务。
- 用 Lua 判断 owner 后释放锁。
这里还要做一层幂等:订单号和 SKU 的预占记录要有唯一约束。如果请求重试,即使再次进入,也不会重复预占。
再比如 ERP 单据审核,同一张采购单不能同时被两个人审核。可以用 po:audit:lock:{poNo} 做锁,但最终仍然要在数据库里校验单据状态:只有 待审核 才能流转到 已审核。Redis 锁减少并发冲突,数据库状态机保证最终正确。
失效、续期与主从切换风险
第一个坑,是没有过期时间。没有 TTL 的锁,遇到进程宕机就可能永远释放不了。
第二个坑,是锁过期时间太短。业务还没执行完,锁先没了,其他线程进来就会并发执行。过期时间要结合业务耗时和最大抖动设置。
第三个坑,是直接删除锁。释放前不判断 owner,很容易误删别人的锁。
第四个坑,是只依赖锁不做幂等。网络重试、消费者重复消费、接口超时重放都可能导致同一业务重复进入。锁只是降低并发概率,幂等才是最后防线。
第五个坑,是大锁。比如用一个 global:lock 锁住所有库存操作,会严重影响吞吐量。锁粒度要尽量贴近业务资源,比如按 SKU、订单号、仓库维度拆分。
总结
Redis 锁常见形态从简单 SETNX 到带 TTL、带 owner、Lua 释放、可重入锁和 Redisson 看门狗锁。项目里最推荐的底线写法是:SET key requestId NX PX ttl 加锁,Lua 判断 owner 后释放,业务做好幂等和状态校验。
锁不是越复杂越安全。更可靠的方案通常包括短锁、合理 TTL、原子释放、业务幂等、陈旧请求隔离和监控告警。对于库存、结算等高风险写入,Redis 锁只负责降低竞争,最终正确性仍应由数据库约束或可验证的状态机保证。