Go-kratos 框架商城微服务实战之订单服务 (十四)
大家好,上一篇文章把库存从商品服务里拆了出来。这一篇继续往下走,开始处理真正的下单流程。
如果你介意下面的内容由 AI 生成,现在可以直接退出了。
这篇文章里的商城业务思路和处理方向仍然来自我。AI 帮我重新读项目,补充代码,整理实现过程,再把它写成文章。你可以把它看成一次 AI 参与完成的项目续写,重点还是放在订单业务和微服务之间的协作上。
一、购物车里的商品不能直接变成订单
用户在购物车里放了一件商品,点击提交订单以后,订单服务需要做的事情远比把几行数据复制到订单表复杂。
购物车保存的是用户当时想买什么。商品名称可能改过,商品可能下架,促销价格可能结束,购物车里记录的价格也可能已经过期。库存服务还要在订单创建以后判断当前是否有足够的库存。
因此,创建订单至少要做这些确认。
- 购物车条目属于当前用户;
- 提交的购物车 ID 和 SKU ID 能够对应上;
- SKU 仍然存在并且处于销售状态;
- 购物车保存的价格没有发生变化;
- 订单使用的金额来自商品服务当前返回的价格;
- 订单商品和收货地址需要保存一份当时的内容;
- 库存预占成功以后,订单才能进入待支付状态。
这里有一个容易混淆的地方。订单服务会把购物车里的商品转换成订单商品快照,但它不会把购物车当成订单的长期数据源。订单创建完成以后,订单详情应该读取订单自己的商品记录,不能每次展示订单时再去商品服务查询当前名称和价格。
二、本篇要串起来的流程
当前项目里,订单服务使用 50054 端口,数据库是独立的 shop_order。它会通过 Consul 发现用户、商品和购物车服务,再通过 RabbitMQ 发布订单事件。
一次从购物车创建订单的流程可以写成下面这样。
用户提交订单
|
v
订单服务校验购物车条目
|
v
商品服务重新查询 SKU、销售状态和当前价格
|
v
订单服务生成商品快照和地址快照
|
v
本地事务写入订单、订单商品、订单地址和 Outbox 事件
|
v
返回订单号,订单状态为库存处理中
|
v
Outbox Relay 发布 order.created
|
v
库存服务消费事件并尝试预占库存
|
+--> inventory.locked -> 订单变为待支付
|
+--> inventory.lock.failed -> 订单变为已取消
订单创建接口不会一直等着库存服务完成。调用方先拿到订单号,库存预占完成以后,再查询订单详情就能看到新的状态。
这也是消息队列在这里的作用。它承担订单服务和库存服务之间的事件传递,订单服务不需要直接连接库存数据库,也不需要把两个数据库放进同一个事务。
三、订单服务的接口
打开 service/order/api/order/v1/order.proto,创建订单的请求主要包含用户、收货地址和购物车条目,定义如下。
message CartItem {
int64 cartId = 1;
int64 skuId = 2;
int64 skuPrice = 3;
int32 skuNum = 4;
}
message OrderRequest {
int64 id = 1;
int64 userId = 2 [(validate.rules).int64 = {gt:0}];
int64 address = 3 [(validate.rules).int64 = {gt:0}];
repeated CartItem cartItem = 4;
}
skuPrice 可以作为调用方提交时看到的价格,但订单服务不能把它当成最终价格。当前项目的购物车接口里保存的是 goodsPrice,shop BFF 提交订单时主要传递购物车 ID、SKU ID 和购买数量,订单服务会再次从购物车服务读取对应条目。
订单服务内部使用的领域对象更简单。
type CartItem struct {
CartId int64
SkuId int64
SkuPrice int64
SkuNum int32
}
type CreateOrder struct {
UserId int64
AddressId int64
CartItem CartItemList
}
API 层负责把 protobuf 请求转换成领域对象,真正的业务判断放在 internal/biz。这样做以后,gRPC 接口的字段变化不会把所有业务判断都塞进 transport 层。
四、先校验购物车条目
订单服务收到请求后,第一步是调用购物车服务的 ListCart。这里没有直接相信客户端传来的 SKU ID,而是重新读取当前用户的购物车列表,建立购物车 ID 到购物车条目的映射。
cartList, err := oc.cartRPC.ListCart(ctx, &cartV1.ListCartRequest{
UserId: order.UserId,
})
if err != nil {
return nil, err
}
cartMap := make(map[int64]*cartV1.CartInfoReply, len(cartList.Results))
for _, cart := range cartList.Results {
cartMap[cart.Id] = cart
}
for _, item := range order.CartItem {
cart := cartMap[item.CartId]
if cart == nil || cart.SkuId != item.SkuId {
return nil, kerrors.New(400, "CART_ITEM_INVALID", "购物车条目与订单不一致")
}
if item.SkuNum <= 0 {
return nil, kerrors.New(400, "SKU_NUM_INVALID", "商品数量必须大于 0")
}
}
这段校验解决了两个问题。
第一个问题是用户不能拿别人的购物车 ID 来创建订单。购物车服务按照用户 ID 查询,订单服务再检查条目是否存在,条目和 SKU 是否匹配。
第二个问题是客户端提交的数据可能在请求过程中被修改。订单服务保存的数量来自经过校验的请求,商品身份则要和购物车服务返回的记录对上。
如果这里发现购物车条目不存在,订单不会继续查询商品,也不会写入订单表。错误尽早返回,后面就少一组需要补偿的数据。
五、价格必须重新确认
购物车里保存价格是为了展示。它不能成为订单最终金额的依据。
订单服务会批量调用商品服务的 SkuList,拿到当前 SKU 信息。
skuResp, err := oc.goodsRPC.SkuList(ctx, &goodsV1.SkuListRequest{
Id: order.CartItem.GetSkuId(),
})
if err != nil {
return nil, err
}
skuMap := make(map[int64]*goodsV1.SkuInfo, len(skuResp.List))
for _, sku := range skuResp.List {
skuMap[sku.Id] = sku
}
然后逐个检查 SKU 是否存在、是否在售,并决定当前使用的价格。当前项目的规则比较简单,只要促销价格大于 0,就使用促销价格,否则使用普通价格。
sku := skuMap[item.SkuId]
if sku == nil || !sku.OnSale {
return nil, kerrors.New(400, "SKU_NOT_FOUND", "SKU 不存在或已下架")
}
price := sku.Price
if sku.PromotionPrice > 0 {
price = sku.PromotionPrice
}
cart := cartMap[item.CartId]
if cart != nil && cart.GoodsPrice > 0 && cart.GoodsPrice != price {
return nil, kerrors.New(400, "PRICE_CHANGED", "商品价格已发生变化,请刷新购物车后再下单")
}
if item.SkuPrice > 0 && item.SkuPrice != price {
return nil, kerrors.New(400, "PRICE_CHANGED", "商品价格已发生变化,请刷新购物车后再下单")
}
这里有两个价格来源需要区分。
购物车中的 GoodsPrice 是用户加入购物车时保存的价格。请求里的 SkuPrice 是调用方提交时携带的价格。它们都可以帮助订单服务发现价格变化,但最后的金额仍然以商品服务本次返回的 SKU 价格为准。
价格发生变化以后,当前实现直接返回 PRICE_CHANGED,让客户端重新读取购物车和商品详情。也可以选择把新价格返回给客户端,让用户确认以后继续下单,但不能在用户没有感知的情况下悄悄改变支付金额。
金额使用整数保存,示例中的 899900 表示 8999.00 元。订单服务按照单价乘以数量计算商品金额。
amount := price * int64(item.SkuNum)
goodsAmount += amount
金额计算放在服务端,支付服务后面还会再次校验支付金额和订单金额。客户端显示的数字只能帮助用户操作,不能决定最终扣款金额。
六、订单商品要保存快照
完成购物车和价格校验以后,订单服务就可以生成订单商品记录。
orderGoods = append(orderGoods, &domain.OrderGoods{
UserId: order.UserId,
SkuId: sku.Id,
SkuName: sku.SkuName,
SkuPrice: price,
Num: item.SkuNum,
TotalPrice: amount,
})
当前项目的 order_goods 表保存这些字段。
- 订单号;
- 用户 ID;
- SKU ID;
- 下单时的 SKU 名称;
- 下单时的单价;
- 购买数量;
- 这一行商品的总金额。
以后商品改名、调价、下架,历史订单仍然要展示购买当时的名称和价格。订单商品表里的这些字段就是交易发生时留下的快照。
收货地址也需要保存快照。用户可能在下单以后修改默认地址,订单详情和发货记录仍然应该指向本次订单的收货信息。订单服务会调用用户服务获取地址,再写入自己的 order_address 表。
address, err := oc.userRPC.GetAddress(ctx, &userV1.AddressReq{
Id: order.AddressId,
Uid: order.UserId,
})
if err != nil {
return nil, err
}
当前代码保存了收货人、手机号、省、市、区县、详细地址和邮编。它们和订单商品一样,都是订单发生时的业务记录,不应该随着用户资料变化而变化。
七、订单状态先进入库存处理中
之前的订单代码在创建订单时直接写入待支付。这样会带来一个问题,订单还没有经过库存服务确认,支付服务已经可能看到待支付状态。
现在把状态拆开。订单表中的状态含义如下。
| 数值 | 状态 | 说明 |
|---|---|---|
| 0 | 库存处理中 | 订单已经落库,等待库存服务处理 |
| 1 | 待支付 | 库存已经预占成功,等待支付 |
| 2 | 已支付 | 支付成功,等待后续发货 |
| 3 | 已发货 | 商家已经发货 |
| 4 | 已签收 | 用户已经签收 |
| 5 | 已取消 | 订单取消或者库存预占失败 |
| 6 | 交易完成 | 订单完成 |
| 7 | 已退款 | 订单已经退款 |
创建订单时使用库存处理中。
od := &domain.Order{
User: order.UserId,
OrderSn: orderSn,
GoodsAmount: goodsAmount,
OrderAmount: goodsAmount,
ExpressAmount: 0,
OrderStatus: domain.OrderStatusInventoryPending,
}
库存服务发回 inventory.locked 后,订单服务通过条件更新把状态从 0 改成 1。库存不足时,订单服务把状态从 0 改成 5。
状态变化必须带上原状态条件,不能只按照订单号直接更新。
UPDATE orders
SET order_status = $1,
updated_at = NOW()
WHERE order_sn = $2
AND order_status = $3;
这样重复的库存结果不会把已经支付的订单重新改成待支付,也不会把已经取消的订单重新打开。RowsAffected 为 0 时,还要查询当前状态。如果当前状态已经是目标状态,就把它当成重复消息处理;如果当前状态不允许流转,再返回状态错误。
八、订单和 Outbox 要放进同一个事务
订单创建涉及几张表。
orders 订单主表
order_goods 订单商品快照
order_address 收货地址快照
order_event_outbox 待发布的订单事件
如果先写订单,再单独向 RabbitMQ 发布 order.created,进程可能刚写完数据库就崩溃。订单已经存在,库存服务却永远不知道这笔订单,最后就会出现订单可以支付、库存没有预占的情况。
当前项目使用 Outbox 模式。创建订单时,订单主表、订单商品、地址和 Outbox 事件由同一个本地事务写入。
func (o *orderRepo) Create(
ctx context.Context,
order *domain.Order,
address *domain.OrderAddress,
items []*domain.OrderGoods,
outbox *domain.OutboxEvent,
) error {
return o.data.ExecTx(ctx, func(ctx context.Context) error {
if err := o.data.DB(ctx).Create(&Order{
User: order.User,
OrderSn: order.OrderSn,
OrderAmount: order.OrderAmount,
GoodsAmount: order.GoodsAmount,
OrderStatus: order.OrderStatus,
ExpressAmount: order.ExpressAmount,
Post: order.Post,
}).Error; err != nil {
return err
}
// 继续写入地址、订单商品和 outbox
return nil
})
}
文章里的代码省略了中间字段映射,当前仓库的完整实现会在这个事务里依次写入订单、地址、订单商品和 Outbox 记录。
事务成功以后,订单已经有了可以查询的订单号,Outbox 里也有了需要投递的消息。事务失败时,这几部分一起回滚,购物车条目也不会被删除。
订单落库成功以后,代码才会尽力删除已经购买的购物车条目。
for _, item := range order.CartItem {
if _, err := oc.cartRPC.DeleteCart(ctx, &cartV1.DeleteCartRequest{
Id: item.CartId,
UserId: order.UserId,
}); err != nil {
oc.log.Errorf("delete cart after order failed: cart=%d err=%v", item.CartId, err)
}
}
删除购物车不参与订单数据库事务。删除失败时,订单不能回滚,否则用户可能已经拿到订单号,数据库却没有订单。当前实现记录错误,后续可以增加购物车清理任务,或者在查询购物车时根据订单状态做一次校正。
九、Outbox Relay 怎样发布消息
订单服务启动时会启动一个简单的 Relay,每秒扫描一次 order_event_outbox,每次最多取 50 条待发布事件。
func (oc *OrderUsecase) relayLoop() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
if err := oc.RelayOutbox(context.Background()); err != nil {
oc.log.Errorf("outbox relay error: %v", err)
}
}
}
发布流程是下面这几步。
- 查询
status = 0的 Outbox 记录; - 把事件发布到
order.exchange; - 发布成功以后更新
status和published_at; - 发布失败增加重试次数;
- 超过重试上限以后标记为失败,交给告警和人工补偿处理。
事件 ID 要贯穿 Outbox 记录和消息体。当前实现生成一次事件 ID,然后同时写入数据库字段和 JSON。
eventID := generateEventID()
outbox := &domain.OutboxEvent{
EventID: eventID,
EventType: "order.created",
OrderSn: orderSn,
Payload: buildOrderEventPayload(
eventID,
"order.created",
orderSn,
order.UserId,
orderGoods,
),
}
消息内容大致如下。
{
"event_id": "evt-2026081512000001",
"event_type": "order.created",
"order_sn": "20260815120000000001",
"user_id": 1,
"payload": {
"skus": [
{
"sku_id": 1,
"num": 2
}
]
}
}
库存服务只需要 SKU ID 和数量,不需要读取订单商品名称,也不需要连接订单数据库。
Outbox 解决的是订单数据库提交和消息发布之间的丢消息问题。它不会让消息只发送一次。发布成功以后,如果订单服务在标记 published 之前崩溃,Relay 下次仍然可能再次发送同一条消息。因此消费者必须支持重复消息。
十、RabbitMQ 在这条流程里的位置
这里使用 RabbitMQ 的 topic exchange 传递业务事件。RabbitMQ 属于基础设施,订单服务和库存服务各自维护自己的生产者和消费者,由它负责保存和转发消息。
当前流程使用这些交换机和路由键。
order.exchange
order.created
-> inventory service
-> TryLock
-> inventory.exchange
inventory.locked
inventory.lock.failed
payment.exchange
payment.success
-> order service
-> MarkPaid
-> order.exchange
order.paid
-> inventory service
-> ConfirmDeduct
order.exchange
order.cancelled
-> inventory service
-> Release
库存服务消费订单事件时,会根据事件类型决定动作。
order.created -> 预占库存
order.paid -> 确认扣减
order.cancelled -> 释放库存
本篇重点放在 order.created。支付成功和订单取消会复用同一套事件机制,支付服务的本地模拟回调在下一篇再展开。
订单服务新增了一个库存结果消费者,监听 inventory.exchange 上的两类消息。
consumer, err := mq.NewConsumer(
o.mqAddr,
"inventory.exchange",
"q.inventory.result",
[]string{"inventory.locked", "inventory.lock.failed"},
o.handleInventoryResult,
)
库存服务处理完成以后发布的消息很小。
{
"order_sn": "20260815120000000001",
"success": true,
"reason": ""
}
订单服务收到结果以后只做状态流转,不重新计算价格,也不直接修改库存。
func (o *OrderService) handleInventoryResult(ctx context.Context, body []byte) error {
var evt inventoryResultEvent
if err := json.Unmarshal(body, &evt); err != nil {
return err
}
if evt.OrderSn == "" {
return kerrors.New(400, "INVENTORY_RESULT_INVALID", "库存结果缺少订单号")
}
if evt.Success {
return o.oc.MarkInventoryLocked(ctx, evt.OrderSn)
}
return o.oc.MarkInventoryFailed(ctx, evt.OrderSn)
}
库存服务里的 TryLock 已经在上一篇文章中实现。它会先使用 Redis Lua 脚本做快速判断,再在 PostgreSQL 事务里锁定库存行,写入 inventory_locks 和 inventory_flows。库存不足或者数据库操作失败时,库存服务会回滚本次预占。
这条消息流转完成后,订单服务和库存服务各自只修改自己的数据库,两个服务之间通过事件传递结果。
十一、消费者为什么要手动确认消息
RabbitMQ 消费者使用手动 ACK。业务处理成功以后才确认消息,处理失败就进入重试逻辑。
当前项目的消费者封装大致做了这些事情。
- 队列使用持久化声明;
- 消息处理成功以后调用
Ack; - 处理失败时增加
x-retry-count; - 按照第几次重试等待几秒;
- 达到 5 次以后发布到
mall.dlx死信交换机; - 业务代码和基础设施错误都通过返回值交给消费者处理。
不过业务失败和基础设施失败要区分开。
库存不足是一种明确的业务结果。库存服务已经完成了检查,并且没有预占成功,这条消息不需要无限重试,库存服务可以发布 inventory.lock.failed,订单服务把订单改成已取消。
数据库连接失败、Redis 超时或者 RabbitMQ 发布失败,说明这次处理没有得到可靠结果。这类错误应该保留消息,让消费者重试,超过上限以后进入死信队列。
库存服务还维护了 consumed_event 表,按照事件 ID 记录已经完成的消费。订单服务这边的状态更新也带有原状态条件,因此重复收到 inventory.locked 时,订单已经是待支付,更新会被当作幂等操作;重复收到 inventory.lock.failed 时,订单已经是已取消,也不会再次产生状态变化。
这两层保护配合起来,才可以应对消息重复投递。单独依赖 RabbitMQ 的投递行为,无法保证业务代码只执行一次。
十二、几种失败情况分别怎么处理
价格在下单前发生变化
订单服务查询商品时发现购物车价格和当前价格不一致,直接返回 PRICE_CHANGED。订单主表、订单商品和 Outbox 都不会写入,购物车仍然保留,用户刷新以后可以重新提交。
SKU 已经下架
订单服务返回 SKU 不存在或已经下架。这里不应该继续创建订单,否则用户会拿到一个无法履约的待支付订单。
库存不足
订单已经先落库,状态是库存处理中。库存服务收到 order.created 后判断库存不足,发布 inventory.lock.failed,订单服务把状态改成已取消。
这笔订单没有进入待支付,支付服务也不应该为它创建有效支付流程。
RabbitMQ 暂时不可用
订单本地事务仍然可以提交,order_event_outbox 里的事件保持待发布状态。Relay 会继续重试。
如果消息一直发布失败,订单会停留在库存处理中,并且 Outbox 最终会被标记为失败。这个状态不能被当成正常结束,生产环境需要告警,并提供重新投递或人工补偿的入口。当前项目的实现先把重试和死信处理搭起来,补偿后台可以继续扩展。
订单数据库写入失败
订单、地址、订单商品和 Outbox 处于同一个事务里,任何一步失败都会回滚。此时购物车不会被删除,客户端可以根据错误重试。
删除购物车失败
订单已经成功创建,删除购物车只是后置清理。删除失败时保留错误日志,不能因为购物车服务失败就回滚已经提交的订单。后续可以增加定时清理,也可以在再次提交时重新校验订单条目。
十三、为什么不直接同步调用库存服务
一种直观写法是订单服务先调用库存服务的 gRPC 接口,库存预占成功以后,再写订单数据库。
这种写法能让接口立即知道库存结果,但仍然有一个两边提交的问题。库存已经锁定以后,订单数据库写入失败,订单服务就必须再次调用库存释放。如果释放请求也失败,就需要额外的重试和补偿。
另一种顺序是先写订单,再同步调用库存。这样库存失败以后要更新订单状态,订单服务还要处理请求超时、服务重启和重复请求。
当前项目选择订单本地事务加消息队列。它接受一个事实,订单创建和库存预占之间存在短暂的状态差异,所以专门增加了库存处理中这个状态。订单先保存自己的事实,库存服务再根据事件执行自己的操作,结果通过另一条消息回到订单服务。
这并不意味着消息队列可以自动解决一致性问题。状态机、幂等、重试和补偿仍然需要业务代码自己处理。消息队列只负责把事件送到正确的服务。
十四、按 Kratos 的目录查看订单服务
订单服务的核心代码分布在这些目录。
service/order/
├── api/order/v1/order.proto
├── cmd/order/
├── configs/
├── internal/
│ ├── biz/order.go
│ ├── data/order.go
│ ├── domain/order.go
│ ├── pkg/mq/publisher.go
│ ├── pkg/mq/consumer.go
│ ├── service/order.go
│ └── server/grpc.go
├── Makefile
└── go.mod
这几个目录分别负责不同的事情。
internal/service负责把 gRPC 请求转换成领域对象,也负责启动 RabbitMQ 消费者;internal/biz负责购物车校验、商品价格判断、订单快照生成、状态变化和 Outbox Relay;internal/data负责 PostgreSQL 事务、订单表、订单商品表、地址表和 Outbox 表;internal/pkg/mq负责 RabbitMQ 连接、交换机、队列、手动 ACK、重试和死信处理。
这几个层次各自有边界。订单业务不应该把 SQL 写到 gRPC service 里,RabbitMQ 连接细节也不应该散落在订单用例中。
十五、运行和验证
如果是从前面的项目继续运行,可以先启动基础设施并执行迁移。
make infra-up
make migrate-up
然后启动用户、商品、购物车、库存和订单服务。订单服务的配置文件在 service/order/configs/config.yaml,主要依赖如下。
server:
grpc:
addr: 0.0.0.0:50054
data:
database:
driver: postgres
source: postgres://postgres:root@127.0.0.1:5432/shop_order?sslmode=disable&TimeZone=Asia/Shanghai
service:
user:
endpoint: discovery:///shop.user.service
cart:
endpoint: discovery:///shop.cart.service
goods:
endpoint: discovery:///shop.goods.service
mq:
addr: amqp://root:root@127.0.0.1:5672/
本地配置中的账号密码只用于演示,生产环境要放在安全的配置管理系统里。
先运行订单服务自己的测试。
cd service/order
go test -race ./internal/biz ./internal/service
本篇新增的测试覆盖了这些情况。
- 购物车条目和 SKU 不匹配;
- 购买数量小于等于 0;
- SKU 不存在或者已经下架;
- 购物车价格和当前商品价格不一致;
- 订单商品使用当前价格生成快照;
- 订单初始状态为库存处理中;
- Outbox 消息使用和数据库记录一致的事件 ID;
- 库存成功和库存失败对应不同的订单状态。
启动服务以后,可以使用 grpcurl 调用订单服务。
grpcurl -plaintext \
-d '{
"userId": 1,
"address": 1,
"cartItem": [
{
"cartId": 1,
"skuId": 1,
"skuNum": 1
}
]
}' \
127.0.0.1:50054 order.v1.Order/CreateOrder
如果订单创建成功,最开始看到的状态可能是“库存处理中”。接口已经返回成功,库存预占还在异步处理中。稍等片刻,再查询订单详情,库存服务处理成功以后状态会变成“待支付”。
如果商品价格发生变化,创建接口会直接返回 PRICE_CHANGED。如果库存不足,订单会先创建,再很快转为“已取消”。这两个结果的出现时间不同,正好可以用来观察同步校验和异步处理的区别。
结束语
订单服务保存的是用户在某个时刻提交的商品、价格、收货地址和订单状态,购物车继续记录用户当前的选择。
购物车负责记录用户的选择,商品服务负责提供当前商品信息,订单服务负责保存交易快照,库存服务负责处理数量,RabbitMQ 负责传递状态变化。每个服务只处理自己拥有的数据,跨服务的动作通过事件连接起来。
这篇文章先把从购物车到库存预占的流程接上。下一篇再继续处理支付单、支付回调,以及支付成功以后如何确认扣减库存。
这里特别感谢一下一直支持观看此系列的同学,更感谢你们的打赏、点赞、分享

感谢您的耐心阅读,动动手指点个赞吧。
本作品采用《CC 协议》,转载必须注明作者和本文链接
关于 LearnKu
推荐文章: