资讯动态

Redis分布式锁核心原理、实现与常见坑位避坑指南

发布时间:2026/10/14 6:13:04 来源:尧图企业网站定制
1. 为什么单机锁扛不住分布式场景先聊一个最基础的问题我们在单体应用里用synchronized或者ReentrantLock锁的是同一个JVM进程里的对象多个线程抢同一个资源只要锁的粒度够小基本不会出问题。但一旦服务拆成多个实例或者一个服务起了多个副本请求会被负载均衡打到不同的进程上每个进程各自有一把锁互相之间根本不知道对方的存在。这时候再让两个线程同时操作同一份库存数据就会出现超卖、重复提交之类的线上事故。我最早接触分布式锁就是一个非常典型的场景某公司的积分系统用户签到后要发放积分逻辑不复杂就是先查账户再更新余额。结果一次活动流量上来两个用户同时签到恰好被分发到两台不同的服务器上两个线程同时读到旧余额各自加完再写回积分直接翻倍。最后排查了半天发现代码逻辑没问题问题出在“多实例之间缺少一个统一的互斥机制”。从那以后我就明白分布式环境下锁必须从“进程内”搬到“跨进程”的层面来设计。Redis分布式锁就是为解决这个问题而生的。它的核心思路很简单利用Redis单线程执行命令的特性让多个进程在同一个Redis实例上争抢一个唯一的键谁成功写入了这个键谁就拿到了锁谁最后删除这个键谁就释放锁。相比数据库行锁、ZooKeeper临时节点Redis锁的优势是快——一次内存操作不到毫秒级而且实现简单接入成本低所以成了目前绝大多数互联网项目的首选。这篇文章适合谁看如果你正在做微服务拆分、多实例部署或者手头有库存、优惠券、签到这类并发敏感的业务又或者你只是听说过分布式锁但还没真正落地用过那这篇内容能帮你避开不少坑。我会把锁的设计思路、具体实现、常见的失效场景全部讲透代码示例以Java和Redisson为主但原理对任何语言都一样。2. 一把可靠的分布式锁要满足什么条件很多初学者以为往Redis里set一个key就是加锁删掉key就是释放锁其实远没那么简单。生产环境里一把锁必须同时满足三个特性互斥性、安全性、可用性。互斥性是指同一时刻只能有一个客户端持有锁。安全性和互斥性紧密相关指的是持有者只能释放自己持有的锁不能误删别人的锁。可用性则包括加锁失败时能快速重试、持有锁的客户端崩溃后锁能自动过期释放不会造成死锁。先说最经典的实现方式。加锁命令一共就一条SET lock_key unique_value NX PX 30000这条命令要拆开看NX表示只有当key不存在时才设置成功这是互斥的保证PX 30000表示锁的最大存活时间是30秒防止持有锁的进程宕掉之后锁永远不释放unique_value是一个唯一标识通常用UUID或者业务请求ID表示这把锁属于谁释放锁的时候必须校验它。这里有个非常关键的教训很多老代码写的是两条命令先SETNX再EXPIRE中间隔了几行代码。这种写法在绝大多数情况下都能跑通但是一旦在SETNX之后、EXPIRE之前进程突然崩溃锁没有设置过期时间就变成了永不过期的死锁。Redis官方后来也明确说加锁和设置过期时间必须原子执行所以要用SET命令带多参数而不是分开调两个方法。再来看解锁。解锁同样要保证“只能删自己的锁”如果只是简单调DEL很可能出现这种情况线程A持有锁执行任务超时锁自动过期了线程B此时成功加锁开始执行任务线程A终于跑完了随手DEL了一下把线程B的锁删掉了。后面线程C又拿到锁三个线程同时操作共享资源锁彻底失去意义。避免这个问题需要用Lua脚本保证“判断持有者”和“删除key”两步操作原子执行if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本的意思很直白先取锁的值和自己手上的唯一标识对比相等才删不相等说明锁不是自己的直接返回。Redis在2.6之后原生支持Lua脚本脚本执行过程中不会被其他命令插入所以这两个操作合在一起是天然原子的。为什么一定要求“锁和解锁的值匹配”你可以把锁想象成储物柜的手环每个柜子只有一个手环只有拿着对应手环才能打开柜门。一个线程没拿到柜子的钥匙硬去开别人的柜子管理上就必须拒绝。除了互斥和防误删还有一个常被忽略的点锁的可重入性。同一个线程在嵌套逻辑里比如方法A加了锁方法A内部又调用方法BB也尝试加同一把锁理想情况下应该直接成功这叫可重入。Redis原生SET NX命令本身不支持可重入因为第二次设置同一个key会失败。要实现重入要么在业务层记录线程标识要么直接用Redisson这类成熟客户端它们内部已经实现了重入机制。说到Redisson它是我实际项目中用得最多的工具。它的加锁API非常简单RLock lock redissonClient.getLock(order:pay:10086); boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑 } finally { lock.unlock(); } }这段代码看似简单背后其实做了很多事。tryLock的三个参数分别代表等待锁的时间、锁的自动释放时间、时间单位。如果你不传leaseTimeRedisson会启用默认的看门狗机制默认锁超时时间是30秒但如果业务还没执行完看门狗会每10秒自动给锁续期一次把它继续延长到30秒。这个机制极大降低了“业务没跑完锁先过期”的概率。不过看门狗也有坑。它只在进程还活着的时候才会续期如果持有锁的节点发生长时间GC暂停或者网络断掉看门狗续不了期锁依然会过期释放。这个问题不是Redisson能解决的而是分布式锁本身的取舍不想死锁就必须允许极端情况下锁提前失效。你需要做的是在业务设计上保证锁失效后被保护的逻辑依然具有幂等性而不是把所有希望都寄托在锁上。3. 从零搭建一个生产可用的Redis分布式锁说了一堆原理直接讲怎么落地。我在项目里通常有两种做法一是引入Redisson用它封装好的RLock二是自己写一套基于Lua脚本的简单工具类。两者各有适用场景Redisson功能全省事但是会引入额外依赖自研工具类轻量逻辑完全可控适合锁的并发量不大、不需要可重入的简单场景。我个人的建议是如果项目里已经有Spring Boot和Redis直接用Redisson的开箱即用能力别重复造轮子。如果只是偶尔用一两次锁或者出于学习目的想搞懂底层那就手写Lua脚本其实也就二十几行代码的事。先讲Redisson落地步骤。第一步是引入依赖这里以Maven为例dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency版本号要根据你的Spring Boot版本选不同大版本之间兼容性有差异。配置好Redis连接后注入RedissonClient即可。实际项目中我会把锁的获取和释放封装成一个模板方法避免每个业务方都写一套try-finallyComponent public class RedisLockTemplate { Autowired private RedissonClient redissonClient; public T T execute(String lockKey, long waitTime, long leaseTime, TimeUnit unit, SupplierT action) { RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(waitTime, leaseTime, unit); if (!locked) { throw new RuntimeException(系统繁忙请稍后再试); } return action.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(获取锁被中断, e); } finally { if (locked) { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } } }注意finally里多了一层isHeldByCurrentThread判断这是因为tryLock可能因为等待超时返回false此时当前线程并没有真正拿到锁如果直接unlock会抛IllegalMonitorStateException。这个细节我第一次写的时候没注意测试环境一压就报错查了半天才发现是释放锁的条件没判断。接着讲自研实现。自研锁的核心就是一个加锁方法、一个解锁方法、一个内部维护唯一标识的机制Component public class DistRedisLock { Autowired private StringRedisTemplate redisTemplate; private static final String LOCK_PREFIX lock:; private static final String LUA_UNLOCK if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end; public boolean lock(String key, String value, long expireMillis) { return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent( LOCK_PREFIX key, value, Duration.ofMillis(expireMillis))); } public boolean unlock(String key, String value) { Long result redisTemplate.execute( new DefaultRedisScript(LUA_UNLOCK, Long.class), Collections.singletonList(LOCK_PREFIX key), value); return result ! null result 1L; } }value怎么生成我每次加锁前会拼一个随机ID加当前线程ID比如UUID.randomUUID().toString().replace(-,) : Thread.currentThread().getId()。这里用线程ID的原因是同一个JVM里不同线程并发调lock方法生成的值必须不同否则锁的owner区分不出来。自研锁还要考虑一个问题Redis连接断了怎么办。StringRedisTemplate默认在连接失败时会抛异常业务侧需要catch住异常并决定是重试还是走降级逻辑。我的做法是封装一层加锁失败时最多重试三次间隔100毫秒如果依然失败直接返回false让上游业务决定是排队还是直接拒绝。分布式锁本身不是百分之百可靠的它只能降低并发冲突概率最终兜底还是要靠业务上的幂等校验。如果你用Redisson还要注意加锁key的粒度。很多人习惯把整个用户ID作为锁key比如lock:user:123这会导致同一个用户的所有并发操作互相阻塞比如同时领券、下单、签到都会串行。更好的做法是加上业务维度比如lock:stock:sku:789、lock:sign:user:123让不同业务之间不互相干扰同一业务内部才互斥。锁的粒度越小并发能力越高。还有一点锁的过期时间到底设置多少合适。这个没有标准答案但有一个基本判断依据锁的过期时间应该大于正常情况下业务执行所需的最大时间。我通常先压测评估一个接口最慢的响应时间再留一倍余量。比如某接口正常最慢300毫秒那锁过期时间至少设500毫秒Redisson默认30秒则更稳妥。如果业务里包含外部调用比如调短信、支付、推送这些IO时间不可控就需要看门狗自动续期或者调大过期时间。4. 分布式锁的五大经典坑我全踩过先列一张常见问题速查表后面逐个展开。现象根本原因解决方案锁永不过期接口全部卡死SETNX和EXPIRE不是原子操作崩溃前没设过期时间使用SET NX PX原子命令后面的线程把前面的锁删了解锁时没校验持有者身份用Lua脚本比对值后再DEL业务没跑完锁就过期了过期时间设得太短或者业务里有长任务给锁自动续期或用Redisson看门狗主从切换时锁丢了Master宕机锁未同步到Slave新Master上锁丢失Redis集群谨慎使用默认方案需要可考虑RedLock同时修改同一资源导致并发锁key粒度太粗不同业务互相阻塞或太细导致覆盖不全按业务场景合理拆分锁key第一坑就是前面讲的SETNX和EXPIRE分离执行这个很多人已经知道了。第二坑是解锁没校验也没多少人真正写对。第三个坑最容易在生产环境暴雷你设了10秒锁过期但业务里调了一个外部接口别人接口超时重试一把锁扛了30秒还没跑完锁就释放了其他线程冲进来一起执行。这时候最直接的办法是给Redisson开启看门狗方法就是不传leaseTime让它默认续期到30秒看门狗每10秒续一次。但要注意看门狗只在Redisson客户端进程活着时才工作如果发生Full GC长时间停顿看门狗线程也可能没机会续期锁最终还是会过期。所以最终守护不是看门狗而是业务的幂等校验。第四个坑是主从切换导致锁丢失。我举个具体例子客户端A向Master节点写入锁key成功Master还没把这个key同步给Slave就宕机了。哨兵或集群把Slave提升为新的Master此时新Master上压根没有锁记录客户端B就能轻松加锁成功。这就出现了经典的“两个客户端同时持锁”情况。要解决这个问题不是单靠Redis单实例能做到的Redis官方给出的方案是RedLock算法向多个独立Redis实例请求锁超过半数实例加锁成功才算真正获得锁。这个算法理论性强但实际部署成本高而且自身也有争议像分布式系统的经典论文里就提到过RedLock在某些场景存在安全性问题。我的建议是如果你的业务要求锁的强一致性非常高比如资金类操作优先考虑ZooKeeper这类基于强一致协议的锁而不是硬上Redis。如果业务能容忍极小概率的重复执行比如优惠券重复发放后通过数据库唯一索引拦截那Redis单实例锁完全够用。第五坑是锁粒度设计问题这个很多人容易忽略。我见过一个项目全站所有需要加锁的地方都用了同一个锁key叫lock:global结果一个导出任务把整个站的写操作全堵住了。反过来也有把锁粒度拆得太细导致控制不了的比如要锁一个订单的所有操作却分别给订单号和用户ID各加了一把锁结果两个线程分别拿了一把锁就能同时改订单。正确做法是锁定“共享资源的唯一标识”然后围绕这个标识设计业务维度。除了上面五个还有一个隐藏比较深的坑锁的等待时间设置不合理。tryLock的waitTime写的是毫秒很多人随手填了0意思是拿不到锁立即返回失败。如果业务依赖锁做串行扣减你填0会导致高并发下大量请求失败用户体验极差。一般建议至少给几百毫秒到一两秒的等待时间让请求排队抢锁。不过等待时间也不能无限长因为客户端等待锁的过程本身占着线程资源等待太久容易拖垮服务。所以我一般把waitTime设成1秒配合限流效果比较理想。还有一个和业务编码相关的坑拿到锁之后finally里一定要释放锁但是释放锁本身也可能抛异常。比如unlock时Redis网络刚好抖动Redisson内部会尝试重试如果重试失败会抛出异常此时原业务异常会被finally里的异常覆盖掉导致我们看不到真实错误。我的做法是在finally里unlock时catch掉异常并记录日志不让它掩盖业务异常。虽然感觉有点“脏”但线上排查问题时这个细节能帮你省很多时间。5. 到底用Redisson还是自研我的选择建议很多人上来就问“分布式锁应该用哪种实现”我的答案很简单能用Redisson就用Redisson不要自己写。原因不是自研不行而是成熟框架替你处理了大量极端情况。Redisson的Lock实现里包含了几件自研很难做好的事自动续期的看门狗、可重入锁、公平锁、红锁、读写锁等扩展能力此外还有一套自己处理的锁等待和pub-sub机制。你只要用一行getLock拿到的锁就具备了这么多特性自己写要调很久。但Redisson也不是没有缺点。它依赖RedissonClient实例和项目里的Spring Data Redis API不同团队需要额外学习成本。它默认的看门狗续期时间可能不符合你的业务预期如果业务本身只需要1秒锁你却让它默认续期30秒那一旦业务进程出现轻微卡顿锁一直续反而拖慢了整体释放速度。所以用Redisson时我建议显式传leaseTime明确锁的最长持有时间这样行为更可控。自研脚本适合什么场景呢我曾在某个小项目里只用到了最基础的互斥需求不存在重入、不需要续期业务执行时间固定几百毫秒。那时我选择自己写Lua脚本加一个RedisTemplate代码量很少依赖也干净。但一旦业务复杂起来比如需要锁续期、需要重入我会立刻换回Redisson。如果你在纠结我给你一个简单的判断标准团队已有Redisson依赖业务并发高、需要自动续期和重入无脑选Redisson。只是内部系统简单互斥加锁成功率高业务逻辑短平快自研足够。需要分布式锁的强一致性不允许重复执行不要用Redis换其他强一致协调组件。这个选择不是越复杂越好而是匹配业务代价。我见过很多项目为了“炫技”引入Redisson结果业务根本用不到看门狗和重入纯粹增加排查问题的复杂度。了解原理后你完全可以根据业务做一个准确的取舍。6. 一个让我印象深刻的线上故障复盘最后分享一个我做过的真实排查过程虽然细节做过脱敏但思路很值得参考。当时某个交易系统上线大促压测时发现偶发性的重复下单。一开始怀疑是数据库唯一索引没挡住的逻辑漏洞查了一遍代码发现下单前确实查了订单表理论上不会重复。后来看日志发现两个不同的服务实例几乎同时打印了“用户点击下单”日志请求落到了同一商品上追踪链路里各自拿到了锁。问题出在哪我们用的是一套自己封装的Redis锁加锁命令是SETNX和EXPIRE分开的。业务代码在调用加锁接口之前先做了一步Redis网络之外的日志记录因为日志上传比较慢导致两个实例之间出现了一个时间窗口后一个实例先SETNX成功但EXPIRE还没执行时前一个实例恰好也SETNX发现key已经存在便直接返回了失败。按理说后一个实例拿到了锁前一个失败了为什么会重复下单再往下查发现前一个实例的加锁“失败”没有正确处理。代码里加锁失败时直接返回false但调用方把false当成了“系统繁忙稍后重试”然后前端页面在用户不知情的情况下重试了一次第二次重试时锁已经过期释放了于是成功下单。所以最终的重复来源不是锁失效而是加锁失败后的重试逻辑叠加。这次故障让我彻底改了习惯加锁失败的业务语义必须是明确的要么告诉用户“正在处理请勿重复提交”要么在服务端做去重表。让客户端无脑重试再可靠的锁也挡不住。后来我把这套锁升级成了Redisson并且在下单入口增加了一个请求幂等号前端每次提交带上一个唯一请求ID服务端用Redis记录这个ID处理完成后写入结果重复请求直接返回第一次的结果。这样即便分布式锁因为极端情况失效幂等号依然能兜住重复提交。这也是我至今推荐的组合拳分布式锁解决并发竞态幂等机制解决重复请求数据库约束做最后防线。从那次之后我对分布式锁的理解就不再是“加锁删锁”这么简单了。真正的可靠是靠多层次的容错设计而不是依赖某一个组件单打独斗。希望你读到这里也不只是记住了几个命令而是理解背后这些相互制约的取舍。遇到实际业务时能先想清楚三个问题锁的粒度怎么定、锁的超时怎么配合业务、锁失效后有没有兜底。把这三个问题想明白Redis分布式锁才能真正成为你工具箱里一个顺手又安全的工具。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑