ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java Spring Boot提交订单功能开发:一致性设计、事务与防超卖实战

Java Spring Boot提交订单功能开发:一致性设计、事务与防超卖实战 做苍穹外卖做到 day08任务终于从看菜加购物车走到了提交订单。我最初觉得这块挺简单的把购物车的数据搬到订单表删掉购物车完事。可真动手写才发现一个 submit 接口背后牵扯到地址校验、菜品状态、金额计算、订单明细快照、库存扣减和事务回滚一整套链路任何一个环节想当然联调时都会被前端和测试一起找上门。这篇文章把我实现提交订单的完整思路、关键代码和踩坑过程整理出来给正好学到这里的人也给所有用 Java Spring Boot MyBatis 写订单模块的人当个参考。1. 提交订单整个外卖链路里最考验一致性的一环1.1 一次点击背后后端究竟要处理多少步用户在前端点提交订单按钮一触发请求打到/user/order/submit后端要处理的事情远不止插入一条订单记录。我当时按业务顺序拆了一遍至少有这么几件事校验地址簿地址必须存在、必须属于当前登录用户、字段不能缺。查询购物车当前用户购物车里的所有条目购物车为空直接拒绝。校验菜品状态菜品是否还在售、是否下架、库存是否够。计算订单金额逐项累加菜品单价乘数量后端重新算不能信前端传的总额。生成订单主表记录状态、支付状态、订单号、收货人快照、金额快照。生成订单明细记录每个菜品一条包含名称、图片、价格、数量。清空购物车下单成功后购物车要清掉不能留着再下一单。这一串操作里地址存在购物车非空菜品在售金额算对订单落库明细落库购物车清空必须全部成功只要中间任何一个步骤失败前面已经写入的数据就必须全部回滚。我拿这个去跟新同事讲的时候用了句大白话提交订单不是一个插入动作而是一个对账动作。你要同时保证业务数据正确、存储数据完整、用户界面状态干净三件事缺一不可。1.2 三个最常见的想当然学习项目做到这个阶段很多人的代码其实是这么写的金额直接让前端传一个 totalAmount后端存进去就算完。库存先select stock from dish where id ?判断 stock 大于等于购买数量再执行update dish set stock stock - ?。下单成功把购物车一删觉得万事大吉。这三个想当然造成的后果分别是订单金额可以被恶意请求伪造并发下单时库存判断和扣减之间有缝隙菜品会超卖订单明细如果只依赖购物车里的瞬时数据而不做快照之后菜品改名、改价、下架历史订单显示就会跟着变。为什么放在第一节先说这些因为提交订单这个功能真正难的不是写接口而是想清楚数据在哪个时间点以什么形态被锁定。后面的所有代码都是围绕这个核心问题展开的。2. 下单前要搭好的底子购物车与地址簿的快照设计2.1 购物车这张表为什么必须存名字、图片、价格购物车表我们通常叫shopping_cart学习项目里典型字段是这样的CREATE TABLE shopping_cart ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT 用户id, dish_id BIGINT NULL COMMENT 菜品id, setmeal_id BIGINT NULL COMMENT 套餐id, name VARCHAR(32) NOT NULL COMMENT 商品名称, image VARCHAR(255) NULL COMMENT 商品图片, amount DECIMAL(10,2) NOT NULL COMMENT 单价, number INT NOT NULL DEFAULT 1 COMMENT 数量, create_time DATETIME NULL );很多第一次写的人不理解菜品表里已经有 name、image、price 了购物车表为什么还要冗余一遍直接存 dish_id下单的时候再去关联菜品表查名称查价格不就行了能查但不能只靠查。原因在于快照两个字。下单动作发生在某个具体时间点这个时间点之后菜品可能涨价、可能改名、可能下架这些都不应该影响已经生成的订单。购物车在用户加购的那一刻把菜品信息复制一层本质上是把用户看到的、确认要买的东西固定下来等真正下单时就按这一份固定数据生成订单明细。另外一个实际好处是减少下单时的查询压力提交订单时只用读购物车表不用再逐条 join 菜品表。2.2 地址簿下单那一刻记下的才是当时的地址地址簿表address_book是我们做订单前要一起梳理清楚的另一张底表CREATE TABLE address_book ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, consignee VARCHAR(32) NOT NULL COMMENT 收货人, phone VARCHAR(20) NOT NULL COMMENT 手机号, province VARCHAR(32) NULL COMMENT 省份, city VARCHAR(32) NULL, district VARCHAR(32) NULL, detail VARCHAR(128) NULL, label VARCHAR(16) NULL COMMENT 标签家/公司, is_default TINYINT DEFAULT 0 COMMENT 默认地址 );用户提交订单时前端传过来的是 addressBookId后端要做两件事一是校验这个地址存在且属于当前用户二是把地址信息完整拷贝进订单表而不是只存一个 address_book_id。为什么不能只存 id很简单。订单是历史事实地址簿里的地址是可变资料。用户今天下单用家里地址明天他修改了地址簿里的家那你一个多月后看订单记录收货地址就不对了。做外卖项目时这问题不明显但思路要一开始就摆正凡是订单需要展示的信息下单那一刻都要物化成快照落进订单表。这里我额外补一个细节真正的生产中地址簿里通常还有经纬度和楼栋门牌号下单时还会做配送范围判断——比如某些地区不配送。苍穹外卖的课程版本不一定要求这一步但你在设计数据结构时给orders表预留address字段做完整拼接是必须的。2.3 从购物车到订单哪些信息必须物化我整理了一个小表写代码前对着它逐项打钩能少漏很多东西信息来源落库位置为什么要快照菜品名称、图片、单价购物车order_detail菜品后续改名改价不影响历史订单菜品数量购物车order_detail订单明细必须独立存数量收货人、电话、地址地址簿orders用户改地址订单不能跟着变订单金额后端重算orders防止前端篡改保证金额一致下单时间服务器时间orders不能信任客户端时间支付方式、支付状态后端生成orders订单流程的状态基础有了这张表你再去看提交订单的接口思路就清晰了它本质上是把散落在购物车、地址簿里的可变数据统一转换成一份不可变的历史记录。3. 从Controller到Mapper提交订单主流程代码怎么落地3.1 两张核心表orders 和 order_detail 的字段设计下单主表 orders我从学习项目里提炼出的通用版本CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, number VARCHAR(64) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL, status INT DEFAULT 1 COMMENT 1待付款 2待接单 3待送达 4已完成 5已取消, pay_status INT DEFAULT 0 COMMENT 0未支付 1已支付, order_time DATETIME NOT NULL, estimated_delivery_time DATETIME NULL COMMENT 预计送达时间, consignee VARCHAR(32) NOT NULL COMMENT 收货人, phone VARCHAR(20) NOT NULL, address VARCHAR(255) NOT NULL COMMENT 完整地址快照, amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, remark VARCHAR(255) NULL COMMENT 备注, cancel_time DATETIME NULL, delivery_time DATETIME NULL, UNIQUE KEY uk_number (number), KEY idx_user_id (user_id) );订单明细表 order_detail一张独立表和 orders 是一对多CREATE TABLE order_detail ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, dish_id BIGINT NULL, setmeal_id BIGINT NULL, name VARCHAR(64) NOT NULL COMMENT 商品名称快照, image VARCHAR(255) NULL, amount DECIMAL(10,2) NOT NULL COMMENT 单价快照, number INT NOT NULL COMMENT 份数, KEY idx_order_id (order_id) );我把订单明细独立成表而不是在订单表里存一个 JSON 字符串主要原因是后续接单、派单、统计销量、评价系统都要用到明细粒度数据单独一张表查起来方便得多。日志和缓存里可以存 JSON业务表不要这么干。3.2 DTO与VO的边界怎么划提交订单请求体前端真正需要传给后端的东西其实很少public class OrdersSubmitDTO { NotNull(message 地址id不能为空) private Long addressBookId; NotNull(message 支付方式不能为空) private Integer payMethod; private String remark; }购物车里的明细、金额、菜品列表后端都有数据不需要前端传。这里有个设计原则前端传的字段越少越不容易被篡改、越不容易因为字段名不一致而出错。返回给前端的 VO 则是下单成功后需要展示给用户的信息public class OrdersSubmitVO { private Long id; private String orderNumber; private BigDecimal orderAmount; private LocalDateTime orderTime; }用户支付页面和订单列表页要用到这些所以接口返回它们就够了。不要图省事把整个 Order 实体直接返回里面很多字段是内部状态暴露出去没有意义还多传不必要的流量。3.3 订单号生成时间戳 随机数看着简单坑也不少订单号要可读性强、有大致的时间感所以我用的是时间戳加随机数public static String buildOrderNumber() { return DateTimeFormatter.ofPattern(yyyyMMddHHmmss).format(LocalDateTime.now()) String.format(%04d, ThreadLocalRandom.current().nextInt(10000)); }这个写法的问题是同一秒内并发订单多了随机数存在碰撞概率。学习项目并发量小问题不大但你要有这个意识。我在表结构里给 number 加了唯一索引万一真撞了插入时报主键冲突事务回滚用户重新下单就好不会静默产生脏数据。在生产项目里订单号一般会改用更严格的方案Redis 自增序列、雪花算法、美团 Leaf 这类。它们解决的不仅是唯一性还有全局有序、跨机房不重复、趋势递增不伤数据库索引。你自己写的时候可以留一个OrderNumberGenerator接口后面想换实现随时换。3.4 Service 层主流程先把步骤写出来再填代码我建议动手前先在注释里把流程写清楚再逐段实现。我当时的 Service 核心代码如下Transactional(rollbackFor Exception.class) public OrdersSubmitVO submit(OrdersSubmitDTO dto, Long userId) { // 1. 校验地址簿必须是当前用户本人的地址 AddressBook address addressBookMapper.getByIdAndUserId(dto.getAddressBookId(), userId); if (address null) { throw new OrderBusinessException(地址信息不合法请重新选择); } // 2. 查询购物车为空不能下单 ListShoppingCart cartList shoppingCartMapper.listByUserId(userId); if (cartList null || cartList.isEmpty()) { throw new OrderBusinessException(购物车为空不能下单); } // 3. 汇总金额并校验菜品状态 BigDecimal totalAmount BigDecimal.ZERO; for (ShoppingCart item : cartList) { if (item.getDishId() ! null) { Dish dish dishMapper.getById(item.getDishId()); if (dish null || DishStatus.UNAVAILABLE.equals(dish.getStatus())) { throw new OrderBusinessException(菜品 item.getName() 已停售请重新下单); } } totalAmount totalAmount .add(item.getAmount().multiply(BigDecimal.valueOf(item.getNumber()))); } // 4. 生成订单主表保存地址快照 Orders order new Orders(); order.setNumber(buildOrderNumber()); order.setUserId(userId); order.setStatus(OrderStatus.PENDING_PAYMENT); order.setPayStatus(PayStatus.UNPAID); order.setAmount(totalAmount); order.setConsignee(address.getConsignee()); order.setPhone(address.getPhone()); order.setAddress(address.getProvince() address.getCity() address.getDistrict() address.getDetail()); order.setOrderTime(LocalDateTime.now()); order.setRemark(dto.getRemark()); ordersMapper.insert(order); // 5. 生成订单明细列表 ListOrderDetail detailList new ArrayList(); for (ShoppingCart item : cartList) { OrderDetail detail new OrderDetail(); detail.setOrderId(order.getId()); detail.setDishId(item.getDishId()); detail.setSetmealId(item.getSetmealId()); detail.setName(item.getName()); detail.setImage(item.getImage()); detail.setAmount(item.getAmount()); detail.setNumber(item.getNumber()); detailList.add(detail); } orderDetailMapper.insertBatch(detailList); // 6. 清空购物车 shoppingCartMapper.cleanByUserId(userId); // 7. 返回下单成功信息 OrdersSubmitVO vo new OrdersSubmitVO(); vo.setId(order.getId()); vo.setOrderNumber(order.getNumber()); vo.setOrderAmount(order.getAmount()); vo.setOrderTime(order.getOrderTime()); return vo; }这段代码有两点我想特意说明。第一事务加在最外层的 submit 方法上。如果我在 Controller 里调 service事务就加在 service 的 public 方法上这样整个步骤 1 到步骤 6 全部在一个事务里任何一步抛异常订单、明细、购物车都会回滚到最初状态。第二金额计算放在校验菜品状态循环里顺手就做了不要先算一次、再单独循环一次。这种一体两用的循环能少一次全表遍历代码也更紧凑。3.5 Mapper 的两个关键 SQL批量插入与条件更新批量插入订单明细MyBatis 里用 foreachinsert idinsertBatch INSERT INTO order_detail (order_id, dish_id, setmeal_id, name, image, amount, number) VALUES foreach collectionlist itemitem separator, (#{item.orderId}, #{item.dishId}, #{item.setmealId}, #{item.name}, #{item.image}, #{item.amount}, #{item.number}) /foreach /insert清空购物车注意一定要带上 userIddelete idcleanByUserId DELETE FROM shopping_cart WHERE user_id #{userId} /delete购物车清空这个操作很多人容易忘了带 user_id 条件导致把别人的购物车也清了。这种低级错误通常只在测试时发现排查起来特别看心态。所有涉及用户数据的 SQL都强制带 user_id这是写业务代码的基本素养。4. 金额重算、库存防超卖、事务边界三个容易翻车的细节4.1 金额永远不要信前端BigDecimal 才是朋友很多第一次做订单的人会问前端下单页明明已经算了总价直接传给我存起来不就行了不行。订单金额一旦被恶意篡改轻则报表对不上重则造成实质资金损失。正确的做法是后端完全重新计算拿购物车里的单价乘数量累加得到订单总额。计算时注意一个基本问题金额字段全程用 BigDecimal不要用 double。0.1 加 0.2 用 double 算会出现一长串浮点误差存进数据库以后对不上账排查成本极高。购物车表的 amount 我用的是 DECIMAL(10,2)Java 实体对应 BigDecimal从源头就避开精度问题。如果你做得更严谨还可以在提交时拿购物车快照价去比对菜品表当前价如果价格变了提示用户部分菜品价格已更新请重新确认。学习项目里不强制要求但接口结构上你完全可以预留这个判断。4.2 库存扣减先查再扣是错的条件更新才对下单要扣库存这是绕不开的。初学者的写法通常是这样Dish dish dishMapper.getById(item.getDishId()); if (dish.getStock() item.getNumber()) { dishMapper.decreaseStock(item.getDishId(), item.getNumber()); }这个写法在并发场景下有问题两个请求同时读到 stock 1都判断库存够然后都执行扣减最终库存变成 -1超卖了。正确做法是把检查库存和扣减库存合并成一条条件更新 SQL数据库层面保证原子性int updated dishMapper.decreaseStockWithCheck(item.getDishId(), item.getNumber()); if (updated 0) { throw new OrderBusinessException(菜品 item.getName() 库存不足); }对应 SQLUPDATE dish SET stock stock - #{number} WHERE id #{id} AND status 1 AND stock #{number}这条 SQL 的意思是只有当前库存不小于购买数量时才允许执行扣减。受影响行数为 0 就说明库存不够或者菜品已停售直接抛异常回滚事务。学习项目里的菜品表不一定有 stock 字段有的版本只做上下架状态判断。那你至少要把status 1这个条件带上保证不会给下架菜品下单。我建议自己练习时加上库存字段体验一下真正的条件扣减写法这个思路在秒杀、抢购、库存系统里是通用的。4.3 Transactional 不是万能我遇到过的几种失效场景下单这种多步写操作没有事务是万万不行的。但Transactional加上去不代表一定生效。我实际排障时遇到过的失效场景整理成了一张表失效场景原因解决办法同类内部方法调用 this.submit()事务代理没有介入拆成不同 Service 调用或注入自身代理异常被 catch 住没往外抛事务不知道出错抛出 RuntimeException或手动 setRollbackOnly方法定义为 private代理对象无法增强保证方法是 publicMySQL 表引擎是 MyISAM不支持事务建表用 ENGINEInnoDB异常类型不是 RuntimeException默认只回滚运行时异常rollbackFor Exception.class我的 Service 里加的是Transactional(rollbackFor Exception.class)这样连受检异常也一并回滚。接单、派单、退款这些后续模块都按同样的姿势写保持风格统一。另外强调一句事务不是越界越好。把耗时的远程调用、消息推送、文件上传放进事务里会让数据库连接长时间被占用并发一高就出问题。下单事务里只做纯数据库操作像下单成功发送短信这类动作放到事务提交后再执行。5. 实测排障重复下单、售罄遗留和订单半截的排查全程5.1 前端双击导致重复订单后端怎么兜底第一次联调时我在测试环境点了一遍提交按钮等接口响应的时间里手又点了一下结果后端生成了两笔一模一样的订单。这个问题的根因不在前端在后端缺少幂等处理。最简单的方案是一次性请求标识前端在下单页初始化时请求一个唯一的 requestNo 存到 Redis提交订单时带上后端用setIfAbsent判断这个 requestNo 是否已经被用过Boolean success redisTemplate.opsForValue() .setIfAbsent(order:submit: userId : dto.getRequestNo(), 1, Duration.ofSeconds(30)); if (Boolean.FALSE.equals(success)) { throw new OrderBusinessException(订单正在提交中请勿重复操作); }setIfAbsent是原子操作多个并发请求同时到达时只有一个能拿到 true其余全部走异常分支。为什么加 30 秒过期因为如果事务失败回滚了这个 key 还能自动释放不会把下单渠道堵死。学习项目没强制要求幂等但你在真实项目里一定会遇到。我的建议是哪怕课程不要求也把这条记在心里这是订单系统和高并发系统的必修课。5.2 菜品已经售罄购物车还残留提交时才暴露跟着苍穹外卖的流程走你会发现购物车里加了一个菜品过一会商家把它下架了你再提交订单接口应该怎么处理我的处理是下单前逐个校验菜品状态和库存遇到停售的直接把整个下单请求抛异常并给出明确提示而不是悄悄跳过这个菜品继续下单。if (dish null || dish.getStatus() ! 1) { throw new OrderBusinessException(菜品【 item.getName() 】已下架请先在购物车移除后重新提交); }提示信息要具体到是哪个菜品方便用户去购物车操作。更完善一点的做法是把异常信息返回到前端弹窗让用户选择去购物车修改还是删除下架商品后再下单。课程里做个提示就够但思路要通。5.3 订单半截了怎么一步步定位有段时间测试反馈订单表里多了一条记录但订单明细表里对应的明细查不到购物车也没清空。一看就是事务没生效或者某一步代码没有抛异常。我的排查顺序是这样的先看接口日志找到这次下单对应的订单号确认请求是否完整走完。查 orders 表看有没有订单记录状态是什么。查 order_detail 表按 order_id 查明细是否存在。查 shopping_cart 表看购物车清空了没有。对照上面四个结果落在哪个环节断了就去查那一段代码。那次问题最终定位在异常被 catch 住没有重新抛出。我在 Service 里写了 try-catch 打印日志却没往外抛导致 MyBatis 插入明细失败后事务照常提交了主表记录。这就是事务失效的典型场景。以后凡是这种多表联动操作我建议日志里把核心节点都打出来包括订单号、订单金额、明细条数、购物车清空结果。日志不丢排障效率至少翻一倍。6. 收尾自查提交订单功能上线前我必跑的清单6.1 一个实测可用的验收清单我把提交订单模块能踩的坑都整理成了一份自查清单每次写完这个功能我都会对着它过一遍检查项通过标准地址校验非本人地址返回明确异常不落库空购物车购物车为空时下单被拦截提示清晰金额重算后端金额与前端展示一致不接受前端传值订单号唯一并发提交不产生重复订单号事务回滚明细写入失败时订单主表同步回滚库存扣减条件更新 SQL 生效并发下不超卖购物车清理仅清理当前用户的购物车不影响他人幂等兜底重复点击不产生重复订单至少心里有方案异常返回下单失败时返回业务文案而不是 500 堆栈日志完整订单号、金额、明细数在日志里可追溯6.2 两个让我少挨骂的习惯最后分享两个我实际养成的小习惯也都是从踩坑里换来的。第一个是金额和数量计算一律用 BigDecimal。这不仅针对订单金额购物车小计、订单明细金额、退款金额统统一样。double 的精度问题不爆发则已一爆发就是账目事故。第二个是所有查用户数据的 SQL 都强制带 user_id 条件。购物车清空要带地址查询要带订单查询也要带。看起来多写一个条件实际上挡住了大量水平越权的低级漏洞。提交订单这个功能做完苍穹外卖项目才算是真正进入了交易闭环。后面还要接支付、接派单、接订单状态流转但核心的数据一致性思维就是从这里开始建立的。你把提交订单这一环做成什么样决定了后续那些功能写起来是顺水推舟还是步步惊心。
返回列表