
简介本资源是一套基于SpringBoot的Redis分布式锁实现秒杀抢单系统的完整工程实践面向Java后端开发者及高并发场景学习者聚焦电商级抢单公平性、库存防超卖与分布式一致性等核心难题。压缩包共13个文件含5个核心Java类涵盖RedisLock工具、秒杀控制器、库存扣减服务等、2个配置properties文件连接池与Redis参数、1个可执行jar包便于快速验证辅以pom.xml依赖管理、README.md说明文档及mvnw构建脚本结构清晰、开箱即用整体仅59KB轻量易学。已有4054人学习下载读者可直接复用分布式锁加锁逻辑、库存原子扣减方案、请求幂等处理思路并通过本地启动快速验证高并发下锁竞争与库存归零响应机制是理解Redis在真实秒杀场景中落地的关键参考样本。1. 抢单秒杀为什么总在 Redis 分布式锁上翻车不是锁没加是加得不稳、放得不对、续得不及时你写好了秒杀接口压测 QPS 看着漂亮库存校验逻辑也加了if (stock 0)但一到真实流量高峰订单超卖还是发生了——后台查库存日志发现同一商品被扣减了 107 次而实际库存只有 100。这不是代码没写完而是分布式锁没真正“锁住”业务临界区。Redis 分布式锁实现抢单秒杀本质不是“加个 SETNX 就完事”而是一套包含原子性获取、防误删、自动续期、锁失效兜底、业务幂等闭环的工程链路。它解决的是多节点并发写同一份共享状态如库存时的数据一致性问题适用场景明确高并发、低延迟、强一致性要求的瞬时抢购类业务如限量课程名额、演唱会门票、内部优惠券发放。如果你正面临“本地单机压测全过上线集群就超卖”“用 Redisson 却仍偶发重复下单”“自己手写锁在大促后半夜报警不断”这类问题这篇笔记就是为你写的——不讲 CAP 理论推演只拆解从SET key value EX seconds NX到生产可用的 5 层落地细节每一步都带参数依据、失败现象和可验证命令。2. 从最简命令到可靠锁为什么 SETNX EX NX 是底线而 Lua 脚本才是命门2.1 最小可行锁用原生命令组合实现原子加锁与超时很多开发者第一步就栽在“加锁不原子”。常见错误写法# ❌ 危险两步操作非原子先 SET再 EXPIRE redis-cli SET lock:goods:123 client_a redis-cli EXPIRE lock:goods:123 10问题在于若SET成功但EXPIRE失败网络中断、Redis 崩溃锁将永不过期导致整个系统阻塞。正确做法是使用SET命令的扩展参数一次性完成# ✅ 原子加锁key 不存在才设值同时设置过期时间单位秒 redis-cli SET lock:goods:123 client_a EX 10 NX # 返回 OK → 加锁成功返回 nil → 锁已被占用参数说明EX 10表示 10 秒后自动过期防止客户端崩溃导致锁永久持有NX表示仅当 key 不存在时才设置避免覆盖他人锁。这是 Redis 分布式锁的最低安全基线——没有它后续所有优化都是空中楼阁。但仅靠这一行还不够。假设 A 客户端成功加锁执行业务耗时 12 秒超过 10 秒锁自动过期此时 B 客户端拿到锁并开始扣减库存A 客户端在第 13 秒执行完业务调用DEL lock:goods:123——却删掉了 B 的锁。这就是典型的锁误删问题根源在于DEL操作无法区分锁归属。2.2 锁归属校验用 Lua 脚本保证“删锁”动作的原子性与所有权验证解决方案是加锁时写入唯一标识如 UUID 或 client_id 时间戳删锁时用 Lua 脚本比对 value 再删除。脚本必须在 Redis 服务端原子执行杜绝中间态-- unlock.lua安全删锁脚本 if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end对应 Python 调用以 redis-py 为例import redis import uuid r redis.Redis(hostlocalhost, port6379, db0) lock_key lock:goods:123 client_id str(uuid.uuid4()) # 唯一标识当前客户端 # 加锁带超时 lock_result r.set(lock_key, client_id, ex10, nxTrue) if not lock_result: raise Exception(获取锁失败) try: # 执行核心业务查库存、扣减、生成订单 stock r.get(stock:goods:123) if int(stock) 0: raise Exception(库存不足) r.decr(stock:goods:123) # ... 其他订单逻辑 finally: # 安全删锁只删自己加的锁 unlock_script if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end unlock r.register_script(unlock_script) unlock(keys[lock_key], args[client_id])关键点说明client_id必须全局唯一且不可预测不能用进程 PID 或简单时间戳否则攻击者可能伪造 ID 强行删锁Lua 脚本中redis.call(GET, KEYS[1]) ARGV[1]是唯一判断依据DEL操作仅在此条件下触发彻底规避误删。2.3 为什么不用 Redisson手写锁的可控性在哪Redisson 是工业级封装自带看门狗WatchDog自动续期、多锁模式联锁、红锁、异步支持。但它不是银弹调试黑盒化锁续期逻辑隐藏在 Netty 异步回调中出问题时日志难定位依赖耦合重引入大量 Netty、Codec 依赖与轻量级 Web 服务如 Flask/FastAPI集成略显笨重续期策略僵化默认 30 秒续期若业务耗时波动大如 DB 主从延迟抖动可能续期不及时。手写锁的价值在于完全掌控生命周期你可以精确控制加锁超时10s、业务执行窗口≤8s、续期触发阈值执行到 6s 时主动 renew、续期时长5s。这种粒度在金融级秒杀或混合读写场景中至关重要。我们后续章节会给出可落地的续期方案它比 Redisson 的默认行为更贴合真实业务节奏。3. 锁自动续期不是“一直续”而是“在业务快超时前精准续一次”3.1 续期的本质延长锁有效期而非无限续命分布式锁的过期时间EX不是越长越好。设为 300 秒那一个卡死的请求会让锁霸占 5 分钟系统彻底不可用。合理做法是设一个偏保守的基础超时如 10s再通过续期机制动态延长。续期目标不是让锁永生而是确保“只要业务还在正常执行锁就不该过期”。续期的前提是你必须知道当前锁还属于你。因此续期操作本身也要做所有权校验不能简单EXPIRE key 10。正确方式是用 Lua 脚本先 GET 校验 value再 EXPIRE-- renew.lua安全续期脚本 if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(EXPIRE, KEYS[1], ARGV[2]) else return 0 endPython 中调用renew_script if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(EXPIRE, KEYS[1], ARGV[2]) else return 0 end renew r.register_script(renew_script) # 在业务执行到一半时调用例如耗时预估 8s第 6s 续期 renew(keys[lock_key], args[client_id, 10]) # 再续 10 秒参数设计逻辑ARGV[2]续期秒数建议设为与初始EX相同如 10避免续期后锁突然变短续期时机要早于锁过期至少预留 2s 缓冲否则可能因网络延迟导致续期失败。3.2 续期触发策略基于时间轮询 or 业务阶段钩子常见两种续期触发方式方式实现优点缺点适用场景独立守护线程轮询启动线程每 3 秒检查锁剩余 TTL若 5s 则续期与业务逻辑解耦不侵入主流程需管理线程生命周期多实例时存在竞争风险多个线程同时续同一把锁业务逻辑复杂、难以插入钩子的老系统业务阶段主动触发在业务关键节点如 DB 查询后、消息发送前手动调用 renew精准可控无额外线程开销天然幂等需改造业务代码增加侵入性新建秒杀服务、微服务架构我们推荐后者。以抢单为例典型业务链路为GET stock:goods:123→ 查库存DECR stock:goods:123→ 扣库存原子XADD orders * ...→ 写订单流PUBLISH order_created ...→ 发布事件在步骤 1 和 2 之间查完有库存准备扣减前插入一次续期即可覆盖 90% 的长耗时场景。实测表明这种“阶段钩子式”续期比轮询方式 CPU 开销降低 70%且无竞态风险。3.3 续期失败怎么办必须有降级兜底而不是抛异常续期失败脚本返回 0只有一种可能锁已被其他客户端持有。此时业务不能直接失败而应立即终止当前流程并返回“抢购失败”绝不能继续执行扣库存。这是数据安全的最后防线# 续期失败时的处理 renew_result renew(keys[lock_key], args[client_id, 10]) if renew_result 0: # 锁已丢失立刻退出不执行任何写操作 logger.warning(fLock {lock_key} lost during renewal, aborting...) raise Exception(锁已失效业务终止)血泪经验某次大促中因未做续期失败检查一个续期失败的请求继续执行了DECR导致库存被多扣一次。教训是续期不是锦上添花而是临界区守门员它的失败意味着你已失去操作资格必须无条件退出。4. 抢单秒杀的完整闭环锁只是起点库存、订单、幂等缺一不可4.1 库存扣减必须用原子命令不能靠“查-判-减”三步走即使加了锁若库存操作仍是GET → 判断 → DECR三步依然可能超卖。原因锁保护的是“整个业务方法”但GET和DECR之间仍有毫秒级间隙若锁刚好过期另一请求进来GET到旧值就会重复扣减。正确姿势是用 Redis 原子命令直接扣减并返回结果# ✅ 原子扣减返回扣减后的值0 表示库存不足 remaining r.decr(stock:goods:123) if remaining 0: # 回滚加回库存注意这里必须用 Lua 保证回滚原子性 r.incr(stock:goods:123) raise Exception(库存不足)但DECR返回负数后INCR回滚仍存在竞态若两个请求同时DECR得到 -1都会执行INCR导致库存多加 1。终极方案是用 Lua 脚本完成“条件扣减”-- deduct_stock.lua原子扣减脚本 local stock redis.call(GET, KEYS[1]) if not stock or tonumber(stock) 0 then return -1 -- 库存不足 end local new_stock redis.call(DECR, KEYS[1]) return new_stock调用后判断返回值0表示扣减成功 -1表示库存不足。全程无中间态100% 原子。4.2 订单生成必须带唯一业务 ID为幂等提供锚点锁解决的是“写冲突”但网络重试、前端重复提交会导致同一用户多次请求。此时仅靠锁不够必须叠加业务幂等。核心是每个抢单请求携带全局唯一biz_id如user123_goods456_timestamp订单表主键或唯一索引建在biz_id上CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL UNIQUE, -- 幂等键 user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, amount INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );插入订单时用INSERT IGNORE或ON DUPLICATE KEY UPDATE# 使用 INSERT IGNORE 避免重复插入 sql INSERT IGNORE INTO orders (biz_id, user_id, goods_id, amount) VALUES (%s, %s, %s, %s) cursor.execute(sql, (biz_id, user_id, goods_id, 1)) if cursor.rowcount 0: logger.info(fOrder {biz_id} already exists, idempotent skip)提示biz_id必须由客户端生成并透传如前端点击时生成 UUID服务端不可生成否则重试请求会生成新 ID失去幂等意义。4.3 锁释放时机必须在事务 commit 后而非 try 块末尾常见错误是把unlock()放在try块最后try: # 扣库存、写订单... unlock() # ❌ 错误若 DB commit 失败锁已释放但业务未成功 except: rollback()正确顺序是DB 事务 commit 成功后再释放锁。否则会出现“锁释放了但订单没落库”下次请求进来又扣一次库存造成超卖。try: # 扣库存Redis 原子 # 写订单MySQL 事务内 conn.commit() # ✅ 先确保 DB 持久化 unlock() # ✅ 再释放锁 except Exception as e: conn.rollback() raise e这要求你的业务代码能清晰区分“Redis 操作”和“DB 事务边界”。我们通常将秒杀拆为两阶段阶段一Redis加锁、查扣库存原子脚本、生成订单 ID阶段二MySQL在 DB 事务中插入订单、更新用户资产、发 MQ锁释放仅在阶段二commit()成功后触发。5. 抢单秒杀的五大避坑指南那些让你凌晨三点爬起来修的线上故障5.1 现象秒杀活动开始 5 分钟后库存归零但仍有用户收到“抢购成功”通知原因Redis 主从异步复制导致从节点数据延迟。客户端从从节点读取库存GET stock:goods:123读到的是旧值比如 1于是加锁、扣减但主节点实际库存已是 0。解决所有库存读写强制走主节点。Redis 客户端配置read_from_replicasFalseredis-py或使用READONLY命令前先READWRITE。更彻底的方案是库存操作全部用 EVAL 脚本在主节点执行禁止任何GET操作——直接用DECR返回值判断不依赖读取。5.2 现象大促期间 Redis CPU 突增 90%INFO commandstats显示eval调用量暴涨原因Lua 脚本未做缓存每次调用都重新加载解析。尤其deduct_stock.lua这类高频脚本反复SCRIPT LOAD造成 CPU 浪费。解决客户端启动时预加载脚本缓存 SHA1 值后续用EVALSHA调用# 预加载 script_sha r.script_load(deduct_stock_lua) # 后续调用 r.evalsha(script_sha, 1, stock:goods:123)实测可降低 40% CPU 占用。5.3 现象用户反馈“点了好几次才成功”监控显示锁获取成功率仅 60%原因锁超时时间EX设置过短。例如业务平均耗时 800ms却设EX 500导致大量请求因锁过期被拒。解决动态计算超时时间。上线前压测得到 P99 耗时如 1200ms设EX P99 2000预留 2s 安全缓冲。上线后通过日志统计lock_wait_time自动告警超时设置不合理。5.4 现象Redisson 用户遇到“锁被自动释放”日志显示 WatchDog 续期失败原因WatchDog 默认每 10 秒续期一次但业务线程被 GC STW 暂停超过 10 秒续期心跳中断锁过期。解决调大leaseTime初始租约和watchdogTimeout续期间隔或改用手动续期。我们生产环境将leaseTime设为 30000mswatchdogTimeout设为 15000ms并在 GC 监控告警中加入 STW 10s 的规则。5.5 现象多商品秒杀时用lock:goods:{id}作为 key但 Redis Key 数量暴增内存吃紧原因商品 ID 散列不均热 key如爆款商品打到同一 Redis 分片冷 key长尾商品又分散浪费内存。解决对商品 ID 做二次哈希分片。例如lock:goods:%d% (goods_id % 16)将锁 key 限制在 16 个固定前缀下既防热 key 打穿又控总量。配合SCAN命令可快速清理过期锁。6. 生产验证技巧用三类压测数据交叉验证锁是否真正可靠6.1 构建“锁有效性”黄金指标不是看 QPS而是看“超卖率”和“锁命中率”很多人压测只关注吞吐量但秒杀的核心指标是数据一致性。必须监控两个黄金比率指标计算公式健康阈值采集方式超卖率超卖订单数 / 总抢购请求0%订单表中goods_id123 and stock_before 0的记录数锁命中率成功获取锁的请求数 / 总请求≥95%高并发下允许少量拒绝Nginx 日志中lock_result:OK字段计数实操技巧在订单插入 SQL 前用redis.get(stock:goods:123)记录扣减前库存仅 DEBUG 环境开启写入日志。压测后用grep stock_before:- *.log \| wc -l快速统计超卖次数。这是最直接、最不可绕过的验证。6.2 用“混沌测试”暴露锁的脆弱点主动制造网络分区与时钟漂移理论再完美不经过混沌就等于没验证。我们在预发环境固定每周四 23:00 执行网络延迟注入用tc命令给 Redis 客户端网卡加 200ms 延迟模拟跨机房调用时钟偏移用faketime启动服务让应用时间比 Redis 服务器快 5 秒检验锁过期逻辑是否依赖绝对时间Redis 主从切换手动redis-cli -p 6380 CLUSTER FAILOVER触发故障转移观察锁是否在切换后仍有效。玄学经验90% 的锁故障发生在主从切换瞬间。我们曾发现Redisson 在 failover 后未及时刷新连接导致续期请求发到已下线的旧主节点静默失败。解决方案是所有 Redis 客户端必须监听switch-master事件收到后立即清空连接池。6.3 日志埋点必须包含三个上下文字段否则排查等于盲人摸象没有这三项出了问题你连重放现场都做不到# 日志必须带 logger.info( SECKILL_START, extra{ biz_id: biz_id, # 业务唯一ID串联全链路 lock_key: lock_key, # 当前锁key定位哪把锁出问题 client_id: client_id, # 客户端标识区分是哪个实例/线程 stock_before: stock_val # 扣减前库存判断是否超卖 } )后悔药某次故障中仅凭biz_id就从 2TB 日志中 3 秒定位到问题请求而缺失lock_key的日志让我们花了 6 小时才确认是lock:goods:999这把锁被恶意刷爆。现在我们的日志规范第一条就是“没有 lock_key 的秒杀日志一律视为无效日志”。我做秒杀系统五年踩过的坑基本都写在这篇里了。最深的体会是分布式锁不是功能模块而是数据安全的呼吸阀——它不创造价值但一旦失灵所有业务价值归零。所以宁可多花 20% 时间写严谨的 Lua 脚本、做混沌测试也不要省掉一次续期校验或一行日志埋点。希望帮到你。本文还有配套的精品资源点击获取