Go-kratos 框架商城微服务实战之库存服务 (十三)

AI摘要
【知识分享】本文是一篇技术文章,作者续写四年前暂停的商城项目,详细介绍了将库存从商品服务中拆分为独立库存服务的过程。内容涵盖库存服务职责、gRPC接口设计、数据库表结构、核心业务逻辑(预占、扣减、释放)、并发控制(行锁、事务)、消息队列集成及测试验证方法,属于技术实践分享。

大家好,这一晃,距离这个系列上一篇文章已经四年多了。

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 件,两个用户几乎同时下单:

  1. 用户 A 查询库存,看到还有 1 件;
  2. 用户 B 查询库存,也看到还有 1 件;
  3. A 和 B 都提交订单;
  4. 两个请求都扣减成功。

库存就被卖成了负数,或者商品卖了两份,但仓库只有一份。这就是典型的超卖问题。

另外,库存的使用场景也不止普通下单。比如一个指定价格的秒杀活动,或者一个只允许指定商品参与的活动,都可能需要单独配置一份活动库存。商品服务里的库存字段只适合表达商品本身的库存,活动库存和秒杀库存混在一起后,规则很快就会变得难以维护。独立出库存服务,后面就可以按活动配置商品对应的独立库存,让普通购买、秒杀和其他营销活动各自使用合适的库存规则。

所以本篇会把库存从商品表中的一个普通字段拆成独立的库存服务。先把库存服务本身做正确,后面再让订单服务通过消息队列驱动库存预占、扣减和释放。

需要说明一下:前面旧文章中的数据库示例主要使用 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。

它至少需要保证三件事:

  1. 可用库存不能小于本次购买数量;
  2. 同一个订单和 SKU 不能重复预占;
  3. 多个 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 的成功路径。

八、确认扣减和释放库存

预占成功后,库存有两种结果。

支付成功:确认扣减

确认扣减的处理步骤是:

  1. 根据订单号和 SKU 找到状态为 1 的锁定记录;
  2. inventory 减少购买数量;
  3. locked 同时减少购买数量;
  4. 把锁定记录更新为状态 2;
  5. 写入一条 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. 找到状态为 1 的锁定记录;
  2. locked 减少购买数量;
  3. inventory 不变;
  4. 把锁定记录更新为状态 3;
  5. 写入一条 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 创建流程中明确一种初始化方式:

  1. 商品创建 SKU 后,同步调用库存服务初始化库存;
  2. 商品服务发布 sku.created 事件,由库存服务异步创建库存;
  3. 迁移旧库存数据后,彻底删除商品服务对库存表的直接写入。

无论选哪一种,都不要让商品服务直接连接 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 协议》,转载必须注明作者和本文链接
微信搜索:上帝喜爱笨人
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

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