SpringBoot_5:商品秒杀场景的并发实践(初步)

目录

一、安装Locust

二、建库建表

三、秒杀接口与业务代码

1、悲观锁

2、悲观锁并发测试

3、乐观锁

4、乐观锁并发测试

5、超卖测试

四、MyBatis-Plus提供的@Version乐观锁注解

五、总结


在学校里,我们曾经开发过TTMS也就是剧院票务系统。我们曾经提出了一些办法去避免超售,但是并没有使用并发工具去实测。所以本文就以Locust测试工具,来测试我们的接口。

一、安装Locust

Locust依赖于python环境,用python语言编写脚本。因此我们需要确保python版本保持在一个较新的状态,我的Python版本为3.14.3为保证安装过程流畅,先升级pip

bash 复制代码
python -m pip install --upgrade pip setuptools wheel

在命令行执行后,现在开始安装Locust

bash 复制代码
pip install locust

输入如下指令看到版本号即安装成功

bash 复制代码
locust --version

编写一个测试脚本:

在vscode中创建一个名为"locustfile.py"的文件,然后编写如下脚本

python 复制代码
from locust import HttpUser, task, between

class MyUser(HttpUser):
    # 模拟用户等待时间:1-3秒
    wait_time = between(1, 3)

    @task
    def home_page(self):
        # 访问根路径
        self.client.get("/")

    @task
    def about_page(self):
        # 访问 about 路径,权重为1(默认)
        self.client.get("/about")

在其所在目录命令行输入:locust即可运行。

运行后,在浏览器输入http://localhost:8089,即可访问locust的webUI.

从上到下依次是:最大用户数,每秒新增多少个用户(一直到最大用户后就保持不变),测试的目标网站地址,如果点开Advanced options里面还有一共需要测试的时间,如果不填则表示一直测试下去,直到手动停止。

这里我们,最大用户数填2,每1秒新增一名用户,host填www.baidu.com测试百度。

点击start后开始测试

测试一段时间后,点击stop

访问了两个接口/和/about,因为百度没有about所以/about一直是失败状态。这两个路径就是我们脚本代码里提前写好的。

二、建库建表

为了方便演示,我们就构建一个库存表,里面存放我们的商品库存。模拟商品秒杀的场景:

sql 复制代码
CREATE TABLE `product_stock` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `product_code` varchar(32) NOT NULL COMMENT '商品编码',
  `stock` int NOT NULL DEFAULT '0' COMMENT '剩余库存',
  `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
  `gmt_modified` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_product_code` (`product_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 初始化一条数据:商品A,库存100
INSERT INTO `product_stock` (`product_code`, `stock`, `version`) VALUES ('SKU_001', 100, 0);

这里我们的初步思路是,业务层面用悲观锁,数据库层面用乐观锁。所以我们给表添加了一个字段version。

现在我们完成实体类和请求和响应的封装:

java 复制代码
/**
*  秒杀请求
*/
@Data
public class SeckillRequest {

    private String productCode;

    private Integer quantity;

    // 可选:用户ID(从token中获取,这里为了演示简单传参)
    private Long userId;
}
java 复制代码
/**
 * 秒杀响应结果
 */
@Data
public class SeckillResponse {
    private String productCode;
    private Integer quantity;
    private Integer remainStock;  // 剩余库存
    private Boolean success;
    private String message;
}

再做一个统一结果封装:

java 复制代码
/**
 * 统一返回结果
 */
@Data
public class Result<T> {
    private Integer code;
    private String msg;
    private T data;
    private Long timestamp;

    public Result(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
        this.timestamp = System.currentTimeMillis();
    }

    public static <T> Result<T> success(T data) {
        return new Result<>(200, "success", data);
    }

    public static <T> Result<T> success(String msg, T data) {
        return new Result<>(200, msg, data);
    }

    public static <T> Result<T> error(String msg) {
        return new Result<>(500, msg, null);
    }

    public static <T> Result<T> error(Integer code, String msg) {
        return new Result<>(code, msg, null);
    }
}

三、秒杀接口与业务代码

现在我们需要编写一个接口用来模拟系统收到用户的购买请求。

java 复制代码
/**
 * 秒杀控制器
 */
@RestController
@RequestMapping("/api/seckill")
@Slf4j
public class SeckillController {

    @Autowired
    private ISeckillService seckillService;

    /**
     * 悲观锁
     * POST /api/seckill/pessimistic
     * Body: {"productCode":"SKU_001", "quantity":1, "userId":1001}
     */
    @PostMapping("/pessimistic")
    public Result<SeckillResponse> pessimisticLockSeckill(@RequestBody SeckillRequest request) {
        log.info("收到混合锁秒杀请求: {}", request);

        // 如果userId为空,生成一个随机ID(演示用)
        if (request.getUserId() == null) {
            request.setUserId((long) (Math.random() * 100000));
        }

        SeckillResponse response = seckillService.decreaseStockWithPessimisticLock(request);
        return response.getSuccess() ? Result.success(response) : Result.error(response.getMessage());
    }

    /**
     * 纯乐观锁秒杀接口(对比测试)
     * POST /api/seckill/pure
     */
    @PostMapping("/pure")
    public Result<SeckillResponse> pureOptimisticSeckill(@RequestBody SeckillRequest request) {
        log.info("收到纯乐观锁秒杀请求: {}", request);

        if (request.getUserId() == null) {
            request.setUserId((long) (Math.random() * 100000));
        }

        SeckillResponse response = seckillService.decreaseStockWithPureOptimistic(request);
        return response.getSuccess() ? Result.success(response) : Result.error(response.getMessage());
    }

    /**
     * 查询商品库存
     * GET /api/seckill/stock/{productCode}
     */
    @GetMapping("/stock/{productCode}")
    public Result<Map<String, Object>> getStock(@PathVariable String productCode) {
        ProductStock stock = seckillService.getStock(productCode);
        if (stock == null) {
            return Result.error("商品不存在");
        }

        Map<String, Object> data = new HashMap<>();
        data.put("productCode", stock.getProductCode());
        data.put("stock", stock.getStock());
        data.put("version", stock.getVersion());
        data.put("gmtModified", stock.getGmtModified());

        return Result.success(data);
    }

    /**
     * 重置库存(测试用)
     * POST /api/seckill/reset?productCode=SKU_001&stock=100
     */
    @PostMapping("/reset")
    public Result<String> resetStock(@RequestParam String productCode,
                                     @RequestParam(defaultValue = "100") Integer stock) {
        try {
            // 直接使用 MyBatis-Plus 的 update 方法
            ProductStock entity = new ProductStock();
            entity.setStock(stock);
            entity.setVersion(0);

            // 这里简化处理,实际需要根据 productCode 更新
            // 建议直接在 SQL 里执行 UPDATE
            log.info("重置库存: {} -> {}", productCode, stock);
            return Result.success("库存已重置为 " + stock);
        } catch (Exception e) {
            return Result.error("重置失败: " + e.getMessage());
        }
    }
}

我们写了三个接口,分别是使用悲观锁、乐观锁处理业务,以及查看商品库存。

现在我们在service层重点实现这些逻辑。

1、悲观锁

java 复制代码
/**
     * 悲观锁
     */
    @Transactional(rollbackFor = Exception.class, timeout = 3)
    public SeckillResponse decreaseStockWithPessimisticLock(SeckillRequest request) {
        String productCode = request.getProductCode();
        Integer quantity = request.getQuantity();
        Long userId = request.getUserId();

        long startTime = System.currentTimeMillis();
        SeckillResponse response = new SeckillResponse();
        response.setProductCode(productCode);
        response.setQuantity(quantity);

        try {
            // 1. 悲观锁查询(串行化读)
            log.debug("用户 {} 开始抢购商品 {}, 使用悲观锁查询", userId, productCode);
            ProductStock stock = stockMapper.selectForUpdate(productCode);

            if (stock == null) {
                log.warn("商品不存在: {}", productCode);
                response.setSuccess(false);
                response.setMessage("商品不存在");
                return response;
            }

            // 2. 业务校验:库存是否充足
            if (stock.getStock() < quantity) {
                log.warn("库存不足, 商品: {}, 当前库存: {}, 需要: {}",
                        productCode, stock.getStock(), quantity);
                response.setSuccess(false);
                response.setMessage("库存不足");
                response.setRemainStock(stock.getStock());
                return response;
            }

            // 3. 更新
            int affectedRows = stockMapper.updateByCode(stock.getProductCode(),quantity);

            // 4. 判断更新结果
            if (affectedRows == 0) {
                response.setSuccess(false);
                response.setMessage("系统繁忙,请重试");
                // 重新查询最新库存
                ProductStock latest = stockMapper.selectById(stock.getId());
                response.setRemainStock(latest != null ? latest.getStock() : 0);
                return response;
            }

            // 5. 扣减成功
            log.info("秒杀成功, 用户: {}, 商品: {}, 数量: {}, 耗时: {}ms",
                    userId, productCode, quantity, System.currentTimeMillis() - startTime);
            response.setSuccess(true);
            response.setMessage("秒杀成功");
            response.setRemainStock(stock.getStock() - quantity);
            return response;

        } catch (Exception e) {
            log.error("秒杀异常, 用户: {}, 商品: {}", userId, productCode, e);
            response.setSuccess(false);
            response.setMessage("系统异常:" + e.getMessage());
            return response;
        }
    }

我们把悲观锁放在一个事务中(只有这样才会生效),而且看我们的SQL.

sql 复制代码
<select id="selectForUpdate" resultType="com.miao.seckill.entity.ProductStock">
        SELECT id, product_code, stock, version, gmt_modified
        FROM product_stock
        WHERE product_code = #{productCode}
            FOR UPDATE
    </select>

使用了FOR UPDATE。其次我们的where条件中使用的是product_code,这是索引字段,因此不会给全表加锁。

那么在这种情况下,其它请求到来时,不能进行update等当前读操作,只能读取MVCC版本快照。

其他用户想要购买只能阻塞到这,等前面的事务结束才会被执行,我们测试一下看看正常单个用户是否能购买成功。

数据库也减少了库存。

2、悲观锁并发测试

现阶段我们的业务过于简单,所以我们手动加一点阻塞:

可以发现,最大一次请求用了427ms,最低一次为啥1ms呢,因为此时库存已经扣完了,走不到休眠逻辑。我们把库存改到999,用低配置跑一下。

平均用时为114ms

先保证数据库库存充足,最开始的配置再跑一遍

这个时候,阻塞已经形成,平均值达到了9552ms!

由此可见,悲观锁在写多读少,事务复杂的场景,例如秒杀场景是非常不合适的。

3、乐观锁

前面我们讲了乐观锁的工作原理,乐观锁用于写多读少,事务复杂的场景。不会阻塞响应,如果失败也会很快给出响应结果,我们可以把代码做如下改造:

java 复制代码
/**
     * 纯乐观锁方案
     */
    @Transactional(rollbackFor = Exception.class)
    public SeckillResponse decreaseStockWithPureOptimistic(SeckillRequest request) {
        String productCode = request.getProductCode();
        Integer quantity = request.getQuantity();
        Long userId = request.getUserId();

        SeckillResponse response = new SeckillResponse();
        response.setProductCode(productCode);
        response.setQuantity(quantity);

        try {
            // 先查询当前版本号
            ProductStock stock = stockMapper.selectOneByProductCode(productCode);
            if (stock == null) {
                response.setSuccess(false);
                response.setMessage("商品不存在");
                return response;
            }

            if (stock.getStock() < quantity) {
                response.setSuccess(false);
                response.setMessage("库存不足");
                response.setRemainStock(stock.getStock());
                return response;
            }

            try {
                Thread.sleep(50);
            }catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            // 直接乐观锁更新
            int affectedRows = stockMapper.decreaseStockWithVersion(
                    productCode, quantity, stock.getVersion()
            );

            if (affectedRows == 0) {
                response.setSuccess(false);
                response.setMessage("库存已更新,请重试");
                ProductStock latest = stockMapper.selectOneByProductCode(productCode);
                response.setRemainStock(latest != null ? latest.getStock() : 0);
                return response;
            }

            response.setSuccess(true);
            response.setMessage("秒杀成功");
            response.setRemainStock(stock.getStock() - quantity);
            return response;

        } catch (Exception e) {
            log.error("纯乐观锁秒杀异常", e);
            response.setSuccess(false);
            response.setMessage("系统异常");
            return response;
        }
    }

版本号可以在放在用户的请求中,也可以在业务执行时查询。我们这种写法也会存在一个问题,那就是A和B可能先后拿到初始版本号,但是B却比A先买到商品,就算把版本放到请求中也会遇到这个问题。

也就是说在并发情况下,谁先买到就说不准了。

4、乐观锁并发测试

按照之前的配置低并发(10,5)测试一下:

平均耗时80ms

我们再用之前的高并发(300,50)测试一下:

可以发现,确实都没有阻塞,而且都成功了。我们想模拟出因为版本不一致,无法购买成功的逻辑。引入线程休眠并不是一个好的方案,因为我们的业务逻辑非常简单所以无法模拟出这样的现象。

所以我们就采用手动执行sql语句去执行。

现在的版本号是1842,我们先执行一次。让版本号变成1843,然后我们拿着1842去修改,看会不会成功。

可以发现受影响的行数为零行,因此我们没有修改成功,version版本还停留在1843

乐观锁下响应速度还是非常快的。

5、超卖测试

现在库存只有2000,编写好locust脚本,假设每次只能买一件商品

python 复制代码
import json
import random
from locust import HttpUser, task, between

productCode = "SKU_001"
quantity = 1
userId = list(range(10001, 10005))


class MyUser(HttpUser):
    # 思考时间:模拟真实用户操作间隔
    wait_time = between(0.5, 1.5)
    
    def on_start(self):
        """用户启动时分配ID"""
        self.user_id = random.choice(userId)
    
    @task
    def optimistic_seckill(self):
        """
        乐观锁秒杀接口压测
        """
        request_data = {
            "productCode": productCode,
            "quantity": quantity,
            "userId": self.user_id
        }
        
        with self.client.post(
            "/api/seckill/pure",
            json=request_data,
            catch_response=True,
            name="乐观锁秒杀"
        ) as response:
            if response.status_code == 200:
                try:
                    data = response.json()
                    
                    # 先判断外层 code
                    if data.get("code") == 200:
                        # 再判断内层 data.success
                        inner_data = data.get("data")
                        if inner_data and inner_data.get("success") is True:
                            response.success()
                        else:
                            # success = false,业务失败(库存不足等)
                            msg = inner_data.get("message", "未知错误") if inner_data else "数据为空"
                            response.failure(f"业务失败: {msg}")
                    else:
                        # 外层 code 不是 200
                        response.failure(f"接口返回错误: {data.get('msg', '未知错误')}")
                        
                except json.JSONDecodeError:
                    response.failure("响应不是有效的JSON")
            else:
                response.failure(f"HTTP状态码异常: {response.status_code}")

填好测试信息

我们发现,一开始就存在失败,说明version乐观锁起到一定作用。响应速度依旧很快。

后面数据库归零后不在扣减,同时稳定后我们发现:Fiils + 2000 = Requests

我们规定的一次只买一件商品因此有这个规律,同时说明并无超售发生。

我们杜绝了超售!

四、MyBatis-Plus提供的@Version乐观锁注解

我们发现只要修改数据,version就得跟着变,如果某条SQL忘了加version校验和自增,那么将会造成严重的业务灾难。

mybatis-plus提供了一个注解,@version,使用前需要导入mybatis-plus依赖

XML 复制代码
<dependency>
   <groupId>com.baomidou</groupId>
   <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
   <version>3.5.7</version>
</dependency>

具体的版本还需要根据springboot版本号指定,当然也可以不指定版本(有BOM的话)

注意:为了避免依赖冲突,mybatis和mybatis-plus只能选择一个使用

同时还需要配置拦截器

java 复制代码
@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 乐观锁拦截器 ← 必须加,否则 @Version 不生效
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        return interceptor;
    }
}

这种仅限于mybatis-plus自动生成的SQL,例如继承BaseMaper,自己写的SQL是不会在update时添加version版本校验的。

其次就是如果update()传入的对象里面version属性为空,那么条件and version = null也会加,但是始终不成立,查不到这样的数据,自然无法修改成功。

我们测试一下:

看到输出的日志,实际执行的SQL里多了and version = 这个条件,这样我们就不用修改数据时每次手写了。

五、总结

本节我们演示了在秒杀场景下,使用悲观锁乐观锁防止数据不一致问题:在具体场景中,两个事务都查到可以售卖但是只能卖一件商品,过了校验之后,再去修改就可能导致超售。有人也许会问:我把校验和修改都丢到一个sql里就行了啊,利用数据库的原子性保证数据一致。但是在企业里,SQL不能承担过多的功能,查数据就只查数据,修改就只修改。如果混用的话,不同的场景下SQL就没法复用了。

随着业务更加复杂,很多校验单纯靠一句SQL能做到吗?不能,必须靠业务层的校验。

因此无论是悲观锁还是乐观锁,在并发场景下都是很有必要的。

悲观锁会阻塞事务,乐观锁会大量失败。那么企业是怎么做的呢?根据两种锁的特点,采取结合的方式。把热点数据放入Redis中,先过滤掉哪些根本不可能成功的操作,这样数据库压力就减轻了。未来我们还会遇到分布式锁,解决跨库事务。

以上就是本节所有内容,如果疏漏请大家指出,作者会认真勘误。

相关推荐
suaizai_1 小时前
AI Agent如何懂你:四层能力拆解
java·前端·人工智能
javachen__1 小时前
Docker一键清理 + 全局限制,告别日志报满
java·docker·容器
陈明勇2 小时前
Go 1.27 重磅发布:泛型方法落地、分配性能跃升,JSON v2 正式可用
后端
早安试言2 小时前
maven安装
java·maven
Zane19942 小时前
create_task 和直接 await 到底有什么不一样?asyncio 的 Task、gather 与并发数量控制
后端·python
喜欢吃豆2 小时前
5 分钟,让网页里站着一个会说话的 3D 数字人
后端
Python私教2 小时前
创业团队做管理系统,别先堆页面:我把 14 个问题做成了需求门禁
后端·python·架构
攻城有术2 小时前
专项攻克-SpringBoot启动后 Bean的实例数量问题
java·spring boot·后端
JimmtButler2 小时前
功能明明正常,架构为什么还是会腐化?<第一章>
后端·架构