公司动态
SpringBoot+Redis+MyBatis单体秒杀系统实战:防超卖与高并发设计
简介高并发场景下系统如何扛住瞬时流量并保证数据一致性秒杀作为典型的高并发读写场景其核心挑战在于将大量请求拦截在数据库之外仅让极少数获得资格的请求触达底层存储。引入缓存层实现热点数据的高频读与原子扣减再配合数据库条件更新作为最终兜底是业界成熟的解决方案。本文以SpringBoot整合Redis与MyBatis构建商品秒杀单体应用为例详解Redis预减库存的Lua脚本实现、数据库乐观锁防超卖、一人一单唯一索引设计以及缓存与数据库一致性保障手段覆盖从环境搭建、核心代码到压测验证的完整链路。无论你是准备面试还是工程落地需要都能从中掌握一套可复现的秒杀架构设计思路。 做秒杀系统这几年我见过太多人一上来就堆微服务、消息队列、分库分表结果单体都还没玩明白。今天我把一个基于 SpringBoot Redis MyBatis 的商品秒杀单体应用完整拆给你看——它麻雀虽小但把秒杀最核心的“高并发防超卖”“缓存与数据库一致性”“异步扣减”这些坑都过了非常适合想真正搞懂秒杀链路的人。无论你是准备面试、还是想在公司内部快速落地一个限量活动这个项目都值得你从头到尾跟一遍。提示下面所有代码和配置都是我在实际项目中跑过的你照着搭就能复现不需要额外魔改。1. 项目整体设计为什么秒杀系统用单体也能搞定1.1 秒杀场景的核心挑战秒杀这个场景本质上就是“短时间、高并发、极少成功”的供需失衡。比如一个商品只有 100 件库存但开抢瞬间可能有 10 万人同时点按钮。这带来的问题不是数据库撑不住而是大量请求会把数据库、带宽、应用线程池全部打满最终导致正常用户也抢不到甚至整个服务宕掉。所以秒杀系统的第一个设计原则就是把请求挡在数据库之外。真正打到数据库的写操作应该只有极少数抢到资格的用户。这也引出了核心问题谁有资格资格怎么发发了之后怎么保证数据库扣减不超卖整体需求拆下来就是三条高并发读商品详情、库存状态要被高频访问不能每次都查数据库。高并发写100 件库存但 10 万请求要快速判断“能不能扣”需要一个高性能的计数器。一致性一旦用户抢到数据库库存必须准确扣减不能出现超卖更不能出现“Redis 扣了、数据库没扣”的对不上账。这三条恰好对应 Redis、SpringBoot、MyBatis 三个组件各司其职Redis 扛读和预扣SpringBoot 管理流程和接口MyBatis 负责最终的数据库落账。1.2 单体应用的选型逻辑你可能想问这么经典的秒杀场景不应该上微服务加 MQ 吗我的回答是看体量看团队看阶段。单体应用的第一个好处是部署简单、链路短、好排查。你只需要一个 SpringBoot 工程连上 Redis 和 MySQL就能跑通完整秒杀。对于中小型活动、内部限量发放、初创期产品来说单体完全够用。我之前带过一个项目刚上线时也是秒杀场景日活不过几万微服务那套架构跑起来反而增加了运维成本出了问题链路长到没人能说清楚。后来收敛成单体反而稳定多了。第二个好处是事务好控制。秒杀最关键的“下单 扣库存”如果拆成两个服务就要引入分布式事务处理不好就是数据不一致。单体里直接用 Spring 的Transactional就能保证原子性开发效率高一个档次。微服务不是不能用而是你得先确认自己真遇到了单体的瓶颈而不是为了架构而架构。第三个好处是面试和教学价值高。单体应用把所有逻辑摊开Redis 预减、异步下单、数据库扣减这些关键节点都能清晰地看到调用链非常适合学习。如果你把单体秒杀都做不稳上了微服务只会更乱。当然单体也有上限比如单机线程池和连接池的资源是有限的但我们可以通过 Nginx 做负载均衡把同一个单体应用水平扩展成多节点——Redis 还是那个 Redis数据库还是那个数据库架构一点不用改。这也是为什么我建议先从单体做起先做对再谈扩展。2. 秒杀链路的核心Redis 预减库存这一步到底在解决什么问题2.1 一次完整的秒杀请求经历了什么我把秒杀流程拆成五个节点每一步都有明确的职责前端按钮置灰 简单限流用户在页面点击抢购前端先判断是否已开始没开始直接提示。这一步防的是“提前狂点”和“脚本刷单”。后端接口限流用 SpringBoot 拦截器或者 Redis 计数器对同一用户同一商品限制一次请求防恶意刷接口。Redis 预减库存库存以商品 ID 为 key 存在 Redis 里请求先在这里判断是否有货、是否扣减成功。这一步是防超卖的第一道防线也是性能关键。异步下单预减成功的请求进入下单逻辑这里可以根据场景选择同步落库或放进队列异步处理。单体应用里我更推荐先同步落库因为依赖简单事务可控如果确实吞吐量大再用线程池或 MQ 削峰。数据库最终扣减用带条件的 UPDATE 语句扣减库存只有影响行数为 1 才算扣成功。这是保证最终一致性的底牌。这套流程的核心思想是“层层过滤”前面挡掉 99% 的无效请求数据库只处理真正抢到的极少数请求。我在实际项目里压过纯数据库直扛的 QPS 也就是几百到一千左右而走 Redis 预减之后单机 QPS 能到几万差距非常明显。2.2 Redis 在秒杀里的三种核心用法Redis 在这个项目里不是可有可无的缓存而是秒杀的“心脏”。我归纳了三种核心用法缺一不可。用法一库存预热与预减秒杀开始前把数据库里的库存同步到 Redis比如seckill:stock:1001 100。开抢后所有请求先走 Redis 的DECR操作。这里要注意如果库存有 100 件100 个请求成功第 101 个请求进来时 Redis 返回小于 0直接拒绝。为了原子性我推荐用 Lua 脚本完成判断和扣减避免多个请求同时读到剩余库存为 1然后都以为自己成功了。-- 预减库存脚本 -- KEYS[1] 为库存 key例如 seckill:stock:1001 local stock tonumber(redis.call(GET, KEYS[1])) if stock nil then return -1 -- 库存未初始化 end if stock 0 then return -2 -- 已售罄 end redis.call(DECR, KEYS[1]) return 1 -- 预减成功用 Lua 而不是GETDECR两条命令是因为 Lua 脚本是原子执行的。如果不这么做两个请求同时读到库存 1各自判断成功再各自 DECR最终库存变 -1超卖就发生了。这是我在项目里踩过最典型的坑。用法二分布式锁单体应用里用 JVM 的synchronized或ReentrantLock其实就够了但我还是建议用 Redis 分布式锁原因有两个一是后续水平扩展成多节点时代码不用改二是可以统一管理锁的获取和释放逻辑比如加超时时间、加可重入性。简单实现可以这样String lockKey seckill:lock: goodsId; String requestId UUID.randomUUID().toString(); // 加锁setnx 过期时间保证原子性 Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 执行业务逻辑 } finally { // 释放锁时校验 requestId防止误删其他人锁 String current stringRedisTemplate.opsForValue().get(lockKey); if (requestId.equals(current)) { stringRedisTemplate.delete(lockKey); } } }注意释放锁之前一定要校验是不是自己的锁否则 A 线程锁超时释放了B 线程拿到锁A 线程的 finally 里直接把 B 的锁删掉就会出大问题。实际项目中我更推荐直接用 Redisson它的看门狗机制能自动续期避免业务没执行完锁就过期。用法三热点数据缓存秒杀商品的详情、剩余库存、活动时间这些热点数据一定不能每次都打数据库。我会在秒杀开始前把商品详情缓存到 Redis并且设置合理的过期时间。真正要实时变化的库存数据用单独的 key 区分这样刷详情接口不会影响扣库存的性能。2.3 为什么要保留数据库最终扣减很多人把 Redis 预减当作全部扣完 Redis 就以为完事了这是不对的。Redis 是内存数据库存在宕机丢数据的风险而且它本身不提供事务性回滚。所以我坚持让 Redis 只做“资格预判”真正的库存扣减和订单生成还是要落到 MySQL。为什么因为只有数据库的 UPDATE 语句才能像下面这样保证“库存不足就不更新”UPDATE goods SET stock stock - 1 WHERE id #{goodsId} AND stock 0这条 SQL 在 InnoDB 的行锁保护下同一时刻只有一个事务能成功更新这一行影响行数为 1 才表示扣减成功。哪怕 Redis 因为某种原因多预减了数据库这层也不会超卖。这是整个系统的最后一道防线绝对不能省。日常运维里我还会加一个定时对账任务比对 Redis 剩余库存和数据库剩余库存是否一致不一致就报警。这样做不是多余而是为了应对极端情况下的缓存与数据库不一致问题。3. 数据库设计与 MyBatis 实践超卖问题从这里根治3.1 商品表与订单表设计秒杀系统的表结构并不复杂但有几个细节需要格外注意。先看最核心的商品表CREATE TABLE goods ( id bigint(20) NOT NULL AUTO_INCREMENT, goods_name varchar(128) NOT NULL COMMENT 商品名称, goods_image varchar(255) DEFAULT NULL COMMENT 商品图片, price decimal(10,2) NOT NULL COMMENT 商品单价, stock int(11) NOT NULL DEFAULT 0 COMMENT 库存-可售, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, seckill_start_time datetime DEFAULT NULL COMMENT 秒杀开始时间, seckill_end_time datetime DEFAULT NULL 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_seckill_time (seckill_start_time,seckill_end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT秒杀商品表;订单表更直接CREATE TABLE seckill_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 订单号, user_id bigint(20) NOT NULL, goods_id bigint(20) NOT NULL, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 订单状态0待支付 1已支付 2已取消, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_goods (user_id,goods_id), KEY idx_goods_id (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT秒杀订单表;看到uk_user_goods这个唯一索引了吗这是防超卖的又一重保险。同一个用户同一件秒杀商品只能有一条订单记录否则就会出现一个用户抢到多单再转卖的情况。我在做活动时被这种问题搞过一次后来补上唯一索引配合接口限流才算彻底堵住。3.2 乐观锁扣减库存的 SQL 写法防超卖最核心的 SQL 有两种思路悲观锁和乐观锁。悲观锁就是SELECT ... FOR UPDATE锁住行直到事务提交适合并发量不大的场景。秒杀这种场景并发高、冲突多更适合乐观锁。乐观锁有两种实现方式。第一种是“条件限制法”就是前面提到的那条UPDATE goods SET stock stock - 1 WHERE id #{goodsId} AND stock 0这条 SQL 不显式加版本号而是利用stock 0这个条件让数据库在行锁保护下判断。只要有一行更新成功就代表库存扣减成功如果影响行数为 0说明库存已经没了。这是我在单体秒杀项目里最推荐的写法简单、高效、不容易出错。第二种是“版本号法”UPDATE goods SET stock stock - 1, version version 1 WHERE id #{goodsId} AND version #{oldVersion}版本号法在应用层先查出当前版本再在更新时比对版本号如果版本变了就重试。它更通用但秒杀场景下会引入大量重试反而不如第一种直接。所以我个人建议秒杀扣库存优先用stock 0条件法配合 MyBatis 的返回值判断是否成功。3.3 MyBatis 使用细节与动态 SQL 实战MyBatis 在这个项目里主要承担数据库操作但有几个坑是新人最容易踩的我必须挑重点说。关于 #{} 与 ${} 的区别这是面试高频题也是实战里的安全问题。#{}是预编译参数MyBatis 会把它替换成?通过PreparedStatement绑定参数能有效防止 SQL 注入。比如用户 ID 传过来使用#{userId}绝对安全。${}是直接字符串拼接MyBatis 会把变量值原样拼进 SQL。它只适合用来拼接表名、列名、ORDER BY 字段这类不能走预编译的场景。如果你把用户输入直接拼进WHERE条件里那就是给攻击者留了个后门。所以我的原则是能用 #{} 的地方绝不用 ${}必须用 ${} 时字段白名单校验。关于动态 SQL秒杀列表查询经常需要根据条件动态拼接 SQL比如按时间过滤、按商品名称模糊查询。MyBatis 的if、where、choose这些标签能让 SQL 灵活起来。比如select idlistSeckillGoods resultTypeGoods SELECT * FROM goods where if testgoodsName ! null and goodsName ! AND goods_name LIKE CONCAT(%, #{goodsName}, %) /if if testnow ! null AND seckill_start_time lt; #{now} AND seckill_end_time gt; #{now} /if /where ORDER BY create_time DESC /select注意这里我用的lt;而不是因为 XML 里会被解析成标签开始符。这是初学者最容易忽略的问题一跑就报 SQL 语法错误。关于 MyBatis 缓存MyBatis 有一级缓存和二级缓存。一级缓存默认开启作用域是同一个 SqlSession也就是一次会话内查询相同数据会走缓存。二级缓存默认关闭作用域是同一个 Mapper 的 namespace开启后多线程共享。但我要泼一盆冷水秒杀项目里MyBatis 的二级缓存建议关掉。因为缓存数据是库存这种强一致性的数据一旦缓存了旧库存用户看到有货实际没货或者扣减后缓存没及时刷新都会出问题。库存数据该实时就要实时Redis 才是缓存层MyBatis 的缓存在这种场景里是帮倒忙。关于 fetchSize做报表或大批量查询时fetchSize这个参数很有用。MySQL 驱动默认是一次把结果集全部拉到内存如果数据量大很容易内存溢出。我之前排查过一个OutOfMemoryError: Insufficient Memory问题最后发现就是一条查询拉了百万级数据还没设置 fetchSize。在 MyBatis 的 XML 里可以给大查询加fetchSize-2147483648MySQL 流式读取的魔法值或者设置fetchSize1000分批拉取。秒杀项目里虽然不太会遇到但这是个通用经验值得记下。4. 完整实操从环境准备到秒杀接口落地4.1 依赖配置与基础环境我先说一下环境版本避免你踩版本坑。SpringBoot 2.7.x JDK 8 是最稳妥的组合Redis 6.xMySQL 5.7 以上。如果你是 JDK 17 用户可以用 SpringBoot 3.x但要注意它底层用了 Jakarta EE很多第三方兼容包要换。项目的pom.xml核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies 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.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependenciesapplication.yml里最需要注意的就是 Redis 和 MyBatis 的配置。Redis 连接池一定要配好否则高并发下连接不够用请求全部排队那 Redis 的优势就没了spring: redis: host: 127.0.0.1 port: 6379 password: lettuce: pool: max-active: 200 max-wait: 3000ms max-idle: 50 min-idle: 10 datasource: url: jdbc:mysql://127.0.0.1:3306/seckill?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000 pool-name: SeckillHikariPool mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.seckill.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case一定要开不然数据库的goods_name映射不到实体类的goodsName属性查出来全是 null。log-impl配置成 StdOut 可以方便调试时观察 SQL。4.2 核心代码实现秒杀接口的服务层是重点我直接给你看核心逻辑Service public class SeckillService { Autowired private StringRedisTemplate stringRedisTemplate; Autowired private GoodsMapper goodsMapper; Autowired private SeckillOrderMapper orderMapper; private static final String STOCK_PREFIX seckill:stock:; private static final String ORDERED_PREFIX seckill:ordered:; private static final String LOCK_PREFIX seckill:lock:; // 秒杀库存预减 Lua 脚本 private static final DefaultRedisScriptLong STOCK_SCRIPT; static { STOCK_SCRIPT new DefaultRedisScript(); STOCK_SCRIPT.setLocation(new ClassPathResource(lua/stock.lua)); STOCK_SCRIPT.setResultType(Long.class); } Transactional(rollbackFor Exception.class) public SeckillResult seckill(Long userId, Long goodsId) { // 1. 判断秒杀是否在有效时间内 Goods goods goodsMapper.selectById(goodsId); if (goods null || !isInSeckillWindow(goods)) { return SeckillResult.fail(不在秒杀时间段内); } // 2. 一人一单Redis setnx 防刷 Boolean first stringRedisTemplate.opsForValue() .setIfAbsent(ORDERED_PREFIX userId : goodsId, 1, Duration.ofHours(24)); if (!Boolean.TRUE.equals(first)) { return SeckillResult.fail(您已参与过该商品秒杀); } // 3. Redis Lua 预减库存 Long stockResult stringRedisTemplate.execute( STOCK_SCRIPT, Arrays.asList(STOCK_PREFIX goodsId)); if (stockResult null || stockResult 0) { return SeckillResult.fail(商品已抢光); } // 4. 数据库扣减库存返回影响行数 int rows goodsMapper.decreaseStock(goodsId); if (rows 0) { // 回补 redis 库存避免不一致 stringRedisTemplate.opsForValue().increment(STOCK_PREFIX goodsId); return SeckillResult.fail(商品已抢光); } // 5. 创建订单 SeckillOrder order new SeckillOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setGoodsId(goodsId); order.setStatus(0); orderMapper.insert(order); return SeckillResult.success(order.getOrderNo()); } }这段代码有四个关键点。第一Transactional保证数据库扣库存和订单插入在同一事务里要么都成功要么都回滚。第二先 Redis 预减再做数据库操作是为了尽早挡掉无效请求。第三如果数据库扣减失败一定要回补 Redis 库存否则 Redis 比数据库少会产生“虚没货”问题。第四一人一单用 setnx 实现命令是原子的天然防并发重复下单。这里要多说一句事务里执行decreaseStock时只要 SQL 用的是stock 0条件即使并发进来两个请求数据库行锁也会让它们串行执行后一个必然影响行数为 0直接回滚。这是整个防超卖体系里最可靠的一环。4.3 压测与验证代码写完不能直接上线必须压测。我最常用的压测工具是 JMeter 和 Apache Benchab。对于单体秒杀项目ab足够用了ab -n 10000 -c 500 -T application/json \ -p body.json http://localhost:8080/api/seckill这个命令意思是总共 10000 个请求500 并发。压测时我重点观察三个指标吞吐量Requests per second看 Redis 预减是否真正把性能提上来了。失败率看被拦截的请求是否按预期返回“已抢光”。数据库连接池是否被打满看 HikariCP 日志里有没有连接超时。压测之后还要验证超卖先去 MySQL 确认库存是 0再查订单总数和库存扣减数是否一致。我见过不止一次“Redis 显示 0但数据库还有库存”的诡异情况多半就是数据库扣减失败后没有回补 Redis。所以压测不只测性能还要顺带检查一致性。另外压测时记得把 JVM 参数调一下至少设置最大堆内存java -jar -Xms512m -Xmx1024m seckill.jar如果压测中遇到OutOfMemoryError: Insufficient Memory优先看是不是线程数开太多、堆太小或者结果集拉太多。我之前帮同事排查过一次就是默认最大堆太小压测线程一多直接 OOM调大堆之后马上稳定了。5. 常见问题排查与避坑指南5.1 超卖和库存不一致超卖是秒杀系统最致命的问题我按严重程度给你列一个排查优先级第一步检查数据库扣减 SQL 是否带了stock 0条件。没有这个条件100% 超卖。第二步看扣库存和创建订单是否在同一个事务里。不在同一事务可能出现“订单建了库存没扣”。第三步看 Redis 预减失败后是否回补。不回补会出现“明明数据库还有货用户却抢不到”。第四步检查 Redis 脚本是否是原子执行。如果是GET和DECR分开执行并发下可能出现预减超卖。第五步检查订单表是否有一人一单的唯一索引。没有唯一索引一个人可能下多单。5.2 缓存穿透、击穿、雪崩这是 Redis 面试必问实战里也真实存在。我在秒杀项目里做了三件事缓存穿透查询一个不存在的商品 ID 时每次都穿透到数据库。我做法是在接口入口用布隆过滤器或者简单判断商品 ID 是否存在也可以把空结果缓存短暂时间但要注意别把“无货”缓存太久。缓存击穿某个热卖商品的缓存刚好过期一瞬间大量请求打到数据库。我处理方式是给热点商品加分布式锁只有拿到锁的线程去查数据库并回写缓存其他线程等待后直接读缓存。缓存雪崩大量 key 同时过期导致数据库被打爆。我习惯给缓存过期时间加上随机值比如基础过期时间 1 小时再加 0 到 5 分钟的随机数避免所有 key 在同一时刻过期。5.3 连接池配置和线程池配置高并发秒杀时数据库连接池和 Redis 连接池如果配置不合理性能会断崖式下跌。我给的配置经验是数据库连接池HikariCP里maximum-pool-size不是越大越好。MySQL 默认连接数有限连接太多反而增加上下文切换和锁竞争。我的经验值是CPU 核数 * 2 加磁盘数然后根据压测微调。单体应用跑 4 核 8G 的机器我一般设置最大 50太高反而拖垮数据库。Redis 的 Lettuce 连接池同理。max-active设 200max-wait设 3000ms 比较合理。如果压测时出现大量连接超时不要盲目调大max-active先看是不是命令执行太慢导致连接被占住比如大 key 操作、KEYS命令这种全表扫描式操作一定要避免。线程池方面SpringBoot 的 Tomcat 默认线程数是 200最大 200。秒杀这种场景200 个线程如果同时阻塞在数据库查询上系统很快就没响应了。建议把线程池调到 500 到 1000但前提是数据库和 Redis 扛得住。这个需要用压测来验证不能拍脑袋。5.4 实战中的几个小细节最后分享几个我在真实项目中踩过的细节都是一行代码的事但影响很大。第一Redis 里的库存值初始化时机。一定要在秒杀开始前做库存预热比如项目启动时或活动上架时把数据库库存同步到 Redis。还要保证数据库和 Redis 的初始化是原子的。我习惯在活动开始前跑一个初始化任务校验 Redis 库存是否为 0为 0 才从数据库拉取避免重复预热导致库存翻倍。第二订单号不能简单用数据库自增 ID 直接暴露。秒杀成功的订单用户拿订单号去支付如果订单号是连续的很容易被恶意用户猜测并尝试刷单。我一般用时间戳加随机数或者引入雪花算法生成 19 位订单号。第三接口要做好 ApiKey 或 Token 安全校验。单体应用的秒杀接口如果裸奔脚本可以无限刷。我建议把用户 ID 从 Token 里解析而不是让前端传一个可信的 userId。项目里我用了拦截器把用户身份统一解析放到ThreadLocal里业务代码直接取。第四Swagger 这类文档工具本地调试可以开生产环境一定要关掉。我在application-prod.yml里会把 swagger 配置成不启用避免暴露接口结构给不怀好意的人。第五SQL 日志生产环境要关。log-impl: StdOutImpl开发时确实方便但生产环境每个请求打印 SQL 会严重拖慢性能还会把参数打进日志。线上我一般改成org.apache.ibatis.logging.slf4j.Slf4jImpl并且把 mapper 的日志级别调到 WARN。说实话秒杀系统这几年被讲得天花乱坠我见过很多团队把简单问题复杂化。这个单体项目最实在的价值就是把秒杀的完整链路用最简单的方式跑通让你亲眼看到 Redis 预减、数据库兜底、一人一单这些机制是怎么协同工作的。我个人在实际操作中的一个体会是做秒杀系统先做减法再做优化。把“Redis 预减 数据库条件扣减 唯一索引兜底”这个最小闭环跑稳比一开始就引入消息队列、分布式事务要靠谱得多。因为所有更复杂的架构本质上都是在换个方式解决这个最小闭环里出现的性能和一致性问题。你把这个单体吃透了后面不管是拆微服务、上 MQ、做分库分表都是有根之木而不是空中楼阁。本文还有配套的精品资源点击获取