简介:基于Spring Boot的电商秒杀系统项目,是一份面向Java后端学习者、高校计算机专业学生及毕业设计开发者的完整源码与说明资源。项目以秒杀场景为主线,整合MySQL、Spring Boot、Redis和RabbitMQ,重点展示如何通过SQL行锁判断、数据库唯一索引和消息队列三种手段防止超卖。资源包含67个Java源码文件、22个HTML页面及配套的12个JS与10个CSS前端文件,另有SQL脚本、配置文件、README说明和少量图片素材,共147个文件,压缩包约1.57MB,结构清晰,便于按模块阅读和二次改造。对希望理解高并发下单、库存扣减与异步削峰的读者来说,这套代码能提供从接口实现到消息队列解耦的完整项目参考,也适合作为课程设计、项目演示或毕业设计基础。目前已有129人浏览/学习,项目代码经测试运行成功,按README指引即可快速还原完整运行环境。

1. 基于Springboot的电商秒杀系统,为什么值得你亲手跑一遍

先抛一个反直觉的结论:一套电商秒杀系统的技术含量,七成不在Springboot本身,而在库存扣减那一行SQL、流量放行的阈值和Redis挂掉之后怎么办。很多人在网上搜“基于Springboot的电商秒杀系统”,拿到的源码能跑、能下单,但一上并发就翻车——不是框架不行,而是对秒杀链路的边界没有概念。

这篇文章就按我实际做过的方案,把这个标题拆成能落地的东西:先讲清楚秒杀系统的业务模型和选型逻辑,再给你一套可复现的Springboot工程结构和核心代码,最后把那些不跑一次绝对不知道的坑列出来。适合三类人:拿这套系统做毕设的学生、中小团队想快速搭秒杀活动后端的人、以及想搞明白“为什么我的秒杀接口一压测就超卖”的开发者。全文不依赖任何商业组件,用的都是Springboot、Redis、RabbitMQ、MySQL这套常见组合,照着敲就能跑。

2. 秒杀系统的技术模型:流量、库存与一致性的三角博弈

2.1 秒杀场景和普通电商下单的五个本质区别

普通电商下单,用户分散、流量平缓,数据库随便扛。秒杀不是。秒杀是 瞬时高并发、热点数据集中、写多读少 的极端场景。五点和普通下单有本质区别:

第一, 流量尖峰 。开抢前的一秒可能只有几千人浏览,开抢后一秒涌入几十万请求。普通下单的QPS假设是几百,秒杀要按峰值QPS的十倍去设计。第二, 热点数据 。所有请求都打在同一个商品ID上,数据库的一行记录、Redis的一个key,成了全局争抢的资源。第三, 读多写少但写极重 。商品详情可以走缓存,但库存扣减和订单创建是强写操作,而且必须在几十毫秒内完成。第四, 一致性要求高 。不能超卖,不能少卖,库存和订单要能对账。第五, 用户体验敏感 。用户点完按钮之后等待超过三秒,基本就流失了。

这五个差异决定了架构选型的方向:普通电商可以把压力直接交给数据库,秒杀必须在数据库前面加缓冲层,把绝大多数请求挡在真正写库之前。

2.2 Springboot在秒杀系统里扮演的角色:不是性能瓶颈,也不是性能救星

很多初学者以为Springboot本身决定了秒杀系统的性能上限,这是个误会。Springboot只是一个应用框架,帮你把Controller、Service、Mapper、事务、配置管理这些基础设施串起来。真正决定秒杀系统能不能抗住并发的是三件事: 线程模型配得好不好、对Redis和MQ的使用姿势对不对、数据库的SQL和索引设计合不合理 。

Springboot默认的Tomcat容器,单机大概能扛住几千QPS的简单接口请求。对于秒杀系统来说,它的定位是 业务编排层 ——接收请求、做参数校验、调用Redis预减库存、发MQ消息、响应前端。它不该直接去操作数据库的库存行,也不该在同步链路里做重活。把这两件事记住,你就理解了为什么这套系统用Springboot做骨架是够用的。

2.3 Redis、MQ、MySQL三者分工:缓存挡流量、队列削峰、数据库兜底

一套成熟的秒杀系统,数据链路是分层的。我一般这样划分:

Redis负责挡流量。 秒杀商品开始前,把库存预加载到Redis。用户请求到达后,先走Redis的原子扣减操作,扣成功了才允许继续下单,扣失败了直接返回“已抢光”。这一层能挡掉90%以上的无效请求。Redis单实例的原子操作可以达到每秒十万级,对秒杀场景完全够用。

MQ负责削峰。 Redis扣减成功之后,不直接写数据库,而是把订单创建消息扔进MQ。后端消费者按自己的节奏慢慢消化,把数据库的写入压力摊平。这样数据库不会在同一瞬间收到几十万个插入请求。RabbitMQ在这里的价值不是速度快,而是能缓冲和重试。

MySQL负责兜底。 最终订单数据、库存数据、用户数据都存在MySQL里。数据库不需要扛并发,只需要保证最终一致性和数据完整性。库存扣减用乐观锁或原子UPDATE,订单插入走批量或异步,这是最后一层防线。

这三层分工明确之后,你会发现秒杀系统的核心设计思路就一句话: 能挡的先挡掉,能异步的别同步做,数据库只在最后一刻出手。

3. 把源码跑起来:从SQL脚本到application.yml的最小启动步骤

3.1 源码工程结构:先分清哪些是核心、哪些是辅助

拿到一套Springboot秒杀源码,先别急着运行,花十分钟把目录结构看一遍。典型的秒杀工程分为几块:

seckill-system/
├── sql/                        -- 数据库初始化脚本
│   ├── seckill.sql             -- 建库建表+测试数据
│   └── seckill_data.sql        -- 秒杀商品测试数据
├── src/main/java/com/seckill/
│   ├── config/                 -- Redis、MQ、线程池配置
│   ├── controller/             -- 秒杀接口、商品接口、订单接口
│   ├── service/                -- 秒杀业务逻辑(核心)
│   ├── mapper/                 -- MyBatis的Mapper接口和XML
│   ├── entity/                 -- 实体类
│   ├── common/                 -- 统一返回、异常处理、工具类
│   └── SeckillApplication.java -- 启动类
├── src/main/resources/
│   ├── application.yml        -- 核心配置
│   ├── mapper/                -- MyBatis XML文件
│   └── static/templates       -- 简单前端页面
├── doc/                        -- 文档说明
└── pom.xml                     -- Maven依赖

看目录时重点看三个地方: sql目录下的建表语句 决定了库存和订单的数据模型; service目录下的秒杀实现 决定了防超卖逻辑怎么写; application.yml里的配置 决定了你能不能跑起来。其他的比如前端页面、工具类,都不影响核心链路的理解。

3.2 数据库初始化:建表、造数据、理解库存模型

秒杀系统的表结构是整条链路的基石。我见过的秒杀系统一般至少有三张核心表:秒杀商品表、秒杀订单表、用户表。下面是典型的建表脚本,SQL实际运行前建议先看清楚字段设计:

-- 秒杀商品表:库存放这里
CREATE TABLE `seckill_goods` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `goods_id` bigint(20) NOT NULL COMMENT '商品ID',
  `seckill_price` decimal(10,2) NOT NULL COMMENT '秒杀价格',
  `stock_count` int(11) NOT NULL COMMENT '库存数量',
  `start_time` datetime NOT NULL COMMENT '秒杀开始时间',
  `end_time` datetime NOT NULL COMMENT '秒杀结束时间',
  PRIMARY KEY (`id`),
  KEY `idx_goods_id` (`goods_id`),
  KEY `idx_start_end` (`start_time`,`end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀商品表';

-- 秒杀订单表:库存扣掉后在这里落一条记录
CREATE TABLE `seckill_order` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
  `goods_id` bigint(20) NOT NULL COMMENT '商品ID',
  `order_id` bigint(20) NOT NULL COMMENT '订单ID',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_goods` (`user_id`,`goods_id`) COMMENT '一个用户对一个商品只能秒杀一次'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀订单表';

注意几点: stock_count 字段是防超卖的关键,不能用int之外的类型,扣减时必须在SQL层面保证不小于零; uk_user_goods唯一索引 是防重复下单的数据库兜底手段,任何时候都不能去掉; start_time和end_time的联合索引 是为了让接口快速过滤不在秒杀时间窗口内的请求。

导入SQL之后,插入两条测试数据,一个库存充足的商品和一个库存为1的商品。库存为1的那个商品,是你后面验证防超卖逻辑好不好用的试金石。

3.3 配置文件里必须改的四个参数:端口、数据源、Redis、MQ

跑秒杀系统之前,application.yml是关键中的关键。初学者常犯的错是直接不改配置就启动,然后报一连串连接异常。下面是常见的核心配置段,我加上了注释说明:

server:
  port: 8080  # 端口按自己环境改,别和本机其他服务冲突

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456  # 改成你自己的MySQL密码
    driver-class-name: com.mysql.cj.jdbc.Driver
    # 连接池参数:秒杀场景下连接池不能太小,否则数据库连接会成为瓶颈
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 5000

  redis:
    host: localhost
    port: 6379
    password:  # 本地Redis没密码就留空
    timeout: 3000ms
    lettuce:
      pool:
        max-active: 50   # Redis连接池上限,秒杀场景建议调大
        max-idle: 20
        min-idle: 5

  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    virtual-host: /
    listener:
      simple:
        concurrency: 8   # 消费者的并发线程数,决定削峰后写库速度
        max-concurrency: 16
        prefetch: 50     # 每次预取消息数,设太大会导致消息积压不平衡

参数怎么调,我给个基本结论: 连接池配置以你的机器内存和数据库连接数为边界 。MySQL默认最大连接数通常是151,所以HikariCP的maximum-pool-size配上20左右是安全的,不要贪大。Redis连接池max-active设50是因为Springboot的Lettuce客户端本身很轻,但这个值也别超过100,否则Redis端会有文件描述符压力。

3.4 Maven依赖与本地环境准备:JDK版本和依赖冲突是第一个坑

秒杀系统的pom.xml一般围绕Springboot版本展开,依赖里必须包含这几类:spring-boot-starter-web(Web能力)、spring-boot-starter-data-redis(Redis客户端)、spring-boot-starter-amqp(RabbitMQ客户端)、mybatis-spring-boot-starter(持久层)、mysql-connector-j(驱动)、lombok(实体类简化)。

<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.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.3.1</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
    </dependency>
</dependencies>

这里有个血泪经验: Springboot 2.x系列对应JDK8或JDK11,Springboot 3.x强制JDK17 。如果你的机器装的是JDK8却拿了基于Springboot 3.x的代码,maven编译会直接报错。拿到源码先看pom.xml里的parent版本,再决定要不要切换JDK。另外注意mybatis-spring-boot-starter用2.x版本而不是3.x,3.x是给Springboot 3用的,混用会出现Mapper扫描不到的玄学问题。

本地环境准备就三步:装JDK(版本对齐pom)、装MySQL并启动、装Redis并启动。RabbitMQ如果本地没有,可以先注释掉MQ相关的依赖跑通下单再补,但能装就装上,因为削峰是秒杀系统的核心设计,跳过它等于没跑全流程。

3.5 IDEA启动配置:怎么避免“端口被占用”和“Bean冲突”

如果你用的是IntelliJ IDEA,启动Springboot项目有几个小细节。首先 在Run Configuration里选好JDK版本 ,确保和pom里的spring-boot版本匹配。其次, 启动前先确认8080端口没被占用 ,Mac和Linux可以用 lsof -i:8080 查看,Windows用 netstat -ano | findstr 8080 。

常见的Bean冲突是RedisTemplate的序列化配置和StringRedisTemplate混用。如果源码里手动配置了RedisTemplate,注意它一定要定义 @Bean 并且指定序列化器为Jackson或String序列化,否则你在命令行里通过redis-cli看到的key会是一长串二进制乱码,调试时非常恶心。

启动成功的标志不是控制台打印了Springboot的banner,而是最后一行出现 Started SeckillApplication in xx seconds 。看到这行日志,再打开浏览器访问 http://localhost:8080/goods/list ,如果能看到商品列表JSON,说明整套环境已经通了,可以开始验证核心链路。

4. 核心链路代码实现:防超卖、限流与异步下单的落地写法

4.1 秒杀接口的完整调用链:从Controller到MQ消费者的每一步

先看一条完整的秒杀请求走过了哪些代码,这样你对系统的全貌才有把握:

用户点击秒杀按钮
  -> Controller接收请求(校验参数 时间窗口 用户身份)
  -> Service层:Redis预减库存(原子操作)
  -> Redis扣减成功 -> 组装订单消息 -> 发送到RabbitMQ
  -> MQ消费者监听队列 -> 写seckill_order表 + 写订单表
  -> 返回"抢购成功,正在排队处理订单"

Controller层的代码非常薄,不该有业务逻辑,只做参数校验和转发。下面是秒杀接口的Controller层写法:

@RestController
@RequestMapping("/seckill")
public class SeckillController {

    @Autowired
    private SeckillService seckillService;

    /**
     * 秒杀接口
     * @param userId 用户ID,实际系统里从登录态获取,这里直接传参方便测试
     * @param goodsId 秒杀商品ID
     * @return 统一响应对象
     */
    @PostMapping("/do")
    public Result doSeckill(@RequestParam("userId") Long userId,
                            @RequestParam("goodsId") Long goodsId) {
        // 参数校验:基础的非空判断
        if (userId == null || goodsId == null) {
            return Result.error("参数不合法");
        }
        // 真正的秒杀逻辑在Service层
        return seckillService.doSeckill(userId, goodsId);
    }
}

这段代码的逻辑说明:Controller只做两件事——接收HTTP参数并转成业务参数,调用Service层方法。为什么不在Controller写业务?因为秒杀逻辑里Redis操作、MQ发送、异常处理都是需要事务和重试的,写在Controller里会变成一坨没法测试的代码。参数用 @RequestParam 接收,简洁够用,实际项目如果前后端分离建议用DTO对象接收并做注解校验。

4.2 防超卖那行UPDATE:库存扣减的最优SQL与索引设计

防超卖是整个秒杀系统的命门。最安全、最常用的方案是 使用UPDATE语句的条件判断扣减 。这条SQL写得不对,后面加什么缓存都白搭:

UPDATE seckill_goods 
SET stock_count = stock_count - 1 
WHERE goods_id = #{goodsId} 
  AND stock_count > 0 
  AND start_time <= NOW() 
  AND end_time >= NOW()

写这条SQL时注意三件事:第一, 必须带 stock_count > 0 这个条件 。MySQL在执行UPDATE时会锁住匹配的行,然后判断条件是否成立。如果库存已经为0,这个条件让UPDATE影响行数为0,你就知道没抢到,不会把库存扣成负数。第二, 索引要建在goods_id上 ,让WHERE条件能快速定位到秒杀商品的那一行,避免全表扫描拖慢整个数据库。第三, 时间窗口判断放在这条SQL里 ,可以过滤不在秒杀时间内的请求,减少一次数据库查询。

Service层拿到影响行数后这样处理:

@Transactional
public Result doSeckill(Long userId, Long goodsId) {
    // 前置检查:Redis秒杀标记,用来挡流量
    Boolean flag = redisTemplate.opsForValue()
            .setIfAbsent("seckill:user:" + userId + ":" + goodsId, "1", 
                          Duration.ofMinutes(30));
    if (Boolean.FALSE.equals(flag)) {
        return Result.error("不能重复秒杀");
    }

    // 执行条件更新,这里用的是上面那条SQL
    int rows = seckillGoodsMapper.reduceStock(goodsId);
    if (rows <= 0) {
        // 影响行数为0,说明库存不足或不在秒杀时间窗内
        return Result.error("商品已抢光");
    }

    // 创建订单记录
    SeckillOrder order = new SeckillOrder();
    order.setUserId(userId);
    order.setGoodsId(goodsId);
    order.setOrderId(generateOrderId());
    seckillOrderMapper.insert(order);

    // 删除秒杀标记
    redisTemplate.delete("seckill:user:" + userId + ":" + goodsId);
    return Result.success(order.getOrderId());
}

这段代码的逻辑说明: @Transactional保证扣库存和建订单在同一个数据库事务里 ,要么都成功要么都失败,不会出现库存扣了订单没建的情况。Redis的 setIfAbsent 利用键的存在性实现 一个用户对一个商品只能秒杀一次 ,这是分布式场景下最简单的幂等控制。最后删除这个标记,是为了让用户在秒杀成功后再看到订单详情时不会误判为重复请求。

但这里有一个重要提醒: 这种同步下单写法只适合并发量低的场景 ,如果压测时TPS超过500,数据库事务的锁竞争会让接口响应时间飙升。真正的秒杀系统会把这里的订单创建替换成MQ异步写入,4.3节就是干这件事。

4.3 Redis预减库存:Lua脚本让“检查+扣减”变成原子操作

在数据库前面加Redis预减库存,是为了把大部分请求拦截在数据库之前。Redis的扣减必须用Lua脚本或 decrBy 类的原子命令,不能在Java代码里先get再set,这两个操作之间会穿插其他线程的修改,导致超卖。

下面是用Lua脚本实现预减库存的推荐写法:

-- 检查key是否存在
if redis.call('EXISTS', KEYS[1]) == 0 then
    return -1  -- 商品不存在 返回-1
end
-- 当前库存
local stock = tonumber(redis.call('GET', KEYS[1]))
-- 库存不够
if stock <= 0 then
    return 0  -- 已抢光 返回0
end
-- 原子扣减
redis.call('DECR', KEYS[1])
return 1  -- 扣减成功 返回1

Java端调用这个Lua脚本:

public Result doSeckillWithRedis(Long userId, Long goodsId) {
    // RedisKey: seckill:stock:{goodsId}
    String stockKey = "seckill:stock:" + goodsId;
    // 执行Lua脚本,参数是key和商品ID
    DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
    Long result = redisTemplate.execute(script, 
            Collections.singletonList(stockKey), goodsId.toString());

    // -1商品不存在 0已抢光 1成功
    if (result == null || result <= 0) {
        return Result.error("商品已抢光");
    }

    // 扣减成功后发MQ消息,异步建单
    String orderMsg = String.format("{\"userId\":%d,\"goodsId\":%d}", userId, goodsId);
    rabbitTemplate.convertAndSend("seckill.order.exchange", "seckill.order", orderMsg);

    return Result.success("抢购成功,正在排队生成订单");
}

这里为什么用Lua?因为Redis的单线程模型保证了Lua脚本执行时不会被其他命令打断, 整个“检查库存-扣减库存”在一个原子操作内完成 。 stock <= 0 的判断放在 DECR 之前,不会出现负数库存。

这套做法的边界条件在于: Redis里的库存数来自数据库初始化时同步过去的值 ,如果数据库被其他系统直接修改了库存,Redis里的数就失真了。所以秒杀系统的库存以数据库为准,Redis只做流量过滤,两者允许短时间不一致。最后用脚本或定时任务对账修复。

4.4 简单有效的限流拦截器:拦截器与令牌桶的配合

秒杀接口必须限流,否则再多的缓存也顶不住恶意刷请求。常见做法是使用拦截器配合Redis实现计数限流,或者直接用Guava的RateLimiter做单机限流。对秒杀场景,我建议把限流放在两个维度: 接口总流量限流 和 用户维度限流 。

一个基于拦截器的全局限流写法如下:

public class SeckillInterceptor implements HandlerInterceptor {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, 
                             Object handler) throws Exception {
        // 获取用户ID,实际场景从请求头或Cookie中解析
        String userId = request.getHeader("userId");
        if (userId == null) {
            response.setStatus(401);
            return false;
        }
        // 用户维度的限流:同一个用户每秒最多3次请求
        String key = "seckill:limit:" + userId;
        Long count = stringRedisTemplate.opsForValue().increment(key);
        if (count != null && count == 1) {
            // 第一次请求,设置过期时间1秒
            stringRedisTemplate.expire(key, Duration.ofSeconds(1));
        }
        if (count != null && count > 3) {
            response.setStatus(429);
            response.getWriter().write("{\"code\":429,\"msg\":\"操作太快啦\"}");
            return false;
        }
        return true;
    }
}

这段代码的逻辑说明: increment 是原子自增,通过设置1秒的过期时间实现 滑动窗口计数 的效果。每个用户每秒允许3次请求,超过就返回429状态码。这种方案的优点是轻量、无侵入,缺点是Redis的key数量会随着用户量增长,所以限流key要设置合理的过期时间及时清理。

注册拦截器时只需要让它拦截秒杀相关的路径:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(seckillInterceptor)
                .addPathPatterns("/seckill/do")
                .addPathPatterns("/seckill/**");
    }
}

限流参数怎么设?我给一个基准: 单用户限流每秒3次、接口总QPS限流设为正常预估峰值的两倍 ,比如预估峰值5000 QPS,限流阈值设10000。这样既能拦住脚本,又不影响正常用户抢购。总是说限流参数要“根据业务调整”,实际更重要的是参数可配置化,别把数字写死在代码里。

5. 秒杀系统避坑指南:这五个问题不解决,上线必翻车

5.1 超卖:压测50并发卖出去了120件商品

现象 :用JMeter模拟50个并发用户同时抢一件库存100的商品,数据库里生成了120条订单,库存变成了负数。 原因 :库存扣减逻辑写成了“先查询再更新”——先 SELECT stock_count FROM seckill_goods WHERE goods_id=? ,判断大于0,再执行 UPDATE ... SET stock_count=stock_count-1 。并发场景下两个线程同时查到库存为1,都以为能扣,就超卖了。 解决 :把查询和扣减合并成一条带条件的UPDATE,在上面4.2节已经写了。这条SQL让数据库自己去判断库存够不够,影响行数为0就是失败。补充一句,如果用的MyBatis,注意Mapper接口和XML里SQL要严格对应,别出现参数名拼写错误导致条件失效。

5.2 Redis挂了,秒杀接口彻底崩溃

现象 :测试时把Redis进程kill掉,再请求秒杀接口,直接报连接异常,前端显示500。 原因 :秒杀链路强依赖Redis预减库存,Redis挂掉后 redisTemplate.execute 抛异常,又没有兜底逻辑,请求直接失败。 解决 :在Redis操作周围加异常捕获,Redis不可用时降级到数据库扣减逻辑,保证系统可用性优先于强一致性。代码上可以这样做——捕获 RedisConnectionFailureException ,然后调用一个走数据库的降级方法 doSeckillByDB(userId, goodsId) 。这个降级方法里面用4.2节的UPDATE语句直接扣库存,没有Redis预减,数据库压力会变大,但至少系统不会整体挂掉。真实上线时还要做Redis的哨兵或集群模式,避免单点。

5.3 同一个用户疯狂刷接口,短时间创建了大量订单

现象 :一个用户用脚本反复调用秒杀接口,产生了多条订单记录。 原因 :Service层没有做用户维度幂等校验,或者校验逻辑只在代码层判断而代码层判断不具备原子性。 解决 :三层兜底——第一层Redis的 setIfAbsent 做用户秒杀标记,第二层数据库订单表建 (user_id, goods_id) 唯一索引,第三层MQ消费者消费前先查订单表是否存在。唯一索引是最后的防线,它保证哪怕前面两层被绕过,数据库也不会让同一用户对同一商品生成两条订单。插入时捕获 DuplicateKeyException ,返回“您已参与过秒杀”。

5.4 数据库连接池被打满,其他业务接口全部卡死

现象 :秒杀压测期间,非秒杀的商品查询接口响应时间从50ms涨到10秒,数据库CPU飙升到90%。 原因 :HikariCP的maximum-pool-size默认10,秒杀高峰时大量请求同时占用连接去执行UPDATE扣库存和INSERT建单,连接耗尽后其他请求排队等连接。 解决 :调整HikariCP参数—— maximum-pool-size 调到20, connection-timeout 设短一些,比如3000ms。更重要的是 把写操作异步化 :秒杀接口的订单创建扔给MQ,数据库写入压力被削平,连接池自然不会被打满。这一条暴露的问题是:限流和削峰不只是保护数据库,也在保护连接池这个容易被忽略的共享资源。

5.5 秒杀还没开始,Redis里的库存就已经被扣光了

现象 :测试时发现Redis里的库存数莫名减少,秒杀没开始就显示“已抢光”。 原因 :秒杀开始前有人直接用接口请求刷库存扣减,或者初始化库存的定时任务重复执行了多次。 解决 :在Redis预减之前先校验当前时间是否在秒杀时间段内,时间窗口判断放在Controller层、Service层各做一次。Redis库存初始化的代码用 setNX 而不是 set ,避免重复任务覆盖已有库存。如果不做时间校验,接口暴露在公网上就是在告诉别人“你可以在任何时间调用扣减接口”。

6. 冷启动验证与压测:怎么判断这套秒杀系统真的能扛住

秒杀系统跑通业务逻辑只是第一步,能不能“秒杀”才是关键。我一般不用Postman点两次就交差,最少要做一次JMeter的 并发压测 ,验证两个数据: TPS峰值 和 库存数据最终一致性 。

测试方案很简单,准备三样:JMeter(或者你想用wrk、ab都可以)、秒杀接口地址、一个库存初始为50的商品。JMeter里创建线程组,设置线程数100、Ramp-Up时间1秒、循环次数1,也就是1秒内打100个并发请求。HTTP请求指向 POST /seckill/do ,参数传userId和goodsId。跑完之后看聚合报告——重点关注三个指标: TPS(吞吐量)、错误率、响应时间90%分位 。

我用Springboot默认配置跑这样的压测,Tomcat默认最大线程数200,Redis预减链路正常的话TPS应该在2000到3000左右。如果你的TPS低于500,先查两个地方:一是Redis连接池是否过小,Lettuce的默认连接池在并发高时会排队;二是日志里是否有MySQL死锁或锁等待超时,说明数据库层成了瓶颈。错误率超过1%就要停下来看日志,别硬加并发量。

库存一致性验证是重头戏。压测结束后查询数据库:

SELECT stock_count FROM seckill_goods WHERE goods_id = 1;
SELECT COUNT(*) FROM seckill_order WHERE goods_id = 1;

如果库存初始是50,最终 stock_count 加上 seckill_order 的数量必须等于50。不等于就说明有超卖或漏单。超卖的原因参考5.1节,漏单多半是MQ消息丢失或消费者异常,去RabbitMQ控制台的Dead Letter Queue里找有没有消费失败的消息。

压测通过之后,这套系统才算真正“毕业”。下一步改造方向有三个: 把Redis从单机换成哨兵集群 解决单点问题; 把MQ换成RocketMQ或Kafka 解决大流量削峰和消息轨迹查询的问题; 把应用层拆成独立的秒杀服务 和商品服务分开部署,避免秒杀流量打爆商品查询。这些都是同一个方向上的延伸,做完你就有了一套可以拿到生产环境去试水的秒杀系统。

最后说我自己的习惯:每次拿到一套秒杀源码,我第一件事不是改代码,是先跑通链路再做压测,用数据决定要改哪个环节。库存扣减的SQL、限流的阈值、连接池的大小,这些参数没有标准答案,只有压测出来的答案。希望帮到你。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐