
做了这么多年电商后端我越来越觉得订单模块是整个交易系统的“心脏”。烘焙坊这套项目推进到第11篇终于要碰这个最硬核的部分了。用户下单、商家接单这一来一回看着简单真做起来全是细节——状态怎么流转、库存怎么扣才不超卖、支付回调怎么处理才能幂等、用户取消和商家拒绝撞在一起怎么办。这篇我会把用户端和商家端两条订单链路完整拆开讲从表结构设计、状态机定义到具体的下单、接单、核销流程再到我实际开发中踩过的几个典型的坑。如果你是正在做订单类需求的后端同学这篇文章应该能帮你少走不少弯路。1. 订单板块的整体设计为什么用户端和商家端必须拆开1.1 同一张订单表两种完全不同的业务视角刚开始搭这个模块的时候团队里有同事提出过一个方案用户端的订单接口和商家端的订单接口复用一套无非是加个role参数区分一下。我当时就否了理由很直接——用户看订单和商家看订单本质上是在看两种完全不同的东西。用户端打开订单列表关心的是“我买了什么”“现在送到哪了”“能不能退款”这是一个典型的消费视角偏查询、偏展示操作频率低但并发量高比如秒杀、活动期间的集中下单。商家端打开订单列表关心的是“哪几单还没做”“哪几单待自提”“今天营业额多少”这是一个典型的工作台视角偏操作、偏流转操作频率高并发量低但实时性要求极强。用户端和商家端如果强行共用一套接口会出现什么情况接口返回的字段没法兼顾两边的诉求用户不需要商家备注商家不关心用户会员等级。更麻烦的是权限模型会被搅浑——用户端接口要校验“这个订单是不是当前用户的”商家端接口要校验“这个店铺是不是当前商家的”两套权限逻辑压在一个接口里代码会越来越拧巴。所以我的做法是底层共用一套orders表和order_items表但向上拆成两个独立的接口模块。用户端包名user.order商家端包名shop.order各自独立 controller、独立 service、独立校验逻辑。表是同一个表但领域模型可以不同——用户端看到的是一个“消费记录”商家端看到的是一个“待办工单”。1.2 订单号设计别用自增ID要能溯源订单号这块我单独拿出来说因为这是很多新手最容易忽略的设计点。自增ID做订单号确实方便但在烘焙坊这种场景下会出两个问题一是订单号会暴露平台的单量竞对抓包能看到你一天出多少单二是后续做对账、做客服查询的时候用户报订单号你拿自增ID根本没法定位是哪一天的订单。我用的方案是业务订单号规则类似20240607120001 用户ID后四位 随机两位具体就是日期时间精确到秒拼上用户ID后四位再补两位随机数。这样生成出来的订单号既能看出下单时间又带用户标识查问题的时候一眼就能锁定范围。当然如果并发量再上去这种规则可能会撞但烘焙坊这种体量完全没有压力。真到大促级别再上雪花算法也不迟。订单表的主键我依然用自增ID但业务上所有对外暴露的、用户能看到的编号一律用order_no这个业务号。数据库层面给order_no加唯一索引防重复下单就靠它。1.3 订单核心表结构主表和明细表的关系订单的表结构其实没什么玄机核心就是两张表orders主表和order_items明细表一对多关系。但有几个字段我想特别强调一下。orders表里除了订单号、用户ID、商家ID、订单金额这些常规字段我建议一定要单独放三个字段total_amount订单总金额、discount_amount优惠金额、pay_amount实付金额。为什么分三个而不只存一个实付金额因为活动运营要分析优惠力度财务要对账核销只有实付金额的话你根本算不出来这单到底优惠了多少。金额字段这里有个坑千万不要用double。浮点数在计算机里是近似存储的0.1加0.2会变成0.30000000000000004算钱算出这种结果哪天对账差一分钱就等着哭吧。我用的是decimal(10, 2)类型在 Java 里对应BigDecimal所有金额计算都必须走它。order_items明细表存的是下单那一刻的商品快照。请注意“快照”这两个字——商品名称、商品图片、单价、规格这些字段是从商品表冗余过来的。这样做的原因是商品信息是会被修改的烘焙坊改了配方改了价格但如果订单明细里存的还是下单时的原样那用户的订单历史就不会被影响。这是订单系统的基本素养不做快照的订单系统就是耍流氓。2. 订单状态机把流程写死才不会出乱子2.1 状态枚举与合法流转路径订单最怕的是什么状态乱串。用户都已经收到货了后台还能把状态改成“备货中”这种系统上线就是在搞笑。所以我在定义状态的时候顺手就把每个状态的合法流转路径给写死了。我把烘焙坊的订单状态分成这么几个待支付PENDING_PAYMENT、待接单PENDING_ACCEPT、制作中PROCESSING、待自提/配送中READY、已完成COMPLETED、已取消CANCELLED、退款中REFUNDING。每个状态的合法流转路径大概是这样的待支付可流转到已取消用户主动取消、已完成支付成功——烘焙坊这里走得快扫码下单即时支付我直接把支付成功后的状态定成待接单待接单可流转到制作中商家接单、已取消商家拒单制作中可流转到待自提/配送中制作完成等待交付待自提/配送中可流转到已完成用户确认收货/自提核销退款中可流转到已取消退款成功已取消、已完成终态不可再流转我在订单状态机上定义了一个OrderStatusEnum每个枚举里放一个nextStatus列表流转的时候判断当前状态的下一个状态是否包含目标状态不包含直接抛业务异常。这个设计看起来死板但它保证了订单状态只会沿着预设的轨迹走不会出现用户点了个取消结果状态变成了处理中这种离谱的事情。2.2 状态变更日志每个状态都要留下痕迹除了状态机本身我强烈建议加一张order_status_log表记录每一次状态变更。字段就是订单号、旧状态、新状态、操作人类型用户/商家/系统、操作人ID、变更时间、备注。这张表的价值平时看不出来排查问题的时候能救命。举个例子用户投诉说“我的订单明明支付成功了怎么显示已取消”如果只有订单表当前状态你根本不知道发生了什么。但有状态变更日志一查就能看到——用户下单之后 20 分钟没有支付系统定时任务自动关单了用户以为自己付了实际上是关单之后才付的款。状态变更日志还能用来做统计每个状态停留了多久、平均接单时长、平均制作时长这些运营指标全部能从日志表里算出来。2.3 两个终态的订单数据处理要小心已取消和已完成是终态这里有个细节终态订单的数据不是不能动了而是不能修改核心状态了。比如已完成的订单用户要开发票你得允许往订单上补充发票信息已取消的订单用户要重新下单你不能复用原来的订单要新出一单。我之前做过一个项目终态订单直接锁死所有字段都不能改结果用户要退货退款已完成订单的时候客服后台没法在订单上挂退款单号只能手工改数据库非常危险。所以我现在处理终态订单会开放一个“不改变订单主状态但对订单信息进行补充”的接口比如补充退款单号、发票信息、核销码红色标记这些字段和状态流转无关允许改。3. 用户端下单流程从购物车到支付完成的链路3.1 创建订单的核心事务链用户端下单前端传过来的是购物车选中的商品列表和自提门店ID。后端要做的是一连串的事情检查商品是否在售、计算总金额、扣减库存、生成主订单、生成订单明细、清空购物车对应项。这一串操作必须在一个数据库事务里完成而且要注意顺序——先锁库存再生成订单。顺序反过来的话会出现订单建好了扣库存失败订单变孤儿订单用户看着订单列表有个待支付点进去发现商品没货的情况。我这里用的扣库存方式是乐观锁方案核心就一条 SQLUPDATE product_sku SET stock stock - #{count} WHERE id #{skuId} AND stock #{count}这条 SQL 的巧妙之处在于stock #{count}这个条件。如果库存不够更新影响的行数就是 0我通过受影响的行数判断是否库存充足不足就直接抛“库存不足”异常整个事务回滚。这就从数据库层面杜绝了超卖比在应用层查一下库存再判断要靠谱得多。库存扣减成功之后生成订单号插入订单主表再把购物车选中的商品逐条写入明细表删购物车记录提交事务。整个链路在我这里走下来大概 200 毫秒用户体感是点击“提交订单”秒回。3.2 防重复下单前端防抖 后端唯一约束用户下单这个操作前端会做按钮防抖防止手快点了两下但这个只能防君子不能防小人。真正的防护在后端——我刚才说过order_no字段有唯一索引但order_no是后端生成的如果两次请求都走到生成订单号这一步还是会生成两条订单。所以我额外引入了一个user_order_token机制用户点击提交订单的时候前端先从后端拿一个一次性唯一的下单令牌后端把这个令牌存 Redis设 30 秒过期。提交订单接口必须携带这个令牌后端拿到令牌先删 Redis 里的 key删除成功说明这个令牌第一次使用可以继续下单删除失败说明令牌已被用过直接拒绝。这个方案是 Redis 分布式锁的简化版实现简单但对于防止重复下单非常有效。3.3 支付回调处理幂等性怎么做支付成功后微信支付/支付宝会回调后端接口通知我们“这个订单付款成功了”。回调有一个特点——它不保证只回调一次可能因为网络抖动回调三五次。如果回调处理接口不做好幂等用户付一次钱订单被处理三遍那后果可想而知。我的支付回调处理逻辑是这样的public String handlePayCallback(PayCallbackRequest request) { // 1. 先查订单状态 Order order orderMapper.selectByOrderNo(request.getOrderNo()); // 2. 如果订单已经是“待接单”或后续状态说明已经处理过回调直接返回成功 if (order.getStatus() OrderStatus.PENDING_ACCEPT.getCode()) { return SUCCESS; } // 3. 校验支付平台的签名信息防止伪造回调 boolean checkSign payService.checkSign(request); if (!checkSign) { throw new BizException(回调签名校验失败); } // 4. 加分布式锁防止两个回调线程同时进来 String lockKey PAY_LOCK: request.getOrderNo(); boolean locked redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS); if (!locked) { return SUCCESS; // 没拿到锁说明有别的线程在处理直接返回成功 } // 5. 更新订单状态为待接单写状态变更日志 try { orderService.updateStatus(request.getOrderNo(), OrderStatus.PENDING_PAYMENT, OrderStatus.PENDING_ACCEPT); } finally { redisLock.unlock(lockKey); } return SUCCESS; }核心思路就是先查状态、判断幂等再加锁、更新状态。订单状态一旦从待支付变成待接单后续所有的重复回调都会在第一步被挡掉不会重复处理。3.4 订单超时关闭延迟消息比轮询优雅用户下单后不支付这台订单不可能永远占着商家的库存。烘焙坊这类系统我设置的是 15 分钟未支付自动关闭同时释放库存。实现方案我对比过两种一种是用定时任务扫表每 30 秒扫一次把超时未支付订单捞出来关闭另一种是消息中间件的延迟消息。定时任务扫表慢但实现简单不引入额外依赖延迟消息实时性好但需要引入 RocketMQ 或者 Redis 的过期事件。我这个项目用的是 Redis 过期事件方案——下单时把订单号写进 Redis设 15 分钟过期同时监听 Redis 的过期事件。但这里提醒一下Redis 过期事件有延迟实际触发时间可能会晚几十秒对于烘焙坊这种体量完全够用。如果未来单量大了再平滑迁移到 RocketMQ 的延迟消息就行。4. 商家端订单管理一个高速运转的工作台4.1 商家订单列表分页、筛选和权限商家端订单列表的核心诉求是快。烘焙坊的商家是店里的店员高峰期可能同时面对十几个订单操作界面必须一目了然。列表页我按订单状态做了 Tab 分类待接单、制作中、待自提/配送中、已完成、已取消。每个 Tab 单独一个分页列表默认按下单时间倒序。权限方面商家端所有查询都必须带shopId而且我要求所有 SQL 都强制带店铺条件不是只查一次然后 Java 里过滤那样万一漏了用户就能看到别人家店铺的订单了。MyBatis-Plus 里可以在查询封装器统一加eq(shop_id, shopId)条件保障数据隔离。这里有个性能优化的细节订单列表页做 MySQL 分页的时候深分页性能会迅速恶化比如LIMIT 100000, 20这种。我的方案是限定商家最多查看最近三个月的订单查询条件强制带上create_time范围让深分页没有机会出现。三个月之前的订单商家端引导去财务系统看报表不提供前台查询。4.2 接单与拒单并发场景下的状态流转商家点击“接单”订单从待接单流转到制作中点击“拒单”订单流转到已取消同时要给用户推送一条取消通知退款原路返回。这个操作看似简单但有个并发问题要想清楚——用户在前台可能正在点“取消订单”商家在后台同时在点“接单”。这种情况怎么处理答案还是状态机。用户取消操作和商家接单操作在代码层面都去调updateStatus方法方法内部先按订单号查询当前状态再判断目标状态是否在合法流转路径中。比如订单当前是“待接单”商家接单和用户取消都是合法的谁先执行谁就成功后执行的会拿到最新状态然后发现自己要做的流转已经不合规了抛异常提示“订单已被取消/已被接单”。这里我还多做了一个处理订单表里加了一个版本号字段version每次更新状态都带着旧的版本号去更新UPDATE orders SET status ?, version version 1 WHERE id ? AND version ?更新行数为 0 就说明有人抢先改了状态需要重试或报错。这就是乐观锁和库存扣减的思路是一脉相承的。4.3 自提核销与配送确认烘焙坊这种业态很多订单是到店自提的。自提的核心是核销——用户到店报订单号商家怎么确认这个订单确实支付过了、可以交货我的方案是下单成功时生成一个核销码是一串 6 位数的随机数字从订单号衍生的哈希取 6 位同时生成一个二维码图片返回给用户。用户到店出示二维码商家在商家端点击“扫码核销”后端校验核销码和订单是否匹配、订单状态是否为待自提校验通过订单流转为已完成同时记录核销时间。核销这个动作有两个注意事项。一是核销码不能直接存在订单表里明文我做了哈希存储防止数据库泄漏后核销码被批量使用。二是核销接口也必须做幂等——用户扫了一次网络不好商家又扫一次第二次要直接返回“订单已核销”不能报错让商家误以为出问题了。配送场景相对简单订单状态为配送中的时候商家点击“确认送达”订单变成已完成。这里我没做骑手轨迹那种复杂功能就是确认送达一个动作适合大部分中小烘焙门店的场景。4.4 商家数据面板今天做了多少钱订单板块做完之后顺带可以做一个小型的数据面板商家端首页展示今日订单数、今日营业额、待处理的待接单数量。这些数据的计算很简单就是带shop_id和date条件去 count 和 sum。但要注意频繁实时计算有压力我的做法是每 5 分钟缓存一次商家端展示的是缓存数据点“刷新”可以强制更新缓存。这个面板虽然不起眼但它是商家每天打开后台必看的东西做好了对整个系统的用户粘性帮助很大。5. 订单状态与库存的联动异常闭环的设计5.1 超时关单后的库存回滚用户下单时扣了库存超时关单时要把库存加回来。这里有一个坑直接SET stock stock 1是错的。举个例子一个蛋糕库存 1 个用户 A 下单扣库存变成 0用户 B 下单因为库存不足被拒绝。这时候 A 超时关单如果执行stock 1库存变成 1这是对的。但如果 A 被关单之后B 又重新下单成功库存又变成 0这时候 A 的关单操作再执行一次stock 1库存就错了。所以库存回滚的 SQL 要这样写UPDATE product_sku SET stock stock #{count} WHERE id #{skuId}注意这个 SQL 不能有stock 0之类的限制回滚操作是纯加库存它就是把之前扣掉的还回去绝不能加条件。而且回滚之前要先判断订单当前状态确实是待支付防止用户刚支付完定时任务又来关单释放库存导致库存和订单对不上。5.2 支付超时与支付成功撞车这是一个特别经典的边界情况用户下单后一直没支付第 15 分钟的时候系统自动关单了但用户恰好在这一秒完成了支付支付回调也成功到达后端。这时候在处理支付回调时会发现订单状态已经变成“已取消”那这单到底算谁的我的处理规则是支付回调先于关单事务执行订单状态为待支付正常流转到待接单关单事务先执行订单状态变成了已取消此时支付回调到达我不能直接把已取消的订单改回待接单那样状态机会乱套。正确做法是将支付成功的金额原路退回给用户订单保持已取消同时给用户发一条通知“您的订单已超时关闭支付金额已原路退回”再加一条人工退款记录方便财务对账。这个场景是一个极其隐蔽的坑不写状态变更日志和幂等判断的话很容易在支付回调处理逻辑里把已取消的订单硬生生掰回待接单。我见过不止一个人在这里翻车。5.3 数据一致性订单状态和支付平台状态要对齐订单系统里最大的数据一致性风险来自两套账——我们的数据库账和支付平台的账。我们这边显示已支付支付平台显示未支付这种就是比较严重的对账问题。我的做法是加一个定时对账任务每天凌晨 2 点拉取前一天所有订单的支付信息去支付平台批量查询真实支付状态和本地数据库比对。发现不一致的生成对账差异记录推送告警给开发负责人。这个任务上线以来确实抓出过几个问题比较典型的就是有几笔支付回调因为网络原因丢失了我们这边订单还是待支付用户却已经扣了款对账任务抓出来之后手动把订单改成已支付并通知商家备货。6. 常见问题与排查技巧实录6.1 用户反馈下单成功但库存没扣下单成功但库存没扣九成是事务问题。创建订单的事务里库存扣减语句执行了但后续生成订单明细时抛了异常事务整体回滚库存扣减也被回滚了。表象来看就是库存没扣但用户端看到的是“下单失败”。排查这类问题第一件事是看日志有没有抛出异常第二件事是确认Transactional有没有生效。Transactional生效的必要条件很反直觉它必须被 Spring 代理才能生效也就是说在同一类内部调用自己的方法事务是不会生效的。我见过太多人把创建订单的逻辑写在同一个类里从外部调用 A 方法A 方法内部调用了同类里的 B 方法B 方法标了Transactional但根本没有起作用。正确做法是把事务边界拆开controller 层薄一点service 层做事务管理核心逻辑全部放入要被外部调用的事务方法里不要在类内部互相调用绕过代理。6.2 商家端显示待接单数量不准商家端首页的待接单角标数字用的是 Redis 缓存 5 分钟。有一次商家反馈说角标一直显示 3 单但点进去已经处理完了用户新下的订单也没加上去。查了半天发现是缓存和数据库一致性出了问题——我更新缓存的时候是先删 Redis 再重新计算但高并发下有请求读到旧值写回去了缓存被旧数据覆盖。后来我把更新策略改成了先更新数据库再用SETEX强制覆盖缓存不做“先删后算”。同时给 Redis key 设置了 60 秒过期兜底即使覆盖失败60 秒后缓存也会自动失效从数据库重新加载。这种方案牺牲一点极端场景的实时性但保证了最终一致性。6.3 大促期间订单表查询变慢怎么办订单表数据量大了之后查询变慢是必然的。我的订单表索引设计是order_no唯一索引、user_id create_time联合索引支撑用户端查询“我的订单”、shop_id status联合索引支撑商家端按状态筛单、shop_id create_time联合索引支撑商家端订单列表排序。这几个索引是实测下来最常用的路径我建议在做订单模块的时候就加上不要等数据量大了再加。MySQL 在大数据量的表上加索引锁表时间非常长对线上业务影响很大。6.4 排查订单问题三件套订单号、状态日志、支付记录最后说一个排查问题的通用方法。遇到任何订单异常我的排查路径永远是固定的三件套先看订单号确认订单当前状态再看order_status_log还原状态流转的全过程最后查支付平台的支付记录确认资金流向。这三步走完九成问题都能定位。剩下的一成基本就是代码逻辑之外的问题——比如用户用了外挂改请求参数、网络异常导致前端没收到返回之类的那些就只能靠客服和用户沟通了。我通常还会在开发环境把日志的 level 调低把这些关键链路串起来真正到了线上需要排查的时候日志就是我们最好的朋友。整个订单模块从设计到上线我前后大概花了三个迭代的时间。回头来看最有价值的决定不是用了什么高深的技术方案而是把状态机定义清楚、把幂等和事务边界想明白。这些看似基础的设定才是整个订单系统稳定运行的地基。