接手一个游戏陪玩平台的订单模块时,最让我头疼的不是并发,不是分布式事务,而是订单状态根本没有章法。陪玩师那边的订单列表显示“进行中”,用户这边却还是“待支付”;客服查单只能靠人工对时间——所有状态都是Java代码里随手写的int值,没有枚举,没有流转校验,数据库表里连个状态变更记录都没有。这篇文章我把基于SpringBoot+MyBatis的陪玩订单流转源码重新整理了一遍,从表结构、状态机、Service事务、MyBatis动态SQL到并发兜底,逐层拆开讲清楚。无论你是刚入行想看懂订单模块的后端新人,还是正在维护一个“不敢动”的老订单体系的同学,照着这套思路走,至少能少踩我踩过的那几个坑。
1. 陪玩订单状态机的设计:为什么不是简单的“待支付-已完成”
1.1 陪玩业务里的订单对象,到底比普通商品订单多在哪
游戏陪玩平台上的订单,并不等价于点外卖或者买商品。普通电商订单的链路是“下单-支付-发货-收货”,用户和商家之间没有强实时互动;而陪玩订单是服务型订单,核心能力是撮合用户和一个真人陪玩师,并且以时间为计费单位。因此订单必须表达出“谁来接单”“服务有没有开始”“中途是否异常结束”这些状态,否则无法支撑结算和售后。
一个完整的陪玩订单流程,在实际业务里大概是这样的:用户浏览陪玩师主页,选择某个游戏某个段位的陪玩套餐,提交订单后生成“待支付”状态;用户支付成功,平台在陪玩师端生成一条“待接单”任务;陪玩师接单后进入“进行中”;服务时间结束或者陪玩师主动点击结束,订单变成“待确认”;用户确认后订单才算“已完成”,平台据此给陪玩师结算。中途任何异常或用户反悔,还要支持“已取消”“退款中”这类终态和半终态。
所以设计这个模块时,先把这些业务节点映射成了一张状态流转表,而不是急着写接口。很多人上来就给订单表加一个status字段,然后从1到5随便定义,等到陪玩师拒单、用户申请售后、超时自动取消这些分支涌进来,状态就彻底失控了。状态机不是后端给自己加戏,它是为了让“谁能在什么条件下把订单推到哪个状态”这件事变得可验证、可追溯。
1.2 表结构里必须带上version和前后状态
陪玩订单的物理表,我见过最简单的只留id和status,也见过把所有字段堆到一张表里的。合理的做法是保持订单主表轻量,状态相关的字段尽量必要且克制动。核心字段大概长这样:
CREATE TABLE `play_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL COMMENT '下单用户ID', `companion_id` BIGINT NOT NULL COMMENT '陪玩师ID', `game_id` BIGINT DEFAULT NULL COMMENT '游戏ID', `game_snapshot` VARCHAR(128) DEFAULT NULL COMMENT '游戏快照信息', `price_amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额,单位元', `duration_minutes` INT NOT NULL DEFAULT 60 COMMENT '陪玩时长,分钟', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付 1待接单 2进行中 3待确认 4已完成 5已取消', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间', `begin_time` DATETIME DEFAULT NULL COMMENT '开始服务时间', `end_time` DATETIME DEFAULT NULL COMMENT '结束服务时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_companion_id` (`companion_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='陪玩订单表';这张表里有两个字段容易被忽略,一个是order_no,一个是version。
order_no必须用业务号而不是自增id暴露给前端。早期我喜欢直接用数据库自增id作为订单号,后来发现一个问题:用户凭订单号找客服时,随便输个数字都可能撞上别人的订单,而且订单号带有时间信息对运营排查也很有用。现在的做法是用“时间戳+用户尾号+随机数”拼一个紧凑串,再加唯一索引兜底,保证同一个用户在同一毫秒内也不会重复。
version字段是专门为并发更新设计的。订单更新不像帖子浏览那样更新频率低,它需要应对支付回调、陪玩师接单、用户取消同时发生的情况。没有version,两条更新互相覆盖,状态就会倒着走。后面第四节我会专门展开讲,这里先记住:version不是可有可无的优化,是订单表的基础设施。
1.3 把“谁允许从哪个状态到哪个状态”写进代码
状态机不一定要引入工作流引擎,对于陪玩订单这种状态数量不超过十个的业务,用枚举加一张流转表就够了。我先列一下最核心的流转规则:
| 当前状态 | 操作 | 目标状态 | 关键校验点 |
|---|---|---|---|
| 待支付 | 支付成功回调 | 待接单 | 支付金额与订单金额一致 |
| 待支付 | 用户主动取消 | 已取消 | 无支付记录或退款流程 |
| 待接单 | 陪玩师接单 | 进行中 | 操作者必须是订单上指定的陪玩师 |
| 进行中 | 陪玩师点击结束服务 | 待确认 | 服务时长已结束 |
| 待确认 | 用户确认完成 | 已完成 | 无售后纠纷 |
| 待接单/进行中 | 超时或用户申请取消 | 已取消 | 平台审核或自动条件 |
这些规则如果散落在Service层的if/else里,下一次需求变更一定有人漏改。我倾向于用一个Java枚举把状态码和流转合法关系收拢起来:
public enum OrderStatus { UNPAID(0, "待支付"), WAIT_ACCEPT(1, "待接单"), IN_PROGRESS(2, "进行中"), WAIT_CONFIRM(3, "待确认"), COMPLETED(4, "已完成"), CANCELLED(5, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } public static boolean canTransition(int from, int to) { switch (OrderStatus.valueOfCode(from)) { case UNPAID: return to == WAIT_ACCEPT.getCode() || to == CANCELLED.getCode(); case WAIT_ACCEPT: return to == IN_PROGRESS.getCode() || to == CANCELLED.getCode(); case IN_PROGRESS: case WAIT_CONFIRM: return to == COMPLETED.getCode() || to == CANCELLED.getCode(); default: return false; } } public static OrderStatus valueOfCode(int code) { for (OrderStatus status : values()) { if (status.code == code) { return status; } } throw new IllegalArgumentException("未知订单状态: " + code); } }这里有一个很多人早期会踩的坑:状态码的数字一旦定义好,上线之后千万不要重排。有人说“我在中间插一个新状态,后面的顺延下不就行了”,结果数据库里历史订单的status就全对不上了,前端状态文案也会全部错位。正确做法是只追加新枚举值,老数字永远不动,宁可状态码看起来不连续,也不能破坏线上数据。
2. SpringBoot的分层编排:从Controller到Mapper的一整条链路
2.1 为什么这套组合仍然能打
SpringBoot+MyBatis这两样组合现在看来不算新潮,但做订单模块我很推荐。SpringBoot把配置简化到了几乎不用操心,业务人员可以更专注在Service层写状态流转;MyBatis则把SQL的控制权交还给开发者,尤其是订单列表这种多条件筛选、订单更新这种只要状态和时间字段的动态SQL,MyBatis写起来非常顺手。相比之下,全自动ORM在复杂条件更新时很容易生成你根本不想看到的整行update,反而增加了出问题的概率。
当然,项目里也可以引入MyBatis-Plus来处理单表CRUD,但订单状态更新这种核心方法我坚持手写SQL。原因很简单:核心更新需要明确的条件控制“status=期望值、version=期望版本”,而不是根据主键无脑更新。手写SQL能让这个意图直接显现在代码里,也方便后续做SQL review。
2.2 创建订单的核心Service实现
订单流转的起点是创建订单。这里给出一个非常典型的Service实现,把事务、业务校验、订单号生成、状态初始化、日志记录都串起来:
@Service public class OrderServiceImpl implements OrderService { private final OrderMapper orderMapper; private final OrderLogMapper orderLogMapper; private final IdGenerator idGenerator; public OrderServiceImpl(OrderMapper orderMapper, OrderLogMapper orderLogMapper, IdGenerator idGenerator) { this.orderMapper = orderMapper; this.orderLogMapper = orderLogMapper; this.idGenerator = idGenerator; } @Override @Transactional(rollbackFor = Exception.class) public String createOrder(CreateOrderRequest req) { // 1. 基础校验:用户状态、陪玩师接单状态、套餐是否有效 // 这里省略具体查询逻辑,但一定不能只校验字段非空 // 2. 生成业务订单号 String orderNo = idGenerator.generateOrderNo(); // 3. 构造订单实体,初始状态为待支付 PlayOrder order = PlayOrder.builder() .orderNo(orderNo) .userId(req.getUserId()) .companionId(req.getCompanionId()) .gameId(req.getGameId()) .gameSnapshot(req.getGameSnapshot()) .priceAmount(req.getPriceAmount()) .durationMinutes(req.getDurationMinutes()) .status(OrderStatus.UNPAID.getCode()) .version(0) .build(); orderMapper.insert(order); // 4. 记录状态变更流水 orderLogMapper.insert(OrderLog.builder() .orderId(order.getId()) .fromStatus(null) .toStatus(OrderStatus.UNPAID.getCode()) .build()); return orderNo; } }这里有几个设计点需要展开。
第一,@Transactional(rollbackFor = Exception.class)不能省。订单创建和订单日志记录必须同生共死,如果订单插入成功但日志失败,整个事务回滚,不会出现“有订单没日志”的情况。
第二,为什么订单号不用数据库自增id。自增id暴露给前端等于泄露业务量,而且陪玩平台经常要做订单号反查,一个可读性强的业务号能减轻客服压力。我们的IdGenerator会生成类似“日期+用户尾号+随机序列”的串,虽然长了点,但在valid和体验之间是值得的。
第三,创建订单时不建议在循环里查询陪玩师状态然后再更新库存之类的操作。这种场景适合在Service里直接先查一次,把校验和创建放在同一个事务里。如果陪玩师同时接了过多订单导致超卖,那是另一个并发问题,应该在陪玩师的订单计数表上加条件更新,不是在这里用锁。
2.3 事务边界与状态流转的落库逻辑
订单流转的核心不是新方法,而是每个状态迁移都必须走到“条件更新”这一步。以陪玩师接单为例,Service层的大致逻辑是这样:
@Override @Transactional(rollbackFor = Exception.class) public boolean acceptOrder(Long orderId, Long companionId) { PlayOrder order = orderMapper.selectById(orderId); if (order == null) { throw new BizException("订单不存在"); } if (!order.getCompanionId().equals(companionId)) { throw new BizException("该陪玩师无权接此订单"); } if (!OrderStatus.canTransition(order.getStatus(), OrderStatus.IN_PROGRESS.getCode())) { throw new BizException("当前状态不能接单,请刷新后重试"); } int rows = orderMapper.updateStatusByCondition( order.getId(), order.getStatus(), OrderStatus.IN_PROGRESS.getCode(), order.getVersion() ); if (rows != 1) { throw new BizException("接单失败,订单已被其他人处理,请刷新"); } orderLogMapper.insert(OrderLog.builder() .orderId(order.getId()) .fromStatus(order.getStatus()) .toStatus(OrderStatus.IN_PROGRESS.getCode()) .build()); return true; }这段代码的本质是“Read-Check-Update”,是订单模块最常见的操作模式。要注意,这里的updateStatusByCondition并不是普通意义上的按主键更新,它把查询时读到的status和version一并放进了where条件。后面MyBatis部分我会给出它的SQL写法。总之,在OrderService这一层,事务边界就是一次状态迁移的最外层,日志、金额、状态字段全部放在同一个事务里,一旦更新影响行数为0就抛异常回滚,保证系统里不会出现“逻辑上已经完成但状态没推进”的中间态。
很多同学喜欢把业务校验放在Controller层,Service只做数据库操作,结果Controller越来越厚,状态流转规则到处引用。我的习惯是Controller只做参数接收和简单校验,真正的状态机和金额判断全部放在Service层。原因很简单:Service会被多个入口调用(比如Web接口、管理后台、定时任务),只有把校验下沉到Service才能保证每种入口都不绕过状态机。
3. MyBatis动态SQL:陪玩订单的查询与状态更新写法
3.1 带筛选条件的订单列表SQL
陪玩订单的查询往往是多条件的:用户要看自己的订单,陪玩师要看待接单列表,运营后台要按游戏、时间区间、状态筛选。这时候MyBatis的 标签很好用,我经常在项目里写下面这种SQL:
<select id="selectOrderList" resultType="com.demo.entity.PlayOrder"> SELECT id, order_no, user_id, companion_id, game_id, price_amount, duration_minutes, status, version, pay_time, begin_time, end_time, create_time, update_time FROM play_order <where> <if test="userId != null"> AND user_id = #{userId} </if> <if test="companionId != null"> AND companion_id = #{companionId} </if> <if test="status != null"> AND status = #{status} </if> <if test="gameId != null"> AND game_id = #{gameId} </if> <if test="startTime != null"> AND create_time >= #{startTime} </if> <if test="endTime != null"> AND create_time <= #{endTime} </if> AND deleted = 0 </where> ORDER BY create_time DESC </select>清单里要刻意把deleted = 0写成无条件,不放进 ,这个作用有两个:一是防止逻辑删除的数据出现在列表里,二是提醒后来人这张表是逻辑删除设计。至于列表分页,我在项目里用了PageHelper,但要提醒一句:PageHelper在使用多表join查询时要小心count语句,最好的办法是在写SQL时把分页查询和count查询分开,或者干脆在SQL里用子查询包一层。
3.2 用动态更新状态,避免覆盖整行
订单状态更新是重头戏。我见过太多项目在Mapper里写一个简单的updateStatus(id, status)方法,执行后update_time没变、version没变,甚至把整行都覆盖了。订单模块绝不能这样。下面的SQL是我的标准写法:
<update id="updateStatusByCondition"> UPDATE play_order <set> status = #{newStatus}, version = version + 1, update_time = NOW(), <if test="payTime != null"> pay_time = #{payTime}, </if> <if test="beginTime != null"> begin_time = #{beginTime}, </if> <if test="endTime != null"> end_time = #{endTime}, </if> </set> WHERE id = #{id} AND status = #{expectedStatus} AND version = #{expectedVersion} AND deleted = 0 </update>注意这里的sql,我们不是无脑set整个字段,而是要变更哪个时间字段就拼接哪个,防止把其他业务写入的数据覆盖。同时where条件里把expectedStatus和expectedVersion都带上,任何一个对不上,影响行数就是0,Service层就能感知到“并发冲突”。
很多人会问:我已经把version当乐观锁了,为什么还要把status也放进去?答案很简单,version是全局递增的,如果只校验version,可能出现一种脏数据场景:某操作读了订单的status=1、version=0,后来订单状态被改为2且version变成1,又因为某些历史原因回退成status=1、version=1,这时旧操作拿着version=0自然会被挡住,但单靠version挡不住“用户在同一状态内部重复操作”的边界情况。把status和version双条件带上,等于把状态机和乐观锁一起压进了数据库层,是订单模块最稳的兜底。
3.3 状态码与枚举的映射,别让前端看到裸数字
后端接口返回订单时,我通常会返回一个OrderVO,里面既有statusCode,也有statusDesc,这样前端展示文案不需要自己翻译数字。更重要的是,枚举的desc不要直接写在Controller里,而是集中放在OrderStatus枚举中,后端多语言也方便扩展。
前端状态管理容易踩的另一个坑是:直接用字符串“2”或“3”做判断,一旦后端枚举变更,前端所有判断都要跟着改。我建议前端也维护一份与后端一致的状态常量表,并在接口文档里把状态机的流转规则画清楚。比如“待接单”状态只能在陪玩师端显示“接单”按钮,在用户端显示“取消订单”按钮,这些规则如果后端只靠状态机校验,前端就能少写很多“不该出现的操作”。
平时开发时也要注意,不要简单地把枚举变成魔法数字传进Mapper。与其在Service里写orderMapper.updateStatus(id, 3),不如显式地传OrderStatus.IN_PROGRESS.getCode()。代码review时看到魔法数字直接打回,这是成本最低的防线。
4. 订单流转的并发问题与兜底方案
4.1 最怕的不是订单丢,而是状态回退
做订单模块的同学一定听过“丢失更新”这个词。放到陪玩场景里,最典型的例子是:用户支付成功回调线程正在把订单从“待支付”改成“待接单”,同时用户在App上点击“取消订单”也发了请求。两个请求并发处理,如果更新方法都是直接修改status,后提交的那个会把前一个的结果覆盖掉。
假设取消请求先读到了status=0,然后支付回调也读到了status=0,此时支付回调先更新status=1,接着取消请求再更新status=5——结果看起来“已取消”,但用户其实已经付款了,款还没退。更恶心的场景是取消请求先更新status=5,支付回调后更新status=1,把一单已取消的订单又激活成待接单。这才是订单系统最怕的:状态倒退。
所以我在设计时把“不变量”定为:订单状态只能向前流转,任何一次更新都必须基于当前真实状态。这个不变量单独靠应用层加锁很难保证,分布式锁太重,同步锁在集群环境里又不可靠,最可靠的其实是数据库本身的行锁配合条件更新。
4.2 乐观锁和条件更新:最容易被低估的兜底
我们来看updateStatusByCondition在并发时序下的真实表现。
- 事务A和事务B同时读取订单id=100,status=0,version=0。
- 事务A执行更新:WHERE id=100 AND status=0 AND version=0,成功,status变成1,version变成1,事务未提交时持有该行锁。
- 事务B执行更新:同样WHERE条件,如果A还没提交,B会阻塞等待;A提交后,B的update匹配不到status=0和version=0,影响行数为0。
- Service层看到rows=0,抛出“订单已被处理”的异常,事务B回滚。
整个过程不需要额外引入Redis锁,不需要synchronized,只靠一条带条件的update语句就挡住了并发覆盖。这就是为什么我说“条件更新是最被低估的兜底方案”。订单这种低频修改场景,乐观锁导致的重试成本几乎可以忽略,但它换来的是数据一致性。
需要提醒的是:不要在事务里先select后update之前加select ... for update,虽然也能锁行,但会明显降低并发吞吐,而且死锁风险更高。我们的方案是“先读快照,再条件更新,失败就回滚重试”,这在订单场景里是性价比最高的做法。如果遇到极端的高并发抢单场景,业务上可以接受“失败让用户重试”,代码结构仍然不变。
4.3 超时未支付订单的自动取消:定时任务实践
订单模块还有一个绕不开的需求:超时未支付自动取消。一般陪玩平台会给用户15分钟左右支付时间,逾期订单自动关闭,把陪玩师的时间释放出来。
我用的方案是Spring自带的@Scheduled,每分钟扫描一次:
@Component public class OrderTimeoutTask { private final OrderMapper orderMapper; private final OrderLogMapper orderLogMapper; public OrderTimeoutTask(OrderMapper orderMapper, OrderLogMapper orderLogMapper) { this.orderMapper = orderMapper; this.orderLogMapper = orderLogMapper; } @Scheduled(cron = "0 */1 * * * ?") public void cancelExpiredUnpaidOrder() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(15); List<PlayOrder> expiredOrders = orderMapper.selectExpiredUnpaid(deadline, 200); for (PlayOrder order : expiredOrders) { try { int rows = orderMapper.updateStatusByCondition( order.getId(), order.getStatus(), OrderStatus.CANCELLED.getCode(), order.getVersion() ); if (rows == 1) { orderLogMapper.insert(OrderLog.builder() .orderId(order.getId()) .fromStatus(order.getStatus()) .toStatus(OrderStatus.CANCELLED.getCode()) .build()); } } catch (Exception e) { // 这里的异常不应该阻塞整批任务,记录日志后继续处理下一单 log.error("cancel expired unpaid order error, orderId={}", order.getId(), e); } } } }这个任务里最容易出问题的是扫描SQL。一定要加上limit,否则订单量一大,每一次定时任务都全表扫描,数据库迟早被拖垮。我在Mapper里写了这样的语句:
<select id="selectExpiredUnpaid" resultType="com.demo.entity.PlayOrder"> SELECT id, order_no, user_id, companion_id, status, version FROM play_order WHERE status = 0 AND create_time < #{deadline} AND deleted = 0 ORDER BY create_time ASC LIMIT #{limit} </select>还有一点经验:定时任务在分布式环境下要小心多节点重复执行。如果项目只有一台机器部署,@Scheduled就够了;如果有多台实例,建议引入分布式锁,或者使用xxl-job这类带任务分配的执行器。否则两个节点同时扫描同一批订单,虽然条件更新能挡住脏数据,但会产生大量无意义的日志和重复处理。
“先查后更”这个模式在定时任务里也成立吗?有人会问,我明明查到的status=0,为什么更新时不直接update,还要带version?因为查询和更新之间存在时间窗口。假设查询时订单确实 unpaid,但用户在这期间完成了支付,如果我们不带status和version条件直接更新,就会把一笔已支付订单取消掉,造成资损。加上条件更新后,支付回调先变更status=1,定时任务再更新时影响行数为0,就不会误取消。这个兜底是必须要有的。
5. 源码之外:上线前必须考虑的几个细节
5.1 配置与脱敏:订单字段别裸奔
订单列表接口刚上线时,我们把MySQL DECIMAL类型的金额直接返给前端,日志里也打印了用户的手机号。后来做了一次安全巡检,发现两个问题:第一,金额在Java里用BigDecimal处理,但接口返回的JSON序列化时,如果不指定保留小数,可能输出1.0或者1.00,前端展示很乱。第二,日志里带出用户手机号、微信号这类敏感信息,一旦日志泄露就是事故。现在我们在订单实体里做了敏感字段脱敏,Controller返回VO,日志只打印订单id和状态。
金额字段还有一个更稳的做法:数据库存分,单位是整数,Java里用Integer/Long,前端展示时再除以100。这样可以完全避开浮点数的精度问题。可惜很多早期表已经用了decimal,改造成本大,那就只能在所有计算里统一用BigDecimal,并且必须使用compareTo而不是equals去比较金额。这些坑不是SpringBoot框架能帮你挡住的,纯粹靠编码习惯。
5.2 我踩过的坑:状态码写进业务代码
这里分享一个让我印象深刻的事故。早期有一个接单按钮的接口,代码长这样:
orderMapper.updateStatus(orderId, 2);当时看起来没什么问题,直到运营后台上线了“强制关闭订单”的功能,也是直接调用这个Mapper。结果有一次投诉处理时,运营不小心对一个“进行中”的订单点了“接单成功”,由于更新方法没有状态条件,订单状态从“进行中”被强行改成了“待接单”。线上用户看到陪玩师端显示接单中,用户端却显示订单已在进行中,两边数据直接冲突。排查了很久才发现是那段没有状态判断的update造成的。
从此以后我给自己定了一条规则:凡是订单状态更新,Mapper方法必须显式接收expectedStatus,不允许出现只接收newStatus的更新接口。如果你在review老代码时看到orderMapper.updateStatus(字段, 新状态)这种签名,趁早改成条件更新,它迟早是个雷。
另一个坑是事务里调用外部服务。曾经为了在创建订单后通知陪玩师,我在事务里同步调用推送接口,结果推送服务变慢,直接拖住了数据库连接池,整个订单创建接口跟着假死。事务边界内只做数据库操作和必要的内存计算,外部RPC、MQ发送、短信通知统统放到事务提交之后,或者用Spring的@TransactionalEventListener监听事务提交事件。这是提高订单模块稳定性的重要习惯。
5.3 后续可扩展:订单消息推送与履约撮合
核心的订单流转跑通之后,千万别以为就万事大吉。真正的生产系统还需要在几个方向做扩展。
支付回调需要增加幂等处理。同一笔支付结果可能重复通知,我们必须在回调处理里通过order_no+支付流水号做唯一约束,否则同一笔订单会被重复推进状态。
订单状态变化要发消息。陪玩师接单、服务开始、订单完成这些节点都是实时性很强的行为,完全可以利用MQ把事件广播出去,让通知服务、统计服务、结算服务分别消费,而不是在OrderService里逐个调用。早期小规模直接同步调用能省事,订单量上来以后一定要拆。
售后和退款要单独设计。本文只写了正向状态流转,但真实业务里一定会有“退款中”“退款成功”这类状态。建议把这些状态作为特殊情况平铺进状态机,再配合独立的refund表,不要在订单主表里加一堆退款字段,否则查询和更新都会越来越难维护。
如果你正在做类似的订单模块,我建议先把状态机、条件更新、订单日志这三件事补上,再考虑消息队列和分库分表。这是我在做了几个服务型订单项目之后最想强调的一点:订单流转的核心从来不是框架选型,而是能不能保证每一次状态变化都建立在正确的当前状态之上,并能完整追溯。把这层地基打好,SpringBoot和MyBatis在这里扮演的角色才真正值回票价。