高并发外卖下单链路实战:库存超卖排查与事务边界设计
苍穹外卖写到第十一天今天的任务很明确把用户端下单这条链路彻底打通。前两天刚把购物车模块跑通但“购物车里有东西”和“能真正提交一笔订单”之间隔着一整条完整的事务链路和一堆并发细节。我原以为今天就是照着接口文档写写 SQL、套个事务注解的事结果从上午理购物车查询开始到晚上连调前端小程序整整被按在地上摩擦了一天。这篇文章把今天的完整过程和踩坑记录写下来尤其是库存超卖的排查链路可以说是实战里最容易翻车的一段。1. 动手写下单前我先把购物车查询这块老代码翻新了一遍1.1 为什么放着能用的购物车不管非要先重构上来就写下单接口是我最早的想法毕竟购物车列表接口上周已经能跑了。但真到了下单流程我才发现那个接口根本扛不住它只把shopping_cart表里每一行原样返回菜品名称、图片、单价全靠前端再去另一个接口查。小程序端要展示购物车列表时前端得先把购物车记录循环一遍再逐个请求菜品接口请求数直接翻几倍用户体验差还是小事关键是高峰期很容易把网关和数据库打爆。更重要的是下单前必须知道商品当前是否还在售。购物车里的记录可能是一个星期前加进去的菜品早就下架了如果不展示“已售罄”状态用户兴冲冲下单到支付环节才发现失败这就是事故。于是我把购物车查询从“单表查询”改成“购物车 菜品/套餐状态”的关联查询一次拿全。1.2 联表查询的 SQL 和返回结构的取舍改查询第一件事就是定 SQL。我的ShoppingCartMapper.xml里原来的语句是select * from shopping_cart where user_id #{userId}我改成这样SELECT sc.id, sc.dish_id, sc.setmeal_id, sc.dish_flavor, sc.number, sc.amount, d.name AS dish_name, d.image AS dish_image, d.price AS dish_price, d.status AS dish_status, s.name AS setmeal_name, s.image AS setmeal_image, s.price AS setmeal_price, s.status AS setmeal_status FROM shopping_cart sc LEFT JOIN dish d ON sc.dish_id d.id LEFT JOIN setmeal s ON sc.setmeal_id s.id WHERE sc.user_id #{userId} ORDER BY sc.create_time DESC这里有两个细节值得说道说道。第一务必用LEFT JOIN而不是INNER JOIN。购物车里的数据是用户自己加的如果菜品后来被物理删除用内连接这条购物车记录会直接消失用户感觉就是“我明明加过怎么不见了”。用左连接至少能让记录还在Service 层再根据dish_status或setmeal_status做标记而不是让记录凭空蒸发。第二为什么不在 SQL 里直接过滤掉下架商品因为购物车展示页需要让用户知道哪些商品已失效而且要允许用户手动删除失效商品。如果 SQL 直接WHERE d.status 1前端永远看不到失效商品也没法提示用户清理反而制造困惑。实体类的返回结构也顺手调整了。购物车 VO 里新增goodsName、goodsImage、goodsStatus这几个字段用一个字段区分是单品还是套餐。前端拿到的数据完全展开不需要二次请求。这样一个接口就把购物车页所有的渲染数据给齐了。1.3 顺手把“加购时价格快照”的坑填掉改查询的时候我突然意识到一个更隐蔽的问题shopping_cart.amount存的是用户加购那一刻的单价快照。外卖改价是常态今天九块九促销明天十五块八如果下单时直接用购物车里的 amount 计算总价用户看到的结算金额就是过期价格等支付回调对账的时候一定出问题。所以我在下单流程中加了一条铁律购物车里的 amount 只用于“展示”最终订单金额必须以商品的当前价格重新计算。单品取dish.price套餐取setmeal.price如果有口味加价还要叠加口味售价。这样不仅订单金额准确后续做财务对账也不会出现“订单表和明细表金额对不上”的扯皮。这个改动看起来小但意义很大。很多项目上线后出现“用户觉得多扣钱”“订单总额和明细对不上”的客诉根源就在价格快照和最新价格的使用场景没有分清楚。今天的翻新其实是把重构成本留在了业务量爆炸之前。2. 下单接口的第一次完整实现事务边界要画在最前面2.1 下单接口到底要拆成几步先别急着写代码我习惯把下单链路拆成清晰的步骤画在心里校验地址。地址必须属于当前登录用户不能拿着别人的地址下单这是个很常见的越权漏洞。遍历购物车明细逐项锁定库存。按最新价格重新计算整单金额。插入订单主表orders和订单明细表order_detail。清空当前用户购物车。返回订单号由前端发起支付。这些步骤看起来简单但顺序是有讲究的。锁库存和算金额必须放在写订单之前不然会出现“订单已经生成了库存其实不够”的尴尬局面。清空购物车要放在最后万一前面某一步抛异常事务回滚后购物车还是原样用户重试成本很低。我现在的 Service 方法签名大概是Transactional(rollbackFor Exception.class) public Long submitOrder(OrderSubmitDTO dto, Long userId) { // 1. 校验地址 AddressBook address addressMapper.getById(dto.getAddressBookId()); if (address null || !address.getUserId().equals(userId)) { throw new BusinessException(收货地址异常); } // 2. 解析购物车、锁库存 ListShoppingCart carts shoppingCartMapper.listByUserId(userId); if (carts null || carts.isEmpty()) { throw new BusinessException(购物车为空); } // 3. 计算订单明细和总价 ListOrderDetail detailList buildOrderDetails(carts); // 4. 插入订单主表 Orders order buildOrder(address, detailList); orderMapper.insert(order); // 5. 批量插入明细 for (OrderDetail detail : detailList) { detail.setOrderId(order.getId()); orderDetailMapper.insert(detail); } // 6. 清空购物车 shoppingCartMapper.deleteByUserId(userId); return order.getId(); }2.2 Transactional 失效的经典现场第一次实现的时候我栽在了一个入门级但特别隐蔽的坑上事务注解加在了一个被this调用的方法上。当时我把完整的业务逻辑拆成了submitOrder和doSubmitOrder两个方法Transactional只加在了doSubmitOrder上然后submitOrder里通过this.doSubmitOrder(dto, userId)去调用。结果下单过程中库存扣减失败抛了异常数据却照样写进了订单表数据库里留下了一堆脏数据。原因并不难理解Spring 的声明式事务是基于 AOP 代理实现的只有从外部通过代理对象调用方法事务增强才会生效。this.doSubmitOrder(...)是对象内部调用走的是this指向的原始对象代理根本没机会拦截注解自然成了摆设。解决办法有两个方向要么把事务注解直接放到入口方法submitOrder上这是最简单直接的要么把事务方法拆到另一个 Service 类里通过注入的代理对象去调用。我选择了前者因为下单本来就应该是一个完整事务事务边界画在最外层方法上才符合语义。与此同时我明确设置了rollbackFor Exception.class。Spring 默认只对RuntimeException和Error回滚如果业务代码抛的是编译期异常事务不会自动回滚这个细节真的能救命。2.3 金额重算时最容易忽略的口味附加费上一节我说了要用最新价格重算金额但真正写buildOrderDetails的时候我发现事情没这么简单。外卖订单里有个很常见的东西叫口味规格比如“中辣”“加香菜”“加酱油”有的口味是要加钱的。这些信息在购物车表里的dish_flavor字段中是一段 JSON 字符串比如[{name:辣度,value:中辣,price:1}]。如果下单的时候只是把dish_flavor原样抄进订单明细金额却只算了菜品基础价那这份明细就是“半残废”的。正确做法是解析口味 JSON把每个口味项的price累加到明细金额里同时把加过费用的口味名称也保留在明细快照中。这样以后用户发起售后客服能清清楚楚看到“这个订单加了中辣多收了1元”。这里提醒一句不要想着在下单时重新查一遍口味表因为口味可能已经被商家修改删除。订单明细的价值就是快照用户下单那一刻的商品和口味信息都应该原样留存之后任何商品调整都不能影响已生成的订单。所以我选择在加购时就把口味快照存到购物车下单时解析快照算金额两边规则完全一致对账的时候省心很多。3. 压测暴露库存超卖完整排查链路3.1 复现超卖的最小压测方式下单接口写完功能上能跑通可我总觉得库存扣减这段写得不够稳。我当时的库存扣减是“先查询库存再在内存里减一然后 UPDATE 回去”的经典写法// 不推荐的写法 Integer stock dishMapper.selectStockById(dishId); if (stock num) { throw new BusinessException(库存不足); } dishMapper.updateStock(dishId, stock - num);这种写法在单线程下没有任何问题但稍微来点并发就会出大事。为了证明这一点我用ab对下单接口做了一轮最简单也最暴力的压测。先给某个菜品把库存设置成 20然后同时发起 100 个下单请求每个都买 1 份命令大概长这样ab -n 100 -c 20 -p order.json -T application/json http://localhost:8080/user/order/submit-n 100表示总共 100 个请求-c 20表示同时保持 20 个并发order.json是下单接口的请求体。压测结束后看数据库那个库存 20 的菜品库存直接变成了负数。更可怕的是订单表和订单明细表已经生成了远超库存数量的下单成功记录。这说明接口在高并发下确实超卖了不是理论问题是实打实的 BUG。为了让这个结论更有说服力我还把压测前后的库存和订单行数都拉出来做了对比。先别急着谈锁复现永远是排查并发问题的第一步。很多同学一上来就改代码改完也不知道到底修没修好没有复现手段后面全是瞎猜。3.2 第一版“无脑加同步锁”为什么不能直接上线拿到超卖结果我的第一反应和大多数人一样给submitOrder方法加上synchronized然后重新压测。结果确实很漂亮200 并发下库存稳定在 0没有负数订单数也正确。但冷静下来我就意识到这个方案只是在单实例环境下骗自己。synchronized锁的是 JVM 里的对象监视器它只会拦截当前这个 Java 进程内的并发。一旦项目按照生产标准部署成多实例前面挂个负载均衡请求被分发到不同的 JVM 上每个实例各有一把锁100 个并发被拆到 20 台机器上每台机器只看到 5 个并发库存照样会超卖。就算后端未来一段时间只部署单实例用synchronized也会把整个下单接口串行化下单这个核心接口的吞吐量直接掉一个量级这不是一个可扩展的方案。这个尝试并不是白费。它起码验证了一件事超卖的本质不是代码笔误而是“检查库存”和“扣减库存”之间不是原子的。只要这两步之间有间隙多线程就能钻进这个间隙把同一个库存余额读走。3.3 真正能扛住的写法条件更新 受影响行数判断想通原理之后解决方案就很清晰了把“检查库存”和“扣减库存”合并成一条数据库层面的原子操作。不需要悲观锁也不需要分布式锁一条带条件的 UPDATE 就能解决UPDATE dish SET stock stock - #{num} WHERE id #{dishId} AND stock #{num}这条 SQL 的执行过程是数据库在更新时锁住这条行记录判断stock #{num}是否成立成立才扣减不成立则影响行数为 0。因为stock - #{num}的赋值和stock #{num}的条件判断在同一个原子操作里检查与扣减之间不再有间隙。Service 层拿到影响行数后做判断int rows dishMapper.deductStock(dishId, num); if (rows 0) { throw new BusinessException(库存不足); }如果影响行数为 0说明库存不够直接抛异常让整个下单事务回滚。如果为 1说明扣减成功可以继续往下走。这里要特别强调UPDATE语句必须在事务里执行并且要放在订单写入之前。一旦后面插入订单主表失败事务回滚会把库存扣减也一起回滚不会出现“库存少了但订单没生成”的状态。有人会问为什么不用SELECT ... FOR UPDATEFOR UPDATE确实是行锁能解决并发问题但它要求你先 SELECT 再 UPDATE多一次往返锁的持有时间也长。高并发下数据库的行锁堆积会让连接池很快耗尽而条件更新是单条语句锁粒度最小持有时间最短更适合下单这种高频接口。还有一个兜底手段可以加在数据库层给 dish 表的 stock 字段加非负约束MySQL 8.0.16 以上版本CHECK约束会真正生效这样就算应用层出 Bug数据库也会拒绝写入负数。我把三种方案做了一个简单对比方便你直接做决策方案原子性适用场景缺点synchronized/本地锁单机内原子单实例、低并发多实例失效串行化降低吞吐SELECT ... FOR UPDATE数据库原子低频、强一致性锁持有时间长容易堆连接UPDATE ... WHERE stock num数据库原子高频扣减、外卖下单必须用影响行数判断不能依赖返回值最后我还给下单入参加了校验商品数量必须是正整数不能传负数和零。否则攻击者完全可以构造一个负数数量的请求让库存不减反增这才是真正意义上的逻辑漏洞。4. 超时未支付自动取消用定时扫描先把流程跑通4.1 为什么这个阶段不直接上 MQ 延迟消息下单流程走完还有一个绕不开的需求用户下了单但一直不支付这张订单不能永远挂着。真实外卖平台的规则通常是“下单后超过一定时间未支付订单自动取消库存自动回补”。对于这个项目最标准的方案当然是引入 MQ 的延迟消息让订单在创建 15 分钟之后自动触发取消逻辑。但我觉得在当前阶段这个复杂度不划算首先要额外部署一套消息队列其次要处理延迟消息的可靠投递和消费幂等这些对现在的项目来说都是比较重的依赖。所以我选择先用 Spring Schedule 定时扫描解决“有”的问题。每次定时任务执行时把那些处于待支付状态且支付截止时间已经过了的订单找出来逐单做取消和库存回补。这个方案没有技术门槛逻辑直接出了问题也很好排查。等到哪天订单量大到每分钟扫描的效率扛不住再平滑替换成 MQ 方案也不迟。下单插入订单主表时我同时计算了支付截止时间order.setStatus(1); // 待支付 order.setPayDeadline(LocalDateTime.now().plusMinutes(15));4.2 定时扫描的 SQL 设计不是简单一条 UPDATE定时任务第一版我想得非常简单一条 SQL 把订单状态从 1 改成 5 就完事。但马上发现问题订单状态改了明细里涉及的菜品库存没有回补这是会出大事的。正确的流程必须是先查出要取消的订单集合再根据订单明细把库存加回去最后才更新订单状态。为了避免单次扫描把全表拖垮我在 SQL 里刻意加了LIMIT每次最多处理 200 条。原因很简单取消订单的回补操作涉及多张表单次处理越多单个事务的持有时间就越长连接池被占住的风险就越大。宁可多跑几次任务也别让一次任务把数据库拖死。SQL 大概是这样的SELECT id, user_id, status, pay_deadline FROM orders WHERE status 1 AND pay_deadline #{now} ORDER BY pay_deadline ASC LIMIT 200这里我特意把判断时间条件用#{now}传参而不是直接在 SQL 里写NOW()。原因是应用服务器和数据库服务器可能不在同一台机器时钟不完全一致如果用数据库的NOW()判断标准就和应用里的订单创建时间存在偏差。最稳妥的做法是统一用应用系统时间作为判断基准。订单表上我给(status, pay_deadline)建了联合索引否则这个定时任务每次都是全表扫描订单量上来之后必然扛不住。4.3 自动取消和回补库存要放在同一个事务里回补库存最怕什么最怕“订单已经取消库存却还扣着”。如果取消订单和回补库存不在同一个事务里中间任何一步失败比如应用重启、数据库抖动就会造成库存账实不符。库存少了用户没法买库存多了商家会觉得莫名其妙这种一致性问题可比超卖还难收拾。我的实现把整个取消动作包在一个事务里Transactional(rollbackFor Exception.class) public void cancelExpiredOrders() { ListLong orderIds orderMapper.listExpiredOrderIds(LocalDateTime.now(), 200); if (orderIds.isEmpty()) { return; } ListOrderDetail details orderDetailMapper.listByOrderIds(orderIds); // 1. 回补库存以订单明细为准逐个菜品加回数量 for (OrderDetail detail : details) { dishMapper.increaseStock(detail.getDishId(), detail.getNumber()); } // 2. 更新订单状态 orderMapper.markAsCancelled(orderIds, 超时未支付系统自动取消); }这里有个细节回补库存必须在更新订单状态之前做。如果把订单状态先置为已取消然后回补库存时数据库连接异常事务回滚了看起来一切都没发生好像也没问题。但如果你不小把状态更新和回补拆成两个事务顺序就变得极其关键。先回补库存就算后面失败最坏结果也就是库存多了、订单还是待支付至少不会出现用户想买却买不到的尴尬。宁可让系统在极端情况下“宽待”一点也不能让库存凭空消失。定时任务我是这样开启的Component public class OrderTimeoutTask { Scheduled(cron 0 * * * * ?) public void run() { try { cancelExpiredOrders(); } catch (Exception e) { log.error(定时取消超时订单失败, e); } } }cron 表达式表示每分钟执行一次。这个频率对 15 分钟支付时限来说完全够用最多让用户多等一分钟体验上无感。5. 前端连调时暴露的约定问题与今日记录5.1 Long 类型主键在 JavaScript 里丢失精度下单接口联调的时候第一个让我哭笑不得的问题不是业务逻辑而是数据类型。后端订单表主键是Long类型雪花 ID20 位的数字通常都很长。结果响应回到小程序端前端同学打印出来的订单 ID 最后几位全变成了 0精度直接丢了。原因不复杂JavaScript 的Number类型能安全表达的整数上限是2^53 - 1大概是 9007199254740991而雪花 ID 动辄十几位超出这个范围后 JS 会自动做舍入。这个问题不解决后面用订单 ID 查详情、发起支付、对账都会串号。后端解决起来很简单让 Jackson 在序列化Long类型的时候统一转成字符串Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder - { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }注意一定要同时配置Long.class和Long.TYPE后者对应基本类型long。只配一个某些字段照样丢精度。这个配置改动影响面比较大凡是返回体里的 Long 字段都会变成字符串所以要在联调一开始就统一约定别等前端写完了再突然改。5.2 接口返回结构别再临时开特例联调过程中我还发现一个差一点埋雷的问题下单接口成功后我一开始直接返回了一个裸的orderId数字而其他所有接口都统一返回ResultT结构。前端同学在调下单接口时不得不单独写一套解析逻辑万一后端某个异常分支忘了包Result前端直接解析崩溃。后来我改成统一返回return Result.success(orderId);其实这不仅是格式统一的问题也是接口设计上的纪律。错误码、返回结构、分页参数这类约定必须在项目早期固定下来不能某个接口因为“方便”就开特例。开一个特例后面就会有人照着特例继续开久而久之接口风格就四分五裂了。5.3 今天最有价值的三个认知傍晚把下单单测和前端连调都跑通之后我坐了一会儿复盘今天一整天的过程。收获最大的不是又学会了哪个框架而是几个特别落地的心得。第一事务边界一定画在入口方法上并且确认rollbackFor。自调用导致Transactional失效这种问题比任何业务逻辑都难发现因为代码能跑、日志正常只有数据异常的时候你才会想起来去查。第二并发问题不要靠“感觉”去修先用压测复现再选一个有明确适用边界的方案。第三像取消订单这种状态变更必须把关联的回补动作放在同一个事务里并且想清楚失败的最坏情况。数据库里那 20 份库存现在终于能稳稳地扣掉了。明天应该可以继续推进支付模块的对接到时候再写新的日记。