进入苍穹外卖项目的第七天,我做的第一件事不是写接口,而是把前六天的代码从头翻了一遍。做过这个项目的人应该都有同感:day07是个特别微妙的节点,前面的员工管理、分类管理、菜品管理说到底是后台CRUD,写多了会给人“项目快做完了”的错觉。但真到了用户下单这一步,很多隐藏的问题一下就冒出来了。我在这一天最强烈的体感是,第七天的任务可以浓缩成一句话——把“用户能看菜”变成“用户能真正把单下下去”。如果你也在跟苍穹外卖这类全栈外卖项目,或者自己从零搭过一个带订单流程的系统,那这篇实战拆解会非常适合你参考:我会把当天涉及的数据建模、下单链路、并发幂等等关键环节逐个说清楚,包括那些不踩一次就意识不到的坑。
1. day07到底该做什么:订单是第一个真正的业务闭环
1.1 前六天的模块,到第七天才开始真正联动
苍穹外卖这类模拟项目,前几天的开发节奏通常都很“舒服”。员工登录做好后,就是菜品分类、菜品管理、套餐管理,每个模块独立成页,接口的输入输出都很清晰,哪怕是第一次接触项目的新手,跟着敲一遍也能把功能跑通。但第七天完全不同,因为订单模块是第一个需要同时串联用户端、商家端、支付流程和库存状态的业务闭环。
我举个例子你就明白了。前面做一个“新增菜品”接口,只需要往菜品表插一条记录,最多再处理一下图片上传,事务范围很小。但一个“用户下单”接口,至少要读取用户的收货地址、遍历购物车里的菜品、校验菜品是否在售、计算订单总金额、扣减库存、生成订单主记录和订单明细、清空购物车,最后还要对接支付。这些操作横跨了至少五六张表,任何一个环节出错,整个功能都跑不起来。
这种“多表联动”才是day07真正的门槛。你会发现,原来写单表CRUD时几乎不用考虑事务边界,到了下单场景,一个接口里要保证要么全部成功,要么全部回滚。如果没有这一步的锻炼,后面做任何带状态流转的业务都会心里没底。
1.2 day07建议完成的功能清单
根据我自己做这个项目时的规划,第七天不建议一上来就把整个订单模块全做完,而是先锁定用户端最核心的那几个功能。我列了一个清单,后面每一节都会围绕其中一部分展开:
- 购物车管理:加入购物车、查询购物车、修改商品数量、清空购物车
- 提交订单:接收地址簿信息、备注、配送时间、生成订单
- 订单列表与详情:用户查看历史订单、点击查看某个订单的具体菜品种类和金额
- 取消订单:仅限未支付或商家未接单状态下取消
- 支付回调处理:模拟支付平台通知,更新订单状态
- 商家端接单与配送:商家查看新订单、接单、标记配送、完成订单
如果你进度比较快,还可以加上“超时未支付自动取消”这个功能,它会顺便锻炼定时任务和状态扫描的能力。我不建议在第七天就引入优惠券、满减活动这些营销逻辑,原因后面会详细说。
1.3 先控制范围:为什么不要急着做优惠和营销
很多同学会忍不住在第七天顺手把“满减活动”也做了,觉得反正都是改金额。但我踩过这个坑,强烈建议你忍住。原因很简单:订单模块本身涉及的状态逻辑已经够多了,如果在这个时候加入优惠计算,订单金额对不上时,你根本不知道问题出在价格计算,还是订单保存,又或者是折扣分摊上。排查范围直接翻倍。
我当时的做法是,第一版订单只按菜品原价计算总额,流程跑通后,再单独抽出一个“价格计算服务”来扩展优惠逻辑。这样做的好处是,每一层的问题都能被单独验证。等订单基础状态流转稳定了,回头再补满减,你会发现自己能很清楚地定位“是优惠规则的问题”还是“订单本身的问题”。
2. 订单模块的数据建模:先把表设计想清楚再动手
2.1 订单主表和明细表:为什么必须拆成两张
外卖订单要存储的信息可以分成两类:一类是订单整体的属性,比如下单用户、收货地址、订单总金额、订单状态、下单时间、支付方式;另一类是订单里每个菜品的属性,比如菜品名、份数、单价、小计。
如果只建一张表,每个菜品都重复存一遍收货地址、备注这些公共信息,数据冗余会非常夸张,而且后续要修改收货地址时,你得同时更新所有行。所以标准做法是拆成订单主表和订单明细表,通过订单号或订单ID关联。
我这里说的“快照”概念很关键。订单明细表里保存的菜品名称、价格,必须是用户下单那一刻的菜品信息,而不是菜品表里的最新信息。因为外卖场景里商家完全可能在用户下单后改价或者修改菜品名称,但已经成交的订单不能受影响。我见过有同学直接在明细表里存菜品表的ID,查询时再去关联当前菜品表,结果商家改价后,历史订单的金额全变了,这就是典型的设计缺陷。
2.2 金额字段为什么必须用Decimal而不是Float或Double
这个知识点我在day07之前一直没太当回事,直到调试时发现订单总金额偶尔出现“差一分钱”的情况,才认真去查了浮点数原理。那时候我才意识到,Float和Double在计算机里是二进制浮点表示,很多十进制小数无法精确存储,像0.1加0.2这样的运算会得到0.30000000000000004。
订单金额一旦出现这种误差,数据库记录、统计报表、前后端显示都会对不上,而且很难排查。正确的做法是,数据库金额字段统一用Decimal类型,比如decimal(10,2),Java后端对应的BigDecimal,前端传参和展示也都按“元”为单位,避免在运算中混用浮点类型。
我自己的习惯是所有金额计算都用BigDecimal的add、multiply方法,不直接使用doubleValue来做运算。虽然代码写起来稍微长一点,但至少不会出现金额精度飘移这种让人抓狂的bug。
2.3 订单号生成的简单可靠方案
订单号看起来只是业务编号,但设计不好也会给自己挖坑。最常见的错误是直接用数据库自增ID当订单号返回给用户,这样不仅暴露了系统每天的订单量,而且在不同业务端对接时也不够规范。
在苍穹外卖这种单体项目阶段,我不建议一上来就引入雪花算法或分布式ID组件,那会增加额外的依赖和部署成本。我用的是一个更轻量的组合:日期时间精确到秒加上用户ID后几位再加上随机数,例如20250107143015 1024 8371这样的格式。这种方案的好处是肉眼可读性强,能直接从订单号看出下单日期,适合项目初期的日志排查;缺点是并发量很高时有极小概率重复,所以数据库最好在订单号字段上建唯一索引,插入时如果冲突就重新生成。
如果你所在的场景对订单号的随机性要求更高,可以考虑在订单号前加上当天自增的序列号,但单体项目里用时间加随机数的组合已经足够了。
2.4 订单状态字段与状态流转控制
外卖订单的生命周期很清晰,我在做数据建模时把状态定义为这样几类:待支付、已支付待接单、已接单配送中、已完成、已取消、退款处理中。数据库里我用数字Integer存储这些状态,代码里用常量类或枚举统一管理,避免散落的魔法数字。
有些同学会直接写死一个状态字段,更新时不管当前什么状态,直接设置成目标状态,这就会产生状态跳跃的问题。比如用户已经取消的订单,理论上不能再被商家接单,但如果代码里没有状态条件,就可能出现这种矛盾记录。
我在第七天使用的办法是:状态更新时带上期望的当前状态,作为UPDATE语句的WHERE条件,受影响行数为0则说明状态已被其他操作修改,需要重新处理。这相当于一个很轻量的乐观锁,不需要引入状态机框架,但已经能挡住大部分非法跳转的情况。后面讲到并发问题时还会再展开。
3. 下单核心流程的实现拆解
3.1 从购物车到订单:一次全量同步
用户点“去结算”,前端把地址簿ID、备注、配送时间、支付方式这些参数传给后端,后端拿到之后开始一套有序的动作。我第一天实现的时候想得很简单,以为就是“把购物车表的数据复制到订单明细表”,结果忽略了主表和明细表要一起生成,状态要一起变化,其实是一个必须放在同一个事务里的操作。
一趟完整的下单流程是这样的:后端先校验地址簿存在且属于当前用户,然后查询购物车,如果购物车为空直接返回错误提醒。接下来遍历购物车中的每一项,根据菜品ID去菜品表拿最新的在售状态和单价,计算单项小计后累加得到订单总额。然后生成订单主表记录,拿到订单ID后按购物车明细逐条生成订单明细记录。最后执行清空购物车的操作,把整个事务提交。任何一个步骤失败,前面写入的订单主表和明细数据都要回滚,不然就会出现“订单生成了,购物车还在”的幽灵订单。
这里还有一个小细节容易被忽略:清空购物车必须是逻辑上“删掉属于当前用户的全部记录”,而不是简单的前端循环删除。如果前端传一个商品ID数组过来,很容易漏删或者误删。最稳妥的做法是后端直接根据用户ID删除购物车中的记录,因为下单本身就是全量操作,不需要部分保留。
3.2 金额计算:不能直接信任前端传的总价
第一次做下单功能时,我犯过一个典型的错误:前端在下单页面已经算好了总金额,和后端参数一起提交,后端直接用这个数值存入订单表。直到测试人员传来一个bug,说修改页面上的价格参数后,订单金额变得异常,我才意识到后端必须自己计算金额。
真实的外卖系统里,接口入参可以被拦截修改,如果后端不对金额做校验,等于把定价权交给了客户端。正确的做法是,前端展示的价格只是预估和展示用途,后端拿到的是菜品ID和数量,然后以菜品表当前有效的售价为准,重新计算每一项的小计和订单总额。只有后端计算的金额才能作为最终存储和支付依据。
计算时还要特别注意“元”和“分”的统一。我曾经遇到前端传过来的价格是字符串,后端解析时默认成整数处理,结果金额直接被放大了一百倍。这个问题的本质是两端没有约定好金额单位,我后来统一约定所有接口金额均以“元”为单位保留两位小数,后端用BigDecimal解析字符串,彻底杜绝了单位混乱。
3.3 支付回调的幂等处理:回调消息会来很多次
支付回调是这个项目里第一个逼我认真思考“幂等”这个概念的地方。以前写普通接口,一个请求对应一次操作,很少考虑同一个请求来两次会怎么样。但支付平台为了保证通知可靠送达,会尝试多次回调,如果后端每次都当新支付处理,订单状态就可能被重复更新。
幂等处理的核心是:在更新订单状态之前,先查一次当前订单的状态,如果已经是“已支付”,就不再执行任何更新,直接返回成功。同时,这个查询和更新动作也要放在一个安全的事务或锁机制里,避免并发情况下两个回调同时进来,都查到“待支付”状态,然后都去更新。
我在项目里用的是条件更新法:直接执行UPDATE订单表SET状态=已支付WHERE订单号=对应订单号AND状态=待支付。如果更新影响的行数为1,说明是首次支付成功;如果影响行数为0,说明订单状态已经变化,直接忽略本次回调。这样既保证了幂等,又不需要额外的锁机制,实现成本很低。另外,真实项目中回调还会带签名验证,我这里也建议把所有验签逻辑独立成方法,不要跟业务更新混在一起。
3.4 超时未支付订单:定时任务自动取消
用户下单后不支付,订单会一直卡在“待支付”状态,占用库存和商家精力。day07阶段最合适的做法是加一个定时任务,定时扫描超过N分钟未支付的订单,把它们改成已取消,同时回补库存。
我采用的是一个简单的Spring定时任务,每60秒执行一次,查询条件就是状态为待支付且创建时间早于当前时间减去15分钟的记录。查询时要注意分页或限制条数,防止一次扫描数据量太大。取消动作同样使用条件更新,把状态从待支付改成已取消,同时要求创建时间小于指定阈值,这样即使两个定时任务重复执行,也不会影响同一个订单。
回补库存时也别忘了把菜品表的库存量加回去。我第一次做的时候只改了订单状态,库存没有恢复,导致后续下单时明明菜品还没卖完,却总是提示库存不足。这个问题排查了很久才发现,根源就是取消订单时没有联动回补库存,做完之后我特意把“取消订单”相关的一系列操作写成了一个独立的方法,每次调用都能把主表状态、明细快照、库存操作一次处理完。
3.5 库存扣减:先校验再扣,避免超卖
外卖项目里库存虽然不像电商那样是核心战场,但如果基础没打好,并发下单时同样会翻车。我刚开始的写法是:查询菜品库存,判断库存大于购买数量,然后在代码里减去数量再更新。这个写法在低并发下没问题,可一旦两个用户同时下单同一个菜品,就可能出现两个请求都读到库存为1,都判断库存足够,然后都执行了扣减,最后库存变成负数。
解决办法是把扣减操作变成一条带条件的原子更新:UPDATE菜品表SET库存=库存-购买数量WHERE菜品ID=?AND库存>=购买数量。这样数据库本身就保证了“库存不足时更新不了”,再根据受影响行数判断是否扣减成功,不需要额外加锁。我在实际测试中用JMeter模拟50个用户同时购买同一款限量菜品,用这条SQL后没有出现超卖,代码也更简洁。
4. 并发与异常场景的坑:排查实录与避坑清单
4.1 用户反复点击导致重复下单:接口幂等与前端防抖
测试人员反馈了一个经典问题:用户在网络慢时连续点了几次“提交订单”,系统生成了好几条一模一样的订单。这个问题光靠前端按钮禁用根本防不住,因为用户可能用脚本重放请求,也可能前端已经禁用但请求还在路上。
后端要做的第一道防线是:在提交订单接口里加一个去重机制。常见的轻量做法是要求前端生成一个唯一的客户端请求号,后端在同一个用户短时间内收到相同请求号时,直接返回上一次的订单结果。不过这种方案需要额外维护请求号,我在这类项目里用的更简单的做法是:在下单接口开始时检查当前用户是否还有未支付的订单,如果存在且刚创建不久,则提示“您有未完成的订单,请先支付或取消”。
当然,最彻底的方式仍然是在数据库层对关键业务做唯一约束,或者在下单服务里增加分布式锁。但day07阶段的系统还没必要上那么重的方案,先用前端防抖、后端重复检查、数据库唯一索引三层组合,对新手项目来说已经足够了。
4.2 并发状态更新互相覆盖:条件更新的价值
商家端接单和用户端取消订单如果同时发生,就很容易出现状态覆盖的问题。用户点击取消时读到订单状态是“已接单”,商家点击接单时读到订单状态也是“待接单”,两个请求都认为自己可以操作。最后谁后执行,订单状态就被谁覆盖,业务状态和实际行为对不上。
我解决这个问题的方式就是前面提到的条件更新,更新语句里强制带上当前状态条件。这样无论两个请求并发多严重,数据库层面的原子更新都能保证只有一个请求成功。如果写了一个功能,发现“明明改了状态,但逻辑上还是不对”,第一反应就应该是检查更新语句里有没有带上条件。
4.3 订单金额对不上:最烦人的排查过程
金额对不上这件事,一出现就让人头大。我总结出了一个排查顺序,几乎能覆盖90%的场景:先核对订单主表总金额是否等于明细表所有小计之和,再核对明细表小计是否等于菜品表当前价格乘以数量,最后核对代码里有没有在某个环节用了Double或者Float计算。
如果金额多了或者少了刚好是优惠金额,那可能是优惠计算逻辑和订单保存逻辑不在同一事务里,导致部分数据回滚而优惠数据没有回滚。如果金额差了“分”这个量级的微小值,基本可以肯定是浮点数精度问题,直接全局搜索代码里的double和float,把它们统一替换成BigDecimal处理。
4.4 第七天常见问题速查表
做成表格方便你对照排查,这些问题的优先级是按出现频率排的:
| 现象 | 可能原因 | 排查口径 | 建议处理 |
|---|---|---|---|
| 重复生成相同订单 | 前端重复提交,后端无幂等控制 | 检查同一用户短时间内是否有多个相同购物车来源的订单 | 加前端防抖,后端做待支付订单检查 |
| 订单总金额有小额误差 | 使用了Double/Float计算 | 检查金额计算链路是否全部为BigDecimal | 统一金额类型,规则入库 |
| 历史订单金额变更 | 明细表未做菜品快照 | 确认明细表存的是不是ID引用 | 明细表冗余菜品名、单价字段 |
| 订单取消后库存未恢复 | 取消逻辑缺少库存回补 | 查看取消订单方法是否调用回补库存 | 把取消操作封装成完整事务 |
| 支付回调导致状态被重复更新 | 回调没有幂等处理 | 查看回调里有无状态条件更新 | 使用条件更新或幂等表 |
| 更新订单状态时被新请求覆盖 | 更新缺少状态条件 | 查看UPDATE语句WHERE条件 | 更新时带期望状态 |
5. 第七天应该养成的工程化习惯
5.1 接口入参校验:不要信任任何前端数据
做到订单模块,接口入参复杂度明显上升,不再是一个简单的名称或ID,而是一个包含地址簿ID、备注、预计送达时间、支付方式等多个字段的对象。如果不在后端做参数校验,各种空指针、脏数据都会冒出来。
我在day07给下单接口写了一套基础校验:收货地址ID必须存在且属于当前用户,支付方式必须限定在枚举范围内,备注长度不能超过数据库字段长度,菜品ID列表不能为空,数量必须大于0。注释校验注解能帮上不少忙,但有些需要查库才能确定的校验规则,比如“地址是否是当前用户的”,还是得写在业务代码里。这一步虽然琐碎,却能省掉后面联调阶段大量“接口怎么又报错”的沟通成本。
5.2 统一接口返回结构和枚举翻译
用户端和商家端在第七天都会开始对接订单页面,如果每个接口返回的字段结构都不一样,前端同学会崩溃。我从一开始就统一使用Result包装类,包含code、message、data三层结构,下单接口只返回订单号,查询接口返回列表或详情对象。
订单状态这种枚举值也需要有一个统一的翻译规范。数据库存的是数字,接口返回时要么翻译成中文描述,要么返回数字并附上状态字典表。我个人的习惯是接口返回数字状态,同时在文档里写明每个数字代表的含义,前端根据状态码自己渲染对应文案。这样后端不用频繁改接口,前端展示也更灵活。
5.3 关键操作必须打日志
第七天的业务涉及支付回调、取消订单、修改状态这类敏感操作,每一步的改变都应该有迹可循。我在支付回调和取消订单的代码里都加了操作日志,记录操作前后的订单状态和关键参数,比如订单号、用户ID、修改前状态、修改后状态、触发原因。
调试过程中这个习惯帮了我大忙。有一次线上反馈一个订单被异常取消,我看日志比看代码更快地定位到了是定时任务扫描导致。如果没有日志,大概率要对着数据推导半天。对于刚开始做后端项目的同学,我特别建议从这类多状态业务开始,养成关键节点记录日志的习惯,这个成本很低但排查效率的提升非常明显。
做苍穹外卖day07这一天,我最真实的收获不是把下单流程跑通了,而是第一次把并发、事务、幂等这些听起来很抽象的词全部落到了具体的代码里。第7天你会遇到各种各样看起来“不该发生”的问题,比如订单金额多了一分钱、库存变负、重复下单、状态被覆盖,这都很正常。耐心一点把每个现象背后的原因摸清楚,再回头看数据模型和状态流转的设计,你就能理解为什么订单模块一直是这类项目的核心环节。第七天之后你再做任何新模块,都会下意识先去想清楚状态变化和边界条件,这是普通CRUD练不出来、也只有亲手做完一遍订单闭环才能沉淀下来的经验。