
先从自己最熟悉的一个场景说起——我当年学Redis练手的时候第一个拿实战项目开刀的就是“黑马点评”里的秒杀环节。这个项目表面上是做优惠券秒杀实际上一整套高并发业务的技术栈基本都被它串起来了缓存预热、分布式锁、Lua脚本、异步削峰、超卖与重复下单控制。面试时被问得最多的“秒杀相关”十有八九都绕不开这里面的设计链条。所以这篇把产品秒杀环节从头到尾拆一遍从需求分析到方案演进再到代码落地和踩坑记录一次性讲透。如果你是刚把Spring Boot和Redis学完、准备拿项目经验写进简历的应届生或者在工作中第一次接触秒杀类业务的CRUD工程师这篇文章应该能帮你少走不少弯路。我尽量把每一步“为什么这么做”也讲清楚不只是给个能跑的代码。1. 秒杀需求分析一个看似简单的下单流程1.1 秒杀业务的典型流程与功能清单很多人觉得秒杀不就是“用户点一下按钮后台扣个库存、生成个订单”吗如果真这么简单就不会有那么多团队在秒杀场景翻车了。先来理清一个完整秒杀流程涉及到的功能点后面所有设计实际上都是针对这些点做加固。黑马点评里的场景是这样的商家发布一张秒杀优惠券设置好库存数量、开始时间和结束时间用户在App或小程序端看到秒杀入口在活动时间点击“抢购”。抢购成功后系统要完成校验库存、校验是否参与过、扣减库存、生成订单这几件事。表面上就五步但每一步在高并发下都有坑。功能清单大概长这样秒杀商品的库存设定与缓存预热秒杀活动时间窗口校验未开始、已结束用户参与资格校验一人一单有些业务是一人限购N单库存扣减与防止超卖订单生成与异常回滚下单请求的削峰与异步化处理这个清单不区分前后台做后端开发时每一行都是一个明确的职责点。如果面试时你能把上面每一项对应的技术选型说出来再配合“为什么”的解释已经能赢过八成候选人。1.2 秒杀环节的核心技术挑战既然流程简单难在哪里难在高并发下保持数据正确性和系统可用性。数据库压力过载如果所有请求直接打到MySQL几万人同时抢购数据库的TPS很快就到达几百几千的瓶颈连接池被打满整个服务雪崩。超卖问题库存只剩1件但10个请求同时通过“库存0”的检查最后可能卖出10单。库存一旦变成负数业务就出事故了。重复下单问题同一个用户同一张秒杀券连续点击抢购按钮在并发下可能产生多个订单。接口响应时间恶化同步下单链路里如果每一单都要写多张表、再同步返回结果高并发下接口RT会线性上升用户的体验就是从“秒杀”变“卡死”。这些问题看着独立实际上全是同一个根源——并发控制。解决思路自然就分两条线一是让数据库别再直接承担流量二是把并发操作变得安全且高效。后面每一节本质上都是在做这两件事。2. 从数据库到Redis秒杀的性能进化2.1 数据库直连方案的局限一开始若直接把库存存MySQL里用一条update语句扣减库存比如update tb_voucher_stock set stock stock - 1 where voucher_id #{id} and stock 0这种写法在低并发下没问题它有条件“stock 0”的限制天然规避了超卖。但要命的是这是行锁和事务行锁的玩法。秒杀期间大量请求同时更新同一行只有抢到锁的事务能提交其他全部在锁等待数据库的行锁竞争会把TPS死死压在几百以内请求一多等待超时、死锁、连接池耗尽全都来了。数据库直连的第二个问题在于“响应时间”。下订单不是只扣一次库存还要查用户、查优惠券、插入订单表、插入订单详情表一个请求动辄涉及七八次SQL操作。即便如此如果项目的QPS只有几十这套也足够但秒杀的QPS经常是瞬间冲到几千上万那就完全不是一个量级了。我的建议是如果做秒杀类设计数据库只用来做最终数据落地所有的高频读和写操作都要前置到更快的一层上去。Redis在这个场景下几乎是标准答案因为它是单线程模型命令操作天然串行化配合原子性操作能把并发问题化解大部分。2.2 缓存预热把库存搬到Redis引入Redis之前先做一件事把秒杀商品的剩余库存提前加载到Redis里。这个动作叫缓存预热。常见做法是在秒杀活动开始前由管理后台触发或者项目启动时加载public void addSeckillVoucher(Voucher voucher) { // 保存优惠券基本信息到数据库 save(voucher); // 保存秒杀库存到Redis stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY voucher.getId(), voucher.getStock().toString()); }key的设计我建议用业务前缀加主键ID例如seckill:stock:1001。这样做的好处是方便按前缀批量查询、排查问题。预热时要注意给key设置超时时间吗这块有争议但我的经验是如果秒杀活动有明确的结束时间可以把key的TTL设置成“活动结束时刻 缓冲时间”。别为了省这点缓存空间去依赖TTL清理否则活动还在进行Redis里的key突然过期等于库存直接蒸发。Redis层的数据读起来快但要知道它和MySQL的库存是两份独立数据后面必须保证最终一致性。稍微大规模一点的系统MySQL里的真实库存还会再做一次兜底校验几乎不会只靠Redis。2.3 Redis单线程模型为什么适合做库存扣减讲个很多人面试被问住的点为什么Redis扣库存不容易超卖因为Redis的命令是单线程依次执行的同时过来的多个请求在Redis内部是排队串行处理。而MySQL是并发执行的带索引的行锁虽然能控制并发但锁竞争开销大、性能上限低。基于Redis的这个特性最粗暴的第一版扣库存方案就是Long stock stringRedisTemplate.opsForValue().decrement(KEY); if (stock 0) { // 库存扣多了要回补 stringRedisTemplate.opsForValue().increment(KEY); }这样的“异步扣减”在单机Redis下绝对不会出现“10个线程同时读到stock1”的情况。但如果同时还要判断一人一单、做下单逻辑那必须把“判断库存、判断用户资格、扣减库存”这几个命令合并成一个整体用一个原子性操作去完成Lua脚本就是为此准备的后面第4部分会重点讲。3. 超卖问题从乐观锁到分布式锁的演进3.1 乐观锁数据库层面的防超卖超卖是并发场景下最经典的问题也最容易让新人踩坑。先说不依赖Redis的传统方案——基于MySQL的乐观锁。给库存表加一个version字段每次更新库存时检查版本号update tb_voucher_stock set stock stock - 1, version version 1 where voucher_id #{id} and stock 0 and version #{oldVersion}这个方案确实能防超卖但代价是并发成功概率大大降低。当大量请求同时进来只有第一个请求能匹配到version并更新成功其余全部失败需要重试或直接返回“已抢光”。这可能误伤很多“手速快但没抢到第一批锁”的用户也容易产生羊群效应。补充一个更轻量的写法把update条件里的version去掉只保留stock 0。这样就不会出现负数同时并发成功概率有所提升因为只要库存没扣完谁抢到行锁谁就能成功。这个方案的核心是“数据库行锁”在兜底本质上是把防超卖交给了数据库的update语句。update tb_voucher_stock set stock stock - 1 where voucher_id #{id} and stock 0这个方案依然存在行锁竞争问题适合并发量没那么极端、又不想引入额外中间件的场景。黑马点评项目里最终在Redis层先做扣减MySQL里的扣减和订单生成走异步两者形成“前置拦截 最终兜底”的双保险。3.2 分布式锁并发控制从单体到集群另一个思路是加锁。JVM里的synchronized或ReentrantLock在单机部署下能解决线程竞争但现在的服务基本都是多节点部署JVM锁在节点A锁住了一个用户节点B照样能放行因为两把锁互相不可见。所以必须把锁放到所有节点都够得着的公共组件上Redis就是最常用的选择。最原始版Redis分布式锁是这样Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, value, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(success)) { // 加锁成功执行秒杀逻辑 try { // ... } finally { stringRedisTemplate.delete(lockKey); } }setIfAbsent就是SETNX命令只有当key不存在时才能设置成功谁设成功谁就拿到了锁。锁的key要设计成跟“锁的粒度”匹配。订单场景下锁粒度是用户维度key可以是lock:order:{userId}。但是若锁的粒度太粗——比如用lock:seckill:{voucherId}——那同一个商品的抢购请求全被串行化系统性能瞬间掉到跟单线程差不多的水平这不是我们想要的。分布式锁这里有三个深坑锁没有设置过期时间如果获取锁的线程在执行过程中宕机了释放锁的代码根本执行不到锁就会一直存在其他请求永远拿不到锁。锁过期时间太短业务还没执行完锁自动释放了其他线程趁虚而入仍然可能造成并发安全问题。锁误删线程A的锁因为业务执行太久自动过期线程B获得了同key的锁此时线程A执行完finally里的delete把线程B的锁删了。解决方式是value带上线程唯一标识删除前先比较value是否一致。这些问题在黑马点评里是这样处理的引入Redisson框架使用它的RLock。Redisson默认提供看门狗机制续期逻辑是只要锁没释放后台会定期把过期时间延长避免“业务没干完锁先没了”的尴尬。RLock lock redissonClient.getLock(lock:order: userId); boolean isLock lock.tryLock(1, TimeUnit.SECONDS); if (!isLock) { return Result.fail(不能重复下单); } try { // 执行一人一单判断与下单逻辑 } finally { lock.unlock(); }注意tryLock的等待时间不要设太大秒杀场景下拿不到锁就快速失败别让用户傻等。3.3 锁的粒度控制与事务的协作用分布式锁之后有人会顺手把“查用户是否有订单 创建订单”整个塞进一个事务方法里然后在整个方法上加锁。这样锁的粒度变大了但容易引发“长事务 长锁”的组合问题事务迟迟不提交连接被占用锁迟迟不释放并发能力打折。更好的做法是锁只保护“检查资格 扣减库存”这个临界区订单的插入等非临界操作放到锁外面去做。或者反过来锁只保护真正需要互斥的代码段而不是把整个方法体都裹住。数据库事务的隔离级别和锁的协作关系也比较容易忽略。如果MySQL默认的RR隔离级别下先查了库存、再扣库存如果查询只是普通的快照读并发情况下仍然会有“读到旧数据”的可能。所以把库存扣减做成update自带条件而不是“先select再update”就是为了避免中间出现快照读导致的错误判断。这个道理放在Redis预扣减那里也一样实际操作时“查-改”这两个动作如果能合并成原子操作就尽量不要拆开。4. Lua脚本让秒杀判断与扣减真正原子化4.1 为什么前面那些方案都没法彻底解决问题上面聊到的分布式锁能保证同一个用户不会重复下单但如果秒杀商品的“库存预减 用户判断 扣减”是由三条独立的Redis命令完成的在高并发下就存在严重的时间窗口线程A执行get stock读到库存为3线程B也执行get stock读到库存为3线程A执行decr stock库存变为2线程B执行decr stock库存变为1如果这两个请求都判断“库存 0”通过了而实际只有一个库存依然会出现“超发一个”的问题。要解决这类并发问题必须保证“读库存、判断、减库存”这三步是一个不可分割的原子操作。在Redis里做原子操作最优雅的方式就是Lua脚本。4.2 秒杀核心Lua脚本逐行解读先给出一版我实际用过的精简脚本黑马点评风格它的作用是检查redis中是否存在该key、判断库存是否充足、校验用户是否已购买过用一个set集合存已购买用户ID最后减库存-- 商品库存key local stockKey KEYS[1] -- 已购买用户集合key local userKey KEYS[2] local userId ARGV[1] -- 判断库存是否存在 if (redis.call(exists, stockKey) 0) then return 1 -- 商品不存在或活动未开始 end -- 判断用户是否已经秒杀过 if (redis.call(sismember, userKey, userId) 1) then return 2 -- 重复下单 end -- 判断库存是否大于0 local stock tonumber(redis.call(get, stockKey)) if (stock 0) then return 3 -- 库存不足 end -- 扣减库存 redis.call(decr, stockKey) -- 记录用户ID到已购买集合 redis.call(sadd, userKey, userId) return 0 -- 秒杀成功这段脚本把“库存校验、用户校验、库存扣减、记录用户”合并到了一个Redis操作里。Redis使用内置的解释器执行整个脚本执行期间不会穿插处理其他命令因此保证了原子性。脚本里的tonumber是必要的因为Redis的GET命令返回的是字符串直接 0比较会出问题。这版脚本对库存的key不存在和key值库存不足做了区分。业务上可以分别返回“活动未开始”和“已售罄”等不同提示。这里有个细节如果活动已经结束更合理的做法是直接把key删除或标记结束。如果只是库存减到0后续所有请求都会卡在“库存不足”分支不会产生超卖。4.3 Java代码调用Lua脚本的完整姿势Spring Data Redis从3.x开始支持直接执行Lua脚本但为了兼容性和可控性我通常使用DefaultRedisScript来执行private static final DefaultRedisScriptLong SECKILL_SCRIPT; static { SECKILL_SCRIPT new DefaultRedisScript(); SECKILL_SCRIPT.setLocation(new ClassPathResource(seckill.lua)); SECKILL_SCRIPT.setResultType(Long.class); }然后在业务方法里调用Long result stringRedisTemplate.execute( SECKILL_SCRIPT, Arrays.asList(seckill:stock: voucherId, seckill:user: voucherId), userId.toString() ); if (result ! 0) { // 根据result值返回提示1活动不存在 2重复下单 3库存不足 } // result 0 才继续后面的下单逻辑有人说每次请求都重新加载脚本太慢。其实Spring Data Redis会缓存DefaultRedisScript对应的脚本内容只有第一次会发送SCRIPT LOAD指令。真正执行时走的是EVALSHA性能开销很小。不要为这个过度优化。Lua脚本返回值的类型要跟setResultType保持一致如果不匹配会执行成功但返回null判断逻辑就跑偏。我遇到过一整个下午排查的“BUG”最后发现是脚本里return了字符串、Java这边却等了Long类型。4.4 Lua的坑与并发下的行为表现Lua脚本里不能使用执行结果作为另一个key的名字这是最常被忽略的限制。因为Redis在集群模式下必须确保一个脚本的所有key都落在同一个slot上如果key是动态拼接的集群路由会直接报错。所以设计上脚本的key集合必须是固定的、由调用方传进来的KEYS数组。还有一点Lua脚本报错会导致整个脚本执行失败并且没有部分执行的概念。比如脚本里先执行了decr后面的sadd因为类型错误失败了那前面的decr一样会被回滚。这个特性让Lua脚本在保证一致性上非常省心。但在Redis主从复制模式下Lua脚本是复制到从节点再执行还是复制结果Redis 3.2以后默认是复制脚本从节点再执行一遍。如果脚本里有time()这类非确定性函数主从数据可能不一致。秒杀场景里我们用的都是普通读写命令问题不大。5. 一人一单并发下如何校验用户是否重复购买5.1 为什么“先查再判断”不可靠秒杀活动经常限制“每人限购1件”。最简单粗暴的校验方式是查数据库订单表里有没有这个用户对这张券的订单记录如果没有才允许创建订单。但并发下这样查是“看起来合理实际会出错”的典型。两个请求同时到达都查到“该用户还没有订单”都通过了校验最终都走到创建订单这一步于是生成两单。这就是经典的check-then-act竞态条件。在黑马点评里最初的实现确实是先查订单表再执行后续逻辑结果测试时用压测工具跑100个并发线程单用户甚至会重复下六七单。这就是为什么必须引入分布式锁或者用Lua脚本合并校验与写入。前面Lua脚本已经用sadd userKey userId做了“一次购买登记”这一步实际上是权限校验的前置它会拦截并发请求中第二个及以后的请求。因为sismember判断和sadd是连续原子操作第二次请求进脚本后先exists判断假如库存已经减过一次第二次同一用户再进来时stock可能还剩很多但sismember直接拦截返回2。5.2 基于Redisson分布式锁的用户维度锁如果不想在Lua脚本中处理用户重复逻辑也可以把“一人一单”校验放回服务层用分布式锁保护。此时锁的key必须是用户维度不能是优惠券维度。粒度设计很关键String lockKey lock:order: voucherId : userId; RLock lock redissonClient.getLock(lockKey);注意这里的key同时包含了voucherId和userId。如果只包含userId那同一个用户抢不同券也会被串行化完全没有必要。如果只包含voucherId等于整个优惠券的秒杀入口被全局锁串行性能极差。锁的获取可以用tryLock(0, 10, TimeUnit.SECONDS)等待时间设0意味着拿不到锁立即放弃返回“正在排队请勿重复提交”。这种快速失败的设计很适合秒杀场景用户在活动页疯狂点击时每个请求都很快返回结果不会堆积。释放锁时用lock.unlock()。如果用的是Redisson它会自动判断持有者防止误删。但注意如果业务代码抛异常且没有捕获要保证finally里一定解锁否则锁泄漏。Redisson的看门狗会续期但不代表你可以依赖它一直续下去。任何锁都是要还的。我遇到过这样一个问题用了Redis分布式锁之后单体服务里面用Transactional标记的service方法锁在事务外导致锁释放了但事务还没提交其他线程进来查数据库订单仍然查不到依旧走了下单逻辑。解决办法是把事务边界控制在锁内部或者先提交事务再释放锁。这个细节在黑马点评的代码里经常被忽略但面试官问起来非常区分水平。5.3 数据库唯一索引兜底分布式锁能防止常规并发问题但没法保证万无一失——比如Redisson出现异常、网络分区、锁自动过期后出现短暂竞态。此时需要数据库做最后一层兜底。给订单表加上(user_id, voucher_id)的唯一索引数据库层重复插入会直接报错表现为“唯一键冲突”应用层捕获这个异常返回“请勿重复下单”就行。这属于经典的三层防护思路入口Lua脚本拦截大部分重复分布式锁拦住窗口期的漏网之鱼数据库唯一索引兜底所有的漏网之鱼。三层全部失效的概率极低而且即使数据库层的冲突发生了充其量只是多接受了一小部分无效请求不会出现真正意义上的多单。5.4 Redis与MySQL数据的最终一致性秒杀流程里用户请求先打RedisRedis扣减库存、登记用户成功后MySQL里的订单表和库存表是异步更新的。如果Redis减了库存但MySQL最终没有同步成功就会出现“Redis显示已售罄但MySQL库存没变”的不一致。在黑马点评的异步下单流程里一般是这样的思路先通过Lua脚本在Redis层面预扣库存预扣成功之后把“创建订单”的消息丢到Stream里后台消费者收到消息后再去MySQL写订单表、扣减MySQL中的库存。如果这一步失败了需要在消费者里做重试。重试超过次数后人工介入或由定时任务对账把差异找出来。这张表的对账逻辑虽不复杂却是最容易出事故的地方。Redis里的库存和MySQL里的库存如果长期不一致会造成实际有货但用户买不了。所以要么消息可靠投递要么每次在活动结束后跑一次对账把两边数据拉平。我自己的习惯是活动结束后的定时任务里做全量对账把MySQL剩余库存覆盖到Redis保证下一轮活动干净起步。6. 消息队列削峰用Redis Stream实现异步下单6.1 同步下单的问题和异步化思路秒杀场景下如果每个请求都“同步”地完成扣库存、创建订单、返回结果那么接口的响应时间完全取决于数据库写盘的耗时。每个请求动辄几十毫秒到上百毫秒。高并发下服务线程被数据库IO大量占用线程池迅速耗尽新请求开始排队RT快速恶化。所以秒杀的经典优化思路是“先承接再处理”用户请求先经过Redis这一层快速校验和预扣库存随后立即返回“抢购成功正在处理订单”真正的订单落库放到异步消费者里去慢慢处理。用户的体验从“等待3秒拿到订单”变成“迅速返回抢购结果、订单稍后生成”。这个异步动作本身不一定要引入Kafka或RabbitMQ。黑马点评这个项目体量还没那么大直接用Redis的Stream类型就可以扮演轻量级消息队列。这样项目里不需要额外部署消息中间件面试时也很容易讲清楚“为什么选Stream而不选MQ”。6.2 Stream的消费者组与消费流程Redis Stream在Redis 5.0引入支持消息持久化、消费者组、消费确认。使用消费者组的核心过程如下生产者把订单消息添加到Stream消费者组按组消费组内多个消费者分摊消息消费成功后发送XACK确认否则消息保留待重试发送消息的伪代码MapString, Object message new HashMap(); message.put(userId, userId); message.put(voucherId, voucherId); stringRedisTemplate.opsForStream() .add(stream:order, message);消费者端大致流程ListMapRecordString, Object, Object messages stringRedisTemplate.opsForStream() .read(Consumer.from(group1, consumer1), StreamReadOptions.empty().count(10).block(Duration.ofSeconds(2)), StreamOffset.create(stream:order, ReadOffset.lastConsumed())); for (MapRecordString, Object, Object msg : messages) { try { // 创建订单、扣减MySQL库存 handleOrder(msg.getValue()); // 确认消费 stringRedisTemplate.opsForStream().acknowledge(stream:order, group1, msg.getId()); } catch (Exception e) { // 记录到pending列表后续重试或转入死信 } }消费者启动后要靠while(true)循环或者Spring的定时任务去拉消息实际工程里建议用单独的线程池跑消费者避免跟Web请求线程互相干扰。消费者拉取消息的count不宜设置太大否则一批消息处理时间过长消费者组内其他消费者都在等待空闲。10到50是比较合适的区间。6.3 消息可靠性XACK、Pending与死信处理Stream之所以比普通的List更适合做消息队列核心在于它的消费者组有“消费确认”机制。消费者读取消息后消息会进入Pending Entries List只有消费者显式发送XACK消息才会从Pending中移除。如果消费者处理消息时崩溃消息就留在Pending里重启后可以通过XREADGROUP GROUP group consumer COUNT 10 STREAMS key 0去读取这些未确认消息并重新处理。具体到秒杀场景消息的重试要防“重复下单”。消费者重复处理同一条订单消息时唯一索引和用户消费记录就可以派上用场。处理消息前先查一下订单表是否已有该用户和该券的订单有就直接确认消费避免重复创建订单。这里的兜底逻辑和前面5.3说的一致。如果消息一直消费失败当下的Redis Stream没有像Kafka那样天然支持死信主题需要自己处理。一个简单的方案是把失败次数超过N的消息转存到一个单独的key里比如stream:order:dead, 然后配合定时任务或人工去检查和修复。6.4 Stream对比MQ的取舍面试经常会被问“为什么不用RabbitMQ或Kafka”。给一个务实的回答对于课程项目和中小规模秒杀业务Redis Stream完全够用它减少了系统组件数运维成本低。而真正的互联网级秒杀系统流量和可靠性要求都非常高会引入专业消息队列靠它来做削峰填谷、异步解耦和故障恢复。但“够用”不等于“没门槛”Stream坏消息堆积、消费者组偏移量等等同样会出问题。所以在代码里要对Stream的消息ID、消费位点做监控一旦发现pending积压或lag超过阈值就要报警。这块虽然是小细节但在生产环境里往往决定系统的稳定性。7. 面试高频问题与实操坑点整理7.1 黑马点评秒杀环节的面试问法把“黑马点评秒杀”写进简历后面试官几乎必定会问下面这些秒杀流程是怎么设计的Redis和MySQL各负责什么库存超卖是怎么解决的为什么直接update库存还会超卖Lua脚本在秒杀里解决了什么问题脚本是怎么写的一人一单怎么实现为什么不用synchronized分布式锁用的什么为什么不用SETNX自己写如果有人恶意刷接口怎么处理如果Redis扣了库存但MySQL订单创建失败怎么办为什么不直接用RabbitMQ要用Redis Stream针对最后一个“为什么不直接上MQ”我的建议是不要只说“项目简单用Stream就够了”这会让人觉得你没有宏观意识。更完整的回答是先说轻量级场景的取舍再说大规模场景下MQ带来的好处消息堆积能力强、消费端可以根据能力拉取、支持延迟消息和死信队列。最后补一句“如果秒杀的量级达到每秒数万那Stream的磁盘存储和消费性能远不如专业的Kafka/RabbitMQ我会选择后者”——这样的回答有深度且不偏激。7.2 部署层面最容易被忽略的两个问题秒杀系统的部署和代码同样值得关注。Redis如果单节点挂掉整个秒杀活动直接瘫痪。生产环境至少做主从加哨兵或Cluster模式。黑马点评本身只是教学项目single node也行但你在简历上写“秒杀模块”时至少要能说出Redis高可用的方案。第二个是数据库连接池的配置。异步消费者在创建订单时会大量占用数据库连接连接池上限要调到一个合理的值。太小会导致消费者排队太大又可能压垮数据库。我一般从20开始压测逐步往上调整直到数据库CPU和连接池情况都稳定为止。7.3 我自己踩过的一些坑坑一库存预热时机不对。上线后才发现活动开始前Redis里还没有库存key所有请求都进了“活动不存在”分支。原因是预热代码写在了项目启动的CommandLineRunner里而活动是运营后台动态配置的。正确做法是运营配置活动时同步预热或者在活动开始前的定时任务里处理。坑二Lua脚本返回值搞错类型。像前面说的脚本里返回了redis.call(get, stockKey)的值是字符串Java里却期待Long结果判断一直失败。找了好半天才意识到是类型不一致。排查方式很简单先直接在redis-cli里执行脚本看返回类型再排查代码。坑三异步消费者并发太高导致数据库连接池爆了。线程池大小设成20但每个线程处理消息都要开事务写多张表数据库连接池跑了不到一分钟就满了。后来把线程池核心线程数调小并且给数据库层加了一层剥峰也就是用批量插入代替单条插入连接压力明显下降。坑四Redis key过期时间过短。活动刚开始Redis里库存key因为设置了10分钟TTL到期了秒杀页面直接展示“活动不存在”。加了一个定时任务在活动期间定期刷新key的TTL再没出现过这个问题。7.4 简历里怎么写秒杀项目简历上的项目描述建议按“业务痛点 技术方案 最终效果”来写重点突出你自己的思考。错误写法负责开发秒杀功能使用Redis和Lua脚本实现库存扣减。推荐写法设计了高并发秒杀接口引入Redis预减库存与Lua脚本原子扣减结合Redisson分布式锁实现一人一单再通过Redis Stream异步生成订单压测QPS约800超卖率为0。QPS这个数字不要虚标自己压测过多少就写多少。面试官看到800是合理的看到8万反而会觉得有问题。最好把压测环境也记一下比如“单机8核16G、Redis单节点、压测工具JMeter”这样更能体现你真正跑过。再附上一点面试时描述秒杀项目一定不要只背流程要按“如果一个环节没有做好会导致什么后果”的方式解释。例如“如果没有Lua脚本三个命令就不是原子的可能出现超卖”或者“如果没有唯一索引兜底极端情况下可能通过分布式锁的短暂失效窗口造成重复订单”。这一套连招打下来面试官基本能确认你是真做过或者真研究过而不是只背了个项目视频。做完这一整个方案我在自己的项目里体会到最深的一点是秒杀没有银弹它的核心是“分而治之”。流量太大就分流数据竞争激烈就原子化耗时操作就异步化。每一层都有各自的职责少一层会出事故多一层会降低可用性。把这些职责理清楚了再落到代码里心里的把握完全不同。最后再顺手小技巧把Lua脚本文件单独放在resources目录下并加上版本注释万一线上需要改脚本回溯和灰度都要方便得多。