
简介一套基于Spring Boot 2.x的秒杀系统教学项目面向想要了解高并发抢购场景的Java开发者重点演示令牌桶限流、缓存预热、分布式架构、消息队列、验证码防刷等关键技术点的落地方式。压缩包共包含66个文件以23个Java源码文件为核心另有SQL脚本用于初始化库表与测试数据前端页面HTML/CSS/JS辅助展示交互效果还有Maven配置、启动脚本及README说明整体大小仅2.34MB结构清晰便于导入开发环境后逐模块阅读。已有162人学习过该资源适合作为并发编程的实战入门参考。通过该工程可以学习到一个完整秒杀系统的数据模型与目录组织方式观察商品库存如何通过缓存和数据库优化应对压力同时借助附带的页面截图和运行演示能够更直观地理解请求排队、异步处理以及验证码校验在项目中的真实用法涵盖从架构设计到代码实现的完整思路无论是个人练手还是课程设计都能在此基础上快速扩展。1. 秒杀系统为什么难别把高并发当作“多线程加速”“高并发秒杀系统”这个词在 Java 后端几乎成了判断一个人有没有真正处理过流量冲击的试金石。功能上无非是限时卖固定数量的商品可当几万人同时点“立即抢购”几万个请求瞬间打进服务时常规的增删改查思路就全崩了最先告警的不是数据库而是 Tomcat 线程池、数据库连接池、Redis 连接池最后数据库被一大堆等待中的 SQL 拖垮库存超卖、重复下单、页面 5xx 一起冒出来。这篇文章想解决的不是“把秒杀跑通”而是“把秒杀跑稳”。你会看到 Redis 预扣减库存怎么挡住超卖、唯一索引怎么兜底重复下单、异步落库怎么让接口快速响应以及压测时 Nginx 和 Tomcat 上有哪些最容易被新手忽略的默认参数。整套链路用 Spring Boot Redis MySQL 就能复现适合准备 Java 面试的开发者也适合想在活动前把库存系统补齐的后端。所谓“简单实现”指的是不引入复杂中间件也能扛住相当大的流量不是把库存扣成负数的那种糙快猛。2. 系统设计先行先把超卖、重复下单和请求放大三个问题拆开2.1 三个并发难题超卖、重复下单、请求放大分别是怎么发生的秒杀系统第一次裸跑最常见的现象是库存只有 100 件数据库里却生成了 300 多条订单。对账时发现不仅超卖还有大量重复订单。这背后其实是三个独立的问题混在一起排查会非常痛苦。超卖的直接原因是“检查库存”和“扣减库存”不是原子操作。典型错误写法是先SELECT stock判断大于 0再执行UPDATE。高并发下 100 个请求可能同时读到“还有 1 件库存”的快照于是都拿到了下单权限库存最终变成负数。这里不是靠增加重试或加锁就能解决的——如果锁粒度不对要么排队排到超时要么锁竞争把数据库拖垮。重复下单是接口幂等做得不够。用户手速快、双击按钮、前端因请求超时自动重试、浏览器后退再提交同一秒能发出多次下单请求。如果订单表没有唯一约束、服务层没做去重同一用户就会在同一商品下生成多张订单。你看着是用户自己的操作问题实际是接口没防御。请求放大则是量级问题。秒杀当天的流量可能是日常的几十倍假如所有请求都默认打到 MySQL即使总数据量不大几千条 SQL 同时排队也会把连接池瞬间吃光。更麻烦的是 MySQL 连接一旦耗尽普通查询跟着一起超时系统就不是“变慢”而是“雪崩”。这三个问题不能各自为战必须在分层设计里一起解决让无效流量在越靠前的位置被拦掉才是高并发系统的核心思路。2.2 分层架构接入层、服务层、存储层各自该守什么我一般把秒杀系统拆成三层来看。接入层用 Nginx 做静态资源分离、连接复用和粗粒度限流服务层用 Spring Boot 做活动校验、用户去重和 Redis 预扣减存储层里 Redis 保存热点库存MySQL 保存最终订单和可信库存。三层各自守住一条底线才不会互相拖累。分层核心职责关键依赖接入层Nginx静态资源、按 IP 限流、连接复用limit_req_zone、worker_connections服务层Spring Boot活动开关校验、幂等去重、Redis 预扣减Lettuce/Jedis 连接池、业务线程池存储层Redis MySQLRedis 保存预扣余量MySQL 保存订单和最终库存唯一索引、条件更新、事务这里有个很常见的误会以为 Nginx 层限了流服务层就可以随便查数据库。实际上 Nginx 限流只能挡住一部分按 IP 限流挡不住分布式的羊毛党也挡不住正常用户在不同设备上的并发请求。服务层的业务校验必须独立存在Nginx 那一层只负责“少放点流量进来”不负责“业务正确”。存储层的分工也要讲清楚。Redis 里的库存是“预扣余量”它的作用是快速拒绝大多数抢不到的请求提高接口吞吐MySQL 里的库存才是“最终可信库存”。为什么不能只信 Redis因为 Redis 可能宕机、可能被清空、可能在主从切换时丢数据。所以真正扣减数据库库存的那一步不能省只是把它挪到流量洪峰之后去执行。2.3 方案选型数据库锁、Redis 预扣减、MQ 削峰各自适合什么场面围绕“怎么防止超卖”业内最常被提起的有四类方案。我给它们排个序从最重到最轻。悲观锁SELECT ... FOR UPDATE是最直接也最笨的办法查询时就把行锁住同一时间只有一个线程能改库存。缺点是数据库连接会被长时间占用高并发下连接池很快就满了适合 QPS 只有几百的运营后台不适合对用户开放的秒杀接口。乐观锁版本号或条件更新比悲观锁轻一些用UPDATE ... WHERE stock 0把库存条件拼进 SQL靠数据库行锁保证不会扣成负数。它能扛住几千 QPS但高并发下大量请求在数据库层等待行锁响应时间会明显拉长。适合业务比较重、Redis 不好介入的 ERP 库存扣减场景——这类“库存并发扣减”的思路和秒杀其实完全同源。Redis 预扣减是目前秒杀系统里最常见的做法。用 Redis 的单线程模型做原子扣减把绝大多数抢不到的请求挡在 MySQL 之前抢到的人再异步落库。这套方案在单机 Redis 下就能扛住数万 QPS是性价比最高的起手式。MQ 削峰则是在 Redis 预扣减之后再叠一层把订单创建请求放到消息队列里由消费者按固定速率落库防止突发写库压力。方案优点缺点适用场景数据库悲观锁实现简单、绝对一致连接占用高、吞吐低后台管理、低并发数据库乐观锁无锁竞争、实现简单高并发下重试多、延迟升高ERP 库存、中低并发Redis 预扣减抗高并发、响应快Redis 宕机需补偿机制秒杀、抢购、热点商品MQ 削峰平滑落库压力引入中间件、消息可靠性成本大库存、复杂订单流程我自己的选择很明确第一版永远先上“Redis 预扣减 数据库兜底”等验证了真实流量、发现了瓶颈再决定要不要引入 MQ。别一上来就上 Kafka 和分布式事务那只会让你同时面对“并发问题”和“分布式问题”两个黑匣子。3. 动手落地用 Spring Boot Redis MySQL 把最小秒杀链路跑通3.1 依赖、连接池配置与两张核心表先搭项目。用 Spring Boot 2.7 以上版本JDK 1.8 或 17 都能编译依赖只需要spring-boot-starter-web、spring-boot-starter-data-redis、spring-boot-starter-jdbc和 MySQL 驱动。不需要引入 MyBatis 之外的重型框架JdbcTemplate足够演示。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyapplication.yml里有两组参数值得注意。数据源连接池我用 HikariCPSpring Boot 默认maximum-pool-size压测时从 20 调到 50但别超过 MySQL 的max_connections否则连接池还没满数据库先拒连接了。Redis 的超时时间我习惯设成 200ms宁可快速失败也不要让 Tomcat 线程卡在 Redis 上等几秒。spring: datasource: url: jdbc:mysql://127.0.0.1:3306/seckill?useUnicodetruecharacterEncodingutf8mb4 username: root password: your_password hikari: maximum-pool-size: 50 minimum-idle: 5 connection-timeout: 1000 redis: host: 127.0.0.1 port: 6379 timeout: 200ms数据库表只需要两张。seckill_product保存秒杀商品和库存seckill_order保存订单。seckill_order必须建UNIQUE KEY uk_user_sku(user_id, sku_id)这是最后一道防重复下单的兜底闸门表设计阶段没有它后面全靠代码去重迟早会漏。CREATE TABLE seckill_product ( id bigint NOT NULL AUTO_INCREMENT, product_name varchar(128) NOT NULL COMMENT 商品名称, seckill_stock int NOT NULL DEFAULT 0 COMMENT 秒杀总库存, seckill_price decimal(10,2) NOT NULL COMMENT 秒杀价格, start_time datetime NOT NULL COMMENT 活动开始时间, end_time datetime NOT NULL COMMENT 活动结束时间, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号条件更新时使用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT秒杀商品表; CREATE TABLE seckill_order ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 用户ID, sku_id bigint NOT NULL COMMENT 秒杀商品ID, order_no varchar(64) NOT NULL COMMENT 订单号, status tinyint NOT NULL DEFAULT 0 COMMENT 0处理中 1成功 2失败, create_time datetime NOT NULL COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_user_sku (user_id, sku_id) COMMENT 同一用户同一商品只能有一条订单 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT秒杀订单表;建表逻辑说明seckill_order的唯一索引不是为了省空间而是为了在数据库层拦截“同一个用户对同一商品重复下单”。version字段服务于后面的条件更新也就是用WHERE seckill_stock 0替代应用层先查后改避免超卖。3.2 秒杀接口核心Redis Lua 原子扣减、幂等校验、库存预热秒杀接口的代码不复杂难在每一步的先后顺序。我的习惯顺序是活动校验 → 重复下单校验 → Redis 原子扣库存 → 写已参与集合 → 异步落库。顺序错了就会出问题比如先扣库存再查用户是否已抢过就会让重复请求也扣掉库存。先准备扣库存的 Lua 脚本。用 Lua 而不是decrement()的原因很简单decrement()只能保证“扣减”这个动作是原子的不能保证“库存大于 0 才扣减”——当库存只剩最后 1 件时并发请求会把库存扣成负数。Lua 脚本把“检查库存”和“扣减库存”包进同一个 Redis 操作里执行这才是真正的原子。/** * 扣减库存的 Lua 脚本 * 返回 -2 表示 key 不存在说明活动未预热 * 返回 -1 表示库存为 0已售罄 * 返回 1 表示扣减成功。 */ private static final String STOCK_SCRIPT if redis.call(exists, KEYS[1]) 0 then return -2 end; local stock tonumber(redis.call(get, KEYS[1])); if stock 0 then return -1 end; redis.call(decr, KEYS[1]); return 1;;参数说明KEYS[1]传入的是库存 key格式建议统一为seckill:stock:{skuId}。脚本内redis.call(exists)是为了区分“没有预热”和“卖完了”两种状态不要把这两种情况都返回“已售罄”否则排查问题时会浪费大量时间。接下来是秒杀主方法。核心结构如下Service public class SeckillService { private static final String STOCK_PREFIX seckill:stock:; private static final String ORDER_PREFIX seckill:order:; Autowired private StringRedisTemplate redisTemplate; Autowired private OrderService orderService; Autowired private ThreadPoolTaskExecutor orderExecutor; public Result seckill(Long userId, Long skuId) { // 1. 活动开关校验秒杀开始前和结束后直接拒绝 if (!activityCache.isInProgress(skuId)) { return Result.fail(活动未开始或已结束); } // 2. 幂等校验同一用户同一商品只能参与一次 String orderKey ORDER_PREFIX skuId; Boolean participated redisTemplate.opsForSet().isMember(orderKey, userId.toString()); if (Boolean.TRUE.equals(participated)) { return Result.fail(您已参加过该商品的秒杀); } // 3. Redis Lua 原子扣减库存 String stockKey STOCK_PREFIX skuId; Long result redisTemplate.execute( new DefaultRedisScript(STOCK_SCRIPT, Long.class), Collections.singletonList(stockKey) ); if (result ! null result -2L) { return Result.fail(活动尚未开放请稍后重试); } if (result ! null result -1L) { return Result.fail(商品已抢完); } // 4. 记录参与用户保证后续请求不会再进来 redisTemplate.opsForSet().add(orderKey, userId.toString()); // 5. 异步落库立即返回成功提示 orderExecutor.execute(() - { try { orderService.createOrder(userId, skuId); } catch (Exception e) { rollbackStock(userId, skuId); log.error(订单创建失败已回补库存, e); } }); return Result.success(秒杀成功订单处理中); } }逻辑说明第 3 步的execute通过DefaultRedisScript把 Lua 脚本发给 Redis整个“检查扣减”在服务端完成。第 5 步用线程池异步执行接口响应时间不会包含数据库写入耗时这也是高并发秒杀系统的标准做法——用户看到的“秒杀成功”不代表订单已经生成只代表库存已经预扣成功。接着是库存预热。这个步骤极其容易被新手忽略如果活动开始后 Redis 里还没有库存数据第一个请求执行exists就会返回 0所有用户都会收到“活动尚未开放”。预热应该在活动开始前 5 分钟由定时任务或运营后台触发public void preloadStock(Long skuId) { Integer stock productMapper.selectStockById(skuId); redisTemplate.opsForValue().set(STOCK_PREFIX skuId, String.valueOf(stock)); }参数说明预热时用简单set即可不需要 Lua。活动进行中如果 Redis 里的 key 过期被删后续请求会全部走到“未预热”分支所以还要给库存 key 设置一个覆盖完整活动时长的 TTL避免无意的过期击穿。3.3 异步落库与线程池参数createOrder 怎么写库存怎么回补异步落库这一步要处理两个问题数据库库存不能扣成负数以及 MySQL 扣减失败后 Redis 库存要回补。createOrder方法里的关键不是先查库存再插入订单而是用条件更新一步完成“检查库存 扣减”Transactional public void createOrder(Long userId, Long skuId) { String orderNo generateOrderNo(); // 号段或雪花算法 // 条件更新库存大于 0 才扣减返回受影响行数 int rows jdbcTemplate.update( UPDATE seckill_product SET seckill_stock seckill_stock - 1, version version 1 WHERE id ? AND seckill_stock 0, skuId ); if (rows 0) { throw new RuntimeException(数据库库存扣减失败); } // 插入订单唯一索引兜底重复问题 jdbcTemplate.update( INSERT INTO seckill_order (user_id, sku_id, order_no, status, create_time) VALUES (?, ?, ?, 1, NOW()), userId, skuId, orderNo ); }逻辑说明UPDATE ... WHERE seckill_stock 0看起来只有一句 SQL实际由 InnoDB 行锁保证不可能把库存扣成负数。多个请求同时执行这句 SQL 时只有第一个请求能拿到行锁并成功扣减后面的请求会因为seckill_stock 0条件不成立而返回 0 行。这比“先 SELECT 再 UPDATE”安全得多也比悲观锁省数据库连接。异步执行需要线程池。线程池大小直接影响吞吐核心线程数太小会让任务排队太久用户虽然收到了“秒杀成功”但迟迟等不到订单。Configuration public class OrderThreadPoolConfig { Bean(orderExecutor) public ThreadPoolTaskExecutor orderExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setQueueCapacity(500); executor.setThreadNamePrefix(order-async-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.initialize(); return executor; } }参数说明核心线程数 8、最大 32、队列 500是一台 4C8G 服务器上的保守配置。队列长度决定服务能“吞下”多少个来不及处理的落库任务超过 500 后新任务会被拒绝被拒绝的任务已经在 Redis 扣过库存所以在主方法里 catch 到RejectedExecutionException时要立刻回补库存并提示用户“系统繁忙请稍后重试”。回补库存的方法也需要写清楚public void rollbackStock(Long userId, Long skuId) { // 回补 Redis 库存 redisTemplate.opsForValue().increment(STOCK_PREFIX skuId); // 移除用户参与标记允许重新参与 redisTemplate.opsForSet().remove(ORDER_PREFIX skuId, userId.toString()); }很多文章只讲回补 Redis 库存不提移除用户标记结果用户明明第一次秒杀失败重试时却被幂等校验挡住造成“库存没卖完但用户全部被拒”的诡异现象。回补库存和移除用户标记必须成对出现。4. 用 JMeter 压测验证TPS、TP99 和性能拐点怎么读4.1 JMeter 线程组设置3000 并发模拟的最小压测计划写完了代码别急着上线先压测。JMeter 是最容易上手的压测工具打开后创建一个测试计划依次添加“线程组”“HTTP 请求”“聚合报告”。线程组配置直接决定压测结果是否可信。我推荐第一次压测按这个表设置JMeter 字段推荐值设计理由线程数3000模拟秒杀活动的瞬时用户量Ramp-up 周期秒6060 秒内逐步发完 3000 个请求避免一起涌出循环次数1秒杀场景每人只抢一次不设循环HTTP 请求超时3000 ms与后端接口超时保持一致新手最容易踩的坑是把 Ramp-up 设成 0。0 意味着所有线程在同一瞬间发起请求这确实能压出极限值但和真实用户行为完全不符——真实用户是分批涌入的。先用 Ramp-up 60 秒摸清系统的稳定能力再用 0 秒摸清极限两个指标分开看。4.2 读懂聚合报告平均响应时间会骗人TP99 才是关键压测结束后打开聚合报告重点看四个指标。吞吐量Throughput单位是requests/sec表示系统每秒能处理多少请求。比如吞吐量 1200/sec代表一秒钟处理了 1200 个秒杀请求。错误率Error %正常应该在 0.5% 以下。如果错误率超过 1%先去看服务端日志别急着调参数。错误可能是超时、连接拒绝、线程池拒绝也可能是接口逻辑抛异常原因完全不同。90% 和 99% 响应时间Percentile这是最容易被忽略的指标。平均响应时间 50ms 很漂亮但如果 99% 是 900ms说明有 1% 的用户在系统边缘等得很痛苦。秒杀场景里99% 响应时间大于 1 秒就不能接受。最后是性能拐点。我会从 500 并发开始压依次加到 1000、2000、3000、5000。每次记录吞吐量和错误率。当响应时间骤增、吞吐量开始下降的那一档并发数就是性能拐点。比如 2000 并发时吞吐量 1800/sec3000 并发时吞吐量掉到 1500/sec 且错误率跳到 5%说明系统在 3000 并发时已经过载这个数字就是容量上限。4.3 瓶颈定位与调优Tomcat 线程池、Nginx worker、数据源连接池压测发现瓶颈后按“接入层 → 服务层 → 存储层 → JVM”的顺序逐个排查。先看 Tomcat 线程池因为它在最前面最容易被流量打满。Spring Boot 内置 Tomcat 默认maxThreads200acceptCount100。换句话说同时只能有 200 个请求正在被处理另有 100 个请求在队列里等待其余直接拒绝。这个默认值对秒杀场景太保守了我一般会调大Connector port8080 protocolorg.apache.coyote.http11.Http11NioProtocol maxThreads800 acceptCount2000 connectionTimeout20000 keepAliveTimeout15000/参数说明maxThreads控制工作线程数调到 800 后能同时处理的请求变多acceptCount是等待队列长度调到 2000 意味着最多允许 2000 个请求排队。要注意别把 maxThreads 调到 2000 以上线程数过多会导致 CPU 频繁切换上下文吞吐反而下降。Nginx 侧关注两个参数worker_processes auto让进程数和 CPU 核心数对齐worker_connections决定每个进程能保持多少连接。秒杀场景连接数通常会很高20480 是一个合理的起点。worker_processes auto; worker_connections 20480; keepalive_timeout 65;逻辑说明调大worker_connections不会立刻提升吞吐它的作用是让 Nginx 不成为连接瓶颈真正限制并发的是后端 Tomcat 线程池。所以调优顺序一定是“后端先调Nginx 再跟着扩”。数据源连接池也需要同步调整。HikariCP 默认maximum-pool-size10压测时很容易打满。注意一个约束条件连接池大小不能超过 MySQL 的max_connections默认 151如果连接池 50、Tomcat maxThreads 800同时只有 50 个数据库连接可用其余线程都在等待连接。这解释了为什么 java 面试八股里总强调“连接池不是越大越好要和上游线程池联动看”。JVM 参数进压测前就设好。4C8G 机器给 Spring Boot 预留 4G 堆-Xms4g -Xmx4g避免压测过程中频繁 Full GC 影响响应时间。如果压测时看到 GC 日志密集优先解决堆内存分配而不是继续调线程池。5. 避坑排查秒杀系统最容易翻车的五个场景逐一对症解决5.1 超卖问题活动发了 100 件订单出了 500 件现象活动结束后seckill_product的seckill_stock变成负数seckill_order里的订单数量远超活动库存。原因代码里用的是“先 SELECT 库存判断大于 0再 UPDATE”。并发下多个请求同时读到剩余库存 1 件全部通过判断全部执行 UPDATE库存被扣成负数。解决把应用层的“先查后改”改成 SQL 条件更新UPDATE ... SET seckill_stock seckill_stock - 1 WHERE id ? AND seckill_stock 0。如果代码已经上线临时止血可以给库存表加一个CHECK (seckill_stock 0)约束MySQL 8.0 支持 CHECK 约束能阻止后续产生负库存但历史脏数据需要单独对账修复。5.2 Redis 售罄但数据库还有货库存对不上的经典僵局现象用户看到“已抢完”但运营后台里商品库存还剩几十件。查 Redis 发现库存 key 为 0查 MySQL 发现库存还有 30。原因Redis 预扣减成功后异步落库失败。比如数据库线程池满了、订单插入超时、事务回滚导致 Redis 扣了库存但 MySQL 没扣。这时如果没有回补逻辑Redis 和 MySQL 的库存就会永久不一致。解决一是异步任务里要 catch 所有异常失败后调用rollbackStock回补 Redis 库存并移除用户标记二是增加一个定时对账任务每 5 分钟扫描 Redis 中库存接近 0 的商品和 MySQL 的实际剩余库存做比对发现差值就触发校正。这个对账任务不复杂但能救你于水火尤其是活动进行中没人敢动 Redis 数据的尴尬时刻。5.3 同一用户重复下单幂等校验漏了数据库唯一索引现象活动结束后发现同一个user_id对同一个sku_id有三四条订单记录用户明明只抢到一次。原因代码里用 Redis Set 做幂等校验但 Redis 记录在异步落库时被清掉或过期或者用户绕过正常接口直接调用下单接口。Redis 不是数据库它的数据可能丢失必须靠数据库层兜底。解决两层防御并行。第一层是 Redis Set 的isMember校验拦截正常用户的重试请求第二层是seckill_order表上的UNIQUE KEY uk_user_sku(user_id, sku_id)。当重复插入触发唯一键冲突时捕获DuplicateKeyException按“用户已参与”处理而不是直接抛出 500。记住Redis 去重是性能优化MySQL 唯一索引才是最终防线。5.4 压测时大量 500Tomcat 线程池被打穿现象压测并发数超过 2000 后聚合报告错误率飙升到 10% 以上服务端日志大量出现“RejectedExecutionException”或“Connection timed out”。原因Spring Boot 内置 Tomcat 默认maxThreads2002000 并发下大部分请求在acceptCount100的等待队列里排队队列满了直接拒绝连接部分请求就算进入处理流程也会因为数据库连接池不足而等待超时。解决按前面第 4.3 节的参数调整 Tomcat 线程池和 HikariCP 连接池同时在秒杀接口最外层加限流让超过系统处理能力的请求直接返回“系统繁忙”。要用好AbortPolicy并捕获RejectedExecutionException这是线程池自带的流量保护机制别因为怕失败而把它关掉。5.5 秒杀结束还有流量进来活动开关没挡住现象活动结束 1 小时后接口还在产生订单商品链接还能下单。原因代码里做了活动时间校验但校验用的是服务器本地时间加数据库活动表活动结束时可能有少量请求恰好卡在边界上更常见的是运营直接改数据库活动时间缓存里的活动状态没有同步更新。解决把活动状态缓存到 Redis活动开始前设置一个“待开始”状态开始后由定时任务或手动发布改成“进行中”结束后改成“已结束”。接口层面做双层判断先查 Redis 状态快速拦截大多数无效请求再查数据库时间精确兜底。活动结束后应立刻在 Nginx 层把 URL 的流量转发到静态页或活动结束页而不是让请求到达后端。接住这些流量的成本很低漏掉它们就会变成惊群效应让数据库在活动结束后的峰值里继续被冲击。6. 进阶优化分布式锁、热点 key 分片与上线前检查清单6.1 把幂等和扣减合进一个 Lua减一次网络往返到这一步你的单体秒杀已经能跑了。如果想优化性能第一个动作不是换中间件而是把“判断是否已参与”和“扣减库存”这两个 Redis 操作合并成一个 Lua 脚本。当前实现的两次 Redis 调用各消耗一次网络往返合并后可以省掉一半的延迟。-- KEYS[1] 库存 key -- KEYS[2] 用户已购 set key -- ARGV[1] 用户ID if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return -3 end if redis.call(exists, KEYS[1]) 0 then return -2 end local stock tonumber(redis.call(get, KEYS[1])) if stock 0 then return -1 end redis.call(decr, KEYS[1]) redis.call(sadd, KEYS[2], ARGV[1]) return 1逻辑说明脚本里用sismember判断用户是否已抢过已抢过直接返回 -3。注意这里的数据一致性问题——如果用户在请求 A 里抢成功又在同一毫秒发出请求 B两个请求都进到 Lua 脚本里执行。Redis 单线程会保证这两个脚本串行执行所以不会出现“两个脚本同时通过库存检查”的情况。这也是为什么秒杀系统天然适合 Redis单线程执行模型省掉了应用层分布式锁。6.2 热点商品 key 分片别再让一个 Redis key 扛全部流量当一个商品特别火爆所有请求都打在同一个 Redis key 上单实例 Redis 的 CPU 会先到瓶颈。常见做法是对库存 key 做分片把 100 件库存拆成 10 个小 key每个 key 存 10 件请求进来时根据用户 ID 取模路由到不同的小 key。int shard (int) (userId % 10); String stockKey seckill:stock: skuId : shard;参数说明分片数是经验值单机 Redis 4 到 8 个分片足够分片后的副作用是部分分片卖完、部分分片还有剩余需要在服务端汇总剩余库存再做“是否还有货”的判断。库存回补时也要遍历所有分片。这个优化适合秒杀量极大、已确认 Redis 是瓶颈时再上前期不必做。6.3 上线前检查清单照着过一遍再开闸最后是一份自检清单是我每次上线秒杀活动前必做的一套动作第一预热脚本是否已执行Redis 里所有商品的库存 key 都存在且数值正确第二超卖核对 SQL 是否准备好活动结束后跑一遍seckill_product和seckill_order的对账确认没有订单数大于库存数第三异步线程池的监控是否接入队列积压何时超过阈值要能收到告警第四检查回补库存的日志有没有输出一旦出现“订单创建失败已回补库存”要立刻排查原因第五把压测报告和真实预估流量对比确认性能拐点高于预估峰值 30% 以上再开闸。做秒杀这类系统我自己的习惯是永远让第一版保持单体和简单先把 Redis 预扣减、幂等校验、异步落库这条链路的每一环跑通确认超卖和重复下单都不再出现再考虑分布式锁、库存分片和消息队列。分布式的东西只有在单点确实扛不住时才值得引入——你手上的秒杀系统也一样先把手里的单体调出稳定的 2000 QPS比直接搭一套半懂不懂的微服务更有价值。希望帮到你。本文还有配套的精品资源点击获取