订单服务版本1设计指南:状态机、幂等与并发控制实战 1. 订单服务为什么值得单独抽一个类从能跑到扛造的差距先说说背景。我之前参与过一个电商项目的起步阶段业务方给的第一个需求很朴素用户能下单、能查订单、能取消订单。听起来就那么回事但等真正动手写代码的时候才发现一个OrderService的版本1设计得好不好直接决定了后面三个月你是天天改bug还是能腾出手做别的事。很多人第一次写订单服务容易陷入一个误区把所有的逻辑都塞进 Controller 里或者干脆在 Service 里堆几百行 if-else。版本1的OrderService之所以值得单独拎出来聊是因为订单这个领域对象几乎牵扯了系统的所有核心能力——库存、支付、用户、商品、物流任何一环变动都要落到订单上。如果不在一开始就把OrderService的职责边界、方法粒度、状态流转理清楚后面每一次加需求都是灾难。我理解的版本1OrderService不是说要一步到位搞微服务、搞消息队列、搞分布式事务而是要在单体应用里先把一个内聚、可测试、状态清晰的服务类写好。这篇内容我会从方法设计、状态机、数据库、幂等与并发这几个维度把自己在版本1里踩过的坑和最终的写法摊开来讲给正在写第一个订单服务的同学一个可以直接参考的骨架。适合谁来读如果你是刚接手订单模块的初级后端、或者正在从零搭一个带交易的小系统这篇内容能帮你避开我当年踩过的坑。如果你已经有几年经验也可以对照看看自己的版本1有没有同样的隐患。2. 版本1 OrderService的方法清单每个方法背后都是一种业务语义2.1 先说清楚版本1必须覆盖哪些业务动作OrderService 的方法不是随便定的它是订单生命周期的映射。我当时的做法是先穷举业务上所有会动订单的场景再归并成方法。版本1阶段订单无外乎这么几个动作创建订单用户从购物车或商品详情发起查询订单单笔查询、列表查询取消订单用户主动取消或超时未支付系统自动取消确认收货用户收到货后确认或者系统超时自动确认处理支付回调支付平台通知我们支付结果基于这些场景OrderService的公开方法我最终收敛成了这五个主方法加两个辅助方法public interface OrderService { // 创建订单入参是下单请求出参是订单号 OrderCreateResult createOrder(OrderCreateRequest request); // 根据订单号查询订单详情含明细、收货信息、支付信息 OrderDetail getOrderDetail(String orderNo); // 分页查询当前用户的订单列表 PageResultOrderSummary queryOrderList(Long userId, OrderQuery query); // 取消订单带上取消原因 OrderCancelResult cancelOrder(Long userId, String orderNo, String cancelReason); // 确认收货 void confirmReceipt(Long userId, String orderNo); // 处理支付回调——这是最容易出问题的入口 void handlePaymentCallback(PaymentCallback callback); // 定时任务用处理超时未支付订单 int closeExpiredOrders(int minutesBeforeExpiry); }这里有一个很多人版本1会踩的坑把updateOrderStatus(Long orderId, Integer status)这种通用更新方法直接暴露出去。表面上看很灵活实际上等于把状态机的约束给拆了——任何调用方都能把订单从已支付改成已发货甚至从已取消改回待支付。我版本1刚上线就因为这个被运营投诉过后台手工改单子的时候把一个已退款订单的状态改乱了。后来我把所有能改状态的操作都收敛成有业务语义的方法updateOrderStatus直接禁止对外暴露。2.2 方法入参出参的设计原则用对象而不是散装参数版本1的下单接口我见过有人写成createOrder(Long userId, Long productId, Integer count, String addressId, String couponId...)五个参数起步后面加一个赠品字段就要改所有调用方。正确的做法是定义请求对象public class OrderCreateRequest { private Long userId; private ListOrderItemRequest items; private Long addressId; private Long couponId; private String buyerRemark; // 幂等键防止用户重复点击导致重复下单 private String clientToken; // getter/setter 省略 } public class OrderItemRequest { private Long productId; private Integer quantity; }出参也一样别返回裸的Order实体。实体是数据库映射里面可能有多余字段比如内部备注、数据库自增id直接返回出去既不安全也不稳定。版本1我就吃过亏订单实体里有deleted逻辑删除字段查询详情接口直接返回了实体结果前端拿到一个莫名其妙的多余字段还被测试当成bug提了。所以凡是出参一律用 VO/DTO 封装。2.3 创建订单的完整流程版本1里最长的那个方法createOrder是 OrderService 的核心也是逻辑最重的地方。我版本1的实现大致分七步每一步都是独立的私有方法方便单测和排查Override public OrderCreateResult createOrder(OrderCreateRequest request) { // 1. 参数校验商品是否存在、数量是否合法 validateRequest(request); // 2. 幂等检查同一个 clientToken 不能重复下单 checkIdempotent(request.getUserId(), request.getClientToken()); // 3. 锁定商品并校验库存后面单独讲为什么这里要锁定 ListStockLockResult stockResults stockService.lockStock( request.getUserId(), request.getItems()); // 4. 计算价格商品金额 - 优惠 运费 PriceDetail price calculatePrice(request, stockResults); // 5. 生成订单号并持久化订单主单和明细 String orderNo generateOrderNo(); Order order buildOrder(request, price, orderNo); orderMapper.insert(order); orderItemMapper.batchInsert(order.getId(), request.getItems()); // 6. 清除购物车中的已下单商品 cartService.removeItems(request.getUserId(), request.getItems()); // 7. 发布订单创建事件版本1可以先是同步调用后面改 MQ eventPublisher.publishOrderCreated(order); return buildResult(orderNo, price); }这里每一步拆出来是因为订单创建过程中任何一步失败都需要不同的回滚策略。比如第3步锁库存失败不能把第5步已经生成的订单留下第6步清购物车失败订单其实已经成功了不能因为购物车清理失败就把订单整个回滚掉。所以版本1里我把事务边界放在了第4步到第5步之间锁库存用独立的补偿机制来释放购物车清理失败只记录日志不影响主流程。这个事务边界的划分是后面单独一章要讲的坑。3. 订单状态机设计别把 status 字段当摆设3.1 版本1最简单的状态定义订单状态是整个 OrderService 的交通规则。我版本1定了一套七状态模型基本可以覆盖大部分电商场景状态码状态名称含义可到达的下一个状态0待支付订单已创建等待用户付款已支付、已取消、已关闭10已支付用户付款成功待发货已发货、已退款售后20已发货商家已发货物流运输中已收货、退款中30已收货用户确认收货或超时自动确认已完成、退款中40已完成交易闭环资金已结算无50已取消用户取消或超时取消无60已关闭异常关闭不可再流转无这里我刻意把状态码用 10、20、30 这样的间隔而不是 1、2、3是因为后续很可能要在中间插入新状态。比如已发货和已收货之间想加一个已签收用 1、2、3 就插不进去了但用间隔 10 可以轻松插入 25。这个细节是我从前辈那里学来的版本1就预留好了。3.2 状态流转用 if-else 还是状态机版本1的代码量不大很多人用 if-else 写状态校验比如取消订单时这样写if (order.getStatus() ! STATUS_PENDING_PAYMENT) { throw new BizException(当前状态不可取消); }这个写法单点看没问题但如果每个操作都写一遍状态校验逻辑就散落在各个方法里而且随着方法增多你很难一眼看出订单到底能从哪个状态到哪个状态。我版本1到后期已经出现这种bug支付回调处理时只判断了待支付可以变已支付但退款成功回调时没有限制状态结果已关闭的订单被退款回调改成了已退款数据直接对不上。我的建议是版本1就用一个简单的状态流转校验工具类不需要引入复杂的状态机框架public class OrderStatusGuard { private static final MapInteger, SetInteger LEGAL_TRANSITIONS new HashMap(); static { LEGAL_TRANSITIONS.put(STATUS_PENDING_PAYMENT, Set.of(STATUS_PAID, STATUS_CANCELLED, STATUS_CLOSED)); LEGAL_TRANSITIONS.put(STATUS_PAID, Set.of(STATUS_SHIPPED, STATUS_REFUNDING)); LEGAL_TRANSITIONS.put(STATUS_SHIPPED, Set.of(STATUS_RECEIVED, STATUS_REFUNDING)); LEGAL_TRANSITIONS.put(STATUS_RECEIVED, Set.of(STATUS_COMPLETED, STATUS_REFUNDING)); // 终态已完成/已取消/已关闭 没有出口 } public static void checkTransition(Integer currentStatus, Integer targetStatus) { SetInteger allowed LEGAL_TRANSITIONS.get(currentStatus); if (allowed null || !allowed.contains(targetStatus)) { throw new BizException( 非法的订单状态流转: currentStatus - targetStatus); } } }所有能改状态的方法统一过这道校验。这个类虽然简单但它是整个订单状态安全的底线。后面加退款中已退款这些状态时只需要改这一张表而不是去每个方法里翻 if-else。3.3 状态变更记录谁在什么时候把订单变成了什么状态版本1最容易忽略的是一张order_status_log表。刚开始我觉得有必要吗查订单表就能看到当前状态啊直到有一次运营问这个单子为什么会变成已取消是用户取消的还是系统超时取消的我才发现订单表里只有结果没有过程根本没法回答这个问题。所以版本1就应该加一张极简的状态日志表CREATE TABLE order_status_log ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, from_status tinyint NOT NULL COMMENT 变更前状态, to_status tinyint NOT NULL COMMENT 变更后状态, operator_type tinyint NOT NULL COMMENT 操作方类型1用户 2系统 3运营, operator_id varchar(64) DEFAULT NULL COMMENT 操作人id或系统任务标识, remark varchar(255) DEFAULT NULL COMMENT 备注如取消原因, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;每次状态变更和主订单的更新放在同一个事务里写一行日志。这个动作的成本非常低但排查问题时的收益极高。我在版本1上线后查过的所有订单状态不对的工单基本都是靠这张日志表定位的。4. 并发与幂等版本1必须提前想清楚的三个致命场景4.1 场景一库存超卖订单服务和库存服务是密不可分的。版本1最经典的超卖场景是两个用户同时下单买同一个商品而库存只剩一件。我做版本1时的第一版库存扣减逻辑是这样的// 错误写法先查库存再扣减 Stock stock stockMapper.selectByProductId(productId); if (stock.getAvailable() quantity) { stock.setAvailable(stock.getAvailable() - quantity); stockMapper.updateById(stock); }这个写法在并发下必然超卖——两个请求同时查到库存还有1件都认为可以买都执行了扣减最后库存变成了 -1。解决办法很简单用数据库层面的条件更新让扣减操作本身具备原子性// 正确写法把库存判断放进 update 的条件里 int affected stockMapper.decrementAvailable(productId, quantity, userId); if (affected 0) { throw new BizException(库存不足); }对应的 SQL 是UPDATE stock SET available available - #{quantity} WHERE product_id #{productId} AND available #{quantity}这样数据库的锁机制会保证只有扣减成功的那一方才会拿到影响行数 1另一个请求影响行数是 0 然后直接报库存不足。靠先查后写做扣减在订单这种高并发场景下就是给自己埋雷。4.2 场景二用户连续点击导致重复下单用户在下单页快速点了两下提交订单如果后端没有防护就会生成两个相同的订单。这个问题的本质是创建订单接口缺少幂等性。我的方案是引入一个clientToken客户端令牌前端在下单页渲染时向后端申请一个唯一 token提交订单时带上后端在order_idempotent表里记录这个 token 的处理状态同一个 token 第一次请求正常走流程第二次直接返回第一次的订单结果。public OrderCreateResult createOrder(OrderCreateRequest request) { // 幂等键唯一索引兜底 IdempotentRecord record idempotentMapper.selectByClientToken(request.getClientToken()); if (record ! null) { // 已经处理过直接返回已生成的订单号 return OrderCreateResult.success(record.getOrderNo(), true); } // 插入幂等记录利用唯一索引防止并发穿透 try { idempotentMapper.insert( new IdempotentRecord(request.getClientToken(), request.getUserId())); } catch (DuplicateKeyException e) { // 并发下另一个请求先插入了以它为准 IdempotentRecord existing idempotentMapper.selectByClientToken(request.getClientToken()); return OrderCreateResult.success(existing.getOrderNo(), true); } // 继续走正常的建单流程 // ... }这里order_idempotent表的client_token字段必须建唯一索引这是并发穿透时的最后一道防线。版本1如果嫌多一张表麻烦也可以用 Redis SETNX 来做但要注意设置合理的过期时间并做好 Redis 和数据库的一致性问题。对于订单这种资金相关的场景我推荐用数据库唯一索引可靠而且天然持久化。4.3 场景三支付回调重复通知支付平台的回调是不保证只通知一次的它可能会因为网络超时、平台重试等原因对同一个支付单发多次通知。如果回调处理逻辑没有幂等性用户支付一次但订单被标记两次已支付或者更严重的是发货流程和财务流程被重复触发。处理回调的通用范式是先查订单状态只处理合法的状态迁移迁移不了的直接算成功返回给支付平台。public void handlePaymentCallback(PaymentCallback callback) { // 1. 根据支付流水号查出我们的支付单和关联订单 PaymentRecord payment paymentMapper.selectByPaymentNo(callback.getPaymentNo()); if (payment null) { throw new BizException(支付单不存在); } // 2. 用状态机守卫做幂等只有待支付才能变成已支付 Order order orderMapper.selectByOrderNo(payment.getOrderNo()); // 3. 关键在同一事务里用乐观锁更新状态 // 如果订单已经不是待支付说明之前已经处理过回调直接返回 int updated orderMapper.updateStatusIfCurrentStatus( order.getOrderNo(), STATUS_PAID, // 目标状态 STATUS_PENDING_PAYMENT // 期望的当前状态 ); if (updated 0) { // 订单已经不是待支付了说明重复回调返回成功即可 log.info(重复支付回调已忽略: orderNo{}, status{}, order.getOrderNo(), order.getStatus()); return; } // 4. 此时才执行支付成功后的业务动作 orderStatusLogMapper.insert(buildStatusLog(order.getOrderNo(), STATUS_PENDING_PAYMENT, STATUS_PAID, callback.getOperatorId())); eventPublisher.publishOrderPaid(order); }这个updateStatusIfCurrentStatus本质是数据库层面的乐观锁把状态判断 状态更新合并成一条带条件的 SQLUPDATE order SET status #{targetStatus}, paid_at NOW() WHERE order_no #{orderNo} AND status #{expectedStatus}只要这条 SQL 的影响行数是 1就说明这次状态变更由我们抢到了其他重复回调进来时 status 已经不是待支付了直接忽略。这是版本1处理回调幂等最干净的方式不需要分布式锁也不需要额外的幂等表。5. 数据库设计与事务边界订单表结构决定了你能走多远5.1 订单主表和明细表的基本结构版本1的订单表设计我建议主表和明细表分开。主表存订单维度的信息明细表存每个商品行的信息。有的系统为了图省事把商品信息直接 JSON 塞进主表的一个字段后续统计、售后、对账全部受限。主表核心字段CREATE TABLE order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号对外暴露, user_id bigint NOT NULL COMMENT 用户id, status tinyint NOT NULL DEFAULT 0 COMMENT 订单状态0待支付 10已支付 20已发货 30已收货 40已完成 50已取消 60已关闭, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, pay_amount decimal(10,2) NOT NULL COMMENT 实付金额, discount_amount decimal(10,2) NOT NULL DEFAULT 0 COMMENT 优惠金额, freight_amount decimal(10,2) NOT NULL DEFAULT 0 COMMENT 运费, address_id bigint NOT NULL COMMENT 收货地址id, address_snapshot varchar(512) NOT NULL COMMENT 收货地址快照下单时的地址文本, client_token varchar(64) NOT NULL COMMENT 幂等键, paid_at datetime DEFAULT NULL COMMENT 支付时间, cancelled_at datetime DEFAULT NULL COMMENT 取消时间, cancel_reason varchar(255) DEFAULT NULL COMMENT 取消原因, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_client_token (client_token), KEY idx_user_id_created_at (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个关键点我解释一下地址快照字段必须存。下单时用户选的收货地址后续可能被修改甚至删除但订单对应的必须是下单那一刻的地址。不存快照的话订单历史数据就失真了。这个是我在版本1被售后同学找过之后才补上的。订单号唯一索引必须建。order_no是业务上唯一定位一条订单的标识所有查询、对账、售后都依赖它。这里不建议用自增 id 直接当作订单号暴露出去——订单号会泄露每天的单量而且太容易猜测别人改一下数字就能遍历你的订单。订单明细表则记录下单时每个商品的快照商品名称、单价、数量、商品图片同样是为了防止商品信息后续变更影响历史订单。5.2 事务边界锁库存、建订单、扣优惠券到底谁和谁一个事务这是版本1最容易出问题的地方。订单创建涉及多个写操作扣减库存、创建订单、扣减优惠券、清理购物车。如果全部放进一个事务任何一个步骤失败都会导致全部回滚。看似安全实际上隐藏着两个大坑坑一如果下单过程中会同步调用外部支付接口而支付接口比较慢事务一直不提交数据库连接被长期占用连接池很快就满了。坑二库存扣减放在和创建订单同一个事务里一旦订单创建失败回滚库存会回滚。但如果订单创建成功、库存扣减的通知已经发出去了比如库存服务监听了 binlog那边已经把库存变化同步给了别的系统这边回滚了两边数据就对不上了。我版本1最终的策略是库存扣减和订单创建放在同一个本地事务里因为它们必须保持强一致外部调用比如支付请求、短信通知、购物车清理全部放在事务外面通过事务提交后执行的方式做。购物车清理这种不重要的操作失败就失败记个日志下次用户登录时重新算就行。Transactional public OrderCreateResult createOrder(...) { // 事务内只做锁库存、算价格、建订单主表和明细表 // 事务外做清购物车、发事件通知 }这个边界不是拍脑袋定的它遵循一条原则事务内只做数据库写操作不做任何外部 IO网络请求、文件写入。因为数据库回滚救不了外部系统的状态反过来外部系统失败也不应该让数据库数据回滚。5.3 乐观锁和悲观锁在订单场景的选型版本1的订单状态更新我上面提到的updateStatusIfCurrentStatus是乐观锁的方式。这里有人会问为什么不直接用SELECT ... FOR UPDATE把订单行锁住再判断状态悲观锁行锁的优点是逻辑写起来更直接先锁住再查询再判断再更新整个过程这个订单的并发操作都会被阻塞。缺点是如果锁住后执行的逻辑有外部 IO锁持有的时间会很长吞吐量直线下降而且锁的范围不好控制容易锁到别的行导致死锁。乐观锁条件更新牺牲了一次更新可能失败的代价换来了更好的并发性能。在订单场景同一个订单的并发操作支付回调、取消、发货本身频率很低用乐观锁完全够用而且每次失败后只需要重新查一下状态、做一次重试或直接拒绝即可。我的建议是订单状态更新单行、低频用乐观锁条件更新库存扣减高频、对账敏感用数据库原子操作UPDATE stock SET available available - X WHERE ...涉及跨表批量操作比如整个购物车结账时再用悲观锁但也要控制临界区范围绝不在锁内做外部调用6. 从版本1到版本2那些我上线之后才醒悟的改造点订单服务写完之后上线第一周可能跑得很平稳但第二周开始问题就来了。我把自己版本1上线后被迫做的几个改造列出来如果你现在还在版本1阶段可以直接把这些经验前置。6.1 订单号生成从数据库序列换成雪花算法版本1我用的是时间戳 随机数 自增拼接的方式生成订单号上线后随着单量上来偶尔出现重复订单号因为随机数碰撞了。后来切换到雪花算法Snowflake ID全局唯一、趋势递增、不需要额外的数据库资源对 MySQL 的索引写入也更友好。需要注意的一点是雪花算法的机器 id 要配置好不然多实例部署时同一毫秒内可能生成重复 id。public String generateOrderNo() { long id snowflake.nextId(); // 订单号做成19位以内前端展示和外部对账都方便 return String.valueOf(id); }6.2 查询接口从单表查询升级为读写分离版本1订单列表查询和下单写操作都在一个库上初期流量小没感觉。等到做秒杀活动瞬时订单写入量上来之后用户查订单列表的接口开始变慢。这是由于 InnoDB 的写操作会占用行锁和 IO拖慢了读操作。后续改造把订单查询路由到从库写操作走主库主从延迟通过订单提交后强制读主库来兜底。这个改造虽然简单但收益非常明显。6.3 订单创建完成后的事件通知从同步改成消息队列版本1的eventPublisher.publishOrderCreated(order)是同步调用也就是说如果下游的积分服务更新失败下单接口会直接报错用户明明已经下单成功了却看到失败页面。这是一个典型的非关键路径上的强耦合问题。订单创建成功这件事本身已经很确定下游关心它的服务应该自己去订阅而不是让下单接口替它们担心。后续改成 MQ 之后下单接口的响应时间从 800ms 降到了 200ms同时下游失败了也不影响主流程。这里有个版本1就要记住的原则订单服务的核心职责是把订单状态管好不是帮别的服务打工。凡是下单成功之后顺便要做的事情都应该从主流程里剥离开。6.4 加一个定时任务来兜底回调丢失支付平台的回调虽然会重试但我们自己的代码不能假设回调一定会到。版本1后期我加了一个定时任务扫描已支付但超过N分钟没有发货的订单更早还有一个兜底扫描待支付但超过30分钟的订单做自动取消。把这个兜底逻辑和状态机的CLOSED状态配合起来订单的终态就能保证最终一致而不是永远挂在待支付上等用户来取消。7. 版本1最容易忽略的非功能性细节日志、异常与可观测性订单服务的 bug 通常都不是跑不起来而是数据悄悄错了。这就要求从版本1开始就把可观测性做好。日志要打全链路的关键信息。每一次状态变更除了落order_status_log表log.info里必须带上订单号、用户id、当前状态、目标状态、操作来源。不要小看这条日志线上排查为什么这个订单不能取消的时候你第一件事就是 grep 这个订单号的完整日志。异常要分清楚业务异常和系统异常。我在 OrderService 里约定库存不足、状态非法、地址不存在这类是业务异常抛BizException对外返回明确的错误码和提示不触发全局异常告警数据库连接失败、外部服务超时这类是系统异常抛SystemException要触发告警并通知值班。这个区分能让你在凌晨三点收到告警的时候第一眼就知道到底要不要爬起来。关键方法要有耗时统计。createOrder和handlePaymentCallback这两个方法一旦耗时异常飙高往往意味着数据库慢查询或者下游服务出问题。版本1用最简单的日志打点就行不用上 APM。我在实际项目里的体会是OrderService的版本1最核心的不是把功能写完而是把状态流转的约束和并发幂等的边界立住。功能后续可以加但底层的安全边界一旦在早期被破坏后面就得用更大的代价去补。如果你正在写自己的第一个订单服务我的建议是先把状态机守卫和条件更新的 SQL 写好把幂等键的表建好再去优化接口的响应时间——这两件事是订单服务这栋楼的地基。