Go-kratos 框架商城微服务实战之订单关闭与库存释放 (十六)

写到这里,最容易把最后一篇写成一道 SQL 题。

找出超过 15 分钟的订单,把状态改成已取消,再把库存里的 locked 减回去。看起来事情就结束了。系统跑起来以后,支付回调可能在同一秒到达,取消接口可能被用户连续点两次,RabbitMQ 还可能把同一条消息再投一遍。

我觉得收官篇应该把这些不整齐的时刻讲清楚。商城能不能正常运行,很多时候就看用户没有付款、付款来晚了、消息重复了以后,系统能不能把现场收拾干净。

如果你介意下面的内容由 AI 生成,现在可以直接退出了。

这篇文章仍然基于当前项目的真实代码。AI 帮我重新理解旧项目,补全缺失的内容,再把实现过程整理出来。到这里,购物车、订单、库存和本地模拟支付已经连在一起,这一篇把支付超时、订单关闭和库存释放补上,也把整个系列收住。

一、待支付只是一个临时状态

第 15 篇里,支付成功以后,订单会从“待支付”变成“已支付”,库存服务随后确认扣减。

用户没有付款时,订单不能一直停在待支付。只要它继续占着库存,其他用户就可能永远买不到这件商品。

当前订单状态里,和这一篇关系最大的有四个。

数值 状态 这一篇里的作用
0 库存处理中 库存还没有预占完成
1 待支付 库存已经预占,等待用户付款
2 已支付 支付成功,库存等待确认扣减
5 已取消 订单关闭,库存可以释放

用户下单以后,订单先是库存处理中。库存预占成功,才会进入待支付。支付成功进入已支付。支付超时或用户主动取消,待支付订单进入已取消。

这里有一个边界需要先说清楚。库存处理中也可能因为库存预占失败变成已取消,但这一类订单通常没有成功的库存预占记录,后面不需要再释放库存。

需要释放的是已经处于待支付状态的订单。

二、订单怎么知道自己该关闭了

订单服务里有一个定时扫描循环。

func (oc *OrderUsecase) timeoutLoop() {
    ticker := time.NewTicker(30 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        if err := oc.CancelExpiredOrders(context.Background()); err != nil {
            oc.log.Errorf(
                "cancel expired orders error: %v",
                err,
            )
        }
    }
}

它每 30 秒扫描一次超时订单。扫描方法会查询创建时间早于 15 分钟、状态仍然是待支付的订单。

func (oc *OrderUsecase) CancelExpiredOrders(
    ctx context.Context,
) error {
    orders, err := oc.repo.ListPendingTimeout(ctx, 15)
    if err != nil {
        return err
    }

    for _, od := range orders {
        if err := oc.CancelOrder(
            ctx,
            od.User,
            od.OrderSn,
        ); err != nil {
            oc.log.Errorf(
                "cancel expired order failed: %s err=%v",
                od.OrderSn,
                err,
            )
        }
    }
    return nil
}

数据层的查询条件比较直接。

cutoff := time.Now().
    Add(-time.Duration(minutes) * time.Minute)

if err := o.data.db.
    WithContext(ctx).
    Where(
        "order_status = ? AND created_at < ?",
        domain.OrderStatusPendingPayment,
        cutoff,
    ).
    Find(&list).Error; err != nil {
    return nil, err
}

这里的 15 分钟是当前项目里的演示配置,扫描周期是 30 秒。一个订单实际可能在 15 分 0 秒到 15 分 30 秒之间被扫描到,这不是精确到秒的定时器。

我倾向于把订单超时理解成一个允许有一点延迟的后台动作。它的任务是最终关闭订单、释放库存,没必要为了追求某个精确秒数把任务写得特别复杂。

三、手动取消和自动关闭应该走同一条路

用户主动点击取消,后台任务发现超时,这两个入口最后都应该走同一个取消方法。

订单服务的 gRPC 接口里有一个取消订单方法。

rpc CancelOrder(
    CancelOrderRequest
) returns (CheckResponse);

message CancelOrderRequest {
  int64 userId = 1;
  string orderSn = 2;
}

业务层的取消逻辑会先读取订单商品。后面发布的取消事件需要带上 SKU 和数量,库存服务只知道订单号还不够。

func (oc *OrderUsecase) CancelOrder(
    ctx context.Context,
    userId int64,
    orderSn string,
) error {
    items, err := oc.repo.ListItemsByOrderSn(ctx, orderSn)
    if err != nil {
        return err
    }
    if len(items) == 0 {
        return kerrors.New(
            400,
            "ORDER_NOT_FOUND",
            "订单不存在",
        )
    }

    changed, err := oc.repo.UpdateStatusIf(
        ctx,
        orderSn,
        domain.OrderStatusInventoryPending,
        domain.OrderStatusCancelled,
    )
    if err != nil &&
        kerrors.FromError(err).Reason == "ORDER_STATUS_INVALID" {
        changed, err = oc.repo.UpdateStatusIf(
            ctx,
            orderSn,
            domain.OrderStatusPendingPayment,
            domain.OrderStatusCancelled,
        )
    }
    if err != nil {
        return err
    }
    if !changed {
        return nil
    }

    eventID := generateEventID()
    outbox := &domain.OutboxEvent{
        EventID:   eventID,
        EventType: "order.cancelled",
        OrderSn:   orderSn,
        Payload: buildOrderEventPayload(
            eventID,
            "order.cancelled",
            orderSn,
            userId,
            items,
        ),
    }
    return oc.repo.CreateOutbox(ctx, outbox)
}

这段代码先尝试把库存处理中改成已取消,再尝试把待支付改成已取消。这样做是为了兼容两种情况。

  • 库存还没有处理完,用户已经取消订单;
  • 库存处理成功,订单已经进入待支付,后来发生超时或用户主动取消。

取消操作也有幂等处理。如果订单已经是已取消,UpdateStatusIf 不会再次修改状态,业务层直接返回成功。用户连续点两次取消,后台任务又扫到一次,都不会重复生成取消事件。

这里的状态判断不能只写成查询再更新。

SELECT order_status FROM orders WHERE order_sn = '...';
UPDATE orders SET order_status = 5 WHERE order_sn = '...';

两个请求可能同时查到待支付,最后都去发取消消息。当前实现把原状态放进更新条件里,数据库只允许其中一次从待支付改成已取消。

四、取消订单以后要发一条事件

订单服务不能调用库存服务的数据库,也不应该在取消接口里同步等待库存释放完成。

订单状态变更以后,订单服务会写入 order.cancelled Outbox 事件。事件里带着订单号和商品快照。

{
  "event_id": "evt-1720000000000-123456",
  "event_type": "order.cancelled",
  "order_sn": "20260816000001",
  "user_id": 1,
  "payload": {
    "skus": [
      {
        "sku_id": 1,
        "num": 1
      }
    ]
  }
}

订单服务的 Relay 再把这条事件发送到 order.exchange。库存服务监听自己的队列,收到以后才处理释放。

订单服务
  ↓ order.cancelled
RabbitMQ
  ↓
库存服务
  ↓ Release
库存恢复可用

取消接口返回成功时,代表订单已经成功进入已取消,取消事件也已经写进订单服务的 Outbox。库存释放属于后面的异步动作,可能稍后完成。

当前代码和第 15 篇里提到的支付成功路径一样,订单状态更新和 Outbox 写入还是两次独立的数据库操作。它们之间存在进程退出的时间窗口。生产实现需要把这两次写入放进同一个事务,避免订单已经取消却没有留下 order.cancelled 事件。

当前实现还有一个容易被忽略的后果。如果订单状态已经改成已取消,但 CreateOutbox 失败了,下一次取消请求会因为订单已经是已取消而直接返回,不能自动补写取消事件。

我把这个问题留在收官篇里,是因为它很容易被接口返回值掩盖。接口返回成功,不能推导出所有下游服务都已经完成。至少要把事件是否落库、是否发送、是否消费这几件事分开看。

五、库存释放具体做了什么

库存服务在第 13 篇里实现了 TryLock、ConfirmDeduct 和 Release,取消订单走 Release。

if evt.EventType == "order.cancelled" {
    routingKey = "inventory.released"
    opErr = s.uc.Release(
        ctx,
        evt.OrderSn,
        items,
    )
    if opErr != nil {
        routingKey = "inventory.release.failed"
        reason = opErr.Error()
        s.log.Errorf(
            "release failed: order=%s err=%v",
            evt.OrderSn,
            opErr,
        )
    }
}

预占库存时,库存总量没有减少,只增加了 locked 数量。

库存总量 = 10
已锁定 = 2
可用库存 = 8

释放时只减少 locked,库存总量保持不变。

库存总量 = 10
已锁定 = 0
可用库存 = 10

数据层会先查找这笔订单、这个 SKU 对应的待释放记录。

var lock InventoryLock
if err := tx.
    Where(
        "order_sn = ? AND sku_id = ? AND status = 1",
        orderSn,
        item.SkuID,
    ).
    First(&lock).Error; err != nil {
    if errors.Is(err, gorm.ErrRecordNotFound) {
        continue
    }
    return err
}

找到预占记录以后,库存表的 locked 减少,预占记录状态变成 3,库存流水增加一条 release 记录。

if err := tx.Model(&Inventory{}).
    Where("sku_id = ?", item.SkuID).
    Updates(map[string]interface{}{
        "locked": gorm.Expr(
            "locked - ?",
            item.Num,
        ),
        "version": gorm.Expr(
            "version + 1",
        ),
        "updated_at": time.Now(),
    }).Error; err != nil {
    return err
}

if err := tx.Model(&InventoryLock{}).
    Where("id = ?", lock.ID).
    Update("status", 3).Error; err != nil {
    return err
}

if err := tx.Create(&InventoryFlow{
    OrderSn: orderSn,
    SkuID: item.SkuID,
    Change: int64(item.Num),
    Type: "release",
}).Error; err != nil {
    return err
}

这几次写入放在库存服务自己的事务里。中途某一步失败,库存数量、预占记录和流水不会只完成一半。

释放完成以后,库存服务会尝试清理对应 SKU 的 Redis 缓存。缓存删除成功后,下次查询会从数据库重新读取最新数量。

六、重复释放不能把库存加多

取消事件可能重复到达。

第一次消费时,预占记录从状态 1 变成状态 3,locked 减少。第二次消费时,查询条件里的 status = 1 找不到这条记录,代码直接跳过。

因此第二次释放不会再次减少 locked。

库存服务还会通过消费事件表记录已经处理过的 event_id。消息消费的大致顺序是这样。

收到 order.cancelled
  ↓
检查 event_id 是否已经消费
  ↓
没有消费过,执行 Release
  ↓
发布 inventory.released
  ↓
记录 event_id
  ↓
ACK 消息

按消费者的设计,业务失败应该被视为终态并记录,数据库或网络错误应该交给 RabbitMQ 重试。业务上的“预占记录不存在”属于可以判断的结果,重复释放可以安全返回成功。

不过,当前代码这里还有一个需要留意的细节。它用 kerrors.FromError(opErr) != nil 判断是不是业务错误,但 Kratos 的 FromError 对普通数据库错误也会返回一个未知错误。因此,某些数据库错误在结果发布成功以后,也可能被标记成已消费,后续不会再重试。这个判断需要改成显式区分业务错误和基础设施错误,才能和注释里的设计保持一致。

这两层保护解决的是不同问题。

  • inventory_locks.status 防止同一笔库存锁被释放两次;
  • consumed_event 防止同一条消息重复进入完整业务处理;
  • RabbitMQ 的 ACK 和重试负责处理 handler 返回的错误,前提是消费者先正确区分业务失败和基础设施失败。

幂等不是加一个 if 就结束了。要看状态表、事件表和消息确认放在一起以后,重复请求会不会真的改变数据。

七、支付回调和超时任务撞在一起

这里我想把这场竞争单独拿出来说。

订单还剩几秒就超时,用户刚好完成支付。支付回调和超时扫描可能同时尝试修改同一条订单记录。

支付成功要执行的 SQL 如下。

UPDATE orders
SET order_status = 2
WHERE order_sn = '...'
  AND order_status = 1;

订单取消要执行的 SQL 如下。

UPDATE orders
SET order_status = 5
WHERE order_sn = '...'
  AND order_status = 1;

谁先更新成功,谁就完成状态转换。

如果支付回调先成功,订单进入已支付。超时任务随后尝试取消时,会发现订单已经不是待支付,取消失败,也不会发布库存释放事件。如果 order.paid 事件正常写入 Outbox 并被库存服务消费,库存就沿着这条事件确认扣减。

如果超时任务先成功,订单进入已取消。支付回调随后尝试把订单改成已支付,会收到订单状态不允许支付。支付服务可能已经把支付单改成已支付,这就留下了一个真实业务问题,钱已经付了,订单却关闭了。

生产系统需要为这种情况准备退款或人工补偿。当前项目没有接真实支付渠道,所以这里只把订单状态竞争和错误路径跑通,不假装已经完成退款。

用户点击取消和后台超时扫描的竞争关系相同。两个请求都从待支付改成已取消,只有一个能成功,另一个会得到幂等结果或者状态不允许。

状态机的价值就在这里。它没有消灭并发,只是让并发请求只能沿着允许的方向改变状态。

八、库存处理中取消的边界

当前项目允许用户取消库存处理中的订单。

如果库存预占已经完成,取消事件到达库存服务以后可以正常释放。如果库存还没有开始预占,Release 找不到待释放记录,会直接返回成功。这样做可以避免没有锁定记录时把取消事件当成异常。

不过,事件顺序仍然值得留意。

order.created 还没处理
  ↓
order.cancelled 先到
  ↓
Release 找不到锁,返回成功
  ↓
迟到的 order.created 再到

如果迟到的 order.created 继续执行 TryLock,已取消订单可能重新锁住库存。当前项目的 Outbox Relay 按记录顺序发送事件,正常情况下创建事件会先于取消事件到达。生产系统还需要在库存侧记录订单已经取消的状态,或者给同一订单的事件增加顺序控制,避免消息乱序以后重新预占库存。

这类问题很难靠本地连续点几次按钮发现。它通常要靠故意打乱消息顺序、暂停消费者、重复投递和恢复服务来测试。

收官篇把它写出来,是因为“超时关闭并释放库存”这句话看起来很短,放进分布式服务以后,后面还有消息顺序和状态竞争。

九、本地验证取消和释放

可以先创建一笔订单,让库存预占成功,订单进入待支付。然后手动调用取消接口。

grpcurl -plaintext \
  -import-path api \
  -proto order/v1/order.proto \
  -d '{
    "userId": 1,
    "orderSn": "20260816000001"
  }' \
  127.0.0.1:50054 \
  order.v1.Order/CancelOrder

查询订单以后,状态应该变成“已取消”。

库存服务的查询接口在 50055 端口。

grpcurl -plaintext \
  -import-path api \
  -proto inventory/v1/inventory.proto \
  -d '{
    "skuIds": [1]
  }' \
  127.0.0.1:50055 \
  inventory.v1.Inventory/QueryStock

观察 locked 数量。取消前它应该包含这笔订单预占的数量,取消事件消费完成以后,locked 会恢复。

也可以重复调用取消接口。第二次调用应该返回成功,库存不会再次变化。

运行订单服务测试。

cd service/order
go test -race ./internal/biz ./internal/service

库存服务的数据层测试需要 PostgreSQL 和 Redis。

cd service/inventory
go test ./...

收官篇建议至少验证这些情况。

  • 待支付订单手动取消;
  • 待支付订单超过 15 分钟自动取消;
  • 重复调用取消接口;
  • 重复投递 order.cancelled;
  • 库存预占记录不存在时执行释放;
  • 支付回调和订单超时同时到达;
  • order.cancelled 发布失败以后重新投递;
  • Redis 缓存清理以后能查到数据库里的新库存。

测试时最好给每一笔订单使用不同的订单号。库存数据有状态,沿用旧订单号会让结果看起来很奇怪,最后查到的数字未必是这一次操作造成的。

十、这个系列到这里就结束了

回头看这个商城项目,最开始的购物车很简单。用户把商品放进去,数量改一改,再把它们列出来。

往后一步,事情开始变得具体。

商品服务提供 SKU 和价格,购物车保存用户当前的选择,订单服务保存下单时的商品和地址快照,库存服务管理预占、扣减和释放,支付服务处理支付单和回调。RabbitMQ 把这些服务之间的状态变化传递下去。

完整的交易主线现在是这样。

购物车
  ↓
创建订单
  ↓
保存商品和地址快照
  ↓
库存预占
  ↓
订单进入待支付
  ↓
创建支付单
  ↓
支付回调
  ↓
订单变成已支付
  ↓
库存确认扣减

没有付款的订单走另一条路。

待支付
  ↓
超时或主动取消
  ↓
订单变成已取消
  ↓
库存释放

我最开始补这个项目时,注意力很容易放在支付成功这条路上。成功路径跑起来以后,页面上能看到订单已支付,感觉事情已经完成。后来把超时、取消和重复消息放在一起看,才发现用户不付款时系统怎样收场,同样是商城必须回答的问题。

这套代码还谈不上生产级完整。支付 Outbox、订单状态和 Outbox 的原子写入、消息乱序、真实退款,这些地方都还有继续打磨的空间。它们属于工程边界,不影响这 16 篇文章把商城最核心的一条交易流程走通。

这个系列就写到这里。感谢你看到最后。
这里特别感谢一下一直支持观看此系列的同学,更感谢你们的打赏、点赞、分享

感谢您的耐心阅读,动动手指点个赞吧。

本作品采用《CC 协议》,转载必须注明作者和本文链接
微信搜索:上帝喜爱笨人
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!