设计订单服务第一版:状态机、事务、幂等与订单号核心实践

发布时间:2026/10/3 3:18:45
设计订单服务第一版:状态机、事务、幂等与订单号核心实践 1. 从“一张订单表”说起OrderService 的第一版职责边界1.1 为什么几乎每个系统都要写一个 OrderService订单是电商、外卖、SaaS 计费、工单系统里绕不开的核心概念。我见过不少团队一开始把订单相关的逻辑塞进 Controller、塞进工具类、甚至塞进一个叫OrderUtil的静态方法里等订单量上来、状态一多改一个需求要同时动五六个文件没人敢碰。OrderService 这个类存在的意义就是把“订单”这个业务聚合根的所有操作收敛到一个清晰的入口。第一版 OrderService 不需要高大上的设计模式也不需要一开始就上 CQRS、事件溯源。它要解决的是最基础但最要命的问题谁能下单、订单怎么生成、状态怎么流转、数据怎么落库。你把这个边界划清楚后续的订单超时关单、售后、对账、分库分表才有地方挂。版本 1 的目标我一般定得很朴素能正确创建订单主表和明细表数据一致订单状态流转有明确的规则不允许随意跳状态所有写操作走同一个 service 入口方便加事务和审计接口幂等重复请求不产生脏数据。先做到这四点比什么“高可用”“高性能”都实在。1.2 第一版订单模型主表 明细表别把鸡蛋放一个篮子订单的数据结构是所有后续逻辑的地基。我踩过最大的坑就是早期图省事把商品名称、单价、数量、收货地址全塞在订单主表的一个 JSON 字段里。查询确实方便但到了要统计 SKU 销量、做对账、拆分售后的阶段JSON 字段根本没法建索引、没法 JOIN、没法保证数据一致性。版本 1 就按最经典的两张表设计订单主表t_order和订单明细表t_order_item。主表只放订单级的信息订单号、用户 ID、订单状态、支付金额、优惠金额、运费、实付金额、收货人信息快照、创建时间、支付时间、更新时间。明细表放每个商品的 SKU ID、商品名称快照、单价、数量、小计金额。为什么要做“快照”因为商品价格、名称、促销信息随时可能变订单一旦生成就必须定格当时的交易信息不然售后和财务对账会对不上。主表和明细表用 order_id 关联事务里同时写入。有人问为什么不用订单号直接做主键我建议主键用自增 ID、订单号单独建唯一索引。原因很实际自增主键在 InnoDB 下插入性能好B 树不会频繁页分裂订单号要面向用户和外部系统长度往往在 20 位以上拿它当主键既占空间又影响二级索引的查询效率。CREATE TABLE t_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号唯一, user_id BIGINT NOT NULL COMMENT 下单用户ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态0待支付 1已支付 2已发货 3已完成 4已取消, total_amount DECIMAL(12,2) NOT NULL COMMENT 商品总金额, discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT 优惠金额, freight_amount DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT 运费, pay_amount DECIMAL(12,2) NOT NULL COMMENT 实付金额 total - discount freight, receiver_info VARCHAR(500) NOT NULL COMMENT 收货人信息快照JSON, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;字段类型上给个建议金额一律用DECIMAL(12,2)绝对不要用FLOAT或DOUBLE。二进制浮点数在金额计算上有精度问题0.1 0.2 不等于 0.3 这种坑在订单金额上出现一次就是线上事故。状态字段用TINYINT而不是字符串省空间、查询快代码里用枚举映射可读性也不差。1.3 OrderService 的接口怎么规划第一版的 OrderService 接口不要贪多把核心链路打通就够了。我通常只暴露这几个方法public interface OrderService { /** * 创建订单返回内部订单号 */ String createOrder(OrderCreateRequest request); /** * 支付回调处理由支付网关调用 */ boolean handlePayCallback(PayCallbackRequest request); /** * 用户主动取消订单 */ boolean cancelOrder(String orderNo, Long userId); /** * 查询订单详情含明细 */ OrderDetailVO getOrderDetail(String orderNo, Long userId); /** * 查询用户订单列表分页 */ PageResultOrderSummaryVO pageUserOrders(Long userId, int pageNum, int pageSize); }你没看错第一版就五个方法。不要一上来就写updateOrderStatus、modifyOrderAddress这种“万能更新接口”。订单状态的变更一定是有业务语义的updateOrderStatus这个名字本身就意味着“谁都能随便改状态”这是订单系统混乱的开始。另外所有查询方法都要强制带userId查询时把user_id条件拼上。不是多此一举这是数据权限的底线——用户只能查自己的订单。我见过有团队订单详情接口只传 orderNo 不带 userId结果一个用户遍历订单号把所有订单都看光了这就是越权漏洞。2. 版本 1 的订单状态机与数据流转2.1 状态不用枚举后面一定后悔订单状态我坚持用枚举 状态机的组合而不是在 Service 里到处写if (order.getStatus() 1)。状态枚举把“订单处于什么阶段”这个信息集中管理状态机把“允许从哪里到哪里”的规则收口。第一版我只会定义五个状态待支付、已支付、已发货、已完成、已取消。注意已取消里我还要区分“用户主动取消”和“超时系统取消”但对外展示的状态可以合并内部用cancel_type字段区分就行。public enum OrderStatus { PENDING_PAYMENT(0, 待支付), PAID(1, 已支付), SHIPPED(2, 已发货), COMPLETED(3, 已完成), CANCELLED(4, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public static OptionalOrderStatus fromCode(int code) { for (OrderStatus status : values()) { if (status.code code) { return Optional.of(status); } } return Optional.empty(); } }这里用Optional而不是直接返回 null是为了强迫调用方处理“状态码不存在”的情况。数据库里如果出现一条 status 99 的记录代码里fromCode(99)返回空调用方必须给出兜底逻辑而不是拿到 null 之后 NPE。2.2 状态机的校验非法流转直接拒绝状态机的核心不是“记录状态”而是“校验流转”。第一版我用一个简单的前置校验方法实现不上状态机框架够用且好维护private static final MapOrderStatus, SetOrderStatus ALLOWED_TRANSITIONS new EnumMap(OrderStatus.class); static { ALLOWED_TRANSITIONS.put(OrderStatus.PENDING_PAYMENT, EnumSet.of(OrderStatus.PAID, OrderStatus.CANCELLED)); ALLOWED_TRANSITIONS.put(OrderStatus.PAID, EnumSet.of(OrderStatus.SHIPPED, OrderStatus.CANCELLED)); ALLOWED_TRANSITIONS.put(OrderStatus.SHIPPED, EnumSet.of(OrderStatus.COMPLETED)); ALLOWED_TRANSITIONS.put(OrderStatus.COMPLETED, EnumSet.noneOf(OrderStatus.class)); ALLOWED_TRANSITIONS.put(OrderStatus.CANCELLED, EnumSet.noneOf(OrderStatus.class)); } private void checkTransition(OrderStatus from, OrderStatus to) { SetOrderStatus allowed ALLOWED_TRANSITIONS.get(from); if (allowed null || !allowed.contains(to)) { throw new BizException(ErrorCode.ILLEGAL_STATUS_FLOW, String.format(非法订单状态流转%s - %s, from.getDesc(), to.getDesc())); } }这段代码的作用是让状态变更“有法可依”。比如已完成的订单不能再次取消已取消的订单不能重新支付这些规则写在状态机里比散落在各个 Service 方法里直观得多。状态机的更新我用一条带条件的 UPDATE 语句而不是先查出来再 update目的是防并发。两个请求同时读到 status 0一个要支付、一个要取消不加条件的话后提交的会把先提交的结果覆盖掉。int updated orderMapper.updateStatusByOrderNo(orderNo, fromStatus.getCode(), toStatus.getCode(), expectedVersion); if (updated 0) { throw new BizException(ErrorCode.ORDER_STATUS_CHANGED, 订单状态已变更请刷新后重试); }对应的 SQL 是UPDATE t_order SET status #{toStatus}, update_time NOW() WHERE order_no #{orderNo} AND status #{fromStatus}这个WHERE status #{fromStatus}就是乐观锁的核心。受影响行数为 0 说明状态已经被别人改过了此时必须放弃操作而不是强行覆盖。2.3 事务边界哪些操作必须包在同一个事务里版本 1 最容易翻车的是事务边界没划清楚。我总结一个原则事务只包裹数据库写操作永远不要包裹远程调用、外部 HTTP 请求、消息发送。创建订单这个动作拆开看包含了以下步骤校验下单参数商品是否存在、库存是否充足、价格是否合法生成订单号冻结/扣减库存插入订单主表批量插入订单明细表发送 MQ 消息通知下游如清点积分、通知仓储。第 1、2 步是纯内存操作不需要事务第 6 步是外部依赖不能放进事务。真正需要保证原子性的是第 3、4、5 步——要么全部成功要么全部失败不允许出现“订单主表成功但明细表没插进去”的状态。所以我第一版的下单方法长这样Transactional(rollbackFor Exception.class) public String createOrder(OrderCreateRequest request) { // 1. 参数校验无事务 validate(request); // 2. 幂等校验 String idempotentKey request.getIdempotentKey(); if (idempotentService.isHandled(idempotentKey)) { return idempotentService.getLastResult(idempotentKey); } // 3. 订单号生成 String orderNo orderNoGenerator.generate(); // 4. 库存扣减数据库乐观锁 boolean deducted stockService.deduct(request.getSkuId(), request.getQuantity()); if (!deducted) { throw new BizException(ErrorCode.STOCK_NOT_ENOUGH, 库存不足); } // 5. 创建订单主表记录 OrderDO order buildOrder(request, orderNo); orderMapper.insert(order); // 6. 批量创建订单明细 ListOrderItemDO items buildItems(request, order.getId()); orderItemMapper.batchInsert(items); // 7. 记录幂等结果同一事务保证幂等标记和订单数据一致 idempotentService.recordSuccess(idempotentKey, orderNo); // 8. 事务提交后发送消息 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { mqProducer.sendOrderCreatedEvent(orderNo); } }); return orderNo; }第 8 步是版本 1 就值得养成的习惯利用afterCommit()在事务真正提交之后再发消息。如果先发消息再提交事务消费者可能读到还没提交的数据如果事务回滚了但消息已经发出去了下游就处理了一条不存在的订单。用afterCommit()能保证消息发送时机和事务结果严格同步就算消息发送失败主流程也不受影响后续可以用对账任务补偿。3. 核心实现细节下单主流程与并发安全3.1 订单号生成别用时间戳 随机数订单号看起来简单实际上是个很容易踩坑的设计。第一版我见过有人用System.currentTimeMillis()加随机数生成订单号并发稍微一高就重复数据库唯一索引一报错用户看到的就是 500。订单号要满足几个条件全局唯一尽量短方便人读和客服查询最好带时间信息方便按天分表和对账。推荐方案是“时间戳 机器标识 序列号”。用雪花算法Snowflake生成 64 位 Long转成字符串。雪花算法的好处是趋势递增、生成快、不依赖数据库。但要处理时钟回拨的问题——机器时间往回跳了生成的 ID 可能重复。第一版我建议直接用数据库号段或者 Redis 的 INCR 生成自增序列配合日期前缀比如20250217 8位自增序列。实现简单、没有时钟回拨的隐患在单库单表的阶段完全够用。等以后分库分表了再换成发号器组件。public String generateOrderNo() { String datePrefix LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); long seq redisTemplate.opsForValue().increment(order:seq: datePrefix); return datePrefix String.format(%08d, seq); }这里INCR是原子的不会出现并发重复。每天一个 key从 1 开始计数8 位序列能支撑每天 1 亿单第一版业务量远到不了这个级别。3.2 库存扣减先扣库存还是先建订单版本 1 要明确一个顺序问题先扣库存还是先建订单我的答案几乎永远是先扣库存。道理很简单库存是稀缺资源订单是记录。如果先建订单再扣库存可能出现“订单建好了但库存不够”的尴尬局面你只能把订单标记为失败白白产生一张废弃订单。扣库存的 SQL 要用条件更新不要先查后改UPDATE t_stock SET available_stock available_stock - #{quantity}, update_time NOW() WHERE sku_id #{skuId} AND available_stock #{quantity}available_stock #{quantity}是防超卖的关键。数据库的行锁会串行化同一 Sku 的扣减请求库存不够时受影响行数直接为 0业务层拿到 0 就知道本次扣减失败。在高并发场景下这个方案会有一点性能损耗但对版本 1 的绝大多数业务足够了没必要一开始就上 Redis 预扣库存 异步对账那一套。扣完库存之后如果订单创建失败必须回滚库存。这里就是事务的用武之地只要createOrder方法加了Transactional第 4 步的库存扣减和第 5 步的订单插入就在同一个事务里任何一步抛异常库存自动回滚不需要手动补偿代码。3.3 幂等设计同一个请求不能产生两笔订单用户在下单页面手抖点了两次“提交”或者支付网关回调重试了三次系统必须保证只产生一笔订单。这就是接口幂等性要解决的问题。版本 1 我用一张幂等表来实现表结构很简单CREATE TABLE t_idempotent ( id BIGINT NOT NULL AUTO_INCREMENT, idempotent_key VARCHAR(64) NOT NULL COMMENT 幂等键唯一, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型ORDER_CREATE / PAY_CALLBACK, biz_result VARCHAR(64) NOT NULL COMMENT 业务处理结果如订单号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_idempotent_key (idempotent_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT幂等记录表;处理流程是请求进来先根据userId 请求业务参数生成一个 md5 的幂等键尝试插入t_idempotent如果主键冲突说明之前处理过了直接返回上次的结果插入成功说明这是第一次请求继续处理业务。try { idempotentMapper.insert(key, bizType); } catch (DuplicateKeyException e) { // 重复请求返回之前的结果 return idempotentMapper.getResultByKey(key); }这里千万不能用“先查再插”两个并发请求同时查询都查不到记录然后同时插入就都进业务了。唯一索引加DuplicateKeyException捕获才是真正可靠的幂等。3.4 支付回调异步通知的签名校验与重复处理支付回调是订单系统里最容易出问题的环节。支付宝、微信的异步通知会重试多次而且回调接口直接暴露在外网必须做两件事验签、幂等。第一版处理支付回调时我的步骤是验证回调签名防止伪造请求根据外部交易号查出业务订单号查询订单当前状态如果是待支付则更新为已支付否则直接返回成功更新支付时间和支付流水号返回“success”给支付网关。第 3 步的判断很关键支付网关的重试通知到达时订单可能已经变成已支付了。此时不能报错反而要返回成功否则网关会一直重试。这个判断用状态机的PENDING_PAYMENT - PAID来完成。状态已经变成 PAID 就说明之前已经处理过回调直接 return true 即可。4. 版本 1 的典型“坑”与排查实录4.1 事务没生效自调用与 private 方法开发中最常见的“事务失效”场景是在同一个类里一个方法调用另一个带Transactional的方法。比如createOrder里调了本类的private deductStock()这个deductStock上加不加注解都一样——Spring 事务基于 AOP 代理被this直接调用时走的是原始对象而不是代理对象事务拦截器根本不会触发。正确做法是把需要事务管理的方法拆到单独的 Service Bean 里让 Spring 容器注入代理对象后调用。排查思路看日志里有没有TransactionInterceptor的Getting transaction for输出或者直接搜org.springframework.transaction的 debug 日志。如果没有任何事务日志那不是没有事务而是方法根本没走代理。4.2 订单状态乱跳update 忘加 status 条件版本 1 最容易出现的线上事故就是订单状态被“改没了”。比如管理员后台有个退款功能直接UPDATE t_order SET status 4 WHERE order_no ?如果此时用户刚好在支付后提交的更新就会把已支付状态覆盖成已取消。我处理这类问题的办法不是单纯靠代码层面人工小心而是把状态更新的入口全部收敛到状态机。任何上层调用都不能直接执行“更新订单状态为 X”的 SQL必须经过OrderStatusService.changeStatus(orderNo, from, to)。数据库层面再加上状态条件双重保险。4.3 重复支付回调与库存超卖的联合作案有一种很隐蔽的并发场景用户下单后连续收到两次支付回调业务层没有幂等处理第一次回调把订单变成已支付第二次回调又扣了一次库存。表面看库存数量没错但实际多扣了。排查办法是找“支付流水表”。第一版就应该在订单支付环节记录支付流水每个支付回调对应一条t_pay_record表上建out_trade_no唯一索引。同一个交易号只允许插入一次第二次插入直接撞唯一索引业务层捕获DuplicateKeyException后返回成功不重复处理业务。4.4 慢查询排查订单列表为什么越查越慢订单表数据量上来之后最容易出现的慢查询是SELECT * FROM t_order WHERE user_id ? ORDER BY id DESC LIMIT 10。这个语句本身有idx_user_id和主键索引问题不大但如果混入了AND status ?条件索引就要重新分析了。第一版订单列表查询我只允许两个入口按用户分页查、按订单号精确查。不要搞“用户 状态 时间范围 商品关键词”的多条件组合搜索。真到了要运营后台做多维筛选的时候不会直接查订单主表而是开 ES 或 ClickHouse 同步数据这是后话版本 1 用不上。4.5 版本 1 的问题速查表症状可能原因排查手段解决方案同一订单下了两笔缺少幂等控制查看t_idempotent表是否有重复 key幂等表 唯一索引库存变负数扣库存 SQL 没有条件判断查看 SQL 是否缺少available_stock quantity条件更新 事务回滚状态从已完成变成已取消状态更新未校验前置状态查看审计日志中状态变更记录状态机收敛 UPDATE 带 status 条件事务没回滚库存自调用导致事务失效检查调用链是否走this.xxx()拆分发方法到独立 Bean支付回调重复扣款未做回调幂等查看支付流水表是否有重复记录支付流水唯一索引5. 版本 1 之后OrderService 的演进方向版本 1 的核心是把订单主流程跑通。后面的演进我会按这样的优先级做第一订单超时未支付自动关闭。做法很简单下单时把订单号丢进延迟队列或者用定时任务扫t_order里status 0 AND create_time NOW() - 30min的记录遍历调用取消接口。注意取消时也要走状态机而且要先判断库存是否要回滚。第二拆出独立的OrderReadService或仓储层。版本 1 的查询逻辑都写在 OrderService 里没问题但订单列表接口越来越复杂时读模型和写模型分开会更从容。第三订单事件发布。下单成功、支付成功、发货、完成这些关键节点都应该发事件。不只是为了通知下游系统更是为了后续做数据同步、风控、营销。第一版用afterCommit()发 MQ 消息这个设计留对了。第四分库分表。单表订单量超过千万级或写入达到瓶颈后按user_id分库是主流方向。分库分表不是版本 1 该考虑的问题但你从第一版就要保证查询都带user_id、订单号全局唯一、不依赖数据库自增 ID 做分布式唯一标识。做到这些后续迁移的代价会小很多。6. 写在最后的一点个人心得OrderService 这个类我前前后后写过不下十遍每次写都有新体会。版本 1 最重要的是克制不要把未来三年的需求都设计进去但要为未来留好后路——状态机、幂等、事务边界、订单号生成这四个点是我认为无论如何都不能妥协的底线。如果你正在设计你们系统的第一个订单服务我的建议是先画一张状态流转图把合法路径全部列出来然后动手写代码。等你发现某个非法状态流转能走通或者某个重复请求产生了脏数据当你真的踩到这些坑才会理解今天说的这些约束和设计。订单系统的优雅不在于用了多复杂的中间件而在于每一个入口都有语义、每一次状态变更都有依据、每一条数据都有迹可循。