简介:高并发场景下,系统如何扛住瞬时流量并保证数据一致性?秒杀作为典型的高并发读+写场景,其核心挑战在于将大量请求拦截在数据库之外,仅让极少数获得资格的请求触达底层存储。引入缓存层实现热点数据的高频读与原子扣减,再配合数据库条件更新作为最终兜底,是业界成熟的解决方案。本文以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 而不是GET+DECR两条命令,是因为 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 id="listSeckillGoods" resultType="Goods"> SELECT * FROM goods <where> <if test="goodsName != null and goodsName != ''"> AND goods_name LIKE CONCAT('%', #{goodsName}, '%') </if> <if test="now != null"> AND seckill_start_time <= #{now} AND seckill_end_time >= #{now} </if> </where> ORDER BY create_time DESC </select>注意这里我用的<=而不是<=,因为 XML 里<会被解析成标签开始符。这是初学者最容易忽略的问题,一跑就报 SQL 语法错误。
关于 MyBatis 缓存
MyBatis 有一级缓存和二级缓存。一级缓存默认开启,作用域是同一个 SqlSession,也就是一次会话内查询相同数据会走缓存。二级缓存默认关闭,作用域是同一个 Mapper 的 namespace,开启后多线程共享。
但我要泼一盆冷水:秒杀项目里,MyBatis 的二级缓存建议关掉。因为缓存数据是库存这种强一致性的数据,一旦缓存了旧库存,用户看到有货实际没货,或者扣减后缓存没及时刷新,都会出问题。库存数据该实时就要实时,Redis 才是缓存层,MyBatis 的缓存在这种场景里是帮倒忙。
关于 fetchSize
做报表或大批量查询时,fetchSize这个参数很有用。MySQL 驱动默认是一次把结果集全部拉到内存,如果数据量大,很容易内存溢出。我之前排查过一个OutOfMemoryError: Insufficient Memory问题,最后发现就是一条查询拉了百万级数据还没设置 fetchSize。在 MyBatis 的 XML 里可以给大查询加fetchSize="-2147483648"(MySQL 流式读取的魔法值),或者设置fetchSize="1000"分批拉取。秒杀项目里虽然不太会遇到,但这是个通用经验,值得记下。
4. 完整实操:从环境准备到秒杀接口落地
4.1 依赖配置与基础环境
我先说一下环境版本,避免你踩版本坑。SpringBoot 2.7.x + JDK 8 是最稳妥的组合,Redis 6.x,MySQL 5.7 以上。如果你是 JDK 17 用户,可以用 SpringBoot 3.x,但要注意它底层用了 Jakarta EE,很多第三方兼容包要换。
项目的pom.xml核心依赖如下:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>application.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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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 DefaultRedisScript<Long> 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 Bench(ab)。对于单体秒杀项目,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设 200,max-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、做分库分表,都是有根之木,而不是空中楼阁。
本文还有配套的精品资源,点击获取