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 协议》,转载必须注明作者和本文链接
关于 LearnKu
推荐文章: