☰
校招秒杀系统第一版:从能跑通到能扛住追问的架构设计
2026/10/13 7:35:29 网站建设 项目流程

1. 校招项目为什么都爱拿秒杀系统练手

每年校招季,简历上出现频率最高的项目类型里,秒杀系统绝对排得进前三。我面过不少应届生,也帮几个学弟学妹看过简历,十份里有六七份写着"高并发秒杀系统",但真正能讲清楚里面门道的,可能连两成都不到。大部分人只是跟着教程敲了一遍,知道要用Redis、要用消息队列,但问他为什么这么设计、不用会怎样、流量到底卡在哪一环,就答不上来了。

这篇东西就是写给这类同学的。假设你正准备做第一个版本的秒杀系统,或者已经做完但感觉只是"跑通了"而没真正理解,那接下来的内容应该能帮你把这套东西从"能演示"提升到"能讲明白、能扛住追问"的程度。我下面讲的这套方案,是一个最小可用但结构完整的初版设计,不追求一步到位做成工业级,而是把核心链路和关键取舍讲透,让你在面试时能说出自己的思考过程,而不是背答案。

先说清楚这个系统要解决什么问题。秒杀的本质是:在极短的时间内,大量用户同时抢购少量商品,系统要保证不超卖、不宕机、响应快。这三个目标互相拉扯——要快就可能牺牲一致性,要稳就可能牺牲吞吐。第一版的目标不是把三个都做到极致,而是先搭出一个能跑通核心流程、并且知道瓶颈在哪的骨架。适合谁看?适合刚学完Spring Boot、懂一点Redis和MySQL、想做一个能拿得出手的校招项目的同学。不需要你有分布式经验,但至少要能看懂基本的数据库操作和HTTP接口。

我见过太多人一上来就堆技术栈,什么Nacos、Sentinel、RocketMQ全往上招呼,结果连最基本的库存扣减都写错了。所以这个第一版,我建议先把单机链路做扎实,再考虑扩展。下面从整体设计思路开始拆。

2. 第一版整体架构与核心取舍

2.1 为什么第一版不建议直接上微服务

很多教程一上来就教你拆服务:商品服务、订单服务、库存服务、用户服务,每个独立部署,用注册中心串起来。听起来很专业,但对校招项目来说,这是典型的过度设计。原因有三点。

第一,拆成微服务之后,你本地调试的成本会急剧上升。原本一个进程里打个断点就能跟完的流程,现在要同时启动四五个服务,还要保证注册中心、配置中心都正常,光是环境问题就能耗掉你一半时间。第二,分布式事务会立刻变成绕不开的坎。库存扣了订单没生成怎么办?订单生成了库存没扣怎么办?这些问题在单机事务里一个@Transactional就解决了,拆开之后你得引入消息队列做最终一致性,复杂度翻倍。第三,面试官真正关心的不是你用了多少组件,而是你对并发和一致性的理解。一个单机但逻辑严密的秒杀系统,比一个组件堆砌但漏洞百出的分布式系统更能加分。

所以第一版的架构我建议是这样:一个Spring Boot应用,内部按模块分层(controller、service、dao),外部依赖MySQL存商品和订单,Redis做缓存和库存预扣。所有逻辑跑在一个进程里,用本地锁加Redis原子操作保证并发安全。等这套跑通了,你再考虑把订单模块拆出去,那时候你对瓶颈的理解会深刻得多。

2.2 核心链路的四个阶段

秒杀请求从进来到结束,可以拆成四个阶段,每个阶段的处理策略完全不同。

第一阶段是流量拦截。秒杀开始前,用户会疯狂刷新页面,这些请求大部分根本不该到达后端。第一版我们至少要做两件事:前端按钮置灰加倒计时,后端接口做限流。限流可以用简单的令牌桶或者计数器,Guava的RateLimiter就够用,不用上Sentinel。

第二阶段是库存判断与预扣。这是整个系统最核心的地方。绝对不能直接查MySQL然后扣减,那样数据库会被打爆。正确做法是把库存预热到Redis,用Redis的原子操作做预扣。这里有个关键点:Redis扣减成功不代表最终一定买到,它只是"占了个坑",后续还要落库。

第三阶段是订单创建。预扣成功后,生成订单号,把订单信息写入消息队列或者直接异步落库。第一版为了简单,可以先用线程池异步处理,不一定非要上RocketMQ。但你要知道,这一步必须异步,否则同步写库的耗时会拖垮整个接口。

第四阶段是结果返回与兜底。用户需要知道自己是抢到了还是没抢到。这里可以用轮询或者长连接查询结果。同时要有一个兜底机制:如果异步落库失败了,要把Redis里预扣的库存加回去,否则就会出现"库存扣了但没订单"的脏数据。

这四个阶段对应到代码里,就是限流器、Redis扣减、异步订单处理、结果查询接口。下面逐个拆开讲。

2.3 数据一致性的底线思维

第一版可以不追求强一致,但必须守住一条底线:不能超卖。超卖是秒杀系统最严重的问题,没有之一。用户付了钱你却说没货,这是事故。相比之下,"少卖"(库存没扣完但活动结束了)虽然也不好,但严重程度低得多。

所以设计时的优先级是:防超卖 > 高吞吐 > 用户体验。防超卖靠的是Redis的原子性。Redis单线程执行命令,DECR、INCR、DECRBY这些操作天然原子,不会出现两个请求同时读到库存为1然后都扣成0的情况。但要注意,GET再SET这种组合操作不是原子的,必须用DECRBY或者Lua脚本。这是很多人踩的第一个坑。

至于MySQL和Redis之间的数据同步,第一版可以接受短暂不一致,只要最终能对上就行。具体怎么对,后面实操部分会讲。

3. 核心模块的细节实现与避坑

3.1 库存预热:什么时候把数据放进Redis

库存预热是整个秒杀的前置动作。活动开始前,你得把商品库存从MySQL读到Redis里。这里有几个细节容易被忽略。

首先是预热时机。不能等第一个请求来了才去读数据库,那样第一个请求会特别慢,而且如果并发高,可能一瞬间几百个请求同时去查库。正确做法是在活动开始前几分钟,由一个定时任务或者手动触发的接口把库存写进Redis。写的时候用SET命令,key设计成seckill:stock:{商品ID},value就是库存数。

其次是预热的数据要加过期时间。很多人忘了这一步,结果活动结束很久了,Redis里还堆着一堆没用的key。建议过期时间设为活动结束后一小时,既保证活动期间不会失效,又能自动清理。但要注意,如果活动可能延时,过期时间要留足余量,否则活动还没结束key就没了,所有请求都会穿透到数据库。

第三个细节是预热失败的处理。如果Redis写失败了怎么办?我的做法是记录日志并告警,同时让活动延迟开始。宁可晚几分钟,也不能让库存状态不确定。这里可以用一个简单的标志位,预热成功才把活动状态置为"可开始"。

注意:预热时不要用SETNX,因为如果之前有残留的key,SETNX会失败导致库存没更新。直接用SET覆盖,或者先DEL再SET。

3.2 Redis扣减库存:原子操作怎么写才不出错

扣减库存的代码看起来简单,但写法差异很大。我先给一个错误的示范,这是我在别人代码里真实见过的:

// 错误写法 Integer stock = redisTemplate.opsForValue().get("seckill:stock:" + goodsId); if (stock > 0) { redisTemplate.opsForValue().decrement("seckill:stock:" + goodsId); // 创建订单... }

这段代码的问题在于get和decrement是两步操作,中间可能被其他线程插入。假设库存是1,两个线程同时get到1,都判断大于0,然后都执行decrement,结果库存变成-1,超卖了。这就是典型的检查后执行竞态。

正确写法有两种。第一种是用decrement的返回值判断:

Long remain = redisTemplate.opsForValue().decrement("seckill:stock:" + goodsId); if (remain >= 0) { // 扣减成功,创建订单 } else { // 库存不足,把多扣的加回去 redisTemplate.opsForValue().increment("seckill:stock:" + goodsId); // 返回秒杀失败 }

这种写法利用decrement的原子性,先扣再判断。如果扣完是负数,说明库存不够,再加回去。注意加回去这一步也要考虑并发,但因为是increment原子操作,不会出错。这种写法在高并发下会有大量请求把库存扣成负数再加回来,虽然结果正确,但Redis压力大。

第二种写法是用Lua脚本,把判断和扣减合成一个原子操作:

-- check_and_decr.lua local stock = redis.call('GET', KEYS[1]) if stock and tonumber(stock) > 0 then redis.call('DECR', KEYS[1]) return 1 else return 0 end

Java里通过DefaultRedisScript执行这个脚本。Lua脚本在Redis里是原子执行的,不会被打断,所以不存在竞态。这种写法更优雅,推荐第一版就用这个。唯一要注意的是脚本里的key要用KEYS传,不要拼在脚本字符串里,否则Redis集群模式下会报错。

3.3 一人一单:怎么防止同一个用户重复抢

秒杀通常限制每个用户只能抢一件。这个限制如果只在前端做,随便改个请求就能绕过。后端必须做校验。第一版的简单做法是用Redis的SETNX:key设计成seckill:order:{活动ID}:{用户ID},抢购前先SETNX,成功才继续,失败说明已经抢过了。

但这里有个坑:如果用户抢到了但后续订单创建失败,这个SETNX的key还在,用户就再也抢不了了。所以要么在订单创建失败时删掉这个key,要么给key设置一个合理的过期时间。我建议两者都做,过期时间设为活动时长,失败时主动删除。

另一个思路是用数据库的唯一索引。订单表上建一个(user_id, goods_id)的唯一索引,插入时如果冲突就说明重复了。这种方案更可靠,但依赖数据库,性能不如Redis。第一版可以两个都用:Redis做第一道拦截,数据库唯一索引做最终兜底。

3.4 异步下单:线程池还是消息队列

预扣库存成功后,要创建订单。这一步如果同步做,用户请求的响应时间会包含数据库写入的时间,通常几十毫秒,高并发下会拖慢整体吞吐。所以必须异步。

第一版我建议用线程池,简单直接。定义一个固定大小的线程池,把订单创建任务提交进去。但要注意几个问题。第一,线程池的队列不能是无界的,否则任务堆积会撑爆内存。用有界队列加拒绝策略,拒绝时把库存加回去。第二,线程池大小要合理,太大反而会因为数据库连接竞争导致性能下降。一般设为CPU核数的2到4倍,再根据压测调整。第三,要保证任务不丢,如果应用重启,队列里的任务就没了。第一版可以接受这个风险,但你要知道这是缺陷,后续可以用消息队列的持久化来解决。

如果要用消息队列,RocketMQ的事务消息是比较合适的方案,但配置和运维成本高。第一版用线程池加一张"订单任务表"也能达到类似效果:提交任务前先往任务表插一条记录,异步处理成功后更新状态。这样即使重启,也能通过扫描任务表恢复未完成的任务。这个方案比纯线程池可靠,又比消息队列简单,适合校招项目。

3.5 接口限流:别让无效请求打到核心链路

限流的目的不是防黑客,而是防止正常用户的重复点击把系统打垮。秒杀开始的一瞬间,很多人会疯狂点按钮,即使前端置灰了,也有人用脚本刷。所以后端必须限流。

第一版用Guava的RateLimiter就够了。在Controller方法入口加一道限流,比如每秒放行500个请求,超出的直接返回"活动太火爆,请稍后再试"。这个限流是单机的,如果部署多台机器,总限流值就是单机值乘以机器数。第一版单机部署,不用考虑这个问题。

限流的粒度可以按用户来,也可以全局。按用户限流更公平,但需要维护每个用户的计数器,内存开销大。全局限流简单,但可能被少数脚本用户占满额度。第一版建议全局限流加IP黑名单,简单有效。IP黑名单可以用Redis记录每个IP的请求频率,超过阈值就拉黑一段时间。

提示:限流的阈值不要拍脑袋定,要用压测工具测出单机能承受的QPS,然后取70%作为限流值,留30%的余量应对突发。

4. 完整实操流程与关键代码

4.1 环境准备与依赖配置

先把基础环境搭起来。需要的东西不多:JDK 8或11、Maven、MySQL 5.7以上、Redis 5以上。Spring Boot版本用2.7.x比较稳,不要用3.x,因为3.x对JDK版本要求高,而且很多教程还没跟上。

Maven依赖里,核心是这几个:spring-boot-starter-web、spring-boot-starter-data-redis、mybatis-plus-boot-starter(或者原生MyBatis)、guava。连接池用lettuce,它是Spring Boot默认的Redis客户端,基于Netty,性能比Jedis好。

配置文件里要设置Redis的连接池参数。默认的配置在高并发下不够用,需要调整:

spring: redis: host: 127.0.0.1 port: 6379 lettuce: pool: max-active: 200 max-idle: 50 min-idle: 20 max-wait: 1000ms

max-active设为200是根据经验值,一般单机Redis能承受几百个并发连接。设太小会导致请求排队,设太大反而增加Redis负担。max-wait设为1000毫秒,超过就抛异常,避免请求无限等待。

数据库连接池用HikariCP,Spring Boot默认就是它。连接数设为20左右,因为异步下单的线程池大小有限,不需要太多数据库连接。

4.2 库存预热接口的实现

写一个预热接口,在活动开始前调用。这个接口要做三件事:从数据库读商品库存、写入Redis、设置活动状态。

@PostMapping("/seckill/preheat/{goodsId}") public Result preheat(@PathVariable Long goodsId) { // 1. 查数据库 Goods goods = goodsMapper.selectById(goodsId); if (goods == null || goods.getStock() <= 0) { return Result.fail("商品不存在或库存不足"); } // 2. 写入Redis,设置过期时间 String stockKey = "seckill:stock:" + goodsId; redisTemplate.opsForValue().set(stockKey, goods.getStock(), 2, TimeUnit.HOURS); // 3. 设置活动状态为可开始 redisTemplate.opsForValue().set("seckill:status:" + goodsId, "READY"); return Result.success("预热完成,库存:" + goods.getStock()); }

这里过期时间设为2小时,假设活动持续1小时,留1小时余量。活动状态用单独的key存,秒杀接口先检查状态,不是"READY"就直接拒绝。

预热接口本身要做权限控制,不能让人随便调。第一版可以用一个简单的密钥校验,请求头里带一个预共享的token,对了才执行。虽然简单,但比裸奔强。

4.3 秒杀核心接口的完整实现

这是整个系统最核心的代码。我把它拆成几步,逐步说明。

第一步,检查活动状态和参数合法性。用户ID从登录态里取,不要从请求参数里取,否则可以伪造。商品ID校验是否存在。

第二步,限流。用RateLimiter的tryAcquire方法,拿不到令牌直接返回失败。

第三步,一人一单校验。用SETNX设置用户抢购标记,key带过期时间。

第四步,Lua脚本扣减库存。扣减成功返回1,失败返回0。

第五步,扣减成功后,提交异步订单任务。如果提交失败(线程池拒绝),把库存加回去,删掉用户标记。

public Result seckill(Long userId, Long goodsId) { // 1. 检查活动状态 String status = (String) redisTemplate.opsForValue().get("seckill:status:" + goodsId); if (!"READY".equals(status)) { return Result.fail("活动未开始或已结束"); } // 2. 限流 if (!rateLimiter.tryAcquire()) { return Result.fail("当前人数过多,请稍后再试"); } // 3. 一人一单 String userKey = "seckill:user:" + goodsId + ":" + userId; Boolean first = redisTemplate.opsForValue().setIfAbsent(userKey, "1", 2, TimeUnit.HOURS); if (Boolean.FALSE.equals(first)) { return Result.fail("您已经参与过本次秒杀"); } // 4. Lua脚本扣减库存 Long result = redisTemplate.execute( checkAndDecrScript, Collections.singletonList("seckill:stock:" + goodsId) ); if (result == null || result == 0) { // 库存不足,删掉用户标记,让用户下次还能抢(如果还有库存的话) redisTemplate.delete(userKey); return Result.fail("商品已售罄"); } // 5. 提交异步订单任务 try { orderTaskExecutor.submit(() -> createOrder(userId, goodsId)); } catch (RejectedExecutionException e) { // 线程池满了,回滚库存和用户标记 redisTemplate.opsForValue().increment("seckill:stock:" + goodsId); redisTemplate.delete(userKey); return Result.fail("系统繁忙,请稍后再试"); } return Result.success("抢购成功,正在生成订单"); }

这段代码有几个细节值得说。第一,库存不足时删掉用户标记,是因为用户这次没抢到,不应该阻止他下次再试(如果后续有人取消订单释放库存的话)。第二,线程池拒绝时回滚库存,保证不超卖。第三,返回给用户的是"正在生成订单",不是"订单已创建",因为异步任务可能还没执行完。

4.4 异步订单创建与失败补偿

异步任务里做两件事:写订单到数据库、更新Redis里的订单状态供用户查询。

private void createOrder(Long userId, Long goodsId) { String orderNo = generateOrderNo(userId, goodsId); try { // 写数据库 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setGoodsId(goodsId); order.setStatus(0); // 待支付 orderMapper.insert(order); // 更新Redis订单状态 redisTemplate.opsForValue().set( "seckill:order:result:" + userId + ":" + goodsId, orderNo, 2, TimeUnit.HOURS ); } catch (Exception e) { // 落库失败,回滚库存和用户标记 redisTemplate.opsForValue().increment("seckill:stock:" + goodsId); redisTemplate.delete("seckill:user:" + goodsId + ":" + userId); log.error("订单创建失败,已回滚库存", e); } }

订单号生成用"时间戳+用户ID后四位+随机数"的格式,保证唯一且有序。数据库插入可能因为唯一索引冲突失败(同一用户重复下单),这时候也要回滚。

用户查询结果的接口就是读seckill:order:result:{userId}:{goodsId}这个key,有值就返回订单号,没值就返回"处理中"。前端轮询这个接口,拿到订单号就跳转到支付页。

4.5 压测验证与参数调优

代码写完了,必须压测。不压测的秒杀系统等于没写。用JMeter或者wrk,模拟1000个并发用户同时请求。

第一次压测大概率会出问题。常见的是Redis连接池不够,报"Could not get a resource from the pool"。这时候把max-active调大,或者检查有没有连接泄漏。另一个常见问题是线程池队列满了,大量请求被拒绝。这时候要么调大队列,要么调大线程池,但根本的解决办法是优化订单创建的速度,比如批量插入。

压测时重点看三个指标:QPS、响应时间、错误率。QPS上不去,说明有瓶颈,通常是Redis或数据库。响应时间突然飙升,说明有锁竞争或者GC。错误率超过1%,说明有逻辑问题或者资源不足。

我自己的经验是,单机4核8G的机器,这套方案能跑到2000到3000 QPS,再高就要考虑集群了。但校招项目能讲清楚这个数字是怎么来的,比数字本身更重要。

5. 常见问题排查与面试追问应对

5.1 超卖了怎么排查

超卖是秒杀系统最严重的问题。如果发现库存扣成负数,按这个顺序排查。

先看Redis扣减是不是原子操作。如果用了get再decrement,那就是竞态导致的,换成Lua脚本。再看有没有其他地方直接操作库存,比如管理后台的补货接口,如果它直接改MySQL而没同步Redis,就会导致两边不一致。最后看回滚逻辑,如果回滚时加库存加多了,也会超卖。回滚必须是精确的,扣多少加多少。

还有一种隐蔽的超卖:Redis扣减成功,但异步任务提交失败后回滚了库存,然而用户标记没删,用户重新抢的时候又扣了一次。这种要保证回滚时库存和用户标记同时处理,要么都成功要么都失败。

5.2 库存少卖了怎么处理

少卖比超卖好处理,但也要知道原因。常见原因是Redis扣减成功但订单创建失败,回滚了库存,但用户标记没删,导致这个用户不能再抢,而其他用户可能也没抢到。解决办法是回滚时把用户标记也删掉。

另一个原因是活动结束时Redis里还有库存,但没人抢了。这属于正常现象,不用处理。如果库存剩很多,说明预热的数量不对,或者限流太严。

5.3 面试官会追问的几个问题

面试官问秒杀系统,通常不是看你用了什么技术,而是看你的思考深度。下面几个问题几乎必问。

"为什么用Redis不用数据库直接扣减?"答:数据库行锁在高并发下会成为瓶颈,而且数据库连接数有限,大量请求排队会导致响应时间飙升。Redis单线程原子操作,性能高几个数量级。

"Redis扣减成功但订单创建失败怎么办?"答:回滚库存和用户标记,保证数据一致。同时记录日志,人工介入排查。

"怎么保证一人一单?"答:Redis的SETNX做第一道拦截,数据库唯一索引做兜底。两层防护,防止绕过。

"限流阈值怎么定的?"答:压测得出单机最大QPS,取70%作为阈值,留余量应对突发。

"如果Redis挂了怎么办?"答:第一版没有高可用方案,这是缺陷。后续可以用哨兵或者集群。但即使Redis挂了,也不能超卖,所以数据库的唯一索引和库存字段的乐观锁是最后一道防线。

5.4 常见问题速查表

问题现象可能原因排查方向解决办法
库存扣成负数扣减非原子操作检查是否用get+decr改用Lua脚本或decr返回值
用户重复下单一人一单失效检查SETNX是否设置成功加数据库唯一索引兜底
接口响应慢同步写库检查订单创建是否异步改用线程池或消息队列
请求大量失败限流过严查看限流阈值压测后调整阈值
Redis连接超时连接池不足查看连接池配置调大max-active
订单丢失异步任务未持久化检查线程池队列加任务表或消息队列

提示:这张表建议打印出来贴在显示器旁边,出问题先对照排查,能省很多时间。

6. 第一版之后可以怎么扩展

第一版跑通之后,你会对瓶颈有更清晰的认识。这时候再考虑扩展,方向就明确了。

如果瓶颈在Redis,可以考虑读写分离或者集群。但要注意,集群模式下Lua脚本的key必须在同一个slot,所以key的设计要加hash tag,比如seckill:stock:{goodsId},用花括号保证同一商品的key落在同一节点。

如果瓶颈在数据库,可以把订单表分库分表,按用户ID取模。但第一版数据量小,没必要。

如果瓶颈在应用层,可以把秒杀接口单独拆出来部署,和普通接口隔离,避免互相影响。

还有一个方向是引入消息队列做削峰。把订单创建任务发到MQ,下游消费者按自己的能力消费。这样即使瞬间流量很大,也不会压垮数据库。但MQ的运维成本高,校招项目如果用了,一定要能讲清楚为什么用、不用会怎样。

我个人建议,第一版做完之后,先别急着加组件,而是把现有代码的边界情况测一遍:Redis挂了会怎样、数据库慢了会怎样、线程池满了会怎样。把这些搞明白,比多学一个中间件有价值得多。面试的时候,面试官更想听到你说"我知道这里有问题,但第一版为了简单先这样,后续可以怎么改",而不是"我用了十个组件所以我很牛"。

最后分享一个我踩过的坑:预热库存的时候,一定要确认数据库里的库存和Redis里的一致。我有一次预热完忘了更新数据库,结果活动结束后对账发现Redis扣了100件,数据库还是200件,差了整整一倍。后来我加了一个对账任务,活动结束后自动比对两边数据,不一致就告警。这个习惯救了我好几次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询