ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

SpringBoot秒杀系统设计与实现:Redis+MQ防超卖高并发实战

SpringBoot秒杀系统设计与实现:Redis+MQ防超卖高并发实战 秒杀系统是很多Java开发者绕不开的经典项目尤其是用SpringBoot来做几乎是毕业设计和面试项目的标配。我自己做过商城类的秒杀活动也带过团队搞过抢购业务今天就把基于SpringBoot的秒杀系统设计与实现从头到尾捋一遍。这篇文章不会只贴一堆Controller代码而是会把“为什么要这么设计”“库存怎么不超卖”“QPS怎么扛上去”这些核心问题讲透适合正在做毕设、准备校招项目、或者想系统学习高并发场景的朋友参考。1. 秒杀系统的核心挑战与整体设计思路1.1 秒杀到底在“秒”什么所谓秒杀就是在极短时间内集中释放大量并发请求比如一个商品库存100件限时10分钟开抢。普通商城接口的TPS可能就几百而秒杀瞬间可能涌入几万甚至几十万个请求。系统要做的不是处理全部请求而是保证最终“只有100人能成功下单”同时不能让系统崩溃也不能让库存扣成负数。这个场景和普通业务最大的区别是高并发 有限资源 强一致性。如果直接用普通的数据库写流程比如查库存、扣库存、生成订单三步走那数据库连接池瞬间被打爆行锁竞争激烈接口响应超时最终系统雪崩。所以秒杀系统设计的第一原则是把请求挡在数据库前面能用缓存扛就用缓存能用队列削峰就削峰。1.2 设计目标和取舍做秒杀系统前先明确几个硬指标并发支撑至少能应对每秒数千级别的请求这在学校毕设或个人项目中已经很可观。库存不超卖库存扣减必须原子性不能出现卖了101件的情况。重复下单拦截同一用户同一商品只能秒杀一次或按规则限制次数。高可用系统在峰值流量下不宕机或者至少核心下单链路不宕机。这里有一个很重要的取舍强一致性 vs 最终一致性。秒杀成功那一刻用户最关心的是“我抢到没有”至于订单异步处理、支付超时释放库存这些是秒杀之后的事情。所以设计上完全可以用Redis原子递减库存来决定是否抢到再去异步建单。这既保证了“不超卖”又避免把数据库拖垮。1.3 整体架构分层我习惯把秒杀系统拆成四个层次入口层Nginx 前端做静态资源分离、简单限流。应用层SpringBoot提供秒杀接口、用户校验、Redis预扣减、MQ消息发送。缓存层Redis库存预扣、用户标记、热点数据。存储层MySQL订单表、商品表、最终一致性扣减。这种分层的好处是每一层都可以独立扩展比如Redis扛不住可以上集群MQ积压可以加消费者。毕设阶段不用搞微服务单机SpringBoot Redis MySQL RocketMQ/Kafka就已经足够展示核心思路了。2. 基于SpringBoot的技术选型与架构分层2.1 核心依赖版本选择这里我强烈建议SpringBoot用2.7.x不要一上来就选3.x。为什么因为SpringBoot3强制JDK17很多老资料和老项目用的是JDK8你要是照着旧教程做没有一定基础容易踩版本坑。而且如果你要做毕设老师那边环境大概率还是JDK8别给自己找麻烦。我的推荐组合组件版本/方案JDK1.8SpringBoot2.7.xMyBatis-Plus3.5.xRedis5.x/6.x用Jedis或Lettuce消息队列RocketMQ 4.x 或 RabbitMQ 3.x数据库MySQL 5.7/8.0压测工具JMeter 或 Postman 简单脚本有的小伙伴问能不能用Kafka当然可以但如果是毕设RabbitMQ或RocketMQ的秒杀场景案例更多资料更好找。我下面的示例用RabbitMQ因为很多人的Windows环境装起来更简单。2.2 项目目录结构规划别用IDEA默认那种“Controller/service/dao/mapper”三层就完事秒杀项目要有意识地分层体现设计感。我通常这样组织seckill-demo ├── src/main/java/com/example/seckill │ ├── controller # 接口层只做参数接收和结果返回 │ ├── service # 业务层核心秒杀逻辑 │ ├── mapper # MyBatis-Plus接口 │ ├── model/entity # 实体类 │ ├── model/dto # 数据传输对象 │ ├── model/vo # 视图对象 │ ├── config # Redis、RabbitMQ、线程池等配置 │ ├── queue # 消息生产者/消费者 │ ├── interceptor # 登录拦截、限流拦截 │ ├── exception # 统一异常处理 │ └── utils # 工具类 ├── src/main/resources │ ├── mapper # xml文件复杂SQL可以放这里 │ ├── static │ └── application.yml这种分层的好处是Controller里不存在业务逻辑Service里不直接操作BaseMapperMQ消费者不反向调用Controller整个链路是单向的。面试官问你“为什么这样分”你可以回答职责单一便于测试和扩展。2.3 为什么必须引入Redis和MQ很多同学刚做完增删改查会问秒杀系统直接写数据库不行吗我简单算笔账。MySQL单机INSERT能力大概每秒几千行但秒杀场景不只是INSERT还要先SELECT库存、再UPDATE扣减这两个操作涉及行锁同一行数据并发更新时吞吐量会掉到几百。假设你有1万并发数据库直接崩。引入Redis后库存扣减变成decrement操作这是单线程原子操作单节点Redis每秒可以处理10万的decr性能完全够。引入MQ后客户端请求先打到接口接口只负责“原子扣减成功则发送消息”真正写订单表交给MQ异步消费。这样数据库的写入压力被削峰变成平滑的队列消费。一句话总结Redis解决“谁能抢到”的问题MQ解决“怎么落库”的问题。3. 秒杀核心流程与关键技术点拆解3.1 秒杀接口的完整时序一个成熟的秒杀请求应该走这样的链路前端点击按钮携带商品ID和用户标识一般从Token中解析。后端先做基础校验商品是否存在、秒杀是否在有效时间段内、用户是否登录。检查Redis中的用户去重标记比如seckill:user:1001:1防止重复下单。执行Lua脚本原子扣减库存同时写入用户标记位。扣减成功后发送MQ消息消息内容包含用户ID、商品ID、订单编号。数据库最终通过MQ消费者创建真实订单状态为“待支付”。前端进入等待页面轮询或通过WebSocket获取结果。注意第4步为什么用Lua脚本而不是先get再decr因为get和decr是两步操作并发下会出现“判断库存大于0但实际已被其他线程扣完”的问题。Lua脚本是原子的Redis会保证整个脚本执行过程中不会被其他命令插入这才是库存不超卖的第一道保障。3.2 库存预扣减的Lua脚本实现我直接贴出我在项目中实测过的Lua脚本-- KEYS[1] 库存key比如 seckill:stock:1001 -- ARGV[1] 用户标记key比如 seckill:user:1001:1 -- ARGV[2] 用户ID -- 判断是否已秒杀 if redis.call(exists, KEYS[2]) 1 then return 0 -- 重复秒杀 end -- 判断库存 local stock tonumber(redis.call(get, KEYS[1])) if stock 0 then return -1 -- 已售罄 end -- 扣减库存 redis.call(decr, KEYS[1]) -- 设置用户标记 redis.call(set, KEYS[2], ARGV[2]) -- 设置标记过期时间防止内存无限增长 redis.call(expire, KEYS[2], 86400) return 1 -- 秒杀成功这段脚本解决了三个问题重复下单、售罄判断、原子扣减。在SpringBoot中调用它用RedisTemplate的execute方法public Long executeSeckill(Long userId, Long goodsId) { String stockKey seckill:stock: goodsId; String userKey seckill:user: goodsId : userId; ListString keys Arrays.asList(stockKey, userKey); Long result redisTemplate.execute( new DefaultRedisScript(SEC_KILL_SCRIPT, Long.class), keys, userId.toString()); return result; }这里如果你用StringRedisTemplate要注意序列化问题别把整数存成带引号的字符串。我踩过这个坑tonumber转换时因为序列化器不一致直接报错。3.3 在SpringBoot中集成Redis的细节首先pom依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency这一步默认用的是Lettuce连接池但在秒杀场景下需要手动配置线程安全的连接池参数spring: redis: host: localhost port: 6379 timeout: 3000ms lettuce: pool: max-active: 100 max-idle: 20 min-idle: 10 max-wait: 3000msmax-active是最大连接数压测时如果太小会出现连接不够用。但也不是越大越好默认100在单机秒杀项目中基本够用。如果你压测发现报Cannot get Jedis connection优先看这里。3.4 RabbitMQ异步下单的实现要点Redis扣减成功后不直接写数据库而是发送MQ消息。生产者很简单public void sendSeckillMessage(Long userId, Long goodsId) { JSONObject json new JSONObject(); json.put(userId, userId); json.put(goodsId, goodsId); json.put(orderNo, SnowflakeUtil.nextId()); rabbitTemplate.convertAndSend( seckill.exchange, seckill.routingkey, json.toJSONString() ); }这里有个容易忽略的细节消息一定要包含订单号并且生产端生成不在消费端生成。因为秒杀接口返回给前端时需要这个订单号前端轮询查询状态时也要用。消费者端重点处理“幂等性”。为什么消息可能会重复因为RabbitMQ在消费成功后如果还没来得及确认就宕机消息会被重新投递。所以消费者里要判断这个订单号是否已经存在存在就跳过。RabbitListener(queues seckill.queue) public void consume(String msg) { JSONObject json JSON.parseObject(msg); Long userId json.getLong(userId); Long goodsId json.getLong(goodsId); String orderNo json.getString(orderNo); // 幂等判断 if (orderMapper.selectCount( new QueryWrapperOrder().eq(order_no, orderNo)) 0) { return; } // 创建订单 ... }有人会问既然MQ消费是异步的用户秒杀成功后查询订单数据还没写入怎么办这时可以让前端在秒杀成功后延迟几秒再轮询或者查Redis里的临时标记位。我的做法是订单写入成功后在Redis里设置一个seckill:order:orderNo前端通过这个key判断“落库是否完成”避免直接穿透DB。4. 数据库设计与防超卖、防重复的深层处理4.1 秒杀相关表结构设计我建议至少三张核心表-- 商品表 CREATE TABLE goods ( id bigint(20) NOT NULL COMMENT 商品ID, goods_name varchar(100) NOT NULL COMMENT 商品名称, goods_img varchar(500) DEFAULT NULL COMMENT 商品图片, goods_price decimal(10,2) NOT NULL COMMENT 商品原价, seckill_price decimal(10,2) NOT NULL COMMENT 秒杀价, stock_count int(11) NOT NULL COMMENT 总库存, start_time datetime NOT NULL COMMENT 秒杀开始时间, end_time datetime NOT NULL COMMENT 秒杀结束时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE order_info ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint(20) NOT NULL COMMENT 用户ID, goods_id bigint(20) NOT NULL COMMENT 商品ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_goods (user_id, goods_id) ) ENGINEInnoDB; -- 秒杀结果表用于记录用户是否成功 CREATE TABLE seckill_result ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, goods_id bigint(20) NOT NULL, status tinyint(4) NOT NULL COMMENT 1成功, PRIMARY KEY (id), UNIQUE KEY uk_user_goods (user_id, goods_id) ) ENGINEInnoDB;订单号为什么不用数据库自增ID因为并发下通过自增ID反推业务量很危险而且多表关联时不方便。我建议用雪花算法生成分布式ID它的结构是“时间戳 机器ID 序列号”单机也能用将来拆库也不怕冲突。4.2 数据库层兜底防超卖虽然Redis已经做了库存预扣减但数据库最终还是要扣减一次库存。为什么因为Redis宕机或数据丢失后不能拿内存当唯一真相。数据库扣减SQL要这样写UPDATE goods SET stock_count stock_count - 1 WHERE id #{goodsId} AND stock_count 0;注意stock_count 0这个条件这是最后的防线。UPDATE返回影响行数如果等于0说明库存已经被扣完。配合MySQL的行锁这个操作在并发下是安全的但性能不高所以只让它处理MQ消费时的那几个请求而不是处理所有并发请求。4.3 用户重复秒杀的数据库约束Redis里的用户标记能挡住大部分重复请求但万一Redis重启了标记丢失数据库层怎么兜底两个方案seckill_result表加唯一索引(user_id, goods_id)重复插入直接报错捕获DuplicateKeyException。插入前先查询但查和插不是原子的除非做分布式锁。我两个都会加因为代价很小。唯一索引不会影响正常业务只会在极端场景下拦截重复。你要明白一个原则缓存层是第一道门数据库层是最后一道闸。4.4 缓存预热与库存恢复秒杀开始前商品库存要提前放到Redis里。这个动作叫缓存预热。我看到很多人是用户请求来了才去Redis查库存第一次查是空的好吗那就返回售罄了。预热可以写在项目启动的ApplicationRunner里Component public class StockPreheatRunner implements ApplicationRunner { Override public void run(ApplicationArguments args) { ListGoods goodsList goodsMapper.selectList(null); for (Goods goods : goodsList) { if (goods.getStartTime().isBefore(LocalDateTime.now()) goods.getEndTime().isAfter(LocalDateTime.now())) { redisTemplate.opsForValue().set( seckill:stock: goods.getId(), goods.getStockCount()); } } } }如果你的秒杀商品是动态上架的改成一个定时任务每分钟扫描一次最近要开始的商品保证Redis里有库存数据。5. 限流、缓存穿透与压测避坑实录5.1 接口限流怎么做秒杀接口不能让人无限刷。最简单的是Redisincr 过期时间的计数器限流。比如同一个用户1秒最多访问3次public boolean isLimited(Long userId) { String key seckill:limit: userId; Long count redisTemplate.opsForValue().increment(key); if (count ! null count 1) { redisTemplate.expire(key, Duration.ofSeconds(1)); } return count 3; }逻辑很简单第一次访问设置过期时间为1秒后续访问如果超过3次就返回“请求过快”。这个方案在单机下够用如果你要做分布式统一限流可以考虑在Nginx层配limit_req模块或者引入Sentinel。毕设阶段Redis计数器限流已经是很好的方案了还能讲清楚原理。5.2 隐藏秒杀地址的“伪”做法很多教程会让前端先请求一个接口获取动态秒杀地址再拼接真实秒杀URL。这个做法的目的是防止用户提前拿到接口直接刷。说实话这防不了专业爬虫但能防小白用户而且能体现“安全设计”的思路毕设加分项。实现也不难// 生成一个随机token存Redis设置有效期 String path UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set(seckill:path: goodsId : userId, path, 5, TimeUnit.MINUTES);秒杀请求参数里带上这个path后端对比Redis中的值不一致直接拒绝。这只是一种体验优化和安全缓解真正的高并发防护还是要靠限流。5.3 Redis缓存穿透和击穿秒杀开始那一刻如果Redis里没有库存N个请求同时去数据库查库存这就是击穿。解决思路是预热前面已经说了。另外如果Redis里的key过期了或者被删了可以加一个互斥锁只允许一个线程回源数据库public String getGoodsWithLock(Long id) { String key seckill:stock: id; String value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } // 尝试加锁 String lockKey lock:stock: id; Boolean ok redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (Boolean.TRUE.equals(ok)) { try { // 查数据库写缓存 Goods goods goodsMapper.selectById(id); redisTemplate.opsForValue().set(key, goods.getStockCount()); return goods.getStockCount().toString(); } finally { redisTemplate.delete(lockKey); } } else { // 自旋重试 Thread.sleep(50); return getGoodsWithLock(id); } }这段代码在并发情况下有点粗暴但够用。关键是锁的过期时间不能设太小否则业务还没执行完锁就过期了其他线程又进来。这里设的5秒是经验值实际看数据库查询耗时来定。5.4 压测时的惨痛教训我用JMeter压测过一个5000并发的秒杀接口第一次压测接口报了一堆500。排查发现根本不是业务代码的问题而是日志打印太夸张。我在秒杀接口里打了很多info级别的日志每个请求都会输出SQL、缓存key、用户ID结果磁盘IO直接成为瓶颈。后来把秒杀链路里的日志全部降到warn或干脆不打QPS直接翻倍。还有一次压测Pool连接池没配好Redis连接等待时间达到了10秒前端全部超时。所以你在压测前一定要先检查三件事Redis连接池max-active和max-wait是否合理。数据库连接池hikari.maximum-pool-size是否满足MQ消费者并发数。是否关闭了MyBatis-Plus的SQL日志输出。另外JMeter压测时线程数设置不要一次性拉到1万先200、再1000、再5000逐步看系统的拐点在哪里。这样出问题好定位。5.5 秒杀结果前端轮询与超时释放用户秒杀成功后前端会反复轮询订单状态。我的轮询接口只查Redis比如GetMapping(/result) public String getResult(Long userId, Long goodsId) { // 先查是否在排队中 String pathKey seckill:path: goodsId : userId; ... // 查订单是否生成 String orderKey seckill:order: userId : goodsId; Object orderNo redisTemplate.opsForValue().get(orderKey); if (orderNo ! null) { return success: orderNo; } return waiting; }如果用户抢到了但一直不支付库存不能一直占着。我的做法是MQ消费者在创建订单时设置一个延迟消息或定时任务比如10分钟后检查订单状态还是“待支付”就取消订单并回补库存public void releaseStock(Long goodsId) { redisTemplate.opsForValue().increment(seckill:stock: goodsId); // 同时更新DB库存 goodsMapper.increaseStock(goodsId); }回补库存时要注意如果商品已经秒杀结束回补库存意义不大。一般秒杀系统的设计是“超时未支付就释放”但回补只对秒杀期间有效。所以定时任务里要判断当前时间是否在秒杀时间段内。6. 项目扩展点与面试高频问题梳理6.1 基于当前项目能扩展哪些功能如果你要把这个项目做成完整毕设建议再加这几块用户登录校验用JWT生成token拦截器统一校验。订单管理后台查询订单列表、导出Excel、手动取消订单。秒杀商品管理管理员可以创建秒杀场次、上传商品图片。支付模拟对接支付宝沙箱或微信支付模拟接口完善订单状态流转。拦截器或AOP实现接口耗时统计压测后能看每个方法的耗时。这几项不会改变秒杀核心但会让项目显得完整。尤其支付模拟你加上之后订单状态的流转就可以闭环待支付 - 已支付。6.2 面试官常问的秒杀问题如果你拿这个项目去面试至少要把下面几个问题答清楚Redis为什么不用getdecr而要Lua脚本因为非原子操作在并发下会产生超卖Lua脚本能保证检查库存、扣减库存、设置标记在一个原子操作中完成。MQ消费者消费失败怎么办手动ack 重试重试多次后进入死信队列人工介入。缓存和数据库库存不一致怎么办先Redis扣减再异步DB扣减如果DB扣减失败比如影响行数0说明数据库库存已不足需要告警并补偿。这类问题不能完全避免只能靠监控发现后修复。单机Redis挂了怎么办对于毕设项目用Redis持久化AOF来降低数据丢失风险。生产环境会做哨兵或集群Redis节点主从切换但那些是很重的运维成本。6.3 可运行的完整代码结构示例下面给出一段核心Service的骨架代码把前面的逻辑串起来Service public class SeckillService { Autowired private StringRedisTemplate redisTemplate; Autowired private RabbitTemplate rabbitTemplate; Autowired private GoodsMapper goodsMapper; public SeckillResult doSeckill(Long userId, Long goodsId) { // 1. 校验秒杀时间 Goods goods goodsMapper.selectById(goodsId); if (goods.getStartTime().after(new Date())) { return SeckillResult.error(秒杀未开始); } if (goods.getEndTime().before(new Date())) { return SeckillResult.error(秒杀已结束); } // 2. 执行Lua脚本预扣减 Long result redisService.executeStockLua(userId, goodsId); if (result 0) { return SeckillResult.error(您已抢购过请勿重复操作); } if (result -1) { return SeckillResult.error(商品已售罄); } // 3. 发送MQ异步下单 String orderNo snowflake.nextId(); redisTemplate.opsForValue().set( seckill:order: userId : goodsId, orderNo, 5, TimeUnit.MINUTES); mqService.sendSeckillMessage(userId, goodsId, orderNo); return SeckillResult.success(orderNo); } }注意第3步先把订单号写入Redis这个操作要放在发MQ之前。如果先发消息MQ消费完成后Redis还没写入轮询接口查不到结果。顺序不能反。6.4 部署上线要改哪些配置本地写完项目总得上线演示。部署SpringBoot秒杀项目我建议用Docker简单省事。写一个DockerfileFROM openjdk:8-jre COPY target/seckill-demo.jar /app/seckill-demo.jar WORKDIR /app ENTRYPOINT [java, -jar, seckill-demo.jar, --spring.profiles.activeprod]然后本地构建镜像mvn clean package docker build -t seckill-demo:1.0 . docker run -d -p 8080:8080 --name seckill-app seckill-demo:1.0如果你租了云服务器记得在安全组放行8080端口。生产环境的application-prod.yml里Redis地址和密码、MySQL地址都要改成线上的RabbitMQ也要注意到。我见过很多同学在本地跑通了部署到服务器就各种连接超时原因基本都是安全组没放行端口或者Redis只绑定了本地回环地址。7. 复盘那些让我印象深刻的坑7.1 关于库存预热的时间点第一次做秒杀项目时我傻乎乎地在商品开售前10分钟才预热库存。结果开售那一刻Redis里根本没库存所有请求都变成了售罄。排查了半天才发现定时任务的cron写错了时区。后来我改成启动时预热 每小时扫描一次再也没出过问题。你要明白预热不是一次性动作而是“实时跟进”的动作尤其是临时加库存的情况。7.2 关于MQ消息的乱序问题秒杀订单要求不大但如果遇到“取消订单”和“创建订单”两条消息同时发且创建订单还没处理完取消订单先把库存还了会出现逻辑错乱。解决办法是让同一用户的消息进入同一个Queue并且消费者用单线程消费或者增加消息版本号。毕设一般不会遇到这个量级但可以在设计文档中提一句面试时是亮点。7.3 关于JMeter压测与真实场景的区别JMeter模拟的并发请求再多和真实用户行为仍有区别。真实用户有网络延迟浏览器会计算JS网络带宽都有限制而JMeter是直连接口压力更集中。所以我压测时一般按“目标QPS的两倍”去压比如我希望接口能扛住3000 QPS就用6000并发去压看系统在哪个点开始降解或不稳定。压测过程中要同时盯服务器CPU、内存和Redis的ops数。我遇到过CPU才50%就大量请求失败的问题后来发现是应用线程池积压垃圾回收频繁STW。这种时候先加JVM参数java -jar -Xms512m -Xmx512m -XX:UseConcMarkSweepGC seckill-demo.jar秒杀项目本质是“限流 异步 缓存”的综合运用比单纯CRUD有深度也比花里胡哨的微服务项目更接地气。按我上面的链路做完整不管毕业答辩还是面试讲项目你都有足够的技术点可以讲。最后再分享一个小技巧给你的秒杀接口加一个模拟的“秒杀倒计时”前端显示倒计时到0时再允许发送请求这样演示效果会好很多毕竟项目最终是给人看的视觉冲击力也很重要。
返回列表