ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

06-业务并发场景实战:订单防重、库存扣减、乐观锁悲观锁

06-业务并发场景实战:订单防重、库存扣减、乐观锁悲观锁 业务并发场景实战订单防重、库存扣减、乐观锁悲观锁黒漂技术佬 · 2026年7月前言先说一个真实到让人头皮发麻的故障。某无人售货柜厂商搞了一次限时活动可乐原价 5 元活动价 1 元每人限购 1 瓶。活动上线 10 分钟库存 500 瓶的可乐被扣了 2000 多瓶后台订单流水里同一用户出现了几十条下单记录。运营拿着对账单找到研发研发打开日志一看——用户疯狂点了立即购买按钮前端没做防抖后端没做防重库存扣减也没加锁。一个活动亏了小几千块还赔了一波客诉。这就是并发问题最常见的两个表现重复下单和超卖。它们不是高并发大厂才遇到的问题哪怕你的售货柜一天就几十单只要用户手快点、网络慢一点就能复现。这篇就把订单防重、库存扣减的几种方案从原理到代码讲透让你看完就能用。一、并发问题到底是怎么产生的很多人觉得我用户量又不大哪来的并发。错。并发不一定来自大量用户同一用户的连续点击就是并发。看这个经典时序时刻 T1: 用户点购买 → 线程A 查库存 10 时刻 T2: 用户又点购买 → 线程B 查库存 10A还没扣减 时刻 T3: 线程A 判断 10 1执行扣减库存 9 时刻 T4: 线程B 判断 10 1执行扣减库存 9覆盖了A的结果最终库存从 10 变成 9但实际卖出去了 2 瓶。这就是超卖。重复下单也类似用户点了一次购买请求还没返回网络抖动让前端自动重试了一次后端收到两个一模一样的下单请求各扣了一次库存、各创建了一个订单。解决思路就两条防重同一笔业务请求只能成功处理一次防超卖扣减库存这个操作必须是原子的二、订单防重的三种方案2.1 唯一索引最简单粗暴给订单表加一个业务唯一键比如user_id device_id order_token的组合唯一索引。第二次插入相同的记录会被数据库直接拒绝。CREATETABLEt_order(idBIGINTAUTO_INCREMENTPRIMARYKEY,order_noVARCHAR(32)NOTNULL,user_idBIGINTNOTNULL,device_idBIGINTNOTNULL,statusTINYINTNOTNULL,-- 防重token前端每次进入下单页生成一个UUIDorder_tokenVARCHAR(64)NOTNULL,create_timeDATETIMENOTNULL,UNIQUEKEYuk_token(order_token))ENGINEInnoDB;Transactional(rollbackForException.class)publicOrderVOplaceOrder(OrderCreateDTOdto){OrderDOorderbuildOrder(dto);try{orderMapper.insert(order);}catch(DuplicateKeyExceptione){// 唯一索引冲突 重复请求thrownewBusinessException(请勿重复下单);}// 后续扣库存、写明细...}优点实现极简数据库层面兜底绝对可靠。缺点只能防完全相同的请求如果两次请求的 token 不一样就防不住——所以 token 必须由前端在进入下单页时申请整个下单流程复用同一个 token。2.2 防重 Token前端后端配合这是上面方案的标准用法流程是用户进入下单页前端调/order/token接口拿一个 TokenUUID后端同时把这个 Token 存到 Redis用户点购买请求带上这个 Token后端收到请求先从 Redis 删除这个 TokenDEL命令删除成功才继续下单如果第二次请求带着同样的 TokenRedis 里已经没了直接拒绝// 1. 发放TokenGetMapping(/token)publicStringgetToken(RequestParamLonguserId){StringtokenUUID.randomUUID().toString().replace(-,);stringRedisTemplate.opsForValue().set(order:token:userId,token,10,TimeUnit.MINUTES);returntoken;}// 2. 下单时校验TokenTransactional(rollbackForException.class)publicOrderVOplaceOrder(OrderCreateDTOdto){StringtokenKeyorder:token:dto.getUserId();// Redis DEL 返回1表示删除成功0表示key不存在BooleandeletedstringRedisTemplate.delete(tokenKey);if(!Boolean.TRUE.equals(deleted)){thrownewBusinessException(请勿重复下单);}// 继续下单逻辑...}关键点用DEL的返回值判断而不是先GET再DELETE——后者在并发下会有竞态条件两个线程都 GET 到了值然后都 DELETE 成功。2.3 分布式锁适用复杂场景如果防重逻辑比较复杂比如同一用户 10 秒内只能下一单用 Redis 分布式锁更灵活。publicOrderVOplaceOrder(OrderCreateDTOdto){StringlockKeyorder:lock:dto.getUserId();// 尝试加锁10秒自动过期防止死锁BooleanlockedstringRedisTemplate.opsForValue().setIfAbsent(lockKey,1,10,TimeUnit.SECONDS);if(!Boolean.TRUE.equals(locked)){thrownewBusinessException(操作太频繁请稍后再试);}try{returndoPlaceOrder(dto);}finally{stringRedisTemplate.delete(lockKey);}}生产环境建议用 Redisson 的RLock它自带看门狗续期比手写的setIfAbsent更可靠。三、库存扣减的三种方案对比防重解决了重复下单但解决不了超卖——两个不同用户同时买最后一件商品都得手。扣减库存必须原子化。3.1 方案对比表方案原理优点缺点适用场景悲观锁SELECT ... FOR UPDATE锁住行强一致简单并发时阻塞可能死锁冲突频繁、写多读少乐观锁版本号 version 字段无锁性能好冲突多时重试成本高冲突少、读多写少Redis原子扣减Lua脚本扣减极高性能需要与DB同步秒杀级高并发3.2 悲观锁SELECT … FOR UPDATE悲观锁的思路是先锁住再操作像上厕所先锁门。-- 开启事务后执行SELECTid,stock,versionFROMt_productWHEREid100FORUPDATE;-- 这一行被锁住了其他事务查询会阻塞等待-- 业务判断库存够不够UPDATEt_productSETstockstock-1WHEREid100;COMMIT;MyBatis-Plus 中可以这样写Select(SELECT * FROM t_product WHERE id #{id} FOR UPDATE)ProductDOselectByIdForUpdate(Longid);注意事项必须在事务内使用否则锁立即释放FOR UPDATE会锁行有索引时或锁表无索引时一定要确保WHERE条件走索引锁等待有超时时间innodb_lock_wait_timeout默认50秒超时会报错避免死锁多个锁的操作顺序保持一致3.3 乐观锁版本号 version 字段乐观锁的思路是先操作再验证像抢票——大家都能点提交时发现被人抢了就重试。数据库加一个version字段ALTERTABLEt_productADDCOLUMNversionINTDEFAULT0;扣减时带上版本号条件UPDATEt_productSETstockstock-1,versionversion1WHEREid100ANDversion#{currentVersion};如果返回影响行数 0说明版本号不匹配被人抢先了需要重试。3.4 Redis 原子扣减极高并发场景下每次都打数据库扛不住。用 Redis 缓存库存Lua 脚本保证原子扣减privatestaticfinalStringDEDUCT_SCRIPTlocal stock redis.call(GET, KEYS[1]) if not stock then return -1 end if tonumber(stock) tonumber(ARGV[1]) then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1;publicbooleandeductStock(StringproductKey,intqty){LongresultredisTemplate.execute(newDefaultRedisScript(DEDUCT_SCRIPT,Long.class),Collections.singletonList(productKey),String.valueOf(qty));returnresult!nullresult1;}Redis 扣减成功后再异步同步到数据库。这种方案适合秒杀但要做好 Redis 和 DB 的一致性补偿对账、定时同步。四、乐观锁实战Version 注解 重试机制MyBatis-Plus 内置了乐观锁支持配置很简单。第一步配置乐观锁插件ConfigurationpublicclassMybatisPlusConfig{BeanpublicMybatisPlusInterceptormybatisPlusInterceptor(){MybatisPlusInterceptorinterceptornewMybatisPlusInterceptor();interceptor.addInnerInterceptor(newOptimisticLockerInnerInterceptor());returninterceptor;}}第二步实体类加 VersionDataTableName(t_product)publicclassProductDO{TableId(typeIdType.AUTO)privateLongid;privateStringname;privateIntegerstock;VersionprivateIntegerversion;// 乐观锁版本号}第三步带重试的扣减逻辑ServicepublicclassStockService{AutowiredprivateProductMapperproductMapper;/** * 乐观锁扣减库存最多重试3次 */publicvoiddeductStockWithRetry(LongproductId,intqty){intmaxRetry3;for(inti0;imaxRetry;i){ProductDOproductproductMapper.selectById(productId);if(productnull){thrownewBusinessException(商品不存在);}if(product.getStock()qty){thrownewBusinessException(库存不足);}// 扣减并设置新版本号MyBatis-Plus自动处理versionproduct.setStock(product.getStock()-qty);intaffectedproductMapper.updateById(product);if(affected0){return;// 扣减成功}// affected0 说明版本号不匹配重试log.warn(乐观锁冲突第{}次重试, productId{},i1,productId);}thrownewBusinessException(系统繁忙请稍后再试);}}MyBatis-Plus 的updateById会自动把version作为WHERE条件并执行version 1。你不需要手写 SQL。重试次数的选择并发越高冲突概率越大重试次数要适当增加。但重试太多会浪费 CPU一般 3-5 次够了。如果重试 5 次还失败说明并发量已经超出乐观锁的适用范围该考虑换悲观锁或 Redis 方案了。五、悲观锁实战FOR UPDATE 的正确姿势5.1 基本用法ServicepublicclassOrderService{AutowiredprivateProductMapperproductMapper;Transactional(rollbackForException.class,timeout5)publicvoiddeductStockPessimistic(LongproductId,intqty){// 加行锁ProductDOproductproductMapper.selectByIdForUpdate(productId);if(product.getStock()qty){thrownewBusinessException(库存不足);}productMapper.deductStock(productId,qty);}}5.2 FOR UPDATE 的三个注意事项1. 锁的是行不是查询结果SELECT ... FOR UPDATE锁的是满足 WHERE 条件的行。如果 WHERE 条件没走索引InnoDB 会锁全表行锁升级为表锁。所以id必须是主键或唯一索引。2. 事务要短锁持有时间 整个事务时间。如果你在FOR UPDATE之后还调了远程接口、睡了 2 秒那这把锁就一直被占着其他线程全在等。悲观锁的事务里只做数据库操作不做 RPC、不做文件 IO。3. 避免死锁两个事务分别以不同顺序锁同一批行就会死锁// 事务A先锁商品1再锁商品2// 事务B先锁商品2再锁商品1// → 死锁解决方法统一加锁顺序。比如按productId升序加锁。六、无人售货柜库存并发扣减完整实现把前面的知识串起来实现一个无人售货柜的下单扣库存逻辑。场景用户扫码开门取货关门后系统根据重量/RFID 计算取了哪些商品然后扣减库存。6.1 表结构CREATETABLEt_device_stock(idBIGINTAUTO_INCREMENTPRIMARYKEY,device_idBIGINTNOTNULLCOMMENT货柜ID,product_idBIGINTNOTNULLCOMMENT商品ID,stockINTNOTNULLDEFAULT0COMMENT当前库存,versionINTNOTNULLDEFAULT0COMMENT乐观锁版本号,UNIQUEKEYuk_device_product(device_id,product_id))ENGINEInnoDB;6.2 完整下单代码ServiceSlf4jpublicclassDeviceOrderService{AutowiredprivateDeviceStockMapperstockMapper;AutowiredprivateOrderMapperorderMapper;AutowiredprivateOrderDetailMapperorderDetailMapper;AutowiredprivateStringRedisTemplateredisTemplate;/** * 关门结算扣减库存 生成订单 */Transactional(rollbackForException.class)publicOrderVOsettleOrder(OrderCreateDTOdto){// 1. 防重基于关门事件的唯一TokenStringtokenKeysettle:token:dto.getDeviceId():dto.getCloseEventId();if(!Boolean.TRUE.equals(redisTemplate.delete(tokenKey))){thrownewBusinessException(本次关门已结算请勿重复处理);}// 2. 乐观锁扣减每个商品库存带重试for(OrderItemDTOitem:dto.getItems()){deductWithRetry(dto.getDeviceId(),item.getProductId(),item.getQuantity());}// 3. 创建订单OrderDOordernewOrderDO();order.setOrderNo(generateOrderNo());order.setUserId(dto.getUserId());order.setDeviceId(dto.getDeviceId());order.setStatus(OrderStatus.PAID.getCode());order.setTotalAmount(calculateTotal(dto.getItems()));orderMapper.insert(order);// 4. 批量写订单明细ListOrderDetailDOdetailsdto.getItems().stream().map(item-OrderDetailDO.builder().orderId(order.getId()).productId(item.getProductId()).quantity(item.getQuantity()).unitPrice(item.getUnitPrice()).build()).collect(Collectors.toList());orderDetailMapper.batchInsert(details);returnbuildOrderVO(order,details);}/** * 乐观锁扣减最多重试3次 */privatevoiddeductWithRetry(LongdeviceId,LongproductId,intqty){for(inti0;i3;i){DeviceStockDOstockstockMapper.findByDeviceAndProduct(deviceId,productId);if(stocknull||stock.getStock()qty){thrownewBusinessException(商品[productId]库存不足);}stock.setStock(stock.getStock()-qty);intaffectedstockMapper.updateById(stock);// 自动带version条件if(affected0){return;}log.warn(扣减库存冲突, retry{}, device{}, product{},i1,deviceId,productId);}// 乐观锁冲突太多降级为悲观锁兜底deductWithPessimisticLock(deviceId,productId,qty);}/** * 悲观锁兜底冲突频繁时使用 */privatevoiddeductWithPessimisticLock(LongdeviceId,LongproductId,intqty){DeviceStockDOstockstockMapper.findByDeviceAndProductForUpdate(deviceId,productId);if(stocknull||stock.getStock()qty){thrownewBusinessException(商品[productId]库存不足);}stockMapper.deductStock(deviceId,productId,qty);}}6.3 Mapper 定义publicinterfaceDeviceStockMapperextendsBaseMapperDeviceStockDO{Select(SELECT * FROM t_device_stock WHERE device_id #{deviceId} AND product_id #{productId})DeviceStockDOfindByDeviceAndProduct(Param(deviceId)LongdeviceId,Param(productId)LongproductId);Select(SELECT * FROM t_device_stock WHERE device_id #{deviceId} AND product_id #{productId} FOR UPDATE)DeviceStockDOfindByDeviceAndProductForUpdate(Param(deviceId)LongdeviceId,Param(productId)LongproductId);Update(UPDATE t_device_stock SET stock stock - #{qty} WHERE device_id #{deviceId} AND product_id #{productId})intdeductStock(Param(deviceId)LongdeviceId,Param(productId)LongproductId,Param(qty)intqty);}6.4 为什么这样设计防重用 Redis Token关门事件有唯一 ID基于此生成 Token重复请求直接拦截默认用乐观锁无人售货柜的并发量不算极高一个柜子同时只有一个人在购物乐观锁足够性能好乐观锁冲突时降级悲观锁极端情况下补货时多人同时操作同一商品冲突频繁悲观锁兜底保证不出错整个方法在一个事务里扣库存和写订单要么都成功要么都回滚七、方案选择指南最后给一张决策图帮你选对方案场景特征推荐方案并发低 50 QPS冲突少乐观锁并发中等冲突频繁悲观锁并发极高秒杀级Redis 原子扣减 异步同步DB跨服务、跨数据源分布式锁Redisson简单防重唯一索引记住一句话没有银弹按场景选方案。无人售货柜用乐观锁 悲观锁兜底就够了如果你做的是秒杀那得上 Redis。先跑起来再优化别一上来就过度设计。
返回列表