先从自己最熟悉的一个场景说起——我当年学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个线程同时读到stock=1”的情况。但如果同时还要判断一人一单、做下单逻辑,那必须把“判断库存、判断用户资格、扣减库存”这几个命令合并成一个整体,用一个原子性操作去完成,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 DefaultRedisScript<Long> 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数据的最终一致性
秒杀流程里,用户请求先打Redis,Redis扣减库存、登记用户成功后,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确认,否则消息保留待重试
发送消息的伪代码:
Map<String, Object> message = new HashMap<>(); message.put("userId", userId); message.put("voucherId", voucherId); stringRedisTemplate.opsForStream() .add("stream:order", message);消费者端大致流程:
List<MapRecord<String, 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 (MapRecord<String, 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目录下并加上版本注释,万一线上需要改脚本,回溯和灰度都要方便得多。