秒杀系统是很多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,别给自己找麻烦。
我的推荐组合:
| 组件 | 版本/方案 |
|---|---|
| JDK | 1.8 |
| SpringBoot | 2.7.x |
| MyBatis-Plus | 3.5.x |
| Redis | 5.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里不直接操作BaseMapper,MQ消费者不反向调用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; List<String> 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 QueryWrapper<Order>().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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 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`) ) ENGINE=InnoDB; -- 秒杀结果表,用于记录用户是否成功 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`) ) ENGINE=InnoDB;订单号为什么不用数据库自增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) { List<Goods> 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为什么不用
get+decr,而要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,简单省事。写一个Dockerfile:
FROM openjdk:8-jre COPY target/seckill-demo.jar /app/seckill-demo.jar WORKDIR /app ENTRYPOINT ["java", "-jar", "seckill-demo.jar", "--spring.profiles.active=prod"]然后本地构建镜像:
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时再允许发送请求,这样演示效果会好很多,毕竟项目最终是给人看的,视觉冲击力也很重要。