SpringBoot_5:商品秒杀场景的并发实践(初步) 目录一、安装Locust二、建库建表三、秒杀接口与业务代码1、悲观锁2、悲观锁并发测试3、乐观锁4、乐观锁并发测试5、超卖测试四、MyBatis-Plus提供的Version乐观锁注解五、总结在学校里我们曾经开发过TTMS也就是剧院票务系统。我们曾经提出了一些办法去避免超售但是并没有使用并发工具去实测。所以本文就以Locust测试工具来测试我们的接口。一、安装LocustLocust依赖于python环境用python语言编写脚本。因此我们需要确保python版本保持在一个较新的状态我的Python版本为3.14.3为保证安装过程流畅先升级pippython -m pip install --upgrade pip setuptools wheel在命令行执行后现在开始安装Locustpip install locust输入如下指令看到版本号即安装成功locust --version编写一个测试脚本在vscode中创建一个名为“locustfile.py”的文件然后编写如下脚本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一直是失败状态。这两个路径就是我们脚本代码里提前写好的。二、建库建表为了方便演示我们就构建一个库存表里面存放我们的商品库存。模拟商品秒杀的场景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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 初始化一条数据商品A库存100 INSERT INTO product_stock (product_code, stock, version) VALUES (SKU_001, 100, 0);这里我们的初步思路是业务层面用悲观锁数据库层面用乐观锁。所以我们给表添加了一个字段version。现在我们完成实体类和请求和响应的封装/** * 秒杀请求 */ Data public class SeckillRequest { private String productCode; private Integer quantity; // 可选用户ID从token中获取这里为了演示简单传参 private Long userId; }/** * 秒杀响应结果 */ Data public class SeckillResponse { private String productCode; private Integer quantity; private Integer remainStock; // 剩余库存 private Boolean success; private String message; }再做一个统一结果封装/** * 统一返回结果 */ Data public class ResultT { 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 ResultT success(T data) { return new Result(200, success, data); } public static T ResultT success(String msg, T data) { return new Result(200, msg, data); } public static T ResultT error(String msg) { return new Result(500, msg, null); } public static T ResultT error(Integer code, String msg) { return new Result(code, msg, null); } }三、秒杀接口与业务代码现在我们需要编写一个接口用来模拟系统收到用户的购买请求。/** * 秒杀控制器 */ 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 ResultSeckillResponse 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 ResultSeckillResponse 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 ResultMapString, Object getStock(PathVariable String productCode) { ProductStock stock seckillService.getStock(productCode); if (stock null) { return Result.error(商品不存在); } MapString, 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?productCodeSKU_001stock100 */ PostMapping(/reset) public ResultString 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、悲观锁/** * 悲观锁 */ 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.select idselectForUpdate resultTypecom.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、乐观锁前面我们讲了乐观锁的工作原理乐观锁用于写多读少事务复杂的场景。不会阻塞响应如果失败也会很快给出响应结果我们可以把代码做如下改造/** * 纯乐观锁方案 */ 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、乐观锁并发测试按照之前的配置低并发105测试一下平均耗时80ms我们再用之前的高并发30050测试一下可以发现确实都没有阻塞而且都成功了。我们想模拟出因为版本不一致无法购买成功的逻辑。引入线程休眠并不是一个好的方案因为我们的业务逻辑非常简单所以无法模拟出这样的现象。所以我们就采用手动执行sql语句去执行。现在的版本号是1842我们先执行一次。让版本号变成1843然后我们拿着1842去修改看会不会成功。可以发现受影响的行数为零行因此我们没有修改成功version版本还停留在1843乐观锁下响应速度还是非常快的。5、超卖测试现在库存只有2000编写好locust脚本假设每次只能买一件商品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, jsonrequest_data, catch_responseTrue, 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(fHTTP状态码异常: {response.status_code})填好测试信息我们发现一开始就存在失败说明version乐观锁起到一定作用。响应速度依旧很快。后面数据库归零后不在扣减同时稳定后我们发现Fiils 2000 Requests我们规定的一次只买一件商品因此有这个规律同时说明并无超售发生。我们杜绝了超售四、MyBatis-Plus提供的Version乐观锁注解我们发现只要修改数据version就得跟着变如果某条SQL忘了加version校验和自增那么将会造成严重的业务灾难。mybatis-plus提供了一个注解version使用前需要导入mybatis-plus依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency具体的版本还需要根据springboot版本号指定当然也可以不指定版本有BOM的话注意为了避免依赖冲突mybatis和mybatis-plus只能选择一个使用。同时还需要配置拦截器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中先过滤掉哪些根本不可能成功的操作这样数据库压力就减轻了。未来我们还会遇到分布式锁解决跨库事务。以上就是本节所有内容如果疏漏请大家指出作者会认真勘误。