Go-kratos 框架商城微服务实战之库存服务 (十三)
大家好,这一晃,距离这个系列上一篇文章已经四年多了。
2022 年写到购物车服务时,这个商城项目就停在这里。那时候其实还想继续写,只是工作和生活都忙了起来,后面的内容一直没有抽出时间补上。
这几年,AI 编程确实经历了一次爆发。最开始 GPT 出来的时候,大家更多是拿它聊天、问问题。我也会让它解释报错、查一段代码,或者帮忙改一个小函数,但问题问完,后面的代码还得自己接着看、自己改、自己跑。后来,Claude Code 这样的 agent 工具出现了,配合 Claude 模型,AI 开始能读项目目录、找代码调用关系、修改文件、运行测试,再根据结果继续往下做。
说实话,我原本没有想过让 AI 来接这个旧项目。进入 vibe coding 的时代,遇到什么问题直接问 AI,让 AI 帮忙实现就行了,谁还会专门去翻几年前的技术文章呢?即便把这个项目补全了,又有什么意义呢?再加上项目停了四年,里面有当时的设计思路、代码实现、框架版本和 Go 语言版本,Go 都已经有泛型了,遗漏的内容也不少。商城的核心内容又绕不开订单、支付和库存处理。我知道这些事情 AI 都是可以做到的,但要把过去的背景、设计思路和实现细节交代清楚,再让 AI 在这个基础上继续往下写,恐怕没有想象中简单,也需要花时间和精力,还要消耗大量 token,都是钱呀,哈哈哈哈。
转机出现在前段时间。我看到 DeepSeek V4 Flash 正式版更新,随后又看了一些编程评测,感觉它的编程能力已经值得试一试。最主要的是,它便宜,😂。于是我抽出一点时间,开始让 AI 重新理解原项目,先把框架版本和 Go 版本升级。升级完成之后,我在本地进行了测试,项目可以正常启动,也没有引入新的问题。确认升级没问题之后,我才开始让 AI 用 vibe coding 的方式,把遗漏的业务一点点补起来。就这样,这个停了四年的项目又重新往下走了,也有了现在这篇文章。
如果你介意下面的内容由 AI 生成,现在可以直接退出了。
这篇文章里的商城业务思路和处理方向仍然来自我,AI 帮我重新读项目,完成升级、补全和整理,再把这些内容写成文章和代码。你可以把它看成一次 AI 参与完成的项目续写,重点还是放在商城业务本身。
前面的文章里,商品服务创建 SKU 的时候顺手创建了一条库存记录。刚开始这样做很简单,查商品的时候顺便把库存带出来就行了。
但是只要真正开始下单,事情就没有这么简单了。
假设现在某个 SKU 只剩 1 件,两个用户几乎同时下单:
- 用户 A 查询库存,看到还有 1 件;
- 用户 B 查询库存,也看到还有 1 件;
- A 和 B 都提交订单;
- 两个请求都扣减成功。
库存就被卖成了负数,或者商品卖了两份,但仓库只有一份。这就是典型的超卖问题。
另外,库存的使用场景也不止普通下单。比如一个指定价格的秒杀活动,或者一个只允许指定商品参与的活动,都可能需要单独配置一份活动库存。商品服务里的库存字段只适合表达商品本身的库存,活动库存和秒杀库存混在一起后,规则很快就会变得难以维护。独立出库存服务,后面就可以按活动配置商品对应的独立库存,让普通购买、秒杀和其他营销活动各自使用合适的库存规则。
所以本篇会把库存从商品表中的一个普通字段拆成独立的库存服务。先把库存服务本身做正确,后面再让订单服务通过消息队列驱动库存预占、扣减和释放。
需要说明一下:前面旧文章中的数据库示例主要使用 MySQL,而当前项目已经切换成 PostgreSQL。本篇的目录、配置和 SQL 都以当前项目为准。
一、库存服务到底负责什么
商品服务负责的是商品信息:
- 商品名称、分类、品牌;
- SKU 编码、规格、价格、图片;
- 商品是否上架。
库存服务负责的是库存状态:
- 库存总量;
- 已经被订单预占的数量;
- 当前还能卖多少;
- 哪个订单预占了哪些 SKU;
- 库存什么时候被锁定、扣减或释放。
库存服务不应该反过来关心商品名称和商品图片。它只需要知道 SKU 编号和数量。
本项目里库存的计算公式非常简单:
可用库存 = 库存总量 - 已锁定库存
比如库存总量是 50,已经锁定了 8 件,那么当前可用库存就是 42 件。
这里的“锁定”很重要。用户提交订单时,不能直接把库存总量减掉,因为此时用户可能还没有支付。正确的过程应该是:
下单成功 -> 预占库存 -> 等待支付
|
+-> 支付成功:确认扣减
|
+-> 超时或取消:释放库存
预占库存只是先把这部分库存从“可用库存”里拿出来,真正支付成功后才扣减库存总量。
二、创建库存服务
如果你是从前面的项目继续写,项目目录应该已经有多个独立服务。库存服务和用户、商品、购物车服务一样,也是一个独立的 Kratos 应用:
service/inventory/
├── api/inventory/v1/inventory.proto
├── cmd/inventory/
├── configs/
├── internal/
│ ├── biz/
│ ├── conf/
│ ├── data/
│ ├── domain/
│ ├── server/
│ └── service/
├── Makefile
└── go.mod
当前服务使用 50055 端口,服务名注册到 Consul 后是 shop.inventory.service,数据库是单独的 shop_inventory。
如果只是看本篇对应的当前代码,可以直接进入服务目录:
cd service/inventory
配置文件在 service/inventory/configs/config.yaml,开发环境大致如下:
server:
grpc:
addr: 0.0.0.0:50055
timeout: 1s
data:
database:
driver: postgres
source: postgres://postgres:root@127.0.0.1:5432/shop_inventory?sslmode=disable&TimeZone=Asia/Shanghai
redis:
addr: 127.0.0.1:6379
password: root
dial_timeout: 1s
read_timeout: 0.2s
write_timeout: 0.2s
mq:
addr: amqp://root:root@127.0.0.1:5672/
trace:
endpoint: http://127.0.0.1:14268/api/traces
这里的账号密码只是本地开发环境示例,生产环境不要照抄。真实配置可以放在同目录的 config.local.yaml 中,避免把密码直接提交到仓库。
启动基础设施:
make infra-up
make migrate-up
然后编译并启动库存服务:
cd service/inventory
make build
./bin/inventory -conf configs
如果 Consul、PostgreSQL 或 Redis 没有启动,服务可能可以编译成功,但运行时会因为连接依赖失败而退出。这几个问题不要混在代码问题里排查,先确认基础设施正常。
三、先设计库存服务的 gRPC 接口
库存服务是给内部服务调用的,所以本篇只提供 gRPC 接口,不在 shop BFF 上重复写一套库存逻辑。
打开 service/inventory/api/inventory/v1/inventory.proto,定义如下:
syntax = "proto3";
package inventory.v1;
option go_package = "inventory/api/inventory/v1;v1";
service Inventory {
rpc QueryStock(StockQueryRequest) returns (StockQueryReply); // 查询库存
rpc TryLock(TryLockRequest) returns (TryLockReply); // 预占库存
rpc ConfirmDeduct(ConfirmDeductRequest) returns (CheckReply); // 确认扣减
rpc Release(ReleaseRequest) returns (CheckReply); // 释放库存
}
message StockItem {
int64 skuId = 1;
int64 inventory = 2;
int64 locked = 3;
int64 available = 4;
}
message StockQueryRequest {
repeated int64 skuIds = 1;
}
message StockQueryReply {
repeated StockItem items = 1;
}
message SkuItem {
int64 skuId = 1;
int32 num = 2;
}
message TryLockRequest {
string orderSn = 1;
repeated SkuItem items = 2;
}
message TryLockReply {
bool success = 1;
string reason = 2;
}
message ConfirmDeductRequest {
string orderSn = 1;
repeated SkuItem items = 2;
}
message ReleaseRequest {
string orderSn = 1;
repeated SkuItem items = 2;
}
message CheckReply {
bool success = 1;
string reason = 2;
}
这四个方法分别对应四种场景。
1. QueryStock
商品列表和商品详情页需要展示库存时调用这个接口。
请求只需要传入一批 SKU:
{
"skuIds": [1, 2, 3]
}
返回结果中:
- inventory 是库存总量;
- locked 是已经预占的数量;
- available 是当前可用库存。
2. TryLock
订单创建成功后预占库存。这里必须带上订单号,因为库存锁定不是一个孤立的数字,而是“某个订单锁定了某个 SKU 的多少件”。
3. ConfirmDeduct
支付成功后确认扣减。这个动作会减少库存总量,同时释放之前的锁定数量。
4. Release
订单取消或者支付超时后释放库存。释放只减少锁定数量,不减少库存总量。
四、库存表不要只剩一个数字
打开 sql/migrations/shop_inventory/000001_init.up.sql,当前库存服务主要有四张表。
1. 实时库存表
CREATE TABLE IF NOT EXISTS inventories (
id BIGSERIAL PRIMARY KEY,
sku_id BIGINT NOT NULL UNIQUE,
inventory BIGINT NOT NULL DEFAULT 0,
locked BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
sku_id 是唯一的,一个 SKU 在库存服务中只能有一条实时库存记录。
version 暂时不是计算可用库存必须的字段,但库存属于并发修改非常频繁的业务,保留版本号后面做乐观锁、对账和问题排查都会方便一些。
2. 库存预占表
CREATE TABLE IF NOT EXISTS inventory_locks (
id BIGSERIAL PRIMARY KEY,
order_sn VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
num INT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_inventory_locks UNIQUE (order_sn, sku_id)
);
order_sn + sku_id 的唯一约束是这里的关键。
同一个订单因为网络超时,可能会重复发送预占请求。如果没有这个约束,第一次请求锁定 2 件,第二次请求又锁定 2 件,库存就会被错误地锁定 4 件。
当前代码里:
- status = 1:已预占;
- status = 2:已经确认扣减;
- status = 3:已经释放。
这些状态值后面最好抽成常量。目前先按照当前项目的实现来理解。
3. 库存流水表
CREATE TABLE IF NOT EXISTS inventory_flows (
id BIGSERIAL PRIMARY KEY,
order_sn VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
change BIGINT NOT NULL DEFAULT 0,
type VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
库存出问题的时候,光看 inventories 表很难知道中间发生了什么。
所以每次预占、扣减和释放,都额外记录一条流水:
- lock:预占库存;
- deduct:确认扣减;
- release:释放库存。
流水表不是用来代替实时库存的,它主要用于审计、排查和后续对账。
4. 消息消费幂等表
CREATE TABLE IF NOT EXISTS consumed_event (
event_id VARCHAR(64) PRIMARY KEY,
order_sn VARCHAR(64) NOT NULL DEFAULT '',
consumed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
这一张表是给 RabbitMQ 消费使用的。后面同一条订单消息即使因为重试再次投递,也可以根据 event_id 判断是否已经处理过。
五、按照 Kratos 的层次拆代码
库存服务的代码依旧按照前面一直使用的方式拆分:
- api:定义 gRPC 协议;
- domain:库存领域对象;
- biz:用例和仓储接口;
- data:GORM、Redis 和事务实现;
- service:把领域用例转换成 gRPC 请求和响应;
- server:启动 gRPC 服务和 Consul 注册;
- cmd:Wire 组装依赖并启动应用。
先看 internal/domain/inventory.go:
package domain
type Inventory struct {
ID int64
SkuID int64
Inventory int64
Locked int64
Version int64
}
type SkuItem struct {
SkuID int64
Num int32
}
domain 不需要知道 GORM 的表名,也不需要知道 Redis 的 key。它只描述库存业务真正关心的数据。
再看 internal/biz/inventory.go 中的仓储接口:
type InventoryRepo interface {
Query(ctx context.Context, skuIds []int64) ([]*domain.Inventory, error)
TryLock(ctx context.Context, orderSn string, items []*domain.SkuItem) error
ConfirmDeduct(ctx context.Context, orderSn string, items []*domain.SkuItem) error
Release(ctx context.Context, orderSn string, items []*domain.SkuItem) error
IsConsumed(ctx context.Context, eventID string) (bool, error)
MarkConsumed(ctx context.Context, eventID, orderSn string) error
}
这里的 biz 层只定义“库存可以做什么”,并不直接操作数据库。
当前 InventoryUsecase 主要负责把调用转发给 InventoryRepo。看上去代码有点薄,但这并不是问题。库存的一致性操作需要在同一个数据库事务里完成,真正依赖 GORM 事务和行锁的是 data 层。
六、先实现库存查询
查询库存是最简单的一个接口,但它会被商品列表、商品详情、订单校验等多个地方使用。
internal/service/inventory.go 中的实现如下:
func (s *InventoryService) QueryStock(
ctx context.Context,
req *v1.StockQueryRequest,
) (*v1.StockQueryReply, error) {
invs, err := s.uc.Query(ctx, req.SkuIds)
if err != nil {
return nil, err
}
reply := &v1.StockQueryReply{}
for _, inv := range invs {
reply.Items = append(reply.Items, &v1.StockItem{
SkuId: inv.SkuID,
Inventory: inv.Inventory,
Locked: inv.Locked,
Available: inv.Inventory - inv.Locked,
})
}
return reply, nil
}
这里不要把 available 存成一个单独字段。
如果同时存了:
- inventory = 50;
- locked = 8;
- available = 42。
以后某个流程只更新了前两个字段,却忘记更新 available,三个数字就会互相打架。
能够计算出来的字段,在返回接口时计算就可以了。
查询数据时,库存仓储会先尝试从 Redis 读取。如果缓存没有命中,再从 PostgreSQL 查询,并把结果写入 Redis。库存发生预占、扣减或释放后,当前实现会删除对应的缓存,让下一次查询重新从数据库加载。
这里再强调一下:Redis 是加速层,不应该成为库存最终事实的唯一来源。最终落库和事务边界仍然在 PostgreSQL。
七、实现预占库存
库存服务最核心的方法是 TryLock。
它至少需要保证三件事:
- 可用库存不能小于本次购买数量;
- 同一个订单和 SKU 不能重复预占;
- 多个 SKU 必须在同一个事务里成功或失败。
核心逻辑可以简化成下面这样:
func (r *inventoryRepo) TryLock(
ctx context.Context,
orderSn string,
items []*domain.SkuItem,
) error {
return r.data.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range items {
var inv Inventory
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("sku_id = ?", item.SkuID).
First(&inv).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("SKU 库存不存在")
}
return err
}
if inv.Inventory-inv.Locked < int64(item.Num) {
return errors.New("库存不足")
}
var lock InventoryLock
err = tx.Where(
"order_sn = ? AND sku_id = ?",
orderSn,
item.SkuID,
).First(&lock).Error
if err == nil {
// 同一个订单、同一个 SKU 已经预占,直接幂等返回
continue
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
err = tx.Model(&Inventory{}).
Where("id = ?", inv.ID).
Updates(map[string]interface{}{
"locked": gorm.Expr("locked + ?", item.Num),
"version": gorm.Expr("version + 1"),
}).Error
if err != nil {
return err
}
err = tx.Create(&InventoryLock{
OrderSn: orderSn,
SkuID: item.SkuID,
Num: item.Num,
Status: 1,
}).Error
if err != nil {
return err
}
err = tx.Create(&InventoryFlow{
OrderSn: orderSn,
SkuID: item.SkuID,
Change: int64(item.Num),
Type: "lock",
}).Error
if err != nil {
return err
}
}
return nil
})
}
这里最重要的一行是:
clause.Locking{Strength: “UPDATE”}
它会让 PostgreSQL 在查询库存记录时加上行级排他锁。
假设两个请求同时锁定同一个 SKU:
- 请求 A 先拿到行锁,读取到可用库存 1;
- 请求 A 把 locked 加 1,提交事务;
- 请求 B 才能拿到行锁;
- 请求 B 再读取时,可用库存已经变成 0,于是返回库存不足。
如果没有这个行锁,两个请求都可能读到旧的库存值,然后都判断为“库存足够”。
多 SKU 为什么要一个事务
订单通常不止一个 SKU。比如一个订单包含:
- SKU 1:2 件;
- SKU 2:1 件。
如果 SKU 1 锁成功,SKU 2 库存不足,不能只留下 SKU 1 的锁。否则订单虽然创建失败,却泄漏了一部分库存。
因此所有 SKU 的数据库更新都放进同一个事务中。任意一个 SKU 失败,前面已经写入的库存、锁定记录和流水全部回滚。
当前项目在数据库事务外面还增加了 Redis Lua 的快速预检查,用来减少高并发时无意义的数据库请求。这个 Redis 过程属于性能增强,不能替代数据库事务。
多 SKU 的 Redis 临时预占必须有完整的补偿逻辑:如果前面的 SKU 已经在 Redis 中增加了锁定数量,但后面的 SKU 检查失败,前面的临时数量也必须回滚。这个场景建议单独写并发测试验证,不要只测试单 SKU 的成功路径。
八、确认扣减和释放库存
预占成功后,库存有两种结果。
支付成功:确认扣减
确认扣减的处理步骤是:
- 根据订单号和 SKU 找到状态为 1 的锁定记录;
- inventory 减少购买数量;
- locked 同时减少购买数量;
- 把锁定记录更新为状态 2;
- 写入一条 deduct 流水。
例如当前库存是:
inventory = 50
locked = 8
available = 42
订单支付成功,确认扣减 3 件之后:
inventory = 47
locked = 5
available = 42
可用库存没有改变,因为这 3 件本来就已经不在可用库存里了。
当前仓储层的核心更新是:
err := tx.Model(&Inventory{}).
Where("sku_id = ?", item.SkuID).
Updates(map[string]interface{}{
"inventory": gorm.Expr("inventory - ?", item.Num),
"locked": gorm.Expr("locked - ?", item.Num),
"version": gorm.Expr("version + 1"),
}).Error
库存和锁定数量必须在同一个事务中更新,锁定记录状态和库存流水也要放在这个事务中。
订单取消:释放库存
释放库存的处理步骤则相反:
- 找到状态为 1 的锁定记录;
- locked 减少购买数量;
- inventory 不变;
- 把锁定记录更新为状态 3;
- 写入一条 release 流水。
释放后,库存重新回到可用状态。
如果找不到状态为 1 的锁定记录,当前 Release 实现会直接跳过。这是为了让取消消息具备一定的幂等性:订单可能已经支付并确认扣减,也可能之前已经释放过,重复收到取消消息时不应该再次减少库存。
确认扣减和释放都应该以锁定记录作为依据,不要只根据请求中的数量直接修改库存。否则客户端重复提交或者消息重复投递时,很容易发生重复扣减。
九、把库存服务接入订单消息
库存服务本身已经可以通过 gRPC 调用,但真正的订单流程不应该让订单服务一直同步等待所有下游操作。
当前项目使用 RabbitMQ 传递订单事件,库存服务消费订单相关消息:
order.created -> TryLock -> inventory.locked / inventory.lock.failed
order.paid -> ConfirmDeduct -> inventory.deducted / inventory.deduct.failed
order.cancelled -> Release -> inventory.released / inventory.release.failed
库存服务启动时,如果配置了 RabbitMQ 地址,就会启动消费者:
- 收到 order.created,提取订单中的 SKU 和数量,预占库存;
- 收到 order.paid,确认扣减库存;
- 收到 order.cancelled,释放库存;
- 处理完成后,把结果发布到 inventory.exchange。
订单事件中至少要包含:
{
"event_id": "event-001",
"event_type": "order.created",
"order_sn": "ORDER-001",
"payload": {
"skus": [
{
"sku_id": 1,
"num": 2
}
]
}
}
为什么要有 event_id?
RabbitMQ 只保证消息能够投递,不保证你的业务代码只执行一次。消费者处理完业务但还没有来得及确认消息时,进程可能突然退出,消息随后会再次投递。
库存服务处理消息前,会先查询 consumed_event:
consumed, err := s.uc.IsConsumed(ctx, evt.EventID)
if err != nil {
return err
}
if consumed {
return nil
}
业务处理成功,或者明确判断为库存不足这类业务终态后,再写入消费记录。数据库连接失败、Redis 失败等基础设施错误不能随便标记为已消费,否则消息虽然不再重试,库存操作却可能根本没有完成。
消费记录主要保护“已经处理完成后再次投递”的场景。多个相同消息在同一时刻并发进入时,单独的先查询再写入并不能替代完整的并发控制,仍然要依靠库存锁的唯一约束、事务和重试结果来兜底。
这部分已经不只是一个简单的 CRUD 服务了。订单、库存、支付之间的完整补偿和状态机,后面还可以单独写一篇,本篇先把库存服务自身的边界讲清楚。
十、商品服务如何读取库存
库存拆分后,商品服务不能再直接读取库存数据库。
当前商品服务通过 Consul 发现 shop.inventory.service,创建库存 gRPC 客户端:
func NewInventoryServiceClient(
sr *conf.Service,
rr registry.Discovery,
) inventoryV1.InventoryClient {
conn, err := grpc.DialInsecure(
context.Background(),
grpc.WithEndpoint(sr.Inventory.Endpoint),
grpc.WithDiscovery(rr),
grpc.WithTimeout(2*time.Second),
)
if err != nil {
panic(err)
}
return inventoryV1.NewInventoryClient(conn)
}
商品查询时,把 SKU ID 批量传给库存服务:
stock, err := g.inv.QueryStock(
ctx,
&inventoryV1.StockQueryRequest{SkuIds: ids},
)
然后把返回的 available 填入商品接口中的 SKU 库存字段。
这个调用方向应该保持单向:
shop -> goods -> inventory
cart -> cart
order -> inventory / goods / payment
购物车服务保存的是用户想买什么,不应该为了展示库存而直接连接库存数据库。需要校验库存时,由上层业务或者订单流程调用库存服务。
一个需要特别留意的迁移问题
旧的商品服务里仍然保留了 goods_inventories,创建 SKU 时也还会写入这张表;新的库存服务则使用自己的 shop_inventory.inventories。
这说明当前代码处在从“库存属于商品服务”迁移到“库存属于独立服务”的过渡阶段。
两套表不能长期同时作为库存真相。否则就会出现:
- 商品服务创建 SKU 写入旧表;
- 商品详情从新库存服务读取;
- 新库存服务里却没有这条 SKU 的库存记录。
当前项目的演示数据已经给库存服务准备了 SKU 1 到 SKU 4 的库存,所以主流程可以先跑通。生产化之前,还需要在 SKU 创建流程中明确一种初始化方式:
- 商品创建 SKU 后,同步调用库存服务初始化库存;
- 商品服务发布 sku.created 事件,由库存服务异步创建库存;
- 迁移旧库存数据后,彻底删除商品服务对库存表的直接写入。
无论选哪一种,都不要让商品服务直接连接 shop_inventory 数据库。跨服务直接写库,等于把服务拆分又拆回去了。
十一、用 grpcurl 验证库存服务
先启动 PostgreSQL、Redis、Consul 和库存服务。
因为当前 gRPC 服务没有依赖反射来描述接口,所以使用 grpcurl 时把 proto 文件传进去:
grpcurl -plaintext \
-import-path api \
-proto inventory/v1/inventory.proto \
-d '{"skuIds":[1]}' \
127.0.0.1:50055 \
inventory.v1.Inventory/QueryStock
正常情况下会看到类似结果:
{
"items": [
{
"skuId": "1",
"inventory": "50",
"locked": "0",
"available": "50"
}
]
}
然后预占 2 件:
grpcurl -plaintext \
-import-path api \
-proto inventory/v1/inventory.proto \
-d '{"orderSn":"TEST-001","items":[{"skuId":1,"num":2}]}' \
127.0.0.1:50055 \
inventory.v1.Inventory/TryLock
再次查询,应该看到:
inventory = 50
locked = 2
available = 48
确认扣减:
grpcurl -plaintext \
-import-path api \
-proto inventory/v1/inventory.proto \
-d '{"orderSn":"TEST-001","items":[{"skuId":1,"num":2}]}' \
127.0.0.1:50055 \
inventory.v1.Inventory/ConfirmDeduct
此时应该变成:
inventory = 48
locked = 0
available = 48
再用另一个订单测试释放:
grpcurl -plaintext \
-import-path api \
-proto inventory/v1/inventory.proto \
-d '{"orderSn":"TEST-002","items":[{"skuId":1,"num":3}]}' \
127.0.0.1:50055 \
inventory.v1.Inventory/TryLock
grpcurl -plaintext \
-import-path api \
-proto inventory/v1/inventory.proto \
-d '{"orderSn":"TEST-002","items":[{"skuId":1,"num":3}]}' \
127.0.0.1:50055 \
inventory.v1.Inventory/Release
最后查询,locked 应该恢复到 0。
如果执行测试后库存数字和示例不一样,不要急着改代码。库存是有状态的,先清理测试数据,或者使用一个新的 SKU 和订单号重新验证。
十二、单元测试和集成测试
当前库存数据层测试放在 service/inventory/internal/data/inventory_test.go,覆盖了几个最基本的动作:
Expect(repo.TryLock(ctx, "T1",
[]*domain.SkuItem{{SkuID: 1, Num: 3}},
)).NotTo(HaveOccurred())
Expect(repo.ConfirmDeduct(ctx, "T1",
[]*domain.SkuItem{{SkuID: 1, Num: 3}},
)).NotTo(HaveOccurred())
Expect(repo.TryLock(ctx, "T2",
[]*domain.SkuItem{{SkuID: 1, Num: 2}},
)).NotTo(HaveOccurred())
Expect(repo.Release(ctx, "T2",
[]*domain.SkuItem{{SkuID: 1, Num: 2}},
)).NotTo(HaveOccurred())
运行测试:
cd service/inventory
go test ./...
这里的测试会连接本地 PostgreSQL 和 Redis,并且在测试开始时清理库存相关表。所以没有启动基础设施时,测试失败不一定是业务代码的问题。
实际项目中还应该继续补充:
- SKU 不存在;
- 库存不足;
- 同一个订单重复预占;
- 多 SKU 中某一个库存不足时整体回滚;
- 支付成功后重复收到扣减消息;
- 取消订单重复收到释放消息;
- 两个请求并发锁定同一个 SKU;
- Redis 缓存命中、未命中和缓存失效。
库存这种代码,单测能证明逻辑,集成测试才能证明事务、行锁和 Redis 配合起来没有问题。两者都不能省。
结束语
到这里,库存服务的核心流程就完成了:
- QueryStock 查询可用库存;
- TryLock 预占库存;
- ConfirmDeduct 支付成功后确认扣减;
- Release 取消或超时后释放;
- PostgreSQL 事务保证一次操作的原子性;
- 行锁保证并发下不会同时扣走同一份库存;
- 订单号唯一约束和消费事件表,为重复请求、重复消息提供幂等保护;并发重复投递仍然需要通过测试验证。
前面的购物车服务解决的是“用户想买什么”,库存服务解决的是“仓库到底还能卖多少”。
下一篇可以继续写订单服务:把购物车中的商品快照、价格校验、库存预占和订单状态串起来。等订单、支付和库存三条链路接上之后,这个商城才算真正开始跑起来。
这里特别感谢一下一直支持观看此系列的同学,更感谢你们的打赏、点赞、分享

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