苍穹外卖写到第十一天,今天的任务很明确:把用户端下单这条链路彻底打通。前两天刚把购物车模块跑通,但“购物车里有东西”和“能真正提交一笔订单”之间,隔着一整条完整的事务链路和一堆并发细节。我原以为今天就是照着接口文档写写 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. 解析购物车、锁库存 List<ShoppingCart> carts = shoppingCartMapper.listByUserId(userId); if (carts == null || carts.isEmpty()) { throw new BusinessException("购物车为空"); } // 3. 计算订单明细和总价 List<OrderDetail> 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 UPDATE?FOR 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() { List<Long> orderIds = orderMapper.listExpiredOrderIds(LocalDateTime.now(), 200); if (orderIds.isEmpty()) { return; } List<OrderDetail> 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类型,雪花 ID,20 位的数字通常都很长。结果响应回到小程序端,前端同学打印出来的订单 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数字,而其他所有接口都统一返回Result<T>结构。前端同学在调下单接口时不得不单独写一套解析逻辑,万一后端某个异常分支忘了包Result,前端直接解析崩溃。
后来我改成统一返回:
return Result.success(orderId);其实这不仅是格式统一的问题,也是接口设计上的纪律。错误码、返回结构、分页参数这类约定必须在项目早期固定下来,不能某个接口因为“方便”就开特例。开一个特例,后面就会有人照着特例继续开,久而久之接口风格就四分五裂了。
5.3 今天最有价值的三个认知
傍晚把下单单测和前端连调都跑通之后,我坐了一会儿,复盘今天一整天的过程。收获最大的不是又学会了哪个框架,而是几个特别落地的心得。
第一,事务边界一定画在入口方法上,并且确认rollbackFor。自调用导致@Transactional失效这种问题,比任何业务逻辑都难发现,因为代码能跑、日志正常,只有数据异常的时候你才会想起来去查。第二,并发问题不要靠“感觉”去修,先用压测复现,再选一个有明确适用边界的方案。第三,像取消订单这种状态变更,必须把关联的回补动作放在同一个事务里,并且想清楚失败的最坏情况。
数据库里那 20 份库存现在终于能稳稳地扣掉了。明天应该可以继续推进支付模块的对接,到时候再写新的日记。