首页 / 资讯中心 / 文章详情

秒杀系统防超卖与一人一单:Redis Lua脚本与分布式锁实战解析

秒杀系统防超卖与一人一单:Redis Lua脚本与分布式锁实战解析 ★ FEATURED ARTICLE
“黑马点评”这个项目但凡学过 Redis 实战的应该都不陌生。它模拟的是类似大众点评的本地生活业务而我这篇笔记要聊的是里面最经典也最容易让人栽跟头的一块——优惠券秒杀。秒杀这玩意儿听起来就是“卖东西”但真做起来库存超卖、一人抢多单、并发打崩数据库每一个坑都能让人调一晚上。我作为一个菜鸟在跟着做这一章的时候代码敲了不止一遍Redis 命令也反复验证才把整个链路真正串起来。这篇笔记不打算照搬教程原文而是把自己从“看得懂”到“写得对”过程中的理解、踩坑、排查思路都整理出来给同样卡在这里的朋友做个参考。文章会围绕这几个核心问题展开秒杀业务到底难在哪、库存扣减怎么用 Redis 保证不超卖、一人一单的限制怎么用分布式锁实现以及常见的报错和排查技巧。如果你正在学黑马点评或者工作中要接类似的秒杀/抢购需求这篇内容应该能帮你少走不少弯路。1. 优惠券秒杀在项目里到底解决什么问题1.1 业务场景还原先把这个业务讲清楚。在黑马点评里商家会发布一些“秒杀券”比如满 100 减 50价格很诱人但数量有限。用户点进详情页可以看到剩余库存点击“立即抢购”按钮如果下单成功就能拿到一张券。从流程上看这个动作可以拆成三步判断库存是否充足充足则扣减库存判断该用户是否已经买过一人一单限制创建订单保存到数据库。听起来很朴素对吧但一旦多个人同时点抢购问题就来了。假设库存只有 1 张两个用户同时发起请求如果在“判断库存”和“扣减库存”之间做了一个耗时操作两个请求都读到了库存为 1然后各自扣减库存就变成了 -1。这就是超卖。超卖在真实业务中是绝对不能接受的库存显示卖超了后面履约时发不出货用户投诉、商家赔付全是麻烦事。所以秒杀系统第一个要解决的核心问题就是并发情况下库存数据不能出错。1.2 技术难点拆解库存不超卖只是第一关后面还有连环问题。第一秒杀场景的并发量通常很高。如果所有请求直接打数据库用 update 语句去扣库存数据库的行锁竞争会让性能直线下降甚至把数据库打挂。教程里选择用 Redis 来做前置的库存控制是因为 Redis 单线程模型天然把并发请求串行化了性能远高于数据库。第二秒杀还有一个隐藏规则通常一个用户只能抢一张券。如果只控制库存不控制人就会出现一个用户用多个账号或者多次请求把库存刷光活动就失去了意义。所以要实现“一人一单”的限制。第三是一致性问题。Redis 里的库存扣了但订单创建失败怎么办数据库里有订单但 Redis 库存没扣怎么办这些都需要在流程设计上想清楚。我在学这一块时的总体感受是秒杀不是某一个技术点难而是把 Redis、分布式锁、数据库事务和业务规则串在一起任何一个环节的疏忽都会导致整个逻辑失效。接下来按我的理解把每个环节逐一拆开讲。2. 秒杀核心库存不超卖是怎么做到的2.1 从超卖问题说起先聊聊最基础的超卖问题。如果不用 Redis代码大概是这样的先查库存判断大于 0然后执行 update 扣减。高并发下两个线程同时查到库存为 1都判断通过然后都执行扣减库存变成 -1。解决办法大家可能第一时间想到把判断和扣减放在一条 SQL 里让数据库来保证原子性。比如UPDATE tb_seckill_voucher SET stock stock - 1 WHERE voucher_id ? AND stock 0这条语句本身就能防止超卖因为 where 条件里带上了 stock 0数据库的乐观锁机制会保证只有库存大于 0 时才会更新成功影响行数为 0 就说明库存不足。这个思路是对的在低并发场景下完全够用。但秒杀的问题在于所有请求都集中在同一把“锁”上——同一个 voucher_id 的行锁。数据库行锁的粒度是行并发高的时候大量请求都在等待这一行的锁数据库的响应时间会越来越长最终拖垮整个库。黑马点评里给出的思路是用 Redis 先把这层并发挡下来。2.2 库存扣减的 Redis 实现Redis 可以把库存保存在一个字符串类型的 key 里比如seckill:stock:1值为剩余数量。扣减库存最直接的方式是用decr命令DECR seckill:stock:1decr是原子操作Redis 单线程执行命令两个并发请求执行decr一定是一个一个来绝对不会出现同时读到同一个值的情况。而且decr执行后返回的新值是小于 0 的就能判断出库存不足然后需要把库存加回去。贴一下我第一次写的伪代码Long stock stringRedisTemplate.opsForValue().decrement(key); if (stock 0) { // 库存不足回补 stringRedisTemplate.opsForValue().increment(key); return 库存不足; } // 库存充足继续下单这个方案比查了再减好多了因为判断和扣减合成了一个原子命令。但它有个隐患如果扣减成功、回补失败怎么办或者订单还没创建Redis 库存已经扣了这个时候其他用户就买不到了。所以更合理的做法是把库存扣减的成功条件放在业务逻辑里判断而不是回补。什么意思就是不扣到负数而是提前判断。2.3 Lua 脚本保证原子性黑马点评教程里最终用的是 Lua 脚本我当时不太理解为什么非要脚本后来想明白了因为秒杀流程有多步操作不是单条 Redis 命令能搞定的。比如要同时判断库存和用户是否已下单if (redis.call(exists, KEYS[1]) 1) then local stock tonumber(redis.call(get, KEYS[1])); if (stock 0) then return 1; end; if (redis.call(sismember, KEYS[2], ARGV[1]) 1) then return 2; end; redis.call(decr, KEYS[1]); redis.call(sadd, KEYS[2], ARGV[1]); return 0; end;这段脚本做的事情是检查库存 key 是否存在如果库存小于等于 0返回状态 1表示库存不足如果用户 ID 已经存在于已购买集合中返回状态 2表示重复下单否则扣减库存并把用户 ID 加入集合返回状态 0表示可以下单。在 Redis 里eval执行这段脚本时整个脚本是原子执行的中间不会插入其他命令。这就相当于把“判断库存 判断用户 扣库存 记录用户”这四步合并成了一个数据库层面不可分割的操作。用代码执行的话Long result stringRedisTemplate.execute( seckillScript, Arrays.asList(stockKey, userIdKey), userId.toString() );执行完返回 0、1、2对应三种结果业务层只需根据返回状态做后续处理即可。这是整个秒杀流程里最核心的一段搞懂它超卖问题就解决了一大半。3. 一人一单分布式锁的实战3.1 为什么需要分布式锁Lua 脚本解决了库存超卖但它不能解决另一个问题同一个用户同时点两次抢购两个请求都执行了脚本脚本里的sismember判断用户不存在于是都扣了库存、都下了订单。这样用户就买了两张券超出了“一人一单”的限制。你可能会问Lua 脚本不是原子的吗原子不代表它逻辑上能识别出并发下的同一个用户。两个请求是先后到达 Redis 的第一个执行完脚本后用户已经被记录进去了第二个再执行时确实会被拦住。但问题是两个请求可能在同一个时刻发起Redis 单线程虽然是串行执行但业务层可能在执行脚本之前已经各自通过了某些前置校验导致两个请求都走到了执行脚本这一步而脚本执行时第一个请求刚把用户加进去第二个请求紧随其后按理说第二个会被拦截。那为什么会出问题因为完整的秒杀流程并不只是脚本那一步。在黑马点评的实现里脚本只是判断和扣减真正的下单操作在 Java 代码里是个包含业务操作和数据库插入的事务方法。如果这个下单操作没有加锁两个请求都执行了脚本每个脚本都扣了库存然后都进数据库去创建订单这时用sismember已经拦截不住因为集合添加发生在脚本里两个请求都添加成功了但数据库里订单也建了两条“一人一单”就被突破了。所以需要在 Java 业务层面再加一道锁保证同一个用户的下单操作在并发下是串行的。这就是分布式锁的用武之地。3.2 锁的演进过程讲到分布式锁很多人的第一反应是 Redisson。但黑马点评的教学路径是循序渐进地让你理解锁的本质我从头到尾跟着走了一遍收获很大。最原始的锁是 JVM 内部的synchronized或者ReentrantLock。这种锁在单机部署下有效但黑马点评的服务是集群部署的多个 Tomcat 实例之间JVM 锁互相不可见。用户请求被负载均衡分发到不同机器机器 A 上的锁根本管不住机器 B 上的并发请求。这就是本地锁的局限。于是引入 Redis 分布式锁。核心思想简单粗暴用一个 Redis key 表示锁。加锁就是setnx只有 key 不存在时才能设置成功谁设置成功了谁就拿到了锁释放锁就是del。但setnx加锁后如果程序崩溃了锁不会被释放其他线程永远拿不到锁。解决方案是给锁加过期时间避免死锁SET lock:user:101 1 NX EX 10NX表示不存在时才设置EX 10表示 10 秒后自动过期。这条命令本身是原子的不用再担心加锁和设置过期时间之间出问题。3.3 锁的误删与原子性问题有了过期时间接下来要考虑的是锁的误删问题。场景是这样的线程 A 拿到锁执行任务但任务执行时间超过了锁的过期时间锁自动释放了。这时候线程 B 拿到锁开始执行。线程 A 执行完后执行del释放锁结果把线程 B 的锁给删掉了。B 刚执行到一半锁没了线程 C 又进来了并发问题再次出现。解决思路是删除锁之前判断这个锁是不是自己的。加锁时给 value 设置一个唯一标识比如 UUID释放时先比较 value一致才删。但这里又有个坑判断和删除是两个操作不是原子的。如果判断通过还没来得及删锁过期了然后另一个线程设置了新的锁刚才的判断就过期了删除会把新锁删掉。所以必须用 Lua 脚本来保证“判断 删除”的原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end到这里一个手工版的分布式锁才算完整加锁用原子命令过期时间防死锁value 唯一标识防误删Lua 脚本保证释放锁的原子性。如果你在工作中不想自己造轮子直接用 Redisson 就行它把看门狗续期、可重入、红锁这些细节都封装好了。但学习阶段自己实现一遍对理解锁的原理非常有帮助。4. 实操过程与关键代码回顾4.1 整体流程梳理我按照教程完成了整个秒杀流程后把代码逻辑重新梳理了一遍。完整的流程大概是这样的用户发起秒杀请求携带 userId 和 voucherId查询优惠券信息判断秒杀是否已经开始、是否已经结束判断库存从 Redis 中读取库存不足直接返回失败执行 Lua 脚本原子性地完成库存扣减和用户记录脚本执行成功返回 0后创建订单并保存到数据库异步或同步返回订单信息给前端。这里有一个很容易忽略的点优惠券的开始时间和结束时间判断是在 Java 代码里做的还是在 Lua 脚本里做的黑马点评的实现里是先查询数据库得到优惠券信息判断时间是否在有效期内再做后续操作。这一步和库存扣减不同时间判断对一致性要求没那么高稍微早一点晚一点影响不大所以放在 Java 代码里完全没有问题。4.2 秒杀业务的核心代码下面对照代码讲一下关键实现。首先是秒杀券下单的 Service 方法我简化掉一些细节Override public Result seckillVoucher(Long voucherId) { // 1. 查询优惠券 SeckillVoucher voucher seckillVoucherService.getById(voucherId); // 2. 判断秒杀是否开始 if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail(秒杀尚未开始); } // 3. 判断秒杀是否结束 if (voucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail(秒杀已经结束); } // 4. 执行 Lua 脚本原子性扣减库存 Long result stringRedisTemplate.execute( SECKILL_SCRIPT, Arrays.asList(seckill:stock: voucherId, seckill:user: voucherId), userId.toString() ); int r result.intValue(); if (r ! 0) { return Result.fail(r 1 ? 库存不足 : 不能重复下单); } // 5. 创建订单 VoucherOrder voucherOrder new VoucherOrder(); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); // 生成雪花算法 ID voucherOrder.setId(idWorker.nextId()); voucherOrderService.save(voucherOrder); return Result.ok(voucherOrder.getId()); }在 Lua 脚本返回 0 之后理论上这个用户已经被记录到 Redis 的 Set 里了所以后续再来的同一个用户请求会在脚本执行时被状态 2 拦住不会重复下单。因此这里其实不再需要额外加分布式锁了。为什么因为脚本已经在 Redis 层面保证了“同一用户不会重复下单”数据库里的订单创建虽然是并发执行但脚本通过sadd已经做了去重在并发情况下只有第一次请求能拿到返回值 0后续请求都返回 2。那分布式锁还要吗如果你只做黑马点评这一版可以不要。但如果你自己扩展成“先判断时间再判断库存再加锁下单”的完整版本锁就是必须的。教程里 Redis 缓存预热 Lua 脚本的方案本质上是用 Redis 的原子性代替了锁这也是一个很值得记住的优化思路。4.3 Redis 与数据库的一致性这块是我学的时候比较困惑的后来才算理顺。Lua 脚本扣了 Redis 库存但如果voucherOrderService.save()失败了数据库订单没有创建成功Redis 库存却已经扣了。用户看到库存减少了但订单没生成这就产生了不一致。教程里没有过多展开这个问题但实际操作中需要考虑补偿。比较常见的手段是创建订单失败时要回补 Redis 库存并移除用户记录。代码改造的思路大概是这样try { voucherOrderService.save(voucherOrder); } catch (Exception e) { stringRedisTemplate.opsForValue().increment(seckill:stock: voucherId); stringRedisTemplate.opsForSet().remove(seckill:user: voucherId, userId.toString()); throw e; }不过真正企业级做法更倾向于用消息队列做异步下单。也就是 Lua 脚本扣库存成功后把“创建订单”的消息发到 MQ消费者异步创建数据库订单。这样同步接口只负责返回“抢购成功”真正的下单异步完成用户体验也会更好。我当时为了把原理吃透先用同步方式跑通再改异步这样对比着学理解更深入。5. 常见问题与排查技巧实录5.1 典型报错与解决我实操时遇到的报错不算少挑几个典型的列出来。报错一Lua 脚本执行报错提示脚本格式错误这个问题大概率是 KEYS 和 ARGV 没有对应上。比如 Lua 脚本里用了KEYS[1]、KEYS[2]但 Java 代码里Arrays.asList()只传了一个 key或者 ARGV 传错了类型导致 Redis 无法把参数解析为字符串。排查时先单独在 Redis 客户端里用eval命令测试脚本能通就说明脚本没问题再去看 Java 代码的传参。报错二decr执行后 redis 返回负数但业务仍然下单成功原因多半是只判断了stock 0没有处理等于 0 的情况。比如库存本来就剩 0decr后返回 -1你判断stock 0会返回失败但如果库存正好 1两个并发都decr一个返回胜利后下单另一个返回 0 后的处理逻辑写错了就会出问题。正确做法是判断返回值是否小于 0而不是是否等于负数。报错三分布式锁释放时报空指针或删锁失败锁被过期自动释放后线程再执行del会返回 0不影响业务。但如果你的代码是用get先判断再del中间锁过期了判断的值可能还是自己的但 del 的时候锁已经变成别人的了这个就是前面说的误删问题。解决方案我已经在前面写过一定要用 Lua 脚本。5.2 压测验证技巧写完代码怎么验证效果我在学习时用 JMeter 做了简单压测思路分享一下。在 Redis 里手动设置库存比如set seckill:stock:1 100清空用户集合del seckill:user:1用 JMeter 开启 200 个线程循环 1 次同时请求秒杀接口压测结束后查看 Redis 库存是否为 0查看数据库订单数量是否为 100假设用户 ID 都不同如果订单数量大于 100说明超卖问题还没解决如果库存是负数说明扣减逻辑有问题。这里要注意如果所有线程都模拟同一个用户 ID 请求那么最终订单数应该只有 1 条这是验证“一人一单”的好办法。我当时分别做了两种压测一种覆盖不同用户 ID 验证库存不超卖一种覆盖同一用户 ID 验证不重复下单。还有一些细节比如压测时 Redis 连接池会被打满需要在配置里调大max-total和max-idle数据库连接池也一样。如果没调压测时会看到大量连接超时那不是业务问题是资源瓶颈。最后的实操体会这一章学下来我最深的感受是秒杀业务代码量不大但每个细节都值得反复推敲。比如 Lua 脚本为什么会返回 0、1、2 三个状态为什么用 Set 记录用户而不是 String为什么脚本运算要在 Redis 端而不是 Java 端——这些问题一开始我都答不上来是自己在 Redis 客户端一点一点敲命令试出来的。学完后我又把“基于 Redisson 实现分布式锁”的版本写了一遍对比两种方案在代码复杂度和执行效率上的差异。如果你也在学这个项目我的建议是不要只盯着教程代码跑通就完事把 Redis 命令手动敲一遍把 JMeter 压测跑一跑把锁和脚本自己实现一遍这些看起来“浪费时间”的事反而是进步最快的地方。遇到问题时顺着“请求从哪来、数据到哪去、失败怎么办”这个思路去排查绝大多数坑都能自己走出来。
阅读完成 · 觉得有帮助?
咨询建站