头歌上的 Redis 实训做到分布式锁与信号量这两关时我第一反应是终于碰到真东西了。前面 String、List、Hash、Set、ZSet 那几关说到底是在记命令语法敲多了自然就背下来了而分布式锁和信号量是把 Redis 从放缓存的地方推到协调多台机器的裁判这个位置上难度和含金量完全不是一个量级。很多人包括几年前的我自己以为分布式锁就是SETNX一把梭真到生产环境被重复扣款、库存超卖、定时任务跑两遍这些问题教育过之后才知道水面下的东西有多深。这篇东西我打算按头歌实验的推进节奏来写但不会只讲实验怎么通关——加锁为什么必须带过期时间、value 为什么要用 UUID、释放锁为什么要走 Lua、信号量和锁到底差在哪、Redisson 帮我们省了哪些事这些才是真正值钱的部分。如果你正在做这套实验或者刚学完 Redis 数据类型准备往分布式方向走又或者面试被Redis 分布式锁怎么实现问得心里发虚后面这些内容应该能把这块帮你补扎实。1. 分布式锁到底解决什么问题为什么头歌把它排在数据类型之后1.1 从多实例并发写同一份数据说起假设你的服务部署了三台机器用户点了一次提交订单网关层做了重试三台机器几乎同时收到请求。每台机器都查一遍库存都看到还剩 1 件都判断可以下单然后都去扣减库存——最后库存变成 -2卖出去三件。这个问题跟 Redis 本身没半点关系它是并发写共享资源的老问题。单机时代用一把synchronized或者ReentrantLock就够了因为所有线程在同一个 JVM 里锁对象在堆内存中谁持有谁等待大家都能看见。一旦进程拆成多个JVM 内的锁就彻底失效了。A 机器上的锁 B 机器看不见两台机器各持一把自己的锁并发照样发生。这时候就需要一把外部可见的锁也就是把互斥的判断交给一个所有实例都能访问的第三方组件来做——Redis 就是最常用的那个第三方。头歌把这一关排在数据类型之后逻辑上是很顺的你得先把 String 的SET、SETNX、EXPIRE这些命令玩熟了才谈得上用它们组合出锁。这里要澄清一个概念分布式锁解决的不是性能问题而是正确性问题。很多人一听说加锁就皱眉觉得会拖慢系统于是用各种无锁方案去绕。但重复扣款、重复发券、定时任务重复执行这类问题一旦发生就是数据事故事后对账修数据的人力成本远超加锁带来的那点延迟。该锁的地方一定要锁这不是保守是底线。1.2 一把合格的分布式锁必须具备的四个特征我在实际项目里挑分布式锁方案时会拿四个硬指标去卡这套标准也是我判断头歌实验里那些写法是否合格的核心依据。互斥性任意时刻同一个业务键只能有一个客户端持有锁。这是最基本的要求做不到就不用谈分布式锁了。防死锁持有锁的客户端如果进程崩溃、网络断开、机器断电锁必须能自动释放不能让后面的请求永远排队。靠的就是给 key 设置过期时间。解铃还须系铃人A 加的锁只能 A 来删不能被 B 顺手删掉。这就要求加锁时写入一个能唯一标识持有者的 value删除前先校验。可重入性与高可用同一线程重复获取同一把锁不该被自己挡住可重入Redis 主从切换后锁的语义不能崩高可用。这两条是进阶要求也是为什么生产上更推荐直接用 Redisson 而不是手写的原因。把这四条摊开看你会发现SETNXEXPIRE这种教科书上的最简写法四条里至少踩中三条坑。这也是为什么头歌的实验会要求你一步步从简单实现改到规范实现——它不是故意折腾你而是在复刻真实踩坑路径。1.3 头歌实验环境与前置知识清单动手之前先把环境和前置知识理清楚不然卡在第一关会非常难受。Redis 相关的部分一般需要在实验环境里启动一个 Redis 服务实例然后通过命令行客户端或者代码来操作。必背命令建议在命令行里手敲一遍别只复制# 连接本机 Redis redis-cli -h 127.0.0.1 -p 6379 # 最基础的加锁尝试key 不存在才设置成功 SET lock:order:1001 uuid-abc NX # 带过期时间的原子写法重点 SET lock:order:1001 uuid-abc NX EX 30 # 查看 key 剩余生存时间-1 表示永久-2 表示不存在 TTL lock:order:1001 # 释放锁先看 value 再删实际要用 Lua GET lock:order:1001 DEL lock:order:1001语言层面头歌的这道题通常提供 Java 和 Python 两种提交模板Java 侧用的是RedisTemplatePython 侧是redis-py。不管哪种你要掌握的公共知识点是一样的Redis 单线程模型保证了单条命令的原子性、SET系列命令的参数含义、Lua 脚本在 Redis 里的执行特性、以及原子性和事务的区别。提示实验环境里的 Redis 一般没有开启持久化重启数据就没了。这是好事做锁的实验不用担心脏数据残留但也意味着别拿实验环境去练重启后锁状态如何恢复这类问题它测不出来。2. Redis 分布式锁的三种实现路径与选型对比2.1 SETNX 原始做法的三个致命伤最早期的写法基本都是这个套路也就是头歌实验第一关大概率会让你先实现的那版# 第一步尝试加锁 SETNX lock:order:1001 1 # 返回 1 表示拿到锁返回 0 表示没拿到 # 第二步设置过期时间 EXPIRE lock:order:1001 30看起来能跑但它在生产上活不过第一周。我把问题拆成三条讲。第一加锁和设置过期时间不是原子的。如果SETNX返回 1 之后服务在到达EXPIRE之前挂了比如 JVM 突然 OOM 退出那么这个 key 就永久存在锁永远释放不掉这就是典型的死锁。虽然概率不高但分布式环境下概率不高乘以每天千万次调用就是每天都会发生。第二没有唯一标识会删错别人的锁。设想 A 拿到锁后处理超时30 秒过期B 拿到了锁开始处理此时 A 处理完了去执行DEL把 B 的锁删了。于是 C 又能加锁成功B 和 C 同时进入临界区互斥性彻底崩塌。这个场景在实验里不好复现但在真实系统里非常常见。第三没有重试和等待机制。SETNX返回 0 就结束了业务要么直接失败要么自己写循环轮询。轮询间隔设多少、最多等多久、等不到怎么降级这一整套东西都得自己兜底写不好就是 CPU 空转或者线程堆积。注意把EXPIRE换成SET key value NX EX 30之后第一条问题就解决了因为SET命令的多参数形式在 Redis 内部是单条指令、单线程执行天然原子。但第二条和第三条问题还在需要 value 校验和客户端层封装继续补。2.2 SET key value NX EX 原子化改造与参数计算规范写法长这样SET lock:order:1001 8f4c2b1e-uuid NX EX 30这里的EX 30不是随便填的。超时时间的确定逻辑应该是业务最长执行时间 × 安全系数。假设你的业务 P99 耗时是 200ms极端情况偶尔到 2 秒那你设置 5 秒左右是合理的安全系数取 2 到 3 倍。设太短业务还没做完锁就过期了互斥失效设太长一旦持锁进程真挂了后面的请求要白等很久。业务类型典型耗时建议锁超时说明缓存重建50~300ms3~5s防止缓存击穿时大量线程排队订单创建100~500ms5~10s涉及多次 DB 写入留足余量定时任务分片秒级到分钟级单独处理建议配合续期或改用任务调度框架对账批处理分钟级不建议用单锁应改用分片锁或状态机value 用什么也有讲究。直接写1、lock这种固定值等于没写用线程 ID 也不够因为不同机器上的线程 ID 会重复。标准做法是UUID.randomUUID().toString()再加上当前线程标识拼接保证全局唯一。2.3 Lua 脚本释放锁与删错锁问题释放锁必须做到判断 value 和删除 key这一步也是原子的否则照样会删错。Redis 支持执行 Lua 脚本脚本执行期间不会被其他命令打断正好满足需求-- KEYS[1] 锁的 key -- ARGV[1] 期望的 value自己的唯一标识 if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end这段脚本的逻辑很直白先取值比对值对得上才删。返回值设计上1表示释放成功0表示锁不是自己的可能已过期被别人拿走用返回 0 来告诉调用方你可能已经失去锁了需要告警。在头歌实验里这一关的常见考法就是让你把上面这段脚本填对然后用EVAL或者RedisTemplate.execute(RedisScript, keys, args)调用。Java 侧调用时要注意序列化问题RedisTemplate默认用 JDK 序列化key 会出现乱码脚本里比对 value 时经常因此失败。我的习惯是单独配置一个StringRedisTemplate专门用来做锁操作key 和 value 都用字符串序列化跟脚本里的字符串字面量才能对得上这个坑我见过太多人踩。3. 手把手实现一把能上生产的分布式锁3.1 参数怎么定超时、唯一标识与重试策略在写代码之前先把三个参数敲定这三条决定了锁的质量上限。唯一标识UUID : Thread.currentThread().getId()。加线程 ID 是为了支持同一 JVM 内的可重入判断虽然简单实现里做不到真重入但至少能让日志看清是谁加的锁。过期时间按业务 P99 乘 3 倍来估再用压测数据校准。不要图省事统一写 30 秒长尾业务和短平快业务的合理区间差得远。重试策略我一般用最多重试 N 次 固定间隔的简化版而不是自旋。比如最多等 3 秒每次间隔 100 毫秒。用Thread.sleep简单但会阻塞线程高并发下要注意线程池大小更好的做法是搭配ScheduledExecutorService做异步轮询或者直接用 Redisson 的tryLock(waitTime, leaseTime, unit)它内部用发布订阅做了通知不用空转。提示Thread.sleep的间隔别设成 1 毫秒那和自旋没区别会把 Redis 的 QPS 打上去。通常 50~200 毫秒是比较舒服的区间具体看你的并发量和业务容忍延迟。3.2 Java 版完整实现与逐行拆解下面这版是我在项目里用过的精简实现去掉了监控埋点保留核心逻辑Component public class RedisLockHelper { private static final String RELEASE_SCRIPT if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end; private final StringRedisTemplate redisTemplate; private final DefaultRedisScriptLong releaseScript; public RedisLockHelper(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; this.releaseScript new DefaultRedisScript(RELEASE_SCRIPT, Long.class); } /** 尝试加锁成功返回唯一标识失败返回 null */ public String tryLock(String key, long expireSeconds) { String token UUID.randomUUID() : Thread.currentThread().getId(); Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, token, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(ok) ? token : null; } /** 带重试的加锁 */ public String lockWithRetry(String key, long expireSeconds, int maxRetry, long intervalMillis) throws InterruptedException { for (int i 0; i maxRetry; i) { String token tryLock(key, expireSeconds); if (token ! null) { return token; } Thread.sleep(intervalMillis); } return null; } /** 释放锁只有 value 匹配才会删除 */ public boolean unlock(String key, String token) { Long result redisTemplate.execute( releaseScript, Collections.singletonList(key), token); return result ! null result 1L; } }逐行说明几个关键点。setIfAbsent(key, value, timeout, unit)对应 Redis 的SET key value NX EX n这一行就是原子加锁的全部用Boolean.TRUE.equals(ok)而不是直接ok是因为返回类型是包装类Boolean网络异常时可能为 null直接拆箱会抛空指针这个细节在实验环境的模拟异常测试里挺容易被抓到。releaseScript声明成成员变量而不是每次 new是因为脚本对象本身可以复用Redis 会根据脚本内容计算 SHA1 并缓存每次传脚本内容虽然也行但多传了几百字节的网络开销。业务代码里的正确姿势一定长这样String token lockHelper.lockWithRetry(lock:order: orderId, 5, 30, 100); if (token null) { throw new BizException(系统繁忙请稍后重试); } try { // 临界区查库存、扣减、写订单 doBusiness(orderId); } finally { lockHelper.unlock(lock:order: orderId, token); }try-finally不能省否则业务抛异常时锁会一直挂到超时。另外要记住加锁和释放锁用的 key 必须完全一致拼接字符串时最好抽个方法统一生成避免一个地方拼lock:order:、另一个地方拼order:lock:这种低级错误。3.3 Redisson 替你做了哪些事手写版练手可以真上生产我更倾向用 Redisson。它在SET NX EX的基础上加了三样东西恰好补上了前面说的可重入和续期两块短板。可重入Redisson 用 Hash 结构存锁field 是客户端标识value 是重入次数。同一线程再次加锁时把计数加一解锁减一减到 0 才真正删除。这解决了递归调用或者同一请求内多次加锁直接把自己锁死的问题。看门狗续期如果加锁时不指定leaseTimeRedisson 会默认给 30 秒过期并启动一个后台线程每 10 秒续一次期只要业务没结束锁就不会过期。这直接缓解了业务执行时间不确定这个最头疼的问题。但要注意看门狗只在客户端存活时有效进程被 kill 掉照样靠过期兜底。RedLock 争议Redisson 还提供了 RedLock 实现向多个独立 Redis 实例申请锁多数成功才算成功。这套方案的学术争议一直存在我的建议是如果你的业务对一致性要求极高且能接受复杂度可以评估否则老老实实用单实例 主从 兜底校验同时保证临界区操作是幂等的比什么都强。幂等设计才是分布式锁的最终保险。4. Redis 信号量和锁长得像用途完全不同4.1 从停车场车位理解信号量的本质锁是同一时刻只允许一个信号量是同一时刻只允许 N 个。这个 N 就是信号量的核心参数可以理解为停车场的车位数。车位数是 3那么同时最多 3 辆车能进场第 4 辆车要么排队等要么掉头走。对应到系统里就是最多 3 个线程同时访问某个资源。这个模型跟ReentrantLock的区别一眼就看清了锁的车位数永远是 1。跟线程池的区别在于线程池限制的是有多少线程信号量限制的是有多少并发操作任务可能是异步的、跨机器的不受线程池约束。有同学可能会问这和 FreeRTOS 里的二值信号量是不是一回事概念上确实同源二值信号量本质上就是计数值为 1 的信号量也就是一把锁。但嵌入式里的信号量运行在单机多任务内靠内核调度器做阻塞唤醒Redis 信号量面对的是多个进程、多台机器靠的是网络通信和轮询/发布订阅。名字一样边界完全不同别把嵌入式那套阻塞即等待的直觉直接搬过来网络环境下阻塞一个线程的成本高得多。4.2 Redis 里实现信号量的三种落地方式头歌实验里信号量这一关一般要求你实现一个能增减并判断是否可用的计数器。落地方式我总结成三种复杂度递增。方式一String 计数器 原子增减。用INCR和DECR是最朴素的实现# 初始化信号量为 3 SET semaphore:download:slots 3 # 获取一个许可先自增再判断是否超限 INCR semaphore:download:slots # 返回 4说明超过上限 3需要立即回退并放弃 DECR semaphore:download:slots # 释放一个许可 DECR semaphore:download:slots这套写法的问题是自增 — 判断 — 回退这三步不是原子的高并发下会互相干扰判断通过了但回退时数值已经被别人改了。所以它只适合并发极低的场景实验里用来理解原理可以生产上不要用。方式二List 当信号量用LPUSH/RPOP。先LPUSHN 个占位元素进列表每个想获取许可的客户端RPOP一个拿到元素的算获得许可列表空了就等待用完再LPUSH回去LPUSH semaphore:download:slots slot1 slot2 slot3 RPOP semaphore:download:slotsRPOP是原子操作多客户端并发也不会拿到同一个元素天然满足并发控制。缺点是需要自己处理超时归还如果客户端拿了元素就崩了这个许可就永久丢失了得靠额外的清理任务扫描。方式三ZSET 有序集合 分数时间戳。这种方式能实现带超时的信号量每个持有许可的客户端往 ZSET 里插入一条value客户端标识, score过期时间戳。获取许可时先清理掉已过期的成员再判断集合大小是否小于 N。这套逻辑通常封装在 Lua 脚本里保证原子性Redisson 的RSemaphore内部差不多就是这个思路。实现方式原子性支持超时归还适用场景String 计数器差需组合不支持原理学习、极低并发List 占位好不支持中等并发、可接受手动清理ZSET 时间戳好Lua 保证支持生产环境推荐4.3 分布式限流场景实战与参数计算信号量最典型的落地场景就是限流。举例某个第三方接口只允许你并发调用 5 次超了会被封。你用线程池控制不了因为可能有多个实例同时在调用令牌桶算法又觉得白白浪费了带宽只想限并发不想限速率这时候信号量就是最贴切的工具。思路很直接任务执行前acquire一个许可执行完release。Redisson 的写法RSemaphore semaphore redisson.getSemaphore(semaphore:third-party:call); // 初始化 N 个许可N 允许的最大并发数 semaphore.trySetPermits(5); if (semaphore.tryAcquire(3, TimeUnit.SECONDS)) { try { callThirdPartyApi(); } finally { semaphore.release(); } } else { // 拿不到许可降级入队延后处理 或 直接返回稍后重试 enqueueForRetry(); }N 怎么定不是拍脑袋。先看第三方接口文档给的并发上限取上限的 70%~80% 留余量如果文档没写就做压力测试从 1 开始逐步加并发观察接口响应时间和错误率找到开始劣化的拐点再打折。我做过一个短信通道的限流对方标称支持 20 并发实测到 15 就开始出现零星超时最后设成 10稳得一批。注意release一定要放在finally里而且只在真正acquire成功后才执行。如果代码写成不管有没有拿到都 release信号量计数会被凭空放大限流就彻底失效了这个 bug 很隐蔽监控上不特意看信号量当前值根本发现不了。5. 头歌实验常见报错与排查实录5.1 报错速查表下面这张表是我和身边做这套实验的朋友总结出来的高频问题基本覆盖了 90% 的卡关情况。现象可能原因排查动作加锁始终返回失败key 是上次实验残留没被清掉先DEL对应 key 或FLUSHDB再重跑释放锁脚本返回 0value 不一致序列化方式不同统一用StringRedisTemplate或显式配置 String 序列化TTL返回 -1只用了SETNX没设过期换成SET key value NX EX n脚本执行报语法错误脚本里用了 Redis 不支持的 Lua 库Redis 只支持标准 Lua 的有限子集别引外部包测试通过但本地复现不了实验环境的 Redis 版本或配置不同用INFO server看版本比对命令支持情况并发测试偶尔失败锁超时时间设太短导致提前释放放大EX值重测确认真是超时导致的5.2 实验指导书里不会写的五条经验第一条先手动敲命令再写代码。很多人一上手就照着模板写 Java报错了完全不知道是代码问题还是 Redis 状态问题。我习惯先redis-cli里把SET NX EX、EVAL跑一遍确认命令本身没问题再写代码封装排错范围一下子缩小一半。第二条每次实验前清库。头歌的环境是复用的上一个同学留下的lock:xxx如果没设过期你的加锁必然全部失败。进环境第一件事就是FLUSHDB实验环境可以这么干生产环境千万别。第三条value 里的线程 ID 换成机器标识 线程 ID。实验里单机跑无所谓养成习惯以后线上多实例环境你会感谢自己。机器标识可以用 IP 后两段或者启动时生成的 UUID。第四条把失败路径也测一遍。实验的测试用例一般只测成功路径但真实的质量在于异常处理。自己写个测试在持锁状态下直接删 key 模拟锁过期被别人拿走看你的释放逻辑会不会误删——会误删说明 Lua 校验那步写错了。第五条看懂实验的评分点再动手。头歌的关卡通常有多个检查项比如加锁成功超时后能重新获取释放他人锁失败这些。先扫一遍检查项能反推出它到底想考你哪一种边界比闷头写半天快得多。6. 从实验通关到生产可用还差哪些功课6.1 锁粒度、性能与降级策略实验里你是对着单个 key 加锁生产上第一个要问的问题是这个 key 的粒度是不是太粗了。如果全站订单用一把lock:order那所有下单请求串行化Redis 撑得住但数据库连接池也撑不住因为你要等几百毫秒才能进临界区。正确做法是按业务主键分片lock:order:{orderId}让不同订单互不干扰。粒度细了key 数量暴涨这时候要关注 Redis 的内存和 key 的过期清理。带EX的锁天然会被自动清理但如果你在临界区里还存了其他不带过期的中间状态就得单独考虑清理策略。降级策略是很多人忽略的一环。加锁失败不等于业务必然失败你可以设计成抢锁失败就走排队、走异步、走降级返回。我做过的一个券码核销场景抢不到锁就直接返回当前人数较多请 3 秒后重试用户体验反而比卡住十几秒好得多。加锁是为了保正确性但不要让锁成为系统可用性的单点。6.2 可观测性怎么在锁出问题时第一时间发现锁这种东西平时不出事一出事就是数据事故所以监控必须提前埋。我在项目里固定会加三个指标抢锁失败率单位时间内tryLock返回 null 的比例。这个值突然升高说明有热点 key 或者有进程持锁没释放。锁等待时长从开始尝试到拿到锁的耗时分布。P99 超过业务容忍阈值就要告警。释放失败次数unlock返回 false 的次数。这个指标最能说明问题——它意味着锁可能已经过期、业务却在继续执行是数据不一致的强烈信号每次出现都应该查日志。日志里一定要打印锁的 key 和唯一 token方便串联谁加的锁、谁删的锁、什么时候过期的。很多团队为了日志干净把这些去掉了出事时追查成本极高得不偿失。最后说说我自己的感受。头歌这套实验的关卡设计其实挺还原真实演进过程的先让你用最粗糙的SETNX实现跑出 bug再一步步用SET NX EX、Lua 校验、唯一 value 把坑填上。如果你只是把答案抄一遍提交完事那这套实验的价值你只拿到三成真正值得花时间的是每一次为什么这样改的追问。我当年第一次在生产上用 Redis 锁就是没做 value 校验结果大促当天出现了一笔重复扣款事后定位了整整两天。那次之后我才明白分布式锁难的不是加锁那行代码而是锁之外的那些边界和兜底。把这些想清楚了实验通关、面试回答、线上稳定其实都是顺带的事。