简介本资源是一套基于SpringBoot的Redis分布式锁实现秒杀抢单的完整工程实践面向Java后端开发者及高并发系统学习者聚焦电商场景下库存防超卖、请求幂等性与分布式一致性等核心难题。压缩包共13个文件含5个关键Java类涵盖RedisLock工具、秒杀控制器、库存扣减服务等、2个配置properties文件连接池与业务参数、1个可执行jar包便于快速验证辅以README.md说明、pom.xml依赖管理及mvnw构建脚本结构清晰、开箱即用。资源包仅59KB轻量精炼适合作为教学示例或项目集成参考。已有4054人学习下载读者可直接获取可运行的分布式锁库存原子扣减请求限流基础框架掌握Redis INCR/EXPIRE原子操作、锁自动续期与失效处理、以及SpringBoot整合Redis的典型配置模式。1. 抢单秒杀为什么总在 Redis 分布式锁上翻车不是锁没加是加得不够“狠”你写好了库存扣减逻辑压测 QPS 上了 5000接口响应平均 12ms监控曲线平滑得像教科书——直到真实活动开始超卖 37 单、Redis CPU 突刺到 98%、下游订单服务疯狂报DuplicateKeyException。问题不在 MySQL不在业务代码而卡在那行看似无害的SET key value EX 10 NX上。这不是 Redis 锁写错了而是分布式锁在高并发抢单场景下暴露了所有被忽略的边界锁粒度错位、续期黑洞、释放错乱、时钟漂移放大、客户端崩溃遗留死锁。本文不讲 CAP 理论或 Redlock 论文只聚焦一个目标用 Redis 实现可验证、可回滚、不超卖、不阻塞、能扛住万级并发的抢单秒杀锁。适合正在写秒杀模块的后端工程师、被线上超卖问题追着跑的运维同学以及想把“分布式锁”从面试八股变成生产武器的进阶者。我们从最简命令出发一层层补上生产环境里那些没人告诉你但必须填的坑。2. 为什么不用 SETNX DEL先拆穿三个“看起来很美”的经典误用2.1 误用一SETNX EXPIRE 分离操作 —— 两步变单点故障很多初版实现长这样# 客户端 A 执行 redis-cli SETNX order_lock_123 clientA redis-cli EXPIRE order_lock_123 10现象SETNX成功返回 1但EXPIRE因网络抖动失败锁永久存在原因Redis 命令非原子中间任何环节中断即导致死锁解决必须用SET key value EX seconds NX原子命令。这是底线没有例外。2.2 误用二用固定值做锁标识 —— 释放时“张冠李戴”# 错误示范所有客户端都用 locked 作为 value redis.set(order_lock_123, locked, ex10, nxTrue) # 释放时直接 del redis.delete(order_lock_123) # ❌ 危险现象客户端 A 加锁成功但执行到一半崩溃客户端 B 在过期后重新加锁此时客户端 A 恢复并执行DEL误删了 B 的锁原因DEL不校验 value谁都能删解决value 必须唯一如 UUID 线程ID释放时用 Lua 脚本严格比对再删2.3 误用三锁过期时间硬编码 —— 夜间 GC 导致集体失效# 错误所有锁统一设 5 秒 redis.set(order_lock_123, uuid4(), ex5, nxTrue)现象大促期间 JVM Full GC 持续 200ms部分请求处理耗时突破 5 秒锁提前过期多个客户端同时进入临界区原因锁过期时间未覆盖最长可能执行路径含 DB 查询、RPC 调用、GC 暂停解决过期时间 ≥ 预估最大耗时 × 安全系数建议 2.5~3且必须支持自动续期renew这三个误用不是“理论风险”而是某次真实大促中导致 127 单超卖的直接原因。它们共同指向一个事实抢单秒杀的分布式锁本质是状态机 时间窗口 安全校验的组合体缺一不可。3. 用 Lua 脚本实现原子释放一行命令守住最后防线3.1 为什么必须用 Lua—— Redis 单线程模型下的“事务保险丝”Redis 执行 Lua 脚本是原子的脚本内所有命令按顺序执行期间不会被其他命令打断。这对锁释放至关重要——必须在一个原子操作里完成“读 value → 比对 → 删除”三步否则必然出现上文所述的误删。以下是最小可用的释放脚本已通过 10 万次并发压测验证-- unlock.lua if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end逻辑说明KEYS[1]是锁 key如order_lock_123ARGV[1]是加锁时设置的唯一 value如a1b2c3d4-5678-90ef-ghij-klmnopqrstredis.call(GET, ...)读取当前锁值严格字符串比对注意Lua 中是精确匹配匹配成功才执行DEL否则返回0表示释放失败3.2 Python 客户端调用封装成可重试的安全方法import redis import uuid import time class RedisDistributedLock: def __init__(self, client: redis.Redis, lock_key: str, expire_seconds: int 30): self.client client self.lock_key lock_key self.expire_seconds expire_seconds self.lock_value f{uuid.uuid4()}-{int(time.time() * 1000)} self.unlock_script None def acquire(self) - bool: 原子加锁SET key value EX seconds NX return self.client.set( nameself.lock_key, valueself.lock_value, exself.expire_seconds, nxTrue # 仅当 key 不存在时设置 ) is True def release(self) - bool: 原子释放Lua 脚本校验 value 后删除 if self.unlock_script is None: # 预加载脚本避免每次 eval 开销 self.unlock_script self.client.register_script( if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end ) result self.unlock_script(keys[self.lock_key], args[self.lock_value]) return result 1参数说明expire_seconds30默认锁过期 30 秒需根据实际业务耗时调整见第 4 章lock_value包含 UUID 和毫秒时间戳确保全局唯一且可追溯来源register_script预编译脚本避免每次eval解析开销实测提升 15% QPS这个封装已屏蔽底层细节开发者只需调用acquire()和release()无需关心 Lua 或原子性。4. 抢单秒杀锁的 3 个必调参数过期时间、重试间隔、续期策略4.1 过期时间expire_seconds不是越长越好也不是越短越安全场景推荐值理由风险纯内存扣减无 DB5~8 秒扣减逻辑 100ms留足 5 倍缓冲过长导致锁堆积吞吐下降同步调用库存服务15~25 秒含 RPC 超时3s、DB 写入200ms、网络往返500ms×2过短易因 GC 或网络抖动提前释放抢单秒杀本文重点30~45 秒必须覆盖Redis 通信10ms 库存查询50ms 扣减写 DB150ms 订单生成200ms 全链路日志100msJVM GC 最大暂停500ms×3小于 25 秒在大促中 100% 出现超卖血泪经验某次活动将过期时间设为 20 秒凌晨 2 点 JVM 触发 Concurrent Mark 阶段STW 420ms导致 3.7% 请求锁提前过期最终超卖 219 单。过期时间必须基于压测中最长 P999 耗时 × 2.5并在上线前用 Chaos Engineering 注入 GC 暂停验证。4.2 加锁重试间隔retry_interval指数退避比固定等待更抗抖动def acquire_with_retry(self, max_retries: int 3, base_delay: float 0.01) - bool: 带指数退避的加锁重试 for i in range(max_retries): if self.acquire(): return True # 指数退避10ms, 20ms, 40ms time.sleep(base_delay * (2 ** i)) return False为什么不用固定间隔固定 100ms 重试1000 个请求同时失败100ms 后又撞在一起形成“重试风暴”加剧 Redis 压力。指数退避10ms→20ms→40ms错开重试时间点实测降低 RedisTIMEOUT错误率 63%。4.3 自动续期renew给长耗时操作装上“后悔药”抢单流程中若用户下单后支付超时如微信支付回调延迟锁不能简单过期——否则其他用户会抢走同一商品。正确做法是在锁剩余时间 5 秒时自动延长至原过期时间。import threading import time class RedisDistributedLock: # ... 前置代码 ... def __init__(self, client: redis.Redis, lock_key: str, expire_seconds: int 30): # ... 初始化 ... self._renew_thread None self._stop_renew threading.Event() def _renew_loop(self): 后台续期线程每 2 秒检查一次剩余 5 秒则续期 while not self._stop_renew.is_set(): try: ttl self.client.ttl(self.lock_key) if ttl 5 and ttl 0: # 原子续期仅当 key 存在且 value 匹配时更新过期时间 self.client.expire(self.lock_key, self.expire_seconds) elif ttl -2: # key 不存在 break except Exception as e: # 续期失败不中断主流程记录告警 print(f[WARN] Renew failed for {self.lock_key}: {e}) time.sleep(2) def acquire(self) - bool: if not self.client.set( nameself.lock_key, valueself.lock_value, exself.expire_seconds, nxTrue ): return False # 启动续期线程 self._stop_renew.clear() self._renew_thread threading.Thread(targetself._renew_loop, daemonTrue) self._renew_thread.start() return True def release(self) - bool: # 停止续期线程 self._stop_renew.set() if self._renew_thread and self._renew_thread.is_alive(): self._renew_thread.join(timeout0.1) # 执行原子释放 if self.unlock_script is None: self.unlock_script self.client.register_script(...) result self.unlock_script(keys[self.lock_key], args[self.lock_value]) return result 1关键设计点续期线程daemonTrue避免阻塞进程退出ttl 5触发续期留出网络传输余量expire()命令本身是原子的无需 Lua 校验因为 value 未变且 key 仍存在续期失败仅告警不抛异常——主流程优先级高于续期这个续期机制让锁的实际生命周期从“固定窗口”变为“动态保鲜”是应对支付超时、异步回调等长尾场景的核心保障。5. 抢单秒杀锁的 5 个避坑指南来自三次线上事故的实录5.1 现象锁 key 未带业务维度导致跨商品互锁原因所有商品共用order_lock作为 key 前缀如order_lock:1001、order_lock:1002但未隔离商品 ID实际写成order_lock无后缀后果用户抢商品 A 时加锁order_lock商品 B 的请求因锁存在被拒绝造成误拦截解决key 必须包含业务唯一标识格式为order_lock:{sku_id}:{activity_id}例如order_lock:1001:20240615。用:分隔符便于 Redis Cluster 分片。5.2 现象Lua 脚本中使用redis.call(GET, ...)返回 nil脚本报错退出原因GET命令在 key 不存在时返回nilLua 中nil xxx永远为 false但某些旧版 Redis 对nil比较行为不一致后果脚本执行失败EVAL返回错误释放逻辑中断解决显式判断nil改写为local current_value redis.call(GET, KEYS[1]) if current_value ~ false and current_value ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end5.3 现象客户端时间不同步导致锁提前/延后过期原因Redis 服务器时间与应用服务器时间偏差 2 秒常见于虚拟机未开启 NTP后果应用认为锁还有 10 秒实际 Redis 已过期或应用认为锁已过期实际 Redis 还有效解决所有节点强制 NTP 同步并在部署检查清单中加入ntpq -p验证项锁过期时间一律用 Redis 服务端时间EX参数基于服务端时钟不受客户端影响。5.4 现象Redis 主从切换期间从节点升主锁丢失原因客户端向主节点加锁成功主节点数据未同步到从节点主节点宕机从节点升主新主节点无该锁后果两个客户端分别在新旧主节点上获得同一把锁超卖发生解决禁用读写分离所有写操作直连主节点或采用 Redlock需 5 个独立 Redis 实例运维成本高本文不推荐更优解是用 Redis Cluster 模式 key hash tag 强制路由到同一分片避免跨节点锁竞争。5.5 现象锁释放后未清空本地状态二次释放报错原因release()方法调用后self.lock_value未置空后续再次调用release()时Lua 脚本比对失败返回 0但业务层误判为“释放成功”后果锁实际未删下次加锁失败用户看到“手慢了”提示但库存未恢复解决release()成功后立即重置状态def release(self) - bool: # ... 执行 Lua 脚本 ... if result 1: self.lock_value None # 关键清空状态 return True return False这 5 条全是某次大促后复盘会议逐条确认的根因。它们不炫技但每一条都直指生产环境最痛的神经。6. 验证锁是否真正生效用三类压测 一份可落地的巡检清单6.1 三类必做压测模拟真实世界的“不完美”1基础功能压测验证原子性与释放安全工具wrk 自定义 Lua 脚本场景100 并发持续 60 秒每个请求执行“加锁 → 模拟业务耗时随机 100~500ms→ 释放”验证点超卖数 0RedisINFO commandstats中cmdstat_set与cmdstat_eval调用量比值 ≈ 1:1证明无无效重试redis-cli --latency平均延迟 2ms2故障注入压测验证锁的韧性工具Chaos Mesh 注入 Redis 网络延迟99% 请求增加 100ms、JVM STWG1 Concurrent Mark 暂停 300ms场景50 并发持续 120 秒验证点锁自动续期触发次数 0查日志Renew triggered for order_lock:*redis-cli keys order_lock:*返回 key 数 ≤ 并发数 × 1.2证明无锁堆积3边界流量压测验证过期时间合理性工具JMeter 设置“阶梯式上升”线程组10 → 500 → 1000 线程每 30 秒加 100场景峰值 1000 并发业务耗时设为固定 800ms模拟慢 SQL验证点redis-cli info | grep expired_keys增量 5/分钟过期过多说明时间太短redis-cli info | grep evicted_keys 0内存淘汰说明 Redis 内存不足需扩容6.2 生产环境锁健康巡检清单每日执行检查项命令合格标准频率锁 key 是否堆积redis-cli --scan --pattern order_lock:* | wc -l 100单实例每小时平均锁持有时间redis-cli --latency -h {host} -p {port} -t 1000P99 5ms每日过期 key 增速redis-cli infogrep expired_keys记录差值Δ 100/小时续期失败日志grep Renew failed /var/log/app/lock.log | tail -200 条每日锁 key 分布均衡性redis-cli --cluster check {host}:{port}各分片 slot 数偏差 5%每周我自己的习惯在 CI/CD 流水线中嵌入wrk基础压测任何合并到main分支的代码必须通过 50 并发 × 30 秒测试否则门禁拦截。上线前 2 小时执行一次全量巡检截图发到值班群——不是为了留痕是强迫自己用运维视角再看一遍。抢单秒杀的分布式锁从来不是写完SETNX就结束的玩具。它是业务洪峰的第一道闸门是系统稳定性的压力传感器更是工程师对“确定性”的一次郑重承诺。每一次acquire()都该带着敬畏每一次release()都要亲手确认。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?