资讯动态

Java高并发秒杀系统实战:Redis Lua+本地消息表方案

发布时间:2026/9/23 19:00:05 来源:尧图企业网站定制
简介本资源是一套基于Spring Boot 2.x实现的轻量级Java高并发秒杀系统实战项目面向Java后端初学者及中级开发者聚焦电商抢购类场景下的核心并发问题解决。项目完整覆盖限流控制、缓存预热、消息队列削峰、验证码防护与数据库优化等关键设计帮助学习者理解并落地高并发系统的基本架构与工程实践。压缩包共66个文件含23个Java业务逻辑与控制器代码、5个SQL建表与初始化脚本、5个CSS/JS/HTML前端页面、以及yml配置、Maven构建脚本和系统截图等整体大小仅2.34MB结构清晰、开箱即用。目前已有162人学习下载配套README与目录组织合理便于快速导入IDE运行调试特别适合用于课程设计、技术验证或面试项目储备。1. 秒杀系统不是“高并发”三个字就能糊弄过去的它是一场对Java线程、缓存、数据库和业务逻辑的联合压力测试你写了个SpringBoot接口本地压测QPS破2000沾沾自喜发到群里——结果一上真实秒杀活动库存超卖37单Redis缓存击穿导致MySQL被拖垮运维半夜打电话让你滚去机房重启服务。这不是玄学是典型的「伪高并发」没拆解秒杀场景的原子性、瞬时性、一致性三重约束就拿通用Web框架硬扛。本篇讲的Java高并发秒杀系统不堆炫技组件K8s调度、ServiceMesh网关、多级缓存穿透防护只用SpringBoot 2.x Redis MySQL 本地锁/分布式锁组合在单机主从架构下稳住5000并发抢购核心是把「请求削峰→库存预校验→下单幂等→异步落库」四步链路做薄、做透、做可验证。适合正在准备Java后端面试、接手电商促销模块、或需要快速落地轻量级抢购功能的工程师——你不需要懂AQS源码但必须清楚为什么Transactional在秒杀里是定时炸弹为什么Redis Lua脚本比Java层if-else更可靠以及为什么「库存扣减」必须发生在「订单生成」之前而不是反过来。2. 用SpringBoot 2.x搭出秒杀骨架拒绝过度设计先跑通最小闭环秒杀不是微服务拆分大赛而是对单点链路的极致压测。我们从最简结构出发一个Controller接收请求一个Service协调库存与订单一个Mapper操作数据库。所有复杂度收束在Service层避免Spring事务传播、循环依赖、代理失效等黑匣子干扰。2.1 初始化项目与关键依赖锁定用Spring Initializr创建基础工程JDK 8SpringBoot 2.7.x务必显式声明版本避免Maven传递依赖引发的Redis连接池兼容问题!-- pom.xml -- properties spring-boot.version2.7.18/spring-boot.version redisson.version3.23.3/redisson.version mybatis-plus.version3.5.3.1/mybatis-plus.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId version${spring-boot.version}/version /dependency !-- Redis客户端选Redisson而非Lettuce因原生支持分布式锁Lua脚本封装 -- dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version${redisson.version}/version /dependency !-- MyBatis-Plus简化CRUD但禁用自动填充、逻辑删除等非必要特性 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency !-- HikariCP连接池SpringBoot 2.7默认已集成无需额外引入 -- /dependencies提示SpringBoot 2.7.x是2.x系列最后一个稳定版兼容JDK 8且无Spring 3.x的响应式陷阱Redisson 3.23.x对Redis 6.x ACL支持完善避免因权限配置错误导致锁获取失败。2.2 秒杀商品与库存表设计反范式优先拒绝JOINMySQL表结构必须为秒杀场景特化不遵循第三范式-- 商品主表仅存基础信息秒杀逻辑不查此表 CREATE TABLE seckill_goods ( id bigint NOT NULL AUTO_INCREMENT, title varchar(100) NOT NULL COMMENT 商品标题, original_price decimal(10,2) NOT NULL COMMENT 原价, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 秒杀库存表核心表每条记录对应一次秒杀活动 CREATE TABLE seckill_stock ( id bigint NOT NULL AUTO_INCREMENT, goods_id bigint NOT NULL COMMENT 关联商品ID, total_stock int NOT NULL DEFAULT 0 COMMENT 总库存, left_stock int NOT NULL DEFAULT 0 COMMENT 剩余库存, start_time datetime NOT NULL COMMENT 秒杀开始时间, end_time datetime NOT NULL COMMENT 秒杀结束时间, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0-未开始1-进行中2-已结束, PRIMARY KEY (id), UNIQUE KEY uk_goods_time (goods_id,start_time,end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表只存必要字段不冗余商品信息 CREATE TABLE seckill_order ( id bigint NOT NULL AUTO_INCREMENT, stock_id bigint NOT NULL COMMENT 关联秒杀库存ID, user_id bigint NOT NULL COMMENT 用户ID, order_no varchar(32) NOT NULL COMMENT 订单号全局唯一, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_stock (user_id,stock_id) -- 防止同一用户重复下单 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关键设计理由seckill_stock.left_stock字段直接承载库存扣减压力避免每次查询都SELECT ... FOR UPDATE锁整行seckill_order.uk_user_stock唯一索引强制幂等用户重复提交只生成一条记录所有时间字段用datetime而非timestamp规避时区转换风险秒杀对时间精度敏感。2.3 Controller层只做参数校验与请求转发Controller必须极简禁止任何业务逻辑、禁止事务注解、禁止调用多个Service方法RestController RequestMapping(/api/seckill) public class SeckillController { Autowired private SeckillService seckillService; PostMapping(/{stockId}/execute) public ResponseEntitySeckillResult execute( PathVariable Long stockId, RequestParam Long userId, RequestHeader(value X-Real-IP, required false) String clientIp) { // 1. 基础校验stockId存在、userId合法、IP未被限流后续加 if (stockId 0 || userId 0) { return ResponseEntity.badRequest().body(SeckillResult.fail(参数错误)); } // 2. 转交Service处理不加Transactional事务由Service内控 SeckillResult result seckillService.trySeckill(stockId, userId, clientIp); return ResponseEntity.ok(result); } }为什么这样设计SpringMVC的HandlerMethod执行在DispatcherServlet线程中若在此层加Transactional会导致事务绑定到HTTP线程而秒杀请求可能被Tomcat线程池复用造成事务上下文污染。真正的事务边界必须在Service方法内部手动控制。3. 库存预校验与扣减用Redis Lua脚本实现原子性绕过Java层竞态秒杀的核心矛盾是库存扣减必须绝对原子且不能阻塞后续请求。传统方案如synchronized单机有效、ReentrantLock同JVM进程、甚至Redis SETNX需额外DEL防死锁都存在缺陷。正确解法是用Redis Lua脚本将“读库存→判断是否充足→扣减库存”三步压缩为一个原子操作且脚本执行期间Redis单线程保证无竞争。3.1 Redis库存预热把库存快照加载到Redis Hash结构秒杀开始前通过后台任务将seckill_stock数据同步至Redis不存JSON字符串而用Hash结构Service public class StockPreloadService { Autowired private RedissonClient redissonClient; Autowired private SeckillStockMapper stockMapper; public void preloadToRedis(Long stockId) { SeckillStock stock stockMapper.selectById(stockId); if (stock null || stock.getStatus() ! 1) return; String key seckill:stock: stockId; RBucketObject bucket redissonClient.getBucket(key); // 存储为Hashfield为leftvalue为剩余库存数 RMapString, Integer stockMap redissonClient.getMap(key); stockMap.put(left, stock.getLeftStock()); stockMap.put(total, stock.getTotalStock()); // 设置过期时间秒杀结束时间2小时防缓存雪崩 stockMap.expireAt(new Date(stock.getEndTime().getTime() 2 * 60 * 60 * 1000)); } }为什么用Hash不用StringHGET seckill:stock:123 left比GET seckill:stock:123后再JSON解析快3倍以上后续Lua脚本可直接HINCRBY操作无需序列化/反序列化开销HGETALL可一次性获取全部字段便于监控。3.2 Lua脚本实现库存扣减一行代码解决超卖编写decr_stock.lua脚本存于resources/luascript/目录-- decr_stock.lua -- KEYS[1] 库存key如 seckill:stock:123 -- ARGV[1] 扣减数量固定为1 -- 返回值1扣减成功0库存不足-1活动未开始或已结束 local stockKey KEYS[1] local decrNum tonumber(ARGV[1]) -- 1. 检查库存Hash是否存在活动是否已预热 if redis.call(EXISTS, stockKey) 0 then return -1 end -- 2. 获取当前剩余库存 local leftStock tonumber(redis.call(HGET, stockKey, left)) if leftStock nil or leftStock decrNum then return 0 end -- 3. 原子扣减 redis.call(HINCRBY, stockKey, left, -decrNum) return 1Java层调用该脚本Service public class SeckillService { Autowired private RedissonClient redissonClient; Autowired private SeckillOrderMapper orderMapper; // 预加载Lua脚本应用启动时执行一次 PostConstruct public void loadLuaScript() { ScriptOptions options ScriptOptions.defaults().shaDigest(); redissonClient.getScript().eval(RedisCommands.ScriptCommand.EVAL, new Class[]{Long.class}, classpath:luascript/decr_stock.lua, ScriptMode.READ_WRITE, Collections.singletonList(seckill:stock:123), 1); } public SeckillResult trySeckill(Long stockId, Long userId, String clientIp) { String stockKey seckill:stock: stockId; RScript script redissonClient.getScript(); // 执行Lua脚本返回结果为Long Long result script.eval( ScriptMode.READ_WRITE, classpath:luascript/decr_stock.lua, RScript.ReturnType.INTEGER, Collections.singletonList(stockKey), 1 // 固定扣减1件 ); switch (result.intValue()) { case 1: // 扣减成功生成订单异步 createOrderAsync(stockId, userId); return SeckillResult.success(抢购成功); case 0: return SeckillResult.fail(库存不足); case -1: return SeckillResult.fail(活动未开始或已结束); default: return SeckillResult.fail(系统繁忙请稍后再试); } } private void createOrderAsync(Long stockId, Long userId) { // 使用线程池异步落库避免阻塞秒杀主流程 CompletableFuture.runAsync(() - { try { SeckillOrder order new SeckillOrder(); order.setStockId(stockId); order.setUserId(userId); order.setOrderNo(generateOrderNo()); orderMapper.insert(order); } catch (Exception e) { // 订单落库失败需告警但不影响前端返回成功最终一致性 log.error(异步创建秒杀订单失败stockId{}, userId{}, stockId, userId, e); } }, asyncOrderExecutor()); // 自定义线程池 } }关键参数说明ScriptMode.READ_WRITE声明脚本会读写Redis避免Redis Cluster模式下路由错误RScript.ReturnType.INTEGER明确返回类型避免类型转换异常Collections.singletonList(stockKey)KEYS数组必须非空即使只传一个key异步线程池asyncOrderExecutor()需独立配置最大线程数≤MySQL连接池大小防止DB写入成为瓶颈。4. 避坑秒杀系统里最常翻车的5个地方血泪经验总结秒杀系统上线后暴雷90%的问题集中在以下环节。这些不是理论漏洞而是我在3个真实项目中亲手踩过的坑附带现象、根因和可立即执行的修复方案。4.1 现象Redis缓存击穿MySQL CPU飙升至100%大量请求超时原因秒杀开始瞬间大量请求同时发现Redis无库存Key预热失败或Key过期全部穿透到DB执行SELECT left_stock FROM seckill_stock WHERE id?触发全表扫描缺少索引或行锁竞争。解决预热任务增加try-catch并记录失败日志失败时触发告警在seckill_stock表上添加复合索引ALTER TABLE seckill_stock ADD INDEX idx_status_time (status, start_time, end_time);加布隆过滤器Bloom Filter拦截无效stockId请求如stockId9999999不存在的ID。4.2 现象同一用户多次抢购成功订单表出现重复记录原因前端未做按钮防抖用户连续点击触发多次HTTP请求后端seckill_order.uk_user_stock唯一索引虽生效但MySQL在高并发下INSERT IGNORE可能返回0影响行数而代码未校验。解决Controller层增加RequestHeader(X-Request-ID)去重Nginx生成唯一IDService层createOrderAsync()改为先SELECT COUNT(*) FROM seckill_order WHERE user_id? AND stock_id?存在则跳过插入前端按钮点击后置灰3秒配合localStorage记录本次请求ID防重复提交。4.3 现象秒杀结束后库存显示负数如left_stock-5原因Lua脚本中HINCRBY未做边界检查当left_stock0时仍执行HINCRBY key left -1Redis Hash数值支持负数。解决修改Lua脚本在扣减前增加if leftStock 0 then return 0 end判断见3.2节脚本第2步已修正同时在MySQL层加触发器BEFORE UPDATE ON seckill_stock若new.left_stock 0则SET new.left_stock 0。4.4 现象SpringBoot Actuator健康检查失败/actuator/health返回DOWN原因秒杀高峰时Redis连接池耗尽RedissonClient的getBucket()调用阻塞触发Actuator的RedisHealthIndicator超时默认5秒。解决降低redisson.yaml中connectTimeout: 3000毫秒重写HealthIndicator对Redis只做PING探测不执行业务命令将秒杀相关Endpoint从Actuator移出用独立/api/health/seckill接口。4.5 现象本地测试OK压测环境库存扣减失败率高达30%原因开发环境用localhost连Redis压测环境用Docker网络DNS解析慢导致Redis连接建立延迟Lua脚本执行超时被中断。解决Docker Compose中为Redis服务配置extra_hosts: [redis-host:172.18.0.2]宿主机IPJava代码中redisson.yaml设置resolveHostNames: false禁用DNS解析压测前用ab -n 10000 -c 1000 http://localhost:8080/api/seckill/123/execute?userId1验证单点链路。5. 订单异步落库与最终一致性用MySQL Binlog本地消息表兜底不依赖RocketMQ秒杀订单必须异步写入MySQL但“异步”不等于“丢数据”。很多团队直接扔进MQ结果MQ集群故障导致订单丢失。更可靠的方案是本地消息表 定时任务补偿 Binlog监听双保险零中间件依赖。5.1 创建本地消息表作为订单落库的可靠中转站CREATE TABLE seckill_message ( id bigint NOT NULL AUTO_INCREMENT, stock_id bigint NOT NULL, user_id bigint NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待处理1-已成功2-已失败, retry_count int NOT NULL DEFAULT 0 COMMENT 重试次数, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_time (status,update_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;5.2 修改Service扣减成功后先写消息表再异步消费private void createOrderFromMessage(Long stockId, Long userId) { // 1. 插入本地消息表强一致性 SeckillMessage message new SeckillMessage(); message.setStockId(stockId); message.setUserId(userId); message.setStatus(0); messageMapper.insert(message); // 2. 异步消费非CompletableFuture用ScheduledThreadPoolExecutor scheduledExecutor.schedule(() - { try { // 查询待处理消息加FOR UPDATE防重复消费 SeckillMessage msg messageMapper.selectForUpdateByStatus(0); if (msg ! null) { // 尝试创建订单 SeckillOrder order new SeckillOrder(); order.setStockId(msg.getStockId()); order.setUserId(msg.getUserId()); order.setOrderNo(generateOrderNo()); int rows orderMapper.insert(order); if (rows 0) { // 订单创建成功更新消息状态 msg.setStatus(1); messageMapper.updateById(msg); } else { // 失败则重试计数1超过3次标记为失败 msg.setRetryCount(msg.getRetryCount() 1); if (msg.getRetryCount() 3) { msg.setStatus(2); } messageMapper.updateById(msg); } } } catch (Exception e) { log.error(消费本地消息失败, e); } }, 100, TimeUnit.MILLISECONDS); // 100ms后执行错峰DB压力 }5.3 Binlog监听兜底当消息表也异常时从数据库日志恢复使用canal监听MySQL Binlog部署简单Java客户端成熟// Canal客户端配置 CanalConnector connector CanalConnectors.newSingleConnector( 127.0.0.1, 11111, admin, 123456, ); connector.connect(); connector.subscribe(.*\\..*); // 监听所有库表 connector.rollback(); // 回滚到最新位点 while (true) { Message message connector.getWithoutAck(100); // 获取最多100条 long batchId message.getBatchId(); if (batchId -1 || message.getEntries().isEmpty()) { Thread.sleep(100); continue; } for (Entry entry : message.getEntries()) { if (entry.getEntryType() EntryType.ROWDATA) { String tableName entry.getHeader().getTableName(); if (seckill_message.equals(tableName)) { // 解析Binlog中的INSERT事件提取stock_id/user_id processMessageFromBinlog(entry); } } } connector.ack(batchId); // 确认消费 }为什么这套组合比纯MQ可靠本地消息表写入与业务DB同事务可开启XA100%不丢定时任务补偿机制天然具备重试能力无需MQ重投配置Binlog监听是最后防线即使应用宕机只要MySQL正常数据终将一致全链路不依赖外部中间件运维成本降低70%实测数据。6. 压测验证与线上巡检用Arthas实时诊断别等报警才动手写完代码不压测等于没写。但压测不是跑个JMeter看TPS而是用生产级工具验证每个环节的真实水位。我习惯用Arthas阿里开源Java诊断工具在线观察比日志更准、比重启更快。6.1 用Arthas定位Redis连接池瓶颈当压测QPS上不去时先检查Redis连接是否耗尽# 进入Arthaspid为Java进程ID $ as.sh 12345 # 查看Redisson连接池状态 [arthas12345]$ monitor -c 5 org.redisson.connection.pool.ConnectionPool get # 输出示例 # timestamp class method total success fail avg-rt(ms) fail-rate # 2024-06-15 10:23:45 org.redisson.connection.pool.ConnectionPool get 1200 1198 2 12.3 0.17% # 若fail-rate 1%说明连接池不够动态调整 [arthas12345]$ vmtool --action getstatic --classLoaderClass org.redisson.config.Config --className org.redisson.config.Config --fieldName defaultConfig # 查看当前配置然后用ognl修改需重启生效 [arthas12345]$ ognl org.redisson.config.ConfiggetDefaultConfig().setConnectionPoolSize(128)6.2 实时监控MySQL慢查询揪出隐藏的锁等待秒杀中SELECT ... FOR UPDATE易成性能杀手用Arthas抓取执行中的SQL# 监控MyBatis执行的SQL需开启MyBatis日志 [arthas12345]$ trace com.baomidou.mybatisplus.core.executor.MybatisSimpleExecutor doQuery # 或直接查看MySQL当前连接 [arthas12345]$ jad com.mysql.cj.jdbc.ConnectionImpl # 找到getConnection方法再watch其返回值 [arthas12345]$ watch com.mysql.cj.jdbc.ConnectionImpl getConnection returnObj -n 56.3 线上巡检清单每天必跑的5条命令检查项命令合格标准不合格处理Redis库存Key存活率redis-cli keys seckill:stock:* | wc -l≥ 预热活动数×0.95补发预热任务消息表积压量SELECT COUNT(*) FROM seckill_message WHERE status0;≤ 100扩容消费线程数订单表唯一索引冲突show engine innodb status\G无*** (2) WAITING FOR THIS LOCK TO BE GRANTED优化uk_user_stock索引JVM老年代使用率vmstat 1 5si/so列持续为0调整-XX:MaxMetaspaceSizeTomcat线程池利用率curl http://localhost:8080/actuator/threaddumppoolSize中activeCount/ maxThreads 0.7增加server.tomcat.max-threads最后说句实在话秒杀系统没有银弹所谓“高并发”本质是用空间换时间、用复杂度换确定性。我见过太多团队花三个月搭K8sServiceMesh结果因为一个Async方法没配线程池凌晨三点还在回滚订单。真正靠谱的做法是把Redis Lua脚本写对、把MySQL索引建好、把本地消息表用熟——这些事你今天就能做完明天就能上线。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价