☰
Redis分布式锁实战:原理、坑点与代码实现
2026/10/10 7:17:22 网站建设 项目流程

1. 从一个线上事故说起:为什么单机锁突然不香了

先讲个我亲手踩过的坑。某天凌晨,负责的一个电商项目突然报警,库存扣减出现负数,超卖了十几单。第一反应是代码里加锁没加对,翻来覆去找了半天,锁加了,synchronized也写了,JVM内存锁在单机测试时稳如老狗。但一上生产,多个实例同时跑,每台机器各持一把本地锁,锁住的只是自己进程里的线程,其他机器的请求照样长驱直入。这就是典型的分布式场景下本地锁失效问题。

那会儿项目组的人急得团团转,有人提议上数据库悲观锁,有人建议用ZooKeeper。但最后我们选了Redis分布式锁,原因很简单:Redis本来就在线上跑着缓存,拿现成的组件实现锁,不用额外引入中间件,6379端口大家也都熟,团队学习成本最低。更重要的是,Redis单线程模型天然串行处理命令,SETNX配合过期时间能搞定绝大多数的互斥需求。

这次事故让我意识到,很多团队对分布式锁的理解停留在“会用Redis SETNX”这个层面,但真要回答“为什么这么设计”“主从切换时会不会出问题”“过期时间设多长合适”这些细节时,大部分人都答不上来。这篇博文就把我这几年的实操经验掰开揉碎讲清楚,包括原理、实操、坑点,以及面试里最爱问的几个隐藏考点。

适用人群:后端开发、架构师、以及所有被分布式锁折磨过的同学。不需要你有多深的Redis功底,跟着文章走一遍就能上手。

2. 分布式锁的整体设计思路:为什么非Redis不可

2.1 锁的本质是什么

锁的本质,说白了就是一颗“约定好的令牌”。线程拿不到令牌就不能进临界区,拿到了才能干活,干完活把令牌还回去,让别的线程接着用。单机时代,JVM内部的锁机制就是这颗令牌,大家在一个进程里,谁拿了令牌一目了然。但微服务拆开之后,每个实例是独立进程,A机器上的线程想锁住B机器上的资源,就必须找一个“所有人都看得见的地方”放这颗令牌。

这个“所有人都看得见的地方”,就是分布式协调组件。常见选择有三类:关系型数据库、ZooKeeper、Redis。数据库方案用行锁或乐观锁实现,胜在可靠,但性能是硬伤,高并发下每条锁记录都在跟磁盘IO较劲;ZooKeeper性能也一般,而且引入它只是为了做锁,太重;Redis就不同了,纯内存操作,单线程模型避免了并发竞争,SETNX命令天然具备“不存在才设置成功”的原子特征,简直是为分布式锁量身定做的地基。

2.2 Redis做锁的优势和不适合的场景

先说优势。第一是快,内存操作微秒级响应,压测环境下加锁解锁对业务耗时的影响可以忽略不计;第二是简单,SET NX EX一条命令搞定,不像ZooKeeper要维护会话和临时节点;第三是生态成熟,主流语言都有完善客户端,踩坑经验网上也一抓一大把。

但Redis锁不是银弹。如果你的业务对锁的可靠性要求极高,比如金融转账、资金流水这类场景,Redis锁在主从切换、网络分区时可能失效,这时候需要更强的一致性保障。有一说一,99%的互联网业务场景,Redis分布式锁是足够用的,关键是你要清楚它在哪里可能失效,以及失效后怎么兜底。

我用过一个朴素的标准来判断:业务能接受极端情况下的偶发重复执行,就用Redis锁省心高效;完全不能接受,宁可牺牲一点性能也要上ZooKeeper或者数据库唯一约束。后面会详细讲主从切换的坑,理解了再选型会更笃定。

3. 核心原理拆解:SETNX、过期时间与Lua脚本

3.1 指令演进:从SETNX到SET NX EX

很多老教程还在讲SETNX,这也没错,但不推荐直接在项目里裸用。SETNX全称是SET if Not eXists,key不存在时设置成功返回1,存在则返回0。光用这一个指令做锁,有个致命问题:设置锁和执行逻辑不是原子的,如果进程在设置完锁后、执行完业务前崩溃了,没有过期时间兜底,锁就永远解不开了。

所以后来Redis官方给出了标准姿势:SET key value NX EX 3000。NX保证只有key不存在时才能设置成功,EX设置过期时间,两个参数在一条命令里下发,原子执行。Redis是单线程模型,命令执行不会被其他命令插入打断,所以这一条命令天然具备了“加锁+设置超时”的原子性。这可比分开执行两次指令靠谱多了,中间不会留下任何窗口期。

这里的value要设置成一个随机字符串。为什么?等会讲解锁的时候你就明白了,提前剧透,这是为了确认“这把锁是我自己加的”,防止误删别人的锁。

3.2 解锁操作为什么要用Lua脚本

解锁比加锁复杂得多。很多人第一次写分布式锁,脑子里蹦出来的解锁逻辑是:先GET一下value,如果是自己的就DEL。分开两条Redis指令就是灾难现场:假如线程A GET完成,判断是自己加的锁,还没来得及DEL,线程B已经因为锁过期抢到了锁,B设置了新值,然后A把DEL执行了,瞬移删掉了B的锁。B的临界区瞬间裸奔,又一个超卖事故就出来了。

解决方案是用Lua脚本把“比较value+删除key”合并成一个原子操作,脚本长这样:

if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end

Redis服务端会完整执行这段脚本,期间不会被其他命令打扰。这就是为什么Redis官方和各语言客户端都推荐用Lua脚本做解锁。你需要关注的只是脚本正确性,执行原子性由Redis保证。

这里有个容易忽略的细节:Lua脚本内部用redis.call读key时,默认遵循Redis的主从读写架构。如果读的是从节点,而主节点刚完成切换,可能读到的是旧值。这个问题后面在“主从架构下的隐患”一节展开讲。

3.3 看门狗机制:解决锁过期而业务未结束

锁设了过期时间,业务跑不完怎么办?比如一次数据库批量导入,平时2秒完事,结果某天IO抖动跑了15秒,锁在5秒时就过期了,另一个线程立刻拿到锁进入临界区,两个线程同时在写同一份数据。

解决思路叫“看门狗续期”。说白了就是起一个守护线程,每隔一段时间(通常是过期时间的1/3)检查一下业务是否还在执行,如果还在执行,就把锁的过期时间重置。Redisson这个库内置了这个机制,Java里用了Redisson的lock方法后,自动给你续期,完全不用自己操心。

如果裸写Redis命令,就得自己实现续期逻辑。我的方案是封装一个锁工具类:加锁成功后启动一个后台定时任务,每过 expireAt/3 的间隔,执行一次PEXPIRE key 过期时间续期;业务执行完毕或者解锁成功,把这个定时任务关闭。注意,续期也必须用Lua脚本判断当前value是不是自己的,避免锁已经被别人持有还傻乎乎地续期,那是帮别人撑伞、给自己挖坑。

判断续期条件时,用同一把锁的随机value作为凭证,如果GET到的value和自己持有的一致,才允许续期。这跟解锁的保护逻辑是同一套设计哲学:所有对锁的写操作,都要先确认凭证。

4. 实操过程:从零写一个生产级Redis分布式锁

4.1 基础环境准备

先准备Redis环境,我这里用的是本地Docker起了一个单机Redis,端口就是6379,密码设置了但测试代码里为了直观没带。生产环境建议一定要设置密码,并且用专门的Redis实例或者单独的db存放锁的key,避免跟业务缓存互相干扰。

Java项目用的Spring Boot 2.7,引入依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

Spring Data Redis自带的StringRedisTemplate足够我们完成所有操作。不需要额外的Redisson,先把裸实现的逻辑跑通,你才能真正理解Redisson帮你做了哪些事。

4.2 加锁解锁核心代码实现

定义锁工具类,核心是加锁和解锁两个方法。加锁时用之前提到的SET NX EX原子命令,value用UUID加线程ID拼一个唯一字符串保证不重复:

public boolean tryLock(String lockKey, String requestId, long expireMillis) { // 等价于 SET lockKey requestId NX PX expireMillis return redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(expireMillis)); }

setIfAbsent方法设置成功返回true,代表拿到锁,设置失败返回false,说明锁被其他线程持有。如果再配合自旋等待或快速失败,就是一个完整的加锁流程。

解锁用Lua脚本,刚才那段就是标准答案。把脚本注册到DefaultRedisScript里:

DefaultRedisScript<Long> unlockScript = new DefaultRedisScript<>(); unlockScript.setScriptText("if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"); unlockScript.setResultType(Long.class); public boolean unlock(String lockKey, String requestId) { Long result = redisTemplate.execute(unlockScript, Collections.singletonList(lockKey), requestId); return result != null && result > 0; }

源码越简单越好,但这些实现已经在无数生产事故中打磨过,缺任何一步都会出大事。

4.3 业务中正确使用锁的完整姿势

锁写好了,使用姿势同样关键。很多人锁加对了,但业务代码的写法不对照样出问题。标准模板是:

String requestId = UUID.randomUUID().toString(); boolean locked = lockService.tryLock("inventory_10086", requestId, 5000); if (!locked) { // 获取锁失败,直接返回或重试 throw new BizException("系统繁忙,请稍后重试"); } try { // 执行核心业务 doDeductStock(); } finally { lockService.unlock("inventory_10086", requestId); }

两个关键点要刻进DNA里:第一,tryLock之后业务必须包在try/finally里,finally里无条件解锁,否则业务抛异常锁永远不会释放,超时时间一过其他线程虽能进来,但你的锁记录还残留在Redis里污染环境;第二,加锁和业务之间不要做耗时操作,比如网络请求这种,因为锁过期时间是从加锁那一刻开始算的,网络请求等几秒,临界区实际可用时间就少了。

拿锁失败时的策略也要提前想清楚。快速失败适合秒杀场景,告诉用户“抢光了”就行;自旋重试适合需要保证吞吐的任务,比如库存回补的定时任务,用循环加短暂sleep去抢锁,抢到了才往下走。自旋注意控制总时长,别超过业务可接受延迟。

4.4 锁粒度与Key命名规范

锁粒度是个容易被忽视的话题。同一个库存商品的锁key,到底是用商品ID还是商品ID加规格ID?粒度太粗,比如整个店铺一把锁,那所有商品下单请求全得排队,性能从之前的一万TPS跌到几百;粒度太细,比如同一个商品的不同SKU各持一把锁,可能超卖。

我的经验是锁的粒度跟真正要保护的资源绑定。如果一次减库存只影响某个SKU,key就用skuId;如果业务逻辑里要更新同一商品的总库存和SKU库存,那就必须用商品ID颗粒度,否则两个锁互相独立,数据一致性没法保证。

命名规范上,推荐统一前缀加业务场景加资源ID:lock:inventory:sku_10086。方便排查问题,也方便设置统一的过期策略或做监控告警。生产上曾见过一个项目所有锁的key都是lock_1、lock_2这种没有业务含义的名称,出了事故根本没法定位。

5. 高频问题与避坑经验:这些坑我一个个帮你蹚过了

5.1 锁误删问题:最隐蔽的线上事故

前面反复强调value唯一,就是为了防误删。举一个真实场景:线程A加锁成功,处理数据库慢查询花了8秒,锁过期时间5秒,锁自动释放。线程B加锁成功开始处理。此时A终于执行完,走到finally里的解锁逻辑,如果没有校验value,直接DEL,删的就是B的锁。B的临界区被强行中断,更严重的是,C线程又能立刻加锁进来,三个线程同时操作共享资源,数据全乱了。

用了Lua脚本校验value后再删,这个问题就彻底解决。A解锁时发现当前value是B的标识,直接不删,返回0。很多初学者图省事,解锁直接DEL,图一时方便,埋的雷迟早引爆。

5.2 过期时间设置:玄学还是科学

过期时间设短了,业务没跑完锁就过期;设长了,万一线程崩溃锁要等很久才能被抢占。这个值没有银弹,只能根据业务耗时估算。一般建议设置为业务平均耗时的5到10倍,留足余量。比如正常处理100ms,设置500到1000ms,既不会频繁过期,也不会在异常时等太久。

更稳的方案是动态续期,前面说的看门狗就是这个思路。实际项目里我习惯组合拳:过期时间设置成业务正常耗时的3倍,同时每1/3周期续期一次。这样就算出现极端慢查询,只要业务线程还活着,锁就不会提前丢。

5.3 主从架构下的失效场景:Redis锁最大的心结

大多数公司的Redis是主从加哨兵的高可用架构。主节点负责写,异步同步到从节点。这个模式下分布式锁有一个理论上的漏洞:线程A在主节点上写入了锁,结果主节点宕机,数据还没同步给从节点,哨兵把某个从节点提升为新主节点,这个新主节点上没有A的锁记录。线程B来加锁,发现key不存在,加锁成功,此时A和B同时持有锁,锁失效了。

这个问题靠Redis自身不好解,主流方案是两个方向。一是Redlock,向多个Redis节点依次请求加锁,超过半数成功就视为加锁成功,理论可靠性更高,但很多团队不敢上是因为它需要独立的若干Redis节点,部署成本和运维复杂度高。二是业务层兜底,比如对数据最终一致性要求高的操作,加一个数据库层面的唯一约束或乐观锁,Redis锁作为第一道拦截,数据库约束作为第二道保险。

我个人的建议是:中小团队优先选第二种,成本低还可靠;真有对一致性追求到极致又愿意投入资源的团队,再考虑Redlock的复杂度。

5.4 一致性问题:Redisson是不是银弹

Redisson确实封装了80%的细节,默认的看门狗续期、可重入锁支持,都是Netty客户端帮你处理的。但用Redisson不是就万事大吉,它的高可用锁(MultiLock)还是需要你部署多个Redis节点,这本身就超出了很多团队的运维能力。

用Redisson时,还是要注意锁的释放时机,特别是锁在业务代码里被传递到异步线程的场景。Redisson的锁和线程绑定,默认情况下谁加的锁由谁来释放,子线程解锁会直接报IllegalMonitorStateException。有人图省事把锁对象从主线程丢到线程池里解密,结果就踩这个坑。

5.5 常见问题速查表

症状可能原因解决方案
获取锁永远失败锁key被写死,没有区分业务维度检查key命名,确认粒度合理
锁偶尔失效,数据重复写入锁过期时间太短,业务超时调大过期时间或引入续期机制
解锁报错IllegalMonitorStateExceptionRedisson锁跨线程确保加锁解锁在同一线程内
主从切换后锁丢失主从同步延迟业务层兜底约束,或考虑Redlock
锁占着不放,超时也无法抢占value不唯一或解锁被跳过检查finally是否无条件解锁

排查的时候可以多用Redis的MONITOR命令观察命令执行序列,定位是否有异常的DEL或者过期的PEXPIRE。生产环境慎用MONITOR,高并发下它会拖垮Redis性能,这个命令只建议在压测环境或低峰期短时间开。

5.6 可重入锁:一个让新手头疼的需求

同一个线程,已经持有了锁,再次进入加锁代码,还能不能加锁成功?比如一个方法A加了锁,方法A里调用了同样加了锁的方法B。如果锁不可重入,就会死锁:线程自己等自己释放锁。

裸写Redis命令实现可重入,需要用一个Hash结构记录持有锁的线程和重入次数。加锁时判断key是否存在且field是否为当前线程,如果是就给计数加1;解锁时减1,减到0才删除key。这个场景直接建议用Redisson的RLock,它原生支持可重入,别重复造轮子了。

不过大多数业务的分布式锁不需要可重入,只要在设计方法拆分时注意好,把获取锁的边界和控制逻辑放在最外层。如果确实遇到了,也别急着上可重入,先想想是不是业务结构设计得有问题。

6. 锁之外的考虑:我踩过后的复盘总结

分布式锁的技术实现只是起点,工程上要操心的事远比想象得多。为什么锁获取失败是直接报错而不是等待重试?这就要结合项目实际情况权衡。我们有一次压测发现,大量线程快速失败后立刻重试,会给Redis带来连锁压力,所以后来统一在获取锁失败时走一个短的随机sleep,再尝试,类似指数退避的策略。这个策略还能有效避免惊群效应:同一时刻大量线程同时抢一把锁,Redis的CPU瞬间飙升,随机退避让请求分散在不同时间点,系统压力平稳很多。

另外一个容易忽略的点:锁的监控和告警。我接手过一个项目,Redis里积累了上千个无人释放的死锁key,占用内存倒是小事,重要的是排查问题时干扰非常大。后来加了一个定时任务,扫描lock前缀下的所有key,标记超过最大超时时间还没删除的,发告警通知。加锁的代码里也打上了TraceId日志,出问题时能快速定位到具体是哪一次请求、哪个线程加的锁。

关于锁的运维,还有个小建议:把锁的key单独放在一个Redis实例或单独的db,不要跟业务缓存混在一起。混用的话,业务缓存的内存淘汰策略可能把锁key给淘汰掉,锁就直接失效了,排查起来还以为自己的代码没问题。

锁的过期时间、续期机制、异常释放、告警监控,这些细节组合在一起,才是一个能上生产的分布式锁方案。框架只是工具,真正的护城河是对场景的理解和对边界情况的完备处理,这一行我干了十几年,每次线上事故复盘,最后赢家都是那些“在大家都在看热闹时多想了三步”的人。希望这篇经验能让你在分布式锁这条路上一马平川,少摔几个跟头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询