ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot单体秒杀实战:Redis+MyBatis高并发优化方案

SpringBoot单体秒杀实战:Redis+MyBatis高并发优化方案 简介这是一份面向Java后端初学者与中级开发者的高并发实战项目资源聚焦电商场景下的商品秒杀系统设计与落地解决单体架构中库存超卖、请求洪峰、响应延迟等典型问题。资源为完整可运行的Spring Boot单体应用集成Redis实现库存预减与缓存穿透防护结合MyBatis完成订单与商品数据的精准持久化涵盖用户鉴权、商品展示、秒杀处理、订单生成及基础监控等六大核心模块。压缩包共61个文件40个Java业务与控制器类、11个MyBatis映射XML、3个配置properties、2个界面PNG图结构清晰含pom.xml、mvnw、LICENSE、README.md及测试目录总大小仅192KB轻量易导入学习。目前已有63人下载学习读者可直接运行调试掌握Redis分布式锁/原子操作实践、MyBatis动态SQL优化技巧、Spring Boot分层架构组织方式以及高并发下接口限流与幂等性设计思路。1. 秒杀不是“加个 Transactional 就能扛住”的玄学SpringBoot Redis MyBatis 单体应用的真实水位线你写完一个 SpringBoot 秒杀接口本地压测 500 QPS 稳如老狗上线后 2000 用户一涌而上数据库连接池瞬间打满、Redis 队列堆积如山、库存扣减错乱、超卖 37 件——这不是服务器不行是没把「单体秒杀」的边界和真实压力点摸透。这个标题里的.zip不是玩具工程它代表一种被低估但极主流的落地形态不引入 RocketMQ、不拆微服务、不搞 Kubernetes纯靠 SpringBoot 做好分层隔离 Redis 做好流量削峰 MyBatis 做好精准落库把单体架构的秒杀能力榨到 3000~5000 QPS 的实操方案。它适合中小电商活动、内部系统抢购、教育平台限时报名等场景既避开了分布式锁的复杂度陷阱又绕开了中间件运维成本。关键不在“用了什么”而在“每层怎么守好自己的门”Redis 不是万能缓存它是第一道闸机MyBatis 不是简单 ORM它是最后的库存校验哨兵SpringBoot 不是胶水框架它是整个链路的调度中枢。下面我带你从零复现这个压缩包里真正能跑通、能压测、能上线的最小可行路径——不讲理论模型只讲命令、参数、日志怎么看、哪里会翻车。2. 搭建骨架SpringBoot 2.7.x MyBatis-Plus 3.5.x Redis 7.x 的版本对齐与初始化这个.zip项目不是随便堆砌依赖就能跑起来的。我见过太多人直接spring-boot-starter-data-redis用 3.x 版本去连 Redis 7结果lettuce连接池在高并发下频繁断连也见过 MyBatis-Plus 4.x 和旧版mybatis-spring-boot-starter冲突导致SelectProvider失效。版本不对齐后面所有优化都是空中楼阁。2.1 为什么选 SpringBoot 2.7.x 而不是 3.xSpringBoot 3.x 强制 JDK 17、Jakarta EE 9而生产环境大量遗留系统仍跑在 JDK 8/11 上。更重要的是MyBatis-Plus 3.5.x 对TableField(fill FieldFill.INSERT_UPDATE)的自动填充支持更稳定且与 Redisson 分布式锁兼容性经过千次压测验证。SpringBoot 2.7.18最新维护版 JDK 11 是当前最稳的组合。Maven 依赖核心片段如下!-- SpringBoot 核心 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent !-- MyBatis-Plus非 mybatis-spring-boot-starter -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- Redis 客户端Lettuce非 Jedis -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId exclusions exclusion groupIdio.lettuce/groupId artifactIdlettuce-core/artifactId /exclusion /exclusions /dependency dependency groupIdio.lettuce/groupId artifactIdlettuce-core/artifactId version6.2.6.RELEASE/version /dependency提示lettuce-core必须显式指定 6.2.6.RELEASE。Lettuce 6.3 在 Redis 7 cluster 模式下存在连接泄漏问题而本项目用的是单节点 Redis6.2.6 是压测中连接复用率最高、GC 压力最低的版本。2.2 Redis 初始化不是装完就完事而是配置三道防线Redis 在秒杀里不是“缓存”是状态中心 队列 锁载体。默认配置尤其是maxmemory-policy和timeout会导致高并发下 key 淘汰不可控、连接超时雪崩。必须手动初始化RedisTemplate并重写序列化器Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // KEY 使用 StringRedisSerializer避免乱码 template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); // VALUE 使用 GenericJackson2JsonRedisSerializer支持 LocalDateTime、BigDecimal GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } Bean public RedisCacheConfiguration redisCacheConfiguration() { return RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) // 所有缓存默认 10 分钟过期 .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( new GenericJackson2JsonRedisSerializer())); } }关键参数说明entryTtl(Duration.ofMinutes(10))强制所有缓存带 TTL防止 Redis 内存无限增长GenericJackson2JsonRedisSerializer比JdkSerializationRedisSerializer小 60% 体积且支持 Java 8 时间类型避免反序列化失败StringRedisSerializer用于 key保证SECKILL:ITEM:1001这类 key 可读、可 debug、可被redis-cli --scan扫描。2.3 MyBatis-Plus 初始化不只是MapperScan而是 SQL 安全网关MyBatis-Plus 在秒杀中最容易被忽略的是SQL 注入防护 分页性能兜底。application.yml中必须启用以下配置mybatis-plus: configuration: # 关闭二级缓存秒杀场景下缓存脏数据风险极高 cache-enabled: false # 开启 SQL 日志上线前必须关压测时必开 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: # 主键策略秒杀商品 ID 必须是业务主键禁用自增 id-type: none # 字段策略空字符串不插入避免 null 写入 insert-strategy: not_null update-strategy: not_null # 分页插件必须否则 limit offset 会拖垮 MySQL pagination: # 启用分页拦截器 interceptor: enabled: true同时在MybatisPlusConfig.java中注册分页插件Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 重点使用 OptimisticLockerInnerInterceptor 防止超卖见第 4 章 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); // 必加PaginationInnerInterceptor 支持 count 查询优化 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }注意OptimisticLockerInnerInterceptor是 MyBatis-Plus 提供的乐观锁插件它会在UPDATE语句末尾自动追加AND version #{version}配合Version注解字段比手写WHERE stock 0 AND version ?更安全、更少出错。3. 秒杀核心链路从请求入口到库存落库的五层过滤器设计真正的秒杀不是“用户点一下按钮后端扣一次库存”而是五层漏斗式过滤每层拦掉 80% 的无效请求最终只有不到 5% 的请求触达数据库。这个.zip项目之所以能扛住 3000 QPS靠的就是这五层不重叠、不冗余、可独立开关的防御体系。3.1 第一层Nginx 层限流防爬虫 防恶意刷在nginx.conf中加入# 定义限流区域按 IP 限速 limit_req_zone $binary_remote_addr zoneseckill_ip:10m rate10r/s; server { location /api/seckill/do { # 允许突发 20 个请求应对网络抖动 limit_req zoneseckill_ip burst20 nodelay; proxy_pass http://backend; } }rate10r/s单 IP 每秒最多 10 次请求超过即返回503 Service Temporarily Unavailableburst20允许短时突发避免正常用户因网络延迟被误杀此层不处理业务逻辑纯靠 Nginx C 模块实现CPU 占用低于 1%是性价比最高的前置过滤。3.2 第二层SpringBoot WebMvcConfigurer 全局拦截器验签名 验时间戳在SeckillInterceptor.java中实现Component public class SeckillInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String sign request.getHeader(X-Sign); String timestamp request.getHeader(X-Timestamp); String itemId request.getParameter(itemId); // 1. 时间戳校验5 分钟内有效 long now System.currentTimeMillis(); if (Math.abs(now - Long.parseLong(timestamp)) 5 * 60 * 1000) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\timestamp expired\}); return false; } // 2. 签名校验HMAC-SHA256(itemId timestamp secret) String expectedSign HmacUtils.hmacSha256(itemId timestamp your_secret_key); if (!expectedSign.equals(sign)) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\invalid sign\}); return false; } return true; } }注册拦截器Configuration public class WebConfig implements WebMvcConfigurer { Autowired private SeckillInterceptor seckillInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(seckillInterceptor) .excludePathPatterns(/api/seckill/item/**) // 商品详情页不拦截 .addPathPattern(/api/seckill/do); // 仅拦截秒杀动作 } }血泪经验签名必须包含itemId和timestamp且secret绝不能硬编码在前端 JS 里。我们曾因前端泄露secret被黑产批量伪造请求损失 23 万元库存。3.3 第三层Redis 预减库存原子操作 TTL 控制这是秒杀链路的心脏层。不查 DB不加锁纯靠 Redis 原子指令完成库存预占Service public class SeckillService { Resource private RedisTemplateString, Object redisTemplate; /** * 预减库存返回 true 表示还有库存可进入下一步 */ public boolean tryPreDeductStock(Long itemId) { String key SECKILL:STOCK: itemId; // 1. INCR 原子自增库存为负数表示已售罄 Long remain redisTemplate.opsForValue().increment(key, -1); // 2. 如果首次访问设置过期时间防止 key 永久存在 if (remain -1) { redisTemplate.expire(key, 24, TimeUnit.HOURS); } // 3. 库存 0 才放行注意remain 是减后的值-1 表示刚好卖完 return remain 0; } }关键设计点INCR key -1Redis 原子操作无竞态条件remain -1时设 TTL避免冷门商品 key 永久驻留内存remain 0判断逻辑0表示最后一份库存被抢走-1表示超卖此时应拒绝3.4 第四层Redis 分布式队列Lettuce Blocking Queue预减成功 ≠ 抢购成功。必须把请求排队再逐个落库避免 DB 瞬间被打爆public class SeckillQueueService { private static final String SECKILL_QUEUE_KEY SECKILL:QUEUE:%d; public void addToQueue(Long itemId, SeckillOrder order) { String key String.format(SECKILL_QUEUE_KEY, itemId); // 使用 LPUSH BRPOP 实现阻塞队列 redisTemplate.opsForList().leftPush(key, order); } public SeckillOrder takeFromQueue(Long itemId) { String key String.format(SECKILL_QUEUE_KEY, itemId); // 阻塞 10 秒避免空轮询 return (SeckillOrder) redisTemplate.opsForList().rightPop(key, 10, TimeUnit.SECONDS); } }消费者线程池PostConstruct初始化PostConstruct public void initConsumer() { ExecutorService executor Executors.newFixedThreadPool(4); // 4 个消费者线程 for (int i 0; i 4; i) { executor.submit(() - { while (!Thread.currentThread().isInterrupted()) { SeckillOrder order takeFromQueue(1001L); // 固定商品 ID 示例 if (order ! null) { // 调用 DB 扣减见第 4 章 boolean success deductStockInDB(order); if (!success) { // 扣减失败回滚 Redis 库存 rollbackRedisStock(order.getItemId()); } } } }); } }玄学提示消费者线程数 ≠ CPU 核数。MySQL 写入是 IO 密集型4 线程 8 连接池大小见第 4 章是实测吞吐最高的组合。线程太多反而引发 MySQL 连接争抢。3.5 第五层MyBatis-Plus 乐观锁 数据库唯一索引终极兜底即使前面四层都通过DB 层仍需双重保险TableId(type IdType.NONE) private Long id; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.UPDATE) private LocalDateTime updateTime; Version // 乐观锁字段 private Integer version; TableField(stock) private Integer stock;对应SeckillMapper.xml中的更新语句update iddeductStock UPDATE seckill_item SET stock stock - 1, version version 1, update_time NOW() WHERE id #{id} AND stock 0 AND version #{version} /update同时数据库表必须建唯一索引防重复下单-- 防同一用户对同一商品重复下单 ALTER TABLE seckill_order ADD UNIQUE INDEX uk_user_item (user_id, item_id);这样即使两个线程同时读到stock1也只有一个能执行UPDATE成功version匹配另一个因WHERE version ?失败而返回影响行数 0触发回滚逻辑。4. 避坑五层链路中踩过的 4 个血泪现场与修复方案这个.zip项目在真实压测中暴露出的坑比文档里写的多得多。下面这 4 条每一条都来自凌晨 2 点的线上事故复盘不是“可能出错”而是“必然翻车”。4.1 现象Redis 预减库存返回true但 DB 扣减始终失败订单堆积在队列里不消费原因SeckillQueueService.takeFromQueue()使用rightPop(key, 10, TimeUnit.SECONDS)但BRPOP在 Redis 主从切换时会丢失阻塞状态导致消费者线程永久阻塞在takeFromQueue()不再拉取新订单。解决改用redisTemplate.opsForList().rightPop(key)无阻塞版并在外层加while(true)Thread.sleep(10)循环配合Scheduled(fixedDelay 100)定时任务兜底。虽然牺牲一点实时性但换来 100% 可用性。4.2 现象高峰期SeckillService.tryPreDeductStock()返回true但SeckillOrder对象序列化进 Redis 后反序列化失败消费者拿到null原因GenericJackson2JsonRedisSerializer默认不支持LocalDateTime的反序列化缺少JavaTimeModule。解决自定义序列化器显式注册时间模块Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { // ... 其他配置 ObjectMapper objectMapper new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); // 关键 objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(objectMapper); // ... 后续设置 }4.3 现象MyBatis-PlusdeductStock方法执行耗时从 5ms 暴涨到 200msMySQLSHOW PROCESSLIST显示大量Sending data状态原因未给seckill_item表的stock字段加索引WHERE stock 0触发全表扫描。解决立即执行ALTER TABLE seckill_item ADD INDEX idx_stock (stock);注意stock是高频更新字段索引会略微增加写入开销但相比全表扫描的灾难性后果这是必须付出的代价。4.4 现象用户抢到商品后查询订单状态始终为“待支付”后台日志显示SeckillOrder插入成功但seckill_order表无记录原因Transactional事务传播行为错误。消费者线程在Async方法中调用insertOrder()但未配置TransactionManager导致事务不生效。解决在Async方法上显式指定事务管理器Transactional(transactionManager transactionManager, rollbackFor Exception.class) public void insertOrder(SeckillOrder order) { orderMapper.insert(order); }确保AsyncConfig中EnableAsync与事务管理器绑定Configuration EnableAsync public class AsyncConfig { Bean(taskExecutor) public Executor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix(seckill-async-); executor.initialize(); return executor; } }5. 压测验证用 JMeter 模拟 3000 并发的完整链路观测法光跑通不算数得用真实流量打穿它。这个.zip项目的价值最终要落在压测报告上。我用 JMeter 5.4.1非 GUI 模式实测了三轮结论比任何文字都有说服力。5.1 JMeter 脚本核心参数seckill.jmx参数值说明Threads (users)3000线程数模拟并发用户Ramp-up period (seconds)6060 秒内均匀加压避免瞬时冲击Loop CountForever持续压测观察稳定性HTTP Header ManagerX-Sign:${__digest(HMAC-SHA256,${itemId}${__time()}your_secret_key)}X-Timestamp:${__time()}动态生成合法签名与时间戳JSON Extractor$.data.orderId→orderId提取下单返回的订单 ID用于后续查询提示__digest函数需安装 JMeter 插件Custom JMeter Functions否则签名无法生成。5.2 关键监控指标与达标线持续 10 分钟监控项达标线实测值观测方式TPS事务/秒≥ 28002913JMeter Summary Report90% 响应时间≤ 350ms287msJMeter Aggregate ReportRedis CPU 使用率≤ 65%58%redis-cli infoMySQL 慢查询数00SHOW GLOBAL STATUS LIKE Slow_queries连接池活跃连接数≤ 80% maxActive72%8/10Druid 监控页面/druid/index.html特别注意Druid监控页面中的ActiveCount曲线如果它在压测中持续贴着MaxActive我们设为 10说明连接池太小如果长期低于 3则说明消费者线程太少或 DB 写入瓶颈。我们最终将maxActive10、minIdle2、initialSize5定为黄金组合。5.3 日志级故障定位三行命令锁定瓶颈当压测中 TPS 下跌或响应时间飙升不要盲目重启用这三行命令快速定位# 1. 查看 Redis 队列积压量单位条 redis-cli LLEN SECKILL:QUEUE:1001 # 2. 查看 MySQL 当前正在执行的慢查询重点关注 Sending data、Copying to tmp table mysql -u root -p -e SHOW PROCESSLIST; | grep -E (Sending|Copy|Locked) # 3. 查看 JVM GC 频率重点关注 CMS 或 G1 的 old gen 回收次数 jstat -gc $(pgrep -f SeckillApplication) 1s 10如果LLEN 500说明消费者线程处理不过来需增加线程数或优化 DB 写入如果PROCESSLIST中出现大量Sending data立刻检查seckill_item.stock是否有索引如果jstat显示FGCFull GC每分钟 ≥ 2 次说明GenericJackson2JsonRedisSerializer序列化对象过大需精简SeckillOrder字段如去掉userDetail对象只存userId。6. 进阶技巧用 Redis Lua 脚本合并预减 队列入队把两步变一步上面五层链路中第三层预减和第四层入队是两个独立 Redis 操作理论上存在微小窗口INCR成功后LPUSH失败导致库存被扣但订单未入队用户以为抢失败实际已超卖。这个问题在 3000 QPS 下概率约 0.002%但对金融级秒杀不可接受。解决方案用 Redis Lua 脚本原子化执行INCRLPUSH。脚本内容如下保存为seckill.lua-- KEYS[1] stock_key, KEYS[2] queue_key, ARGV[1] order_json local stock redis.call(INCRBY, KEYS[1], -1) if stock 0 then redis.call(LPUSH, KEYS[2], ARGV[1]) return 1 else return 0 endJava 调用方式public boolean tryPreDeductAndEnqueue(Long itemId, SeckillOrder order) { String stockKey SECKILL:STOCK: itemId; String queueKey String.format(SECKILL:QUEUE:%d, itemId); String script local stock redis.call(INCRBY, KEYS[1], -1) if stock 0 then redis.call(LPUSH, KEYS[2], ARGV[1]) return 1 else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(); redisScript.setScriptText(script); redisScript.setResultType(Long.class); Long result redisTemplate.execute(redisScript, Arrays.asList(stockKey, queueKey), JSON.toJSONString(order)); return result ! null result 1L; }这个技巧的价值在于把原本 2 次网络往返RTT压缩成 1 次且绝对原子。我们在压测中对比发现开启 Lua 后超卖率从 0.002% 降至 0TPS 提升 3.7%因减少了一次 Redis 请求。但它也带来新约束Lua 脚本不能超过 512MB实际不会且不能调用redis.call(KEYS)等危险命令本脚本无此问题。最后说一句实在话这个.zip项目不是终点而是起点。我上线后第三天就加了 Redis 限流熔断redis.call(INCR, SECKILL:FAIL:COUNTER)EXPIRE、第七天接入了 Sentinel 自动故障转移、第十五天把SeckillOrder改成了 Kafka 异步写入。但所有这些演进都建立在最初这个单体骨架足够扎实的基础上——它没用花哨技术却用最朴素的分层、最克制的组件、最真实的压测把“秒杀”这件事做回了工程师该有的样子。希望帮到你。本文还有配套的精品资源点击获取
返回列表