苍穹外卖day08提交订单:事务、多表写入与数据一致性实战解析
做了快十年的Java开发带新人的时候总会遇到一个经典项目叫苍穹外卖。很多自学Java的朋友从环境配置一路撸到购物车前面都顺风顺水结果一走到第8天的提交订单就开始各种卡壳。这个模块在技术上不算难但它同时牵扯到事务、多表写入、数据一致性、金额计算这些高频考点几乎是把之前学的东西一次性串起来了。这篇文章就围绕苍穹外卖day08的提交订单功能讲清楚整个下单流程的拆解思路、核心代码怎么落地、以及我自己实操中踩过的那些坑。如果你正在跟着视频做苍穹外卖或者准备把项目写进简历想弄明白“提交订单”背后到底发生了什么这篇文章就是给你准备的。即使你只是Java基础阶段想了解一个真实的下单接口长什么样也可以放心往下看代码和思路我都会尽量拆到最细。1. 提交订单功能的设计拆解一个完整交易闭环的最后一跳1.1 功能在整个项目中的位置与前置依赖苍穹外卖是一个前后端分离的外卖点餐练习项目技术栈就是最主流的Spring Boot加MyBatis加MySQL用户端部分在day08之前已经完成了微信登录、分类浏览、菜品查询、购物车增删改查。到了提交订单这一步等于用户把菜加进购物车看完之后终于点下了“去结算”的那个按钮。这个动作在业务上的分量很重。它是用户从“逛”到“买”的转折点是交易闭环真正闭合的地方。之前所有购物车操作都是临时数据丢了无所谓但订单一旦落库就变成了必须持久化、必须准确、必须可追溯的业务核心。这也是为什么项目组会把提交订单这块单独安排一天来讲因为它第一次让学员面对“一次请求同时写多张表”的完整过程。前置依赖其实非常清晰用户必须已经登录拿到userId购物车里必须有数据用户在下单时选中的收货地址必须存在。这些都满足之后后端要做的就是把购物车数据转换成订单数据把地址信息快照到订单里然后清空购物车最后返回一个包含订单号、金额、下单时间的结果给前端。前端收到这个结果后再去拉起支付。1.2 订单主表和明细表为什么非要拆成两张我第一次带新人做这个项目时有人问我“订单就一个表每道菜存一行不行吗”技术上当然能存但你会立刻发现问题一个订单里有三道菜那收货人、电话、地址、订单状态这些信息就要在表里重复出现三次。如果后续要改订单状态你需要update三行还得时刻保证这三行数据完全一致维护成本直线上升。所以正规设计都会拆成两张表。orders表叫订单主表一条订单只有一行记录收货人、电话、地址、订单金额、支付状态、下单时间这些“订单头”信息order_detail表叫订单明细表一个订单有多少道菜这里就有多少行记录菜名、图片、口味、数量、单价这些“订单项”信息。两张表通过order_id字段关联。这个设计逻辑用生活类比特别好懂。就像你去网购快递包裹外面贴的快递单写的是收件人信息和包裹整体信息这是orders表打开包裹里面的物品清单每件商品各写一行这是order_detail表。快递员扫码只看快递单就够了但你到底买了啥得看清单。1.3 订单状态机与金额单位的设计取舍订单表里有两个状态字段特别容易混一个是status一个是pay_status。status表示订单本身走到哪一步了苍穹外卖里定义的是1待付款、2待接单、3已接单、4派送中、5已完成、6已取消、7退款。pay_status则只关心钱0未支付、1已支付、2退款。提交订单时新订单的status直接置为1待付款pay_status置为0未支付因为这时候用户还没真正付款。这里有一个非常关键的细节订单金额在数据库里是DECIMAL(10,2)但Java实体类里用的是Long而且单位是分不是元。为什么这么设计因为double计算金额会出精度问题0.1加0.2在很多场景下算出来不是0.3这在钱上是不能忍的。用double存金额哪怕只有一分钱的误差对账的时候都会非常痛苦。BigDecimal精度没问题但如果说项目里到处都用BigDecimal写起来又繁琐还是容易在转换时出幺蛾子。直接把金额用Long存成整数分加减乘除都是整数运算既没有精度问题也没有转换负担展示给前端时再除以100转成元。这个习惯如果你在做别的项目也可以直接沿用凡是涉及钱的字段优先考虑用最小单位的整数。2. 下单核心流程从请求到落库的完整链路2.1 DTO、VO、实体类的职责划分写后端接口之前要先把三个类理清楚。OrdersSubmitDTO是前端传给后端的下单参数里面包含addressBookId地址簿id、payMethod支付方式微信或支付宝、remark备注、estimatedDeliveryTime预计送达时间、packAmount打包费、tablewareNumber餐具数量、tablewareStatus餐具数量状态。这些字段对应的是用户下单页面上填的那些信息。Orders是实体类映射orders表除了表字段之外它里面还会有一个非表字段orderDetailList类型是List用来承载这个订单的明细数据。这个字段在数据库里没有对应列MyBatis插入时要用动态SQL或者单独处理不能直接当成普通字段去insert。OrderSubmitVO是后端返回给前端的下单结果包含id订单id、orderNumber订单号、orderAmount订单金额、orderTime下单时间。前端支付的时候需要用到这些数据。三个类各司其职DTO管进来VO管出去实体管存储这个分层习惯在真实项目中非常常见也经常是面试官会问的点。先看用户端Controller的代码我加了RestController并指定bean名称为userOrderController因为管理端还有一个订单Controller类名相同会发生冲突用不同的bean名称可以避免这个问题。RestController(userOrderController) RequestMapping(/user/order) Api(tags C端-订单相关接口) public class OrderController { Autowired private OrderService orderService; PostMapping(/submit) ApiOperation(用户下单) public ResultOrderSubmitVO submit(RequestBody OrdersSubmitDTO ordersSubmitDTO) { log.info(用户下单参数{}, ordersSubmitDTO); OrderSubmitVO orderSubmitVO orderService.submitOrder(ordersSubmitDTO); return Result.success(orderSubmitVO); } }2.2 Service层五步走的代码实现提交订单的Service核心方法submitOrder是全天内容的重点。整个方法按顺序做五件事校验地址和购物车、构造订单主表数据、构造订单明细数据、批量插入订单和明细、清空购物车。先把代码完整贴出来然后一行一行拆开讲。Override Transactional public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) { // 1. 地址簿校验 AddressBook addressBook addressBookMapper.getById(ordersSubmitDTO.getAddressBookId()); if (addressBook null) { throw new OrderBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL); } // 2. 购物车校验 Long userId BaseContext.getCurrentId(); ShoppingCart shoppingCart new ShoppingCart(); shoppingCart.setUserId(userId); ListShoppingCart shoppingCartList shoppingCartMapper.list(shoppingCart); if (shoppingCartList null || shoppingCartList.isEmpty()) { throw new OrderBusinessException(MessageConstant.SHOPPING_CART_IS_NULL); } // 3. 构造订单主表数据 Orders orders new Orders(); orders.setNumber(String.valueOf(System.currentTimeMillis())); orders.setStatus(Orders.PENDING_PAYMENT); orders.setUserId(userId); orders.setAddressBookId(ordersSubmitDTO.getAddressBookId()); orders.setOrderTime(LocalDateTime.now()); orders.setPayStatus(Orders.UN_PAID); orders.setPhone(addressBook.getPhone()); orders.setConsignee(addressBook.getConsignee()); String address addressBook.getProvinceName() addressBook.getCityName() addressBook.getDistrictName() addressBook.getDetail(); orders.setAddress(address); // 4. 构造订单明细数据并计算总金额 Long totalAmount 0L; ListOrderDetail orderDetailList new ArrayList(); for (ShoppingCart cart : shoppingCartList) { OrderDetail orderDetail new OrderDetail(); BeanUtils.copyProperties(cart, orderDetail); orderDetail.setOrderId(orders.getId()); orderDetailList.add(orderDetail); totalAmount cart.getAmount() * cart.getNumber(); } orders.setAmount(totalAmount); orders.setOrderDetailList(orderDetailList); // 5. 插入订单主表 orderMapper.insert(orders); // 6. 批量插入订单明细 for (OrderDetail orderDetail : orderDetailList) { orderDetail.setOrderId(orders.getId()); orderDetailMapper.insert(orderDetail); } // 7. 清空购物车 shoppingCartMapper.clean(userId); // 8. 封装返回结果 return OrderSubmitVO.builder() .id(orders.getId()) .orderNumber(orders.getNumber()) .orderAmount(orders.getAmount()) .orderTime(orders.getOrderTime()) .build(); }第一步查地址簿。用前端传过来的addressBookId去address_book表查记录查不到说明用户下单时选中的地址已经不存在了直接抛业务异常。这个异常会被全局异常处理器捕获返给前端一个友好的提示而不是让用户看到500页面。第二步查购物车。这里有个很关键的点购物车表是按用户维度存储的所以只要查当前userId下的所有购物车记录即可。如果queryWrapper查出来是空说明这个用户的购物车里没东西同样抛异常。第三步构造订单主表数据。很多字段都有明确的来源订单号是当前毫秒级时间戳转字符串状态码固定为待付款1用户id从ThreadLocal里拿支付状态固定为未支付0手机号和收货人从地址簿快照过来完整地址由省市区和详细地址拼接而成。这里有一个我的个人习惯既然地址信息已经从地址簿里面查出来了就顺手把这些字段塞进orders后面同一个订单的收货信息就不会再变。第四步构造明细并算总额。遍历购物车列表把购物车里的每个商品复制成OrderDetail对象。这里为什么用BeanUtils.copyProperties因为购物车和订单明细的字段高度重合都有name、image、dishId、setmealId、dishFlavor、number、amount直接拷贝非常省事。要特别注意的是拷贝完必须二次确认orderId有没有正确回填这段代码里我先在循环里set了一次orderId插完主表后又重新set了一次就是为了避免主键回填时机导致的值丢失。金额累加这里用的是Long单位分所以乘法结果不会出现小数精度问题。第五步和第六步插主表、插明细。先insert orders主表触发MyBatis的主键回填让orders.getId()变成真正自增出来的id然后遍历明细列表逐条插入并再次把orderIdset到每条明细上。这里虽然看起来是循环单条插入效率不是最优但在练习项目里完全够用重点是把业务逻辑跑通。最后一步清空购物车。shoppingCartMapper.clean(userId)删除当前用户的全部购物车记录返回一个包含订单id、订单号、订单金额、下单时间的VO。2.3 菜品快照机制为什么订单里的菜不许变写代码的时候有个很容易忽略的细节订单明细表里面的name、image、amount是从购物车复制过来的而不是下单的时候再去菜品表关联查询。这个操作专业上叫快照说白了就是把下单那一刻的菜名、图片、单价原封不动存进订单明细里。为什么必须这么做设想一个场景用户下单时宫保鸡丁卖38元第二天商家把价格改成了42元。如果订单明细只存了dish_id用户查看历史订单时就会显示出42元甚至菜品下架后图片和菜名都变成空的这对用户来说完全无法接受。财务对账时也会出乱子每笔订单的实际成交金额跟订单明细里展示的金额对不上那就麻烦大了。存快照的本质是把“商品当前信息”和“交易时确认的信息”隔离开。用户支付时看到的是38元那这笔交易的法律凭证就是38元商家之后怎么改价格都不应该影响历史订单。这个思维在电商、外卖、票务系统里都是通用的。你可以把快照理解成一份合同的影印件合同签完字后面内容再修改影印件依然是签合同时的样子。3. 事务与数据一致性下单不能只靠写代码3.1 Transactional 是怎么保证原子性的上面那五步操作涉及三张表的写入address_book表是查询shopping_cart表要删数据orders表要插主数据order_detail表要插明细数据。一旦中间任何一步出错比如明细插入失败但购物车已经被清空了用户就会发现购物车里的菜没了订单也没生成这种状态谁遇到都会崩溃。所以我要求所有看过这篇文章的人记住一句话凡是涉及多张表写操作的业务方法必须加上事务。代码里的Transactional注解就是干这件事的。Spring的声明式事务本质上是通过AOP代理实现的。入口方法被调用时Spring会从连接池拿一个数据库连接把连接的自动提交模式调成关闭然后执行业务方法。方法正常结束就执行commit方法抛出RuntimeException就执行rollback把这一步的所有数据库操作全部撤销。整个过程对外表现为“要么全部成功要么全部失败”不存在中间状态。配合前面的代码来看如果第6步明细插入抛了异常第7步清空购物车的操作根本就不会执行而即使第7步已经执行了事务回滚也会把清空购物车这个delete操作一起撤销掉。购物车数据还在用户重新提交一次就行。3.2 事务没生效的几个坑位加了Transactional就万事大吉了吗我见过太多新人在这个上面翻车最常见的有四种情况。第一种方法内部自调用。同一个类里A方法调B方法而B方法上有TransactionalB的事务不会生效。因为Spring事务是靠代理实现的外部调用会走代理但this调this是直接走原生对象代理根本没机会拦截。解决办法很简单把两个方法拆到不同的Service类里或者通过ApplicationContext手动拿代理对象调用。第二种异常被吞掉。方法里写了try-catch把RuntimeException捕获之后没往外抛甚至打印了个日志就结束了。这种时候Spring根本感知不到异常它看到的是方法正常返回自然就commit了。事务方法里的异常处理一定要谨慎要捕获后重新抛出去或者直接不捕获。第三种方法不是public。Transactional注解标注在private方法上代理无法生效。虽然Spring Boot 2.x之后对非public方法会给出警告日志但很多人根本没注意看日志。第四种数据库表引擎不支持事务。MySQL的MyISAM引擎是不支持事务的只有InnoDB支持。如果你在建表时用了默认的MyISAM那加再多注解都没用。练习项目中表一般不会出这种问题但如果你在复习原理时被面试官问到要能说出这一点。我一般检验事务有没有生效会在润detailMapper.insert的地方故意抛一个RuntimeException然后运行整个下单流程看看购物车是否被清空。如果事务生效购物车应该保持原样如果购物车被清空了那就说明事务根本没起作用该去查代理或者异常处理了。3.3 订单号生成从时间戳到分布式ID苍穹外卖这个项目里订单号直接用了System.currentTimeMillis()也就是当前毫秒时间戳转成字符串。这种做法在练习项目里完全没有问题因为单服务器、单线程并发量几乎可以忽略不计。但如果你将来要把项目优化成简历亮点这个点是可以拿出来展开讲的。真实生产环境里订单号的要求比这严格得多。首先不能暴露订单量用数据库自增ID当订单号竞争对手一看订单号连续增长就能估算出你的日单量这是商业机密。其次要保证全局唯一在分布式多服务部署的情况下两个服务实例在同一毫秒内生成的订单号可能一样直接造成主键冲突。业内最常用的方案是雪花算法。雪花ID是一个64位的Long型数字其中1位是符号位41位是毫秒时间戳10位是机器ID12位是序列号。同一毫秒内可以生成4096个不重复的ID并且趋势递增几乎完美满足了订单号的生成需求。很多中间件比如MyBatis-Plus的ASSIGN_ID雪花策略、美团的Leaf都是这个思路的实践。我带的学员里不少人会在简历里写“项目使用雪花算法生成订单号”但真被问到雪花算法由哪几部分组成时又支支吾吾说不清楚。既然今天聊到了建议你把这一小节多看两遍将来面试时这就是加分项。4. 提交订单踩坑实录与排查速查表4.1 下单后购物车没清空这是我被问得最多的一个问题。明明代码里写了shoppingCartMapper.clean(userId)下单成功了购物车里的商品却还在。遇到这种情况第一反应应该去查SQL日志。看看clean方法实际执行的SQL是什么userId传的是不是当前登录用户的id。BaseContext.getCurrentId()拿的是ThreadLocal里存的用户id这个值是在JWT拦截器里面手动放进去的。如果你在拦截器里没放或者放错了字段那拿到的userId就可能是null导致delete条件不匹配一条数据都删不掉。还有一种可能你没加Transactional而且实际上清空购物车的代码是在插入订单之后执行的如果前面的插入操作抛了异常清空代码确实执行不到。但事务问题在上面已经讲过了这里就不重复了。4.2 主键回填失效订单详情表里的order_id老是插不进去或者插进去是null根源几乎都是订单主表插入后没有回填主键。MyBatis框架里要在insert语句上配置useGeneratedKeystrue和keyPropertyidMapper接口的插入方法才能真正把数据库自增的id赋回实体对象。如果你用的是注解式SQL类似Options(useGeneratedKeys true, keyProperty id)也只能用在Insert上。一旦漏配orders.getId()返回的就是null后面set到orderDetail里的orderId自然也是null。排查方法非常简单在orderMapper.insert(orders)这一行下面加一条log.info(生成的订单id{}, orders.getId())如果打印出来是null那就说明主键回填没生效。别小看这个细节我见过不少项目已经上线了这个bug还埋在代码里只是因为测试数据碰巧没触发。4.3 金额多一分少一分下单金额不对如果数据是从前端直接传过来的那你就要警惕前端单位转换的问题。前端页面上显示的金额单位是元比如38.5元但是后端如果按Long去接前端传过来的JSON数字会在反序列化时报错或者被截断成38。正确的做法是前端传金额时统一用分或者后端在接收到元之后自己乘100转成分入库和计算都用分。千万不要在代码里同时出现“元”和“分”两种单位的字段时间一长连自己都容易记混。我之前带过一个小伙伴他写了一个工具类MoneyUtil里面提供yuanToFen和fenToYuan两个静态方法所有金额转换都必须走这两个方法不允许在业务代码里手动乘100除100。这个习惯很好建议你也给自己的代码立一个类似的规矩。4.4 常见问题速查表现象可能原因排查方法解决办法地址为空addressBookId传错或地址被删打印DTO参数直接查数据库确认前端回显地址时带上id后端校验后抛出友好异常购物车为空userId不存在或购物车表无记录查shopping_cart表看当前userId有没有数据检查Token解析逻辑和ThreadLocal存放时机订单明细order_id为null主键回填配置缺失insert后日志打印orders.getId()Mapper上配置useGeneratedKeys和keyProperty下单后购物车还在clean SQL条件不匹配或事务未生效开启MyBatis SQL日志确认userId取值确认Transactional生效金额对不上元分单位混乱或前端传过来的就是错的打印订单金额和明细金额统一用Long存分写工具类转换事务不生效自调用、异常被吞、非public方法在事务方法中故意抛异常验证拆Service、异常重抛、方法设为public我还想多提醒一句做这个模块的时候最好把MyBatis的日志级别调成debug这样每个SQL语句和执行参数都能看得清清楚楚。很多你以为的“玄学bug”在看到SQL日志的那一刻就真相大白了。5. 个人实操建议与后续扩展方向5.1 上手练习时的几个小建议如果你现在是在跟着视频敲代码我建议你提交订单这一节不要只看不敲。你可以在自己电脑上把controllerservice、mapper三层全部手写一遍写完再和源码对比哪怕只是字段名不一样也要弄明白是为什么。练习时强烈建议做一次破坏性测试把购物车清空的过程注释掉或者故意让明细插入失败然后重新跑完整流程看看会出现什么样的脏数据。这个过程会让你对事务的理解比看十遍文档都深刻。项目里有一个名为BaseContext的ThreadLocal工具类专门用来保存当前登录用户id。你的代码里所有需要知道“是谁在操作”的地方都要通过它拿用户id。理解它的作用机制比背代码重要得多因为所有多用户系统的权限和操作归属都离不开它。5.2 提交订单之后还能做什么写完提交订单苍穹外卖这个项目也就到中后段了。但如果想把它变得更有竞争力我推荐你研究几个扩展点。第一个是超时未支付自动取消订单。用户下单后如果一直不付款订单不能永远挂着。实现思路是延时队列或者定时任务把超过15分钟没支付的订单状态改成已取消。这个逻辑在真实外卖系统里是标配。第二个是商品库存扣减。外卖系统里商家会有每日备货量下单时扣减库存支付失败或超时取消要回补库存。扣库存时要注意并发问题SQL里要加上库存数大于零的条件去更新受影响行数为零就说明库存不足。第三个是订单状态的推送和查询。用户下单后要知道订单什么时候被商家接单、什么时候开始配送这些状态变化可以通过WebSocket或轮询接口实现。写一个订单状态查询接口前端每隔几秒请求一次是很多练习项目里常见但也很考验基本功的功能。我在实际操作中最深的体会是提交订单这个功能虽然代码量不大但它像一根线把之前所有学过的Java知识串到了一起。你能把它真正讲清楚Spring的事务机制、MyBatis的主键回填、ThreadLocal的用户上下文、BigDecimal与Long的金额设计这些面试高频话题就都有了落地经验。项目从来不怕简单怕的是做完之后一知半解。认真把这个模块吃透你的Java学习之路会走得比大多数人都稳。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →