《电商系统架构设计与实现》

面向中大型团队的实战指南

最后更新: 2026-04-18


项目简介

本书是基于作者多年电商系统开发经验,系统梳理电商平台架构设计与工程实践的技术专著。全书以理论与实践结合架构与代码并重的方式,深入讲解从领域建模到系统落地的完整过程。

定位

  • 工程实践指南:提供可落地的实现方案和代码示例
  • 架构设计参考:讲解架构决策过程和系统边界划分

适合读者

  • 中高级后端工程师(3-8年经验)
  • 准备系统设计面试的候选人
  • 电商/O2O 领域的架构师
  • 希望系统学习 DDD 和 Clean Architecture 的开发者

内容结构

全书分为三大部分,共 18 章:

第一部分:架构方法论与设计原则(4章)

  • Clean Architecture、DDD、CQRS 三位一体
  • 领域驱动设计战略篇
  • 整洁代码与设计模式
  • 架构质量保障

第二部分:电商核心系统设计(11章)

Part A:全局架构

  • 第5章:电商系统全景图
  • 第6章:系统集成与一致性设计

Part B:商品供给与运营

  • 第7章:商品中心系统
  • 第8章:库存系统
  • 第9章:营销系统
  • 第10章:商品供给与运营管理

Part C:交易链路

  • 第11章:计价系统(基础模块:PDP/购物车/创单/支付全链路试算)
  • 第12章:搜索与导购
  • 第13章:购物车与结算
  • 第14章:订单系统
  • 第15章:支付系统

第三部分:综合案例与落地(3章)

  • 第16章:B2B2C 平台完整架构(200人团队、日订单200万)
  • 第17章:系统演进与重构
  • 第18章:团队协作与工程实践

核心特色

1. 理论与实践深度结合

  • 每个系统都讲解架构设计思路具体实现方案
  • 提供 Go 语言的生产级代码示例
  • 包含真实案例踩坑经验

2. 系统边界与集成贯穿始终

  • 每个核心系统章节都有**"系统边界与职责"**小节
  • 详细讲解与其他系统的集成模式和契约
  • 提供集成失败处理降级策略

3. 完整的知识体系

  • 方法论(Clean Architecture + DDD + CQRS)
  • 核心系统(商品、库存、订单、支付等11个系统)
  • 再到综合案例(200人团队的 B2B2C 平台)

4. 面试与工程双重价值

  • 涵盖系统设计面试的高频考点
  • 提供工程落地的实战方案
  • 附录包含面试题精选集成模式速查表

预期篇幅

850-950 页,包含:

  • 核心正文:约 750 页
  • 附录(技术选型、面试题、术语表等):约 100 页

当前进度:已完成约 15 万字(16 章),预计占正文的 50-60%


相关资源

GitHub 仓库

开源地址github.com/wxquare/wxquare.github.io

仓库内容

  • 书籍完整内容(Markdown 格式)
  • 配套代码示例(Go 语言)
  • 架构图源文件
  • 勘误与讨论(Issues)

博客文章

本书的很多内容源自博客文章,可以提前阅读:

  • 电商系统设计系列
  • 架构与整洁代码系列

许可协议

本书计划采用 CC BY-NC-SA 4.0(署名-非商业性使用-相同方式共享)协议开源:

  • 可以自由阅读和分享
  • 可以用于学习和教学
  • 不可用于商业出版(需获得授权)

最后更新时间:2026-04-17
当前状态:已完成 16 章(第1-16章),共计约 150,000 字
最新进展:第二部分(电商核心系统设计)全部完成!

导航书籍主页 | 完整目录 | 下一章:第2章


第1章 架构设计三位一体

Clean Architecture、DDD 与 CQRS——电商系统架构的方法论基础


1.1 引言:为什么需要架构方法论

在开始深入电商系统的具体设计之前,我们需要先建立一套统一的架构思维框架。这不仅仅是为了"设计出好的系统",更是为了在后续的 15 章中,能够用同一套语言、同一套思维方式去理解和分析每一个子系统。

1.1.1 软件架构的本质挑战

想象你正在设计一个中型电商平台,团队规模 50-100 人,需要支撑日订单 50 万+。你会面临这些问题:

挑战 1:如何组织代码?

  • 商品、订单、支付、库存...这些模块应该如何划分?
  • 每个模块内部应该分几层?
  • 模块之间如何调用?
  • 数据库表应该如何设计?

挑战 2:如何应对变化?

  • 业务每周都有新需求(新的促销玩法、新的支付方式)
  • 如何让代码能够快速响应变化,而不是"牵一发而动全身"?
  • 如何避免代码腐化成"大泥球"?

挑战 3:如何多人协作?

  • 50+ 开发者同时开发,如何避免相互干扰?
  • 业务专家说的"订单"和技术人员理解的"订单"是一回事吗?
  • 如何让新人快速上手,理解系统?

挑战 4:如何平衡性能与复杂性?

  • 订单详情页需要展示商品、价格、库存、物流...十几个维度的信息
  • 如何在保证数据一致性的同时,还能支撑高并发查询?
  • 如何避免"为了性能牺牲代码质量"?

这些问题,正是 Clean ArchitectureDDDCQRS 这三个架构方法论要解决的。

1.1.2 三个方法论的定位

Clean Architecture、DDD 和 CQRS 这三个概念经常被一起提及,甚至被误认为是一回事。但实际上,它们关注的维度完全不同:

  • Clean Architecture 关注分层与解耦
  • DDD 关注业务建模
  • CQRS 关注数据读写的路径优化

如果把开发一套复杂的软件比作经营一家餐厅:

概念餐厅类比核心关注点
Clean Architecture餐厅的平面布局图(前台、后厨、仓库界限清晰)依赖方向与边界
DDD菜单的设计和后厨的工作流程(怎么定义招牌菜,主厨和二厨怎么分工)业务建模与通用语言
CQRS点餐和上菜的通道设计(点餐走前台系统,上菜走传菜电梯,互不干扰)读写路径分离

一句话总结

Clean Architecture 给你的代码盖房子,DDD 决定房间里怎么住人,CQRS 给房子装了专门的入户门和逃生通道。

1.1.3 为什么电商系统特别需要这三件套

电商系统是典型的业务复杂、高并发、强一致性场景,有以下特点:

业务复杂

  • 订单流程涉及商品、库存、价格、营销、支付等十几个子系统
  • 促销规则频繁变化(每周上新活动)
  • 业务规则多且容易冲突(优惠券叠加、库存超卖)

高并发

  • 大促期间订单 QPS 可达 10 万+
  • 商品详情页 QPS 百万级
  • 搜索、列表页 QPS 千万级

强一致性

  • 支付金额必须准确(容忍度为 0)
  • 库存不能超卖(用户体验直接受损)
  • 订单状态必须与支付状态同步

这些特点,让电商系统成为实践三件套的最佳场景。后续章节中,你会看到这三个方法论在每一个子系统中的具体应用。

1.1.4 本章结构

本章分为以下几个部分:

  1. Clean Architecture(1.2):讲解如何通过依赖反转实现分层解耦
  2. DDD(1.3):讲解如何通过领域建模应对业务复杂性
  3. CQRS(1.4):讲解如何通过读写分离优化性能
  4. 三者协作(1.5):讲解如何在同一个项目中组合使用
  5. 最佳实践(1.6-1.7):讲解何时采用、如何渐进演进

让我们开始深入每一个概念。


1.2 Clean Architecture(整洁架构)

1.2.1 核心思想:依赖规则

Clean Architecture 由 Robert C. Martin(Uncle Bob)在 2012 年提出,其核心思想非常简单:

业务逻辑应该独立于 UI、数据库、框架或任何外部代理。

这句话的含义是:当你决定从 MySQL 换到 PostgreSQL,或者把 Web 框架从 Gin 换到 Echo 时,核心的业务逻辑(Use Cases 和 Entities)不需要改动一行代码

依赖规则:源代码的依赖方向只能向内。外层(如数据库、Web 框架)可以依赖内层,但内层绝不能知道外层的存在

┌──────────────────────────────────────────────────────────────┐
│  Frameworks & Drivers  (Web, DB, External APIs)              │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  Interface Adapters  (Controllers, Gateways, Repos)  │    │
│  │  ┌──────────────────────────────────────────────┐    │    │
│  │  │  Application Business Rules  (Use Cases)     │    │    │
│  │  │  ┌──────────────────────────────────────┐    │    │    │
│  │  │  │  Enterprise Business Rules (Entities) │    │    │    │
│  │  │  └──────────────────────────────────────┘    │    │    │
│  │  └──────────────────────────────────────────────┘    │    │
│  └──────────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────────┘

                   依赖方向 ──────→ 向内

1.2.2 四层模型

Clean Architecture 将系统划分为四层,每层有清晰的职责:

层级职责示例
Entity(实体)最核心的业务规则,与应用无关Order, Product 的领域模型
Use Cases(用例)特定于应用的业务逻辑"处理订单"、"计算运费"
Interface Adapters(接口适配器)数据格式转换,连接内外层Controller, Presenter, Repository 接口实现
Frameworks & Drivers(框架和驱动)具体技术实现MySQL, Redis, Gin, gRPC

关键理解

  • Entity 层是纯业务逻辑,不知道 HTTP、数据库、消息队列的存在
  • Use Case 层编排业务流程,依赖 Entity 层的接口,不依赖具体实现
  • Adapter 层连接内外,实现 Entity/Use Case 层定义的接口
  • Framework 层是具体的技术选型,可以随时替换

1.2.3 Go 项目中的典型目录映射

在 Go 项目中,Clean Architecture 通常映射为以下目录结构:

myapp/
├── cmd/
│   └── server/
│       └── main.go           # 启动入口 & 依赖注入
│
├── domain/                    # Entity 层:纯业务模型和接口定义
│   ├── order/
│   │   ├── order.go           # 订单聚合根
│   │   ├── order_item.go      # 订单明细实体
│   │   ├── money.go           # 金额值对象
│   │   └── repository.go      # Repository 接口(Port),不含实现
│   └── product/
│       ├── product.go
│       └── repository.go
│
├── usecase/                   # Use Case 层:应用业务逻辑
│   ├── place_order.go         # 下单用例
│   ├── cancel_order.go        # 取消订单用例
│   └── query_order.go         # 查询订单用例
│
├── adapter/                   # Interface Adapter 层
│   ├── inbound/               # 入站适配器
│   │   ├── http/              #   HTTP handler
│   │   │   ├── order_handler.go
│   │   │   └── product_handler.go
│   │   └── grpc/              #   gRPC handler
│   │       └── order_service.go
│   └── outbound/              # 出站适配器
│       ├── persistence/       #   数据库实现(实现 domain 接口)
│       │   ├── mysql_order_repo.go
│       │   └── mysql_product_repo.go
│       └── messaging/         #   消息队列实现
│           └── kafka_event_bus.go
│
└── infra/                     # Frameworks & Drivers 层
    ├── mysql/                 # MySQL 连接管理
    │   └── connection.go
    ├── redis/                 # Redis 连接管理
    │   └── client.go
    └── kafka/                 # Kafka 连接管理
        └── producer.go

关键设计原则

  1. 依赖方向向内adapter 依赖 usecaseusecase 依赖 domain,但反向不成立
  2. 接口在内层定义domain/order/repository.go 定义接口,adapter/persistence/mysql_order_repo.go 实现接口
  3. 框架在外层:Gin、GORM、Kafka 等框架只在 adapterinfra 层使用

1.2.4 核心价值:技术无关的业务逻辑

让我们通过一个具体的代码示例来理解 Clean Architecture 的价值。

反例:依赖具体实现

// ❌ 反例:OrderService 直接依赖具体的 MySQL 实现
package service

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

type OrderService struct {
    db *sql.DB  // 直接依赖 MySQL
}

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) (*Order, error) {
    // 业务逻辑和数据库操作混在一起
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return nil, err
    }
    defer tx.Rollback()
    
    order := &Order{
        CustomerID: req.CustomerID,
        Items:      req.Items,
        Total:      calculateTotal(req.Items),
    }
    
    // SQL 语句直接写在业务逻辑中
    _, err = tx.ExecContext(ctx, 
        "INSERT INTO orders (customer_id, total, status) VALUES (?, ?, ?)",
        order.CustomerID, order.Total, "pending")
    if err != nil {
        return nil, err
    }
    
    return order, tx.Commit()
}

问题

  1. 业务逻辑和数据库操作混在一起,难以测试
  2. 换数据库(如 PostgreSQL)需要改动业务逻辑代码
  3. 无法编写不依赖数据库的单元测试
  4. OrderService 直接依赖 database/sql 和 MySQL 驱动

正例:依赖抽象

// ✅ 正例:Clean Architecture 方式

// domain/order/repository.go — 内层只定义接口
package order

type Repository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id string) (*Order, error)
}

// domain/order/order.go — 领域模型
package order

type Order struct {
    id         string
    customerID string
    items      []OrderItem
    status     Status
    totalPrice Money
}

func NewOrder(customerID string) *Order {
    return &Order{
        id:         generateID(),
        customerID: customerID,
        items:      make([]OrderItem, 0),
        status:     StatusDraft,
    }
}

func (o *Order) AddItem(product Product, qty int) error {
    if o.status != StatusDraft {
        return ErrOrderNotEditable
    }
    if qty <= 0 {
        return ErrInvalidQuantity
    }
    item := NewOrderItem(product, qty)
    o.items = append(o.items, item)
    o.recalculateTotal()
    return nil
}

func (o *Order) Place() error {
    if len(o.items) == 0 {
        return ErrEmptyOrder
    }
    o.status = StatusPlaced
    return nil
}
// usecase/place_order.go — Use Case 依赖接口而非实现
package usecase

import "myapp/domain/order"

type PlaceOrderUseCase struct {
    orderRepo order.Repository  // 依赖抽象接口
}

func (uc *PlaceOrderUseCase) Execute(ctx context.Context, req PlaceOrderRequest) (*PlaceOrderResponse, error) {
    // 创建订单聚合
    o := order.NewOrder(req.CustomerID)
    
    // 添加商品
    for _, item := range req.Items {
        product := order.Product{ID: item.ProductID, Price: item.Price}
        if err := o.AddItem(product, item.Quantity); err != nil {
            return nil, err
        }
    }
    
    // 下单
    if err := o.Place(); err != nil {
        return nil, err
    }
    
    // 持久化(通过接口)
    if err := uc.orderRepo.Save(ctx, o); err != nil {
        return nil, err
    }
    
    return &PlaceOrderResponse{OrderID: o.ID()}, nil
}
// adapter/persistence/mysql_order_repo.go — 外层实现接口
package persistence

import (
    "database/sql"
    "myapp/domain/order"
)

type MySQLOrderRepo struct {
    db *sql.DB
}

func NewMySQLOrderRepo(db *sql.DB) order.Repository {
    return &MySQLOrderRepo{db: db}
}

func (r *MySQLOrderRepo) Save(ctx context.Context, o *order.Order) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO orders (id, customer_id, total, status) VALUES (?, ?, ?, ?)",
        o.ID(), o.CustomerID(), o.Total().Amount, o.Status().String())
    return err
}

func (r *MySQLOrderRepo) FindByID(ctx context.Context, id string) (*order.Order, error) {
    // 实现查询逻辑
    // ...
    return nil, nil
}
// adapter/persistence/mongo_order_repo.go — 换存储只需新增实现
package persistence

import (
    "go.mongodb.org/mongo-driver/mongo"
    "myapp/domain/order"
)

type MongoOrderRepo struct {
    collection *mongo.Collection
}

func NewMongoOrderRepo(col *mongo.Collection) order.Repository {
    return &MongoOrderRepo{collection: col}
}

func (r *MongoOrderRepo) Save(ctx context.Context, o *order.Order) error {
    _, err := r.collection.InsertOne(ctx, bson.M{
        "_id":         o.ID(),
        "customer_id": o.CustomerID(),
        "total":       o.Total().Amount,
        "status":      o.Status().String(),
    })
    return err
}

对比收益

维度反例(依赖具体实现)正例(依赖抽象)
测试必须启动 MySQL 才能测试用 Mock 实现接口即可测试
换存储改动业务逻辑代码只需新增一个 Adapter
理解成本业务逻辑和技术细节混在一起业务逻辑清晰独立
并行开发数据库schema确定后才能开发定义好接口就可以并行开发

1.2.5 依赖注入的 Go 实现

在 Clean Architecture 中,组装(将接口与实现绑定)发生在最外层——通常是 main.gocmd/server/main.go

方式一:手动注入(推荐,适合中小项目)

// cmd/server/main.go
package main

import (
    "database/sql"
    "log"
    "myapp/adapter/inbound/http"
    "myapp/adapter/outbound/persistence"
    "myapp/infra/mysql"
    "myapp/usecase"
    
    _ "github.com/go-sql-driver/mysql"
    "github.com/gin-gonic/gin"
)

func main() {
    // 1. Infrastructure 层:初始化基础设施
    db, err := sql.Open("mysql", "user:pass@tcp(localhost:3306)/mydb")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 2. Adapter 层:创建实现(实现 domain 接口)
    orderRepo := persistence.NewMySQLOrderRepo(db)
    productRepo := persistence.NewMySQLProductRepo(db)

    // 3. Use Case 层:注入依赖
    placeOrderUC := usecase.NewPlaceOrderUseCase(orderRepo, productRepo)
    cancelOrderUC := usecase.NewCancelOrderUseCase(orderRepo)

    // 4. Adapter 层(Inbound):创建 HTTP Handler
    orderHandler := http.NewOrderHandler(placeOrderUC, cancelOrderUC)

    // 5. Framework 层:启动 Web 服务器
    router := gin.Default()
    orderHandler.RegisterRoutes(router)
    router.Run(":8080")
}

优点

  • 零依赖,不需要引入任何 DI 框架
  • 编译时检查,类型安全
  • 调试直观,依赖关系一目了然

缺点

  • 当依赖超过 20 个时,main.go 变得冗长
  • 手动管理依赖顺序,容易出错

方式二:Wire(适合大型项目)

Google 的 Wire 通过代码生成实现依赖注入:

// cmd/server/wire.go
//go:build wireinject

package main

import (
    "myapp/adapter/inbound/http"
    "myapp/adapter/outbound/persistence"
    "myapp/infra/mysql"
    "myapp/usecase"
    
    "github.com/google/wire"
)

func InitializeOrderHandler() (*http.OrderHandler, error) {
    wire.Build(
        // Infrastructure
        mysql.NewConnection,
        
        // Adapters (Outbound)
        persistence.NewMySQLOrderRepo,
        persistence.NewMySQLProductRepo,
        
        // Use Cases
        usecase.NewPlaceOrderUseCase,
        usecase.NewCancelOrderUseCase,
        
        // Adapters (Inbound)
        http.NewOrderHandler,
    )
    return nil, nil
}

运行 wire ./cmd/server 后,Wire 会自动生成 wire_gen.go

// Code generated by Wire. DO NOT EDIT.

func InitializeOrderHandler() (*http.OrderHandler, error) {
    db, err := mysql.NewConnection()
    if err != nil {
        return nil, err
    }
    orderRepo := persistence.NewMySQLOrderRepo(db)
    productRepo := persistence.NewMySQLProductRepo(db)
    placeOrderUC := usecase.NewPlaceOrderUseCase(orderRepo, productRepo)
    cancelOrderUC := usecase.NewCancelOrderUseCase(orderRepo)
    handler := http.NewOrderHandler(placeOrderUC, cancelOrderUC)
    return handler, nil
}

优点

  • 自动处理依赖顺序
  • 编译时检查,类型安全
  • 适合大型项目(100+ 依赖)

缺点

  • 需要学习 Wire 的 API
  • 代码生成可能影响调试体验

方式三:Uber Fx(运行时注入)

// cmd/server/main.go
package main

import (
    "go.uber.org/fx"
    "myapp/adapter/inbound/http"
    "myapp/adapter/outbound/persistence"
    "myapp/infra/mysql"
    "myapp/usecase"
)

func main() {
    fx.New(
        // Infrastructure
        fx.Provide(mysql.NewConnection),
        
        // Adapters
        fx.Provide(persistence.NewMySQLOrderRepo),
        fx.Provide(persistence.NewMySQLProductRepo),
        
        // Use Cases
        fx.Provide(usecase.NewPlaceOrderUseCase),
        fx.Provide(usecase.NewCancelOrderUseCase),
        
        // HTTP Handler
        fx.Provide(http.NewOrderHandler),
        
        // Start server
        fx.Invoke(func(h *http.OrderHandler) {
            router := gin.Default()
            h.RegisterRoutes(router)
            router.Run(":8080")
        }),
    ).Run()
}

优点

  • 支持生命周期管理(启动/关闭钩子)
  • 支持依赖图可视化
  • 适合微服务框架

缺点

  • 运行时注入,类型错误要到运行时才能发现
  • 学习曲线较陡

推荐选择

  • 小型项目(<50 个依赖):手动注入
  • 中型项目(50-200 个依赖):Wire
  • 大型项目(200+ 个依赖,微服务):Fx

1.2.6 架构风格对比:Clean vs 六边形 vs 洋葱

在学习 Clean Architecture 时,你可能还会遇到另外两个相似的概念:六边形架构(Hexagonal Architecture)洋葱架构(Onion Architecture)。它们经常被混用,但实际上有细微差别:

维度Clean Architecture六边形架构 (Hexagonal)洋葱架构 (Onion)
提出者Robert C. Martin (2012)Alistair Cockburn (2005)Jeffrey Palermo (2008)
核心隐喻同心圆,层层向内六边形,端口与适配器洋葱,层层剥开
关键概念Entity, Use Case, AdapterPort(接口), Adapter(实现)Domain Model, Domain Service, App Service
外部交互方式通过 Interface Adapter 层通过 Port + Adapter 对通过 Infrastructure 层
核心共识依赖方向向内,业务逻辑不依赖外部技术同左同左
graph TB
    subgraph "Clean Architecture"
        direction TB
        CA_E[Entity] 
        CA_U[Use Case] --> CA_E
        CA_A[Adapter] --> CA_U
        CA_F[Framework] --> CA_A
    end
    
    subgraph "Hexagonal"
        direction TB
        H_D[Domain Core]
        H_PI[Inbound Port] --> H_D
        H_PO[Outbound Port] --> H_D
        H_AI[Driving Adapter] --> H_PI
        H_AO[Driven Adapter] --> H_PO
    end

    subgraph "Onion"
        direction TB
        O_DM[Domain Model]
        O_DS[Domain Service] --> O_DM
        O_AS[App Service] --> O_DS
        O_IF[Infrastructure] --> O_AS
    end

实际差异很小,三者在 Go 项目中的落地几乎一样——关键是守住一条线:内层定义接口,外层实现接口

Port & Adapter 模式的 Go 实现

六边形架构中,**Port(端口)**是接口,**Adapter(适配器)**是实现。在 Go 中天然契合:

// domain/port.go — Outbound Port(领域层定义接口)
package domain

type PaymentGateway interface {
    Charge(ctx context.Context, orderID string, amount Money) (*PaymentResult, error)
}

// adapter/payment/stripe_adapter.go — Driven Adapter(基础设施层实现接口)
package payment

import (
    "myapp/domain"
    "github.com/stripe/stripe-go/v72"
)

type StripeAdapter struct {
    client *stripe.Client
}

func NewStripeAdapter(apiKey string) domain.PaymentGateway {
    return &StripeAdapter{
        client: stripe.NewClient(apiKey),
    }
}

func (a *StripeAdapter) Charge(ctx context.Context, orderID string, amount domain.Money) (*domain.PaymentResult, error) {
    params := &stripe.ChargeParams{
        Amount:   stripe.Int64(amount.Amount),
        Currency: stripe.String(amount.Currency),
    }
    resp, err := a.client.Charges.New(params)
    if err != nil {
        return nil, fmt.Errorf("stripe charge failed: %w", err)
    }
    return &domain.PaymentResult{
        TransactionID: resp.ID, 
        Status:        "success",
    }, nil
}
// adapter/payment/mock_adapter.go — 测试时可替换为 Mock
package payment

type MockPaymentAdapter struct {
    ShouldFail bool
}

func NewMockPaymentAdapter() domain.PaymentGateway {
    return &MockPaymentAdapter{ShouldFail: false}
}

func (a *MockPaymentAdapter) Charge(ctx context.Context, orderID string, amount domain.Money) (*domain.PaymentResult, error) {
    if a.ShouldFail {
        return nil, errors.New("mock payment failure")
    }
    return &domain.PaymentResult{
        TransactionID: "mock-txn-001", 
        Status:        "success",
    }, nil
}

关键理解

  • Port(接口)在领域层定义,表达"我需要什么能力"
  • Adapter(实现)在基础设施层提供,表达"我如何提供这个能力"
  • 测试时,可以用 Mock Adapter 替换真实的 Stripe Adapter
  • 换支付渠道(如从 Stripe 换到支付宝),只需新增一个 Adapter

1.2.7 反模式:常见违规案例

在实际项目中,Clean Architecture 的违规往往不是故意的,而是在时间压力下"顺手"写下的。以下是三个最常见的反模式:

Anti-pattern 1:跨层调用

// ❌ 反例:Handler 直接引用了 MySQL 包(跳过了 domain 和 usecase 层)
package handler

import (
    "database/sql"
    "net/http"
)

func GetOrder(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        row := db.QueryRow("SELECT * FROM orders WHERE id = ?", r.URL.Query().Get("id"))
        // 直接在 handler 里写 SQL...
    }
}

问题

  • Handler 直接依赖数据库,无法测试
  • 业务逻辑散落在各个 Handler 中,无法复用
  • 换数据库需要改动所有 Handler
// ✅ 正例:Handler 只依赖 Use Case 接口
package handler

type OrderQuerier interface {
    GetOrderDetail(ctx context.Context, id string) (*OrderDetailDTO, error)
}

type OrderHandler struct {
    querier OrderQuerier
}

func NewOrderHandler(q OrderQuerier) *OrderHandler {
    return &OrderHandler{querier: q}
}

func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    dto, err := h.querier.GetOrderDetail(r.Context(), r.URL.Query().Get("id"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(dto)
}

Anti-pattern 2:基础设施泄漏到领域层

// ❌ 反例:领域实体中使用了 sql.NullString(基础设施类型侵入领域)
package domain

import "database/sql"

type Order struct {
    ID       string
    Remark   sql.NullString  // ← 领域层不应该知道 SQL 的存在
    Status   int             // ← 用魔数表示状态
}

问题

  • sql.NullStringdatabase/sql 包的类型,领域层不应该依赖基础设施包
  • 领域模型变得"贫血",只是数据容器,没有行为
// ✅ 正例:领域层使用纯 Go 类型,转换在 adapter 层完成
package domain

type Order struct {
    id     OrderID       // 强类型 ID
    remark string        // 空字符串表示无备注
    status OrderStatus   // 枚举类型,不是魔数
    items  []OrderItem
}

func (o *Order) UpdateRemark(remark string) error {
    if len(remark) > 500 {
        return ErrRemarkTooLong
    }
    o.remark = remark
    return nil
}

// adapter/persistence/converter.go — 在 Adapter 层做类型转换
func toDomain(po *OrderPO) *domain.Order {
    remark := ""
    if po.Remark.Valid {
        remark = po.Remark.String
    }
    status := domain.StatusFromInt(po.Status)
    return domain.ReconstructOrder(
        domain.OrderID(po.ID),
        remark,
        status,
        toItemList(po.Items),
    )
}

func toPO(o *domain.Order) *OrderPO {
    return &OrderPO{
        ID:     string(o.ID()),
        Remark: sql.NullString{String: o.Remark(), Valid: o.Remark() != ""},
        Status: o.Status().ToInt(),
    }
}

Anti-pattern 3:循环依赖

❌ domain/order.go imports adapter/notification
   adapter/notification imports domain/order
   → 编译失败:import cycle

问题

  • Go 不允许循环依赖,编译直接报错
  • 即使在允许循环依赖的语言(如 C#),也会导致模块耦合

解法:在 domain 层定义 Notifier 接口,adapter 层实现它。方向始终向内

// domain/notifier.go — 领域层定义接口
package domain

type Notifier interface {
    NotifyOrderPlaced(ctx context.Context, order *Order) error
}

// usecase/place_order.go — Use Case 依赖接口
type PlaceOrderUseCase struct {
    orderRepo order.Repository
    notifier  domain.Notifier  // 依赖抽象
}

func (uc *PlaceOrderUseCase) Execute(ctx context.Context, req PlaceOrderRequest) error {
    // ... 创建订单 ...
    if err := uc.orderRepo.Save(ctx, o); err != nil {
        return err
    }
    // 通过接口调用通知
    return uc.notifier.NotifyOrderPlaced(ctx, o)
}

// adapter/notification/sms_notifier.go — Adapter 层实现接口
package notification

type SMSNotifier struct {
    smsClient *SMSClient
}

func (n *SMSNotifier) NotifyOrderPlaced(ctx context.Context, order *domain.Order) error {
    message := fmt.Sprintf("您的订单 %s 已创建", order.ID())
    return n.smsClient.Send(ctx, order.CustomerPhone(), message)
}

依赖方向

domain (定义 Notifier 接口)
   ↑
usecase (依赖 Notifier 接口)
   ↑
adapter/notification (实现 Notifier 接口)

1.3 DDD(领域驱动设计)

1.3.1 战略设计:架构层面

DDD 不是一种架构,而是一套方法论。它认为软件的灵魂在于其解决的业务问题(即"领域")。

DDD 分为两个层面:

  • 战略设计:架构层面,关注如何划分领域、如何确定投资策略、如何划分上下文边界
  • 战术设计:代码层面,关注如何用聚合、实体、值对象等战术模式编写高质量的领域模型

我们先讲战略设计。

1.3.2 领域分层与投资策略

为什么需要领域分层?

一个中大型系统往往包含十几个甚至几十个子系统。假设你是一家电商平台的 CTO,面对以下子系统:

  • 订单系统、支付系统、商品管理、库存管理
  • 用户系统、搜索系统、推荐系统、评价系统
  • 消息通知、物流跟踪、风控系统、数据报表

核心问题:资源有限(人力、预算、时间),不可能对所有子系统投入同等精力。如何决定:

  • 哪些系统必须自研,投入最好的团队?
  • 哪些系统可以定制开发,用常规团队?
  • 哪些系统直接买现成方案或用开源?

如果投资决策错误:

  • ❌ 把资源浪费在通用能力上(如自研消息队列),错失核心业务创新
  • ❌ 在核心竞争力上妥协(如用低质量的订单系统),导致业务受限

DDD 的答案:按照业务价值对领域分层,实施差异化投资策略。这就是核心域(Core Domain)、支撑域(Supporting Domain)、通用域(Generic Domain)的由来。

三种领域的定义与特征

域类型定义业务价值竞争差异化投资策略组织形式技术选型
核心域
Core Domain
平台的核心竞争力,创造差异化价值最高,决定平台成败高度差异化,竞品难模仿重点投入,自研最优秀团队,独立编制自主可控,完全掌握
支撑域
Supporting Domain
支撑核心业务的必要能力中等,必须有但不差异化有一定特色但可被超越适度投入,可定制常规团队,共享资源定制开发,参考业界
通用域
Generic Domain
通用基础能力,行业共性低,无差异化行业标准,无竞争优势最小投入,采购外包/工具团队开源/SaaS/采购

核心域(Core Domain)

  • 什么是"核心竞争力"? 直接影响营收、用户体验、留存率的能力,是公司在市场中胜出的关键
  • 特点:频繁变化(紧跟业务创新)、技术复杂、需要领域专家
  • 识别标志:如果这个域做不好,公司会输;如果做得特别好,会赢
  • 案例:电商的订单系统、金融的交易系统、SaaS 的租户管理

支撑域(Supporting Domain)

  • 为什么"必须有但不差异化"? 业务依赖但不产生竞争优势,做到 80 分和 95 分对业务影响不大
  • 特点:相对稳定、有一定复杂度、需要理解业务
  • 识别标志:缺了不行,但不是赢的关键
  • 案例:电商的商品管理、金融的账户系统、SaaS 的权限系统

通用域(Generic Domain)

  • 为什么可以采购? 行业已有成熟方案,无需重复造轮子,自研的投入产出比很低
  • 特点:标准化、变化少、技术成熟
  • 识别标志:市面上有多个成熟产品可选
  • 风险:过度依赖外部服务,但可通过多供应商策略缓解
  • 案例:用户认证(Auth0/Keycloak)、消息推送(Twilio)、存储(AWS S3)

领域划分方法论

如何判断一个子域属于哪一类? 下面提供一套可操作的评分框架。

判断维度与评分模型
判断维度核心域(8-10分)支撑域(4-7分)通用域(1-3分)评分问题
业务价值直接影响收入/利润/核心指标间接影响业务,必需但不关键不影响业务差异化这个域对营收/留存的影响有多大?
竞争差异化独特能力,竞品难以模仿有特色但可被超越行业标准,无差异竞品能轻易复制这个能力吗?
变化频率频繁变化,紧跟业务创新定期调整优化稳定,很少大改多久需要大改一次?
技术复杂度高度复杂,需要领域专家中等复杂,需要业务理解成熟方案可解决普通团队能否 hold 住?

评分方法

  • 每个维度打分 1-10 分
  • 总分 = 四个维度分数相加(满分 40 分)

总分判断标准

  • 32-40 分 → 核心域(Core Domain)
  • 16-31 分 → 支撑域(Supporting Domain)
  • 4-15 分 → 通用域(Generic Domain)

注意事项

  • 边界分数(如 31-32 分)需要结合公司战略、团队能力综合判断
  • 初创公司可以适当放宽核心域标准(28 分以上即可),聚焦资源
  • 成熟公司标准更严格,避免核心域过多导致资源分散
方法论应用:电商系统实战分析

下面选择电商系统的 3 个典型域,应用评分模型进行深度分析。

案例 1:订单域(核心域)
维度评分详细分析
业务价值10订单流程直接影响 GMV(成交总额),每提升 1% 转化率就是百万级营收
竞争差异化9拼团、秒杀、预售、分期等玩法是核心竞争力,竞品难以完全模仿
变化频率9每个大促(618、双11)都会调整订单流程,支持新的营销玩法
技术复杂度9分布式事务(Saga)、状态机、高并发、幂等性、最终一致性
总分37核心域

为什么是核心域?

  • 订单流程的流畅度直接影响用户下单转化率
  • 支持的营销玩法越丰富,平台竞争力越强
  • 每个促销活动都可能需要调整订单逻辑
  • 技术上涉及多个复杂的分布式系统问题

投资建议

  • 团队配置:最优秀的架构师 + 3-5 名资深后端开发,独立团队
  • 技术选型:自研,完全掌控,不依赖外部服务
  • 质量要求:99.99% 可用性,全链路监控,灰度发布
  • 迭代策略:快速响应业务需求,2 周一个迭代
  • 文档要求:完整的设计文档、接口文档、故障预案
案例 2:商品域(支撑域)
维度评分详细分析
业务价值7商品管理是必需的,但 SPU/SKU 模型本身不产生差异化
竞争差异化5各家电商的商品模型大同小异,主要差异在类目和属性配置
变化频率6新品类上线时需要调整,但不频繁(季度级别)
技术复杂度6有一定复杂度(EAV 模型、搜索索引),但方案成熟
总分24支撑域

为什么是支撑域?

  • 商品管理做到 80 分和 95 分,对用户体验影响不大
  • SPU/SKU 模型是行业通用方案,没有太多创新空间
  • 但又不能没有(缺了商品管理,电商就玩不转)

投资建议

  • 团队配置:常规开发团队 2-3 人,可以与其他支撑域共享资源
  • 技术选型:参考业界成熟方案(如有赞、Shopify 的商品模型),适度定制
  • 质量要求:99.9% 可用性,降级策略
  • 迭代策略:稳定为主,谨慎迭代,充分测试后再上线
  • 文档要求:基础设计文档和接口文档
案例 3:用户域(通用域)
维度评分详细分析
业务价值3用户注册登录是基础能力,但不产生差异化(用户不会因为注册流程选择平台)
竞争差异化2注册登录是行业标准(手机号、邮箱、第三方登录),无差异
变化频率2很少变化,除非监管要求(如实名认证)
技术复杂度3SSO、OAuth 2.0 都有成熟方案(Auth0、Keycloak)
总分10通用域

为什么是通用域?

  • 注册登录不会成为平台的竞争优势
  • 市面上有大量成熟的身份认证服务
  • 自研的投入产出比很低

投资建议

  • 团队配置:外包或使用 SaaS 服务,内部只需 1 人对接
  • 技术选型:采购(Auth0、Keycloak、AWS Cognito)
  • 质量要求:依赖服务商 SLA(通常 99.95%+)
  • 迭代策略:按需对接新的认证方式(如生物识别),最小投入
  • 文档要求:对接文档即可

跨行业对比:方法论的通用性

同样的方法论在不同行业如何应用?下表展示三个典型行业的域划分:

行业核心域(差异化竞争力)支撑域(业务必需)通用域(行业标准)
电商• 订单系统(交易流程)
• 支付系统(资金安全)
• 商品管理
• 库存管理
• 计价引擎
• 营销系统
• 用户认证
• 搜索
• 消息推送
• 物流跟踪
• 风控
金融• 交易系统(买卖撮合)
• 风控系统(反欺诈)
• 账户系统
• 清结算
• 合规报送
• 用户认证
• 消息通知
• 报表系统
• 存储
SaaS• 租户管理(多租户隔离)
• 计费系统(订阅模式)
• 权限系统(RBAC)
• 审计日志
• 集成中心(API)
• 用户认证
• 消息
• 存储
• 监控告警

关键洞察

  1. 核心域因行业而异

    • 电商的核心是「交易流程」和「资金安全」
    • 金融的核心是「买卖撮合」和「风控合规」
    • SaaS 的核心是「多租户」和「订阅计费」
    • → 核心域反映了行业的本质和竞争焦点
  2. 通用域高度相似

    • 用户、消息、存储在各行业都是通用域
    • 这些能力已经高度标准化,有大量成熟方案
    • → 通用域是「不需要重新发明轮子」的领域
  3. 支撑域体现业务特点

    • 电商的商品、库存、计价有一定特色,但不是核心竞争力
    • 金融的账户、清结算是必需的,但各家差异不大
    • SaaS 的权限、审计是基础能力,但实现相对标准
    • → 支撑域是「需要理解业务,但可以参考业界实践」的领域

1.3.3 限界上下文(Bounded Context)

同一个"商品"在不同的上下文中有完全不同的含义:

 ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
 │   商品上下文      │     │   订单上下文      │     │   物流上下文      │
 │                  │     │                  │     │                  │
 │  商品 = SKU +    │     │  商品 = 商品快照 + │     │  商品 = 包裹 +    │
 │  价格 + 库存     │     │  购买数量 + 金额   │     │  重量 + 体积      │
 └─────────────────┘     └─────────────────┘     └─────────────────┘

为什么需要限界上下文?

在一个大型系统中,如果所有模块都对"商品"有统一的定义,会导致:

  • ❌ 商品模型越来越臃肿(既要支持展示,又要支持下单,还要支持物流)
  • ❌ 一个模块的需求变化影响所有其他模块
  • ❌ 团队之间沟通成本巨大(每次讨论都要对齐"商品"的定义)

Bounded Context 的解决方案

  • 每个上下文内,"商品"有自己的定义和模型
  • 上下文之间通过明确的接口领域事件通信
  • 上下文内部的变化,不会影响其他上下文

电商系统的 Bounded Context 示例

graph TB
    subgraph 商品上下文
        P1[Product<br/>SKU ID, Name, Price<br/>Stock, Images]
    end
    
    subgraph 订单上下文
        O1[OrderItem<br/>Product Snapshot<br/>Quantity, Price at Order Time]
    end
    
    subgraph 搜索上下文
        S1[SearchDocument<br/>SKU ID, Title, Price<br/>Category, Sales Count]
    end
    
    商品上下文 -->|商品变更事件| 订单上下文
    商品上下文 -->|索引同步| 搜索上下文

不同上下文之间通过防腐层(Anti-Corruption Layer)领域事件通信,避免概念混淆。我们会在 1.3.4 详细讲解。

1.3.4 上下文映射(Context Map)

限界上下文划分好之后,它们之间如何协作?Context Map(上下文映射)定义了上下文之间的关系模式

常见的上下文关系模式

模式定义适用场景电商案例
共享内核
Shared Kernel
两个上下文共享部分模型代码紧密协作的两个团队商品上下文和库存上下文共享 SKU 定义
客户-供应商
Customer-Supplier
下游依赖上游,上游需要考虑下游需求明确的上下游关系订单上下文依赖商品上下文
遵奉者
Conformist
下游完全遵循上游模型,无话语权接入第三方系统接入微信支付API
防腐层
Anti-Corruption Layer
下游通过翻译层隔离上游变化上游模型不稳定或不可控接入供应商API时的适配层
开放主机服务
Open Host Service
上游提供标准化接口供多方使用上游服务多个下游商品中心提供统一的商品查询API
发布语言
Published Language
定义标准的数据交换格式跨团队/跨公司协作订单事件的JSON Schema定义

电商系统的 Context Map 实例

graph LR
    A[商品上下文] -->|发布领域事件| B[订单上下文]
    B -->|调用防腐层| C[支付上下文]
    B -->|发布领域事件| D[物流上下文]
    A -->|共享内核| E[库存上下文]
    F[供应商系统] -.->|遵奉者模式| B
    A -->|开放主机服务| G[搜索上下文]
    A -->|开放主机服务| H[营销上下文]

关系说明

  1. 商品 → 订单:发布领域事件(ProductPriceChanged),订单上下文异步消费
  2. 订单 → 支付:通过防腐层调用支付API,隔离支付系统的变化
  3. 订单 → 物流:发布领域事件(OrderShipped),物流上下文异步消费
  4. 商品 ↔ 库存:共享内核(共享 SKU 的定义)
  5. 供应商 → 订单:遵奉者模式(完全遵循供应商的履约接口)
  6. 商品 → 搜索/营销:开放主机服务(提供标准化的商品查询API)

1.3.5 战术设计:代码层面

DDD 的战术设计关注的是代码层面的实现:如何用聚合、实体、值对象等战术模式编写高质量的领域模型。

战术设计概述

概念定义示例
Aggregate(聚合)一组相关对象的集合,确保数据的一致性边界Order 聚合包含 OrderItem 列表
Aggregate Root(聚合根)聚合的入口对象,外部只能通过它访问聚合Order 是聚合根,OrderItem 不能被单独访问
Entity(实体)有唯一标识的对象,按 ID 区分User(不同 ID = 不同用户)
Value Object(值对象)没有唯一标识,仅由属性定义Money(100, "USD")Address
Domain Event(领域事件)领域中发生的有意义的事实OrderPlacedPaymentCompleted
Domain Service(领域服务)不属于任何实体的业务逻辑跨聚合的转账操作

Go 代码示例:Order 聚合

// domain/order/order.go
package order

import (
    "errors"
    "time"
)

type OrderID string

type Order struct {
    id         OrderID
    customerID string
    items      []OrderItem
    status     OrderStatus
    totalPrice Money
    createdAt  time.Time
}

// 聚合根通过方法保护业务不变量
func (o *Order) AddItem(product Product, qty int) error {
    // 业务规则 1:只有草稿状态的订单才能添加商品
    if o.status != OrderStatusDraft {
        return ErrOrderNotEditable
    }
    // 业务规则 2:数量必须大于 0
    if qty <= 0 {
        return ErrInvalidQuantity
    }
    // 业务规则 3:同一商品不能重复添加(或合并数量)
    for i, item := range o.items {
        if item.ProductID == product.ID {
            o.items[i].Quantity += qty
            o.recalculateTotal()
            return nil
        }
    }
    
    item := NewOrderItem(product, qty)
    o.items = append(o.items, item)
    o.recalculateTotal()
    return nil
}

func (o *Order) Place() ([]DomainEvent, error) {
    // 业务规则 4:订单必须至少有一个商品
    if len(o.items) == 0 {
        return nil, ErrEmptyOrder
    }
    // 业务规则 5:只有草稿状态才能下单
    if o.status != OrderStatusDraft {
        return nil, ErrInvalidOrderStatus
    }
    
    o.status = OrderStatusPlaced
    
    // 发布领域事件
    events := []DomainEvent{
        OrderPlacedEvent{
            OrderID: o.id,
            Total:   o.totalPrice,
            At:      time.Now(),
        },
    }
    return events, nil
}

func (o *Order) recalculateTotal() {
    total := Money{Amount: 0, Currency: "USD"}
    for _, item := range o.items {
        itemTotal, _ := item.Price.Multiply(item.Quantity)
        total, _ = total.Add(itemTotal)
    }
    o.totalPrice = total
}

// Getters(聚合根控制外部访问)
func (o *Order) ID() OrderID { return o.id }
func (o *Order) CustomerID() string { return o.customerID }
func (o *Order) Status() OrderStatus { return o.status }
func (o *Order) Total() Money { return o.totalPrice }
// domain/order/money.go — Value Object(值对象)
package order

import "errors"

type Money struct {
    Amount   int64  // 使用分为单位,避免浮点精度问题
    Currency string
}

func (m Money) Add(other Money) (Money, error) {
    if m.Currency != other.Currency {
        return Money{}, ErrCurrencyMismatch
    }
    return Money{
        Amount:   m.Amount + other.Amount,
        Currency: m.Currency,
    }, nil
}

func (m Money) Multiply(factor int) (Money, error) {
    if factor < 0 {
        return Money{}, errors.New("factor must be positive")
    }
    return Money{
        Amount:   m.Amount * int64(factor),
        Currency: m.Currency,
    }, nil
}

func (m Money) String() string {
    return fmt.Sprintf("%d.%02d %s", m.Amount/100, m.Amount%100, m.Currency)
}

关键设计要点

  1. 业务规则内聚:所有的业务规则都在聚合根的方法中,而不是散落在 Service 层
  2. 不变量保护:聚合根保证内部数据的一致性(如 totalPrice 始终是 items 的总和)
  3. 领域事件:聚合根发生重要状态变化时,返回领域事件
  4. 值对象Money 是值对象,保证金额计算的精度和货币一致性

1.3.6 通用语言(Ubiquitous Language)

开发者和业务专家用同一套词汇交流,代码里的变量名就是业务里的术语:

业务术语代码命名反面教材
下单Order.Place()Order.SetStatus(1)
加入购物车Cart.AddItem()Cart.Insert()
发起退款Refund.Initiate()Refund.Create()
库存扣减Stock.Deduct()Stock.Update()

为什么重要?

在传统的开发模式中,业务专家和开发者之间存在"翻译"过程:

  • 业务专家说"用户下单后锁定库存"
  • 产品经理翻译成"创建订单后更新库存状态"
  • 开发者实现成 updateInventoryStatus(orderId, status=2)

问题

  • 业务术语(锁定库存)→ 技术术语(更新状态 = 2),中间丢失了业务含义
  • 代码中的 status=2 没人知道是什么意思
  • 业务规则隐藏在魔数和 SQL 中,无法追溯

通用语言的解决方案

// ✅ 代码直接使用业务术语
func (s *InventoryService) LockStock(ctx context.Context, skuID string, qty int) error {
    stock, err := s.stockRepo.FindBySKU(ctx, skuID)
    if err != nil {
        return err
    }
    
    // 业务术语:锁定库存
    if err := stock.Lock(qty); err != nil {
        return err
    }
    
    return s.stockRepo.Save(ctx, stock)
}

// domain/inventory/stock.go
func (s *Stock) Lock(qty int) error {
    if s.available < qty {
        return ErrInsufficientStock
    }
    s.available -= qty
    s.locked += qty
    return nil
}

对比

  • updateInventoryStatus(orderId, status=2) — 技术术语,无业务含义
  • stock.Lock(qty) — 业务术语,直接表达意图

1.3.7 聚合设计原则

聚合设计是 DDD 战术层面最难的部分。三条核心原则:

原则一:一个事务只修改一个聚合

// ❌ 反例:一个事务同时修改 Order 和 Inventory 两个聚合
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCmd) error {
    return s.txManager.RunInTx(ctx, func(tx *sql.Tx) error {
        order := domain.NewOrder(cmd.CustomerID)
        order.AddItem(cmd.ProductID, cmd.Qty)
        order.Place()
        s.orderRepo.SaveTx(tx, order)      // 修改 Order 聚合
        s.inventoryRepo.DeductTx(tx, cmd.ProductID, cmd.Qty) // ← 同时修改 Inventory 聚合
        return nil
    })
}

问题

  • 两个聚合在同一个事务中修改,导致锁粒度过大
  • Order 和 Inventory 强耦合,无法独立演进
  • 高并发场景下容易死锁
// ✅ 正例:通过领域事件实现跨聚合协作
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCmd) error {
    order := domain.NewOrder(cmd.CustomerID)
    order.AddItem(cmd.ProductID, cmd.Qty)
    events, err := order.Place()
    if err != nil {
        return err
    }
    
    // 只保存 Order 聚合
    if err := s.orderRepo.Save(ctx, order); err != nil {
        return err
    }
    
    // 发布事件,由 Inventory 服务异步消费
    s.eventBus.Publish(ctx, events...)
    return nil
}

// inventory 服务的事件处理器
func (h *InventoryEventHandler) OnOrderPlaced(ctx context.Context, e OrderPlacedEvent) error {
    return h.stock.Deduct(ctx, e.ProductID, e.Qty)
}

收益

  • Order 和 Inventory 解耦,可以独立扩展
  • 事务范围缩小,提高并发性能
  • 通过事件实现最终一致性

原则二:小聚合优于大聚合

维度小聚合大聚合
并发冲突低(锁粒度小)高(整个大聚合被锁)
内存占用小(按需加载)大(整棵树一次加载)
一致性范围单个核心不变量多个不变量混在一起
适用场景高并发写入强一致性要求的小规模数据

判断标准:如果两个实体之间没有需要在同一个事务中保护的业务不变量,就应该拆成两个聚合。

示例

// ❌ 大聚合:Order 聚合包含 Customer 的完整信息
type Order struct {
    id       OrderID
    customer *Customer  // 包含 Customer 的所有信息
    items    []OrderItem
}

// 问题:加载 Order 时被迫加载 Customer 的所有信息(地址、订单历史等)
// ✅ 小聚合:Order 只保存 CustomerID
type Order struct {
    id         OrderID
    customerID CustomerID  // 只存 ID,需要时按需查询
    items      []OrderItem
}

// 需要 Customer 信息时,通过 Repository 查询
func (uc *OrderDetailUseCase) GetOrderDetail(ctx context.Context, orderID string) (*OrderDetailDTO, error) {
    order, err := uc.orderRepo.FindByID(ctx, orderID)
    if err != nil {
        return nil, err
    }
    customer, err := uc.customerRepo.FindByID(ctx, order.CustomerID())
    if err != nil {
        return nil, err
    }
    return &OrderDetailDTO{
        OrderID:      string(order.ID()),
        CustomerName: customer.Name(),
        Items:        order.Items(),
    }, nil
}

原则三:通过 ID 引用其他聚合

// ❌ 聚合内直接持有另一个聚合的引用
type Order struct {
    customer *Customer  // 直接引用 → 加载 Order 时被迫加载 Customer
}

// ✅ 通过 ID 引用
type Order struct {
    customerID CustomerID  // 只存 ID,需要时按需查询
}

收益

  • 聚合边界清晰,加载 Order 不会加载 Customer
  • 聚合之间松耦合,可以独立演进
  • 减少内存占用和数据库 JOIN

1.3.8 Repository 与 Unit of Work 模式

Repository 是领域模型和持久化之间的桥梁,它提供了类似集合的接口来访问聚合。

标准 Repository 模式

// domain/order/repository.go — 领域层定义接口
package order

type Repository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id OrderID) (*Order, error)
    FindByCustomerID(ctx context.Context, customerID string, limit int) ([]*Order, error)
}

Repository 的职责

  • 提供类似集合的接口(Save, FindByID, Remove)
  • 隐藏底层存储细节(MySQL/MongoDB/Redis)
  • 保证聚合的完整加载和保存

Unit of Work 模式

标准 Repository 每个操作独立,但有时需要在一个事务中协调多个 Repository(例如保存聚合根 + 写 Outbox 表)。Unit of Work 模式解决这个问题:

// domain/uow.go — 领域层定义接口
package domain

type UnitOfWork interface {
    OrderRepo() order.Repository
    OutboxRepo() OutboxRepository
    Commit(ctx context.Context) error
    Rollback(ctx context.Context) error
}

// infrastructure/uow_impl.go — 基础设施层实现
package infrastructure

type mysqlUnitOfWork struct {
    tx         *sql.Tx
    orderRepo  *MySQLOrderRepo
    outboxRepo *MySQLOutboxRepo
}

func NewUnitOfWork(db *sql.DB) (domain.UnitOfWork, error) {
    tx, err := db.Begin()
    if err != nil {
        return nil, err
    }
    return &mysqlUnitOfWork{
        tx:         tx,
        orderRepo:  &MySQLOrderRepo{tx: tx},
        outboxRepo: &MySQLOutboxRepo{tx: tx},
    }, nil
}

func (u *mysqlUnitOfWork) OrderRepo() order.Repository  { return u.orderRepo }
func (u *mysqlUnitOfWork) OutboxRepo() OutboxRepository { return u.outboxRepo }
func (u *mysqlUnitOfWork) Commit(ctx context.Context) error   { return u.tx.Commit() }
func (u *mysqlUnitOfWork) Rollback(ctx context.Context) error { return u.tx.Rollback() }
// application/command/place_order.go — Use Case 使用 UoW
func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCmd) error {
    uow, err := h.uowFactory(ctx)
    if err != nil {
        return err
    }
    defer uow.Rollback(ctx)

    order := domain.NewOrder(cmd.CustomerID)
    events, err := order.Place()
    if err != nil {
        return err
    }

    // 同一个事务中保存聚合和 Outbox
    if err := uow.OrderRepo().Save(ctx, order); err != nil {
        return err
    }
    for _, e := range events {
        if err := uow.OutboxRepo().Save(ctx, toOutboxEntry(e)); err != nil {
            return err
        }
    }
    
    return uow.Commit(ctx)
}

1.3.9 领域事件异步化:Outbox Pattern

问题:保存聚合到数据库后,还要发送事件到 Kafka。这两个操作无法在一个事务中完成(双写问题)。如果先写 DB 再发 Kafka,发送失败则事件丢失;如果先发 Kafka 再写 DB,写 DB 失败则产生幽灵事件。

解法:Outbox Pattern——将事件写入本地数据库的 Outbox 表(与业务数据同一事务),再由独立的 Relay 进程异步发送到 Kafka。

sequenceDiagram
    participant App as Application
    participant DB as MySQL
    participant Relay as Outbox Relay
    participant MQ as Kafka

    App->>DB: BEGIN TX
    App->>DB: INSERT orders (聚合数据)
    App->>DB: INSERT outbox (领域事件)
    App->>DB: COMMIT TX
    
    loop 定期轮询
        Relay->>DB: SELECT * FROM outbox WHERE status='pending'
        Relay->>MQ: Publish(event)
        MQ-->>Relay: ACK
        Relay->>DB: UPDATE outbox SET status='sent'
    end

Outbox 表设计

CREATE TABLE outbox (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    event_type  VARCHAR(128) NOT NULL,
    event_key   VARCHAR(128) NOT NULL,
    payload     JSON NOT NULL,
    status      ENUM('pending', 'sent', 'failed') DEFAULT 'pending',
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    sent_at     TIMESTAMP NULL,
    retry_count INT DEFAULT 0,
    INDEX idx_status_created (status, created_at)
);

Relay 实现

package infrastructure

import (
    "context"
    "log/slog"
    "time"
)

type OutboxRelay struct {
    outboxRepo OutboxRepository
    producer   MessageProducer
}

func (r *OutboxRelay) Run(ctx context.Context) {
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            entries, err := r.outboxRepo.FetchPending(ctx, 100)
            if err != nil {
                slog.Error("fetch outbox failed", "error", err)
                continue
            }
            for _, entry := range entries {
                if err := r.producer.Publish(ctx, entry.EventType, entry.EventKey, entry.Payload); err != nil {
                    slog.Error("publish event failed", "id", entry.ID, "error", err)
                    r.outboxRepo.MarkFailed(ctx, entry.ID)
                    continue
                }
                r.outboxRepo.MarkSent(ctx, entry.ID)
            }
        }
    }
}

关键保证

  • At-least-once delivery:Relay 崩溃后重启会重新发送 pending 的事件,消费者必须做幂等处理
  • 顺序保证:按 created_at 顺序拉取,同一 event_key 的事件保持顺序
  • 死信处理retry_count > 5 的事件转入死信表,人工介入

1.4 CQRS(命令查询职责分离)

1.4.1 为什么要读写分离

CQRS 的逻辑非常直白:处理"改变数据"(Command)的逻辑和处理"读取数据"(Query)的逻辑应该完全分开

在复杂系统中,写的逻辑和读的需求往往是矛盾的

维度写(Command)读(Query)
关注点业务规则、校验、权限、事务跨表关联、全文搜索、分页排序
数据模型范式化(3NF),保证一致性反范式化(宽表),优化查询速度
性能目标保证正确性 > 速度保证速度 > 实时性
扩展方式垂直扩展(事务安全)水平扩展(读副本、缓存)
典型存储MySQL, PostgreSQLElasticsearch, Redis, ClickHouse

电商订单详情页的矛盾

用户打开订单详情页,需要展示:

  • 订单基本信息(订单号、状态、总价)
  • 商品信息(名称、图片、规格)
  • 价格明细(商品价、运费、优惠券)
  • 物流信息(快递公司、运单号、物流轨迹)
  • 售后信息(退款状态、退货进度)

如果用写模型(范式化)来查询:

-- ❌ 需要 JOIN 5-6 张表,性能差
SELECT o.*, oi.*, p.*, l.*, r.*
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
JOIN logistics l ON o.id = l.order_id
LEFT JOIN refunds r ON o.id = r.order_id
WHERE o.id = ?

如果用读模型(反范式化):

// ✅ 一个宽表或一个 ES 文档,性能极佳
{
  "order_id": "123",
  "status": "delivered",
  "total_price": 299.00,
  "items": [
    {"product_name": "iPhone", "image": "...", "price": 299.00}
  ],
  "logistics": {"company": "SF Express", "tracking_no": "SF123"},
  "refund": null
}

1.4.2 架构全景

flowchart LR
    subgraph 写路径 Command Side
        A[Client] -->|Command| B[Command Handler]
        B --> C[Domain Model / Aggregate]
        C --> D[(Write DB - MySQL)]
        C -->|Domain Event| E[Event Bus]
    end

    subgraph 读路径 Query Side
        E -->|同步/异步投影| F[Read Model Builder]
        F --> G[(Read DB - ES/Redis)]
        H[Client] -->|Query| I[Query Handler]
        I --> G
    end

关键理解

  • Command Side(写路径):走领域模型,保证业务规则,数据存储在 MySQL
  • Query Side(读路径):绕过领域模型,直接从优化的读库(ES/Redis)返回 DTO
  • 同步机制:通过领域事件 + 投影器(Projector)将写模型同步到读模型

1.4.3 Command 与 Query 的设计

Command — 表达意图,不返回业务数据

// application/command/place_order.go
package command

type PlaceOrderCommand struct {
    CustomerID string
    Items      []OrderItemDTO
}

type CommandResult struct {
    Success bool
    ID      string
    Error   error
}

type PlaceOrderHandler struct {
    orderRepo order.Repository
    eventBus  EventBus
}

func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) CommandResult {
    order := domain.NewOrder(cmd.CustomerID)
    for _, item := range cmd.Items {
        if err := order.AddItem(item.ProductID, item.Qty); err != nil {
            return CommandResult{Error: err}
        }
    }
    
    events, err := order.Place()
    if err != nil {
        return CommandResult{Error: err}
    }
    
    if err := h.orderRepo.Save(ctx, order); err != nil {
        return CommandResult{Error: err}
    }
    
    h.eventBus.Publish(ctx, events...)
    return CommandResult{Success: true, ID: string(order.ID())}
}

Command 的特点

  • 表达用户的意图(下单、取消、退款)
  • 走领域模型,执行业务规则
  • 只返回操作结果(成功/失败 + ID),不返回完整的业务数据

Query — 直接返回展示层需要的 DTO

// application/query/order_detail.go
package query

type OrderDetailQuery struct {
    OrderID string
}

type OrderDetailDTO struct {
    OrderID      string    `json:"order_id"`
    CustomerName string    `json:"customer_name"`
    Items        []ItemDTO `json:"items"`
    TotalPrice   string    `json:"total_price"`
    Status       string    `json:"status"`
    CreatedAt    string    `json:"created_at"`
}

type OrderDetailHandler struct {
    readDB ReadModelRepository
}

func (h *OrderDetailHandler) Handle(ctx context.Context, q OrderDetailQuery) (*OrderDetailDTO, error) {
    // 绕过领域模型,直接从读库获取
    return h.readDB.FindOrderDetail(ctx, q.OrderID)
}

Query 的特点

  • 绕过领域模型,不触发任何业务逻辑
  • 直接从优化的读库(ES/Redis/宽表)返回 DTO
  • 数据结构完全匹配前端需求,减少转换

1.4.4 核心价值

极致的性能优化。你可以针对写操作使用关系型数据库(保证强一致性),针对读操作使用 Elasticsearch 或 Redis(保证高并发)。读写模型可以独立扩展、独立优化

电商系统的实际案例

场景写模型(MySQL)读模型(Elasticsearch)
下单保证事务一致性,写入订单表不涉及
订单详情页不涉及从 ES 读取宽表(包含商品、物流、售后)
订单列表不涉及从 ES 搜索(支持筛选、排序、分页)
数据分析不涉及从 ClickHouse 读取(OLAP)

1.4.5 Event Sourcing:事件溯源

Event Sourcing 经常和 CQRS 一起被提及,但它们是独立的概念,可以单独使用,也可以组合使用。

核心思想

传统方式存储的是当前状态(state),Event Sourcing 存储的是导致状态变化的事件序列(events)。当前状态通过重放事件计算得出。

传统方式:
  orders 表: {id: 1, status: "paid", total: 200, updated_at: "2026-04-07"}

Event Sourcing:
  events 表:
    {seq: 1, type: "OrderCreated",  data: {id: 1, customer: "alice"}}
    {seq: 2, type: "ItemAdded",     data: {product: "shoe", price: 100, qty: 2}}
    {seq: 3, type: "OrderPlaced",   data: {total: 200}}
    {seq: 4, type: "PaymentReceived", data: {amount: 200, method: "credit_card"}}

与 CQRS 的关系

graph LR
    A[CQRS] --- B[可以独立使用]
    C[Event Sourcing] --- B
    A --- D[组合使用效果最佳]
    C --- D
    D --> E[写侧用事件存储<br/>读侧用物化视图]
  • 只用 CQRS 不用 ES:写侧用普通数据库,读侧用独立的读模型。最常见的方式。
  • 只用 ES 不用 CQRS:事件存储 + 重放计算状态,读写用同一个模型。适合审计场景。
  • CQRS + ES:写侧用事件存储,读侧通过投影事件构建物化视图。适合金融、交易系统。

适用与不适用场景

适用不适用
需要完整审计追踪(金融、合规)简单 CRUD 应用
需要时间旅行/回放(调试、分析)高频更新的状态(计数器、在线人数)
事件本身有业务价值数据模型频繁变更
需要撤销/补偿操作团队对 ES 没有经验且交期紧

本书立场:电商系统大部分场景只用 CQRS 不用 Event Sourcing。Event Sourcing 适合金融、合规等场景,但对于电商的商品、订单等模块,增加的复杂度超过了收益。

1.4.6 最终一致性处理策略

引入 CQRS 后,写模型和读模型之间存在延迟(通常毫秒到秒级)。这需要在架构层面和用户体验层面同时处理。

架构层面

策略一:幂等消费

投影器可能收到重复事件(at-least-once delivery),必须做幂等处理:

func (p *OrderProjector) Project(ctx context.Context, event DomainEvent) error {
    // 幂等性检查:事件是否已处理
    exists, err := p.readDB.EventProcessed(ctx, event.ID())
    if err != nil {
        return err
    }
    if exists {
        return nil // 已处理过,跳过
    }

    // 处理事件
    switch e := event.(type) {
    case OrderPlacedEvent:
        dto := OrderDetailDTO{
            OrderID:    string(e.OrderID),
            Status:     "placed",
            TotalPrice: e.Total.String(),
            CreatedAt:  e.At.Format(time.RFC3339),
        }
        if err := p.readDB.Upsert(ctx, dto); err != nil {
            return err
        }
    }
    
    // 标记事件已处理
    return p.readDB.MarkEventProcessed(ctx, event.ID())
}

策略二:补偿事务(Saga)

当跨服务操作中某一步失败,通过发布补偿事件回滚前面的步骤:

正向流程:CreateOrder → ReserveStock → ChargePayment
补偿流程:                ReleaseStock ← RefundPayment ← PaymentFailed

我们会在第6章详细讲解 Saga 模式。

用户体验层面

Optimistic UI(乐观更新):前端在发送 Command 后立即更新 UI,不等待读模型同步。

用户点击"下单" 
  → 前端立即显示"订单已创建"(乐观更新)
  → 后端 Command 异步处理
  → 读模型延迟 200ms 后更新
  → 用户下次刷新时看到真实状态

Read-your-writes:Command 成功后返回版本号,Query 时带上版本号,确保读到的是自己写入之后的数据。

// Command 返回版本号
type CommandResult struct {
    Success bool
    ID      string
    Version int64  // 版本号
}

// Query 时带上版本号
type OrderDetailQuery struct {
    OrderID         string
    MinVersion      int64  // 期望读到的最小版本
}

// Query Handler 检查版本
func (h *OrderDetailHandler) Handle(ctx context.Context, q OrderDetailQuery) (*OrderDetailDTO, error) {
    dto, err := h.readDB.FindOrderDetail(ctx, q.OrderID)
    if err != nil {
        return nil, err
    }
    // 如果读模型版本低于期望版本,返回错误或等待
    if dto.Version < q.MinVersion {
        return nil, ErrReadModelNotReady
    }
    return dto, nil
}

1.4.7 投影器(Projector)实现模式

投影器是 CQRS 架构中将领域事件转化为读模型的组件。

flowchart LR
    A[Event Store / MQ] --> B[Projector]
    B --> C[(Read DB)]
    
    B --> D{事件类型路由}
    D -->|OrderPlaced| E[创建订单读模型]
    D -->|ItemAdded| F[更新商品明细]
    D -->|OrderCancelled| G[标记订单取消]

完整实现

// adapter/projection/projector.go
package projection

type Projector interface {
    Handles() []string // 返回该 Projector 关心的事件类型列表
    Project(ctx context.Context, event DomainEvent) error
}

type OrderReadModelProjector struct {
    readDB ReadModelRepository
}

func (p *OrderReadModelProjector) Handles() []string {
    return []string{"OrderPlaced", "OrderCancelled", "ItemAdded", "PaymentCompleted"}
}

func (p *OrderReadModelProjector) Project(ctx context.Context, event DomainEvent) error {
    switch e := event.(type) {
    case OrderPlacedEvent:
        return p.readDB.Upsert(ctx, OrderReadModel{
            OrderID:   string(e.OrderID),
            Status:    "placed",
            Total:     e.Total.Amount,
            Currency:  e.Total.Currency,
            CreatedAt: e.At,
        })
    case OrderCancelledEvent:
        return p.readDB.UpdateStatus(ctx, string(e.OrderID), "cancelled")
    case PaymentCompletedEvent:
        return p.readDB.UpdateStatus(ctx, string(e.OrderID), "paid")
    default:
        return nil
    }
}

投影器的运行模式

模式机制延迟适用场景
同步投影Command Handler 执行完后同步调用 Projector零延迟读写在同一进程、低吞吐
异步投影事件通过 MQ 传递,Projector 独立消费毫秒~秒级高吞吐、读写分离部署
Catch-up 投影Projector 从事件存储按序号拉取事件可控重建读模型、新增投影视图

电商系统推荐异步投影,理由:

  • 读写可以独立扩展(写用MySQL,读用ES)
  • 故障隔离(读模型崩溃不影响写操作)
  • 性能最优(异步处理不阻塞写操作)

1.5 三者的协作关系

在现代大型微服务或复杂单体中,Clean Architecture、DDD 和 CQRS 通常是这样组合的:

1.5.1 协作关系图

graph TB
    subgraph "Clean Architecture 提供分层骨架"
        direction TB
        E[Entity Layer]
        U[Use Case Layer]
        A[Adapter Layer]
        F[Framework Layer]
        F --> A --> U --> E
    end

    subgraph "DDD 填充业务建模"
        direction TB
        AG[Aggregate Root]
        VO[Value Object]
        DE[Domain Event]
        DS[Domain Service]
    end

    subgraph "CQRS 优化数据流转"
        direction TB
        CMD[Command Path]
        QRY[Query Path]
    end

    E --- AG
    E --- VO
    U --- CMD
    U --- QRY
    U --- DE
    U --- DS
角色职责
Clean Architecture(架构底座)定义目录结构和依赖方向,确保领域层位于中心,不依赖外部技术
DDD(核心建模)在 Entity 和 Use Cases 层中,利用聚合根、实体和领域服务编写复杂的业务逻辑
CQRS(数据流转)在 Use Cases 层进行读写拆分:写操作走 DDD 的领域模型(Command),读操作绕过复杂的领域模型,直接通过 DTO 投影(Query)到前端

1.5.2 在 Go 项目中的落地结构

myapp/
├── cmd/
│   └── server/main.go              # 启动入口 & 依赖注入
│
├── domain/                          # ← Clean Arch: Entity 层
│   ├── order/                       # ← DDD: Order 聚合
│   │   ├── order.go                 #   聚合根
│   │   ├── order_item.go            #   实体
│   │   ├── money.go                 #   值对象
│   │   ├── events.go                #   领域事件
│   │   └── repository.go           #   仓储接口(Port)
│   └── inventory/                   # ← DDD: Inventory 聚合
│       ├── stock.go
│       └── repository.go
│
├── application/                     # ← Clean Arch: Use Case 层
│   ├── command/                     # ← CQRS: 写路径
│   │   ├── place_order.go
│   │   └── cancel_order.go
│   └── query/                       # ← CQRS: 读路径
│       ├── order_detail.go
│       └── order_list.go
│
├── adapter/                         # ← Clean Arch: Interface Adapter 层
│   ├── inbound/
│   │   ├── http/                    #   HTTP handler
│   │   └── grpc/                    #   gRPC handler
│   ├── outbound/
│   │   ├── persistence/             #   Write DB 实现
│   │   ├── readmodel/               #   Read DB 实现
│   │   └── messaging/               #   Event Bus 实现
│   └── projection/                  #   事件 → 读模型的投影器
│
└── infra/                           # ← Clean Arch: Frameworks & Drivers 层
    ├── mysql/
    ├── elasticsearch/
    ├── redis/
    └── kafka/

1.5.3 数据流全景

sequenceDiagram
    participant C as Client
    participant H as HTTP Handler<br/>(Adapter)
    participant CMD as Command Handler<br/>(Use Case)
    participant AGG as Aggregate Root<br/>(Domain)
    participant WDB as Write DB<br/>(MySQL)
    participant EB as Event Bus<br/>(Kafka)
    participant PRJ as Projector<br/>(Adapter)
    participant RDB as Read DB<br/>(ES/Redis)
    participant QRY as Query Handler<br/>(Use Case)

    Note over C,QRY: ── 写路径(Command)──
    C->>H: POST /orders
    H->>CMD: PlaceOrderCommand
    CMD->>AGG: NewOrder() + AddItem() + Place()
    AGG-->>CMD: DomainEvents
    CMD->>WDB: Save(order)
    CMD->>EB: Publish(OrderPlacedEvent)

    Note over C,QRY: ── 读路径(Query)──
    EB->>PRJ: OrderPlacedEvent
    PRJ->>RDB: Upsert 读模型 (宽表/索引)

    C->>H: GET /orders/{id}
    H->>QRY: OrderDetailQuery
    QRY->>RDB: 直接查询读模型
    RDB-->>QRY: OrderDetailDTO
    QRY-->>H: DTO
    H-->>C: JSON Response

1.5.4 完整链路 Walk-through:下单请求

以一个电商"下单"请求为例,完整走一遍三件套协作的全链路。每一步标注所属的架构层概念

步骤 ① [Adapter 层 / Inbound] HTTP Handler 接收请求

// adapter/inbound/http/order_handler.go
package http

import (
    "myapp/application/command"
    "github.com/gin-gonic/gin"
)

type OrderHandler struct {
    placeOrderHandler *command.PlaceOrderHandler
}

func (h *OrderHandler) PlaceOrder(c *gin.Context) {
    var req PlaceOrderRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    
    // 转换为 Command(DTO → Command)
    cmd := command.PlaceOrderCommand{
        CustomerID: req.CustomerID,
        Items:      toCommandItems(req.Items),
    }
    
    result := h.placeOrderHandler.Handle(c.Request.Context(), cmd)
    if result.Error != nil {
        c.JSON(500, gin.H{"error": result.Error.Error()})
        return
    }
    
    c.JSON(201, gin.H{"order_id": result.ID})
}

步骤 ② [Application 层 / CQRS Command Path] Command Handler 编排业务流程

// application/command/place_order.go
package command

import (
    "context"
    "myapp/domain"
    "myapp/domain/order"
)

type PlaceOrderHandler struct {
    uowFactory   func(context.Context) (domain.UnitOfWork, error)
    productReader ProductReader
}

func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) CommandResult {
    // 创建 UoW(事务边界)
    uow, err := h.uowFactory(ctx)
    if err != nil {
        return CommandResult{Error: err}
    }
    defer uow.Rollback(ctx)

    // ③ [Domain 层 / DDD Aggregate] 操作聚合根
    o := order.NewOrder(order.CustomerID(cmd.CustomerID))
    for _, item := range cmd.Items {
        product, err := h.productReader.GetByID(ctx, item.ProductID)
        if err != nil {
            return CommandResult{Error: err}
        }
        if err := o.AddItem(product, item.Qty); err != nil {
            return CommandResult{Error: err}
        }
    }
    
    events, err := o.Place() // 聚合根返回领域事件
    if err != nil {
        return CommandResult{Error: err}
    }

    // ④ [Adapter 层 / Outbound] 持久化聚合 + Outbox
    if err := uow.OrderRepo().Save(ctx, o); err != nil {
        return CommandResult{Error: err}
    }
    for _, e := range events {
        if err := uow.OutboxRepo().Save(ctx, toOutboxEntry(e)); err != nil {
            return CommandResult{Error: err}
        }
    }
    if err := uow.Commit(ctx); err != nil {
        return CommandResult{Error: err}
    }

    return CommandResult{Success: true, ID: string(o.ID())}
}

步骤 ⑤ [Adapter 层 / Projection] Outbox Relay 发送事件 → Projector 更新读模型

// adapter/projection/order_projector.go
package projection

func (p *OrderProjector) Project(ctx context.Context, event DomainEvent) error {
    switch e := event.(type) {
    case domain.OrderPlacedEvent:
        return p.readDB.Upsert(ctx, ReadOrderModel{
            OrderID:      string(e.OrderID),
            CustomerName: p.customerName(ctx, e.CustomerID),
            Items:        p.buildItemList(ctx, e.Items),
            TotalPrice:   e.Total.String(),
            Status:       "placed",
            CreatedAt:    e.At,
        })
    }
    return nil
}

步骤 ⑥ [Application 层 / CQRS Query Path] 读请求绕过领域模型

// application/query/order_detail.go
package query

type OrderDetailHandler struct {
    readDB ReadModelRepository
}

func (h *OrderDetailHandler) Handle(ctx context.Context, q OrderDetailQuery) (*OrderDetailDTO, error) {
    // 直接从读库返回 DTO,绕过领域模型
    return h.readDB.FindByOrderID(ctx, q.OrderID)
}

全链路概览表

步骤架构层概念代码位置
① 接收 HTTP 请求Adapter (Inbound)-handler/order_handler.go
② 编排业务流程ApplicationCQRS Commandcommand/place_order.go
③ 操作聚合根DomainDDD Aggregatedomain/order/order.go
④ 持久化 + OutboxAdapter (Outbound)Outbox Patternpersistence/mysql_order_repo.go
⑤ 投影到读模型Adapter (Projection)CQRS Projectorprojection/order_projector.go
⑥ 读请求直查ApplicationCQRS Queryquery/order_detail.go

1.6 常见误区与最佳实践

1.6.1 常见误区澄清

误区澄清
"用了 DDD 就必须用 CQRS"两者独立,简单 CRUD 场景用 DDD 不需要 CQRS
"CQRS 等于 Event Sourcing"Event Sourcing 是可选的,CQRS 可以只做读写模型分离
"Clean Architecture = 洋葱架构 = 六边形架构"思想相似但不完全等同,核心都是依赖反转
"所有项目都应该用这三件套"简单的 CRUD 应用用这套是过度设计
"DDD 就是 Entity + Repository"战略设计(Bounded Context 划分)比战术设计更重要

1.6.2 何时采用三件套

flowchart TD
    A[项目复杂度评估] --> B{业务逻辑是否复杂?}
    B -->|简单 CRUD| C[标准三层架构即可]
    B -->|中等复杂度| D[Clean Architecture]
    B -->|高复杂度| E{读写比例差异大?}
    E -->|是| F[Clean Architecture + DDD + CQRS]
    E -->|否| G[Clean Architecture + DDD]

适用场景(适合上三件套):

  • 业务规则复杂且频繁变化(电商、金融、保险)
  • 读写比例悬殊(读:写 > 10:1)
  • 多团队协作,需要清晰的 Bounded Context 边界
  • 需要针对读写使用不同存储引擎

不适用场景:

  • 简单的管理后台 / CRUD 应用
  • 原型验证(MVP)阶段
  • 团队缺乏 DDD 经验且没有时间学习

1.6.3 过度设计的识别方法

在实际项目中,过度设计设计不足更常见。以下是几个危险信号:

信号说明应该怎么做
聚合根只有 CRUD 操作没有真正的业务不变量需要保护回退到简单的 Service + Repository
读模型和写模型完全一样没有读写分离的必要去掉 CQRS,用同一个模型
Bounded Context 只有一个实体过度拆分,上下文太小合并到相邻上下文
领域事件没有消费者为了 DDD 而 DDD去掉事件,直接方法调用
接口只有一个实现除非是为了测试或已知的未来扩展考虑直接使用具体类型

经验法则:如果你花在架构上的时间超过了写业务逻辑的时间,大概率过度设计了。

1.6.4 团队能力评估

引入架构方法论是一项投资,需要评估团队的准备程度:

flowchart TD
    A[团队评估] --> B{是否有 DDD 经验的成员?}
    B -->|有| C{项目周期是否允许学习成本?}
    B -->|没有| D[从 Clean Architecture 开始<br/>积累经验后再引入 DDD]
    C -->|允许| E[可以全套引入<br/>但需要架构师持续指导]
    C -->|紧急| F[先用 Clean Architecture<br/>后续迭代引入 DDD]

建议

  • 如果团队完全没有 DDD 经验,先从 Clean Architecture 开始
  • 有 1-2 名有经验的架构师,可以全套引入,但需要持续指导
  • 项目周期紧张,可以先用 Clean Architecture,后续迭代引入 DDD

1.7 渐进式采用指南

三件套不需要一步到位。从最简单的三层架构出发,在痛点出现时逐步演进。

1.7.1 阶段 0:标准三层架构

触发条件:项目启动,业务简单明确

myapp/
├── handler/        # 表现层
│   └── order.go
├── service/        # 业务逻辑层
│   └── order.go
├── repository/     # 数据访问层
│   └── order.go
└── main.go
// service/order.go — 典型的三层架构
type OrderService struct {
    repo *repository.OrderRepository  // 直接依赖具体实现
    db   *sql.DB
}

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) (*Order, error) {
    order := &Order{
        CustomerID: req.CustomerID, 
        Items:      req.Items,
    }
    order.Total = s.calculateTotal(order.Items)
    return s.repo.Save(ctx, order)
}

问题浮现:当你想从 MySQL 换到 PostgreSQL 时,发现 OrderService 到处都是 *sql.DB 和 MySQL 特有的语法。

1.7.2 阶段 1:引入 Clean Architecture

触发条件:需要更换数据库/框架,或需要编写不依赖基础设施的单元测试

改造要点:引入接口层,依赖方向反转

myapp/
├── domain/
│   ├── order.go         # 实体 + 业务规则
│   └── repository.go    # 接口定义(Port)
├── usecase/
│   └── create_order.go  # 应用逻辑
├── adapter/
│   ├── handler/
│   └── persistence/     # 接口实现
└── main.go              # 依赖注入
// domain/repository.go — 内层定义接口
package domain

type OrderRepository interface {
    Save(ctx context.Context, order *Order) error
}

// usecase/create_order.go — 依赖接口而非实现
type CreateOrderUseCase struct {
    repo domain.OrderRepository  // 依赖抽象
}

func (uc *CreateOrderUseCase) Execute(ctx context.Context, req CreateOrderRequest) error {
    order := domain.NewOrder(req.CustomerID)
    // ... 业务逻辑 ...
    return uc.repo.Save(ctx, order)
}

收益CreateOrderUseCase 可以用 Mock Repository 做单元测试,不需要启动数据库。

1.7.3 阶段 2:引入 DDD

触发条件:业务规则越来越复杂,Service 层开始膨胀,同一个概念在不同模块有不同含义

改造要点:识别聚合根、值对象、领域事件

// 阶段 1 的 "贫血模型"
type Order struct {
    ID     string
    Status int     // 用魔数表示状态
    Total  float64 // 用 float 表示金额
}

// 阶段 2 的 "充血模型"
type Order struct {
    id     OrderID
    status OrderStatus   // 值对象,枚举约束
    total  Money         // 值对象,精度安全
    items  []OrderItem
}

func (o *Order) Place() ([]DomainEvent, error) {
    if len(o.items) == 0 {
        return nil, ErrEmptyOrder  // 聚合根保护不变量
    }
    o.status = OrderStatusPlaced
    return []DomainEvent{OrderPlacedEvent{...}}, nil
}

收益:业务规则内聚在聚合根中,不再散落在 Service 层。新成员阅读 Order.Place() 就能理解下单的所有约束。

1.7.4 阶段 3:引入 CQRS

触发条件:读写性能矛盾突出(读 QPS 远大于写,或读需要跨聚合的宽表查询)

改造要点:分离 Command/Query Handler,引入独立读模型

application/
├── command/              # 写路径 → 走领域模型
│   └── place_order.go
└── query/                # 读路径 → 直查读库
    └── order_detail.go

adapter/outbound/
├── persistence/          # Write DB (MySQL)
├── readmodel/            # Read DB (ES/Redis)
└── projection/           # Event → Read Model

收益:写操作保证事务一致性,读操作针对查询优化。两者可以独立扩展

1.7.5 演进决策树

flowchart TD
    A[当前是三层架构] --> B{测试困难?<br/>换存储/框架?}
    B -->|是| C[阶段 1: Clean Architecture]
    B -->|否| A
    C --> D{业务规则复杂?<br/>Service 层膨胀?}
    D -->|是| E[阶段 2: + DDD]
    D -->|否| C
    E --> F{读写矛盾?<br/>查询需要宽表?}
    F -->|是| G[阶段 3: + CQRS]
    F -->|否| E

关键原则:每次只前进一步,在当前阶段的痛点确实出现后再演进。过早引入会带来不必要的复杂性。


1.8 本章小结

核心要点回顾

本章介绍了三个架构方法论,并讲解了它们如何协作:

Clean Architecture(整洁架构)

  • 核心:依赖方向向内,业务逻辑独立于技术
  • 价值:换框架、换数据库不影响业务逻辑
  • 实践:接口在内层定义,实现在外层提供

DDD(领域驱动设计)

  • 战略设计:领域分层(核心域/支撑域/通用域)、限界上下文、上下文映射
  • 战术设计:聚合根、实体、值对象、领域事件、领域服务
  • 价值:代码反映业务,应对复杂性

CQRS(命令查询职责分离)

  • 核心:读写分离,独立优化
  • 价值:写保证一致性,读优化性能
  • 实践:Command 走领域模型,Query 直查读库

三者协作

  • Clean Architecture 提供分层骨架
  • DDD 填充业务建模
  • CQRS 优化数据流转

与后续章节的关系

本章建立了全书的方法论基础。在后续的章节中,你会看到:

  • 第5章:电商系统全景图 → 应用 DDD 的领域分层
  • 第7-15章:各个核心系统 → 应用 Clean Architecture 的分层结构
  • 第6章、14章、15章:系统集成 → 应用 CQRS 和领域事件
  • 第16章:B2B2C 平台完整架构 → 三件套的综合应用

延伸阅读

  • Robert C. Martin, Clean Architecture: A Craftsman's Guide to Software Structure and Design, 2017
  • Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, 2003
  • Vaughn Vernon, Implementing Domain-Driven Design, 2013
  • Martin Fowler, CQRS Pattern

下一章第2章 领域驱动设计战略篇 将深入讲解 DDD 的战略设计,包括如何识别限界上下文、如何建立通用语言、如何进行上下文映射。这些战略设计的决策,将直接影响电商系统的整体架构。


导航返回目录 | 书籍主页 | 下一章:第2章

导航书籍主页 | 完整目录 | 上一章 | 下一章:第3章(即将发布)


第2章 领域驱动设计战略篇

限界上下文、通用语言与上下文映射——在写聚合之前先画地图


2.1 为什么需要战略设计

第 1 章从 Clean ArchitectureDDDCQRS 的协作关系出发,已经用「三位一体」搭好了工程骨架,并简要触及了限界上下文、上下文映射与通用语言。本章不再重复分层目录、Outbox 或读写分离的细节,而是把镜头拉近到 DDD 的战略设计:它回答的是「边界在哪里、团队如何协作、概念如何对齐」——这些问题若未澄清,战术层的聚合与仓储很容易变成「漂亮的样板代码」,却无法降低沟通与演进成本。

2.1.1 战术先行常见症状

许多团队第一次接触 DDD 时,会直接从「实体 / 聚合 / 仓储」入手,短期内代码结构变整齐,但很快遇到以下矛盾:

  • 同名不同义:产品口中的「商品」指前台可售的 SKU;库存同学口中的「商品」是可售量与仓位的组合;订单里的「商品」又是下单快照。没有上下文边界时,一个 Product 结构体会被迫承载三套语义。典型症状是代码中出现 Product.InventoryQty(库存关注)和 Product.DisplayTitle(商品关注)混杂在同一个类型中,任何修改都需要跨团队协调,变更成本高昂。

  • 跨团队改同一张表:订单服务为了赶需求直接更新库存表,或营销脚本回写订单金额字段。短期省事,长期让「谁拥有这条数据」变得模糊。某次故障中,订单团队修改了库存表的索引,导致库存域的查询性能暴跌;两边互相推诿责任,最终花了一周才定位问题。这种「绕过契约直连数据库」的路径,是边界模糊的最大隐患。

  • 集成靠私下约定:RPC 参数、Kafka Topic、回调字段在口口相传中演进,缺少显式的上下游关系与防腐策略。某次营销域修改了优惠券事件的字段名(从 coupon_id 改为 couponCode),订单域的消费者直接报错,才发现双方没有契约测试与版本策略。线上故障持续了 2 小时,影响了数万订单。

真实案例:某团队在引入 DDD 后,代码中充满了精美的聚合、仓储、领域服务,但每次跨服务需求都需要「拉群对齐」,因为没有明确的上下文地图。产品经理提需求时说「改一下商品价格显示逻辑」,三个团队(商品、订单、营销)都认为这是自己的职责,最终在会议室吵了一下午才确定归属。

战略设计的目标,是把上述隐性知识变成可评审的工件:上下文地图、术语表、子域投资优先级与集成模式(客户-供应商、防腐层等)。战术设计再在这些边界之内展开。有了清晰的上下文映射,「改商品价格显示」的需求可以在 10 分钟内定位到正确的上下文(计价域),而不是三方扯皮。

关键认知:战略设计不是「画图玩」,而是团队协作的操作系统。当产品提需求、技术做设计、代码做评审时,都参考同一张上下文地图、同一份术语表,沟通成本会显著降低。没有战略设计的团队,每个需求都要重新「对齐理解」;有战略设计的团队,大部分对齐工作已经提前完成。

2.1.2 战略设计与第 1 章的分工

主题第 1 章侧重本章侧重
限界上下文与分层、CQRS 并列介绍概念识别方法、划分原则、电商多域对照
上下文映射模式列表与示意关系选型、Go 侧接口与适配器落地
通用语言命名对照示例工作坊流程、术语治理与演进
子域分类核心 / 支撑 / 通用与评分投资策略、资源配比与常见误判

读完本章,你应能用一页纸向团队说明:我们有哪些限界上下文、各自语言是什么、之间用哪种映射集成、哪几块值得重仓投入

实践建议:战略设计不必一次性做到完美。可以先用一次 2 小时的工作坊识别出 3-5 个核心上下文,画出简单的映射图,建立初版术语表(20-30 个核心术语)。在后续迭代中,根据实际协作痛点逐步细化边界、补充术语、调整映射关系。重要的是让战略设计的产物(上下文地图、术语表、集成契约)成为团队评审与决策的依据,而不是藏在某个架构师的脑子里。


2.2 限界上下文(Bounded Context)

限界上下文是模型的显式边界:在边界之内,术语含义稳定、规则可推敲;跨边界则允许同名不同义,但必须通过契约(API、事件、发布语言)连接。

2.2.1 识别限界上下文

识别不是一次性「微服务切分」,而是对业务能力与协作现实的建模。可组合使用以下线索:

  1. 业务能力:下单、收款、发货、圈品投放通常是不同能力,各自有独立生命周期。例如「订单履约」是一个完整的业务能力,包含订单创建、支付确认、发货、收货等完整流程,这些步骤紧密耦合,应该归属同一个上下文。而「商品展示」则是另一个独立能力,包含商品上架、搜索、详情页等,两者可以独立演进。

  2. 语言边界:当同一个词在两处讨论时含义开始分叉,往往意味着边界临近。例如「锁库」在订单侧可能是预留,在库存侧可能是可售量扣减。再比如「价格」,在商品域是「标价」,在计价域是「试算结果」,在订单域是「合同金额」,在支付域是「实付金额」——四个上下文中的「价格」含义完全不同,需要明确划分。

  3. 一致性边界:需要同事务维护的不变量,通常落在同一上下文内;可接受最终一致的协作,适合跨上下文用事件衔接。例如订单总价必须等于各明细之和(强一致),适合在订单上下文内用聚合保证;而库存扣减后通知搜索索引更新(可接受秒级延迟),适合跨上下文用事件。

  4. 团队与发布节奏(康威定律):若两个模块永远由同一小队同节奏发布,拆成两个部署单元的紧迫性要重新评估;反之则倾向清晰上下文与契约。例如订单域和支付域虽然业务相关,但由不同团队维护、发布节奏独立、技术栈不同(订单用 Go,支付用 Java),应该拆分为独立上下文,通过清晰的 API 与事件集成。

电商示例(订单链路)

graph TB
    subgraph 订单上下文
        O[Order 聚合<br/>生命周期 / 金额快照]
    end
    subgraph 商品上下文
        P[Catalog / SKU<br/>上架与展示属性]
    end
    subgraph 库存上下文
        I[Stock / Reservation<br/>可售 / 预留 / 扣减]
    end
    subgraph 营销上下文
        M[Promotion / Coupon<br/>规则与资格]
    end
    O -->|下单前询价 / 快照| P
    O -->|预留 / 释放| I
    O -->|试算 / 核销| M

识别要点:每个上下文有清晰的核心职责与生命周期——订单管理订单状态流转,商品管理可售商品信息,库存管理可售量与预占,营销管理优惠规则。它们通过定义明确的接口与事件协作,而不是共享同一个「大而全」的模型。

2.2.2 上下文边界的划分原则

  1. 优先保护不变量:订单总价与明细一致、库存不为负等,各自应在所属上下文的聚合内守护,而不是靠分布式事务「一把梭」。例如订单聚合在 AddLine 方法中同步更新 TotalAmount,保证总价与明细一致性;而库存扣减与订单创建分属两个上下文,通过事件最终一致。

  2. 拒绝共享大模型:不要把「全局统一 Product」当作目标;不同上下文各自建模,用 ID 与快照连接。订单上下文的 OrderLine 包含商品快照(标题、单价),商品上下文的 Product 包含展示信息(详情、图片),两者模型不同但通过 ProductID 关联。

  3. 数据所有权清晰:每个业务表有唯一写入方;其他上下文只通过 API 或事件消费。库存表只能由库存服务写入,订单服务需要库存数据时通过 GetStock API 查询,而不是直接读库存表。

  4. 映射显式化:同步调用、异步事件、批量对账的选择应写进架构说明,而不是隐含在代码路径里。例如在上下文映射图中明确标注「订单 → 库存:同步预占 API + 超时释放事件」。

拆分决策树(可与团队工作坊共用):

flowchart TD
    A[是否存在稳定业务边界?]
    A -->|是| B[候选独立上下文]
    A -->|否| C{一致性要求是否冲突?}
    C -->|是| D[倾向拆分并定义集成]
    C -->|否| E{是否不同团队 / 发布节奏?}
    E -->|是| F[拆分 + 强化契约与监控]
    E -->|否| G[模块化单体中先划清包边界]

使用建议:这个决策树适合在「是否拆分」的争议中使用。当团队对某个功能模块是否应该独立成服务有分歧时,逐个回答上述问题,多数情况下能达成共识。关键是不要为了拆而拆——模块化单体同样可以有清晰的限界上下文,只是物理部署在同一个进程中。等到团队规模、发布节奏确实需要独立时,再升级为独立服务。

2.2.3 电商案例:订单域、商品域、营销域

以下表格用于对齐职责与对外能力(示例命名可按团队语言替换):

限界上下文核心关注点典型聚合(示例)对其他上下文承诺的能力
订单域订单生命周期、应付金额、状态机OrderOrderLinePlaceOrderCancelOrder、领域事件 OrderPlaced / OrderPaid
商品域可售商品、类目属性、媒体素材ProductSKU批量查询基础信息、按 SKU 返回标题与规格
营销域券、活动、互斥叠加规则CouponCampaignDiscountRulePreviewPromotionCommitPromotionHold

实战案例:边界划分的决策过程

某电商平台早期把计价能力分散在订单、营销、商品三个域:订单域计算小计,营销域计算优惠,商品域返回基础价。随着业务复杂度上升,出现多处问题:

症状

  • 购物车、订单创建、支付确认三处的价格计算逻辑不一致
  • 营销规则变更需要同步修改订单与商品的计算代码
  • 无法支持「PDP 加购试算」场景,因为没有统一的计价入口

重构决策

  1. 识别核心能力:「给定商品清单、营销规则、用户身份,计算出各层级价格」是一个完整的业务能力
  2. 独立上下文:新建计价上下文,职责是提供统一的试算接口,收敛所有价格计算逻辑
  3. 定义边界
    • 计价上下文不拥有商品基础价、营销规则、订单状态——它是编排者
    • 对外提供 Calculate(items, promotions, context) -> PriceBreakdown
    • 各场景(PDP / 购物车 / 订单)通过统一接口获取价格

收益

  • 价格计算的一致性得到保证(同一套代码服务所有场景)
  • 营销规则变更只需在营销域发布事件,计价域订阅后自动生效
  • 支持了试算、价格预览、价格审计等新需求

经验:识别边界不是一次性切分,而是在「职责不清、协作成本高、重复逻辑多」的信号出现时,主动重构出清晰边界。

Go 建模提示:在订单上下文中,不要直接引用商品聚合的类型,而是使用 ID + 快照 表达跨边界依赖。

package order

type ProductID string

// Money 在订单上下文中表示「合同金额」;实现细节可复用共享包,但语义归属订单。
type Money struct {
	Cents    int64
	Currency string
}

// OrderLine 属于订单上下文:保存下单时刻解释合同所需的快照。
type OrderLine struct {
	ProductID   ProductID
	ProductName string // 快照:避免商品改标题影响历史订单
	UnitPrice   Money  // 快照:避免改价影响已生成应付金额
	Qty         int
}
package catalog

type ProductID string

// Product 属于商品上下文:关注展示与销售属性,而非订单合同解释。
type Product struct {
	ID          ProductID
	Title       string
	Description string
	OnShelf     bool
}

要点:两个包里的「商品信息」形状不同不是重复,而是上下文各有权威——合同解释以订单快照为准,陈列以商品上下文为准。

实践建议:在代码评审时,如果发现两个上下文共享同一个 Product 类型,应该追问:「这两处对商品的关注点是否相同?」如果答案是「不同」(一个关注展示,一个关注合同),那么应该拆分为两个独立的类型。宁可有一些字段重复,也不要为了「消除重复」而强行共享模型——这种重复是有意义的重复,体现了不同上下文的自治性。


2.3 通用语言(Ubiquitous Language)

通用语言是业务方与研发共同维护的精确词汇系统,贯穿需求、设计与代码。战略阶段的价值在于:先对齐语言,再讨论服务拆分与表结构。

2.3.1 建立通用语言

推荐从一次轻量工作坊开始:

  1. 列出动词与名词:下单、支付、发货、锁库、核销、退款……标记同义词(「关闭订单」vs「取消订单」)。
  2. 为每个词写一句业务定义:谁触发、前置状态、成功后的世界有何不同。
  3. 映射到代码锚点:包名、类型名、公开方法名尽量使用一致词汇(如 PlaceOrder 而非 CreateOrderRecord)。
  4. 记录禁用词:例如团队约定不用「更新状态 2」这类技术黑话对外沟通。

工作坊实践流程(2 小时示例)

参与者:产品经理、领域专家、架构师、核心开发各 1-2 人。

第一阶段(30 分钟):业务流程梳理

  • 在白板上画出核心业务流程(如「用户下单到收货」)
  • 标记出关键状态节点与触发动作
  • 识别出现频率最高的业务名词(订单、商品、库存、优惠券)

第二阶段(45 分钟):术语对齐

  • 逐个讨论每个名词的精确定义
    • 示例:「库存」在商品上架时指初始可售量,在订单创建时指预占后的剩余量,在发货后指实际扣减
    • 决策:用「可售库存(Available Stock)」、「预占库存(Reserved Stock)」、「已扣库存(Deducted Stock)」三个明确术语替代模糊的「库存」
  • 识别同义词并统一
    • 示例:技术侧说「关单」,业务侧说「取消订单」→ 统一为 CancelOrder
    • 示例:「锁库」、「预占库存」、「冻结库存」→ 统一为 ReserveStock

第三阶段(30 分钟):映射到代码

  • 为每个术语分配英文命名(供代码使用)
  • 明确哪些术语属于哪个限界上下文
  • 示例输出:
| 中文术语 | 英文命名 | 所属上下文 | 定义 |
|---------|---------|-----------|------|
| 下单 | PlaceOrder | 订单域 | 用户提交购买意图,生成待支付订单 |
| 预占库存 | ReserveStock | 库存域 | 为订单预留库存,防止超卖;超时后自动释放 |
| 核销优惠券 | RedeemCoupon | 营销域 | 将优惠券从可用状态变更为已使用 |

第四阶段(15 分钟):归档与宣导

  • 将术语表提交到代码仓库(docs/glossary.md
  • 在下次需求评审时强制对齐:新需求必须使用术语表中的词汇
  • 代码评审时检查:新增 API / 事件命名是否符合术语表

工作坊成果示例

## 订单上下文术语(v1.0)

- **下单(PlaceOrder)**:用户提交购买意图,生成待支付订单;不等于支付成功。
  - 前置条件:商品可售、库存充足、优惠券可用
  - 后置状态:订单状态为 `PendingPayment`,库存为 `Reserved`
  
- **锁库 / 预占库存(ReserveStock)**:为指定订单行预留可售库存,防止超卖;不等同于「扣减库存」。
  - 触发方:订单域在创单时调用库存域接口
  - 超时策略:30 分钟未支付自动释放
  
- **订单已支付(OrderPaid)**:支付渠道确认成功后的领域事实;会触发履约与扣减等后续流程。
  - 事件订阅者:库存域(扣减库存)、物流域(创建配送单)、营销域(核销优惠券)

实战技巧

  • 不要追求第一版术语表的完美——先建立 60% 共识,剩余在迭代中补充
  • 争议术语标记「待定」,给出 2-3 个候选,在实际编码中验证哪个更顺
  • 每季度 Review 一次术语表,淘汰不再使用的术语,补充新增的核心概念

Go:让类型系统承载语言

package order

type OrderID string
type UserID string
type ProductID string

// PlaceOrderCommand 用业务动词命名命令,而非数据库操作。
type PlaceOrderCommand struct {
	BuyerID UserID
	Lines   []OrderLineDraft
}

type OrderLineDraft struct {
	ProductID ProductID
	Qty       int
}

2.3.2 语言的演进与维护

语言会随业务演进,需要低成本维护机制:

  • ADR / RFC:当术语含义变化(例如「预售」从全款改为定金),用简短架构记录说明新旧语义与兼容期。
  • 版本化 API:对外契约(REST / gRPC / 事件 Schema)与术语表联动更新,避免「文档是新的、代码是旧的」。
  • 定期 Review:每个迭代挑一个争议需求,反问「我们用的是哪一个上下文里的定义?」

演进示例:当业务引入「先用后付」,需明确它属于支付上下文的授信产品,还是订单上下文的支付子状态——结论应写回术语表,并调整 OrderStatus 与集成事件名,而不是仅在 if 分支加 flag。

版本化策略:术语表应该有版本号(如 v1.0、v1.1),每次重大变更(如删除术语、修改定义)都升级版本并记录变更日志。这样新人可以追溯「为什么当初选择这个词」,避免重复讨论已解决的问题。同时,对外API的命名也应该与术语表版本对应,例如PlaceOrder v1使用术语表v1.0的定义,PlaceOrder v2使用v1.1的定义,保证向后兼容。

跨团队同步:术语表变更应该通知所有相关团队。可以在 Git 仓库中设置术语表文件的 CODEOWNERS,任何修改都需要相关团队的 Approver 确认。这样可以避免「术语表改了但代码没改」或「不同团队理解不一致」的问题。

2.3.3 反模式:技术术语污染业务讨论

典型反模式包括:在评审中使用 OrderDTO / OrderVO、把数据库动词当业务语言(InsertOrder)、用魔法状态码沟通。它们会阻断业务专家的参与。

package badexample

// ❌ 技术噪声:业务方无法从命名理解用例意图
type OrderService struct{}

func (s *OrderService) HandleSubmit(data map[string]any) error { return nil }
// ✅ 使用业务动词与强类型参数,评审可对读
package order

import "context"

type OrderService interface {
	PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error)
}

更多语言污染案例与纠正

反模式 1:用数据库字段名代替业务概念

// BAD: 数据库思维泄漏到业务层
type Order struct {
    OrderNo    string
    UserId     int64
    TotalAmt   int64
    StatusCode int    // 0=待支付 1=已支付 2=已取消 3=已关闭
}

func (s *OrderService) UpdateStatusCode(orderNo string, code int) error {
    // 业务规则隐藏在魔法数字背后
}
// GOOD: 业务语言驱动设计
type Order struct {
    ID         OrderID
    CustomerID CustomerID
    Total      Money
    Status     OrderStatus // 枚举类型
}

type OrderStatus int
const (
    StatusPendingPayment OrderStatus = iota
    StatusPaid
    StatusCanceled
    StatusFulfilled
)

// 业务动词显式化
func (o *Order) MarkAsPaid(paidAt time.Time) error {
    if o.Status != StatusPendingPayment {
        return ErrInvalidTransition
    }
    o.Status = StatusPaid
    o.PaidAt = paidAt
    return nil
}

收益:代码评审时,业务方能直接参与讨论状态转换规则,而不是盯着 SQL 猜测 status = 1 的含义。


反模式 2:接口命名只有 CRUD,没有业务意图

// BAD: 贫血模型 + CRUD
type OrderRepository interface {
    Insert(ctx context.Context, order *Order) error
    Update(ctx context.Context, order *Order) error
    Delete(ctx context.Context, id string) error
    Select(ctx context.Context, id string) (*Order, error)
}

问题

  • Update 可以改任意字段,无法表达业务约束
  • 新人看不出「订单支付」应该调用哪个方法
  • 业务规则散落在 Service 层的 if-else 中
// GOOD: 用例驱动的接口
type OrderUseCase interface {
    PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error)
    MarkAsPaid(ctx context.Context, orderID OrderID, paidAt time.Time) error
    CancelOrder(ctx context.Context, orderID OrderID, reason string) error
}

收益:接口即文档——每个方法对应一个明确的业务用例。


反模式 3:在需求评审中使用技术黑话

真实案例:产品提需求「用户支付后,订单状态改为 2」。

问题

  • 产品被迫记忆「2」的含义(下次可能记错)
  • 新人无法从文档理解业务流程
  • 数据库状态码变更时,文档需要全局替换

纠正方案

  • 需求文档使用业务语言:「用户支付后,订单状态改为已支付
  • 代码中使用枚举常量:StatusPaid
  • 数据库存储可以是数字,但对外接口和文档必须是业务术语

落地检查

  • 代码评审时,发现「魔法数字」或「技术缩写」,要求作者用业务术语重命名
  • API 文档生成时,枚举值自动展示为业务含义(如 "status": "paid"
  • 新人培训时,术语表是必读文档

2.4 上下文映射(Context Mapping)

上下文映射描述谁依赖谁、如何集成。它把组织关系与架构关系对齐,避免「谁都能改」的隐式耦合。

2.4.1 上下游关系模式

常见关系(节选):

模式关系典型集成电商提示
客户-供应商(Customer-Supplier)下游依赖上游,上游需考虑下游诉求版本化查询 API、批量接口订单(客户)依赖商品(供应商)
遵奉者(Conformist)下游无力改变上游模型直接采用对方模型税务、监管、强势渠道
防腐层(ACL)下游翻译上游模型,保护自身核心适配器封装第三方 SDK对接微信 / 支付宝支付
开放主机服务(OHS)上游提供稳定多租户接口标准 REST / gRPC + 兼容策略商品中心对搜索、推荐、活动统一供数
发布语言(Published Language)双方约定中立交换格式JSON Schema、Avro、开放事件规范OrderPaid 事件字段集
graph LR
    subgraph 上游
        Catalog[商品上下文]
        Promo[营销上下文]
    end
    subgraph 下游
        Checkout[结算上下文]
        Order[订单上下文]
    end
    Catalog -->|Customer-Supplier| Order
    Promo -->|Customer-Supplier| Checkout
    Checkout -->|同步编排| Order

各模式的实战应用

客户-供应商(Customer-Supplier)实践

场景:订单域(客户)依赖商品域(供应商)获取商品信息。

关键点

  • 上游(商品域)提供版本化 API,保证向后兼容
  • 下游(订单域)通过契约测试验证依赖稳定性
  • 定期召开「契约评审会」,下游提需求,上游评估可行性

实现示例

// 商品域对外提供的稳定接口(v1版本)
package catalogapi

type GetProductRequest struct {
    ProductID string `json:"product_id"`
}

type GetProductResponse struct {
    ID          string  `json:"id"`
    Title       string  `json:"title"`
    Price       float64 `json:"price"`
    Available   bool    `json:"available"`
}

// 订单域依赖商品域的接口
package order

type ProductAPI interface {
    GetProduct(ctx context.Context, productID string) (*catalogapi.GetProductResponse, error)
}

契约测试

func TestProductAPI_Contract(t *testing.T) {
    // 验证商品域的响应格式是否符合订单域的预期
    resp := &catalogapi.GetProductResponse{
        ID:    "SKU123",
        Title: "iPhone 15",
        Price: 5999.00,
        Available: true,
    }
    // 断言必需字段存在
    assert.NotEmpty(t, resp.ID)
    assert.NotEmpty(t, resp.Title)
}

遵奉者(Conformist)实践

场景:对接税务系统、支付渠道等强势上游,无力改变对方模型。

策略

  • 直接使用对方的数据结构(避免无谓的翻译层)
  • 在上游变更时快速跟进(监听对方发布公告)
  • 内部文档记录「为什么使用对方模型」(避免后人困惑)

示例

// 直接使用支付宝 SDK 的类型
import "github.com/alipay/alipay-sdk-go"

type AlipayAdapter struct {
    client *alipay.Client
}

func (a *AlipayAdapter) CreatePayment(orderID string, amount float64) error {
    // 直接使用 Alipay SDK 的请求结构
    req := alipay.TradeCreateRequest{
        OutTradeNo:  orderID,
        TotalAmount: fmt.Sprintf("%.2f", amount),
        Subject:     "订单支付",
    }
    _, err := a.client.TradeCreate(&req)
    return err
}

适用场景:上游是成熟的外部系统,模型变更频率低,翻译成本高于收益。


开放主机服务(OHS)+ 发布语言实践

场景:商品中心需要服务多个下游(搜索、推荐、营销、订单),避免为每个下游定制接口。

策略

  • 设计通用查询接口,支持灵活的筛选与投影
  • 发布标准事件(JSON Schema / Protobuf),所有下游订阅相同事件
  • 使用 API Gateway 管理多租户访问(限流、鉴权、版本路由)

示例

// 商品域发布统一的查询接口
type ProductQueryAPI interface {
    ListProducts(ctx context.Context, req ListProductsRequest) (*ListProductsResponse, error)
}

type ListProductsRequest struct {
    CategoryID string   `json:"category_id,omitempty"`
    Tags       []string `json:"tags,omitempty"`
    OnShelf    *bool    `json:"on_shelf,omitempty"`
    Limit      int      `json:"limit"`
    Offset     int      `json:"offset"`
}

发布语言(事件)

{
  "event_type": "ProductOnShelf",
  "version": "1.0",
  "product_id": "SKU123",
  "title": "iPhone 15",
  "price": 5999.00,
  "occurred_at": "2026-04-17T10:00:00Z"
}

收益

  • 下游(搜索、推荐)可以独立订阅事件,无需与商品域强耦合
  • 商品域只需维护一套接口,降低维护成本
  • 通过 Schema Registry 管理事件版本,保证向后兼容

选型决策树

flowchart TD
    start([识别上下游关系]) --> q1{上游是否可控?}
    q1 -->|是| q2{下游数量?}
    q2 -->|1-2个| customer[Customer-Supplier]
    q2 -->|3个以上| ohs[OHS + Published Language]
    q1 -->|否| q3{模型是否冲突?}
    q3 -->|冲突| acl[ACL 防腐层]
    q3 -->|不冲突| conformist[Conformist 遵奉者]

2.4.2 共享内核

共享内核是两方共同维护的一小块模型或库。它减少重复,但会牺牲自治,需要强治理。

电商谨慎场景:订单与库存若共享「SKU ID 类型 + 基础校验函数」这类极小内核尚可;一旦共享「库存数量字段」或「订单状态枚举」,边界会迅速模糊。

// sharedkernel/sku.go — 保持极小、稳定、少变更
package sharedkernel

type SKUCode string

func (c SKUCode) IsWellFormed() bool {
	return len(string(c)) >= 6 // 示例规则:长度下限
}

实践建议:共享内核应能通过双人评审 + 语义化版本演进;否则优先改为 Published Language(如清晰的事件字段)而非代码级共享。

何时使用共享内核

  • 两个上下文由同一团队维护,且模型变更成本低
  • 共享的是极其稳定的基础类型(如 ID、Money、Email 等值对象)
  • 双方都同意「修改共享内核需要通知并等待对方确认」

何时避免共享内核

  • 两个上下文由不同团队维护(会严重拖慢发布节奏)
  • 共享的是经常变化的业务规则(如订单状态、库存策略)
  • 无法保证「修改前通知」的纪律(会导致隐式破坏性变更)

真实案例:某团队在订单与库存之间共享了 ProductStatus 枚举。营销需求要求增加「预售」状态,订单团队快速修改了枚举并发布,但忘记通知库存团队。库存服务在处理「预售商品」时因为没有对应的分支处理逻辑,导致库存同步失败。最终双方约定:将共享内核降级为 Published Language(事件 Schema),每次变更必须走 RFC 流程并双方确认。

2.4.3 防腐层

防腐层把外部不稳定协议挡在边界之外,领域层只依赖自己的端口接口。

package order

import (
	"context"
	"fmt"
)

type OrderID string

// Money 表示订单上下文的应付金额(示例:用分存储,避免 float)。
type Money struct {
	cents    int64
	currency string
}

func NewMoneyFromCents(cents int64, currency string) Money {
	return Money{cents: cents, currency: currency}
}

func (m Money) DecimalYuan() string {
	if m.cents < 0 {
		return "0.00"
	}
	yuan := m.cents / 100
	fen := m.cents % 100
	return fmt.Sprintf("%d.%02d", yuan, fen)
}

// PaymentSession 是领域侧对「可跳转支付」的最小抽象,不暴露渠道字段。
type PaymentSession struct {
	CheckoutURL string
}

type StartPaymentCommand struct {
	OrderID OrderID
	Payable Money
}

// PaymentGateway 由订单领域定义:表达「我需要的支付能力」,由基础设施实现。
type PaymentGateway interface {
	StartPayment(ctx context.Context, cmd StartPaymentCommand) (*PaymentSession, error)
}
package alipayacl

import (
	"context"
	"fmt"

	"example/order"
)

// 仅示意第三方 SDK 的能力边界,避免示例依赖真实包名。
type alipayPrecreateClient interface {
	Precreate(ctx context.Context, body map[string]any) (*alipayPrecreateResult, error)
}

type alipayPrecreateResult struct {
	QRCodeURL string
}

// AlipayACL 位于基础设施侧:翻译领域命令 ↔ 支付宝请求 / 响应。
type AlipayACL struct {
	client alipayPrecreateClient
}

func NewAlipayACL(client alipayPrecreateClient) *AlipayACL {
	return &AlipayACL{client: client}
}

func (a *AlipayACL) StartPayment(ctx context.Context, cmd order.StartPaymentCommand) (*order.PaymentSession, error) {
	req := map[string]any{
		"out_trade_no": string(cmd.OrderID),
		"total_amount": cmd.Payable.DecimalYuan(),
		"subject":      fmt.Sprintf("订单支付 %s", string(cmd.OrderID)),
	}
	resp, err := a.client.Precreate(ctx, req)
	if err != nil {
		return nil, err
	}
	return &order.PaymentSession{CheckoutURL: resp.QRCodeURL}, nil
}

收益

  • 当渠道字段变更时,修改集中在 ACL;订单聚合与用例不被第三方类型污染
  • 可以为同一个端口提供多个实现(支付宝 ACL、微信 ACL、PayPal ACL),通过工厂模式或配置切换
  • 测试时可以使用 Fake 实现替代真实支付渠道,提升测试速度和可靠性
  • 防腐层承担了「翻译」职责,领域层保持纯粹,不受外部依赖污染

反模式警示:有些团队会在防腐层之外再包一层「防防腐层」,过度封装导致代码层级过深。原则是一次翻译足矣——从第三方模型翻译到领域模型,中间不需要再多一层「通用模型」。

2.4.4 电商系统的上下文映射实例

综合一版可挂在 Wiki 首页的示意(箭头表示依赖方向):

graph LR
    OrderBC[订单上下文]
    CatalogBC[商品上下文]
    InventoryBC[库存上下文]
    PaymentBC[支付上下文]
    LogisticsBC[物流上下文]
    PromoBC[营销上下文]

    OrderBC -->|Customer-Supplier| CatalogBC
    OrderBC -->|Customer-Supplier| InventoryBC
    OrderBC -->|ACL| PaymentBC
    OrderBC -->|Customer-Supplier| LogisticsBC
    OrderBC -->|Customer-Supplier| PromoBC
    InventoryBC -.->|极小共享内核| CatalogBC

落地检查清单

  • 每个箭头是否有明确契约(OpenAPI / Proto / 事件表)?
  • 失败模式(超时、重试、幂等)是否写清归属上下文?
  • 是否存在「绕过契约直连数据库」的路径?若有,计划消除。

集成失败案例与解决方案

案例 1:订单域直接读取库存表(违反边界)

背景:订单服务为了展示「剩余库存」,在查询订单详情时直接 JOIN 库存表。

问题

  • 库存表结构变更时,订单服务也要修改(耦合)
  • 库存域无法独立演进(加缓存、分库、切换存储)
  • 破坏了「库存域拥有库存数据」的所有权原则

解决方案

// 订单域定义端口
type StockQueryPort interface {
    GetAvailableQty(ctx context.Context, sku string) (int, error)
}

// 基础设施层实现(调用库存域 API)
type RemoteStockAdapter struct {
    client *stockservice.Client
}

func (a *RemoteStockAdapter) GetAvailableQty(ctx context.Context, sku string) (int, error) {
    resp, err := a.client.GetStock(ctx, &stockpb.GetStockRequest{Sku: sku})
    if err != nil {
        return 0, fmt.Errorf("query stock: %w", err)
    }
    return int(resp.AvailableQty), nil
}

收益:库存域可以独立优化存储、加缓存、切换数据库,订单域只依赖接口契约。


案例 2:营销规则变更导致订单金额计算不一致

背景:营销域上线新规则后,订单域的价格计算逻辑未同步更新,导致订单金额与用户预览不一致。

根因:没有统一的计价入口,订单域、购物车、PDP 各自实现计算逻辑。

解决方案

  1. 新建计价上下文作为编排者
  2. 所有场景通过计价域的 Calculate 接口获取价格
  3. 营销规则变更时,只需更新营销域;计价域自动订阅规则变更事件
graph LR
    PDP[商品详情页] -->|试算请求| Pricing[计价上下文]
    Cart[购物车] -->|试算请求| Pricing
    Order[订单域] -->|确认金额| Pricing
    Pricing -->|查基础价| Catalog[商品域]
    Pricing -->|查规则| Promo[营销域]

案例 3:支付回调丢失导致订单状态不同步

背景:支付域通过 HTTP 回调通知订单域支付成功,但网络抖动导致回调丢失,订单长时间停留在「待支付」状态。

问题

  • 同步回调不可靠(网络、超时、重启)
  • 缺少补偿机制

解决方案

  1. 异步事件 + 重试:支付域发布 PaymentCaptured 事件到 Kafka,订单域订阅并幂等处理
  2. 对账任务:每小时扫描「待支付」订单,调用支付域查询实际状态,发现不一致则补偿
  3. 状态机保护:订单状态机禁止「已支付 → 待支付」的逆向转换,防止数据损坏
// 订单域订阅支付事件
func (s *OrderService) HandlePaymentCaptured(ctx context.Context, event PaymentCapturedEvent) error {
    order, err := s.repo.FindByID(ctx, event.OrderID)
    if err != nil {
        return err
    }
    // 幂等检查
    if order.Status == StatusPaid {
        return nil // 已处理
    }
    return order.MarkAsPaid(event.PaidAt)
}

经验:跨上下文集成必须考虑失败场景——同步调用加超时与重试,异步事件加幂等与对账。


2.5 领域的分类

战略精炼的重要产出,是把公司能力地图分为 核心域、支撑域、通用域,以指导人力与风险的投放。

2.5.1 核心域、支撑域、通用域

  • 核心域:差异化竞争力所在,复杂且多变,应重仓自研与深度建模。例如亚马逊的推荐算法、阿里的交易风控,这些是竞争壁垒,必须投入顶尖人才与架构资源。
  • 支撑域:业务必需但非胜负手,可适度定制,避免过度设计。例如商品管理、库存管理,参考业界成熟模型即可,不必追求极致创新。
  • 通用域:行业共性,成熟外包或 SaaS 更划算。例如短信通知、对象存储、日志监控,云服务比自研更经济。

2.5.2 投资策略

可用「四维度」快速评分:业务价值、复杂度、变化频率、差异化。不必追求精确分数,关键是相对比较与资源承诺。

子域示例倾向投资建议(示意)
订单履约核心域架构师 + 高可用工程化,明确 SLA 与演练
库存准确性核心域并发控制、对账与仿真压测
商品管理支撑域参考业界模型,控制自定义范围
消息通知通用域使用云短信 / 邮件 SaaS,内部仅封装网关

投资决策案例

案例 1:搜索推荐从支撑域升级为核心域

背景:某平台早期将搜索视为支撑域,采用开源 Elasticsearch + 简单配置。随着 GMV 增长,发现 70% 流量来自搜索,转化率直接影响营收

决策过程

  • 重新评估:搜索从「辅助发现」变为「核心转化入口」
  • 投资升级
    • 组建搜索算法团队(相关性、个性化排序)
    • 自研召回引擎与排序服务(而非依赖 ES 默认评分)
    • 建设 ABTest 平台与实时指标体系
  • 资源配比:从 1 人维护 → 8 人团队(算法 + 工程)

收益:搜索点击率提升 40%,GMV 贡献占比从 70% 提升到 85%。


案例 2:自研消息队列的代价

背景:某团队因「担心 Kafka 运维复杂」,自研了轻量级消息队列。

问题暴露

  • 维护成本:集群故障、数据丢失、性能瓶颈需要专人处理
  • 功能缺失:不支持事务、延迟消息、死信队列等企业级特性
  • 团队分心:核心业务开发被「救火中间件」打断

纠正方案

  • 迁移到云服务商托管的 Kafka(或 RocketMQ)
  • 内部仅封装薄的 SDK 层(日志、监控、错误处理)
  • 释放的人力投入到核心业务优化

经验:通用域的自研往往是「过早优化」——先用成熟方案,待瓶颈确认后再评估自研价值。


常见误判

  • 把所有模块都标成核心域:资源分散,真正的差异化无人深耕。
  • 把支撑域做成「无设计」:质量太差会反向拖垮核心域(数据错误、不可用)。
  • 在通用域自研中间件:短期有掌控感,长期维护成本侵蚀核心业务投入。
  • 忽视支撑域的稳定性:商品数据错误、库存不准确会直接影响订单转化——支撑域不是「二等公民」。

投资复盘机制

建议每半年召开一次「子域投资复盘会」:

  1. 回顾当前分类:哪些域的重要性发生了变化?
  2. 评估资源配比:核心域是否得到了足够的架构师与工程资源?
  3. 识别欠投资域:哪些支撑域因质量问题拖累了核心域?
  4. 调整策略:将资源从「过度投资的通用域」转移到「欠投资的核心域」

输出示例

子域上次分类本次分类资源变化理由
搜索推荐支撑域核心域+5 人转化率提升是 Q1 关键目标
消息队列通用域通用域-2 人迁移到云服务,释放人力
库存准确性核心域核心域+3 人大促准备,加强对账与监控

2.5.3 电商平台的领域分类

结合国内中大型平台的常见划分(需按公司战略微调):

核心域候选:交易订单、库存准确性、交易风控、推荐与搜索体验(若差异化来自发现与转化)。

支撑域候选:商品中心、营销规则、计价、物流对接、客服工单。

通用域候选:登录注册、对象存储、日志监控、基础消息通道。

详细领域分类与资源配比

子域分类业务价值复杂度变化频率差异化资源配比示例
订单履约核心域极高10人团队,架构师+高工
库存准确性核心域极高8人团队,专项优化
交易风控核心域极高极高极高算法团队+工程团队
搜索推荐核心域极高极高算法+工程双轨
商品中心支撑域5人团队,参考业界
营销系统支撑域6人团队,规则引擎
计价系统支撑域3人团队,统一接口
物流对接支撑域2人维护,适配器模式
登录注册通用域使用 OAuth2 服务
对象存储通用域云服务(OSS/S3)
消息通知通用域云服务+薄封装

分类决策的实战案例

案例 1:库存系统从支撑域升级为核心域

初始阶段

  • 分类:支撑域(「库存只是个数字,没啥复杂的」)
  • 投资:2 人维护,简单 Redis + MySQL

问题暴露

  • 大促期间频繁超卖,投诉量激增
  • 供应商库存同步延迟,导致用户下单后被取消
  • 库存准确性成为用户信任的核心指标

重新评估

  • 业务价值:库存不准 → 超卖 → 投诉 → 品牌损失(极高)
  • 复杂度:多仓、多供应商、实时同步、预占释放(高)
  • 差异化:准确率 99.9% vs 竞品 95%(高)
  • 结论:升级为核心域

投资升级

  • 团队扩充到 8 人(架构师 + 高工 + 算法)
  • 建设预占释放机制、对账系统、实时监控
  • 引入分布式锁与 Lua 脚本保证并发正确性
  • 建立库存准确性 SLA(99.95%)

收益:超卖率从 5% 降到 0.1%,用户满意度显著提升。


案例 2:消息通知从自研降级为云服务(通用域)

初始阶段

  • 分类:支撑域
  • 投资:4 人团队自研短信 / 邮件 / 推送网关

问题暴露

  • 维护成本高(渠道对接、失败重试、速率控制)
  • 功能落后(不支持模板管理、A/B 测试)
  • 团队被「救火」占用,无法投入核心业务

重新评估

  • 业务价值:中(通知到达率影响体验,但非核心竞争力)
  • 差异化:无(所有平台都需要通知,无差异化空间)
  • 结论:降级为通用域,使用云服务

调整方案

  • 迁移到云服务商(阿里云 / 腾讯云 / AWS SNS)
  • 内部仅保留薄封装层(日志、监控、降级)
  • 释放的 4 人转投到核心域(搜索推荐优化)

收益:维护成本降低 80%,功能更丰富(模板管理、多渠道支持),团队聚焦核心业务。


包结构与投资映射

// 用包结构反映投资重心(示意):核心域包内更完整领域模型,外围保持薄封装。

// 核心域:完整的 DDD 分层
// core/order/
//   ├── domain/       (聚合、实体、值对象、领域服务)
//   ├── application/  (用例、命令处理器)
//   ├── adapter/      (HTTP、gRPC、事件订阅)
//   └── infra/        (仓储实现、外部集成)

// 支撑域:适度建模,控制复杂度
// supporting/catalog/
//   ├── service/      (业务逻辑)
//   ├── repository/   (数据访问)
//   └── api/          (对外接口)

// 通用域:薄封装,优先使用外部服务
// generic/notification/
//   ├── client/       (云服务 SDK 封装)
//   └── config/       (配置与降级)

说明:包划分只是辅助沟通的手段,真正重要的是团队边界、发布边界与数据所有权是否与之一致。核心域投入更多架构设计与代码质量保障,支撑域保持适度复杂度,通用域优先复用成熟方案。


2.6 本章小结

2.6.1 核心要点回顾

  • 战略设计解决边界、协作与语言问题,是战术建模的前置条件;与第 1 章的架构骨架互补而非重复。
  • 限界上下文按业务能力、语言分叉、一致性与团队现实划分;订单、商品、营销应各自维护模型,以 ID 与快照连接。
  • 通用语言需要术语表、命令动词与演进机制,避免技术黑话污染评审。
  • 上下文映射把依赖关系产品化:客户-供应商、防腐层、共享内核(克制使用)、开放主机服务与发布语言各得其所。
  • 子域分类驱动投资:核心域求精,支撑域求稳,通用域求省,并定期复盘调整。

2.6.2 落地检查清单

在项目启动或重构时,可用以下清单自查:

限界上下文识别

  • 是否识别出 3-8 个主要限界上下文?(过少说明边界模糊,过多说明过度拆分)
  • 每个上下文是否有清晰的职责描述(一句话能说清楚)?
  • 是否避免了「大而全的领域模型」(如一个 Product 类服务所有上下文)?
  • 跨上下文的数据引用是否通过 ID 与快照,而非直接依赖对方的聚合类型?

通用语言建立

  • 是否建立了术语表(docs/glossary.md)?
  • 代码中的核心类型、方法名是否与术语表一致?
  • 是否消除了魔法数字与技术黑话(如 status=2UpdateData)?
  • 新需求评审时,是否强制使用术语表中的词汇?

上下文映射

  • 是否有明确的上下文映射图(类似 2.4.4 的 Mermaid 图)?
  • 每个依赖关系是否标注了集成模式(Customer-Supplier / ACL / OHS)?
  • 是否为关键集成定义了契约(API 文档 / Proto / 事件 Schema)?
  • 是否考虑了失败场景(超时、重试、降级、对账)?

子域投资

  • 是否明确标记了核心域、支撑域、通用域?
  • 核心域是否得到了架构师与高级工程师的投入?
  • 通用域是否优先使用成熟的开源 / 云服务,而非自研?
  • 是否有定期复盘机制(每半年 Review 一次子域分类)?

2.6.3 常见陷阱总结

陷阱症状纠正方向
战术先行代码结构优美,但跨团队协作仍然混乱补充战略设计:画出上下文地图,建立术语表
边界过度拆分10+ 个微服务,每个只有几百行代码合并职责相近的上下文,先模块化后服务化
共享大模型一个 Product 类被所有服务依赖每个上下文独立建模,用 ACL 翻译
术语不一致同一概念有 3 种命名(Order / Purchase / Transaction)通过工作坊对齐,强制使用术语表
忽视集成失败只考虑 Happy Path,线上频繁出现数据不一致为每个集成点设计失败处理(重试、降级、对账)
投资不均衡核心域缺人,通用域自研中间件占用大量资源重新评估子域分类,调整资源配比

2.6.4 实践建议

  1. 战略设计不是一次性工作——建议每季度召开一次「边界复盘会」,回顾上下文划分是否合理,术语表是否需要更新。

  2. 工作坊轻量化——不必追求「完美的限界上下文图」,先建立 60% 共识,剩余在实际编码中验证和调整。

  3. 术语表即文档——将术语表提交到代码仓库,作为新人培训与需求评审的必读材料。

  4. 从小处着手——若团队尚未实践 DDD,可以从「建立一个术语表」或「重命名一个核心 API」开始,而非一次性重构整个系统。

  5. 与第 1 章方法论结合——战略设计画出边界 → Clean Architecture 确保依赖方向 → DDD 战术设计守护不变量 → CQRS 优化读写路径,四者协同发力。

与下一章的衔接:第 3 章将回到代码层面的整洁性与可维护性(函数、Pipeline、策略模式等),把本章画出的边界落实为日常工程纪律。战略设计画出了「是什么、在哪里」,战术设计(第 3 章)将回答「怎么做、怎么守护」。两者结合,才能构建出既有清晰边界、又有良好代码质量的系统。


导航返回目录 | 上一章 | 书籍主页 | 下一章:第3章(即将发布)

导航书籍主页 | 完整目录 | 上一章 | 下一章:第4章(即将发布)


第3章 整洁代码与设计模式

在电商核心链路中,用 Pipeline、策略与轻量规则引擎驯服复杂度——与第1章的架构分层互为表里


3.1 复杂业务代码的痛点

第1章解决了「系统如何分层、依赖如何向内」的问题;本章解决「同一层里,代码如何写得可演进、可测试」。电商的下单、库存、营销是典型的长流程 + 多分支 + 高频变更场景,若缺少约束,业务代码会迅速腐化。

3.1.1 千行函数的噩梦

下面这段伪代码浓缩了真实项目中常见的「上帝函数」形态:校验、读用户、查库存、算价、营销、风控、落库、支付、通知全部堆在一个入口里。

// ❌ 反例:单函数承载整条下单链路(职责过多、难以测试)
func CreateOrder(req *CreateOrderRequest) (*Order, error) {
	if req == nil || req.UserID == "" || len(req.Items) == 0 {
		return nil, errors.New("invalid request")
	}
	// 用户信息、库存、价格、券、积分、运费、活动、风控……
	// 数百行嵌套在同一函数中,修改任意一步都可能波及其他步骤
	return &Order{}, nil
}

直接后果

  • 无法为「只改营销校验」写独立单测,只能起全套集成环境;
  • Code Review 难以聚焦,合并冲突集中在一个文件;
  • 新人需要整段读完才敢改一行。

3.1.2 代码腐化的根因

根因表现与架构的关系
缺少流程骨架步骤顺序靠注释和隐式顺序Clean Architecture 只保证分层,不自动拆分函数
分支爆炸if-else 按品类、地区、活动维度嵌套领域模型仍在,但应用编排层失控
上下文随意传递十几个参数或「万能 struct」未用显式 Context 对象承载流水线状态
规则与代码绑死运营改文案/门槛就要发版与第9章营销系统呼应:需数据驱动的规则层

3.1.3 技术债的累积

技术债不一定是「烂代码」,更多时候是当时合理、后来失配的设计:例如早期用三层 Service 足够,随着营销玩法与多仓库存策略叠加,仍坚持在一个 OrderService 里堆逻辑,债就滚雪球。

可操作的止损线(团队可写入评审清单):

  • 单个导出函数超过约 80~120 行(视团队约定)必须拆分或抽 Pipeline;
  • 圈复杂度(McCabe)超过约定阈值必须拆解策略或子函数;
  • 同一文件内出现 3 处以上「复制粘贴再改一点」的折扣/库存分支,优先考虑策略或规则表。
flowchart LR
	A[需求变更] --> B{能否局部修改?}
	B -->|否| C[千行函数 / 深层 if-else]
	C --> D[测试困难]
	D --> E[不敢重构]
	E --> C

3.2 函数设计原则

在引入模式之前,先收紧函数级的纪律:这是所有模式能落地的前提。

3.2.1 单一职责

一个函数应回答一个业务问题。例如「校验下单请求」与「持久化订单」不应混在同一函数中。

// ❌ 反例:校验与副作用混在一起
func PlaceOrder(ctx context.Context, req *PlaceOrderRequest, db *sql.DB) error {
	if req.CustomerID == "" {
		return errors.New("customer required")
	}
	_, err := db.ExecContext(ctx, `INSERT INTO orders (...) VALUES (...)`, req.CustomerID)
	return err
}

// ✅ 正例:校验纯函数化,写库由仓储承担
func ValidatePlaceOrder(req *PlaceOrderRequest) error {
	if req.CustomerID == "" {
		return errors.New("customer required")
	}
	if len(req.Lines) == 0 {
		return errors.New("order lines required")
	}
	return nil
}

3.2.2 函数长度控制

经验法则:一屏能读完(含错误处理)较理想。长逻辑不是「拆成很多小函数」就够了,还要让调用方读出业务流程,这正是 3.3 节 Pipeline 的价值。

3.2.3 参数设计

参数过多往往说明缺少「用例级上下文」。把一次下单所需输入收敛为 PlaceOrderInput,中间态收敛为 PlaceOrderState(或下文 OrderPipeContext),Handler 只负责绑定 HTTP → DTO → 用例输入。

// ❌ 反例:参数平面展开
func PriceForCheckout(userID, region, platform string, qty int, skuID string, useCoupon bool, couponCode string) (int64, error) {
	return 0, nil
}

// ✅ 正例:输入聚合为结构体
type CheckoutPriceQuery struct {
	UserID     string
	Region     string
	Platform   string
	SKU        string
	Qty        int
	CouponCode string
}

3.2.4 错误处理

在 Go 中建议:

  • 可预期业务失败用哨兵错误或自定义类型,便于上层映射 HTTP 4xx;
  • 系统/依赖故障%w 包装,保留链路与 errors.Is / As 能力;
  • 不要在业务深层 log.Fatal,把决策留在 main 或任务入口。
// ✅ 正例:包装依赖错误
func (r *MySQLOrderRepo) Save(ctx context.Context, o *Order) error {
	if _, err := r.db.ExecContext(ctx, `INSERT INTO orders (id, customer_id) VALUES (?, ?)`, o.ID, o.CustomerID); err != nil {
		return fmt.Errorf("order repo save: %w", err)
	}
	return nil
}

3.3 Pipeline 模式

Pipeline(管道)把阶段化流程显式化:每一步是一个 Processor,共享一个上下文,由 Pipeline 顺序驱动。它与责任链的共同点都是「链式处理」,不同之处在于:Pipeline 更强调数据沿管道变换、阶段职责清晰,常用于下单、结算试算、营销列表组装等。

3.3.1 从嵌套到流水线

flowchart LR
	P1[Validate] --> P2[LoadUser]
	P2 --> P3[ReserveInventory]
	P3 --> P4[ApplyPricing]
	P4 --> P5[PersistOrder]

对比

维度深层嵌套Pipeline
流程可见性靠读完全文组装处即文档
扩展改中央函数增删 Processor
单测每步独立 Mock

3.3.2 实现要点

  1. 上下文对象:承载输入、中间结果、错误累积标记;避免用包级变量。
  2. 早失败:任一步返回错误即中止管道(或实现可配置的「继续/中止」策略)。
  3. 命名Processor.Name() 便于日志与指标打标签。
  4. 与 Clean Architecture:Pipeline 通常落在 Application / Use Case 编排层,领域不变量仍在聚合根内。

3.3.3 订单处理的 Pipeline 实例

下面给出一份可编译思路的精简实现:创建订单流水线——校验 → 加载商品行 → 预留库存 → 写单。

package orderpipe

import (
	"context"
	"errors"
	"fmt"
)

// OrderPipeContext 承载一次「创单」流水线的输入与中间态。
type OrderPipeContext struct {
	Req            PlaceOrderRequest
	ResolvedLines  []OrderLine
	ReservationID  string
	CreatedOrderID string
}

type PlaceOrderRequest struct {
	CustomerID string
	Lines      []LineRequest
}

type LineRequest struct {
	SKU string
	Qty int
}

type OrderLine struct {
	SKU       string
	Qty       int
	UnitCents int64
}

// Processor 单步处理逻辑。
type Processor interface {
	Name() string
	Process(ctx context.Context, c *OrderPipeContext) error
}

// Pipeline 顺序执行。
type Pipeline struct {
	steps []Processor
}

func NewPipeline(steps ...Processor) *Pipeline {
	return &Pipeline{steps: steps}
}

func (p *Pipeline) Run(ctx context.Context, c *OrderPipeContext) error {
	for _, s := range p.steps {
		if err := ctx.Err(); err != nil {
			return err
		}
		if err := s.Process(ctx, c); err != nil {
			return fmt.Errorf("%s: %w", s.Name(), err)
		}
	}
	return nil
}

// --- Processors ---

type validateProcessor struct{}

func (validateProcessor) Name() string { return "validate" }

func (validateProcessor) Process(_ context.Context, c *OrderPipeContext) error {
	if c.Req.CustomerID == "" {
		return errors.New("customer_id required")
	}
	if len(c.Req.Lines) == 0 {
		return errors.New("at least one line")
	}
	return nil
}

type Catalog interface {
	ResolveLines(ctx context.Context, lines []LineRequest) ([]OrderLine, error)
}

type resolveCatalogProcessor struct{ cat Catalog }

func (p resolveCatalogProcessor) Name() string { return "resolve_catalog" }

func (p resolveCatalogProcessor) Process(ctx context.Context, c *OrderPipeContext) error {
	lines, err := p.cat.ResolveLines(ctx, c.Req.Lines)
	if err != nil {
		return err
	}
	c.ResolvedLines = lines
	return nil
}

type Inventory interface {
	Reserve(ctx context.Context, customerID string, lines []OrderLine) (reservationID string, err error)
}

type reserveInventoryProcessor struct{ inv Inventory }

func (p reserveInventoryProcessor) Name() string { return "reserve_inventory" }

func (p reserveInventoryProcessor) Process(ctx context.Context, c *OrderPipeContext) error {
	rid, err := p.inv.Reserve(ctx, c.Req.CustomerID, c.ResolvedLines)
	if err != nil {
		return err
	}
	c.ReservationID = rid
	return nil
}

type OrderRepository interface {
	Insert(ctx context.Context, c *OrderPipeContext) (orderID string, err error)
}

type persistOrderProcessor struct{ repo OrderRepository }

func (p persistOrderProcessor) Name() string { return "persist_order" }

func (p persistOrderProcessor) Process(ctx context.Context, c *OrderPipeContext) error {
	id, err := p.repo.Insert(ctx, c)
	if err != nil {
		return err
	}
	c.CreatedOrderID = id
	return nil
}

组装处(例如在 cmdapplication 包)一眼读完流程

func NewPlaceOrderPipeline(cat Catalog, inv Inventory, repo OrderRepository) *Pipeline {
	return NewPipeline(
		validateProcessor{},
		resolveCatalogProcessor{cat: cat},
		reserveInventoryProcessor{inv: inv},
		persistOrderProcessor{repo: repo},
	)
}

3.4 策略模式

当分支维度稳定、而每个分支内部都很厚时,用策略(Strategy)把「选谁」与「怎么做」拆开:注册表负责选择,策略对象负责算法。

3.4.1 消除 if-else

// ❌ 反例:按库存类型硬编码
func ReserveStock(kind string, sku string, qty int) error {
	if kind == "platform" {
		return platformReserve(sku, qty)
	} else if kind == "vendor_realtime" {
		return vendorRealtimeReserve(sku, qty)
	} else if kind == "vendor_async" {
		return vendorAsyncReserve(sku, qty)
	}
	return errors.New("unknown stock kind")
}

新增一种库存来源时,必须修改该函数,违反开闭原则,且冲突面大。

3.4.2 策略的注册与选择

package stockstrategy

import (
	"context"
	"errors"
	"fmt"
)

type ReserveInput struct {
	SKU string
	Qty int
}

// Reserver 库存预留策略。
type Reserver interface {
	Kind() string
	Reserve(ctx context.Context, in ReserveInput) error
}

type Registry struct {
	byKind map[string]Reserver
}

func NewRegistry(rs ...Reserver) (*Registry, error) {
	m := make(map[string]Reserver, len(rs))
	for _, r := range rs {
		k := r.Kind()
		if _, dup := m[k]; dup {
			return nil, fmt.Errorf("duplicate reserver: %s", k)
		}
		m[k] = r
	}
	return &Registry{byKind: m}, nil
}

func (reg *Registry) Reserve(ctx context.Context, kind string, in ReserveInput) error {
	r, ok := reg.byKind[kind]
	if !ok {
		return errors.New("unknown stock kind")
	}
	return r.Reserve(ctx, in)
}

3.4.3 库存策略的实例

type platformReserver struct{}

func (platformReserver) Kind() string { return "platform" }

func (platformReserver) Reserve(ctx context.Context, in ReserveInput) error {
	// 调用平台自有库存服务(Redis + DB 等)
	_ = ctx
	if in.Qty <= 0 {
		return errors.New("qty must be positive")
	}
	return nil
}

type vendorRealtimeReserver struct{}

func (vendorRealtimeReserver) Kind() string { return "vendor_realtime" }

func (vendorRealtimeReserver) Reserve(ctx context.Context, in ReserveInput) error {
	// 同步调用供应商库存 API
	_ = ctx
	return nil
}

type vendorAsyncReserver struct{}

func (vendorAsyncReserver) Kind() string { return "vendor_async" }

func (vendorAsyncReserver) Reserve(ctx context.Context, in ReserveInput) error {
	// 写入待同步队列,由异步任务向供应商确认
	_ = ctx
	_ = in
	return nil
}
classDiagram
	class Reserver {
		<<interface>>
		+Kind() string
		+Reserve(ctx, in) error
	}
	Reserver <|.. platformReserver
	Reserver <|.. vendorRealtimeReserver
	Reserver <|.. vendorAsyncReserver
	Registry --> Reserver : lookup by kind

与 Pipeline 的分工:Pipeline 回答「先做啥后做啥」;策略回答「同一类步骤里用哪套算法」。例如「预留库存」这一步内部再根据 kind 选策略。


3.5 规则引擎

3.5.1 何时需要规则引擎

适合

  • 运营频繁调整的门槛、互斥、叠加(满减、品类券、会员日);
  • 需要按优先级尝试多条规则,并输出可追溯的「命中说明」。

不适合

  • 极少变化且分支很少(直接写在领域服务里更清晰);
  • 强实时、超低延迟且规则解释执行成本高(需编译型或预计算方案)。

3.5.2 轻量级规则引擎设计

核心思想:规则 = 条件(数据) + 动作(数据),引擎负责排序、匹配、互斥与累计。下面示例用内存规则列表演示;生产可替换为从 DB 加载并带版本号缓存。

package rules

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"time"
)

type MarketingContext struct {
	Now       time.Time
	NewUser   bool
	Subtotal  int64 // 分
	VIPLevel  int
	Category  string
}

type Effect struct {
	RuleID   int
	RuleName string
	Discount int64 // 分,正数表示扣减应付
}

type Rule struct {
	ID         int
	Name       string
	Priority   int
	Enabled    bool
	Start, End time.Time
	MutexWith  []int
	When       func(MarketingContext) bool
	Apply      func(*MarketingContext, *Evaluation) error
}

type Evaluation struct {
	Applied []Effect
	used    map[int]struct{}
}

func NewEvaluation() *Evaluation {
	return &Evaluation{used: make(map[int]struct{})}
}

func (e *Evaluation) markUsed(id int) {
	e.used[id] = struct{}{}
}

func (e *Evaluation) hasUsedAny(ids []int) bool {
	for _, id := range ids {
		if _, ok := e.used[id]; ok {
			return true
		}
	}
	return false
}

type Engine struct {
	rules []*Rule
}

func NewEngine(r ...*Rule) *Engine {
	rs := append([]*Rule(nil), r...)
	sort.Slice(rs, func(i, j int) bool { return rs[i].Priority > rs[j].Priority })
	return &Engine{rules: rs}
}

func (eng *Engine) Evaluate(ctx context.Context, mc MarketingContext) (*Evaluation, error) {
	_ = ctx
	out := NewEvaluation()
	for _, rule := range eng.rules {
		if !rule.Enabled {
			continue
		}
		if mc.Now.Before(rule.Start) || mc.Now.After(rule.End) {
			continue
		}
		if out.hasUsedAny(rule.MutexWith) {
			continue
		}
		if rule.When == nil || !rule.When(mc) {
			continue
		}
		out.Applied = append(out.Applied, Effect{RuleID: rule.ID, RuleName: rule.Name})
		if err := rule.Apply(&mc, out); err != nil {
			out.Applied = out.Applied[:len(out.Applied)-1]
			return nil, fmt.Errorf("rule %d: %w", rule.ID, err)
		}
		out.markUsed(rule.ID)
	}
	return out, nil
}

3.5.3 营销规则引擎实例

func DemoRules() *Engine {
	return NewEngine(
		&Rule{
			ID: 1, Name: "新客首单立减", Priority: 100, Enabled: true,
			Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
			End:   time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC),
			When: func(m MarketingContext) bool { return m.NewUser },
			Apply: func(m *MarketingContext, ev *Evaluation) error {
				d := int64(2000)
				m.Subtotal -= d
				if m.Subtotal < 0 {
					return errors.New("subtotal underflow")
				}
				ev.Applied[len(ev.Applied)-1].Discount = d
				return nil
			},
		},
		&Rule{
			ID: 2, Name: "满 100 减 15", Priority: 80, Enabled: true,
			Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
			End:   time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC),
			MutexWith: []int{1},
			When:      func(m MarketingContext) bool { return m.Subtotal >= 10000 },
			Apply: func(m *MarketingContext, ev *Evaluation) error {
				d := int64(1500)
				m.Subtotal -= d
				ev.Applied[len(ev.Applied)-1].Discount = d
				return nil
			},
		},
		&Rule{
			ID: 3, Name: "数码品类满减", Priority: 70, Enabled: true,
			Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
			End:   time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC),
			When: func(m MarketingContext) bool { return m.Category == "digital" && m.Subtotal >= 5000 },
			Apply: func(m *MarketingContext, ev *Evaluation) error {
				d := int64(500)
				m.Subtotal -= d
				ev.Applied[len(ev.Applied)-1].Discount = d
				return nil
			},
		},
	)
}

生产落地时,可将 When / Apply 替换为声明式条件 AST + 有限动作集,配合配置中心热更新;与第9章「营销计算引擎」衔接时,注意资损防控(双写试算、幂等锁券)。

flowchart TB
	subgraph Engine
		R1[按优先级遍历规则]
		R2{时间窗与开关}
		R3{互斥检查}
		R4{条件匹配}
		R5[执行动作 / 累计结果]
		R1 --> R2 --> R3 --> R4 --> R5
	end
	MC[(MarketingContext)] --> Engine
	Engine --> OUT[(Evaluation / 命中说明)]

3.6 依赖注入与测试

3.6.1 接口与依赖反转

Pipeline 的 Processor、策略的 Reserver、规则引擎依赖的「加载器」都应在内层定义接口,由外层适配器实现——这与第1章的 Port-Adapter 一致。

3.6.2 依赖注入模式

推荐顺序(与第1章一致):

  1. 手动构造(依赖数量可控时最清晰);
  2. Wire 生成装配代码(中大型服务);
  3. Fx(需要生命周期与动态插件时)。

Pipeline 本身通过构造函数注入 CatalogInventoryOrderRepository不要在 Processor 内部 sql.Open

3.6.3 可测试性设计

// ✅ 正例:假库存用于单测 Pipeline
type fakeInventory struct{}

func (fakeInventory) Reserve(ctx context.Context, customerID string, lines []OrderLine) (string, error) {
	_ = ctx
	_ = customerID
	_ = lines
	return "resv_test_1", nil
}

测试用例应覆盖:

  • 每步 Processor失败路径(确保错误带上 Name);
  • 策略注册表的重复 Kind、未知 Kind;
  • 规则引擎的互斥优先级(同一输入下命中顺序稳定)。

3.7 本章小结

本章从电商落地视角串联了四件事:

  1. 痛点:千行函数与深层 if-else 是技术债的外显,要用团队约定的长度与复杂度红线约束。
  2. 函数纪律:单一职责、参数对象化、fmt.Errorf 包装错误,是 Pipeline / 策略能读得懂的前提。
  3. Pipeline:把「创单」等长流程拆为阶段与共享上下文,编排层即文档。
  4. 策略与轻量规则引擎:策略消除「选算法」的 if-else;规则引擎把高频变更从代码挪到数据,并保留优先级与互斥扩展位。
  5. 依赖注入:Processor / Reserver / Loader 均通过接口注入,测试以 Fake 替换,延续第1章的整洁架构边界。

与全书的关系:第1章给出分层与 CQRS 骨架;本章给出同层内的代码组织手法;第4章将从评审与质量门禁角度防止回潮;第9章、第14章将在营销与订单领域放大这些模式。


导航返回目录 | 上一章 | 书籍主页 | 下一章:第4章(即将发布)

导航书籍主页 | 完整目录 | 上一章 | 下一部分:第5章


第4章 架构质量保障

分阶段 Code Review:从架构决策到上线前检查的实战清单


4.1 为什么需要分阶段评审

软件工程里有一句常被引用的话:好的代码是重构出来的,不是一次写出来的。初稿几乎必然欠打磨,真正可靠的质量来自持续、有纪律的迭代。Code Review 把这种迭代前移到合并之前——它把个人习惯拉平到团队标准,把隐性知识显性化,把缺陷拦截在扩散之前。

然而,「随便看看」式的评审往往流于表面:有人只看风格,有人只看有没有明显 bug,有人被 diff 的噪声淹没。结果是:架构层面的失误晚到无法廉价修正,设计层面的模糊在代码里被放大成技术债,上线前才发现性能或可观测性缺口。

4.1.1 单次 PR 评审的认知陷阱

陷阱典型表现后果
问题域混杂在讨论 SQL 索引时顺便「拍板」限界上下文决策缺少干系人与记录,后续反复
噪声淹没信号2000 行 MR 里找架构问题高风险项被 style nitpick 挤出注意力
缺少外部脚手架依赖评审者当天状态遗漏与团队经验强相关,不可复制

Checklist 的价值在于降低认知负荷:在疲劳、时间压力或上下文切换时,仍有一个外部脚手架防止遗漏。它并不替代经验与判断力——遇到清单未覆盖的灰区,恰恰说明团队应该把新教训反哺进清单或 ADR(Architecture Decision Record)。

4.1.2 四阶段评审:在正确时机问正确问题

本书建议按四个阶段组织评审,而不是在单次 PR 里眉毛胡子一把抓:

  1. 架构评审:新项目、新服务、新子域或大规模模块拆分——确认分层、边界、读写路径与技术选型。
  2. 设计评审:接口与模型冻结前——核对聚合、命令 / 查询、领域事件与模式选型是否与领域一致。
  3. 代码评审:日常 MR——用 SOLID、函数质量、命名、错误处理与依赖方向守住实现细节。
  4. 上线前检查:发布窗口——补齐性能、并发、可观测性、测试、回滚与文档。
flowchart LR
  A[架构评审<br/>设计期] --> B[设计评审<br/>详设期]
  B --> C[代码评审<br/>MR 期]
  C --> D[上线前检查<br/>合并期]
  D --> E[发布 / 观测 / 复盘]

4.1.3 运作建议:让清单「活」起来

  • 责任人明确:架构项由 Tech Lead / 架构负责人主评;设计项由领域 Owner 主评;PR 项由作者与至少一名熟悉该域的审阅者共担;上线前项与 SRE / On-call 对齐。
  • 粒度分层:巨型 MR 可先要求作者附「自审清单」勾选说明,再在评论里对争议点逐条引用章节编号,避免无结构的「感觉不对」。
  • 与工具链结合:复杂度、静态检查、依赖图、覆盖率门槛作为门禁;清单作为人工语义层补充(例如:覆盖率够了但测的是 happy path,仍需人眼过业务不变量)。
  • 可追溯结论:架构与设计阶段的结论落在 ADR、RFC 或设计文档;Code Review 只核对「实现是否背离结论」。PR 中发现架构级问题应上升到设计讨论,而不是在局部 hack 里修掉症状。

团队实践案例

案例 1:架构评审会的标准流程(某电商团队实践)

时机:新服务立项、重大重构(影响 3+ 服务)、技术选型变更

参与者

  • 必需:Tech Lead、系统负责人、相关团队代表
  • 可选:SRE(高可用关注)、DBA(存储关注)、安全(合规关注)

流程(60 分钟):

  1. 背景介绍(5分钟):系统负责人讲解业务背景、问题域、核心挑战
  2. 架构方案宣讲(15分钟):分层、限界上下文、读写路径、技术选型
  3. 质疑与讨论(30分钟):按 4.2 清单逐项检查,重点追问:
    • 依赖方向是否违反?
    • 边界划分是否合理?(参考第 2 章战略设计)
    • 读写比假设是否量化?
    • YAGNI 检查:是否过度设计?
  4. 决策与记录(10分钟)
    • 通过 / 有条件通过 / 回炉重做
    • 记录到 ADR(Architecture Decision Record)
    • 指定 Follow-up 责任人

输出示例(ADR-015):

## ADR-015: 订单域引入 CQRS

### 状态
已批准(2026-04-15)

### 背景
订单查询(订单列表、详情、搜索)QPS 是写入的 50 倍;
当前写模型(含 JOIN)拖慢查询性能。

### 决策
引入 CQRS,读模型使用物化视图(MySQL)+ ES 索引。

### 方案
- 写模型:订单聚合 + Outbox 事件
- 读模型:订阅 OrderPlaced / OrderPaid 事件,更新 order_view 表与 ES
- 一致性:最终一致(可容忍 1-2 秒延迟)

### 风险与对策
- 风险:读写数据不一致
- 对策:对账任务(每小时),差异告警

### 评审结论
通过,需在 Q2 上线前完成性能压测。

案例 2:设计评审中发现聚合边界过大

背景:订单团队在设计评审时提交了一个 Order 聚合,包含订单基础信息、明细、支付记录、履约记录、售后记录。

评审意见

  • 问题:聚合过大,任何字段变更都需要加载整个对象,性能差
  • 追问:「支付记录」是否需要和订单在同一事务中修改?
  • 结论:不需要——支付成功是外部事件触发,可以通过事件异步更新

重构方案

  • Order 聚合:订单基础信息 + 明细(需要强一致性)
  • Payment 聚合:支付记录(独立生命周期)
  • Fulfillment 聚合:履约记录(独立生命周期)
  • 集成:通过领域事件(OrderPlacedOrderPaid)衔接

收益:订单聚合从平均 2KB 缩小到 500 字节,查询性能提升 4 倍。


案例 3:PR 评审中拦截的架构违规

背景:开发者在 HTTP Handler 中直接写 SQL,绕过了应用层。

评审意见

// ❌ 违反依赖方向
func HandleCreateOrder(w http.ResponseWriter, r *http.Request) {
    db := mysql.Default() // Handler 直接依赖基础设施
    _, _ = db.ExecContext(r.Context(), "INSERT INTO orders ...")
}

处理流程

  1. 识别问题:违反 4.2.1 依赖方向检查
  2. 上升讨论:在 PR 中标记 needs-architecture-review
  3. 解决方案
    • 定义应用层用例:PlaceOrderUseCase
    • 定义领域层端口:OrderRepository
    • Handler 只依赖应用层
  4. 后续:将此类问题补充到团队的「评审反模式」文档

经验:当 PR 中出现架构级违规时,不要在代码层面修修补补,而是叫停并重新设计。短期看延迟了交付,长期避免了技术债累积。

4.1.4 何时进入哪一阶段:决策树

flowchart TD
  start([变更进入评审]) --> q1{是否改变系统边界<br/>或核心数据流?}
  q1 -->|是| arch[必须先过架构评审<br/>必要时更新 ADR]
  q1 -->|否| q2{是否改变聚合 / 事件契约<br/>或对外 API 语义?}
  q2 -->|是| design[设计评审 + 契约评审]
  q2 -->|否| code[代码评审 MR]
  arch --> design
  design --> code
  code --> q3{是否进入发布窗口<br/>或影响关键路径 SLO?}
  q3 -->|是| ship[上线前检查]
  q3 -->|否| merge[合并后持续观测]
  ship --> merge

4.2 架构评审阶段

适用时机:立项、新服务、新子域或大规模模块拆分。目标是在写大量代码之前,把分层、边界、一致性、读写特征与技术选型对齐。

4.2.1 分层结构检查

标准:是否明确定义 Domain / Application / Adapter / Infrastructure(或等价四层)?源代码依赖是否一律指向内层(Domain 为最内),外层通过接口向内依赖?

反例(违反依赖方向):HTTP Handler 直接 import 具体 MySQL 驱动或 ORM 包,绕过应用服务与领域端口。

// BAD: handler depends on concrete DB package
import "github.com/org/repo/infra/mysql"

func HandlePlaceOrder(w http.ResponseWriter, r *http.Request) {
    db := mysql.Default()
    _, _ = db.ExecContext(r.Context(), "INSERT INTO orders ...")
}

合规方向:Handler 只依赖应用层用例;持久化通过 Repository 接口在领域或应用边界声明,由 Infra 实现。

// GOOD: handler -> application port -> domain; infra implements port
type PlaceOrderHandler struct {
    App *application.OrderService
}

func (h *PlaceOrderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    cmd, err := decodePlaceOrder(r)
    if err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    if err := h.App.PlaceOrder(r.Context(), cmd); err != nil {
        http.Error(w, err.Error(), http.StatusConflict)
        return
    }
    w.WriteHeader(http.StatusCreated)
}
检查点通过标准常见反模式
依赖方向domain 不引用 adapter / infraHandler 内写 SQL
端口归属Repository 接口由内层拥有接口定义在 infradomain 引用
组装根main / cmd 完成绑定在领域 New* 里创建具体 DB

评审追问:若团队暂时未引入完整四层,是否至少在包级约定 adapter 不得被 domain import,并在 CI 用 grep / 自定义 linter 守护?

4.2.2 限界上下文验证

标准:是否识别 核心域、支撑域、通用域?每个 BC 是否有清晰的 Ubiquitous Language 与对外契约(API / 事件),避免「一个大而全的领域模型」?

反例:订单子域与库存子域共用同一个 Product 结构体,字段含义在两边互相拉扯。

// BAD: one struct serves two contexts with conflicting meanings
type Product struct {
    ID           string
    Title        string
    PriceCent    int64 // pricing in order context
    WarehouseQty int   // stock in inventory context — coupling contexts
}

合规 sketch:不同 BC 使用不同模型与防腐层翻译;集成通过 API、消息或显式 ACL。

// GOOD: separate models + explicit mapping at boundary
type catalog.ProductView struct{ ID, Title string }

type ordering.OrderLine struct {
    ProductID     string
    UnitPrice     Money
    SnapshotTitle string
}

type inventory.StockUnit struct {
    SKU    string
    OnHand int
}
检查点通过标准评审问题
模型隔离各 BC 有独立类型与映射层是否共享「富模型」而非仅 ID?
契约稳定对外 API / 事件有版本与兼容性策略破坏性变更如何灰度?
语言一致docs/glossary.md 或等价物CustomerUser 是否混用?

4.2.3 读写路径分析

标准:是否量化 读写比、延迟与一致性要求?读路径若存在重 JOIN、宽表、复杂筛选,是否考虑 独立读模型 / 投影,而不是全部堆在写模型上?

反例:在命令路径(下单)同步执行多表 JOIN 报表查询,拖慢写入尾延迟。

// BAD: command handler does heavy read for side UI
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) error {
    _ = s.db.QueryRowContext(ctx, `SELECT ... heavy join for dashboard ...`)
    return s.persistOrder(ctx, cmd)
}
// GOOD: split; async projection or query DB
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) error {
    if err := s.orders.Save(ctx, newOrderFrom(cmd)); err != nil {
        return err
    }
    return s.outbox.Publish(ctx, OrderPlaced{OrderID: cmd.IdempotencyKey})
}
检查点通过标准
写路径只持久化命令所需最小一致性数据
读路径物化视图、搜索索引或专用查询服务
指标是否测量 p99 写延迟读 QPS

评审追问:若读是写的两个数量级以上,独立读模型往往是经济解(与第 1 章 CQRS 呼应)。

4.2.4 技术选型审查

标准:存储与中间件是否与 访问模式 匹配(点查、范围扫、全文检索、图关系、流处理)?是否记录选型假设与回退方案?

维度评审问题
数据量与热点预估行数、分区键、热点键
一致性强一致 / 最终一致是否与业务容忍度一致
运维成本备份、多 AZ、升级窗口
合规留存周期、脱敏、跨地域

反例:全文搜索需求用 MySQL LIKE '%keyword%' 扛流量,缺少倒排索引与相关性能力。

过度设计与 YAGNI(纳入技术选型同一关口)

标准:是否仅为已确认的变更点引入抽象?能否用更简单的模型先交付,再演化?

反例:典型 CRUD 后台强行上 DDD + CQRS + Event Sourcing 全家桶,团队无力维护投影与版本化事件。

评审追问:若去掉 Event Sourcing,业务是否仍成立?若答案是肯定的,则 ES 很可能是可选优化而非当前必需。CQRS 是否由观测到的读写不对称驱动,而不是由「流行架构标签」驱动?


4.3 设计评审阶段

适用时机:接口评审、领域模型评审、用例与事件清单冻结前。目标是让 战术设计(聚合、Repo、Command / Query、事件)与战略分层一致。

4.3.1 聚合边界检查

标准一致性边界是否以聚合为单位设计?是否避免在单个事务中强行修改多个聚合根,除非有显式的领域规则与补偿策略?

反例:一个数据库事务内同时更新 OrderInventory 聚合,绕过领域事件与最终一致性。

// BAD: one transaction mutates two aggregates directly
func SaveOrderAndDeductStock(ctx context.Context, tx *sql.Tx, o *Order, inv *Inventory) error {
    if err := persistOrder(tx, o); err != nil {
        return err
    }
    inv.Quantity -= o.LineItems[0].Qty
    return persistInventory(tx, inv)
}
检查点通过标准
聚合根入口外部只能通过根修改状态
一事务一根跨聚合协作走事件 + 最终一致(或已文档化的 Saga)
暴露集合不返回可变内部 slice 引用

聚合根识别补充:外部代码禁止绕过根直接改内部实体(如导出 []*OrderLine 被外部改 Qty)。若根方法数量爆炸,区分是聚合过大还是缺少领域服务

实体与值对象(本小节一并核对):实体有稳定标识、状态变更走受控方法;值对象不可变、按值相等;Money 等禁止提供可变 setter,对外构造函数保证合法组合。

4.3.2 命令查询分离验证

Command 设计

标准:命令是否表达 业务意图PlaceOrderCancelSubscription),而不是贫血 CRUD(UpdateOrder + 任意 map)?

// BAD: command is just a data bag
type UpdateOrderCommand struct {
    OrderID string
    Patch   map[string]any
}
// GOOD: explicit intent
type PlaceOrderCommand struct {
    CustomerID     string
    Items          []OrderItemDTO
    IdempotencyKey string
}
检查点通过标准
语义动词 + 业务名词,可映射到用例
幂等携带幂等键 / 乐观锁(如需要)
失败语义可映射为明确业务结果,而非一律 500

Repository(与命令 / 查询配套检查):接口定义在领域层;方法名表达业务需要(FindActiveByCustomer)而非表驱动;复杂筛选优先归入 Query 侧,避免 Repository 万能方法膨胀。

Query 设计

标准:查询是否直接返回 DTO / 读模型不强行加载完整领域图?是否避免在查询路径上触发写模型副作用?

// BAD: query returns rich aggregate for read-only UI
func (s *QueryService) OrderForUI(ctx context.Context, id string) (*domain.Order, error) {
    return s.orders.LoadFullGraph(ctx, id)
}
// GOOD: dedicated read DTO
type OrderSummaryDTO struct {
    OrderID   string
    Status    string
    TotalCent int64
    PlacedAt  time.Time
}

4.3.3 领域事件设计

标准:关键业务状态变更是否发布 领域事件?命名是否使用 过去式OrderPlacedPaymentCaptured)并携带必要上下文(版本、发生时间)?

// BAD: imperative name
type PlaceOrder struct{ OrderID string }

// GOOD: past tense, domain vocabulary
type OrderPlaced struct {
    OrderID    string
    OccurredAt time.Time
    Version    int
}
检查点通过标准
命名过去式 + 领域词汇
载荷消费者演进所需字段(版本、关联 ID)
投递Outbox / 至少一次 + 消费者幂等

4.3.4 模式选型审查

详设阶段可快速对照下表,避免「每个地方都 if-else」或「每个地方都上框架」。

场景特征推荐模式说明
多步骤顺序流程Pipeline(管道)与第 3 章 Pipeline 呼应
同一接口多种实现策略模式扩展点清晰
频繁变化的业务规则规则引擎 / 规则表驱动需版本化与评审
跨聚合协作领域事件 + Outbox与第 1 章 Outbox 呼应

反例:全系统统一 RuleEngine.Execute(ctx, ruleSetID, facts),但规则集无人版本化与评审,线上等于「可执行的配置漂移」。

合规:规则变更走 PR + 审计 + 影子流量;核心不变量仍保留在代码与单测中,引擎只编排可变的参数化策略


4.4 代码评审阶段

适用时机:每次合并请求。把设计约束落到 Go 代码的可观察性质上。

4.4.1 SOLID 原则检查

对每一项,用「一句检查问句」把握核心;争议点再用第 1 章分层与端口对齐。

原则检查问句典型反例合规方向
S该类型是否只有一个变化理由?OrderService 又发邮件又导 CSV按职责拆服务
O扩展新行为是否无需改稳定路径?switch payment 无限增长PaymentGateway 接口 + 多实现
L实现是否可替换且不 surprise?Charge 静默成功显式 Fake / 诚实错误
I客户端是否不被迫依赖不需要的方法?Storage 胖接口Reader / Writer 隔离
D高层是否依赖抽象?NewAppsql.Open构造注入 Repository

DIP 延伸——包级依赖方向domain 不 import adapter / infraapplication 不直接引用 HTTP、ORM、消息 SDK;无循环依赖(必要时提取 domain/sharedkernel 最小类型)。go list -deps 或 IDE 依赖图可抽查。

LSP 反例 sketch

// BAD: implementation surprises caller
type NoOpPaymentGateway struct{}
func (NoOpPaymentGateway) Charge(ctx context.Context, amount int64) error {
    return nil // silently skips payment
}

4.4.2 函数质量审查

维度阈值 / 标准工具或手段
长度单函数宜 < 80 行拆私有步骤或 Pipeline 阶段
圈复杂度< 10(团队可校准)golangci-lint / gocyclo
嵌套深度< 3Guard clause 早返回
参数个数< 5Options 结构体或 functional options
gocyclo -over 10 ./...
// GOOD: named steps keep orchestration readable
func (h *PlaceOrderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    if err := h.ensureAuth(ctx, r); err != nil {
        h.writeErr(w, err)
        return
    }
    cmd, err := h.decode(r)
    if err != nil {
        h.writeErr(w, err)
        return
    }
    if err := h.app.PlaceOrder(ctx, cmd); err != nil {
        h.writeErr(w, err)
        return
    }
    w.WriteHeader(http.StatusCreated)
}

评审追问context.Context 是否作为 第一个参数 传递 I/O 边界函数,而不是塞进结构体字段隐式携带?

4.4.3 命名与可读性

  1. 变量 / 函数名反映业务术语:名称来自 Ubiquitous Language,而非数据库列名机械翻译。
  2. 团队内一致:同一概念只有一个词(Customer vs User 要治理)。
  3. 避免技术术语代替业务术语:不用 SetStatus(1),而用 MarkShipped()
// BAD: magic status
func (o *Order) SetStatus(s int) { o.status = s }

// GOOD: business verb
func (o *Order) MarkShipped(at time.Time) error {
    if o.status != StatusPaid {
        return ErrInvalidStateTransition
    }
    o.status = StatusShipped
    o.shippedAt = at
    return nil
}

4.4.4 错误处理验证

  1. 禁止静默忽略错误:是否存在 _ = xxx 或空白 if err != nil { }
  2. 错误 wrap 携带上下文:跨层 fmt.Errorf("place order: %w", err)
  3. 区分业务错误与系统错误:调用方能否区分「库存不足」与「应重试的基础设施错误」?
var ErrOutOfStock = errors.New("out of stock")

func (s *InventoryService) Reserve(ctx context.Context, sku string, qty int) error {
    if qty > available(sku) {
        return fmt.Errorf("reserve %s: %w", sku, ErrOutOfStock)
    }
    return nil
}

DDD 战术与聚合不变量(与错误语义一并核对)

聚合不变量 sketch

func (o *Order) AddLine(sku string, qty int, unitCent int64) error {
    if qty <= 0 {
        return ErrInvalidQty
    }
    if o.status != StatusDraft {
        return ErrOrderNotEditable
    }
    lineTotal := unitCent * int64(qty)
    if lineTotal < 0 {
        return ErrOverflow
    }
    o.lines = append(o.lines, OrderLine{SKU: sku, Qty: qty, UnitCent: unitCent})
    o.totalCent += lineTotal
    return nil
}

4.5 上线前检查

适用时机:发布分支、灰度前、重大重构合并前。与功能完成度无关的「生产就绪」项在此收敛。

4.5.1 性能与并发

性能

  • 关键路径是否有 benchmark 或压测基线?
  • 是否关注 alloc/op、GC 停顿、锁竞争(mutex profile)?
  • 异步路径是否避免无界队列导致内存膨胀?
func BenchmarkPlaceOrder(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        // exercise hot path
    }
}

并发安全

// BAD: unsynchronized map writes
var cache = map[string]int{}
func Set(k string, v int) { go func() { cache[k] = v }() }

// GOOD: mutex or single-owner goroutine
type SafeCache struct {
    mu sync.RWMutex
    m  map[string]int
}
检查点通过标准
数据竞争go test -race 纳入 CI 或发布前门禁
泄漏长测采样 NumGoroutine;channel 不阻塞在默认分支
锁内 I/O避免在持锁时调用慢外部依赖

4.5.2 可观测性

标准metrics(RED / USE)、trace(关键 span)、结构化日志request_idorder_id 等关联字段)。

logger.Info("order_placed",
    "order_id", orderID,
    "customer_id", customerID,
    "duration_ms", elapsed.Milliseconds(),
)
检查点通过标准
日志键值字段可查询,而非仅拼接长句
链路跨服务传播 trace 上下文
SLO新路径有指标与告警阈值

4.5.3 测试覆盖

标准:核心业务规则覆盖率按团队约定(例如 > 80%);集成测试覆盖仓储、消息、外部 HTTP 的 fake / 容器。

检查点通过标准
边界表格驱动覆盖错误路径
Flaky修复或隔离,避免 t.Skip 永久化
语义覆盖不变量,而非仅「能跑通」
func TestPlaceOrder_OutOfStock(t *testing.T) {
    t.Parallel()
    // arrange: 0 stock -> expect ErrOutOfStock
}

4.5.4 回滚方案

标准feature flag 或配置开关;数据库迁移可回滚或具备向前兼容的双写 / 双读;事件 schema 向后兼容或双写新字段。

回滚方案检查清单

维度检查项通过标准
代码回滚Feature Flag关键功能可通过配置开关禁用,无需重新发布
数据库迁移双向脚本UP/DOWN 脚本齐全,测试过回滚流程
事件 Schema向后兼容新增字段可选,旧消费者不受影响
API 兼容性版本策略新版本 API 与旧版本共存,客户端可选升级
配置变更灰度发布配置分批推送,每批观察指标后再继续
依赖服务降级预案下游服务故障时,上游可降级(返回默认值/缓存)

Feature Flag 实践

// 使用 Feature Flag 控制新功能
package order

import "context"

type FeatureFlags interface {
    IsEnabled(ctx context.Context, feature string) bool
}

func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) error {
    // 旧逻辑
    if err := s.validateBasic(cmd); err != nil {
        return err
    }
    
    // 新功能:风控检查(可通过 Feature Flag 关闭)
    if s.flags.IsEnabled(ctx, "order.fraud_detection") {
        if err := s.fraudDetector.Check(ctx, cmd); err != nil {
            return err
        }
    }
    
    return s.repo.Save(ctx, newOrderFrom(cmd))
}

收益

  • 新功能上线后发现问题,可立即关闭 Feature Flag,无需回滚代码
  • 灰度发布:先对 5% 用户开启,观察指标后再逐步放量
  • A/B 测试:对不同用户群开启不同策略,对比效果

文档与运维:架构变更(新 BC、事件契约、SLA)同步到 README / ADR / 运维手册;On-call 知道降级、重放消息、解读关键告警;新人能仅凭文档拉起本地依赖(docker-compose / make 目标)。

运维文档模板

## 服务运维手册

### 关键告警
- `order_create_latency_p99 > 500ms`:订单创建延迟过高
  - **可能原因**:数据库慢查询、库存服务超时
  - **处理步骤**:
    1. 查看 Grafana 面板确认瓶颈(DB/库存/计价)
    2. 若库存服务超时,执行降级:`kubectl set env deployment/order INVENTORY_FALLBACK=true`
    3. 通知库存团队排查

### 降级开关
- `INVENTORY_FALLBACK=true`:库存查询降级,使用本地缓存
- `FRAUD_DETECTION=false`:关闭风控检查(紧急情况)
- `PROMOTION_ENABLED=false`:关闭营销试算(性能问题)

### 回滚流程
1. 确认回滚目标版本:`kubectl rollout history deployment/order`
2. 执行回滚:`kubectl rollout undo deployment/order --to-revision=N`
3. 观察监控:关注错误率、延迟、上下游调用
4. 数据库回滚(如需要):执行 DOWN 脚本

4.6 本章小结

4.6.1 全阶段总览表(评审清单)

阶段必查项(高杠杆)
架构评审依赖向内、BC 划分、聚合边界、读写评估、YAGNI
设计评审聚合根入口、值对象不可变、Repo 在领域层、Command 意图、领域事件
代码评审SRP、函数规模与复杂度、业务命名、错误 wrap、依赖方向
上线前Benchmark / 压测证据、并发与 race、可观测性、测试与集成、回滚与文档

4.6.2 MR 描述区模板(可复制)

## Self review (author)
- [ ] 4.4 SOLID: 新类型职责与扩展点合理
- [ ] 4.4 函数长度 / 复杂度 / 嵌套 / 参数个数
- [ ] 4.4 命名与 glossary 一致
- [ ] 4.4 错误 wrap,无静默 `_ = err`
- [ ] 4.4 依赖方向与 DDD 战术(不变量、VO)

## Release readiness (if applicable)
- [ ] 4.5 Benchmark 或压测链接
- [ ] 4.5 并发 / race 检查
- [ ] 4.5 Metrics + logs + traces
- [ ] 4.5 核心规则测试与集成测试
- [ ] 4.5 回滚 / 迁移 / 双写方案
- [ ] 4.5 文档 / ADR 更新

## Design links
- ADR / RFC: ...

4.6.3 实战案例与反模式

案例 A:库存预占接口「顺手」改了聚合边界(设计评审失效)

背景:结算服务在「创单前预占」需求中,直接在订单聚合的事务内更新库存行,图省事。

症状:大促锁竞争升高;库存与订单发布节奏耦合,回滚困难。

处理:设计评审阶段强制改为 OrderPlaced / ReserveStockRequested 事件驱动或显式 Saga;代码评审拦截「双聚合同一事务」。

案例 B:营销规则 JSON 线上漂移(模式选型 + 运维失守)

背景:规则引擎读取未版本化的 JSON,运营后台可直接保存到生产。

症状:线上行为与测试环境不一致,难以复盘。

处理:规则集 版本号 + PR 审核 + 审计日志;核心不变量仍在单测与代码中;影子流量验证。

案例 C:Handler 直连 DB(架构评审后置到 PR)

背景:原型代码直接进入主干,后续 MR 只在 SQL 层修修补补。

症状:领域规则散落在 SQL;单测必须起库。

处理:上升架构评审,引入 端口 + 用例;本 MR 仅允许「垂直切片」式重构到合规结构,不接受继续堆 SQL。


案例 D:缺少性能测试导致的线上故障

背景:订单服务上线了「批量取消」功能,代码评审通过,但未做性能测试。

线上故障

  • 运营同学一次性取消 5000 个订单
  • 服务在循环中逐个发送取消事件到 Kafka,耗时 30 秒
  • 期间所有订单查询请求超时(共享同一个 goroutine 池)
  • 用户投诉量激增

根因分析

  • 代码评审通过:功能逻辑正确,无明显bug
  • 缺失上线前检查:没有性能测试,没有评估「批量场景下的资源占用」

改进方案

  1. 补充 4.5.1 性能检查:批量操作必须有 Benchmark
  2. 异步化:批量取消改为后台任务,分批处理(每批 100 个)
  3. 限流:批量接口加频控,防止运营误操作

经验:代码评审通过≠生产就绪。上线前检查(4.5)是最后一道防线,必须覆盖性能、并发、可观测性。


案例 E:聚合不变量在 PR 中被破坏

背景:订单聚合有不变量「总价 = 各明细之和」,某次 PR 为了修复 bug,直接修改了 TotalAmount 字段。

代码变更

// BAD: 直接修改总价,破坏不变量
func (o *Order) ApplyDiscount(amount int64) {
    o.TotalAmount -= amount // ❌ 绕过了明细,破坏一致性
}

后果

  • 订单详情页显示的小计与总价不一致
  • 财务对账时发现差异,追溯到这次变更

评审反思

  • 设计评审阶段应明确聚合不变量(4.3.1)
  • 代码评审阶段应检查是否有直接修改聚合字段的行为
  • 测试应覆盖不变量(如 assert(order.Total == sum(order.Lines))

正确方案

// GOOD: 通过明细修改,自动更新总价
func (o *Order) ApplyDiscountToLine(lineIndex int, discountAmount int64) error {
    if lineIndex >= len(o.Lines) {
        return ErrInvalidLineIndex
    }
    o.Lines[lineIndex].UnitPrice -= discountAmount
    o.recalculateTotal() // 重新计算总价,保证不变量
    return nil
}

func (o *Order) recalculateTotal() {
    total := int64(0)
    for _, line := range o.Lines {
        total += line.UnitPrice * int64(line.Qty)
    }
    o.TotalAmount = total
}

案例 F:缺少回滚方案的数据库迁移

背景:库存服务需要新增字段 reserved_qty,开发者提交了 PR 包含数据库迁移脚本。

问题

  • 只有 UP 脚本,没有 DOWN 脚本(无法回滚)
  • 没有双写策略:新代码直接依赖新字段,回滚时会报错

线上故障

  • 新版本上线后发现性能问题,需要回滚
  • 回滚代码后,服务启动失败(读取不存在的字段)
  • 被迫紧急修复:手动删除字段、重新上线旧版本

改进方案(4.5.4 回滚方案):

  1. 三阶段迁移
    • 阶段 1:加字段,代码双写(写新旧两个字段),读旧字段
    • 阶段 2:代码切换为读新字段
    • 阶段 3:删除旧字段
  2. 每阶段可独立回滚:任何一步出问题都能回到上一阶段
  3. UP/DOWN 脚本齐全:迁移工具(如 migrate)强制要求两个方向

评审清单补充

  • 数据库迁移是否有 DOWN 脚本?
  • 新字段是否通过双写 / 双读策略引入?
  • 回滚后服务是否仍能正常启动?

4.6.4 按角色的最小阅读路径

角色建议优先阅读
作者(提 MR)4.4 全文 + 4.6.2 模板
审阅者(同域)4.4.3–4.4.5 + 与 4.3 冲突点
Tech Lead(新模块)4.2、4.3 + 4.5
SRE / On-call4.5 + 事件与迁移说明

4.6.5 核心要点

系统化的 Code Review 不是挑剔,而是把重构前移到成本最低的阶段。按 架构 → 设计 → 代码 → 上线前 四段清单推进,并与第 1 章方法论、第 2 章战略设计、第 3 章战术实现交叉引用,团队可以在一致语言下讨论分层、边界与实现细节。建议将 4.6.1 嵌入 MR 模板,并在复盘时根据失效案例增补第 21 条——最好的 Checklist 永远是活文档。


导航返回目录 | 上一章 | 书籍主页 | 下一部分:第5章

导航书籍主页 | 完整目录 | 上一章 | 下一章


第5章 电商系统全景图

本章定位:在读完第一部分的方法论之后,进入第二部分「电商核心系统设计」之前,用一张可落地的全景图把业务能力、应用系统、数据资产与技术基础设施对齐到同一坐标系。后续第 7 章至第 15 章将沿本章划定的边界逐域深入;第 6 章则在全景之上讨论跨系统集成与一致性模式。

阅读建议:若你已熟悉 DDD 战略术语,可快速浏览 5.2 后进入 5.1 的图示;若你更习惯从接口与数据表入手,建议从 5.1.3 数据架构读起,再回到 5.1.1 校正业务语义。无论哪种路径,都请至少完成 5.4 的三条时序走读,因为后续各章的「集成小节」默认你已经知道主链路的参与者与先后次序

本章产出物(可用于团队对齐)

  1. 一页 限界上下文图(5.1.1)贴在内网架构 wiki。
  2. 一张 服务依赖图(5.1.2)导入架构治理工具(如 Backstage / 内部 CMDB)。
  3. 一份 主数据清单(5.1.3 表格)作为数据 Owner 会议的输入。
  4. 一套 集成模式评审话术(5.3)写进 RFC 模板。
  5. 一张 十二系统职责表(5.5.1)作为新人 Onboarding 必读。

5.1 系统全景架构

中大型电商平台的架构讨论,如果只停留在「微服务拆分清单」,很容易失去业务语义;如果只停留在「业务功能列表」,又难以指导工程依赖与数据落点。实践中常用 EA(企业架构)+ 4A 的多视角方法并行:

视角英文缩写回答的问题本章对应小节
业务架构BA(Business Architecture)平台提供哪些业务能力?投资优先级如何?5.1.1
应用架构AA(Application Architecture)系统如何划分?依赖方向与编排关系?5.1.2
数据架构DA(Data Architecture)主数据、索引、缓存、事件各自承担什么角色?5.1.3
技术架构TA(Technology Architecture)运行时、中间件、可观测性与安全如何承载上述系统?5.1.4

下面四节分别给出图示与解读要点。图示刻意与后续各章的术语保持一致,便于你在阅读第 7 章至第 15 章时「对照地图」。

与博客文章《电商系统设计(一):全景概览与领域划分》的关系:原文从 EA + 4A 视角给出了高质量的全景素材与系列文章索引;本书第 5 章在此基础上做了三件事——按书籍目录重排小节编号;把 C 端读路径(搜索、购物车)与结算编排显式画入应用架构;把十二个核心系统与第 7 章至第 15 章的章节锚点一一对齐,便于纸质阅读时的交叉引用。若你在网上已读过该文,可把本章视为其书籍化、边界化、评审化的增强版。

术语对照(避免口语歧义)

口语本书用语说明
价格中心 / 促销算价计价系统 + 营销系统「谁制定规则、谁做试算快照」应分开讨论。
交易中心订单 + 结算 + 购物车交易是链路,不是单服务。
上架后台商品上架 + 运营平台流程编排与批量工具职责不同。
搜索推荐搜索与导购(第 12 章)推荐可作为子模块,但集成模式与搜索高度相似。

从单体到分布式的认知迁移:在单体时代,模块边界靠包名与 Code Review 维持;在分布式时代,网络边界会放大设计缺陷——原本一次函数调用的地方,变成了超时、重试与部分失败。因此全景章的价值,不在于「数有多少个微服务」,而在于为每个跨边界调用预先分配 一致性语义、超时预算与观测标签。当你在第 14 章阅读订单状态机时,应能指出:某次迁移对应 5.4.2 中的哪一步、失败时由谁补偿。

5.1.1 业务架构(DDD 视角)

从 DDD 战略设计看,电商平台的业务能力应被组织为一组限界上下文(Bounded Context):每个上下文内部有独立的通用语言与生命周期;上下文之间通过显式关系(客户方 / 供应方、防腐层、发布语言等)协作。下图用「限界上下文 + 域分类」表达业务架构,颜色区分核心域、支撑域、通用域(分类标准与第 2 章 2.5 节一致,此处侧重系统级映射)。

flowchart TB
  subgraph Core["核心域(Core Domain)"]
    BC_Order["限界上下文:订单<br/>契约:订单号、状态机、履约编排"]
    BC_Pay["限界上下文:支付<br/>契约:支付单、渠道、清结算"]
    BC_Checkout["限界上下文:结算<br/>契约:试算、预占、拆单预览"]
    BC_Cart["限界上下文:购物车<br/>契约:行项目、会话合并"]
  end

  subgraph Support["支撑域(Supporting Domain)"]
    BC_Product["限界上下文:商品中心"]
    BC_Inv["限界上下文:库存"]
    BC_Price["限界上下文:计价"]
    BC_Mkt["限界上下文:营销"]
    BC_List["限界上下文:商品上架"]
    BC_Ops["限界上下文:B 端运营"]
    BC_Life["限界上下文:生命周期与供给治理"]
  end

  subgraph Generic["通用域(Generic Domain)"]
    BC_Search["限界上下文:搜索与导购"]
    BC_User["限界上下文:用户与会员(外采/标准能力)"]
    BC_Msg["限界上下文:消息通知(外采/标准能力)"]
  end

  BC_Cart --> BC_Checkout
  BC_Checkout --> BC_Order
  BC_Order --> BC_Pay

  BC_Checkout -.->|试算/ Hydrate| BC_Price
  BC_Checkout -.->|预占| BC_Inv
  BC_Checkout -.->|券与活动校验| BC_Mkt

  BC_Order -.->|快照与金额确认| BC_Price
  BC_Order -.->|库存确认/回退| BC_Inv
  BC_Order -.->|营销锁定与扣减| BC_Mkt
  BC_Order -.->|商品快照| BC_Product

  BC_List --> BC_Product
  BC_List --> BC_Inv
  BC_List --> BC_Price
  BC_Ops --> BC_Product
  BC_Ops --> BC_Inv
  BC_Ops --> BC_Mkt
  BC_Ops --> BC_Price
  BC_Life --> BC_Product
  BC_Life --> BC_List

  BC_Search -.->|发现与列表| BC_Product
  BC_User -.->|身份与权益| BC_Order
  BC_Msg -.->|异步触达| BC_Order

读图要点

  1. 核心域集中了「钱与承诺」相关的上下文:购物车暂存购买意图,结算把意图推进为可支付的约束集合(价格、库存、优惠),订单把承诺持久化为合同,支付完成资金侧的闭环。
  2. 支撑域提供可售性、可算价、可营销、可供给四类「规则与主数据」能力;它们高度影响交易,但行业模式相对可参照。
  3. 通用域中的搜索本书会深入(第 12 章),因其在工程上与商品、计价、库存的 Hydrate 编排强耦合;用户与消息等更常采购标准方案,在全景中保留接口位即可。

上下文映射(与第 2 章衔接):上图中实线箭头多表示「客户方依赖供应方」的下游调用关系;虚线表示「通过发布语言(Published Language)或 ACL 防腐」的弱耦合。落地时建议显式标出:

  • 供应方(Upstream):商品中心对「商品快照 ID」、计价系统对「价格快照版本」、库存对「预占凭证」拥有定义权。
  • 客户方(Downstream):订单与结算消费上述契约,但不应要求供应方暴露内部表结构。
  • 防腐层(ACL):对接供应商、旧单体或外采营销引擎时,把外部模型挡在边界之外,避免污染核心域通用语言。

面试与评审中的表述模板:「业务架构图不是组织架构图;它描述的是能力边界与语义所有权。若两个团队共改一张宽表,通常意味着限界上下文识别失败或缺少明确的集成契约。」

5.1.2 应用架构(微服务视角)

应用架构关注可部署单元之间的依赖。原则是:依赖方向自上而下、由稳定侧指向易变侧,避免出现「基础数据服务回调订单服务」这类环。下图在博客原文分层思路上,补全 C 端读路径(搜索、购物车)与结算编排,并标注后续章节编号,便于索引。

flowchart TB
  subgraph L0["接入与 BFF"]
    GW[API Gateway / BFF]
  end

  subgraph L_read["读路径与暂存"]
    SearchSvc["搜索与导购服务<br/>第12章"]
    CartSvc["购物车服务<br/>第13章"]
  end

  subgraph L_data["主数据与规则基座"]
    ProductSvc["商品中心<br/>第7章"]
    InvSvc["库存系统<br/>第8章"]
    MktSvc["营销系统<br/>第9章"]
    PriceSvc["计价系统<br/>第11章"]
  end

  subgraph L_trade["交易编排与资金"]
    CheckoutSvc["结算编排服务<br/>第13章"]
    OrderSvc["订单系统<br/>第14章"]
    PaySvc["支付系统<br/>第15章"]
  end

  subgraph L_supply["供给与运营"]
    ListingSvc["商品上架<br/>第10章"]
    OpsSvc["供给与运营管理<br/>第10章"]
    LifeSvc["生命周期协调<br/>第10章"]
  end

  GW --> SearchSvc
  GW --> CartSvc
  GW --> CheckoutSvc
  GW --> OrderSvc
  GW --> PaySvc
  GW --> ListingSvc
  GW --> OpsSvc

  SearchSvc --> ProductSvc
  SearchSvc --> InvSvc
  SearchSvc --> PriceSvc
  CartSvc --> ProductSvc

  CheckoutSvc --> PriceSvc
  CheckoutSvc --> InvSvc
  CheckoutSvc --> MktSvc
  CheckoutSvc --> OrderSvc

  OrderSvc --> ProductSvc
  OrderSvc --> InvSvc
  OrderSvc --> MktSvc
  OrderSvc --> PriceSvc
  OrderSvc --> PaySvc

  ListingSvc --> ProductSvc
  ListingSvc --> InvSvc
  ListingSvc --> PriceSvc
  OpsSvc --> ProductSvc
  OpsSvc --> InvSvc
  OpsSvc --> MktSvc
  OpsSvc --> PriceSvc
  LifeSvc --> ProductSvc
  LifeSvc --> ListingSvc

依赖解读

  • 商品中心是多数读路径与订单快照的事实来源(System of Record for catalog);库存、计价、营销在各自上下文内维护规则,但在创单链路上被订单编排调用。
  • 结算服务常实现为独立部署的「长事务 / Saga 编排器」,在应用层与购物车解耦:购物车偏会话与展示,结算偏资源锁定与一致性门槛(详见第 13 章)。
  • 上架、运营、生命周期在应用层可能合并为一个「供给平台」团队维护的多个服务,逻辑上仍建议按限界上下文拆分数据与发布节奏(第 10 章展开)。

典型调用链(便于与 5.4 对照)

用户意图入口服务同步扇出(节选)异步副作用(节选)
搜索列表搜索与导购商品中心、计价、库存 Hydrate曝光日志、排序特征回流
加购购物车商品中心校验 SKU无或弱:会话写 Redis
打开结算页结算编排计价试算、库存预占、营销校验审计日志、风控评分
提交订单订单系统快照固化、库存确认、营销扣减OrderCreated 驱动清购物车、发券统计
去支付支付系统渠道路由、收银台创建PaymentSucceeded 驱动分账、消息触达

循环依赖治理:若发现「商品中心回调订单」一类需求,优先改为事件订阅查询倒置(由订单侧拉取快照),而不是在数据层打开反向通道。

5.1.3 数据架构

数据架构回答三件事:主数据放哪、派生数据如何构建、事件与缓存如何对齐。下图描述一条典型的「写主库、异步投影、读多路」路径,与第 1 章 CQRS 与 Outbox 思路衔接。

flowchart LR
  subgraph Writers["写入侧(Command Path)"]
    SVC_W[业务服务<br/>订单/商品/库存等]
    DB[(MySQL 集群<br/>事务边界内)]
    Outbox[(Outbox 表<br/>同库事务)]
  end

  subgraph Bus["集成与解耦"]
    MQ[Kafka / Pulsar<br/>领域事件总线]
    CDC[CDC 可选<br/>Binlog 流]
  end

  subgraph Readers["读取侧(Query Path)"]
    ES[(Elasticsearch<br/>搜索索引)]
    Redis[(Redis<br/>库存热点/购物车)]
    DW[(数仓 / OLAP<br/>分析投影)]
  end

  SVC_W -->|本地事务| DB
  SVC_W -->|同事务写入| Outbox
  Outbox -->|Relay| MQ
  DB -.->|可选| CDC
  MQ -->|商品变更订阅| ES
  MQ -->|库存变更| Redis
  MQ -->|订单事实| DW
  CDC --> ES

落地要点

  1. 订单、支付、商品主档等强一致实体以 MySQL(或同类)为权威存储;跨聚合协作优先 Outbox + 消息(见第 1 章 1.3.9),避免「双写」在故障时无法对账。
  2. 搜索索引、推荐特征、报表属于派生视图,允许最终一致;延迟由业务容忍度与补偿任务共同约束。
  3. 库存常见「Redis 扛热点 + MySQL 审计」的双存储形态,必须单写者(Single Writer)与周期对账(第 8 章)。

主数据与派生数据清单(评审用)

数据类型权威存储常见派生副本一致性策略
商品主档MySQL(商品中心库)ES 文档、CDN 静态化、本地缓存Outbox / CDC → 最终一致
价格规则与快照MySQL + 计价服务缓存订单行上的快照 JSON创单时以订单持久化为准
可售库存Redis 计数 + MySQL 流水搜索侧的「是否有货」标签单写者 + 定时对账
订单合同MySQL(订单库)数仓订单事实表、客服只读库Binlog / 事件双播
支付单与账务MySQL(支付库)渠道对账文件、会计凭证T+0 / T+1 对账任务

数据所有权一句话:谁对「业务不变量」负责,谁就拥有该数据的写入 API;其余路径只能投影或引用。

离线数仓与实时数仓的边界:订单与支付事件进入数仓后,用于分析与风控建模,不得反向写回在线交易库作为业务依据;若运营需要「实时看板」,应通过专用 OLAP 或流式聚合服务读取消息总线,而不是直接查询订单主库拖垮 P99。若确需运营干预线上数据,应走带审批的正式 API 与审计日志,而不是「数仓导表回灌」。这类约束也是第 4 章上线前检查中「数据变更路径」的必审项。

5.1.4 技术架构

技术架构把应用服务映射到运行时与平台能力:流量入口、服务通信、数据存储、异步集成、可观测性与零信任边界。下图为参考拓扑,实际规模会按环境裁剪。

flowchart TB
  subgraph Edge["边缘与接入"]
    CDN[CDN / WAF]
    LB[负载均衡]
    GW2[API Gateway<br/>鉴权 限流 mTLS]
  end

  subgraph Runtime["服务运行时"]
    SVC_POD[Kubernetes Pods<br/>Go 微服务]
    Mesh[可选 Service Mesh<br/>重试 熔断 流量镜像]
  end

  subgraph Data["数据与中间件"]
    MY2[(MySQL)]
    RD2[(Redis)]
    ES2[(Elasticsearch)]
    KF2[Kafka]
  end

  subgraph Platform["平台能力"]
    REG[服务注册发现]
    CFG[配置中心]
    SEC[密钥管理]
    LOG[日志聚合]
    MET[指标与告警]
    TRACE[分布式追踪]
  end

  CDN --> LB --> GW2 --> SVC_POD
  GW2 --> Mesh
  Mesh --> SVC_POD
  SVC_POD --> MY2
  SVC_POD --> RD2
  SVC_POD --> ES2
  SVC_POD --> KF2
  SVC_POD --> REG
  SVC_POD --> CFG
  SVC_POD --> SEC
  SVC_POD --> LOG
  SVC_POD --> MET
  SVC_POD --> TRACE

与后续章节的关系:第 7 章至第 15 章主要在应用与数据架构层面展开;当你评估「是否需要 Service Mesh」「Kafka 分区策略」时,应回到本节检查观测性是否先于网格消息是否已成为事实管道等平台前提。

非功能需求(NFR)与全景的对应关系

  • 可用性:网关限流与服务熔断保护核心交易路径;搜索与报表故障不得拖垮创单。
  • 性能:读路径大量使用缓存与索引;写路径控制扇出深度,结算页试算可合并批量 RPC(第 13 章)。
  • 安全:密钥不进仓库;支付回调验签在独立模块;内部服务 mTLS 或网络策略隔离。
  • 可观测性:以 trace_id 贯穿网关、结算、订单、支付;对 Saga 每一步有结构化日志与业务指标(转化率、预占失败率)。
  • 合规与审计:订单与支付字段变更可追溯;营销补贴与实付金额可对账。

渐进式演进建议:早期可用「单体 + 清晰包边界」模拟上图拓扑;当团队规模与发布冲突上升时,再按限界上下文拆出独立部署单元,避免「先拆微服务、后补边界」的高成本路径。

容灾与多活(点到为止):技术架构图未展开「单元化 / 多 Region」,但在全景阶段应预留认知:订单与支付数据往往要求 Region 内强一致 + 跨 Region 异步复制;搜索索引与购物车会话更适合 就近读取。若在多活场景下仍沿用单 Region 的强同步调用链,容灾切换时容易遭遇「依赖未起、核心不可用」;因此第 6 章在谈 Saga 时也会隐含「地理边界上的超时预算」问题。


5.2 核心域与支撑域

DDD 强调:不是所有子域都值得同等投入。战略设计的产出之一,是一张「域分类表」,用于指导组织排兵布阵与技术选型(自研 / 定制 / 采购)。

5.2.1 核心域:交易与支付

核心域承载差异化与最高业务风险,典型包括:

限界上下文业务价值失败影响工程特征
订单合同与履约编排的单一事实来源错单、重复下单、无法履约状态机、幂等、Saga、审计
支付资金收付与对账闭环资损、监管与信任危机幂等、渠道适配、账务分录
结算把「可卖」推进为「可付」转化暴跌、资源错锁长事务编排、降级与超时释放
购物车购买意图与会话合并体验与转化问题高并发读写、合并策略

本书将订单、支付、购物车与结算作为交易链路主轴(第 13 章至第 15 章),并在第 6 章从一致性模式上把它们串成可复用的集成语言。

投资与组织策略(与第 2 章 2.5 节对照)

维度建议
团队配置核心域配最强工程与业务分析能力;接口契约由领域 Owner 签字。
发布节奏核心域应支持高频小步发布 + 特性开关;重大促销前冻结非关键变更。
质量门禁核心域 PR 适用第 4 章全阶段评审;支付与订单变更默认要求双人审。
技术债核心域技术债「零容忍排队」;偿债预算单独列项,不与功能挤同一队列。

常见误区:把「购物车」当成纯前端本地存储。实际上购物车是高并发有状态服务,涉及登录合并、库存展示与营销提示,与结算的边界必须在 API 契约上划清(第 13 章)。

5.2.2 支撑域:商品、库存、营销、定价与供给

支撑域是核心域的「地基」:没有可售商品与可算价格,订单与支付无从谈起;没有库存与营销约束,结算编排也会失去输入。

分组限界上下文与核心域的接口关系
商品与供给商品中心、上架、运营、生命周期提供 SPU/SKU、快照、上下架状态;不直接参与支付
规则与资源库存、营销、计价提供预占、券活动、试算与快照;被结算与订单编排调用

第 7 章至第 11 章分别深入各支撑系统;第 10 章从组织上常合并「上架 + 运营 + 生命周期」,但限界上下文仍建议在模型层分开,以避免「一个上帝服务」拖垮发布节奏。

为什么支撑域也值得深度自研:支撑域虽非「卖点」,却是故障的放大器。例如库存超卖、计价错误、营销叠加漏洞,都会在订单层集中爆发。架构评审中常问:「若该支撑域宕机 30 分钟,核心域能否优雅降级?」——答案决定缓存策略、兜底价、降级开关的设计深度。

与核心域的集成契约(摘要)

  • 商品中心输出:快照 ID、类目路径、禁售标签
  • 库存输出:预占凭证、可售数量区间、渠道库存类型(第 8 章二维模型)。
  • 营销输出:可叠加规则集、锁定 token、预算占用凭证
  • 计价输出:试算结果哈希或版本号,供创单时校验「结算页所见即所得」。

5.2.3 通用域:用户、搜索、消息等

通用域标准化程度高,通常采购或薄封装即可;例外是搜索与导购:虽然模式成熟,但在中大型平台中与商品、价格、库存的实时编排深度交织,本书第 12 章单独成章。

能力常见策略与交易链关系
用户与会员SSO、OAuth、IdP提供主体身份与风控标签
消息通知短信、邮件、Push订阅订单与支付事件
搜索ES + 召回排序 + HydratePDP/列表需联动计价与库存态

反模式提醒:把「通用域 = 可以随便写」等同于降低质量要求。正确做法是:减少自研范围,但不降低 SLO 与可观测性要求

关于搜索域的「重要性升级」:从严格 DDD 分类看,搜索常被归为通用域(技术方案成熟);但从业务入口与 GMV 贡献看,它又接近核心体验。本书采取工程折中:在域分类上保留通用属性,在章节权重上按核心链路对待(第 12 章),因其失败模式会直接影响列表价、库存态与活动标签的呈现。

用户与消息:用户域提供主体标识与会员等级,消息域消费订单与支付事件做触达。二者与交易链的耦合主要是读侧鉴权异步通知,应避免在下单同步路径强依赖外部推送可用性。


5.3 系统间的交互模式

跨系统协作可归纳为三类:同步 RPC异步事件数据同步(批式或流式)。它们不是互斥的,同一链路常组合使用;选型取决于一致性语义、延迟上限与故障隔离需求。

5.3.1 同步调用

典型场景:结算页试算、创单前库存确认、支付创建。特征是调用方阻塞等待结果,语义接近「读己之写」或强校验。

优点:实现直观、调试路径短。
风险:级联故障、线程占用、超时风暴;需配合超时、重试、熔断、舱壁与清晰的错误契约(第 6 章 6.6 节)。

工程要点(Go 服务常见落地):为出站 RPC 设置上下文超时每依赖独立超时;重试仅对幂等读或带幂等键的写开放;对核心交易路径实施舱壁线程池并发上限,避免试算扇出把进程拖死。返回错误时区分业务可预期错误(如券不可用)与基础设施错误(如超时),前者映射为 4xx 与明确 code,后者触发降级与告警。

电商实例:结算页打开时,编排服务并行调用计价、库存、营销;只要任一关键依赖超时,应整体返回「请稍后重试」或切换至缓存兜底价 + 延迟锁券策略,而不是无限等待(第 13 章详述降级矩阵)。

5.3.2 异步事件

典型场景:订单已创建、支付已成功、商品变更。特征是最终一致,通过消息中间件解耦峰值与异构消费者。

优点:吞吐与弹性好,天然适合多订阅者(搜索索引、数仓、营销统计)。
风险:重复消息、乱序、滞后;需幂等消费、版本号、可补偿流程(第 1 章 Outbox、第 6 章事件驱动)。

工程要点:事件体应携带聚合 ID、版本号、发生时间、幂等键;消费者使用「处理表」或唯一索引实现 at-least-once 下的精确一次业务效果。对支付成功类事件,建议以支付系统 Outbox 为唯一发布源,避免订单与支付双写双发导致重复记账。

电商实例ProductChanged 发布后,搜索索引、推荐特征、运营看板可能各自消费;它们失败不应阻塞商品主事务,但需要通过死信队列与可观测面板暴露积压,防止索引长期陈旧引发客诉。

5.3.3 数据同步

典型场景:搜索索引重建、报表 T+1、跨机房冗余。实现路径包括定时批处理、CDC、双写(谨慎)。

优点:对在线路径侵入小。
风险:延迟与对账;CDC 需处理 schema 演进与回放。

工程要点:优先 CDC + 消息Outbox 形成可回放管道,避免业务代码里手写双写。若必须双写,应配置对账任务比较主从差异并自动修复。搜索全量重建应走蓝绿索引别名切换,避免重建期间查询抖动。

同步 / 异步 / 数据同步对比总览:三者回答的是不同维度的问题——同步保障「此刻的正确」,异步保障「吞吐与解耦」,数据同步保障「派生视图的规模构建」。架构评审可用下图作开场白板,再落到具体接口与 SLA。

flowchart TB
  subgraph Sync["同步 RPC(Request/Response)"]
    S1["一致性:强一致读 / 即时校验"]
    S2["延迟:毫秒级 P99 约束"]
    S3["故障:调用链扩散 → 需熔断舱壁"]
    S4["典型:试算 创单校验 支付创建"]
  end

  subgraph Async["异步事件(Message/Event)"]
    A1["一致性:最终一致 + 补偿"]
    A2["延迟:秒级可接受 / 削峰"]
    A3["故障:隔离好 → 消费者独立重试"]
    A4["典型:订单已支付 商品变更广播"]
  end

  subgraph DataSync["数据同步(Batch / CDC)"]
    D1["一致性:以快照或日志为准"]
    D2["延迟:分钟级 ~ 小时级"]
    D3["故障:可回放 / 对账修复"]
    D4["典型:搜索索引 数仓 跨库复制"]
  end

  Q["选型提问:调用方能否接受短暂不一致?失败能否补偿?是否必须占用用户请求线程?"]
  Q --> Sync
  Q --> Async
  Q --> DataSync

Go 侧抽象示例:在应用层用接口表达三种出口,避免在业务代码里散落 HTTP 客户端细节。

package integration

import "context"

// SyncPricing 同步计价试算(RPC):强一致读、可返回明确业务错误码。
type SyncPricing interface {
	QuoteCheckout(ctx context.Context, req CheckoutQuoteRequest) (*CheckoutQuoteResult, error)
}

// AsyncPublisher 异步领域事件(Outbox relay 之后投递)。
type AsyncPublisher interface {
	PublishOrderPaid(ctx context.Context, evt OrderPaidEvent) error
}

// ProductIndexProjector 数据同步投影(可由 Kafka consumer 或 CDC worker 实现)。
type ProductIndexProjector interface {
	ApplyProductChanged(ctx context.Context, change ProductChangedLog) error
}

结算编排中的幂等键(与第 13、14 章衔接):同一用户多次点击「提交订单」时,应以客户端或服务端生成的 idempotency_key 贯穿结算会话与创单请求,避免重复扣减与重复订单。下面展示在 Go 中的最小承载方式(字段名可按公司规范调整):

package checkout

import "time"

// CheckoutSession 表示结算页的一次编排会话。
type CheckoutSession struct {
	SessionID        string
	UserID           string
	IdempotencyKey   string
	QuoteVersion     int64
	ExpiresAt        time.Time
}

5.4 数据流转全景

本节用三条完整时序链把 5.1 至 5.3 的静态结构串成动态故事线。图中参与者命名与后续章节标题一致,便于对照。

三条链路的共同模式(背诵版):每条链路都同时存在 同步确认(保证局部不变量)与 异步传播(放大读模型与运营可见性)两类步骤。设计时请先标出「哪一步失败会导致资损或客诉」——这些步骤应尽量落入短事务 + 明确幂等键;其余步骤尽量推出消息总线。另一个共同点是 Hydrate:搜索与列表在 C 端读路径上,往往需要二次拉取商品、价格、库存以修补索引延迟;这与订单创单时的「快照固化」是同一思想的不同形态——用显式版本与快照对抗时间差

与大促场景的关系:商品流在大促前表现为「批量改价、改库存、改活动」的洪峰;订单流在秒杀瞬间表现为「创单与扣减」的尖峰;支付流在峰值表现为「渠道限流与回调延迟」。全景上需要预留 降级开关与异步化边界:例如列表页短时跳过非关键 Hydrate、支付回调与订单状态更新解耦等(细节分散在第 8、12、13、15 章)。

5.4.1 商品数据流

覆盖从 B 端提交到 C 端可搜、可算、可卖的闭环。

sequenceDiagram
  autonumber
  actor Merchant as 商家/运营
  participant Listing as 商品上架
  participant Life as 生命周期管理
  participant Product as 商品中心
  participant Inv as 库存系统
  participant Price as 计价系统
  participant Bus as 消息总线
  participant ES as 搜索索引
  participant Search as 搜索与导购

  Merchant->>Listing: 提交上架申请
  Listing->>Listing: 审核/风控策略
  Listing->>Product: 创建/更新 SPU SKU
  Listing->>Inv: 初始化/同步可售库存
  Listing->>Price: 配置基础价与费用模板
  Product->>Bus: ProductChanged 事件
  Inv->>Bus: InventoryChanged 事件
  Price->>Bus: PriceSheetChanged 事件
  Bus-->>ES: 投影商品文档
  Note over ES: 异步最终一致
  Merchant->>Life: 发起下架/同步修正
  Life->>Product: 状态迁移与编辑边界控制
  Life->>Listing: 回流审核/发布任务
  Search->>ES: 列表/搜索召回
  Search->>Product: Hydrate 缺失字段(可选)
  Search->>Price: 列表价 Hydrate(可选)
  Search->>Inv: 可售状态 Hydrate(可选)

阶段解读:步骤 1~5 属于 B 端写路径,强一致要求集中在「商品主档 + 初始库存 + 基础价」三者是否同事务可见;多数平台会拆成多个本地事务 + Saga,用补偿保证最终一致。步骤 6~8 属于 异步投影,搜索可见略滞后于库表写入是预期行为,但应对运营提供「索引就绪率」指标。步骤 9~12 体现 生命周期对上架与主数据的回流:下架、供应商同步修正、违规处罚都会触发再次审核或索引失效。

伏笔:第 7 章讲清商品模型与快照;第 8 章区分预占与实物库存;第 10 章拆解上架与运营编辑的权限与状态机;第 12 章展开 Hydrate 编排与降级。

5.4.2 订单数据流

从结算页到订单持久化,强调编排、快照与回滚责任

sequenceDiagram
  autonumber
  actor User as 用户
  participant GW as API Gateway
  participant Cart as 购物车
  participant Checkout as 结算编排
  participant Price as 计价系统
  participant Inv as 库存系统
  participant Mkt as 营销系统
  participant Product as 商品中心
  participant Order as 订单系统
  participant Bus as 消息总线

  User->>GW: 进入结算页
  GW->>Checkout: 打开结算会话
  Checkout->>Price: 试算(基础价+营销+费用)
  Checkout->>Inv: 库存预占(或预校验策略)
  Checkout->>Mkt: 券/活动可用性校验
  Price->>Product: 读取商品主数据
  User->>GW: 提交订单
  GW->>Order: 创单请求(携带试算令牌/版本)
  Order->>Product: 拉取/确认商品快照
  Order->>Price: 固化价格快照
  Order->>Inv: 确认预占或二次扣减
  Order->>Mkt: 锁定或扣减营销资源
  Order->>Order: 持久化订单与状态机
  Order->>Bus: OrderCreated 事件
  Bus-->>Cart: 提示清理已下单行(异步)

阶段解读:结算阶段(打开结算页)与创单阶段(提交订单)必须对试算结果有明确版本策略:常见做法是计价返回 quote_version 或签名摘要,订单持久化时校验,防止「页面价与实付不一致」引发纠纷。库存侧若已在结算预占,创单多为确认;若仅在结算校验、创单时才预占,则需评估高峰下的重试风暴(第 8 章)。营销锁定与扣减宜拆成「锁定 → 确认 / 释放」两阶段,与订单状态机对齐,避免券冻结长期占用。

异常路径(图中未展开但工程必备):创单任一步失败应沿 Saga 反向释放预占与券锁定;若订单已写库但后续异步失败,应依赖订单状态机驱动补偿任务,而不是人工改库。

伏笔:第 11 章定义试算与快照边界;第 13 章给出 Saga 步骤与超时释放;第 14 章深入状态机与补偿;第 6 章把这些步骤抽象为可复用的一致性模式。

5.4.3 支付数据流

聚焦支付单生命周期与订单回写、清结算衔接。

sequenceDiagram
  autonumber
  actor User as 用户
  participant Order as 订单系统
  participant Pay as 支付系统
  participant Chan as 支付渠道
  participant Ledger as 账务/清结算
  participant Bus as 消息总线

  User->>Order: 去支付
  Order->>Pay: 创建支付单(order_id 幂等键)
  Pay->>Pay: 路由渠道路由/风控标签
  Pay->>Chan: 调用渠道下单接口
  Chan-->>User: 收银台/重定向
  Chan-->>Pay: 异步支付结果通知
  Pay->>Pay: 验签 幂等 状态机推进
  Pay->>Order: 同步支付结果(或订单轮询)
  Pay->>Ledger: 记账/分账指令
  Pay->>Bus: PaymentSucceeded 事件
  Bus-->>Mkt: 实付触达营销核算(可选)
  Note over Pay,Order: 失败/关单需可补偿:关支付单、回滚营销、释放库存(与各域策略绑定)

阶段解读:支付创建应以 order_id(或业务侧支付请求号)做天然幂等键,渠道侧重复调用不产生重复扣款。回调处理必须「先记账、后通知订单」或采用可对账的两阶段状态:确保账务系统(Ledger)与支付核心状态一致。PaymentSucceeded 事件驱动营销核算、分润、积分等下游时,仍应坚持 Outbox 语义,避免在回调线程堆叠扇出。

与订单的边界:订单系统关心「应付金额与履约状态」;支付系统关心「渠道收单结果与资金事实」。订单不应直接保存渠道原始报文全字段,应由支付系统规范化后回写支付结果摘要

伏笔:第 15 章展开渠道适配、对账与退款;第 6 章讨论跨系统幂等与补偿事务编排。


5.5 系统边界总览

5.5.1 各系统的职责边界(十二个核心系统)

下表给出本书采用的十二个核心可部署系统(与 5.1.2 应用架构及第 7 章至第 15 章对应)。「不负责」列用于架构评审时的负面清单,防止边界侵蚀。

编号系统核心职责明确不负责深入章节
1商品中心SPU/SKU、类目属性、商品快照与主数据质量库存扣减、营销计算、支付第 7 章
2库存系统可售量、预占/确认/释放、对账与供应商同步价格计算、订单状态机第 8 章
3营销系统券/活动/补贴、圈品、预算与防刷订单持久化、支付渠道第 9 章
4计价系统多场景试算、费用、价格快照与降级营销资金账、库存数量第 11 章
5搜索与导购Query、召回、排序、Hydrate 编排不作为订单或支付事实来源第 12 章
6购物车行项目暂存、合并、批量操作不持有支付契约第 13 章
7结算编排结算页 Saga、预占协调、拆单预览不替代订单合同存储第 13 章
8订单系统合同、状态机、拆单、履约协调入口渠道密钥、资金划拨第 14 章
9支付系统支付单、渠道路由、回调、对账与退款商品主数据、库存数量第 15 章
10商品上架上架审核、发布流程、供给侧状态机不复制商品中心全量模型职责第 10 章
11供给与运营管理批量任务、配置工具、权限与审计不绕过商品中心直接写「影子库」第 10 章
12生命周期与供给治理同步/编辑/下架边界、跨系统编排约束不实现全量搜索召回第 10 章

说明:第 10 章在目录上合并了上架、运营与生命周期;在工程上可拆为多服务,但在边界表中仍建议分开陈述职责,以便治理。

十二系统「一句话职责」扩展(评审口播版)

  • 商品中心:维护「卖得是什么」——结构化商品、类目约束与面向订单的快照能力;对外暴露稳定读模型与快照创建接口。
  • 库存系统:维护「还能卖多少」——把可售量、渠道库存、预占凭证与对账闭环收敛在库存库表与热点缓存中。
  • 营销系统:维护「怎么促卖」——圈品、券活动、补贴预算与叠加互斥规则;不负责把最终应付金额写入订单。
  • 计价系统:维护「收多少钱」的规则引擎与快照——对接商品基础价与营销减免,输出可校验的试算版本。
  • 搜索与导购:维护「怎么找得到」——索引与排序是手段,Hydrate 与场景识别是业务核心;索引永远晚于主库一秒是常态而非事故。
  • 购物车:维护「用户想买什么」——会话级行项目与合并逻辑,不承担资金与库存的最终承诺。
  • 结算编排:维护「现在能不能付」——把试算、预占、营销校验收敛为短窗口内的可执行计划,再交给订单持久化。
  • 订单系统:维护「合同与履约状态」——状态机、拆单、快照引用与对外协调接口;不保存渠道密钥与支付通道报文。
  • 支付系统:维护「资金事实」——支付单、渠道、回调、账务分录与对账;不反向驱动商品编辑。
  • 商品上架:维护「供给侧流程」——审核、发布、异步补偿与状态机;它是编排器而非商品主数据的越权写入者。
  • 供给与运营管理:维护「运营效率」——批量导入导出、配置工具、权限审计;所有落库应通过正式领域 API。
  • 生命周期与供给治理:维护「时间与责任的边界」——上下架、同步冲突、编辑互斥与跨系统回滚策略的协调者。

按价值链聚类(便于向业务方解释)

价值链阶段涉及系统(编号见上表)业务语言
进场与治理10、11、12、1、2、4「有货、有价、合规可售」
发现与暂存5、6、1、4、2、3「看得见、算得清、加得进」
成交与资金7、8、9「锁得住、记得准、收得到」

5.5.2 边界不清的常见问题

  1. 商品中心写库存流水:库存的并发语义与对账域应收敛在库存上下文,否则易出现双写不一致。
  2. 营销系统直接改订单金额:优惠「算出来」与订单「记下来」应分离;否则退款与审计难以追溯。
  3. 搜索索引当主库:索引延迟会导致「搜得到但买不了」,必须在 Hydrate 或结算侧再次以权威服务为准。
  4. 支付系统承载订单状态机:资金状态与履约状态相关但不同;混写会导致渠道回调与拆单场景难以治理。
  5. 计价系统写订单行:计价负责「算」与试算令牌;订单负责「记」与版本校验。混写会让退款金额拆分失去依据。
  6. 购物车持有库存预占:预占属于资源锁定,应落在结算或订单编排;购物车仅存意图与展示缓存。
  7. 运营后台直连生产库改价:绕过计价与审计,极易产生监管与对账风险;应走审批流 + 正式 API。
  8. 搜索服务在召回阶段调用支付:读路径不应触碰资金系统;价格与活动以 Hydrate 调用计价与营销只读接口为界。

5.5.3 边界划分原则(落地检查清单)

  1. 单一事实来源(SSOT):每个聚合只有一个权威上下文持久化。
  2. 编排与状态分离:编排服务可以无状态或仅存会话;合同状态由订单上下文持有。
  3. 读模型可替换:搜索、推荐、报表可重建;不可重建的是资金流水与订单合同
  4. 跨域用契约,不用隐式共享表:表连接是反模式;用 API、事件与明确 DTO。
  5. 把「能不能买」的最后一次校验放在离钱最近且可审计的一步(通常是创单或支付创建)。
  6. 明确「编排」与「领域服务」:编排负责步骤顺序与超时;领域服务负责业务规则判定。二者勿混在同一「上帝类」中。
  7. 每个跨系统接口都有 SLI:例如试算 P99、索引延迟上限、支付回调处理延迟;无指标的接口等于无边界。
  8. 用例驱动的边界测试:为每个系统维护「本系统拒绝处理的请求样例」,在 CI 或契约测试中固定下来,防止回归侵蚀。

5.6 本章小结

本章是全书的总领章,目标不是替代后续各章的深度,而是建立三样东西:同一套词汇(限界上下文与十二个系统)、同一张依赖图(应用与数据架构)、同一套交互纪律(同步 / 异步 / 数据同步的组合拳)。

你可以带走的关键结论

  1. 四视角对齐:BA 决定投资与语义边界,AA 决定可部署单元与依赖方向,DA 决定权威数据与投影路径,TA 决定规模化运行时能力。缺任一视角,评审容易出现「各说各话」。
  2. 十二个核心系统:商品中心、库存、营销、计价、搜索、购物车、结算编排、订单、支付、商品上架、供给与运营、生命周期治理——分别对应第 7 章至第 15 章的主体叙事;其中第 10 章在工程上常合并多个部署单元,但在治理上仍建议按职责拆分讨论。
  3. 域分类指导排兵:核心域(订单、支付、结算、购物车)追求正确性与可审计性;支撑域追求稳定与可替换的集成契约;通用域追求成本与 SLO 的平衡。搜索处于「通用技术 + 核心体验」的交叉带,第 12 章会展开其 Hydrate 与降级策略。
  4. 交互模式不可偏科:只有同步会导致故障传播;只有异步会拉长不一致窗口;只有批式同步无法满足实时导购。实际架构是在 SLA、成本、团队成熟度 约束下的组合。
  5. 三条主链路是阅读地图:商品流回答「货怎么进来并被发现」;订单流回答「承诺如何形成」;支付流回答「资金如何闭环」。后续章节均可挂载到这三条链上自检:本章的哪个小节、哪张图覆盖了当前话题。

与第 6 章的衔接:第 6 章将把 5.3 的交互模式上升为 Saga、幂等、对账、事件驱动 等一致性语言,并把 CAP 折中讲透。建议在读第 6 章前,先用 5.4 的时序图遮住文字,尝试口述一遍每条箭头上的失败与补偿,检验是否已建立全景肌肉记忆。

与第 7 章至第 15 章的衔接:进入任一系统章时,建议先回答四个问题——谁是上游、谁是下游、我的 SSOT 是什么、我发布哪些事件。答不上来则回到 5.5.1 边界表补齐。

面向架构师的自检清单(离开本章前):能否在 10 分钟内手绘 5.1.2 依赖图并标出三条可能形成环的依赖?能否用业务语言向非技术干系人解释「为什么搜索不是订单的一部分」?能否列举支付回调失败时的三个系统状态组合及各自补偿动作?若尚不能,建议在笔记中重画一遍 5.4 时序图,再进入第 6 章。

建议阅读顺序:若你更熟悉业务,可按 5.4 节三条链路走读,再回看 5.1.1 的限界上下文;若你更熟悉工程,可从 5.1.2 与 5.3 节开始,把依赖与交互模式对齐后再进入各系统章节。若你正在准备架构评审,可携带 5.1.3、5.3.3 与 5.5.3 作为一页纸附录。


导航书籍主页 | 完整目录 | 上一章 | 下一章

导航书籍主页 | 完整目录 | 上一章 | 下一章


第6章 系统集成与一致性设计

本章定位:在第 5 章全景图之后,本章把「跨系统协作」从经验描述提升为可复用的方法论:用 CAP 与一致性谱系解释取舍,用 Saga / 事件驱动 / 幂等 / 对账四条主线覆盖绝大多数集成场景。后续第 7–15 章在讲商品、库存、营销、计价、购物车、订单、支付时,都会反复回到本章的词汇表与模式库。

阅读提示:若你更熟悉「一个数据库里用本地事务搞定一切」的单体思维,建议带着三个问题读完全章——第一,分区发生时你更不愿意牺牲哪一项(一致性、可用性、延迟);第二,失败是可逆还是不可逆(决定补偿语义);第三,重复请求与重复消息是否已被建模为一等公民(决定幂等与 Outbox 是否值得投资)。

章节衔接:第 1 章给出 Outbox 与 CQRS 动机;第 5 章给出系统交互全景;第 8、15 章分别从库存与支付角度展示对账与幂等的「硬落地」;第 13–14 章把 Saga 与幂等键嵌进交易链路。本章把这些散落能力收敛为一套可迁移的检查清单,避免每个团队在各自服务里重复发明名词与重复踩坑,并便于与权限分级、审计留痕制度及变更评审流程对齐,并支持持续优化。

写作约定:文中 Go 示例为教学裁剪版,聚焦结构与语义;生产落地请补齐超时、退避、注入、鉴权、观测字段与错误码规范,并把「成功 / 业务失败 / 可重试失败」三类语义贯穿全链路。阅读时建议对照你们公司的网关规范、数据库迁移规范与消息平台手册做二次裁剪,并把示例中的表名与字段名映射到真实审计字段、合规要求与数据留存周期。


6.1 分布式事务挑战

跨服务、跨库、跨供应商的电商链路,本质上是在没有全局锁的前提下完成协作。讨论「分布式事务」之前,先要统一语言:我们追求的往往不是银行核心那种「强一致实时可见」,而是用户可理解的一致性财务可审计的一致性的组合。

把一致性目标写成 SLO:方法论章节最容易停留在概念层,落地时建议把「一致性」翻译成可监控指标。例如:「创单成功后 99.9% 用户在 1 秒内读到订单详情」「支付成功后 5 分钟内订单状态同步完成」「对账差异在 T+1 日内闭环率」。没有指标,团队就会在事故后争论「这算不算一致」。SLO 也会反向约束集成模式:如果你承诺秒级读己之所写,就不能把关键读路径绑在慢消费者之后。

可观测性是最小一致性保障:跨系统集成里,最大风险不是「短暂不一致」,而是「不知道不一致发生在哪」。因此链路追踪(Trace)、关联 ID(order_id / payment_id / idempotency_key)、以及跨系统日志检索规范,应被视为一致性基础设施的一部分,而不是「运维锦上添花」。当你能在 5 分钟内从用户投诉定位到具体参与方与具体请求,你已经把大量 P1 事故降级为 P3。

6.1.1 CAP 理论在电商中的应用

CAP 指出:在**网络分区(P)**客观存在时,系统只能在 C(线性化一致性)A(可用性:非故障节点可继续响应) 之间做权衡。电商里更务实的表述是:

  • P 不可逃避:机架故障、运营商割接、云厂商区域抖动都会制造分区;你的系统要么承认它,要么假装它不存在(后者通常以事故收场)。
  • C 与 A 不是 0/1:多数业务选择的是 延迟可接受的一致性(例如订单创建立即可见、搜索索引秒级滞后)与 降级后的可用性(例如暂停个性化推荐但不阻断下单)。

下面的三角图用三个顶点表达「三者不可同时取满」的直觉(示意,非形式化证明)。

flowchart LR
  subgraph CAP[CAP 在分区下的取舍三角]
    C((C\n强一致 / 线性化))
    A((A\n高可用 / 持续服务))
    P((P\n分区容错))
  end
  C ---|分区下难与 A 兼得| A
  A ---|要一致则需限制写入| P
  P ---|承认分区| C

  style C fill:#ffebee
  style A fill:#e8f5e9
  style P fill:#e3f2fd

电商映射示例

场景更偏向原因
支付记账、余额扣减CP 倾向差错成本高,宁可失败重试也不要错账
商品详情、推荐列表AP 倾向短暂旧数据可接受,可用性影响转化
创单 + 库存预占工程上常选 BASE + 补偿同步强一致跨多服务代价大,Saga / TCC 更常见

在 CAP 之外,工程讨论里还常用 PACELC:在**正常(Latency)分区(Partition)**两种状态下,分别在 C 与 A、**C 与 L(延迟)**之间权衡。电商系统的真实矛盾往往不在「选 CP 还是 AP」这种标签,而在「把哪一类不一致暴露给用户」:用户能容忍搜索列表晚 1 秒,但很难容忍「支付成功却订单仍待付款」这种语义断裂。于是产品与技术要共同定义 RPO/RTO 的业务翻译——例如「支付回调最长可延迟多久仍可被用户理解」「库存预占展示与真实可售不一致的上限是多少」。

另一个常见误区是把 「强一致」当成银弹。跨服务的两阶段提交(2PC)与其变体在微服务规模下会放大故障域:协调者不可用、参与者长时间持锁、尾延迟抖动都会被交易洪峰放大。更稳妥的工程路径通常是:在单个聚合 / 单个服务内用数据库事务守住硬约束,跨边界用 Saga / TCC / 可靠消息组合,再用 对账兜住不可避免的外部异步。

6.1.2 一致性分类

从工程师落地角度,建议把「一致性」从口号拆成可见性容错模型经济后果三层,再映射到技术策略。

flowchart TB
  subgraph L1[用户可见一致性]
    U1[读己之所写 Read-your-writes]
    U2[单调读 Monotonic reads]
    U3[因果一致 Causal]
  end
  subgraph L2[跨系统事实来源]
    S1[单主库权威 Single source of truth]
    S2[多副本异步复制]
    S3[事件驱动物化视图]
  end
  subgraph L3[财务 / 履约后果]
    F1[强一致账务]
    F2[最终一致业务态]
    F3[对账闭环]
  end
  L1 --> L2
  L2 --> L3

谱系速查

  • 强一致(线性化 / 串行化):单个分片内靠数据库事务;跨分片靠分布式协调(代价高、故障域大)。
  • 会话一致 / 单调读:常见于「用户刚下的单,列表里立刻能看到」——可用主从路由策略与 sticky 读实现。
  • 最终一致:允许短暂漂移,但必须有版本、水位、对账与补偿兜住边界。
  • 因果一致:适合「评论回复依赖发帖可见」等场景;电商里多用于协作类附属功能,主交易链路仍以订单状态机为准。

为了把「一致性」从论文语言落到排障语言,建议团队在架构评审里强制区分三类问题,并把它们映射到不同手段:

  1. 读可见性问题:用户「刚操作完却看不到」——多数是读写路由、缓存、投影延迟;优先查主从延迟、Outbox 堆积、消费者 lag。
  2. 跨系统事实冲突:订单说已支付、支付说未成功——多数是回调丢失、幂等键不一致、状态机非法迁移;优先查渠道流水与平台流水对齐。
  3. 跨时间窗口的统计不一致:GMV 看板与财务口径对不上——多数是异步汇总、时区、退款冲正口径;优先查离线任务水位与口径文档。

下表给出「用户感知」与「系统手段」的对照,用于和需求方对齐预期(避免把技术极限包装成业务承诺)。

用户感知目标典型手段需要额外付出的代价
下单后列表立刻出现读主库 / 读己之所写路由主库压力、热点风险
全站搜索秒级更新Outbox + 近实时索引运维复杂度、契约治理
支付成功即刻可履约同步确认 + 强校验 + 幂等尾延迟、渠道配额
大促高峰仍可下单异步化、削峰、降级读短暂不一致、对账压力

6.1.3 典型场景分析

  1. 创单链路(订单、库存、营销、计价):跨多个限界上下文,同步点越多尾延迟越大;典型解法是「结算页强校验 + 创单 Saga + 异步投影」。
  2. 搜索与商品主数据:索引滞后是常态,关键是可观测的延迟降级展示(第 12 章)。
  3. 支付回调与渠道对账:渠道侧状态与平台侧状态异步收敛;必须幂等、必须可对账(第 15 章与 6.5 呼应)。
  4. 供应商库存:供应商为权威时,平台侧是快照 + 同步策略;分区时以可解释的拒单 / 延迟确认交换「永不超卖」承诺(第 8 章)。
  5. 退款与售后:涉及支付渠道、营销回退、库存回冲、供应商拦截等多参与方,不可逆节点(已打款、已出票)会把「补偿」切换为「工单 / 人工」。这类链路更需要 Saga 日志 + 对账批次号 + 幂等退款单号 三件套,避免重复退款与部分成功。
  6. 秒杀与抢购:热点 SKU 的约束是「库存单调递减」与「请求风暴」叠加。工程上通常是 Redis 原子预减 + 异步落库对账,而不是跨服务同步强一致;否则会把尾延迟与失败面扩散到整个站点入口。
  7. 清结算与分账:平台、商家、渠道、营销补贴多方账本需要在 T+N 周期收敛。这里的「一致性」更像会计恒等式:借贷平衡、可追溯、可审计,而不是用户请求路径上的毫秒级一致。
  8. 跨境与多币种:汇率快照、税费、支付路由使得「金额一致」必须显式引入 snapshot_id / rate_version,否则对账会把产品问题误判为技术故障。

这些场景的共同点,是都要求你在架构文档里写清楚一句话:一致性的责任边界在哪里结束。例如库存预占由库存服务负责语义,订单只保存 reserve_id 凭证;支付金额以支付核心的记账为准,订单侧只保存 pay_amount 快照与引用号。边界写清楚,集成才不会退化成「谁都能改一笔」。


6.2 Saga 编排模式

Saga 把长事务拆为本地事务序列,用补偿事务撤销已提交步骤的可逆效果。它不要求全局锁表,也不要求所有参与方实现预留接口(对比 TCC 的 Try/Confirm/Cancel),因此在跨团队集成中最常见。

很多团队会把「分布式事务」理解成「找一个中间件把多个数据库一次性提交」。在电商微服务里,这种理解往往会把问题推向两个极端:要么 过度依赖全局协调(可用性与尾延迟受损),要么 完全回避一致性话题(只能靠人肉修数)。Saga 的价值在于承认现实:跨服务没有免费的全局原子性,但可以把不确定性收敛到 可测试的本地事务 + 可审计的补偿 + 可对账的凭证。当你能清楚说出「哪一步失败会留下什么外部痕迹」,你就已经比大多数项目更接近可控。

落地时还要区分 业务失败系统失败:库存不足是业务失败,通常不应触发重试;渠道超时是系统失败,需要退避重试与查询对齐。把两者混在一个 if err != nil 里,Saga 会表现为「无意义重试放大雪崩」或「该补偿却不补偿」。建议在错误模型里显式引入 BusinessErrorTransientError(或等价错误码),编排器据此分支。

6.2.1 Saga 基础概念

  • 子事务(Local Transaction):在一个服务 / 一个库边界内可原子提交。
  • 补偿(Compensation):语义上撤销前一步业务效果;必须幂等,且要能处理「原操作其实失败」的空补偿。
  • Saga 日志:记录每一步状态,支撑断点续跑、人工介入与审计。

TCC(Try-Confirm-Cancel) 相比,Saga 对参与方的接口要求更低,但业务侧要承担更多「补偿语义」的设计成本。可以用下表做模式选型(不是非此即彼,很多系统会在支付子域用更严格的协议,在营销子域用 Saga)。

维度SagaTCC
参与方改造低:正向 + 补偿即可高:三阶段接口与资源预留语义
一致性强度依赖补偿正确性与对账Try 成功后可更强约束提交
失败处理补偿链 + 人工兜底Cancel 路径必须可靠
典型适用创单、结算编排支付、余额、库存强约束场景(团队成熟度高)

6.2.2 编排 vs 协同

flowchart LR
  subgraph Orch[编排 Orchestration]
    O[OrderOrchestrator\n集中状态机]
    O --> I[InventorySvc]
    O --> M[MarketingSvc]
    O --> P[PricingSvc]
  end

  subgraph Cho[协同 Choreography]
    E1[OrderCreated 事件]
    E2[InventoryReserved 事件]
    E3[MarketingLocked 事件]
    OrdSvc[订单服务]
    InvSvc[库存服务]
    MktSvc[营销服务]
    OrdSvc --> E1 --> InvSvc
    InvSvc --> E2 --> MktSvc
    MktSvc --> E3
  end

选型经验

  • 编排:调试路径清晰,适合强流程(创单、退款、结算);缺点是编排器可能成为热点与变更集中点。
  • 协同:解耦参与者,适合弱流程扩展;缺点是全局可观测性与顺序约束更难,需要严格的事件契约与版本治理

电商创单、退款等有严格顺序与对账要求的链路,业界更常见的是编排为主、事件为辅

混合形态:编排器完成「强一致点的凭证收集」(试算快照、预占号、营销锁),聚合根提交后再通过 Outbox 广播 OrderCreated 给搜索、推荐、风控等读模型或旁路系统。这样既保留集中调试与审计的主线,又避免把读侧耦合进同步链路。

6.2.3 补偿机制设计

补偿不是「数据库回滚」的同义词,而是业务语义撤销。设计要点:

  1. 可逆性分级:库存释放、券解锁可逆;已发货、已出票常不可逆,需要转入人工 / 工单 / 财务流程。
  2. 补偿的幂等:网络重试会导致补偿重复执行;应用层用业务幂等键状态检查保证安全。
  3. 失败面分类:业务拒绝(库存不足)与基础设施故障(超时)要分流,后者才触发重试与退避。
  4. 超时与悬挂:子调用超时后,编排器要通过查询接口把「未知」落成「成功 / 失败」之一,再决定前进或回滚。

Saga 持久化与恢复:请求线程崩溃、实例重启、发布滚动都会导致「执行到一半」的外部视图。生产系统应至少落一张 saga_instance 表(或等价事件日志),字段建议包含:saga_idbiz_key(幂等键)、current_stepstatus(running/compensating/succeeded/failed)、payload_jsonstarted_atupdated_atversion(乐观锁)。恢复线程按 status=running 扫描,结合每步的 query 结果把流程推向下一个合法状态。没有这张表,你只能依赖日志拼凑现场,事故复盘成本会指数级上升。

并发与重入:同一个 biz_key 的重复请求不应启动第二个 Saga 实例。常见做法是「数据库唯一约束 + 返回进行中的 saga_id」或「Redis 分布式锁(短 TTL + 续期谨慎)」。锁方案要警惕:锁超时后另一个实例进入,会造成双轨执行;因此最终仍应以 幂等键与 Saga 状态机 作为真相来源。

sequenceDiagram
  participant O as Orchestrator
  participant S as StepService
  O->>S: forward()
  alt 成功
    S-->>O: OK
    O->>O: persist saga step done
  else 明确失败
    S-->>O: FAIL business
    O->>S: compensate previous (idempotent)
    S-->>O: OK
  else 超时 / 未知
    S-->>O: TIMEOUT
    O->>S: query()
    S-->>O: committed? yes/no
    O->>O: reconcile then forward or compensate
  end

6.2.4 订单创建的 Saga 实例

下面给出一个教学裁剪版结构完整的 Go 示例:三步正向(计价快照绑定、库存预占、营销锁定)与两步补偿(释放库存、解锁营销)。示例省略了真实 RPC、链路追踪与部分错误包装,但保留编排骨架、幂等键透传、补偿逆序等关键结构。

Saga 流程图

flowchart TD
  Start([开始 CreateOrderSaga]) --> T1[Step1: AttachPriceSnapshot]
  T1 -->|失败| X1([结束: 失败无补偿])
  T1 -->|成功| T2[Step2: ReserveInventory]
  T2 -->|失败| C1[Comp1: 无需库存补偿]
  T2 -->|成功| T3[Step3: LockMarketing]
  T3 -->|失败| C2[Comp2: ReleaseInventory]
  T3 -->|成功| Done([持久化订单\n提交本地事务])
  C2 --> X2([结束: 已回滚库存])

  style Done fill:#e8f5e9
  style X1 fill:#ffebee
  style X2 fill:#fff3e0

Go 编排骨架(单文件可 go run,将下游调用替换为真实 gRPC / HTTP SDK 即可落地):

Walk-through(对照流程图读代码)

  1. AttachSnapshot 失败时,系统尚未占用库存与营销资源,因此直接返回错误即可,对应流程图左侧「失败无补偿」分支。
  2. Reserve 成功后,reserve_id 成为库存侧的外部凭证;后续任何释放都必须携带它,并在库存服务侧以幂等方式处理。
  3. LockPromo 失败进入补偿:只释放库存,不解锁营销(因为锁未成功)。若未来某版本 LockPromo 变成「部分成功」(例如锁定成功但写审计失败),必须把补偿扩展为「先查锁是否存在再解锁」,否则会出现补偿空转或补偿遗漏。
  4. Run 成功后,订单服务应在单一本地事务中写入订单主表、明细、价格快照引用、外部凭证集合,并写入 Outbox 触发下游投影。不要把「订单持久化」拆到多个没有事务边界的 RPC 里,否则你会重新发明分布式事务。
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"time"
)

// ---- 端口:生产环境应使用 gRPC / 生成代码 ----

type PricingClient interface {
	AttachSnapshot(ctx context.Context, orderID, snapshotID string) error
}

type InventoryClient interface {
	Reserve(ctx context.Context, idempotencyKey, orderID, sku string, qty int) (reserveID string, err error)
	Release(ctx context.Context, idempotencyKey, reserveID string) error
}

type MarketingClient interface {
	LockPromo(ctx context.Context, idempotencyKey, orderID, promoRef string) (lockID string, err error)
	UnlockPromo(ctx context.Context, idempotencyKey, lockID string) error
}

// ---- 编排器 ----

type CreateOrderCommand struct {
	OrderID        string
	IdempotencyKey string
	PriceSnapshot  string
	SKU            string
	Qty            int
	PromoRef       string
}

type CreateOrderSaga struct {
	Pricing   PricingClient
	Inventory InventoryClient
	Marketing MarketingClient
}

func (s *CreateOrderSaga) Run(ctx context.Context, cmd CreateOrderCommand) error {
	if cmd.OrderID == "" || cmd.IdempotencyKey == "" {
		return errors.New("invalid command")
	}

	// Step 1: 绑定计价快照(失败则无资源占用)
	if err := s.Pricing.AttachSnapshot(ctx, cmd.OrderID, cmd.PriceSnapshot); err != nil {
		return fmt.Errorf("attach snapshot: %w", err)
	}

	// Step 2: 库存预占
	reserveID, err := s.Inventory.Reserve(ctx, cmd.IdempotencyKey+":inv", cmd.OrderID, cmd.SKU, cmd.Qty)
	if err != nil {
		return fmt.Errorf("reserve inventory: %w", err)
	}

	// Step 3: 营销锁定
	lockID, err := s.Marketing.LockPromo(ctx, cmd.IdempotencyKey+":mkt", cmd.OrderID, cmd.PromoRef)
	if err != nil {
		// 补偿:释放库存(逆序)
		if relErr := s.Inventory.Release(ctx, cmd.IdempotencyKey+":inv:rel", reserveID); relErr != nil {
			return fmt.Errorf("lock promo failed: %v; release inventory failed: %w", err, relErr)
		}
		return fmt.Errorf("lock promo: %w", err)
	}

	log.Printf("saga ok order=%s reserve=%s lock=%s", cmd.OrderID, reserveID, lockID)
	// Step 4(示意):在同一服务的数据库事务里插入订单主表与明细
	return nil
}

// ---- 内存桩:演示幂等 + 成功路径 ----

type memPricing struct{}

func (memPricing) AttachSnapshot(ctx context.Context, orderID, snapshotID string) error {
	return nil
}

type memInventory struct {
	released bool
}

func (m *memInventory) Reserve(ctx context.Context, idempotencyKey, orderID, sku string, qty int) (string, error) {
	return "resv_123", nil
}

func (m *memInventory) Release(ctx context.Context, idempotencyKey, reserveID string) error {
	m.released = true
	return nil
}

type memMarketing struct{}

func (memMarketing) LockPromo(ctx context.Context, idempotencyKey, orderID, promoRef string) (string, error) {
	return "lock_456", nil
}

func (memMarketing) UnlockPromo(ctx context.Context, idempotencyKey, lockID string) error {
	return nil
}

// 将 HTTP 客户端映射为 InventoryClient 的示例(真实项目用专用 SDK)
type httpInventory struct {
	client *http.Client
	url    string
}

func (httpInventory) Reserve(ctx context.Context, idempotencyKey, orderID, sku string, qty int) (string, error) {
	// 伪代码:构造 POST /v1/reservations,Header 携带 Idempotency-Key
	return "", errors.New("not implemented in demo")
}

func (httpInventory) Release(ctx context.Context, idempotencyKey, reserveID string) error {
	return errors.New("not implemented in demo")
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	saga := &CreateOrderSaga{
		Pricing:   memPricing{},
		Inventory: &memInventory{},
		Marketing: memMarketing{},
	}
	err := saga.Run(ctx, CreateOrderCommand{
		OrderID:        "ord_1",
		IdempotencyKey: "idem_user_click_001",
		PriceSnapshot:  "pshot_9f3c",
		SKU:            "sku_a",
		Qty:            1,
		PromoRef:       "promo_x",
	})
	if err != nil {
		log.Fatal(err)
	}
}

落地清单(从示例走向生产)

  • 为每一步引入持久化 Saga 表saga_idstepstatuspayloaderror_code)。
  • 所有 outbound 调用携带关联 IDorder_idtrace_ididempotency_key)。
  • 对「超时未知」统一走 query + reconcile 状态机,而不是立刻补偿。

Saga 表结构示例(MySQL,示意):落地时按你们公司的审计规范补全操作者与 trace 字段。

CREATE TABLE saga_instance (
  saga_id       BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_key       VARCHAR(128) NOT NULL,
  name          VARCHAR(64)  NOT NULL,
  status        VARCHAR(32)  NOT NULL,
  current_step  INT            NOT NULL,
  payload       JSON           NOT NULL,
  version       INT            NOT NULL DEFAULT 0,
  created_at    DATETIME       NOT NULL,
  updated_at    DATETIME       NOT NULL,
  UNIQUE KEY uk_saga_biz (biz_key, name)
);

CREATE TABLE saga_step (
  id         BIGINT PRIMARY KEY AUTO_INCREMENT,
  saga_id    BIGINT NOT NULL,
  step_no    INT    NOT NULL,
  step_name  VARCHAR(64) NOT NULL,
  status     VARCHAR(32) NOT NULL,
  req        JSON,
  resp       JSON,
  err        TEXT,
  created_at DATETIME NOT NULL,
  KEY idx_saga (saga_id, step_no)
);

6.3 事件驱动架构

事件驱动架构(EDA)把系统间的耦合从「知道对方的表结构」变为「订阅对方愿意公布的事实」。它与 DDD 的**领域事件(Domain Event)**天然契合:事件名应是业务过去式(OrderPlaced),而不是命令式(PlaceOrder)。

EDA 并不自动带来解耦:如果事件载荷里塞满下游私有字段,或消费者之间隐式依赖顺序却缺乏分区策略,你只会得到「异步耦合的大泥球」。因此本章强调三件事:契约顺序边界可观测的投递语义。与第 16 章的事件发布实践结合时,请把「事件平台能力」与「领域建模能力」分开评估:Kafka 再强也替代不了你对聚合边界的判断。

6.3.1 领域事件

领域事件用于表达聚合内已发生且不可变的事实。好的事件:

  • 自描述:携带必要标识与版本(schema_version)。
  • 可演进:兼容字段新增,慎改语义。
  • 与命令分离:命令可丢弃重试;事件一旦发布,消费者会据此做副作用。

命名与版本:事件名建议稳定且可检索(order.placed.v1),避免把促销规则编码进 topic 名称。载荷里携带 occurred_atproducerschema_version,消费者才能做 向后兼容 解析。另一个实践是把「业务关键字段」与「展示字段」分层:关键字段用于幂等与投影,展示字段允许缺失并由读模型降级。

聚合边界:领域事件应从聚合根的不变量中自然产生,而不是为了通知某个下游临时「造事件」。后者会导致事件泛滥、顺序难以推理、回放成本失控。若你发现自己需要 SomethingMaybeChanged 这类含糊事件,通常意味着限界上下文边界需要重塑。

6.3.2 事件的发布与订阅

flowchart LR
  subgraph OrderBC[订单限界上下文]
    AR[Order Aggregate]
    AR -->|产生| DE[Domain Events]
    DE --> OB[Outbox Table]
  end
  subgraph Infra[基础设施]
    REL[Outbox Relay / Poller]
    BUS[(Message Bus)]
  end
  subgraph Consumers[订阅方]
    InvProj[库存读模型投影]
    Srch[搜索索引增量]
    Risk[风控评分任务]
  end
  OB --> REL --> BUS
  BUS --> InvProj
  BUS --> Srch
  BUS --> Risk

发布订阅的工程细节

  • Topic 分区键:与顺序性强相关的字段(同一 order_id)应映射到同一分区,避免乱序消费;但分区键过粗会造成热点分区,需要业务侧权衡。
  • 消费者组:一组消费者共享进度,实现水平扩展;重平衡(rebalance)会带来短暂停顿,要评估是否影响实时性 SLA。
  • 至少一次投递:因此消费者必须 幂等;常见实现是 UNIQUE(consumer, event_id) 或业务唯一键。
  • 顺序与并行:能并行就并行(不同聚合互不相关),不能并行就必须把「会改变含义的顺序」收敛到单分区或单线程处理器。
  • 背压:投影任务落后时,应有 lag 告警与降级读策略,避免把读路径拖死。

与第 16 章「事件发布」对齐时,把「谁允许发什么事件」纳入治理:事件不是自由文本广播,而是受版本管理的契约。建议在仓库中维护 events/ 目录(或 Buf Schema Registry),把破坏性变更当作发布流程的一部分。

6.3.3 事件溯源(Event Sourcing)

事件溯源(ES)把状态还原为事件流的折叠state = fold(events)。它带来强大审计与回放能力,但也引入:

  • 模型复杂度:投影、快照、版本迁移成本高。
  • 查询压力:多数业务仍需要物化读模型(CQRS)。

电商建议:账务、支付指令、库存流水等强审计子域可评估 ES;一般商品展示、搜索索引用物化视图 + Outbox 性价比更高(与第 1 章 CQRS 小节呼应)。

何时值得上 ES:当你明确需要「按时间回放任意业务态」且愿意投入 投影重建、快照策略、事件迁移工具链;否则先用 审计日志表 + 不可变对象存储归档 + 定期校验 往往更划算。ES 不是银弹,它是把复杂度从数据库迁移到了事件存储与投影运维。

快照(Snapshot):长生命周期聚合(例如会员账户、长期预售订单)如果每次都从头折叠事件,读路径会不可接受。快照本质是「在某版本截断事件流」,需要定义 快照写入频率快照与事件的版本对齐规则

6.3.4 实践要点与 Outbox 完整实现

Outbox 模式解决的核心矛盾是:数据库事务提交消息发布难以跨资源原子化。做法是:在同一本地事务中写入业务表与 outbox 表;由独立进程异步投递到消息总线,实现 at-least-once 发布且不丢单(消费者仍需幂等)。

Outbox 时序图

sequenceDiagram
  participant API as Order API
  participant DB as MySQL
  participant R as Outbox Relay
  participant K as Kafka

  API->>DB: BEGIN
  API->>DB: INSERT orders ...
  API->>DB: INSERT outbox(event_type,payload,status)
  API->>DB: COMMIT
  loop poll
    R->>DB: SELECT ... FOR UPDATE SKIP LOCKED
    R->>K: produce message(idempotent key)
    K-->>R: ack
    R->>DB: UPDATE outbox SET status=published
  end

Go 实现骨架(使用 database/sql;生产环境可替换为 sqlx / ORM,但保持「同事务写两张表」不变):

package outboxdemo

import (
	"context"
	"database/sql"
	"encoding/json"
	"time"
)

type OutboxEvent struct {
	ID        int64
	Aggregate string
	EventType string
	Payload   json.RawMessage
	Status    string // pending / published / dead
	CreatedAt time.Time
}

type OrderRepository struct {
	DB *sql.DB
}

// CreateOrderWithOutbox 演示:订单写入与 outbox 同事务
func (r *OrderRepository) CreateOrderWithOutbox(ctx context.Context, orderID string, evtType string, payload any) error {
	bytes, err := json.Marshal(payload)
	if err != nil {
		return err
	}

	tx, err := r.DB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx, `INSERT INTO orders(id, status) VALUES(?, 'CREATED')`, orderID); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO outbox(aggregate_id, event_type, payload, status, created_at)
		 VALUES(?,?,?,?,?)`,
		orderID, evtType, bytes, "pending", time.Now().UTC(),
	); err != nil {
		return err
	}
	return tx.Commit()
}

// Relay 轮询投递:演示 SKIP LOCKED 多实例安全
type Relay struct {
	DB *sql.DB
}

func (relay *Relay) PollOnce(ctx context.Context, publish func(ctx context.Context, ev OutboxEvent) error) (int, error) {
	tx, err := relay.DB.BeginTx(ctx, nil)
	if err != nil {
		return 0, err
	}
	defer func() { _ = tx.Rollback() }()

	rows, err := tx.QueryContext(ctx, `
		SELECT id, aggregate_id, event_type, payload, status, created_at
		FROM outbox
		WHERE status='pending'
		ORDER BY id ASC
		LIMIT 50
		FOR UPDATE SKIP LOCKED`)
	if err != nil {
		return 0, err
	}
	defer rows.Close()

	var batch []OutboxEvent
	for rows.Next() {
		var ev OutboxEvent
		var agg string
		if err := rows.Scan(&ev.ID, &agg, &ev.EventType, &ev.Payload, &ev.Status, &ev.CreatedAt); err != nil {
			return 0, err
		}
		ev.Aggregate = agg
		batch = append(batch, ev)
	}
	if len(batch) == 0 {
		return 0, tx.Commit()
	}

	for _, ev := range batch {
		if err := publish(ctx, ev); err != nil {
			return 0, err
		}
		if _, err := tx.ExecContext(ctx, `UPDATE outbox SET status='published' WHERE id=?`, ev.ID); err != nil {
			return 0, err
		}
	}
	return len(batch), tx.Commit()
}

func DemoDDL() string {
	return `
CREATE TABLE IF NOT EXISTS orders (
  id VARCHAR(64) PRIMARY KEY,
  status VARCHAR(32) NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  aggregate_id VARCHAR(64) NOT NULL,
  event_type VARCHAR(128) NOT NULL,
  payload JSON NOT NULL,
  status VARCHAR(16) NOT NULL,
  created_at DATETIME NOT NULL,
  KEY idx_outbox_pending (status, id)
);`
}

工程清单

  • Relay 进程要独立扩容,与 API 进程分离;发布失败应退避重试并将多次失败送入死信队列人工处理。
  • 消息体应携带 event_id / aggregate_id / causation_id,与消费者表上的唯一约束联合实现端到端幂等。
  • 与第 16 章「事件发布」衔接时,把事件契约(JSON Schema / Protobuf)纳入 CI,避免「字段悄悄改名」造成投影脏写。

Outbox 运维要点pending 堆积通常不是 Kafka 坏了,而是 Relay 吞吐不足、DB 锁竞争、或下游拒绝消息。建议把以下指标做成仪表盘:outbox_pending_countrelay_lag_secondspublish_fail_ratedead_letter_count。出现持续堆积时,优先扩容 Relay 与检查热点 aggregate_id(大单事件风暴)。

顺序投递 vs 批量投递:某些支付相关事件需要严格顺序,Relay 可以按 aggregate_id 分区串行投递;而搜索增量可批量合并,降低总线开销。关键是不要把「所有事件都塞进一个全局顺序」里,否则系统吞吐会被最慢的消费者绑架。

与第 1 章 Outbox 小节的关系:第 1 章强调「领域事件异步化」的动机与边界;本章补齐 实现骨架、时序与运维指标,便于你在第 7–15 章落地到具体服务时直接对照检查清单。


6.4 幂等性设计通用方案

6.4.1 幂等性的本质

幂等性回答的问题是:同一个业务意图被执行多次,是否与只执行一次等价。分布式系统里重复来源包括:用户双击、网关重试、消息重复投递、回调重放。

从接口语义上,幂等还应区分两类返回:

  • 语义幂等:第二次调用返回与第一次业务等价的结果(可能 HTTP 状态码不同,但业务码一致),典型是支付创建。
  • 严格幂等:第二次调用应尽可能返回同一响应体(含错误),以便客户端无需分支处理;这通常依赖网关或应用侧的 响应缓存

另一个关键维度是 时间窗口:创单幂等键可能只需 24 小时;支付幂等键可能要跨结算周期。窗口外的重复请求应被明确拒绝还是进入人工?这属于产品策略,但必须在技术方案里写死,否则会出现「以为幂等永远有效」的误用。

6.4.2 实现策略

策略适用注意
天然幂等SET status='CANCELLED' WHERE id=? AND status='PAID'仍需防止错误状态迁移
业务幂等键支付、创单、退款需要落库索引与 TTL 治理
令牌桶 / 去重表高并发写定期归档,冷热分离
唯一约束DB 层最终防线冲突即视为重复成功需返回同一结果

组合策略才是常态:接口层挡住「明显重复」;服务层用状态机挡住「非法重放」;数据库用唯一约束挡住「并发双插」。任何单层都可能被绕过(例如内部任务不经过网关),因此不要迷信「只加 Header 就安全」。

测试清单:至少覆盖「并发双请求同一幂等键」「第一次超时后重试」「第一次失败第二次成功」「消息重复投递」四类用例;支付与退款还要覆盖「渠道侧已成功但平台超时」的对称场景(与第 15 章联动)。

6.4.3 各层的幂等性保证

flowchart TB
  subgraph Edge[接入层]
    GW[API Gateway\nIdempotency-Key 透传 / 快速去重]
  end
  subgraph App[应用服务层]
    SVC[Service\n幂等表 / 状态机守卫]
  end
  subgraph Data[数据层]
    DB[(MySQL UNIQUE\n业务键 / 请求键)]
    MQ[消息消费者\n消费位点 + 业务唯一键]
  end
  Client[Client] --> GW --> SVC --> DB
  BUS[(Kafka)] --> MQ --> SVC

接口层示例:幂等键落库

type IdempotencyStore interface {
	// TryBegin 返回 true 表示首次;false 表示重复,应返回缓存响应
	TryBegin(ctx context.Context, key, route string) (bool, error)
	SaveResponse(ctx context.Context, key string, code int, body []byte) error
	GetResponse(ctx context.Context, key string) (code int, body []byte, found bool, err error)
}

func WithCreateOrderIdempotency(store IdempotencyStore, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		key := r.Header.Get("Idempotency-Key")
		if key == "" {
			http.Error(w, "missing Idempotency-Key", http.StatusBadRequest)
			return
		}
		first, err := store.TryBegin(r.Context(), key, r.URL.Path)
		if err != nil {
			http.Error(w, "idempotency error", http.StatusInternalServerError)
			return
		}
		if !first {
			code, body, found, err := store.GetResponse(r.Context(), key)
			if err != nil || !found {
				http.Error(w, "duplicate without cached response", http.StatusConflict)
				return
			}
			w.WriteHeader(code)
			_, _ = w.Write(body)
			return
		}
		// TODO: 包装 ResponseWriter 捕获状态码与 body,成功后 SaveResponse
		next.ServeHTTP(w, r)
	})
}

服务层:把幂等键与业务键(order_idpayment_id)建立映射;对「处理中」状态设置合理超时,避免永久悬挂。

数据层:对 merchant_order_nochannel_trade_no 等建立 UNIQUE 索引;冲突时读取已有行并返回与首次一致的结果体(支付场景极其关键,见第 15 章)。

消息消费者层:除了业务唯一键,还要注意 at-least-once 带来的「处理成功但 ack 前崩溃」重复。常见做法是:业务写入与「消费记录」同事务,或采用 幂等表 + 业务状态机 组合。Kafka 的幂等 producer 只能保证生产端不重复,不能保证消费端。

与第 13–15 章的衔接:购物车提交、结算创单、支付创建与回调,是幂等设计最密集的区域。请把这些链路里的 Idempotency-Key 来源、TTL、冲突返回码统一成一份「交易接口规范」,否则每个团队会发明一种错误语义,客户端与对账都会被拖垮。


6.5 数据一致性保证

6.5.1 最终一致性

最终一致性不是「暂时不一致然后祈祷」,而是满足三个条件:

  1. 收敛性:在无新写入时,系统应到达稳定态。
  2. 可观测性:能度量漂移(延迟、差异条数)。
  3. 可修复性:通过对账与补偿把漂移拉回业务可接受范围。

最终一致的工程含义:允许短暂不一致,但不允许「永远不一致」。因此必须定义 最大允许漂移时间(MTTD)修复时限(MTTR) 的业务含义。例如「支付回调最多延迟 5 分钟,对账必须在 T+1 日内闭环」,这类指标比对程序员说「我们最终一致」更有约束力。

与缓存的关系:读路径缓存(商品详情、列表价)引入的是另一类一致性。原则是:写路径更新权威,再异步失效 / 刷新缓存;不要反过来用缓存驱动写。库存热路径若使用 Redis,必须与第 8 章一样把 对账与回补 当作一等能力,而不是事后补丁。

6.5.2 对账机制

对账回答的问题是:两份账本是否在说同一件事。电商常见对账维度:

  • 平台订单 vs 支付渠道:金额、手续费、状态、退款。
  • 库存流水 vs 实物出库:WMS / OMS 对齐。
  • 营销预算 vs 实际核销:防止薅羊毛与预算透支。

设计要点(与第 8 章库存、第 15 章支付呼应):

  1. 对账文件与解析:渠道侧日终文件 + 平台侧流水导出;解析必须版本化。
  2. 三层匹配:长款(渠道有平台无)、短款(平台有渠道无)、金额不一致。
  3. 差错工单:自动修复仅限白名单场景;其余进入人工复核。
  4. 幂等与回放:同一对账批次重复跑不产生重复账务分录。

对账批次生命周期(建议)INITPARSEDMATCHEDDIFF_GENERATEDFIXUP_APPLIEDCLOSED。每一步落审计日志,支持监管问询与内部复盘。短款与长款不要混在一个工单模板里:短款更像「钱可能丢了」,长款更像「重复记账风险」,处理 SLA 与审批链往往不同。

平台侧数据准备:对账不只读「业务库」,还要聚合 渠道回调日志、网关请求日志、消息投递记录。否则你会出现「业务状态对,但财务凭证缺角」的尴尬。实践中常用 不可变事件流水 作为对账输入之一,因为它比业务表更抗「事后改字段」。

与第 8 章库存对账的衔接:库存侧常见是 Redis 计数与 MySQL 流水、供应商快照三方对齐。支付侧则是平台支付单与渠道清算文件对齐。两者共享同一套工程套路:差异分类 → 自动白名单修复 → 人工复核 → 复盘入库

package recon

import (
	"context"
	"database/sql"
	"time"
)

type ChannelRow struct {
	TradeNo     string
	AmountCents int64
	Status      string
	OccurredAt  time.Time
}

type PlatformRow struct {
	PaymentID   string
	ChannelRef  string
	AmountCents int64
	Status      string
}

// ReconcileBatch 演示:以 channel_ref 对齐(生产需处理多币种、多清算周期)
func ReconcileBatch(ctx context.Context, db *sql.DB, ch []ChannelRow, pf []PlatformRow) error {
	pidx := map[string]PlatformRow{}
	for _, p := range pf {
		pidx[p.ChannelRef] = p
	}
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()

	for _, c := range ch {
		p, ok := pidx[c.TradeNo]
		if !ok {
			_, _ = tx.ExecContext(ctx, `INSERT INTO recon_diff(batch_at, kind, ref, detail) VALUES(?,?,?,?)`,
				time.Now().UTC(), "SHORT_PLATFORM", c.TradeNo, "channel has, platform missing")
			continue
		}
		if p.AmountCents != c.AmountCents || p.Status != c.Status {
			_, _ = tx.ExecContext(ctx, `INSERT INTO recon_diff(batch_at, kind, ref, detail) VALUES(?,?,?,?)`,
				time.Now().UTC(), "MISMATCH", c.TradeNo, "amount or status")
		}
	}
	return tx.Commit()
}

差错分类与处理策略(支付对账视角的抽象):无论渠道是微信、支付宝还是银行卡收单,差异最终都会落到有限几类。

  • 状态不一致但金额一致:常见于回调丢失或延迟。处理上优先以渠道终态为准触发状态迁移,并确保迁移动作幂等。
  • 金额不一致:高风险,通常需要冻结相关支付单与关联订单,禁止自动发货,进入财务复核;同时要回溯是否存在重复退款、重复记账或币种转换错误。
  • 手续费 / 分账字段不一致:多见于规则变更窗口与历史数据混跑。处理上应引入 规则版本号生效时间,避免用新规则解释旧交易。
  • 时间窗口不一致:渠道文件是「清算日」,平台流水是「交易发生日」。对账前要先把口径写到 SOP:以哪个时区、哪个切分点为准。

自动化修复的边界:只有满足「可逆、可证明、可回放」三条件的动作才适合自动化。例如「补写缺失的支付回调记录」可以自动化;「直接给用户退款」通常需要更高等级审批与多重校验。自动化的目标是减少人工 机械劳动,不是替代 风险判断

6.5.3 补偿任务

补偿任务与 Saga 补偿不同:后者在请求生命周期内;前者是异步修复器,用于:

  • 回调迟到导致的悬挂单;
  • 消息堆积造成的投影落后;
  • 对账发现的轻微差异批量冲正。

设计清单:可重入批大小上限死信隔离人工止血的开关

任务编排建议:补偿任务尽量 幂等、可观测、可暂停。出现大面积渠道故障时,最危险的是「自动修复脚本跑得比人还快」,把差错扩散成二次事故。要有 全局开关 + 分渠道开关 + 最大自动修复笔数阈值

与支付补偿的差异:Saga 补偿发生在用户请求上下文内,强调快速失败与回滚;异步补偿任务发生在分钟到小时级窗口,强调批处理、限流与审计。两者不要混用同一套重试策略。

案例化走读:支付回调迟到(与第 15 章呼应):用户支付成功,渠道回调因网络抖动晚到 10 分钟。期间订单可能停留在「待支付」,客服系统可能提示用户重复支付。补偿任务不应「直接改状态为成功」了事,而应执行一条可审计的状态迁移:WAIT_PAYPAID,并触发 履约消息、发票消息、积分入账 等下游;每一步仍要带幂等键,避免回调重复造成重复履约。

案例化走读:库存 Redis 与 MySQL 漂移(与第 8 章呼应):热路径扣减在 Redis,权威在 MySQL。对账发现 Redis 小于 MySQL 的可用量,可能意味着回补丢失;反过来可能意味着 Redis 多扣。修复策略应区分「业务可自动纠正」与「需要冻结 SKU 人工介入」。自动纠正必须带 上限来源证据(流水号、操作者、任务批次),否则会把数据修复变成新的数据破坏源。


6.6 集成模式总结

6.6.1 同步调用模式

  • 适用:强实时、需要立即失败反馈(试算、库存预占校验)。
  • 要点:超时、重试、熔断、幂等键向后兼容的 API 版本

常见反模式:把十几个同步调用串成「上帝编排」,任何一个下游抖动都会放大尾延迟;没有 bulkhead(舱壁) 时,还会出现「支付抖动拖垮创单」的级联故障。治理手段包括:并发化可并行步骤硬超时 + 部分降级把非关键校验挪到异步

接口演进:同步集成最怕破坏性变更。建议强制 version 字段或 URL 版本,并在网关层做 灰度路由;同时给客户端明确的 错误码字典(业务拒绝 vs 基础设施失败),否则重试风暴不可避免。

6.6.2 异步消息模式

  • 适用:解耦峰值、跨团队广播事实、最终一致投影。
  • 要点:Outbox、消费者幂等严格有序 vs 并行的权衡、死信队列。

典型反模式:业务先写库再「顺手发 Kafka」,崩溃窗口会导致消息丢失;或消费者不做幂等,靠「应该不会重复」的侥幸心理。另一个反模式是 把异步当同步用:通过轮询消息结果阻塞用户请求,这会把消息系统的延迟特性原封不动搬进关键路径。

观测性:异步链路必须能回答三个问题:消息发出去了吗、消息被处理了吗、处理正确吗。分别对应 Outbox 状态、消费者 lag、对账差异。

6.6.3 数据同步模式

  • CDC / Binlog 订阅:近实时同步到数仓或搜索;关注 schema 变更治理。
  • 定时批量:对账、报表、冷数据归档。
  • 双写:高风险,仅在迁移窗口短期使用,需校验任务护航。

CDC 的边界:它擅长复制「事实行变更」,但不自动复制「业务含义」。例如拆表、改主键、把枚举从字符串改成数字,都会让下游投影误读。需要 契约变更流程双读双写过渡期

定时批量的价值:很多一致性不是实时问题,而是「日终必须平」。批量任务的关键是 可重跑、可分段、可限流,并在大促日提前做 容量演练

6.6.4 选型决策树

flowchart TD
  Q1{需要立即知道\n下游成功与否?}
  Q1 -->|是| Q2{失败是否必须\n阻断用户?}
  Q1 -->|否| M[异步消息 +\nOutbox / 消费者幂等]

  Q2 -->|是| S[同步 RPC\n+ 超时 / 熔断 / 幂等键]
  Q2 -->|否| Q3{是否可以接受\n秒级最终一致?}
  Q3 -->|是| M
  Q3 -->|否| S

  S --> Q4{是否广播给\n多个订阅方?}
  Q4 -->|是| HY[同步拿到关键凭证\n+ 异步事件分发读模型]
  Q4 -->|否| S

  M --> Q5{是否需要强审计\n可回放?}
  Q5 -->|是| ES[评估事件溯源 /\n不可变日志]
  Q5 -->|否| P[物化视图 +\n对账修复]

  style S fill:#e3f2fd
  style M fill:#e8f5e9
  style HY fill:#fff3e0
  style ES fill:#f3e5f5
  style P fill:#eceff1

如何使用决策树(避免误用):决策树的每个叶子都不是「唯一正确答案」,而是默认起点。真实系统往往处在叶子之间的灰区:例如创单需要同步拿到 price_snapshot_id,但搜索索引更新可以异步。灰区的处理原则是:把「用户当下要看到的结果」留在同步路径,把「世界最终会知道的结果」放到异步路径,并用对账兜底。

与实时性相关的常见误判:团队容易把「运营后台要立即看到」误认为「用户主链路必须同步」。后台可采用 近实时 CDC + 物化视图,而用户侧主链路仍应保持最小同步半径。把后台需求塞进核心交易链路,是尾延迟与大促故障的高频来源。

与后续章节的关系(阅读地图)

  • 商品、搜索、推荐:AP + 异步投影为主,强调延迟可观测(第 7、12 章)。
  • 库存、营销:同步预占 / 锁定 + 异步对账(第 8、9 章)。
  • 计价、购物车:读路径可弱一致;写路径谨慎用缓存(第 11、13 章)。
  • 订单、支付:幂等 + 对账 + 补偿任务三位一体的资金安全网(第 14、15 章)。

6.7 本章小结

本章建立了系统集成的方法论「四件套」:

  1. CAP 与一致性谱系帮助你在分区现实下做可解释的取舍,而不是用「都要」掩盖矛盾。
  2. Saga(编排优先)给出跨服务长流程的工程主路径,补偿必须可逆且幂等
  3. 事件驱动 + Outbox 把「写库再发消息」变成可验证的本地事务,为 CQRS 投影与搜索增量提供底座。
  4. 幂等与对账 是分布式世界的安全带:前者防重复,后者治漂移;补偿任务负责把系统从边角态拉回主航道。

落地检查清单(建议你复制到评审模板)

  • 每个跨服务写链路是否写明 一致性级别(用户可见 / 财务 / 读模型)?
  • 是否存在「先外部成功、后本地提交」的窗口?若有,是否有 query/reconcile
  • 关键接口是否具备 幂等键冲突返回语义
  • 是否避免「双写」作为长期方案?若必须双写,是否有 校验任务
  • 事件是否走 Outbox?Relay 是否有 堆积告警
  • 是否定义 对账批次差错分级?自动修复是否有 阈值与开关
  • Saga / 异步任务是否 可重入?是否能在发布滚动中恢复?

给团队负责人的一句话建议:把「集成复杂度」当作与「业务复杂度」并列的成本项。没有 Outbox、没有对账、没有幂等键的系统也能上线,但它会把成本推迟到 大促夜、监管审计、渠道切流 这些最难的时刻一次性兑现。本章的目的,是把这部分成本前移为 可评审、可测试、可监控 的工程资产。

给一线开发者的一句话建议:写跨服务调用时,默认网络会超时、消息会重复、回调会迟到;把这三条写进单元测试与集成测试的假设里,比写一百行防御性注释更有用。

带着这套语言进入第 7 章之后的各子系统,你会更容易判断:此处该同步还是异步、事件应不应该广播、失败该当场回滚还是记账异步修。下一章(第 7 章)将从商品中心开始,把这些模式落实到具体边界与接口之上。


导航书籍主页 | 完整目录 | 上一章 | 下一章

导航书籍主页 | 完整目录 | 上一章 | 下一章


第7章 商品中心系统

本章定位:商品中心是电商平台的「商品库」与「商品事实源(Source of Truth)」。本章在统一 SPU/SKU 内核的前提下,讨论类目属性、异构品类治理、搜索缓存、订单侧快照,以及与库存、计价等系统的边界与集成模式。内容基于中大型平台常见实践,可直接映射到工程落地与面试追问。


7.1 系统概览

7.1.1 业务场景

商品中心承担 商品信息管理(PIM)导购读模型供给订单不可变快照的原材料 三类职责。典型调用方包括:商城前台(详情、列表、加购)、搜索与推荐(索引与 Hydrate)、上架与运营系统(写入与审核)、订单与计价(快照与试算参数)、营销(圈品与标签)。

业务模式上,B2B2C 聚合平台往往以供应商同步为主,商家自营为辅;B2C 自营平台则更强调运营配置效率与品牌一致性。无论哪种模式,商品中心都应坚持一条原则:商品域只表达「卖什么、长什么样、属于哪一类」,不把「卖多少钱、还剩多少」等易变事实硬编码为唯一真相(后文 7.7 展开)。

生命周期 看,商品中心至少覆盖:建模(类目与模板)→ 创建(草稿)→ 审核(机审/人审)→ 上架(可售)→ 迭代(改图改文改规格)→ 下架/失效。每一步都应留下 可追溯审计信息(谁在何时改了什么),否则一旦出现合规纠纷或供应商对账争议,团队只能依赖数据库备份「猜历史」。从 组织协作 看,商品团队常与类目运营、搜索推荐、风控审核、供应商接入等多角色交叉;接口契约(字段含义、枚举口径、版本策略)要比代码实现更早被文档化,否则「同名不同义」会在集成边界反复爆炸。

7.1.2 核心挑战

挑战表现设计回应
异构商品实物多规格、虚拟卡密、服务日历、组合捆绑统一内核 + 策略/适配器 + 扩展存储(7.4)
多角色上架商家、供应商、运营入口与审核差异状态机 + 审核策略路由 + 幂等同步
高并发读详情万级 QPS、列表千级 QPS多级缓存 + 搜索卸载 + 热点治理(7.5)
数据一致性改价改图后搜索/缓存滞后事件驱动 + 可接受最终一致 + 对账补偿
订单纠纷风险下单后商品信息变化商品快照与版本引用(7.6)

非功能需求(摘录):商品详情读接口通常要求 P99 低于百毫秒量级(在命中 L1/L2 缓存的前提下);搜索列表可接受更高延迟,但需控制 长尾查询 对 ES 集群的冲击。可用性方面,写入链路可短暂降级(例如暂停供应商同步),但读取链路应尽量 只读降级(返回缓存旧版本 + 明确提示),避免核心导购全站不可用。扩展性方面,新品类接入应尽量做到 「配置 + 插件」,避免每个品类复制一套微服务。

7.1.3 系统架构

商品中心在应用架构上通常拆为 写入域(上架/同步)读取域(导购详情/列表) 两条链路:写入强调事务边界、审核与版本;读取强调延迟、命中率与降级。二者通过领域事件(如 product.changed)与投影(搜索索引、缓存失效)解耦。

graph TB
    subgraph 客户端与上游
        U1[商家 Portal]
        U2[运营后台]
        U3[商城前台]
        P1[供应商系统]
    end

    subgraph 接入层
        GW[API Gateway]
    end

    subgraph 商品中心服务
        S1[商品信息服务<br/>SPU/SKU/属性]
        S2[类目属性服务]
        S3[快照服务]
        S4[同步服务]
        S5[搜索写入 Worker]
        S6[缓存治理 Worker]
    end

    subgraph 数据与基础设施
        D1[(MySQL 分库分表)]
        D2[(Redis)]
        D3[(Elasticsearch)]
        D4[(MongoDB/JSON 扩展)]
        D5[(对象存储 OSS)]
        MQ[Kafka]
    end

    subgraph 下游消费者
        O[订单系统]
        I[库存系统]
        PR[计价系统]
        M[营销系统]
    end

    U1 --> GW
    U2 --> GW
    U3 --> GW
    P1 --> MQ
    GW --> S1
    GW --> S2
    S1 --> D1
    S1 --> D4
    S1 --> D5
    S1 --> MQ
    MQ --> S4
    MQ --> S5
    MQ --> S6
    S5 --> D3
    S6 --> D2
    S3 --> D1
    MQ --> O
    MQ --> I
    MQ --> PR
    MQ --> M

读路径建议:前台详情优先走 缓存 + 必要时的 DB 回源;列表与筛选走 搜索服务,避免让商品中心 MySQL 直接承担复杂筛选与相关性排序。

写入与读取消耦的工程含义:写库成功后,不必强要求搜索与缓存立刻一致;但必须保证 事件不丢、可重放、可观测。实践中常见组合是:业务事务内写 MySQL,同事务写 Outbox 表,再由独立进程把变更投递到 Kafka(见第 1 章 Outbox 模式)。这样即使消息中间件短时故障,也不会出现「数据库已提交但下游永远收不到变更」的静默失败。读侧投影(ES 文档、Redis 详情)应能根据 versionupdated_at 做幂等更新,避免乱序消息把新数据覆盖成旧数据。

容量与分片提示:当 SPU 规模达到亿级、SKU 达到数十亿级时,单库单表不可持续。常见拆分是按 spu_id 哈希分库分表,SKU 表与 SPU 表同学路由键,保证同一 SPU 的子 SKU 聚合查询在同一分片完成。热点 SPU(大促单品)还需要在缓存层做 单飞(singleflight)热点 key 拆分,否则会出现 Redis 单 key QPS 顶满、集群倾斜。


7.2 SPU/SKU 模型设计

7.2.1 标准商品模型

SPU(Standard Product Unit) 表达「卖的是什么」:标题、类目、品牌、图文、共用属性、规格维度定义。SKU(Stock Keeping Unit) 表达「可售卖的最小单元」:在某个规格取值组合下的可独立售卖与履约单元。实践中,下单行项目通常锚定 SKU,而搜索列表常以 SPU 聚合展示,用户进入详情后再选择 SKU。

标准模型还应区分 销售属性(参与生成 SKU 组合,如颜色、尺码)与 关键属性(不参与 SKU 组合但影响购买决策,如材质、保修条款),避免把「展示字段」误建模为「规格维度」,导致 SKU 爆炸。

面试与评审常问:「既然订单行指向 SKU,为什么还需要 SPU?」答案是 聚合展示与运营效率:SPU 承载共性信息(标题、主图、详情图文、类目属性),SKU 承载差异信息(规格取值、条码、可单独下架)。没有 SPU,运营每次改标题要批量改一万个 SKU;没有 SKU,则无法表达「同款不同价不同库存」的现实售卖粒度。另一个追问是:「SKU 是否一定要对应物理库存单元?」在虚拟/服务类里不一定,但仍建议保留 SKU 作为 订单锚点,让库存、计价、履约系统能用统一字段对接。

7.2.2 SPU 与 SKU 的关系

一对多关系是常态;特殊场景包括:

  • 单 SKU SPU:虚拟充值面额、单一规格标品。
  • 无显式 SKU:极少数极简虚拟品可用「默认 SKU」技巧保持订单模型统一。
  • 组合商品:父 SPU 下挂子 SKU 引用(7.4),库存与价格在组合层做编排。

关系一致性与删除策略:当 SPU 被下架或删除时,SKU 的处理策略必须在系统层明确:通常采用 级联不可售(SKU 状态同步为不可售)而不是物理删除,以保留历史订单可解释性。若业务允许「换款不换链」,还要考虑外部系统保存的 deep link:更安全的做法是 SPU 永久不可复用旧 ID,或通过跳转映射表承接流量。

7.2.3 规格与属性

规格维度在数据层建议 稳定排序sort_order),以保证笛卡尔积生成与前端展示一致。对无效组合(如某颜色不提供某尺码)应引入 约束表或规则引擎输出,避免「先生成再删除」带来的垃圾 SKU 与库存初始化成本。

无效组合治理:服装行业常见「断码」不是随机缺失,而是可枚举规则。工程上可用 deny_rules 表表达(color_id, size_id)黑名单,或在运营后台用矩阵编辑器生成 allowed pairs。生成 SKU 时先求笛卡尔积,再过滤;过滤结果应写审计日志,便于排查「为什么少了一个 SKU」。另一种模式是 不预生成 SKU,仅在用户选择规格时动态创建(适合长尾、组合爆炸品类),但订单与库存对接会更复杂,需要团队能力匹配。

多维度扩展:当规格维度超过三维(例如颜色 × 尺码 × 版本 × 渠道专供)时,组合数会指数增长。此时应回到业务问三个问题:哪些维度真的影响履约与库存?哪些维度只是展示差异?哪些维度应转为「同一 SKU + 购买选项」而不是硬 SKU?错误的建模会把库存系统拖入「每个渠道一个 SKU」的泥潭。

7.2.4 多维度 SKU

多维度 SKU 本质是规格维度的笛卡尔积;工程上要额外考虑:

  • SKU 编码:建议包含 spu_id 语义前缀 + 哈希/序列,便于分片与排障。
  • 价格带:列表可展示 price_min / price_max(维护在 SPU 投影或搜索文档),减少详情外的重计算。
  • 上下架粒度:可细化到 SKU 级,SPU 级状态为其子 SKU 的聚合结果。

列表价字段归属说明:许多团队在 SKU 表存放 list_price 作为展示参考,但「可售价格」仍应由计价系统决定。商品中心若保存价格,必须明确它是 标价/厂商指导价 还是 实时可售价,并在接口文档中对前台展示与试算链路分别说明,避免产品同学把字段当成「最终价」。

数据模型图(逻辑 ER)

下列 ER 图强调 SPU 为聚合根、SKU 为子实体 的主从关系,以及属性、扩展、快照的外挂结构。

erDiagram
    SPU ||--o{ SKU : contains
    SPU }o--|| CATEGORY : classified_as
    SPU ||--o{ PRODUCT_ATTRIBUTE_VALUE : describes
    ATTRIBUTE_DEF ||--o{ PRODUCT_ATTRIBUTE_VALUE : typed_by
    SPU ||--o| PRODUCT_EXT_DOC : extends
    SPU ||--o{ PRODUCT_SNAPSHOT : materializes
    SKU ||--o{ PRODUCT_SNAPSHOT : referenced_by

    SPU {
        string spu_id PK
        string title
        bigint category_id FK
        int product_type
        int status
        bigint version
        json spec_definitions
        datetime updated_at
    }

    SKU {
        string sku_id PK
        string spu_id FK
        json spec_values
        int status
        string barcode
        datetime updated_at
    }

    CATEGORY {
        bigint category_id PK
        bigint parent_id
        string name
        int level
        string path
    }

    ATTRIBUTE_DEF {
        bigint attr_id PK
        string name
        string input_type
        bool required
    }

    PRODUCT_ATTRIBUTE_VALUE {
        bigint id PK
        string spu_id FK
        bigint attr_id FK
        string value_text
    }

    PRODUCT_EXT_DOC {
        string spu_id PK
        json payload
    }

    PRODUCT_SNAPSHOT {
        string snapshot_id PK
        string spu_id FK
        string sku_id FK
        string content_hash
        json snapshot_body
        datetime created_at
    }

Go 领域模型骨架

// SpecDimension 销售属性维度(如颜色、存储)
type SpecDimension struct {
	Code   string   // machine key, e.g. "color"
	Label  string   // display, e.g. "颜色"
	Values []string // allowed values
	Sort   int
}

// SPU 聚合根:规格定义挂在 SPU,SKU 只承载取值组合
type SPU struct {
	SPUID        string
	Title        string
	CategoryID   int64
	BrandID      int64
	ProductType  string // standard/virtual/service/bundle
	Status       int
	Version      int64
	SpecDims     []SpecDimension
	MainImages   []string
	Description  string
	UpdatedAt    int64
}

// SKU:可售卖最小单元
type SKU struct {
	SKUID      string
	SPUID      string
	SpecValues map[string]string // {"color":"black","storage":"256G"}
	Status     int
	UpdatedAt  int64
}

// BuildSKUsFromSpecs 生成笛卡尔积;调用方应再套用无效组合过滤
func BuildSKUsFromSpecs(spuID string, dims []SpecDimension) []*SKU {
	if len(dims) == 0 {
		return []*SKU{{SPUID: spuID, SKUID: defaultSKUID(spuID), SpecValues: map[string]string{}}}
	}
	var out []*SKU
	var dfs func(i int, cur map[string]string)
	dfs = func(i int, cur map[string]string) {
		if i == len(dims) {
			m := cloneMap(cur)
			out = append(out, &SKU{
				SPUID:      spuID,
				SKUID:      deterministicSKUID(spuID, m),
				SpecValues: m,
			})
			return
		}
		d := dims[i]
		for _, v := range d.Values {
			cur[d.Code] = v
			dfs(i+1, cur)
			delete(cur, d.Code)
		}
	}
	dfs(0, map[string]string{})
	return out
}

func cloneMap(in map[string]string) map[string]string {
	out := make(map[string]string, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

领域不变量(建议写进模块文档)

  1. 任意可售 SKU 必须属于已上架且合规通过的 SPU。
  2. sku_id 一旦对外发布(产生历史订单引用),默认不可复用给另一商品。
  3. 规格取值必须落在 SPU 规格维度定义允许集合内(防止脏数据写入)。
  4. 类目叶子节点才允许创建商品(可配置例外,但必须有审批)。

这些不变量是代码评审时判断「补丁是否破坏模型」的抓手,也能帮助新同学快速建立心智模型。


7.3 类目与属性系统

7.3.1 类目树设计

类目树承担三类能力:前台导航属性模板绑定搜索 facet 配置。工程上常用 物化路径path = /1/10/1005/)或 闭包表 加速「取子树」;叶子类目才允许挂商品,避免分类语义漂移。

类目治理的现实问题:业务方常会提出「临时类目」「活动类目」需求。建议把活动类展示交给运营配置与搜索标签,而不是把类目树当成运营画布频繁改结构;类目树变更会牵动属性模板、搜索 facet、供应商映射,成本高。另一个常见坑是 跨站点类目复用:全球化平台不同国家类目不完全一致,要么维护「全球基础类目 + 本地扩展节点」,要么在商品上增加 site 维度,不要把两套语义硬塞进同一棵树导致无法筛选。

type CategoryNode struct {
	CategoryID int64
	ParentID   int64
	Name       string
	Level      int
	Path       string
	IsLeaf     bool
	Sort       int
}

// ChildrenIndex 将平铺类目转为邻接表
func ChildrenIndex(nodes []*CategoryNode) map[int64][]*CategoryNode {
	m := make(map[int64][]*CategoryNode)
	for _, n := range nodes {
		m[n.ParentID] = append(m[n.ParentID], n)
	}
	return m
}

7.3.2 属性模板

属性模板按叶子类目绑定:定义字段类型、是否必填、校验规则、枚举数据源。运营变更模板会影响新发商品与编辑表单,因此需要 版本号兼容策略(老商品字段只读、新字段默认空)。

继承与覆盖:父类目可定义通用属性(如「3C 的保修方式」),子类目继承;子类目可 覆盖 是否必填、枚举范围、展示顺序。实现上可用「解析时合并」:读取叶子类目模板时,向上回溯合并父链配置,子级优先级更高。注意性能:缓存合并后的模板结构,避免每次保存商品都递归查整棵树。

校验分层:前端校验提升体验;商品中心服务端必须 再次校验(防绕过)。对供应商同步,还要区分「硬错误」(阻断上架)与「软警告」(进入待运营确认),否则同步成功率会被不重要的字段噪声拉低。

7.3.3 动态属性(EAV 模型)

对长尾属性,全宽表不可维护,全 EAV 查询成本高。主流做法是 混合模型

  • 强筛选 / 强展示字段:进入 SPU 主表或 JSON 索引字段(如品牌、型号)。
  • 长尾属性spu_id + attr_id + value 的 EAV 表或文档库存 payload

查询与索引:EAV 的最大痛点是详情页组装需要多行查询。常见优化包括:按 spu_id 聚簇、批量 WHERE spu_id IN (...)、热点属性冗余到 JSON。对「强筛选属性」,应评估是否值得进入 ES keyword 字段;否则会出现「列表筛不出来,但详情能看到」的体验问题。

一致性与迁移:当属性从 EAV 提升到主表字段时,需要离线迁移任务回填,并双写一段时间校验 diff。迁移失败往往来自 单位/枚举口径 不一致(例如「英寸」与「厘米」),这类问题应在模板层用统一单位约束解决。

type AttrValueRow struct {
	ID        int64
	SPUID     string
	AttrID    int64
	ValueText string
}

// LoadAttrMap 读模型聚合:详情页一次组装
func LoadAttrMap(rows []AttrValueRow) map[int64]string {
	out := make(map[int64]string, len(rows))
	for _, r := range rows {
		out[r.AttrID] = r.ValueText
	}
	return out
}

7.4 异构商品治理

7.4.1 异构商品的挑战

异构性体现在 SKU 颗粒度库存维度价格输入履约方式 四个轴上。若用大量 if product_type == ... 污染核心写入链路,短期能交付,长期必然失控。

下表用于评审会上快速对齐「差异在哪里」,避免团队只在名词层面争论「商品」:

维度标准实物虚拟卡密服务日历组合套餐
SKU 复杂度中到高低到中高(房型/时段)高(子品约束)
库存真相位置仓配库存系统卡密池/渠道额度日历房态/座位图子品库存联合
价格形态标价 + 促销叠加面额/折扣日历价/动态溢价打包价 + 分摊
履约物流发货发码/直充预约/入住/出行分履约合并展示

7.4.2 统一抽象

统一抽象的目标是:同一套生命周期事件、同一套搜索文档骨架、同一套订单行引用模型。差异部分下沉到:

  • 扩展存储(MongoDB / JSON)承载品类特有字段;
  • 策略 承载校验、规格生成、搜索归一化;
  • 适配器 承载外部供应商模型到平台模型的映射(同步服务内)。

7.4.3 适配器模式

适配器聚焦 Partner → Platform 的字段与枚举映射,不在此处理业务规则(避免上帝类)。商品中心核心服务只认识平台内的 SPU / SKU

适配器落地的接口切分:建议为每个大类供应商提供 PartnerProductNormalizer 实现,输入为供应商 DTO,输出为平台 UpsertCommand。命令进入商品核心服务前,完成 币种、单位、类目映射、图片 URL 清洗(防盗链/水印策略) 等纯转换工作。对无法映射的字段,进入 unknown_payload 并触发运营任务,而不是静默丢弃,否则会出现「供应商有字段但平台永远看不见」的隐性损失。

7.4.4 配置化方案与策略模式应用

策略模式适合放在 「写路径规则选择」:例如校验、规格展开、搜索文档 enrich、库存维度声明。下面示例展示 注册表 + 显式策略接口,避免核心服务直接依赖具体品类包(可拆插件模块)。

type ProductKind string

const (
	KindStandard ProductKind = "standard"
	KindVirtual  ProductKind = "virtual"
	KindService  ProductKind = "service"
	KindBundle   ProductKind = "bundle"
)

// GovernanceStrategy:异构治理策略(写路径/建模路径)
type GovernanceStrategy interface {
	Kind() ProductKind
	Validate(spu *SPU, skus []*SKU) error
	EnrichSearchDoc(doc *SearchDoc, spu *SPU, skus []*SKU)
	StockDimensions() []string
}

type SearchDoc struct {
	SPUID      string
	Title      string
	CategoryID int64
	Tags       []string
	Extra      map[string]string
}

type standardStrategy struct{}

func (standardStrategy) Kind() ProductKind { return KindStandard }

func (standardStrategy) Validate(spu *SPU, skus []*SKU) error {
	if len(skus) == 0 {
		return ErrSKUsRequired
	}
	return nil
}

func (standardStrategy) EnrichSearchDoc(doc *SearchDoc, spu *SPU, skus []*SKU) {
	doc.Tags = append(doc.Tags, "physical")
}

func (standardStrategy) StockDimensions() []string { return []string{"warehouse_sku"} }

type serviceStrategy struct{}

func (serviceStrategy) Kind() ProductKind { return KindService }

func (serviceStrategy) Validate(spu *SPU, skus []*SKU) error {
	// 允许 SKU 对应房型等;库存走日历维度(库存系统)
	return nil
}

func (serviceStrategy) EnrichSearchDoc(doc *SearchDoc, spu *SPU, skus []*SKU) {
	doc.Extra["inventory_profile"] = "calendar"
	doc.Tags = append(doc.Tags, "service")
}

func (serviceStrategy) StockDimensions() []string { return []string{"date", "room_type"} }

type StrategyRegistry struct {
	byKind map[ProductKind]GovernanceStrategy
}

func NewStrategyRegistry() *StrategyRegistry {
	r := &StrategyRegistry{byKind: map[ProductKind]GovernanceStrategy{}}
	r.Register(standardStrategy{})
	r.Register(serviceStrategy{})
	// virtual/bundle 同理注册
	return r
}

func (r *StrategyRegistry) Register(s GovernanceStrategy) {
	r.byKind[s.Kind()] = s
}

func (r *StrategyRegistry) Resolve(spu *SPU) GovernanceStrategy {
	k := ProductKind(spu.ProductType)
	if s, ok := r.byKind[k]; ok {
		return s
	}
	return r.byKind[KindStandard]
}

// UpsertSPU 演示策略介入点:校验 + 搜索投影 enrich
func UpsertSPU(ctx context.Context, reg *StrategyRegistry, repo ProductRepository, spu *SPU, skus []*SKU) error {
	st := reg.Resolve(spu)
	if err := st.Validate(spu, skus); err != nil {
		return err
	}
	if err := repo.SaveSPUAndSKUs(ctx, spu, skus); err != nil {
		return err
	}
	doc := &SearchDoc{SPUID: spu.SPUID, Title: spu.Title, CategoryID: spu.CategoryID, Extra: map[string]string{}}
	st.EnrichSearchDoc(doc, spu, skus)
	return PublishSearchUpsert(ctx, doc)
}
classDiagram
    class GovernanceStrategy {
        <<interface>>
        +Kind() ProductKind
        +Validate(spu, skus) error
        +EnrichSearchDoc(doc, spu, skus)
        +StockDimensions() []string
    }
    class StrategyRegistry {
        -byKind map
        +Register(s)
        +Resolve(spu) GovernanceStrategy
    }
    class standardStrategy
    class serviceStrategy
    GovernanceStrategy <|.. standardStrategy
    GovernanceStrategy <|.. serviceStrategy
    StrategyRegistry o-- GovernanceStrategy : resolves

与适配器的边界:适配器解决 外部形状不一致;策略解决 内部规则不一致。二者可组合:同步适配器产出平台 SPU,再进入策略校验。

配置化平台的边界:表单、步骤、审核模板配置能显著加快新品类接入,但不要把「强业务规则」无限制脚本化到运营可编辑,否则难以测试与回滚。建议把规则分级:L1 配置(字段/枚举/展示)运营可改;L2 规则(库存维度声明、搜索 enrich)需要发布评审;L3 代码(复杂约束、组合分摊)必须走研发变更。

组合商品补充:组合 SPU 的子项引用建议存「子 spu_id + 子 sku_id + 数量」,并在策略层实现 联合校验(子品任一不可售则父不可售)。组合售价分摊属于计价/订单域更合适;商品中心可提供「子品结构事实」(BOM),但不负责最终金额计算。


7.5 商品搜索与缓存

7.5.1 Elasticsearch 索引设计

索引文档建议同时携带 SPU 级字段nested SKU 列表,以支持「按规格价区间过滤」「按 SKU 库存状态过滤」等复杂导购。title 建议 text + keyword 双字段;筛选字段尽量 keyword / long;高基数标签谨慎正排。

nested vs 扁平化:nested 查询语义正确但成本更高;若列表页只需要 price_min/max 聚合,SKU 细节仅在详情需要,可采用 SPU 文档 + SKU 侧车索引(sidecar index)或在详情回源商品中心。取舍原则是:列表查询模式 决定索引形状,而不是把 DB 表结构原样搬进 ES。

字段爆炸治理flattened 类型适合高基数动态属性,但会牺牲部分查询精度;对强运营筛选字段仍应显式建模。图片、长文本详情不建议全量进索引文档,Hydrate 阶段再补。

7.5.2 多级缓存策略

典型路径:L1 本地(Caffeine)→ L2 Redis → L3 DB。本地缓存 TTL 应短(秒级),避免集群间长时间不一致;Redis 承担跨实例一致性,事件驱动失效。

预热与穿透:大促前可对 Top N SPU 预热;对不存在商品要缓存 空值短 TTL,配合布隆过滤器降低穿透。热点 key 过大时,可将详情拆为「基础信息」与「重信息」两个 key,避免单 value 过大导致 Redis 网络抖动。

压缩与序列化:详情 DTO 建议使用紧凑 JSON 或 protobuf;同时注意 CPU 与包大小的权衡。对图片 URL 只存引用,不把大图 base64 塞进缓存。

7.5.3 缓存一致性

完全强一致代价高,主流接受 秒级到分钟级 的最终一致。要点:

  • 写后发布 product.changed(带 version);
  • 消费者顺序:同 spu_id 使用分区键保序;
  • 兜底:定时对账任务比对 DB 与 ES 的版本号。

智能刷新(可选):对高曝光商品缩短投影延迟,对长尾商品降低刷新频率,以节省 ES 写入与缓存抖动。热度可结合曝光、加购、销量与运营打标计算(参考原博文「热点分数」思路),但注意 反馈回路:缓存命中高 ≠ 商品更重要,避免错误加权。

func InvalidateProductReadModels(ctx context.Context, redis RedisClient, spuID string) {
	_ = redis.Del(ctx, "pd:"+spuID)
	// 本地缓存由订阅线程或带版本广播清理
}

7.6 商品快照

7.6.1 快照的必要性

订单域需要展示 下单瞬间 的商品标题、主图、规格文本等,若直接引用实时商品,会遇到改价、换图、改标题后的纠纷。商品快照提供不可变展示材料;应付金额仍应以计价系统快照为准(第 11 章),二者不可混为一谈。

法务与客服视角:客服工单里最常见的问题之一是「我下单时页面明明写的是 A」。若订单只存 spu_id 而不存快照,团队只能凭日志猜测。快照不是为了替代审计日志,而是给 用户可见解释 提供最低成本证据链。对跨境业务,还要考虑快照是否包含 合规字段(例如能量标、成分表、警示语),这些字段若仅存在运营后台而未进入快照,纠纷时依旧说不清。

7.6.2 生成策略

推荐策略:

  1. 创单前:订单服务向商品中心请求 CreateSnapshot(spu_id, sku_id),返回 snapshot_id
  2. 内容寻址:对快照正文做规范化序列化(稳定 key 排序)后计算哈希;哈希即 snapshot_id(或映射到 UUID)。
  3. 并发创建:数据库层对 snapshot_id 做唯一约束,插入冲突则直接返回已存在记录。

与计价快照的衔接:商品快照建议只固化 展示相关字段(标题、图、规格文本、基础属性)。若把促销价、券后价写入商品快照,会与营销域频繁变化纠缠,复用率也会骤降。更干净的做法是:订单表同时引用 product_snapshot_idpricing_snapshot_id(或等价结构),客服展示时组合渲染。

版本字段:除内容哈希外,可增加 spu_version 字段写入快照正文,用于快速判断「商品中心是否发生过大版本回滚」;但它不应替代哈希作为主键,否则会出现「内容相同但版本号不同导致无法复用」的浪费。

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"time"
)

type ProductSnapshot struct {
	SnapshotID  string // content hash
	SPUID       string
	SKUID       string
	BodyJSON    string
	ContentHash string
	CreatedAt   int64
}

type SnapshotInput struct {
	SPU         *SPU
	SKU         *SKU
	AttrDisplay map[int64]string // 已解析展示属性
}

func stableJSON(v any) (string, error) {
	// 使用稳定序列化:sort keys,避免 map 迭代顺序导致 hash 抖动
	// 生产可替换为 jsoniter + SortMapKeys 或 canonical JSON 库
	b, err := json.Marshal(v)
	return string(b), err
}

func BuildSnapshotBody(in SnapshotInput) (string, string, error) {
	payload := map[string]any{
		"spu_id": in.SPU.SPUID,
		"sku_id": in.SKU.SKUID,
		"title":  in.SPU.Title,
		"specs":  in.SKU.SpecValues,
		"attrs":  in.AttrDisplay,
		"images": in.SPU.MainImages,
		// 注意:可不包含实时价,避免与计价域重复;或仅保存 list_price 作为展示参考
	}
	body, err := stableJSON(payload)
	if err != nil {
		return "", "", err
	}
	sum := sha256.Sum256([]byte(body))
	hash := hex.EncodeToString(sum[:])
	return body, hash, nil
}

func FindOrCreateSnapshot(ctx context.Context, repo SnapshotRepository, in SnapshotInput) (*ProductSnapshot, error) {
	body, hash, err := BuildSnapshotBody(in)
	if err != nil {
		return nil, err
	}
	if snap, _ := repo.GetByID(ctx, hash); snap != nil {
		return snap, nil
	}
	snap := &ProductSnapshot{
		SnapshotID:  hash,
		SPUID:       in.SPU.SPUID,
		SKUID:       in.SKU.SKUID,
		BodyJSON:    body,
		ContentHash: hash,
		CreatedAt:   time.Now().UnixMilli(),
	}
	if err := repo.InsertIgnoreDuplicate(ctx, snap); err != nil {
		// 并发下可能命中唯一键冲突,回读即可
		if isUniqueViolation(err) {
			return repo.GetByID(ctx, hash)
		}
		return nil, err
	}
	return snap, nil
}

7.6.3 复用优化

  • 内容寻址:相同 spu + sku + 展示属性集合 自然复用一条记录,极大节省存储。
  • 分层快照:若属性来自模板且变化频繁,可将「模板版本号」写入快照正文,减少字段膨胀。
  • 冷热分离:历史订单引用极多的快照可归档到低频存储,在线库保留近期热数据。

哈希稳定性工程清单:字段序列化必须 键有序;浮点数要谨慎(不同 CPU/JSON 库可能格式不同);图片列表建议只存 稳定 URL 或对象存储 key;对运营富文本详情,若强需求进快照,应做 HTML 归一化(去空白、属性排序),否则哈希会频繁变化导致复用失败。

碰撞处理:理论上 SHA-256 碰撞可忽略;若业务偏执,可采用 hash + business_salt 或在插入后使用数据库自增 ID 作为外部引用。对外订单引用仍建议用 短 ID(Snowflake)映射内容哈希,避免把过长哈希直接暴露给客户端日志。

flowchart LR
    A[创单请求] --> B[组装 SnapshotInput]
    B --> C[稳定序列化 + Hash]
    C --> D{DB 已存在?}
    D -- 是 --> E[复用 snapshot_id]
    D -- 否 --> F[插入快照]
    F --> E
    E --> G[订单行引用 snapshot_id]

7.7 系统边界与职责

7.7.1 商品中心的职责边界

负责:SPU/SKU 与类目属性、商品状态与版本、导购读模型(经缓存/搜索)、商品展示快照的生成与存储、商品变更事件。

不负责:库存数量真相、预占扣减、实时价与促销价计算、支付、履约执行。

「商品可售」到底是谁说了算:前台常把「上架 + 有货 + 可售卖区域」合成一个可售状态。建议拆分为:商品中心给出上架与基础合规库存系统给出是否有货/可预占计价系统给出是否可成交价格风控/合规系统给出是否允许售卖(必要时)。任何「一个字段代表一切」的聚合,最终都会在边界扩张时崩掉。

7.7.2 商品 vs 库存:边界划分

主题商品中心库存系统
SKU 是否存在引用 SKU ID
可售数量展示可来自投影✓ 唯一真相
预占/扣减
仓库/批次可选展示维度

商品中心最多保留 非权威的「展示库存」投影(来自库存系统的异步同步),下单前必须以库存接口为准。

多仓与渠道:若同一 SKU 在多仓有库存,商品中心不应尝试计算「总可售」作为唯一真相;最多展示 汇总估算 并明确免责声明。渠道专供(例如某 SKU 仅 App 可售)属于 售卖规则,更接近营销/交易配置域,商品中心可提供绑定关系,但执行应在下单链路校验。

7.7.3 商品 vs 价格:边界划分

主题商品中心计价系统
标价 / list price可选字段✓ 可接管基础价源
促销/券/会员价
税费/运费✓(或结算域)
价格快照不参与应付金额✓ 订单金额真相

反模式:商品中心内部「顺手算最终价」并写入订单,会导致营销规则重复实现、一致性灾难。

基础价与协议价:B2B2C 里常见「供应商底价 + 平台加价」。底价协议可能在供应商合同系统,商品中心只存展示标价与必要的 成本参考字段(权限严格控制)。无论如何,对用户展示价 仍应由计价试算输出,以避免前台与结算页价格不一致。

7.7.4 不负责的内容(反模式)

  • 在商品服务内直连扣减库存或调用支付。
  • 把供应商原始 JSON 不经映射直接暴露给前台(破坏防腐层)。
  • 用搜索索引当作交易真相(ES 丢失/延迟不可用)。
  • 在商品中心实现「下单级营销最优解」或「券叠加求解」。
  • 把商品详情缓存当作库存预占凭证(缓存命中不代表可售)。

边界评审清单(可复用):每次需求评审问五个问题:写入哪个系统的数据库?谁是唯一真相?失败如何补偿?读路径是否可降级?事件顺序是否可保证?商品中心相关需求,至少应画出 写事务边界读权威来源


7.8 与其他系统的集成

7.8.1 上游:供应商、运营、商家

  • 供应商:Push / Pull 进入同步服务,强调幂等键、版本号与乱序保护。
  • 运营/商家:经上架系统或运营后台进入,附带审核流(第 10 章)。

上游权限与审计:同一 spu_id 可能被供应商改价、运营改标题、商家改图同时触发。需要 来源优先级 规则(例如「运营锁定字段」)与冲突检测,否则会出现后写覆盖先写但业务上不合理的结果。审计日志应记录来源系统、操作者、trace id。

7.8.2 下游:搜索、订单、营销、计价

  • 搜索:消费 product.changed 或 CDC,更新 ES 文档。
  • 订单:创单读取快照 ID;库存/价格各自调用对应系统。
  • 营销:读取标签、类目、圈品所需属性;避免反向写入商品事实。
  • 计价:读取商品类型、类目、基础价参数(不负责促销计算)。

Hydrate 模式的注意点:搜索列表常做「列表价、活动标签、库存状态」的 Hydrate。若 Hydrate 强依赖多个下游,商品中心不应成为 同步扇出中心;更合理的是由 导购聚合服务(第 12 章)编排,商品中心提供批量查询接口即可。

7.8.3 商品数据同步策略

模式适用风险
同步 RPC强一致读(少)链路放大、故障串联
领域事件搜索/缓存投影需要幂等与重放治理
CDC数仓/旁路系统schema 变更敏感

7.8.4 商品变更事件发布

事件应携带 spu_id、变更类型、version、发生时间;必要时携带 diff 摘要以减少消费者回源。分区键使用 spu_id 保序。

事件版本与兼容性:事件 schema 演进要遵循向后兼容或双写窗口。消费者(尤其外部团队维护的搜索管道)最怕「静默新增必填字段」。建议在事件头加入 schema_version,并在治理平台登记兼容策略。

重放与幂等:消费者应以 spu_id + versionevent_id 做幂等表,避免重启消费组后重复投影导致 ES 写入放大。

7.8.5 集成失败处理与降级

  • 搜索延迟:详情仍可由 DB/缓存提供;列表可提示「结果可能非最新」。
  • 快照创建失败:创单失败快速返回,不入单;禁止「无快照先进单」。
  • 供应商同步堆积:限速 + 死信队列 + 可观测重放工具。
graph LR
    subgraph 上游写入
        V[供应商同步]
        OP[运营/商家上架]
    end

    subgraph 商品中心
        PC[商品核心写服务]
        EB[事件总线 Kafka]
    end

    subgraph 下游
        SE[搜索索引]
        CA[缓存失效]
        OR[订单创单读快照]
        MK[营销圈品刷新]
        PR[计价基础参数缓存]
    end

    V --> PC
    OP --> PC
    PC --> EB
    EB --> SE
    EB --> CA
    OR --> PC
    MK --> EB
    PR --> EB

集成模式对比(落地选型):当下游需要强一致读最新商品字段时,优先评估是否 其实需要交易快照 而不是实时商品;多数「强一致」诉求源于边界未划清。对搜索索引,默认选 异步最终一致;对创单快照,选 同步 RPC + DB 唯一约束;对营销圈品刷新,可用延迟队列合并短时间多次变更,降低风暴。


7.9 工程实践要点

7.9.1 商品 ID 生成

Snowflake / UUID / DB 分段取号皆可;分库分表场景优先 全局可路由 ID(如 Snowflake)。注意时钟回拨治理与跨机房时钟同步。

可读性与安全:对外暴露的商品编码是否可预测,会影响爬虫与撞库风险。若需要公开分享链接,建议 spu_id公开 token 分离:内部用 Snowflake,外部用短链随机 token 映射。

7.9.2 性能优化

  • 读写分离:导购读走从库 + 缓存。
  • 热点 SPU:单 key 拆分(本地缓存短 TTL + 单飞回源)。
  • 批量接口:列表避免 N+1 查询。

数据库侧:为 (category_id, status, updated_at) 等常见筛选加组合索引;大文本与富文本尽量拆附属表,避免主行过宽拖慢缓存组装。对 sku_tab 高频按 spu_id 查询,确保聚簇或覆盖索引命中。

发布峰值:大促前供应商全量同步可能造成写入排队。应准备 限速、批处理合并、异步队列削峰,并提前扩容 Kafka 分区与消费组并行度,避免搜索投影长时间滞后引发「上架了搜不到」事故。

7.9.3 监控告警

关注:同步失败率、ES 消费滞后、product.changed 重试队列深度、详情接口 P99、缓存命中率、快照插入冲突率(过高可能暗示序列化不稳定)。

业务指标:审核通过率、供应商字段映射失败率、类目覆盖率、快照创建耗时分布。把技术指标与业务指标关联,才能判断「慢」是系统问题还是供应商数据质量问题。

API 契约、向后兼容与演练(纳入同一治理面):商品中心的对外接口往往是全链路最高频依赖之一,契约治理与第 6 章「系统集成」方法天然衔接:对供应商同步接口,必须定义 幂等键最大重试窗口字段默认值未知字段忽略策略;对前台导购接口,必须定义 分页稳定性(游标与 offset 的取舍)、批量上限降级字段(例如缺图时的占位图策略)。读取接口新增字段一般向后兼容;删除或改语义字段应走版本化路径(例如 /v2/products)或灰度窗口。写入接口对「部分更新 PATCH」要特别谨慎:SKU 列表部分更新容易导致客户端误删未提交字段,常见做法是显式 replace_skus 模式或提交 version 乐观锁。建议每季度做一次「Kafka 消费滞后 + ES 写入失败 + 缓存大面积失效」的组合演练,验证商品详情是否仍能 降级可读、创单是否 快速失败、供应商同步是否 可暂停可恢复


7.10 本章小结

商品中心的核心是 以 SPU/SKU 与类目属性为内核,用 事件驱动 连接搜索与缓存读模型,用 快照 服务订单不可变展示,用 策略与适配器 治理异构品类。与库存、计价的边界一旦模糊,就会出现超卖、错价、补偿困难等系统性风险。落地时建议团队反复使用三句话自检:商品中心只保证商品事实库存系统保证数量真相计价系统保证成交金额解释链

与全书其他章节的衔接建议:读完本章再读第 8 章《库存系统》理解「数量真相」与预占;读第 11 章《计价系统》理解「试算与快照」;读第 12 章《搜索与导购》理解 Hydrate 与索引延迟;读第 14 章《订单系统》理解订单行如何引用商品快照并与 Saga 编排协同。这样可以把「商品写入 → 投影 → 导购 → 创单」串成一条完整的可讲解链路。

下一章《库存系统》将从库存真相与预占扣减视角,继续完善交易链路拼图。


导航书籍主页 | 完整目录 | 上一章 | 下一章

导航书籍主页 | 完整目录 | 上一章 | 下一章


第8章 库存系统

本章定位:库存是交易链路的硬约束之一。本章在统一「商品库存」视角下,给出可扩展到多品类的二维抽象、可落地的扣减与预占策略、供应商集成模式、Redis 与 MySQL 的最终一致性方案,以及清晰的系统边界与工程实践(含 Go 与 Redis Lua)。


8.1 背景与挑战

8.1.1 多品类库存差异

在数字商品、本地生活、旅行票务等场景中,库存不是单一形态:有的按数量售卖,有的按券码唯一性售卖,有的按日历房态管理,还有的由供应商实时掌握座位或房量。下表概括常见差异(与业务配置 deduct_timing 联动)。

品类库存特点典型扣减时机平台侧关注点
电子券(Deal)券码唯一下单预占券码池、防重复出货
虚拟服务券(OPV)数量制下单预占高并发扣减、营销叠加
酒店按日期房态多为支付后确认供应商同步延迟、日历维度
机票 / 票务座位 / 场次多为支付后出票实时查询、强一致
礼品卡预采购或实时生成视模式而定安全、异步生成补偿
话费充值等无限库存无需扣减仅审计与风控

结论:若每个品类单独实现一套库存服务,短期看似更快,长期必然走向「模型割裂、接口爆炸、对账困难」。本章采用 二维分类 + 策略路由 的方式,把差异收敛到配置与策略实现层。

8.1.2 核心痛点

  1. 模型割裂:代码里到处 if category == hotel 的分支,复用性差,测试成本高。
  2. 数据不一致:Redis 热路径与 MySQL 权威数据、供应商快照三者之间出现漂移。
  3. 供应商策略不统一:实时查询、定时同步、Webhook 推送混用,缺乏统一降级语言。
  4. 边界不清:订单、营销、商品、供应商网关都在「顺手改库存」,故障定位困难。
  5. 可观测性不足:超卖、差异、同步延迟往往在用户投诉后才暴露。

容量与并发视角的补充:库存系统往往是交易洪峰的「第一扇闸门」。当创单 QPS 在短时间内抬升一个数量级时,最先暴露的通常不是 CPU,而是 热 Key、连接池、消息堆积与下游供应商配额。因此在需求阶段就要区分两类指标:对用户承诺的 创单成功率,以及对内部承诺的 库存服务自身 SLO(例如 Reserve P99、对账修复时延)。两者混谈会导致「系统看起来没挂,但用户体验崩了」。

8.1.3 设计目标

目标说明优先级
统一模型以二维属性描述品类,策略可插拔P0
高性能热路径走内存型存储与原子脚本P0
可扩展新品类优先改配置与策略,不改核心编排P0
最终一致Redis、MySQL、供应商视图可异步对齐,但有对账闭环P0
清晰边界预占、确认、释放的职责归属明确P0

8.2 库存分类体系

8.2.1 二维分类模型

统一库存的关键,是把品类差异抽象为两个正交维度

  • 维度一:谁管库存(Management Type)——库存的「主权」在平台还是供应商,或无需管理。
  • 维度二:库存单元形态(Unit Type)——库存是可数数量、唯一券码、日历维度,还是组合型。
// ManagementType:谁拥有库存事实来源
const (
	SelfManaged     = 1 // 平台自管:平台维护可用量与流水
	SupplierManaged = 2 // 供应商管理:平台保存快照 + 同步策略
	Unlimited       = 3 // 无限库存:不维护可用量(仍可有审计日志)
)

// UnitType:库存如何被扣减与表达
const (
	CodeBased     = 1 // 券码制:最小粒度为唯一 code
	QuantityBased = 2 // 数量制:最小粒度为整数数量
	TimeBased     = 3 // 时间维度:按日期 / 时段切片(酒店、部分票务)
	BundleBased   = 4 // 组合型:多子项联动扣减(套餐)
)

设计要点

  • 两个维度独立变化:例如「供应商管理 + 时间维度」刻画酒店房态;「自管理 + 券码制」刻画平台预采购礼品卡。
  • 扣减时机(deduct_timing) 常作为第三配置轴(下单 / 支付 / 发货),与二维模型一起写入 inventory_config,由上层交易编排解释。

下面的矩阵图用 Mermaid 表达「管理类型 × 单元类型」的组合空间(示意,非穷举所有业务)。

flowchart TB
  subgraph D1[维度一:ManagementType]
    M1[SelfManaged 自管理]
    M2[SupplierManaged 供应商管理]
    M3[Unlimited 无限]
  end

  subgraph D2[维度二:UnitType]
    U1[CodeBased 券码制]
    U2[QuantityBased 数量制]
    U3[TimeBased 时间维度]
    U4[BundleBased 组合型]
  end

  M1 --> C1[Deal / OPV / 本地服务 / 预采购礼品卡等]
  M1 --> C2[典型:数量制虚拟商品]
  M2 --> C3[酒店日历房态 / 机票座位等]
  M2 --> C4[供应商券码实时生成等]
  M3 --> C5[话费、部分直充类]

  U1 --> C1
  U2 --> C2
  U3 --> C3
  U4 --> C6[组合套餐:子项各自路由策略]

  style M1 fill:#e8f5e9
  style M2 fill:#e3f2fd
  style M3 fill:#fff3e0

读图方式:先确定「事实来源」再走「单元形态」路径,最终在策略路由器里选择 InventoryStrategy 实现(见 8.8 节)。

8.2.2 管理类型(谁管)

类型库存事实来源平台侧数据角色典型风险
自管理平台数据库 / Redis权威 + 热缓存热冷数据漂移、补货并发
供应商管理供应商系统快照 + 预占记录 + 同步任务同步延迟、重复预订
无限日志 / 统计供应商侧失败、账务不一致

8.2.3 单元类型(什么样)

类型数据落点并发控制要点
券码制inventory_code_pool 分表 + Redis LIST出货原子性、补货游标、空池短路
数量制inventory 行 + Redis HASHLua 脚本保证 available/booking/issued 联动
时间维度calendar_date 切片日期范围校验、跨日边界、时区
组合型子 SKU 多条配置子项逐一成功或整体回滚(Saga)

时间维度(TimeBased)落地要点:时间维度的难点不在「存日期」而在「一致性语义」。例如酒店连住多晚,可能要求 每一天都有房 才能售卖;也可能允许「部分日期升级 / 部分日期无早」等复杂包规。库存系统应优先实现 可验证的硬约束(每日可售量、最小可售窗口、闭店日),把复杂打包规则上移到商品 / 套餐域或规则引擎。技术上,calendar_date 常作为分片键之一,与 sku_id 组合唯一;查询走批量 IN 或区间扫描时要注意索引设计与缓存击穿。

组合型(BundleBased)落地要点:组合扣减建议采用「父单 + 子行」模型:父单代表用户购买的套餐实例,子行映射到真实库存 SKU。预占时按子 SKU 顺序扣减可以降低死锁概率(按字典序固定加锁顺序);失败时按逆序释放。若子项跨越自管理与供应商管理,不要试图在库存服务内做分布式强一致——应以 Saga 编排为准,并在产品上明确「部分子项失败时整单失败」或「降级替换子项」的策略。

8.2.4 品类分类矩阵

下表将常见品类映射到 (ManagementType, UnitType),并给出推荐扣减时机(业务可配置)。

品类管理类型单元类型推荐扣减时机
电子券 DealSelfCode下单预占
OPV / 本地服务SelfQuantity下单预占
酒店SupplierTime支付 / 供应商确认(按合同)
机票SupplierQuantity / Time支付前后组合(按供应商能力)
话费 TopUpUnlimited-无库存扣减
礼品卡(预采购)SelfCode下单预占
礼品卡(实时生成)SupplierCode支付后生成
组合套餐SelfBundle下单预占(子项联动)

下面的 四象限矩阵图 用「平台是否掌握库存事实来源」与「库存单元是否强结构化」两个视角,把常见品类放到同一张图里讨论(示意,用于工作坊对齐;边界案例以合同与供应商能力为准)。

flowchart TB
  subgraph Q1[象限 I:平台强主权 + 结构化数量/码池]
    Q1a[自管理 + 数量制:OPV / 本地服务]
    Q1b[自管理 + 券码制:Deal / 预采购礼品卡]
  end

  subgraph Q2[象限 II:平台强主权 + 组合复杂度]
    Q2a[自管理 + 组合型:套餐(子 SKU 联动)]
  end

  subgraph Q3[象限 III:供应商主权 + 时间/座位视图]
    Q3a[供应商 + 时间维度:酒店日历房态]
    Q3b[供应商 + 数量/座位:机票 / 部分票务]
  end

  subgraph Q4[象限 IV:弱库存约束 / 特殊模式]
    Q4a[无限库存:话费等直充]
    Q4b[供应商 + 券码实时生成:支付后出码]
  end

  MT[维度一:ManagementType] --> Q1
  MT --> Q2
  MT --> Q3
  MT --> Q4

  UT[维度二:UnitType] --> Q1
  UT --> Q2
  UT --> Q3
  UT --> Q4

与营销库存的关系:商品库存回答「有没有货」,营销库存回答「活动名额 / 补贴预算够不够」。秒杀等场景往往需要 双扣减(商品 + 营销),本章在 8.7 节说明集成边界;营销细节见第 9 章。

统一数据模型(持久化层摘要):二维分类解决「策略选择」,持久化层解决「数据落在哪里」。实践中常用三张核心表承载配置、数量视图与券码池(字段可按团队规范微调,但语义建议保持一致)。

  1. inventory_config(按 SKU 一条):声明 management_typeunit_typededuct_timingsupplier_idsync_strategy 等,是策略路由的唯一配置源。
  2. inventory(数量 / 时间维度的聚合行):维护 total/available/booking/locked/sold 与供应商快照字段(如 supplier_stock),并承载库存恒等式校验。
  3. inventory_code_pool_XX(券码制分表):一码一行,状态机驱动;Redis LIST 仅存 codeId 热数据,权威仍在 MySQL。
CREATE TABLE inventory_config (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  item_id BIGINT NOT NULL,
  sku_id BIGINT NOT NULL DEFAULT 0,
  management_type INT NOT NULL COMMENT '1=自管理,2=供应商,3=无限',
  unit_type INT NOT NULL COMMENT '1=券码,2=数量,3=时间,4=组合',
  deduct_timing INT NOT NULL DEFAULT 1 COMMENT '1=下单,2=支付,3=发货',
  supplier_id BIGINT NOT NULL DEFAULT 0,
  sync_strategy INT NOT NULL DEFAULT 0 COMMENT '1=定时,2=实时,3=推送',
  sync_interval INT NOT NULL DEFAULT 300,
  oversell_allowed TINYINT NOT NULL DEFAULT 0,
  low_stock_threshold INT NOT NULL DEFAULT 100,
  UNIQUE KEY uk_item_sku (item_id, sku_id)
);

CREATE TABLE inventory (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  item_id BIGINT NOT NULL,
  sku_id BIGINT NOT NULL,
  batch_id BIGINT NOT NULL DEFAULT 0,
  calendar_date DATE DEFAULT NULL,
  total_stock INT NOT NULL DEFAULT 0,
  available_stock INT NOT NULL DEFAULT 0,
  booking_stock INT NOT NULL DEFAULT 0,
  locked_stock INT NOT NULL DEFAULT 0,
  sold_stock INT NOT NULL DEFAULT 0,
  supplier_stock INT NOT NULL DEFAULT 0,
  supplier_sync_time BIGINT NOT NULL DEFAULT 0,
  status INT NOT NULL DEFAULT 1,
  UNIQUE KEY uk_sku_batch_date (sku_id, batch_id, calendar_date)
);

库存恒等式(自管理)

total_stock = available_stock + booking_stock + locked_stock + sold_stock

可售库存的计算要分管理类型:自管理用平台 total/sold/booking/locked;供应商管理应用 supplier_stock 作为外部事实输入;无限库存返回业务定义的上限或哨兵值。把这段逻辑收敛在领域服务 CalcAvailable 中,避免在订单、搜索、结算各自复制一份。


8.3 库存扣减策略

8.3.1 扣减时机

扣减时机是交易体验与资损风险的权衡轴:

  • 下单预占(Reserve / Book):用户体验好(下单即锁货),但占用时长内库存不可用,需要可靠的超时释放。
  • 支付后扣减(Sell on pay):减少无效占用,更适合供应商成本高或确认链路长的品类。
  • 发货扣减:实物电商更常见;数字商品平台多用前两者的组合。

工程上建议把时机写入 inventory_config.deduct_timing,由订单 / 结算编排读取,而不是散落在订单代码的 switch

配置值与交易编排的契约deduct_timing 只是标签,真正决定行为的是订单状态机与库存 API 的组合。推荐在内部文档中固定一张「状态 × 库存动作」表,例如:PENDING_PAYMENT → ReleasePAID → ConfirmCLOSED → Release(幂等)。当同一品类在不同国家 / 不同供应商合同中扣减时机不同,用配置驱动可以避免为每个市场复制一套订单服务。

8.3.2 预占与确认

预占(Reserve) 的本质:把「可售」迁移到「已占用(booking)」状态,并保证操作原子、可幂等、可追踪。

确认(Confirm / Sell) 的本质:把「占用」迁移到「已售(sold / issued)」,并与支付成功事件对齐。

自管理数量制的状态迁移(Redis HASH 字段视角):

available --(reserve)--> booking --(confirm)--> issued
available <---(release)--- booking

券码制则是 AVAILABLE → BOOKING → SOLD 的状态机,失败路径需要可逆。

策略模式落地(路由与编排解耦):业务层只依赖统一的 InventoryManager(或应用服务),由它读取 inventory_config 后选择策略实现。这样「新品类接入」优先体现为 配置 + 策略类,而不是修改订单核心代码。

// InventoryStrategy 抽象了库存生命周期中可被统一编排的动作集合。
type InventoryStrategy interface {
	CheckStock(ctx context.Context, req *CheckStockReq) (*CheckStockResp, error)
	BookStock(ctx context.Context, req *BookStockReq) (*BookStockResp, error)
	UnbookStock(ctx context.Context, req *UnbookStockReq) error
	SellStock(ctx context.Context, req *SellStockReq) error
	RefundStock(ctx context.Context, req *RefundStockReq) error
}

type StrategyRouter struct{}

func (StrategyRouter) MustStrategy(cfg *InventoryConfig) (InventoryStrategy, error) {
	switch cfg.ManagementType {
	case SelfManaged:
		return NewSelfManagedStrategy(cfg), nil
	case SupplierManaged:
		return NewSupplierManagedStrategy(cfg), nil
	case Unlimited:
		return NewUnlimitedStrategy(), nil
	default:
		return nil, fmt.Errorf("unknown management_type=%d", cfg.ManagementType)
	}
}

与「营销锁定」的关系:数量制 Redis HASH 常会增加 locked 以及按 promotion_id 维度的动态字段,用于表达「活动独占库存」。商品详情页展示的可售量,与下单强校验使用的可售量,可能不是同一个聚合口径——务必在接口契约里写清楚,避免运营配置误解导致客诉。

8.3.3 超时释放

超时释放至少要回答三个问题:谁来触发?以什么为准?失败如何兜底?

常见实现组合:

  1. Redis TTL / 预占记录过期:快速回收「短期锁」。
  2. 延时队列:在创单时投递 delay=15m 的任务,到点检查订单是否已支付。
  3. 定时扫描:扫描 PENDING_PAYMENT 且超时的订单,幂等调用库存释放接口。

下面的时序图展示「下单预占 → 支付确认 / 超时释放」的主路径(商品库存服务视角)。

sequenceDiagram
  autonumber
  participant O as 订单系统
  participant I as 库存服务
  participant R as Redis
  participant Q as 延时队列
  participant P as 支付系统

  O->>I: ReserveStock(order_id, sku, qty, ttl=15m)
  I->>R: EVAL Lua 原子扣减 available 并增加 booking
  R-->>I: OK
  I-->>O: reserved
  O->>Q: schedule ReleaseStock(order_id) @T+15m

  alt 用户在 TTL 内完成支付
    P-->>O: PaymentSuccess
    O->>I: ConfirmStock(order_id)
    I->>R: booking -= qty; issued += qty
    I-->>O: confirmed
    Note over Q: 可选:取消延时任务(若支持精确去重)
  else 超时未支付
    Q-->>I: ReleaseStock(order_id) 幂等
    I->>R: booking -= qty; available += qty
    I-->>O: released
    O-->>O: CloseOrder(timeout)
  end

与上时序图互补,建议再用 状态机 固化「预占记录」本身的生命周期(尤其是 Redis 侧 reservation:{order_id} 与 DB 影子行并存时)。下图把「可重复进入的幂等终态」标出,避免研发在「重复回调 / 重复释放」上各写一套语义。

stateDiagram-v2
  [*] --> NONE: 未创单 / 未预占
  NONE --> RESERVED: Reserve 成功\n(available↓ booking↑)
  RESERVED --> CONFIRMED: Confirm\n(booking↓ issued↑)
  RESERVED --> RELEASED: Release / 超时\n(booking↓ available↑)
  CONFIRMED --> [*]: 终态(可审计重复 Confirm)
  RELEASED --> [*]: 终态(可审计重复 Release)
  note right of RESERVED
    幂等键:order_id
    并发护栏:Lua / 版本号 / 行锁择一
  end note

关键细节

  • 幂等键order_id 贯穿 Reserve / Confirm / Release,重复调用必须安全。
  • 顺序依赖:若营销与商品双预占,失败回滚顺序应与成功顺序相反(Saga 补偿语义)。

8.3.4 超卖防护

超卖防护应分层:

  1. 热路径原子性:Redis Lua 或单分片事务,保证「检查 + 扣减」不可分割。
  2. 业务幂等:同一 order_id 重复确认只生效一次。
  3. 冷路径校验:支付回调后,在确认库存前读取 MySQL 侧汇总做二次校验(容忍更高延迟)。
  4. 对账兜底:周期任务发现 available + booking + sold 恒等式破坏或 Redis / MySQL 偏差过大,自动冻结商品并告警(见 8.5.2)。

CheckStock 与 ReserveStock 为什么要拆开? 只读 Check 适合列表页、加购前的快速失败;但它不能保证并发下的正确性。正确做法是:创单路径必须以 Reserve 这种「读改写原子操作」为准,Check 只是辅助。否则会出现「校验时还有货,下单时被抢走」的经典竞态。

秒杀场景的 Facade(可选优化):当商品库存与营销库存必须同事务化编排时,常规做法是订单 Saga 两步调用;在极端 QPS 下可以引入 FlashSaleInventoryFacade.CheckAndReserve 聚合接口,把限流、热点治理、重复请求拦截收敛到库存域的专用入口。注意:Facade 是性能与风控的「窄接口」,不要让它反向吞噬订单领域的编排职责。


8.4 供应商集成

供应商集成本质是 把「外部库存事实」映射为平台可售视图,并在预订 / 取消时调用供应商 API 对齐状态。

8.4.1 实时查询

适用:变化快、对超卖极度敏感(机票、部分热门票务)。

模式

  • 读路径:短 TTL 缓存 + 超时控制 + 熔断降级。
  • 写路径:同步预订或异步预订(供应商返回 pending 时需轮询,见博客原文异步 booking 状态机)。

读路径的 Go 骨架(与第 16 章风格一致:先缓存、后供应商、再回写、可观测)

// CheckSupplierStock 演示:实时查询 + 短缓存 + 异步快照(示意代码)
func (s *SupplierManagedStrategy) CheckStock(ctx context.Context, req *CheckStockReq) (*CheckStockResp, error) {
	cacheKey := fmt.Sprintf("inventory:supplier:%d:%d:%s", req.ItemID, req.SKUID, req.Date)

	// 1) 先读 Redis 缓存(例如 30s TTL:机票可更短,酒店可更长)
	if v, err := s.rdb.Get(ctx, cacheKey).Int(); err == nil {
		return &CheckStockResp{Available: int32(v), FromCache: true}, nil
	}

	// 2) 供应商调用必须带超时;失败要映射为可重试/不可重试
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	resp, err := s.supplier.QueryStock(ctx, &SupplierQuery{
		SupplierID: req.SupplierID,
		ProductID:  req.ExternalProductID,
		Date:       req.Date,
	})
	if err != nil {
		return nil, MapSupplierErr(err) // Retryable / Fatal / Unknown
	}

	// 3) 回写缓存 + 异步落快照(快照用于运营后台、对账与熔断时的最后成功视图)
	_ = s.rdb.Set(ctx, cacheKey, resp.Stock, 30*time.Second).Err()
	go func() {
		bg, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		_ = s.snapshot.Save(bg, req.ItemID, req.SKUID, req.Date, resp.Stock, "api")
	}()

	return &CheckStockResp{Available: resp.Stock, FromCache: false}, nil
}

工程要点(把「实时」变成可运营能力)

  • 缓存击穿:热点航线/场次在缓存过期瞬间会把供应商 QPS 顶满;需要单飞(singleflight)、随机抖动 TTL、以及网关层按 supplier_id 配额限流。
  • 错误语义Unknown 不要当作「0 库存」返回,否则会把用户引导到错误决策;应显式返回「暂不可校验」并由前端降级展示。
  • 观测:必须记录 from_cachesupplier_latency_mssupplier_error_class,否则线上只能看到「库存服务慢」,无法判断是供应商还是自研逻辑。

8.4.2 定时同步

适用:变化中等、可接受分钟级延迟(部分酒店库存)。

模式

  • 定时任务拉取供应商库存,写入本地 inventory 快照字段(如 supplier_stocksupplier_sync_time)。
  • 读路径优先读本地快照,必要时触发「刷新任务」。

8.4.3 推送模式

适用:供应商能力较强,主动推送房态 / 价格变更。

要点

  • Webhook 入口必须鉴权、幂等、重放安全。
  • 推送与定时拉取可并存:推送负责快变字段,拉取负责兜底对齐。

8.4.4 降级策略

触发条件平台行为用户侧体验
供应商超时返回可重试 / 排队;读缓存则明确标注「仅供参考」可能看到「库存紧张」
连续失败超阈值熔断一段时间,仅允许读取上次成功快照可能暂停售卖
异步预订 pending 过久进入人工处理队列,避免盲目关单造成纠纷「处理中」

下面的架构图对比 实时查询定时同步 在读路径上的差异(简化)。

flowchart LR
  subgraph RT[实时查询路径]
    A1[用户请求] --> B1[库存服务]
    B1 --> C1{Redis 缓存命中?}
    C1 -->|是| Z1[返回缓存库存]
    C1 -->|否| D1[供应商 API]
    D1 --> E1[回写 Redis 短 TTL]
    E1 --> Z1
  end

  subgraph SCH[定时同步路径]
    J1[定时任务] --> K1[供应商批量接口]
    K1 --> L1[更新 MySQL 快照字段]
    L1 --> M1[可选:刷新 Redis 视图]
    A2[用户请求] --> B2[库存服务]
    B2 --> Z2[读本地快照 / 缓存]
  end

实践建议:同一家供应商也可能混用(例如酒店:列表页用快照,下单页强刷一次实时),关键是把策略写进配置中心而非写死在代码分支。

礼品卡横跨多种模式的启示:预采购卡密(Self + Code)、实时生成卡密(Supplier + Code)、无限库存(Unlimited)往往并存于同一业务线。统一模型的价值在于:团队可以用同一张「策略决策表」讨论边界,而不是在三个服务里分别口述规则。

异步预订(pending → confirmed)的工程清单:当供应商只能异步确认时,至少补齐以下构件:supplier_booking 映射表、可重入的轮询 worker、超时与人工介入队列、订单侧状态机联动、对账任务对「平台已占 / 供应商未确认」的专项扫描。否则极易出现「钱扣了但供应商没单」或「供应商有单但平台没单」的双向不一致。


8.5 数据一致性保证

8.5.1 Redis 与 MySQL 同步

典型路径是 「Redis 同步执行,MySQL 异步落库」

  • 同步:Lua 脚本更新 Redis 中的 available/booking/issued 或券码池。
  • 异步:发送 InventoryEvent 到 Kafka,消费者批量写 inventoryinventory_operation_log
操作RedisMySQL一致性语义
预占同步 Lua异步事件最终一致
确认售出同步 Lua异步事件最终一致
运营强锁 / 黑名单视场景:可同步双写 DB强一致需求更高

原则

  • Redis 不是账本:故障恢复应以 MySQL + 日志为准,Redis 可重建。
  • Outbox(可选):若要求「绝不丢事件」,在订单或库存事务内写 outbox 表,再异步投递。

双写与消息丢失的权衡:纯「先 Redis 后发 Kafka」在进程崩溃时可能丢消息。工程上常见三种增强手段(按成本从低到高):

  1. 同步写操作日志表(简化版 outbox):Redis 成功后同步插入 inventory_operation_log(或写 binlog),再由后台任务投递 MQ;代价是热路径多一次 DB 写。
  2. 事务消息 / Outbox:与业务状态同事务提交,确保「状态变更」与「事件」原子一致。
  3. 对账修复为主、消息为辅:接受短窗口不一致,用对账把差异拉回(适合容忍度稍高、但吞吐极大的场景)。

选型没有银弹:机票酒店类强一致诉求更高,虚拟券码大促类更偏向吞吐与事后修复。

8.5.2 对账机制

对账目标不是「每时每刻 Redis == MySQL」,而是 尽快发现破坏恒等式与异常漂移,并可控修复

建议对账维度:

  1. 单行恒等式total = available + booking + locked + sold(字段含义以你的表结构为准)。
  2. 跨存储视图:Redis available vs MySQL available_stock 差值。
  3. 订单侧一致性PENDING_PAYMENT 订单是否仍存在预占记录;是否出现「仅商品预占成功、营销失败」等半截状态。
flowchart TD
  A[定时对账任务启动] --> B[拉取自管理 SKU 配置]
  B --> C[读取 Redis 聚合视图]
  C --> D[读取 MySQL 权威行]
  D --> E{恒等式成立?}
  E -->|否| X[告警 + 冻结售卖 + 记缺陷单]
  E -->|是| F{Redis vs MySQL 差值超阈?}
  F -->|否| Z[记录健康指标]
  F -->|是| G{允许自动修复?}
  G -->|是| H[以 MySQL 为准重写 Redis]
  G -->|否| Y[仅告警 + 人工确认]
  H --> Z

修复策略要谨慎:自动以 MySQL 覆盖 Redis 适合「Redis 丢数据」类问题;若根因是重复消费导致 MySQL 多减,则应阻断自动修复,先定位消息幂等缺陷。

对账任务的伪代码骨架(Go):对账不仅是数值 diff,更是「缺陷驱动」的运营工具。下面示例强调阈值、恒等式与人工门闩(auto_reconcile)。为便于阅读,abs / max 等函数省略实现。

// 伪代码骨架:abs/max/alert/rewrite 需按项目工具库实现
func ReconcileItem(ctx context.Context, cfg InventoryConfig) error {
	redisAvail := readRedisAvailable(ctx, cfg.ItemID, cfg.SKUID)
	mysqlRow, err := loadInventoryRow(ctx, cfg.ItemID, cfg.SKUID)
	if err != nil {
		return err
	}

	if !mysqlRow.identityOK() {
		return fmt.Errorf("mysql identity broken: item=%d sku=%d", cfg.ItemID, cfg.SKUID)
	}

	diff := redisAvail - mysqlRow.AvailableStock
	if abs(diff) > max(100, mysqlRow.AvailableStock/10) {
		alert(ctx, "large inventory diff", cfg.ItemID, cfg.SKUID, diff)
	}

	if cfg.AutoReconcile {
		return rewriteRedisFromMySQL(ctx, cfg.ItemID, cfg.SKUID, mysqlRow)
	}
	return nil
}

8.5.3 补偿任务

补偿任务用于处理:

  • Kafka 消费失败导致日志未落库。
  • 供应商异步预订最终态与本地订单状态不一致。
  • Saga 补偿某一步失败后的「人 + 程序」协同修复。

建议补偿任务具备:可观测进度、可重入、可限流、可人工跳过,并在执行前获取分布式锁或基于 order_id 的行级互斥,避免双写打架。

补偿与对账的分工:对账偏「批量、周期性、发现漂移」;补偿偏「单点、事件触发、把状态推进到合法终态」。两者叠加才能覆盖「消息乱序」「重复投递」「供应商晚到回调」等真实世界的粗糙边缘。

Kafka 消费者的吞吐与顺序:库存事件消费端建议「按 item_id 分区有序 + 批量落库」:item_id 分区可以保证同一商品变更串行应用,批量 INSERT 日志与合并更新可以降低 MySQL TPS。需要警惕的是:重试会导致重复消息,因此 MySQL 写入必须基于 event_id 或业务幂等键去重;否则对账会看到「日志重复 / 库存多减」。

跨库存类型一致性(商品 + 营销):秒杀场景下商品预占成功但营销失败时,必须回滚商品预占。回滚失败不要把系统留在「半占用」状态:应记录缺陷单并阻塞该 order_id 的继续支付,直到补偿成功或人工判定。该话题与第 6 章 Saga、第 9 章营销库存紧密相关,本章强调 库存侧 API 必须可单独幂等重放,以便编排器反复补偿。


8.6 系统边界与职责

8.6.1 库存系统的职责边界

库存系统应该负责

  • SKU 维度的可售数量 / 券码 / 日历切片视图的维护。
  • 预占、确认、释放、退款相关的原子操作与审计日志。
  • 供应商库存同步策略的执行与降级。

库存系统不应该负责

  • 订单优惠分摊、支付路由、用户风控评分(可读取必要参数,但不拥有规则)。
  • 商品详情文案、主图、类目属性(属于商品中心)。

8.6.2 库存 vs 商品:边界划分

维度商品中心库存系统
核心聚合SPU/SKU、属性、类目SKU(或批次 / 日期)库存数量与码池
上架生成可售商品视图根据模板初始化 inventory_config / 初始库存
快照商品快照用于订单展示可选择是否在快照中冗余「库存展示字段」

建议:商品详情页展示库存「有 / 无」可以来自搜索 / 商品聚合读模型;下单路径的强校验必须调用库存服务。

8.6.3 平台库存 vs 供应商库存

  • 平台自管:平台能强约束不超卖(在自有数据正确前提下)。
  • 供应商管理:平台只能「尽力而为」,必须定义 同步延迟下 的用户协议与技术降级(例如显示「库存紧张」、下单后异步确认)。

把「可售」定义成合同:供应商管理并不等于「平台不承担责任」。产品条款、详情页提示、客服话术需要与技术策略一致:例如列表页展示的是「上次同步快照」,下单页展示的是「下单瞬间强刷结果」,支付页又可能进入「供应商二次确认」。这些差异如果只靠前端临时拼接字段,极易引发纠纷;建议由商品 / 库存领域共同产出 可售声明(availability disclaimer) 的配置,并在关键触点统一渲染。

时间维度下的边界:酒店类库存往往以「入住日」为切片,查询与扣减都携带日期参数。库存服务应提供明确的日期合法性校验(不可售日期、最小连住、跨日边界),但不要吞掉「价格日历」职责——价格仍归计价系统,库存只回答「这一天还有没有房 / 席位」。

8.6.4 库存预占的归属

推荐由 库存服务提供 Reserve / Confirm / Release API,订单系统编排调用。避免订单服务直接写 Redis,否则:

  • 权限边界模糊,排障困难;
  • 原子脚本难以复用;
  • 监控指标分散。

进一步建议:把「预占记录」视为库存域内的聚合片段(可用 Redis HASH、也可用独立表存储影子状态),对外只暴露语义化 API。订单系统持有 order_id 与支付超时策略;库存系统持有「这单占了多少、占在哪一批次 / 哪一天」。当两边都要保存时,必须明确 主键映射与幂等回放 规则:支付回调重复到达时,Confirm 只能执行一次;超时释放与支付成功并发时,必须以「订单最终状态」为仲裁者。

组合型(Bundle)扣减的边界:套餐类商品是「一个售卖单元,多个库存单元」。库存系统可以提供 BundleReserve 事务式 API,内部仍以子 SKU 为单位调用原子脚本,但整体成功准则由库存域定义(全成或全败)。不建议把子项拆解交给订单服务循环调用——否则补偿顺序、部分失败、日志关联都会变得脆弱。


8.7 与其他系统的集成

8.7.1 与商品中心集成(商品上架时初始化库存)

商品中心在 SKU 生效时发出领域事件(或消息)是最佳挂钩点:库存服务消费事件后创建 inventory_config,并初始化 inventory 行(数量、批次、默认阈值等)。这里的关键是 幂等:同一 SKU 的重复发布 / 回滚发布不得生成重复配置行;建议使用 item_id + sku_id 唯一键约束,并在消费端用「版本号 / 生效时间窗」判定是否应用变更。

对「供应商管理」品类,初始化阶段就要写入 supplier_idsync_strategy,并创建供应商适配器所需的 外部商品编码映射(否则库存同步与预订调用会在上线后才发现无法对齐)。对于券码制,还要初始化 batch_id 维度与分表路由规则,避免大促时临时改路由。

8.7.2 与订单系统集成(预占 / 扣减 / 释放)

创单路径建议以 Reserve 作为硬闸门:订单系统先拿到库存服务的成功回执,再写入订单主表为 PENDING_PAYMENT。如果顺序反过来,会出现「订单已创建但库存未占」的不可恢复窗口,除非再引入复杂补偿。

支付成功后的 Confirm 应与支付回调幂等键绑定(支付单号 / 回调事件 id)。实践中常见错误是:支付重放导致库存二次加 issued,或支付失败却误触发 Confirm。关单 / 超时释放 应与订单状态机严格对齐:只有从可取消状态进入释放,才调用 Release;对于已进入履约的订单,释放必须转为退款域的逆向流程(可能涉及供应商取消接口)。

8.7.3 与供应商系统集成(实时查询 / 定时同步)

供应商集成建议落在 供应商网关库存适配器层,由库存服务调用,而不是让订单服务直连供应商:订单系统只需要知道「库存服务承诺的结果」,不需要理解每家供应商的 OAuth、签名算法与重试语义。

适配器层应统一:超时、重试(仅对幂等读 / 明确幂等写)、熔断、隔离舱(bulkhead)、以及 错误码映射。强烈建议把供应商错误抽象为三类:Retryable(可重试)、Fatal(明确失败)、Unknown(需要人工核对)。Unknown 类错误不要自动重试写入路径,否则极易造成重复预订。

8.7.4 库存变更事件发布

事件字段建议包含:event_idevent_typeitem_idsku_idorder_idquantitybefore/after 快照、时间戳。消费者可以是:搜索引擎刷新可售标签、报表、风控。

事件设计要兼顾 可排序可去重event_id 建议全局唯一;event_type 建议稳定枚举;before/after 用于审计与对账回放。对于券码制,还应携带 code_ids 或哈希摘要(避免明文扩散到不该出现的下游)。如果下游是搜索索引,通常只需要「可售阈值变化」而非每一次微抖动,可增加 聚合投影(projector)把高频事件折叠为低频索引更新。

8.7.5 集成模式与降级策略

  • 同步编排 + 异步对账 是默认主路径;
  • 秒杀聚合接口(一次网络往返完成商品 + 营销预占)属于性能优化特例,应被清晰标记为「窄场景专用」,避免成为全局耦合点。

集成时序(常规创单:订单编排库存):下图强调「库存服务不创建订单」,只提供原子操作;订单系统承担 Saga 与超时任务。

sequenceDiagram
  autonumber
  participant U as 用户
  participant O as 订单系统
  participant PI as 商品库存
  participant CI as 营销库存
  participant DB as MySQL

  U->>O: 提交订单
  O->>PI: ReserveStock(order_id)
  PI-->>O: OK
  O->>CI: ReserveQuota(order_id)
  CI-->>O: OK
  O->>DB: InsertOrder(PENDING_PAYMENT)
  O-->>U: 创单成功

  Note over O,PI: 支付成功回调路径省略;失败时按逆序补偿 Release

降级策略(库存不可用):严格模式直接失败;宽松模式允许「先创单后补扣」(极易超卖,仅适合内部试单或供应商兜底能力极强且可取消的场景)。若启用宽松模式,必须同步启用 更频繁对账 + 更强支付确认校验 + 明确法务条款


8.8 工程实践

8.8.1 Lua 脚本原子性

Redis 单线程执行 Lua,可保证脚本内多条命令原子执行,非常适合「读-判断-写」库存扣减。

数量制预占脚本(示例):从 available 扣减并增加 booking,不足返回 -1

-- KEYS[1]: inventory:qty:stock:{itemID}:{skuID}
-- ARGV[1]: qty
local key = KEYS[1]
local qty = tonumber(ARGV[1])

local available = tonumber(redis.call('HGET', key, 'available') or '0')
if available < qty then
  return -1
end

redis.call('HINCRBY', key, 'available', -qty)
redis.call('HINCRBY', key, 'booking', qty)
return available - qty

带幂等门的预占(强烈建议):仅靠业务层判断「是否已预占」仍可能出现并发双调。更稳妥做法是把幂等状态写进同一个 HASH(或独立 key),让 Lua 一次完成「首次预占 / 重复预占返回成功」。

-- KEYS[1]: inventory:qty:stock:{itemID}:{skuID}
-- KEYS[2]: inventory:qty:reservation:{orderID}
-- ARGV[1]: qty
-- ARGV[2]: ttlSeconds
local stockKey = KEYS[1]
local resKey = KEYS[2]
local qty = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])

if redis.call('EXISTS', resKey) == 1 then
  return 1
end

local available = tonumber(redis.call('HGET', stockKey, 'available') or '0')
if available < qty then
  return -1
end

redis.call('HINCRBY', stockKey, 'available', -qty)
redis.call('HINCRBY', stockKey, 'booking', qty)

redis.call('HSET', resKey,
  'qty', qty,
  'status', 'RESERVED'
)
redis.call('EXPIRE', resKey, ttl)
return 0

确认与释放(与预占配对):确认时将 booking 转为 issued;释放时退回 available。下面脚本演示「仅当 reservation key 仍存在且状态为 RESERVED 才确认」,用于防止重复支付回调导致二次加 issued

-- KEYS[1]: inventory:qty:stock:{itemID}:{skuID}
-- KEYS[2]: inventory:qty:reservation:{orderID}
-- ARGV[1]: op -- CONFIRM or RELEASE
local stockKey = KEYS[1]
local resKey = KEYS[2]
local op = ARGV[1]

if redis.call('EXISTS', resKey) == 0 then
  return 2
end

local qty = tonumber(redis.call('HGET', resKey, 'qty') or '0')
local st = redis.call('HGET', resKey, 'status')

if st ~= 'RESERVED' then
  return 3
end

if op == 'CONFIRM' then
  local booking = tonumber(redis.call('HGET', stockKey, 'booking') or '0')
  if booking < qty then return -1 end
  redis.call('HINCRBY', stockKey, 'booking', -qty)
  redis.call('HINCRBY', stockKey, 'issued', qty)
  redis.call('HSET', resKey, 'status', 'CONFIRMED')
  redis.call('PERSIST', resKey)
  return 0
end

if op == 'RELEASE' then
  local booking = tonumber(redis.call('HGET', stockKey, 'booking') or '0')
  if booking < qty then return -1 end
  redis.call('HINCRBY', stockKey, 'booking', -qty)
  redis.call('HINCRBY', stockKey, 'available', qty)
  redis.call('DEL', resKey)
  return 0
end

return 4

Go 侧调用(go-redis v9 示例)

package inventory

import (
	"context"
	"fmt"

	"github.com/redis/go-redis/v9"
)

const reserveQtyLua = `
local key = KEYS[1]
local qty = tonumber(ARGV[1])
local available = tonumber(redis.call('HGET', key, 'available') or '0')
if available < qty then return -1 end
redis.call('HINCRBY', key, 'available', -qty)
redis.call('HINCRBY', key, 'booking', qty)
return available - qty
`

type RedisInventory struct {
	rdb redis.UniversalClient
}

func (s *RedisInventory) ReserveQuantity(ctx context.Context, itemID, skuID int64, qty int) (int64, error) {
	key := fmt.Sprintf("inventory:qty:stock:%d:%d", itemID, skuID)
	res, err := s.rdb.Eval(ctx, reserveQtyLua, []string{key}, qty).Int64()
	if err != nil {
		return 0, err
	}
	if res < 0 {
		return 0, ErrNotEnoughStock
	}
	return res, nil
}

券码池出货脚本(示例)LRANGE + LTRIM 同事务化,避免读到数据却在截断前被并发修改。

-- KEYS[1]: inventory:code:pool:{item}:{sku}:{batch}
-- ARGV[1]: n
local n = tonumber(ARGV[1])
local codes = redis.call('LRANGE', KEYS[1], 0, n - 1)
redis.call('LTRIM', KEYS[1], n, -1)
return codes

补货并发与空池短路:券码制常见问题是 Redis LIST 空时频繁穿透数据库。应组合使用「空池标记(短 TTL)」「补货分布式锁」「MySQL 侧复合索引 + id 游标分页」,避免补货慢事务拖垮热路径。

脚本版本管理:生产环境建议把 SHA 载入或显式 SCRIPT LOAD,并对脚本变更做版本号控制,避免滚动发布期间混用旧脚本。

8.8.2 性能优化

  • 热点 Key:本地缓存、随机副本读、网关限流、拆分活动维度字段。
  • 批量落库:Kafka consumer 批量 INSERT 操作日志,减少 MySQL roundtrip。
  • 预热:大促前把券码池批量灌入 Redis,避免冷启动补货抖动。

容量与峰值的经验法则(中型平台量级):当峰值下单 QPS 相对日均放大两个数量级以上时,瓶颈往往不在「业务 if-else」,而在 Redis 热 Key、Kafka 消费滞后、MySQL 批量写入窗口。因此性能优化应优先围绕:热点分散、消息攒批、限流前置、以及「允许短暂最终一致」的产品与风控共识。

Redis 故障降级:Redis 不可用时,可短期切到 MySQL 行级锁扣减(UPDATE ... WHERE available_stock >= ?SELECT ... FOR UPDATE),并把实例标记为 degraded,待恢复后做一次 以 MySQL 为准的全量回填。降级期间的延迟与锁竞争上升是预期成本,需要在监控面板明确标注「降级模式」,避免误读 SLO。

8.8.3 监控告警

建议至少监控:

  • reserve/confirm/release 成功率与 P99 延迟;
  • Redis / MySQL 差异直方图;
  • 供应商调用错误率与熔断状态;
  • 对账修复次数与人工介入队列长度。

告警分级示例(可与 Prometheus 规则结合)

级别触发条件响应目标
P0任意 SKU 出现「已售大于总量」或恒等式破坏立即停售 + 紧急修复
P1Redis / MySQL 可用量长期分叉且持续扩大1 小时内定位根因
P2供应商同步延迟超阈值但未破坏交易降级展示 + 供应商工单

8.9 本章小结

本章围绕「统一二维模型 + 策略实现 + 清晰系统边界」展开:

  • ManagementType × UnitType 收敛多品类差异,并以矩阵图帮助团队建立共同语言。
  • 预占 / 确认 / 超时释放 为主轴设计扣减策略,并用时序图明确订单、库存、延时队列与支付的协作关系。
  • 在供应商集成上区分 实时查询与定时同步 的读路径差异,并强调降级与产品文案的一致性。
  • 在一致性上采用 Redis 同步 + MySQL 异步 + 对账修复 的组合拳,避免把 Redis 当作唯一账本。
  • 在工程层用 Go + Lua 落实热路径原子性,配合监控与补偿任务形成闭环。

落地检查清单(团队可用)

  1. 每个 SKU 是否都能解释其 (management_type, unit_type, deduct_timing)
  2. 创单路径是否以 Reserve 原子接口 为准,而不是仅 Check?
  3. order_id 是否在 Reserve / Confirm / Release 全链路幂等?
  4. 是否同时具备 TTL、延时队列、定时扫描 至少两道释放防线?
  5. 是否具备 Redis / MySQL / 订单预占 三角对账与人工门闩?
  6. 供应商异步确认是否有 映射表 + worker + 人工队列

阅读建议:若读者刚完成第 7 章商品中心与第 6 章一致性章节,可按「配置初始化 → 创单预占 → 支付确认 → 对账修复」的顺序对照本章示意图走读一遍;再把自家品类的 (management_type, unit_type) 填入矩阵,通常能在工作坊中快速对齐产品与工程预期。建议同时准备 1~2 个真实故障案例作为讨论锚点,避免停留在抽象原则层面。

下一章预告:第 9 章将深入营销系统,重点讨论优惠计算与营销库存(券、活动、补贴)如何与商品库存协同,避免「算得便宜却卖超了」这类跨域问题。


导航书籍主页 | 完整目录 | 上一章 | 下一章

导航书籍主页 | 完整目录 | 上一章 | 下一章


第9章 营销系统

交易链路关键域:营销系统负责「增长与让利」的可编排表达——优惠券、积分、活动、补贴等工具在成本可控前提下提升转化;同时必须与计价、订单、库存、支付等系统严格分工,避免「算价口径漂移」与「资源扣减双写」。


9.1 系统概览

9.1.1 营销系统的定位

一句话定位:营销系统是电商平台的增长引擎让利规则中心,它不替代「商品价格事实来源」(商品中心 / 计价中心),而是对可售商品集合施加条件化权益(券、活动价、积分抵扣、平台补贴),并在交易链路中完成可审计的占用与核销

与相邻系统的关系(职责视角):

系统营销系统依赖它什么营销系统不替它承担什么
商品中心类目、SPU/SKU、可售状态、圈品范围商品主数据维护、上下架编排
计价中心统一试算编排、价格分层模型、快照口径基础价/渠道价等「标价事实」的唯一来源(按组织边界而定)
库存系统可售库存、预占结果实物库存扣减与释放
订单系统创单编排、状态机、补偿入口订单主单据生命周期(营销只参与其中资源步骤)
用户系统画像标签、等级、风控信号账号体系与鉴权主责
支付系统实付金额、分账、补贴清算渠道对接与支付状态机

价值闭环:投放 → 领取/参与 → 试算曝光 → 下单锁定 → 支付核销 → 结算对账 → 报表 ROI。缺任何一环都会出现「看得见优惠、对不上账」的工程事故。

9.1.2 核心业务场景

典型场景可按用户生命周期与平台目标拆分:

拉新:新人券、首单立减、渠道专属券批次、注册礼包(券 + 积分组合)。

促活与留存:签到积分、任务体系、会员等级权益、生日礼、沉默召回券。

成交提升(GMV / AOV):满减满折、跨店凑单、限时折扣、N 元购、买赠。

热点营销(高并发):秒杀、抢券、限量补贴;对系统提出与普通促销完全不同的容量与一致性要求。

平台型业务(B2B2C):商家自营销 + 平台统一规则 + 审核流;成本承担方可能是商家、平台或按比例共担,必须在支付与清结算链路可解释、可分摊。

B2C 与 B2B2C 的营销差异(决定你是否要引入「审核流、分账、跨店叠加」三件套):

维度B2C(自营为主)B2B2C(平台 + 商家)
营销主体平台单一主体平台与多商家并存
成本承担平台预算闭环即可需定义商家承担、平台补贴、联合出资比例
活动审核通常内部运营闭环商家活动常需平台审核与风控评分
优惠叠加平台统一互斥组即可需处理跨店、跨卖家券、店铺券与平台券的优先级
结算复杂度订单金额 ≈ 平台收入口径需分账、分润、逆向退款时的营销成本回冲

非功能需求(NFR)速查:营销系统既要「算得对」,也要「扛得住、赔得起、查得到」。建议在架构评审材料中显式写出下列指标,并与监控看板一一映射:

  • 一致性:券与积分的状态迁移与订单支付状态单调一致;允许的最终一致边界写清楚(例如报表延迟分钟级)。
  • 幂等性:领取、冻结、核销、回滚接口全部带业务幂等键;消息消费以 event_id(biz_type,biz_id,action) 去重。
  • 可用性:试算路径可降级;写路径失败可补偿;热点活动具备独立熔断域,避免拖垮全站下单。
  • 可观测性:每一次试算输出 trace_id;每一次核销写审计流水;预算与库存类 Redis Key 有容量与过期策略。
  • 安全与合规:防刷、频控、隐私最小化;补贴与券的发放记录满足审计留存周期。

9.1.3 系统架构

工程上通常采用「接入编排 + 工具域服务自治 + 计算引擎集中」的形态:网关负责鉴权、路由、限流与实验分桶;券/积分/活动各自拥有独立数据库边界(逻辑或物理分库);营销计算引擎聚合多源输入并调用规则引擎;异步事件通过消息总线广播给通知、风控、数据仓库与对账任务。

flowchart LR
  U[用户终端] --> G[营销网关 / BFF]
  G --> CS[优惠券服务]
  G --> PS[积分服务]
  G --> AS[活动服务]
  G --> CE[营销计算引擎]

  CS --> CDB[(券库 MySQL)]
  PS --> PDB[(积分库 MySQL)]
  AS --> ADB[(活动库 MySQL)]
  AS --> ES[(Elasticsearch)]

  CE --> RE[[规则引擎]]
  CE --> RC[(规则配置 Redis)]

  subgraph Cache[高性能层]
    R[(Redis: 库存/频控/锁)]
  end

  CS --> R
  PS --> R
  AS --> R

  subgraph Bus[异步总线]
    K[Kafka]
  end

  CS --> K
  PS --> K
  AS --> K
  CE --> K

  G --> PR[商品中心]
  G --> PC[计价中心]
  G --> OR[订单服务]

协作要点

  1. 读路径:试算以「低耦合聚合」为目标,尽量通过计价中心统一编排(见 9.6、9.7),营销服务提供「可用工具集合 + 规则解释」。
  2. 写路径:领取、冻结、核销、回滚必须可幂等、可补偿;与订单 Saga 步骤一一对应(见 9.7.3)。
  3. 观测路径:任何金额差异必须能定位到「规则版本 + 输入快照 + 引擎输出」。

典型技术选型(可按团队资产替换,但角色分工建议保留)

组件类型常见选型在营销系统中的职责
关系型数据库MySQL / PostgreSQL券批次、用户券、积分流水、活动配置、审计表;强一致事实源
缓存与计数Redis热点库存、用户领券次数、预算桶、分布式锁、滑动窗口限流
消息队列Kafka / Pulsar异步通知、对账、数据仓库同步、延迟核销补偿
搜索与分析Elasticsearch活动检索、运营圈人、券批次检索;与交易主路径解耦
流量治理Sentinel / Envoy / 自研网关热点接口限流、熔断、排队策略入口

容量与体验的经验区间(用于评审对齐,不是 SLA 承诺):日常试算 QPS 与购物车刷新强相关;大促峰值往往来自「领券 + 秒杀下单」叠加。实践中常把「试算」与「领券写路径」在网关层拆分集群,避免读放大拖慢写。秒杀接口的目标不是无限吞吐,而是失败要快、成功要稳:失败请求在边缘以毫秒级返回,成功请求进入受控队列,尾部延迟可接受。


9.2 营销工具体系

营销工具体系的本质是权益载体不同:券是「凭证类权益」,积分是「账户类权益」,活动是「时段/集合类权益」,补贴是「清算类权益」。统一抽象有利于计算引擎与对账。

从领域建模角度,建议抽一层极薄的**营销权益(PromotionEntitlement)**通用语言:任何工具最终都落到「是否可用、可用多少、如何占用、如何确认、如何冲正」五问。这样订单编排层不必理解「满减与折扣的数学差异」,只理解统一的 Try/Confirm/Cancel 契约即可。

反模式提醒

  • 把「活动价」直接写进商品中心主价格字段,导致历史订单与供应商结算口径被破坏。
  • 在订单服务内复制一份券规则计算逻辑,短期最快,长期必然与营销引擎漂移。
  • 补贴只记在营销表、不落订单行快照,导致支付成功后的财务还原无法对齐。
flowchart TB
  subgraph Tools[营销工具域]
    COUP[优惠券子域]
    PT[积分子域]
    ACT[活动子域]
    SUB[补贴子域]
  end

  subgraph Shared[共享能力]
    ID[权益实例 ID]
    SM[状态机与审计日志]
    POL[互斥/叠加策略引用]
  end

  COUP --> Shared
  PT --> Shared
  ACT --> Shared
  SUB --> Shared

  CE2[营销计算引擎] --> POL
  CE2 --> COUP
  CE2 --> PT
  CE2 --> ACT

三大工具与数据平面的关系(落地视图):优惠券与积分强依赖「用户维度」一致性与账务流水;活动强依赖「商品维度」圈品与时段索引。下图从读写路径拆开,便于与容量规划对齐:写路径(领取、冻结、核销)走高一致通道;读路径(列表、试算)可走缓存与只读副本,但创单前必须有一次穿透校验。

flowchart TB
  subgraph CouponPlane[优惠券平面]
    CB[(券批次表)]
    CU[(用户券实例)]
    CL[(券审计流水)]
    CRS[[券库存 Redis]]
  end

  subgraph PointsPlane[积分平面]
    PA[(积分账户)]
    PL[(积分流水)]
    PE[(过期索引 / 批次桶)]
  end

  subgraph ActivityPlane[活动平面]
    AC[(活动主数据)]
    AP[(圈品映射 / 规则表达式)]
    ES[(活动检索 ES)]
    ARS[[活动配额 Redis]]
  end

  GW2[营销网关] -->|写: 领取 / 冻结| CouponPlane
  GW2 -->|写: 发放 / 扣减| PointsPlane
  GW2 -->|读: 命中检索| ActivityPlane

  CE3[营销计算引擎] -->|读模型| CouponPlane
  CE3 -->|读余额| PointsPlane
  CE3 -->|读命中| ActivityPlane
  CE3 -->|不写账务| GW2

9.2.1 优惠券系统

模型拆分

  • Coupon(券批次):描述面额/折扣、门槛、总库存、每用户限领、适用范围(全场/类目/SKU)、承担方(平台/商家)。
  • CouponUser(用户券实例):领取时间、过期时间、状态(未使用/冻结/已使用/作废)。
  • CouponLog(审计流水):谁在何时以何因做了何动作;是对账与客服判责依据。

关键实现约束

  1. 超发控制:热点券批次用 Redis 原子扣减「可领库存」,DB 落库作为最终事实;二者通过异步对账修正(见 9.8.2)。
  2. 核销一致性:下单冻结、支付成功确认核销、关单回滚;状态迁移必须落在单用户券粒度锁或等价乐观锁上,避免并发双花。

券批次生命周期与运营协同:除技术状态外,建议为运营提供「紧急下线」与「仅禁止新领取、已领取仍可用」两种模式。前者用于舆情与合规风险,后者用于预算将尽时的平滑收口。两种模式在网关与试算引擎侧都要有显式开关,避免只改数据库导致缓存层继续发券。

import (
	"context"
	"strconv"
	"time"
)

// CouponUserStatus 描述用户券生命周期(示意)
type CouponUserStatus string

const (
	CouponUserUnused  CouponUserStatus = "UNUSED"
	CouponUserFrozen  CouponUserStatus = "FROZEN"
	CouponUserUsed    CouponUserStatus = "USED"
	CouponUserExpired CouponUserStatus = "EXPIRED"
)

type FreezeCouponCommand struct {
	UserID       int64
	CouponUserID int64
	OrderID      int64
	Idempotency  string
}

type CouponAppService struct {
	repo   CouponRepository
	locker DistributedLock
	bus    EventBus
}

func (s *CouponAppService) Freeze(ctx context.Context, cmd FreezeCouponCommand) error {
	unlock, err := s.locker.Lock(ctx, "coupon_user:"+strconv.FormatInt(cmd.CouponUserID, 10), 3*time.Second)
	if err != nil {
		return err
	}
	defer unlock()

	cu, err := s.repo.GetCouponUserForUpdate(ctx, cmd.CouponUserID)
	if err != nil {
		return err
	}
	if cu.UserID != cmd.UserID {
		return ErrNotOwner
	}

	// 幂等:同一订单重复冻结直接成功
	if cu.Status == CouponUserFrozen && cu.FrozenOrderID != nil && *cu.FrozenOrderID == cmd.OrderID {
		return nil
	}
	if cu.Status != CouponUserUnused {
		return ErrInvalidState
	}

	return s.repo.Transition(ctx, cmd.CouponUserID, Transition{
		From: CouponUserUnused,
		To:   CouponUserFrozen,
		OrderID: cmd.OrderID,
		Reason:  "freeze_for_order",
		IdemKey: cmd.Idempotency,
	})
}

9.2.2 积分系统

账户模型available / frozen 双桶;流水追加不可变;过期建议「批次/桶」或「到期索引表」驱动,避免全表扫描。

并发更新:高冲突账户使用 version 乐观重试;低冲突可用单行 CAS。对外接口必须支持业务幂等键(例如 biz_type + biz_id)防止重复发放。

import (
	"context"
	"strconv"
	"time"
)

type SpendPointsCommand struct {
	UserID      int64
	Points      int64
	OrderID     int64
	Idempotency string
}

func (s *PointsAppService) Spend(ctx context.Context, cmd SpendPointsCommand) error {
	if ok, err := s.repo.InsertIdempotency(ctx, "points_spend", cmd.Idempotency); err != nil {
		return err
	} else if !ok {
		return nil
	}

	for i := 0; i < 5; i++ {
		acct, err := s.repo.GetAccount(ctx, cmd.UserID)
		if err != nil {
			return err
		}
		if acct.Available < cmd.Points {
			return ErrInsufficientPoints
		}

		affected, err := s.repo.UpdateAvailableCAS(ctx, cmd.UserID, acct.Version, acct.Available-cmd.Points, acct.TotalSpent+cmd.Points)
		if err != nil {
			return err
		}
		if affected == 1 {
			_ = s.repo.AppendLog(ctx, PointsLog{
				UserID: cmd.UserID, Type: "SPEND", Delta: -cmd.Points,
				BizType: "ORDER", BizID: strconv.FormatInt(cmd.OrderID, 10),
			})
			return nil
		}
		time.Sleep(time.Duration(10*(i+1)) * time.Millisecond)
	}
	return ErrWriteConflict
}

9.2.3 活动系统

活动系统负责规则配置 + 圈品 + 生命周期治理。活动类型差异很大,但工程上可收敛为:

  1. 活动元数据:时间窗、状态机(草稿/待审/生效/结束/作废)。
  2. 参与单元:SKU 级活动价、店铺级满减、平台级跨店活动。
  3. 执行策略:由计算引擎解释 rule_config(JSON / DSL),活动服务自身避免堆叠 switch 地狱(与 9.3 联动)。

圈品(与商品中心集成详见 9.7.1):活动侧存 activity_product 映射或存规则表达式;运行时以 product_id/sku_id/category_id 多路判定,注意索引与缓存击穿。

活动运营与工程协作:活动系统往往是运营配置最高频的子系统。建议把配置错误分为三类分别治理:语法错误(JSON Schema 校验拒绝保存)、语义风险(例如折扣低于成本阈值触发风控审核)、容量风险(圈品过大导致试算超时,需异步预计算 + 结果缓存)。Engineering 侧提供「沙箱试算」与「灰度发布」能力,比单纯堆人审核更有效。

常见活动形态与工程关注点(节选):

活动形态业务目标工程关注点
满减满折提升客单价跨店分摊、尾差、与券叠加顺序
限时直降清库存 / 打爆款与基础价、渠道价冲突检测
秒杀抢购引流热点库存、风控、异步下单、超卖校准
买赠关联销售赠品行生成、赠品库存、履约拆单

9.2.4 补贴系统

补贴与「券/活动」不同之处在于:它往往不直接以用户可见凭证表达,而是以平台/商家承担比例进入清结算。典型场景:

  • 平台秒杀补贴:活动价低于供货价差额由平台承担。
  • 联合营销:商家出资 70%,平台出资 30%。
  • 支付立减:渠道补贴 + 平台补贴叠加(需风控与预算)。

数据落点:订单行级记录「营销成本分摊字段」;支付成功后由营销结算服务生成结算事实表,推送给财务/对账系统(与 9.7.5 呼应)。

type SubsidySplit struct {
	OrderID        int64
	LineID         int64
	PlatformCent   int64
	MerchantCent   int64
	ThirdPartyCent int64
	Currency       string
}

func mulDiv64(a, b, denom int64) int64 {
	if denom == 0 {
		return 0
	}
	return (a * b) / denom
}

func BuildSubsidySplit(line LinePriceSnapshot, policy CostSharePolicy) SubsidySplit {
	discount := line.ListCent - line.PayableCent
	platform := mulDiv64(discount, policy.PlatformBP, 10_000)
	third := mulDiv64(discount, policy.ChannelBP, 10_000)
	merchant := discount - platform - third
	if merchant < 0 {
		merchant = 0
	}
	return SubsidySplit{OrderID: line.OrderID, LineID: line.LineID, PlatformCent: platform, MerchantCent: merchant, ThirdPartyCent: third, Currency: line.Currency}
}

9.3 营销计算引擎

营销计算引擎是「把业务上含糊的便宜」翻译成「可执行、可分摊、可回滚」的工程模块。它输入购物车行、用户工具实例、活动集合、规则版本;输出每个 SKU 行的优惠拆分与订单级汇总。

为什么必须单独建设「引擎」而不是散落在各接口里? 因为营销规则的变化频率远高于交易主流程:运营每周都可能调整叠加策略、临时插入互斥组、或对某渠道单独放量。若把规则散落在购物车、结算、创单多个服务,最终一定出现「页面能买、结算不能买」或「结算能买、支付少减」的漂移。引擎化的核心价值是把规则解释收敛到单一模块,并把输入输出契约化,让其他系统以「黑盒服务」方式依赖它。

输入输出的工程契约(建议写进接口文档的第一页)

  • 输入必须可序列化快照化:不仅是商品 ID 列表,还应包含价格快照引用、店铺维度、会员等级、渠道、时区与活动版本。任何无法快照的输入都不应进入创单强一致路径。
  • 输出必须可分摊:除了订单级优惠总额,还要给出「行级拆分」与「税/运费处理建议字段」(若业务需要),否则财务与发票域会再次各自实现一套拆分。
  • 输出必须可回放trace 不是日志炫技,而是客服判责与线上排障的最低成本工具;建议以结构化 JSON 存储关键决策点(命中、未命中原因、互斥裁决)。

9.3.1 规则引擎设计

规则引擎的目标不是追求通用 AI,而是追求:可版本化、可灰度、可解释、可单测。推荐分层:

  1. 事实层(Facts):用户、店铺、渠道、会员等级、商品标签、时间窗。
  2. 约束层(Constraints):互斥组、优先级、每单上限、每用户上限、黑白名单。
  3. 策略层(Policies):叠加顺序(先活动后券 / 先券后活动)、分摊策略(按比例/按剩余价)、取整模式。
  4. 执行层(Actions):生成 AppliedPromotion 列表与金额。
flowchart TB
  IN[试算请求\n购物车行 + 用户选择] --> NORM[规范化 Facts\n类目/店铺/渠道/等级]
  NORM --> MATCH[规则匹配\n索引 + 过滤]
  MATCH --> CONS[约束求解\n互斥/上限/黑名单]
  CONS --> ORD[策略排序\n优先级 + tie-break]
  ORD --> APPLY[动作执行器\n生成应用明细]
  APPLY --> ALLOC[分摊器\n尾差修正]
  ALLOC --> OUT[试算结果\n明细 + 汇总 + trace]

  CFG[(规则配置版本)] --> MATCH
  CFG --> CONS
  CFG --> ORD

  subgraph Exec[执行器插件]
    A1[满减]
    A2[折扣封顶]
    A3[积分抵扣]
    A4[活动价覆盖]
  end

  APPLY --> Exec

落地建议:规则配置存版本号;试算响应携带 rule_versionengine_trace_id;创单快照必须引用同一版本,避免「页面价 ≠ 创单价」纠纷。

规则引擎实现梯度(从简到繁)

  1. 配置驱动 + 少量代码:适合多数电商平台;规则以结构化 JSON 存储,由固定管线解释;上线规则走版本表 + 灰度。
  2. DSL + 安全沙箱:适合玩法极多、运营希望「自写表达式」的团队;需限制可调函数集合、CPU 时间、内存与外部 I/O。
  3. 外置规则引擎(Rete 系):适合金融级复杂规则或强审计行业;引入成本高,需评估团队运维能力。

无论哪一梯度,都不要把「外部 I/O」藏在规则匹配的热路径里:事实应在进入引擎前由编排层并行拉齐并做超时兜底,引擎内部尽量纯函数化,便于单测与回放。

type RuleEngine interface {
	Evaluate(ctx context.Context, in BasketInput, cfg RuleSetVersion) (Evaluation, error)
}

type Evaluation struct {
	Applied []AppliedPromotion
	Trace   []TraceStep
}

type DefaultRuleEngine struct {
	matcher   Matcher
	solver    ConstraintSolver
	applier   ApplierChain
	allocator LineAllocator
}

func (e *DefaultRuleEngine) Evaluate(ctx context.Context, in BasketInput, cfg RuleSetVersion) (Evaluation, error) {
	candidates, err := e.matcher.Match(ctx, in, cfg)
	if err != nil {
		return Evaluation{}, err
	}
	filtered, err := e.solver.ApplyConstraints(ctx, in, candidates)
	if err != nil {
		return Evaluation{}, err
	}
	applied, trace, err := e.applier.Apply(ctx, in, filtered, cfg.StackingPolicy)
	if err != nil {
		return Evaluation{}, err
	}
	if err := e.allocator.AllocateToLines(ctx, in.Lines, &applied); err != nil {
		return Evaluation{}, err
	}
	return Evaluation{Applied: applied, Trace: trace}, nil
}

9.3.2 优惠叠加与互斥

叠加规则是事故高发区。建议产品口径与实现口径合一:用「互斥组 ID + 优先级 + 可叠加白名单」三要素表达一切

flowchart TD
  S([开始叠加编排]) --> P1[步骤1: 活动价 / 秒杀价\n命中后刷新行内基准价]
  P1 --> P2[步骤2: 店铺级促销\n满减 / 满折 / 店铺券池]
  P2 --> G{互斥组校验\n同组择优}
  G -->|存在冲突| R[按优先级 / 用户选择\n保留唯一胜出项]
  G -->|无冲突| P3[步骤3: 平台级促销\n跨店满减 / 平台券]
  P3 --> P4[步骤4: 积分抵扣\n上限、比例、最低应付]
  P4 --> P5[步骤5: 支付渠道优惠\n由支付域承接可选]
  P5 --> E([输出最终应付\n含 trace 与分摊])

互斥典型:同一互斥组内多张券二选一;活动价与部分券互斥;渠道支付券与平台券互斥。实现上不要在多个服务各写一段 if,而应由引擎读取同一份配置

type StackingPolicy struct {
	Steps []StackStep
}

type StackStep struct {
	Name        string
	MutexGroups []string // promotions in same group are mutually exclusive within this step
}

type MutexGuard struct{}

func (MutexGuard) PickAtMostOne(ps []Candidate) ([]Candidate, error) {
	seen := map[string]Candidate{}
	out := make([]Candidate, 0, len(ps))
	for _, c := range ps {
		if c.MutexGroup == "" {
			out = append(out, c)
			continue
		}
		old, ok := seen[c.MutexGroup]
		if !ok || c.Priority > old.Priority {
			seen[c.MutexGroup] = c
		}
	}
	for _, v := range seen {
		out = append(out, v)
	}
	return out, nil
}

9.3.3 最优解求解

「最优」必须业务定义:常见是用户应付最小平台补贴最小GMV 最大。工程上可用:

  • 小规模:券张数 ≤ 3 且活动组合有限时,有界枚举最可靠。
  • 中等规模:动态规划(若可分解为线性结构);或贪心 + 校验(先取门槛最高券,再修正)。
  • 大规模:启发式 + 约束剪枝;必须输出可解释 trace,避免黑盒。
type Plan struct {
	ChosenCoupons []int64
	DiscountCent  int64
}

func BestCouponBruteForce(cents int64, coupons []CouponView) Plan {
	best := Plan{DiscountCent: -1}
	n := len(coupons)
	for mask := 0; mask < (1 << n); mask++ {
		var sum int64
		var ids []int64
		for i := 0; i < n; i++ {
			if mask&(1<<i) == 0 {
				continue
			}
			c := coupons[i]
			if cents < c.MinSpendCent {
				sum = -1
				break
			}
			sum += c.DiscountCent
			ids = append(ids, c.CouponUserID)
		}
		if sum < 0 {
			continue
		}
		if sum > best.DiscountCent {
			best = Plan{ChosenCoupons: append([]int64(nil), ids...), DiscountCent: sum}
		}
	}
	return best
}

复杂度与工程边界:有界枚举在「券实例候选数」与「活动组合数」上是指数级,评审时要写清楚上限。实践中常通过产品约束「一单最多使用 N 张券」「同一互斥组仅允许一张」把搜索空间压到可接受范围。若业务坚持「多券最优」,建议把求解器做成独立服务并设置硬超时与降级策略(返回用户已选方案或启发式方案),避免阻塞创单主链路。

从枚举到「可证明正确」的贪心:当互斥组把候选压成「每张券至多一张、每组至多一张」时,常见目标函数(应付最小)往往可通过「按门槛分层 + 组内按优惠额排序」的贪心得到最优,前提是产品承认规则满足 拟阵(matroid) 或近似结构。工程上不必引入过重数学证明,但应在设计文档写清 贪心成立的前提(例如:折扣不随剩余金额非单调变化、不存在「用券 A 才解锁券 B」这类交叉依赖)。一旦出现交叉依赖,应显式退回枚举或 MILP 小模型求解,并在超时后降级为「用户已选方案」。

动态规划适用的一种典型子结构:若订单可拆为若干「独立店铺子篮」,且店铺间仅存在「平台跨店满减」一条耦合边,可先按店求局部最优,再在平台层做一次低维 DP(阶梯满减档位通常 ≤10)。这与「全购物车暴力 bitmask」相比,复杂度从指数降到近似多项式,是大厂 B2B2C 场景常用的工程折中。

与「用户主观选择」的冲突处理:最优解未必等于用户勾选。常见策略是:结算页提供「系统推荐组合」与「用户手动选择」两种模式;手动模式以校验为主(不重新最优),并在 UI 明确提示损失金额或不可用原因,减少客诉。

9.3.4 试算与预览

试算接口必须无副作用;预览与创单必须使用同一套输入契约(行价格快照 ID、券实例 ID、活动版本、用户地址/会员状态)。

建议字段

  • pricing_snapshot_id:来自计价中心的基准价快照。
  • marketing_rule_version:规则集版本。
  • client_scenePDP / CART / CHECKOUT(不同场景可用不同策略,但要显式)。
type PreviewMarketingRequest struct {
	UserID              int64
	PricingSnapshotID   string
	SelectedCouponIDs   []int64
	UsePoints           int64
	Lines               []LineInput
	Scene               string
	IdempotencyKey      string
}

type PreviewMarketingResponse struct {
	RuleVersion     string
	PayableCent     int64
	DiscountCent    int64
	LineAllocations []LineAllocation
	Warnings        []string
}

缓存与一致性策略:试算读多写少,可对「活动命中结果」做短 TTL 缓存,但务必以 pricing_snapshot_id 作为缓存键的一部分,避免基准价变化后命中脏数据。对于「用户已领券列表」类数据,强一致诉求更高,建议短 TTL + 用户维度本地缓存谨慎使用,或在关键操作(创单)前做一次穿透校验。

与创单的衔接:预览返回的 LineAllocations 应可被订单原样持久化为「营销快照」子文档;创单重放时不得再次调用可能变化的试算逻辑去「修正」历史订单,除非走明确的改价流程(通常需要客服授权与审计)。


9.4 高并发场景设计

9.4.1 秒杀与抢券

秒杀本质是:把绝大多数失败请求挡在极便宜的路径上,把极少数成功请求放进可串行化的扣减与下单管道。它与普通促销的差异在于:热点 SKU 的竞争半径远大于库存规模。

flowchart TB
  U[用户请求] --> CDN[CDN/静态页]
  U --> WAF[WAF/风控前置]
  WAF --> GW[API 网关\n鉴权 + 签名]
  GW --> RL[限流\n用户/设备/IP]
  RL --> CAP[验证码/挑战]
  CAP --> SS[秒杀服务\n无状态副本]
  SS --> HOT[(Redis 集群\n库存 + 令牌)]
  SS -->|成功令牌| MQ[Kafka 下单队列]
  MQ --> WK[下单 Worker\n幂等消费]
  WK --> ORD[(订单库)]
  WK --> INV[库存服务\n确认扣减]
  WK --> MKT[营销服务\n营销库存消耗]

  SS -. 异步校准 .-> DB[(活动/券 DB)]

关键设计点

  1. 库存拆分:商品库存与营销库存(见 9.5)分别扣减,避免「营销卖爆但仓库没货」或反向超卖。
  2. 令牌化:网关层发放有限令牌,后端只验证令牌,避免打穿 DB。
  3. 排队与等待:返回「排队中」优于同步拖垮线程池(取决于体验要求)。

抢券与秒杀的共性差异:抢券失败通常是「库存耗尽」;秒杀失败还可能是「商品库存不足但营销库存仍显示可买」这类双库存不一致。务必在架构层定义哪一个是用户可见的剩余量,以及异步校准任务的 SLA(例如 1 秒内把 DB 回灌到 Redis)。

9.4.2 限流与降级

限流维度:用户 ID、设备指纹、IP 段、活动 ID、接口名。降级策略(需产品确认):

  • 试算失败:按原价或可延迟重试。
  • 领券失败:明确「已抢光」与「系统繁忙」文案,避免重复猛刷。
  • 引擎超时:熔断返回保守结果 + 记录补偿任务。

限流实现分层(从外到内)

层级手段说明
边缘CDN、静态化、验证码降低无效流量与脚本命中率
网关全局限流、活动级配额保护下游不被突发打满
服务实例并发槽、队列长度避免 goroutine/线程池堆积导致雪崩
数据层Redis 单 Key 分片、Lua 原子脚本热点写入串行化且保持正确性

降级与用户体验的契约:降级不是「悄悄少优惠」,而是「明确告知当前无法应用优惠」。若业务允许静默降级,必须在法务与客服层面评估投诉风险;技术上建议至少记录 degraded=true 与原因码,便于事后补偿。

import "github.com/sony/gobreaker"

func NewMarketingBreaker() *gobreaker.CircuitBreaker {
	return gobreaker.NewCircuitBreaker(gobreaker.Settings{
		Name:        "marketing_preview",
		MaxRequests: 5,
		Interval:    time.Second * 10,
		Timeout:     time.Second * 30,
		ReadyToTrip: func(c gobreaker.Counts) bool {
			if c.Requests < 20 {
				return false
			}
			failRatio := float64(c.TotalFailures) / float64(c.Requests)
			return failRatio >= 0.4
		},
	})
}

9.4.3 防刷与风控

防刷是「业务风控 + 工程限流」的组合:设备指纹、代理 IP 聚类、异常领取节奏、黑名单、券码猜测防护。工程上务必:

  • 热点 Key 分片;避免单 Key 成为 Redis 热点。
  • 异步写审计,主链路只做最小校验。
  • 与风控系统通过评分结果而不是全量明细耦合,降低 RT。

黑产对抗的分层策略:第一层是「明显的工程滥用」(高频请求、批量注册、同设备多号),用限流与验证码解决;第二层是「业务规则套利」(拆单、凑单、退款薅券),需要规则与订单域联合治理;第三层是「支付侧套利」(拒付、chargeback),已超出营销系统边界,但必须把营销核销数据完整输出给风控与财务。

策略落地建议:不要把所有风控判断都改成同步 RPC。典型做法是:领券接口同步只做硬规则(黑名单、频控),复杂模型异步回扫;一旦发现异常,可下发「冻结券使用资格」事件,让用户在结算页看到需要人脸核验或客服介入。这样可以在不大幅增加主链路 RT 的前提下提升对抗能力。


9.5 营销库存系统

营销库存是活动参与配额,与商品可售库存解耦。秒杀中「500 件活动库存 + 10000 件商品库存」意味着两路都要成功才能成交。

营销库存 vs 商品库存(概念对齐表,避免团队各说各话):

维度商品库存营销库存
本质可售实物或履约能力活动参与名额 / 补贴预算的数字化表达
典型驱动采购、仓储、供应商可用量营销预算、活动目标、风控阈值
管理维度SKU、仓、批次活动、SKU、用户、时段
扣减含义少一件货少一次优惠资格或一分预算
失败体验缺货活动结束 / 已抢光 / 超出限购
一致性策略强一致预占 + 补偿常采用 Redis 原子脚本 + 异步校准

工程结论:下单链路里若同时存在两类库存,编排顺序必须写死(先营销后商品或相反)并配套一致的回滚顺序;任何「只扣一类」的实现都会在极端并发下出现难复现的幽灵订单。

9.5.1 券库存管理

券批次 total / used / reserved 三界清晰:reserved 对应创单未支付阶段的冻结量;支付成功由 reserved → used;关单释放 reserved。

批次库存与 Redis 热计数的一致性策略:公开领券场景下,常见做法是「Redis 原子扣减可领余量 + 异步刷新 DB 已领量」,主链路避免对券批次行高频 UPDATE。需要接受的前提是:极端情况下 Redis 与 DB 存在短暂偏差,因此必须配套 日终对账(按 coupon_id 聚合 coupon_user 与 Redis 计数)与 紧急熔断(运营一键停领后,网关与脚本两侧同时生效)。若业务要求「绝不能超发一张」,则要么将扣减下沉到单批次行的强一致事务(牺牲峰值),要么引入分桶库存(把 100 万张券拆成 N 个 sub-batch,各自 Redis 计数,DB 汇总)。

用户券实例与批次维度的联动:用户侧 CouponUser 状态迁移(未使用 → 冻结 → 已使用)应与批次维度的 reserved/used 单调一致;实现上可在 Confirm 阶段用 单笔订单幂等键 保证「批次已用 +1」只执行一次。冻结阶段是否同步增加批次 reserved,取决于财务口径——若冻结即占用预算,则批次层也应体现 reserved,便于运营实时看到「被锁住的成本」。

9.5.2 预算控制

活动预算与补贴池建议 Redis 原子扣减 + 日终对账;预算耗尽应快速失败并联动运营告警(短信/IM)。

预算模型拆分:至少区分「活动总预算」「单 SKU 子预算」「单用户补贴上限」三层。总预算用于财务控制;子预算用于防止单一 SKU 把活动打穿;用户上限用于防止单用户套利。上线前要与财务确认:预占是否计入消耗(通常创单即占用预算,关单释放),否则会出现「未支付订单占用预算导致活动提前结束」的体验问题。

Redis 与 DB 的职责:Redis 承担热点路径的原子判断与扣减;DB 承担审计与汇总;二者不一致时以 DB 为准修复 Redis 是常见策略,但要评估修复延迟期间的用户影响(短暂超发或短暂不可领)。若业务零容忍超发,需要把关键扣减下沉到 DB 或使用更强一致方案,代价是峰值容量下降。

const decrBudgetLua = `
local v = redis.call("GET", KEYS[1])
if not v then return -1 end
local n = tonumber(v)
local d = tonumber(ARGV[1])
if n < d then return 0 end
redis.call("DECRBY", KEYS[1], d)
return 1
`

// budgetRedis 抽象 go-redis 的 Eval,便于单测注入 mock
type budgetRedis interface {
	Eval(ctx context.Context, script string, keys []string, args ...interface{}) interface {
		Int() (int64, error)
	}
}

func DecrBudgetAtomically(ctx context.Context, r budgetRedis, key string, delta int64) (bool, error) {
	res, err := r.Eval(ctx, decrBudgetLua, []string{key}, delta).Int()
	if err != nil {
		return false, err
	}
	if res == -1 {
		return false, ErrBudgetNotInitialized
	}
	return res == 1, nil
}

9.5.3 实时监控

核心指标:

  • 领取成功率 / 拒绝原因分布(库存不足 vs 风控 vs 限流)。
  • 冻结/核销/回滚计数与订单状态对齐曲线。
  • 预算消耗速率(每分钟消耗,预测耗尽时间)。
  • 引擎 RT 分位与规则版本维度下钻。

从指标到告警的落地方法:不要只对「错误率」告警,要对「结构变化」告警。例如:领取失败率不变,但「风控拒绝占比」突然上升,往往意味着活动被黑产盯上;又如:冻结成功但确认核销失败升高,通常是支付回调或订单状态机异常的前兆。营销监控看板建议固定三类视图:活动运营视图(转化、消耗、ROI)、稳定性视图(RT、限流、熔断)、资金风险视图(预算、异常大额订单、补贴分账失败队列)。


9.6 系统边界与职责

9.6.1 营销系统的职责边界

营销系统应负责:工具发放与状态机、活动配置与圈品、营销库存、补贴分摊事实生成、试算解释与审计日志、与订单冻结/回滚对应的资源操作。

营销系统不应负责:商品主数据、基础标价、支付渠道、物流、发票税务口径的唯一裁定。

边界不清的典型症状(出现任一条都值得开专项治理):

  • 订单表出现大量「手写促销字段」,营销服务却不知道这些字段如何产生。
  • 同一个满减规则在详情页、购物车、结算页算出三种金额。
  • 支付回调后才发现优惠无法分账,只能人工补单。
  • 风控拦截发券,但试算仍展示可用,用户完成下单后失败。

9.6.2 营销 vs 计价:谁算什么

这是最容易跨团队扯皮的边界。推荐清晰分工

计算内容建议负责方说明
商品基础价、渠道价、会员价计价中心(或商品+计价组合域)作为「价格事实」与快照源头
券/活动/积分是否可用与优惠额营销计算引擎输出结构化应用明细
购物车/结算页统一应付计价中心编排调用营销用户看到单一「应付」
创单价格快照订单域落库 + 引用计价/营销版本售后按快照解释

反模式:订单服务里手写一段「满 100 减 20」与营销服务另一套重复逻辑——必然漂移。

推荐协作模式(一句话):计价中心负责「把钱算清楚并快照」,营销系统负责「把规则讲清楚并证明合规」。当两者接口契约稳定后,前端与订单域都应对营销细节保持「无知」,只消费结构化结果。

9.6.3 平台营销 vs 商家营销

平台券与商家券在成本承担、审核、叠加策略、结算上不同;系统上建议「券实例维度绑定承担方」,订单行维度记录分摊,支付后生成清算明细。

9.6.4 营销规则 vs 营销执行

规则:可配置、可版本、可读多。执行:冻结、扣减、回滚、消息投递,必须可幂等与可补偿。不要在规则脚本里直接写数据库副作用;执行器与规则解释器分离(9.3.1 的分层)。


9.7 与其他系统的集成

集成章节的目标是:把「同步调用边界」与「异步补偿边界」画清楚。营销系统处于交易链路中段,最容易出现长事务重试风暴,因此接口设计要比普通 CRUD 更严格:超时、幂等、可观测三者缺一不可。

跨系统调用的最小契约(建议作为内部 OpenAPI 规范附件):下列字段在多团队扯皮时最有用——X-Idempotency-Key(写路径必填)、X-Rule-Version / pricing_snapshot_id(试算与创单对齐)、X-Biz-SceneCHECKOUT / ORDER_CREATE)、X-Trace-Id(全链路透传)。补偿任务消费侧应至少支持 (biz_type, biz_id, action) 唯一约束,避免 Kafka 重投导致二次核销。

调用方 → 被调方典型接口一致性语义失败退避策略
计价 → 营销PreviewPromotions只读,可缓存超时 → 保守不可用券
订单 → 营销TryFreeze / Confirm / Cancel可补偿幂等重试 + 逆序 Cancel
订单 → 营销ConsumeCampaignQuota与支付回调对齐补偿表重放
支付 → 营销OnPaymentSucceeded(事件)至少一次幂等 + 对账
营销 → 商品BatchGetProductTags只读短超时 + 部分失败降级

9.7.1 与商品中心集成(圈品规则)

商品中心提供类目、标签、上下架状态;营销读取时应缓存 + 兜底超时降级(降级策略需业务拍板:宁可不可用券,不可错误可用券)。

失败模式:商品中心超时 → 试算无法判断圈品 → 建议默认「该活动对此 SKU 不适用」而不是「适用」,避免错误让利。对于已加购用户,可提示稍后重试或刷新。

9.7.2 与用户系统集成(画像与风控)

画像用于定向投放;注意隐私合规与最小必要原则。风控评分作为硬门槛时,应有明确失败原因码供前端展示(避免「神秘失败」)。

失败模式:画像服务延迟 → 定向券领取接口可异步化处理(先返回受理中),但创单路径若依赖画像,必须设置硬超时并走保守策略(按非定向规则校验)。

9.7.3 与订单系统集成(锁定 / 扣减 / 回退)

订单创建立即涉及「资源锁定」;营销侧需提供 Try/Confirm/Cancel 语义或等价 Saga 接口:freeze_couponconfirm_couponrollback_coupon,积分同理。

失败模式:订单 Try 成功但网络超时导致订单重试 → 营销接口必须幂等,重复 Try 不得重复扣减。Confirm 晚到必须先识别「已确认 / 已回滚」状态,避免二次核销。

9.7.4 与计价系统集成(试算接口)

计价中心调用营销预览接口时,传入价格快照而不是实时价字符串,避免时间差;返回应用明细后由计价做最终取整与应付。

失败模式:营销试算成功但创单延迟十分钟 → 必须以快照版本为准;若规则版本在此期间变更,创单应拒绝或提示用户重新结算,而不能静默改价。

9.7.5 与支付系统集成(补贴分账)

支付成功事件触发:营销确认核销、生成补贴分账数据、推送给清算系统。必须处理重复回调幂等

失败模式:支付回调重复 → Confirm 幂等;支付成功但营销 Confirm 失败 → 必须有补偿任务把订单推到一致状态,并阻断发货或数字履约直到营销侧确认完成(视业务风险阈值而定)。

9.7.6 集成时序图与补偿机制

sequenceDiagram
  participant U as 用户
  participant O as 订单服务
  participant P as 计价中心
  participant M as 营销服务
  participant I as 库存服务
  participant Pay as 支付

  U->>O: 创单请求
  O->>P: 试算/确认价\n(pricing_snapshot)
  P->>M: 预览可用优惠\n(rule_version)
  M-->>P: 应用明细
  P-->>O: 应付金额 + 快照

  O->>M: Try 冻结券/扣减积分
  M-->>O: OK
  O->>I: Try 预占库存
  I-->>O: OK
  O->>O: 持久化订单(PENDING_PAY)

  U->>Pay: 发起支付
  Pay-->>O: 支付成功回调(幂等)
  O->>M: Confirm 核销券/确认积分
  O->>I: Confirm 扣减库存
  O->>O: 更新订单(PAID)

  Note over O,M: 任一步失败进入 Saga 逆序补偿\n并写入补偿任务表重试

补偿表字段建议:biz_idactionpayloadnext_retry_atstatus;超过阈值人工介入。对账任务按日核对营销核销与订单快照。


9.8 工程实践

9.8.1 性能优化

  • 多级缓存:券批次元数据本地缓存 + Redis;注意失效传播。
  • 并行 I/O:预览时券列表、活动命中、用户等级查询可 errgroup 并行(注意超时串联)。
  • 热点分片:秒杀库存键按 activity_id + shard 拆分;避免单 Key QPS 顶满单线程。

压测与容量规划建议:至少拆三条压测曲线——「仅试算」「领券写路径」「秒杀下单全链路」。把下游依赖(商品、计价、库存)分别做故障注入,观察营销服务是否会出现重试放大。热点活动前执行 Redis 预热与连接池参数复核,避免冷启动把连接打满。

连接池与 goroutine 背压:试算接口最容易在大促被放大为「购物车行数 × 活动命中次数」次下游调用。除缓存外,应在网关或服务入口配置 最大并发试算协程数单请求活动匹配上限(例如每 SKU 最多评估 K 个活动),超出部分直接标记为「未评估,用户可手动领券」。否则会出现「CPU 不高但延迟爆炸」的典型症状——根因是无限并行导致的协调开销与下游排队。

import (
	"context"
	"golang.org/x/sync/errgroup"
	"time"
)

// PreviewParallel 演示:试算阶段并行拉取多源事实,统一超时兜底。
func PreviewParallel(ctx context.Context, userID int64) (coupons int, points int64, err error) {
	g, ctx := errgroup.WithContext(ctx)
	ctx, cancel := context.WithTimeout(ctx, 120*time.Millisecond)
	defer cancel()

	g.Go(func() error {
		// 伪代码:查询用户可用券数量
		coupons = 3
		return nil
	})
	g.Go(func() error {
		points = 1200
		return nil
	})

	if err := g.Wait(); err != nil {
		return 0, 0, err
	}
	return coupons, points, nil
}

9.8.2 数据一致性

主路径用 Saga;异步用 Outbox 发 Kafka;消费者幂等键用 event_id 或业务联合键;日终对账修数据。

对账维度清单(营销侧最小集)

对账项对比双方发现差异后的处理
券核销营销核销流水 vs 订单快照以订单快照为准回补或冲正
积分变动积分流水 vs 订单支付事件重放补偿任务
活动消耗Redis 计数 vs DB 汇总以 DB 为准回灌或人工修正
补贴分账订单行分摊 vs 支付清算单冻结差异单,财务介入

9.8.3 成本控制

预算桶、单用户上限、异常消耗报警、活动 ROI 看板;技术上防止「无限重试放大写压力」。

成本与体验平衡:预算耗尽应「快速失败」而不是「排队重试吞吞吐」。对于平台补贴型活动,建议设置分钟级消耗速率告警:一旦斜率异常(脚本薅羊毛),可自动触发熔断与黑名单联动。


9.9 本章小结

本章从工具体系(券、积分、活动、补贴)出发,拆解了营销系统的职责边界与架构分层;深入讲解了营销计算引擎的规则分层、叠加互斥与最优求解;针对秒杀抢券给出了高并发架构与限流降级策略;阐述了营销库存与预算独立于商品库存的原因与原子扣减模式;重点厘清了营销 vs 计价的算价边界,并通过集成时序图说明订单、计价、营销、库存、支付在全链路的 Try/Confirm 与补偿关系。

落地检查清单

  1. 试算与创单是否引用同一 pricing_snapshot_idmarketing_rule_version
  2. 券/积分状态机是否覆盖冻结、过期、回滚全路径?
  3. 秒杀链路是否分离商品库存与营销库存,并有异步校准?
  4. 补贴分账是否能在财务对账中还原到订单行?

与全书其他章节的阅读顺序建议:若你正在实现交易链路,建议将本章与第 11 章(计价系统)、第 13 章(购物车与结算)、第 14 章(订单系统)交叉阅读:把「试算 → 锁定 → 支付确认 → 清算」同一条时间轴画在白板上,再把每个系统的接口填进去,你会很快发现团队里哪些职责被重复实现、哪些补偿路径尚未覆盖。


延伸阅读:本书第 6 章(一致性)、第 11 章(计价系统)、第 14 章(订单系统)、第 15 章(支付系统);博客原文《电商系统设计(四):营销系统深度解析》可作为附录级细节与 SQL/Lua 参考。

导航书籍主页 | 完整目录 | 上一章 | 下一章


第10章 商品供给与运营管理

本章定位:承接第7章「商品中心」的主数据模型,聚焦商品如何进入平台、如何被持续维护、如何与供应商与运营两类角色协同。核心命题是:在同一套商品主数据之上,区分「上架(Create)」「同步(Upsert)」「运营编辑(Update)」三种语义,并用状态机 + 差异化审核 + 异步编排把风险、成本与时效性平衡到可运营的水平。

读完本章你应能回答的关键问题

  1. 为什么「上架系统」不等价于商品中心? 前者解决流程、编排与风控;后者解决主数据模型与查询契约。混写会导致状态爆炸与审计缺口。
  2. 供应商同步何时必须审核、何时可以直写? 取决于字段敏感度、幅度、商品热度与来源可信度;应用「风险评估引擎」把策略从 if-else 中解放出来。
  3. 如何保证跨系统初始化可恢复? 用任务状态机 + Saga/补偿 + Outbox,把「一次上架」变成可观测、可重试、可回滚的事务序列。
  4. 如何定义系统边界与数据主导权? 外部事实与平台治理字段不同源;边界不清时优先回到「单一真源 + 明确写入入口 + 事件传播读模型」三原则。

与源材料的映射:本章内容对齐博客《商品生命周期管理(上架、同步与运营编辑)》的方法论,并把它放回全书目录中的「商品供给与运营」位置:你既会看到熟悉的 diff/幂等/审核分层,也会看到面向落地的服务拆分、监控与集成时序。

阅读建议:第一次阅读可顺着 10.1 → 10.4 建立语义与边界;第二次阅读建议直接跳到 10.6 与 10.11,把审核与集成事件流当作跨团队对齐的「合同条款」来审。第三次阅读可对照自家系统的监控面板,把文中指标一项项补齐或替换为等价口径,并与 10.13 本章小结对照验收,再分阶段逐步推广。


10.1 系统定位与架构

10.1.1 供给侧 vs 运营侧

在大型电商平台中,「商品」从来不是单一系统的产物,而是一条跨组织的价值链:

  • 供给侧(Supply):负责把外部事实(供应商目录、库存、价格、可售状态)可靠地映射到平台主数据。典型诉求是高频、批量、可回放、可补偿。
  • 运营侧(Ops):负责把平台策略(类目治理、内容合规、活动圈品、展示排序的输入条件)落到商品上。典型诉求是可控、可审计、可灰度、可追责。

如果把商品中心比作「账本」,那么本章讨论的系统更像「入账与调账流程」:它决定一笔变更以什么身份进入账本、是否需要复核、何时对下游生效。

10.1.2 三类用户(供应商 / 运营 / 商家)

角色主要目标典型操作风险画像
供应商把真实可售商品同步到平台Push / Pull、批量增量数据错误、接口不稳定、重复投递
运营平台治理与效率批量导入、批量上下架、内容修正误操作、批量事故、权限越界
商家在规则内经营Portal 上架、改价、改库存刷单、违规内容、恶意改价

工程上建议用统一的操作者模型Operator)抽象三者,但在策略路由时必须保留来源维度Source = SUPPLIER|OPS|MERCHANT),否则审核与幂等语义会被混在一起。

10.1.3 整体架构

推荐将「供给与运营」拆成三个相互独立又可组合的应用能力(可部署为独立服务,也可先以模块化单体落地):

  1. Listing(上架域):处理「从无到有」的创建语义,产出 item_id 与上架任务。
  2. Supplier Ingest(供应商接入域):处理 Upsert、增量水位、冲突合并、同步监控。
  3. Ops Console(运营域):批量任务、导入导出、权限审计、运营配置入口。

它们与「商品中心(Product Catalog)」的关系应当是:本域负责流程与决策,商品中心负责主数据存储与对外查询契约。跨域写入尽量通过明确 API 或领域事件,避免运营后台直连商品库。

flowchart TB
  subgraph clients[接入方]
    SUP[供应商系统]
    OPS[运营后台]
    MER[商家 Portal]
  end

  subgraph supply_ops[商品供给与运营]
    L[Listing 上架域]
    SI[Supplier Ingest 供应商接入]
    OC[Ops Console 运营域]
    AP[Approval 审核域]
    ORCH[Orchestrator 编排器]
  end

  PC[(商品中心 Product Catalog)]
  INV[(库存系统)]
  PRC[(计价系统)]
  SRC[(搜索索引)]
  BUS[(消息总线 Kafka)]

  SUP --> SI
  OPS --> OC
  MER --> L

  L --> ORCH
  SI --> ORCH
  OC --> ORCH

  ORCH --> AP
  AP --> PC

  ORCH --> INV
  ORCH --> PRC
  PC --> BUS
  BUS --> SRC

10.1.4 核心挑战

挑战 1:语义混叠
如果把供应商同步误走「完整上架审核」,会把中低风险的高频变更拖进人工队列;如果把运营批量改价直接写主库,会失去审计与风控抓手。

挑战 2:最终一致性与可观测性
上架往往伴随「初始化库存 / 初始化价格 / 建索引」等多步骤。必须用 Saga / Outbox 明确每一步的可补偿性与可重试性。

挑战 3:并发与主导权
同一 item_id 上可能同时存在:供应商改价、运营改标题、系统自动下架(库存为 0)。必须定义冲突解决优先级乐观锁版本策略。

挑战 4:规模化批量
十万级导入若采用「一次性读入内存 + 单线程写库」,会在大促筹备期制造人为故障。需要流式解析、分批提交、背压与隔离舱

挑战 5:合规与可审计性并行
商品内容涉及广告法、知识产权、类目资质与禁限售清单。系统层面要把「可发布」从直觉判断,拆成可机读规则 + 可追踪证据链:规则版本号、命中条款、模型版本、审核员决策与修改前后快照必须能关联到同一次 trace_id,否则事后监管问询时无法复盘。

挑战 6:组织协作与系统边界的动态漂移
业务扩张期最常出现的不是技术债,而是职责漂移:运营为了赶进度直连数据库改价;供应商为了抢流量绕过网关重复推送;商品中心团队被迫在库里补字段兼容历史脏数据。治理抓手应回到三件事:写入入口单一化(只认命令 API)、策略配置中心化(阈值与权重可灰度)、变更可回放(任务与事件双账本)。

下表给出本章讨论域与相邻系统之间「最容易踩界」的协作点,可作为架构评审的检查项(与第 7、8、11 章呼应):

协作点常见误放置推荐归属一致性手段
基础价写入运营脚本直写商品表商品中心 / 计价初始化 API命令幂等 + Outbox
可售库存事实商品中心自算库存库存系统为唯一真源事件投影 + 对账
促销价商品中心拼活动价营销 + 计价试算读路径组装,写路径分离
搜索可见性同步阻塞写 ES异步索引 + 可观测 SLA消费者幂等 + 重放
审核策略散落在各服务 if-else审核域统一路由配置版本 + 影子对比

10.2 商品生命周期管理

商品生命周期回答的是:商品在平台内被允许处于哪些状态、谁可以驱动迁移、迁移时下游如何感知。它与「上架任务状态机」相关但不等价:前者偏主数据生命周期,后者偏流程实例

10.2.1 完整生命周期状态机

下图给出中大型平台常见、且可与审核/发布解耦的主状态集合(可按业务裁剪,但不宜再合并「审核中」与「在售」):

stateDiagram-v2
  [*] --> DRAFT: 创建草稿

  DRAFT --> PENDING: 提交审核
  DRAFT --> ARCHIVED: 放弃并归档

  PENDING --> APPROVED: 审核通过
  PENDING --> REJECTED: 审核驳回

  REJECTED --> DRAFT: 修改后再提交
  REJECTED --> ARCHIVED: 放弃并归档

  APPROVED --> PUBLISHED: 发布(预发布)
  PUBLISHED --> ONLINE: 满足可售条件后上线

  ONLINE --> OFFLINE: 下架
  ONLINE --> ARCHIVED: 归档(需满足约束)

  OFFLINE --> ONLINE: 重新上架
  OFFLINE --> ARCHIVED: 归档

  ARCHIVED --> [*]

设计说明

  • PUBLISHED 作为预发布态,便于在「审核通过」与「对用户可见」之间插入价格/库存初始化、索引预热、风控抽检等步骤。
  • REJECTED 必须能回到 DRAFT,否则运营流会被「只能重建」的坏体验拖垮。

10.2.2 状态流转规则

状态机要落地为可执行的规则表,并显式区分三类约束:

  1. 结构约束:状态图允许的边(非法迁移直接拒绝)。
  2. 业务前置条件:例如上线要求 price > 0available_stock > 0、类目必填、敏感字段已审核。
  3. 权限约束:商家能否从 OFFLINE 自恢复 ONLINE,通常取决于平台模式(POP 与自营差异极大)。
迁移前置条件(示例)典型操作者
DRAFT → PENDING必填字段完整、图片合规商家 / 运营
PENDING → APPROVED审核通过审核员 / 系统(自动)
APPROVED → PUBLISHED基础价已落库、关键属性锁定系统编排
PUBLISHED → ONLINE库存初始化成功、索引可用(可降级为异步)系统编排
ONLINE → OFFLINE无强约束 / 或存在风控拦截运营 / 系统(库存0)
* → ARCHIVED无未完成订单、无未结算争议(按业务)运营

权限与职责矩阵(落地提示)
生命周期状态迁移不仅要「技术上可执行」,还要能回答审计问题:谁在什么证据下推动了状态变化。建议将权限检查拆为两层:

  1. 领域权限:角色是否允许触发该边(例如商家通常不允许从 APPROVED 直跳 ONLINE)。
  2. 数据范围权限:运营仅能操作其负责类目;供应商账号仅能操作绑定 supplier_id 的映射商品。

对于系统自动迁移(例如库存为 0 触发 ONLINE → OFFLINE),必须在状态日志中记录 operator_type=SYSTEM 与触发规则编号,避免被误解为「后台偷偷改数据」。

与「变更审批」的关系澄清
主数据生命周期状态(DRAFT/.../ONLINE)与「字段级变更审批单」是两条正交维度:商品可以长期处于 ONLINE,但某个高价差改价仍可能处于 pending_approval。工程上不要让 item.status 承担所有流程语义,否则报表、搜索与交易链路会对状态产生错误假设。

Go:状态机骨架(结构约束 + 前置条件 + 乐观锁)

package lifecycle

import (
	"context"
	"errors"
	"fmt"
	"time"
)

type ItemStatus string

const (
	StatusDraft     ItemStatus = "DRAFT"
	StatusPending   ItemStatus = "PENDING"
	StatusRejected  ItemStatus = "REJECTED"
	StatusApproved  ItemStatus = "APPROVED"
	StatusPublished ItemStatus = "PUBLISHED"
	StatusOnline    ItemStatus = "ONLINE"
	StatusOffline   ItemStatus = "OFFLINE"
	StatusArchived  ItemStatus = "ARCHIVED"
)

type Item struct {
	ItemID     int64
	Status     ItemStatus
	Version    int64
	Title      string
	CategoryID int64
	BasePrice  int64
	Stock      int64
}

type ItemRepository interface {
	GetByID(ctx context.Context, itemID int64) (*Item, error)
	UpdateStatusCAS(ctx context.Context, itemID int64, from, to ItemStatus, expectedVersion int64, now time.Time) (int64, error)
}

type Preconditions interface {
	Check(ctx context.Context, item *Item, to ItemStatus) error
}

type StateMachine struct {
	repo    ItemRepository
	precond Preconditions
}

func NewStateMachine(repo ItemRepository, pre Preconditions) *StateMachine {
	return &StateMachine{repo: repo, precond: pre}
}

func (sm *StateMachine) CanTransition(from, to ItemStatus) bool {
	allowed := map[ItemStatus][]ItemStatus{
		StatusDraft:     {StatusPending, StatusArchived},
		StatusPending:   {StatusApproved, StatusRejected},
		StatusRejected:  {StatusDraft, StatusArchived},
		StatusApproved:  {StatusPublished},
		StatusPublished: {StatusOnline},
		StatusOnline:    {StatusOffline, StatusArchived},
		StatusOffline:   {StatusOnline, StatusArchived},
	}
	nexts, ok := allowed[from]
	if !ok {
		return false
	}
	for _, s := range nexts {
		if s == to {
			return true
		}
	}
	return false
}

func (sm *StateMachine) Transition(ctx context.Context, itemID int64, to ItemStatus) error {
	for attempt := 0; attempt < 3; attempt++ {
		item, err := sm.repo.GetByID(ctx, itemID)
		if err != nil {
			return err
		}
		if !sm.CanTransition(item.Status, to) {
			return fmt.Errorf("invalid transition: %s -> %s", item.Status, to)
		}
		if err := sm.precond.Check(ctx, item, to); err != nil {
			return err
		}

		rows, err := sm.repo.UpdateStatusCAS(ctx, itemID, item.Status, to, item.Version, time.Now())
		if err != nil {
			return err
		}
		if rows == 1 {
			return nil
		}
		// version conflict: retry
	}
	return errors.New("concurrent update: exceeded retries")
}

10.2.3 生命周期事件

状态迁移的可观测产物应是领域事件,而不是「下游轮询商品表」。建议事件携带:

  • event_id(幂等键)、item_idfrom_statusto_statusoccurred_at
  • trace_id(全链路)
  • payload(尽量小:变更摘要 + 版本号,避免把大 JSON 塞进总线)

消费者边界建议

  • 搜索:关注 ONLINE/OFFLINE/ARCHIVED 与影响召回的字段变更。
  • 推荐:关注类目、品牌、标签变更。
  • 缓存:关注价格与可售状态变更(或统一订阅「商品变更投影」)。
package lifecycle

import "time"

type DomainEvent struct {
	EventID   string         `json:"event_id"`
	EventType string         `json:"event_type"`
	ItemID    int64          `json:"item_id"`
	From      ItemStatus     `json:"from_status"`
	To        ItemStatus     `json:"to_status"`
	Occurred  time.Time      `json:"occurred_at"`
	Payload   map[string]any `json:"payload"`
}

func MapEventType(from, to ItemStatus) string {
	if from == StatusDraft && to == StatusPending {
		return "product.submitted_for_review"
	}
	if from == StatusPending && to == StatusApproved {
		return "product.approved"
	}
	if from == StatusPublished && to == StatusOnline {
		return "product.online"
	}
	if to == StatusOffline {
		return "product.offline"
	}
	if to == StatusArchived {
		return "product.archived"
	}
	return "product.status_changed"
}

可靠性:事件发布优先采用 Outbox:状态落库与 outbox 插入同事务,异步 Dispatcher 投递到 Kafka,消费者以 event_id 去重。

事件契约与版本治理
生命周期事件是跨团队集成的「公共 API」,建议显式包含:

  • schema_version:事件体字段演进时用于兼容消费端。
  • aggregate_version:商品聚合版本号,便于投影端检测乱序或重复。
  • causation_id / correlation_id:把一次上架编排中的多步调用串起来,排障时极有用。

乱序与重复的现实处理
消息系统通常只保证「至少一次」投递。消费者侧除了 event_id 去重,还要对「迟到事件」做策略:若收到 product.offline 时本地缓存仍是上架态,应以单调版本或**最后写入时间戳(带时钟偏移保护)**决定是否覆盖,避免旧事件把新状态回滚。

投影读模型(可选但强烈建议)
前台读链路往往需要「可售 + 展示价 + 活动标签 + 库存水位」的合成视图。与其让搜索/推荐各自拼表,不如由商品域维护一份只读投影(可由 CDC 或消费生命周期事件构建),并把 SLA(延迟上限、允许缺失字段)写清楚。投影失败不应反向阻断主数据状态机,否则会形成分布式死锁。


10.3 商品上架(从无到有)

10.3.1 数据来源分类

来源输入形态质量特征典型治理
运营后台表单结构化字段相对稳定模板校验 + 审核
商家 Portal结构化字段 + 图片波动大更严格内容安全
批量 Excel/CSV半结构化错误率高行级错误报告、可部分成功
供应商首批导入API / 文件字段映射复杂适配器 + 映射版本化

10.3.2 上架状态机(流程实例)

上架流程建议用任务表建模(listing_task),不要直接把「流程状态」与 item.status 混在一张表里,否则供应商 Upsert 与运营改价会把任务状态污染。

推荐任务状态:

CREATED → VALIDATING → APPROVAL_PENDING → APPROVED → PROVISIONING → DONE / FAILED

其中 PROVISIONING 对应多系统初始化(库存、计价、索引)。

为什么需要任务状态机与主数据状态机「双层」
如果只维护 item.status,你会被迫把「校验失败」「图片异步检测中」「库存初始化重试中」等流程态硬塞进主数据,结果是:

  • 报表口径混乱:运营统计「在售商品数」会把中间态算进去或漏算。
  • 搜索与交易耦合:索引系统不得不理解大量非业务态。
  • 失败恢复困难:无法只对任务重试而不触碰已发布主数据。

任务状态机记录流程实例listing_task),主数据状态机记录业务允许态item)。失败重试应优先重放任务,而不是反复触发主数据迁移。

上架主流程(从受理到可售)

flowchart TD
  subgraph intake[受理与校验]
    A[提交上架] --> B[创建 listing_task]
    B --> C[字段/类目/图片校验]
    C -->|失败| X[FAILED + 错误明细]
  end

  subgraph gate[审核闸门]
    C -->|通过| D{需要人工审核?}
    D -->|是| E[进入审核队列]
    E --> F{审核结果}
    F -->|驳回| X
    F -->|通过| G[APPROVED]
    D -->|否| G
  end

  subgraph provision[供给编排]
    G --> H[写商品中心主数据]
    H --> I[初始化库存]
    I --> J[初始化计价]
    J --> K[触发索引/outbox 事件]
    K --> L[DONE / ONLINE]
  end

10.3.3 异步处理架构

上架的长耗时环节包括:图片转码、敏感词检测、类目预测、价格合规校验、写索引。API 层应快速受理,把重活交给异步 Worker,并通过 Webhook / 轮询接口返回进度。

flowchart LR
  API[Listing API] --> Q[(队列 / 延迟队列)]
  API --> DB[(任务库)]
  Q --> W1[校验 Worker]
  Q --> W2[审核编排 Worker]
  Q --> W3[供给编排 Worker]

  W3 --> PC[商品中心写入]
  W3 --> INV[库存初始化]
  W3 --> PRC[计价初始化]
  W3 --> ES[搜索索引构建]

  W2 --> APQ[审核队列]
  APQ --> W2

  PC --> OB[(Outbox)]
  OB --> BUS[Kafka]

Go:受理请求(落库 + 投递 + 幂等键)

package listing

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"time"
)

type CreateListingCommand struct {
	IdempotencyKey string
	OperatorID     int64
	CategoryID     int64
	PayloadJSON    []byte
}

type ListingTask struct {
	TaskID    int64
	TaskCode  string
	Status    string
	CreatedAt time.Time
}

type Store interface {
	InsertTaskIfAbsent(ctx context.Context, t *ListingTask) (inserted bool, err error)
	EnqueueValidateJob(ctx context.Context, taskID int64) error
}

type Service struct {
	store Store
	clock func() time.Time
}

func taskCodeFrom(cmd CreateListingCommand, now time.Time) string {
	h := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%d",
		cmd.IdempotencyKey, cmd.OperatorID, cmd.CategoryID, now.UnixNano())))
	return hex.EncodeToString(h[:12])
}

func (s *Service) CreateTask(ctx context.Context, cmd CreateListingCommand) (*ListingTask, bool, error) {
	now := s.clock()
	task := &ListingTask{
		TaskCode:  taskCodeFrom(cmd, now),
		Status:    "CREATED",
		CreatedAt: now,
	}
	inserted, err := s.store.InsertTaskIfAbsent(ctx, task)
	if err != nil {
		return nil, false, err
	}
	if !inserted {
		// 返回已有任务:幂等语义
		return task, false, nil
	}
	if err := s.store.EnqueueValidateJob(ctx, task.TaskID); err != nil {
		return task, true, err
	}
	return task, true, nil
}

10.3.4 批量上传

批量上传的关键不是「快」,而是可恢复可解释

  • 流式读取:避免 OOM。
  • 分批事务:每批 200~2000 行(按行宽调参),批内失败可重试。
  • 错误文件回传:失败行附带 error_code / message / raw_line
  • 背压:限制全局并发导入数,避免拖垮商品中心连接池。

运营侧体验:从「提交文件」到「可追责结果」
大促筹备期的批量导入,价值不仅在于吞吐,还在于可运营:需要进度百分比、可暂停、可重试失败子集、以及「部分成功」的明确语义。建议任务表至少记录:batch_id、总行数、已处理游标、失败桶对象存储路径、以及最后一次心跳时间(用于检测 worker 假死)。

Go:流式读取 + Worker Pool(骨架)

package batchimport

import (
	"bufio"
	"context"
	"io"
	"sync"
)

type RowJob struct {
	LineNo int
	Text   string
}

type RowHandler func(ctx context.Context, job RowJob) error

type ImportRunner struct {
	workers int
}

func NewImportRunner(workers int) *ImportRunner {
	if workers <= 0 {
		workers = 8
	}
	return &ImportRunner{workers: workers}
}

func (r *ImportRunner) Run(ctx context.Context, rd io.Reader, handle RowHandler) error {
	sc := bufio.NewScanner(rd)
	const max = 1024 * 1024
	buf := make([]byte, 0, 64*1024)
	sc.Buffer(buf, max)

	jobs := make(chan RowJob, r.workers*4)
	errCh := make(chan error, r.workers)

	var wg sync.WaitGroup
	for i := 0; i < r.workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for job := range jobs {
				if err := handle(ctx, job); err != nil {
					errCh <- err
					return
				}
			}
		}()
	}

	lineNo := 0
	for sc.Scan() {
		if err := ctx.Err(); err != nil {
			close(jobs)
			wg.Wait()
			return err
		}
		lineNo++
		jobs <- RowJob{LineNo: lineNo, Text: sc.Text()}
	}
	close(jobs)
	wg.Wait()
	close(errCh)

	select {
	case err := <-errCh:
		return err
	default:
	}
	return sc.Err()
}

说明:生产环境通常不会「首错即停」,而是把错误写入失败桶并继续;同时用独立 semaphore 限制写库 QPS,并把 batch_id 写入审计日志,便于按批次回放。


10.4 供应商同步(Upsert 场景)

10.4.1 实时推送 vs 定时拉取

模式优点缺点适用
Push(供应商回调/Webhook)延迟低需要签名验真、重放治理价格/库存强实时品类
Pull(定时增量拉取)平台掌控节奏延迟与水位设计成本高供应商能力弱、批量目录

工程上通常是 Pull 为主、Push 为辅,并在网关层统一:认证、限流、幂等、审计

水位与游标:增量 Pull 的「正确打开方式」
定时拉取最容易失败在「我以为增量了,其实漏了」:供应商侧若只提供 updated_at,在时钟回拨、批量修复、或「先删后建」时会制造空洞。更稳妥的组合是:

  • 单调游标:优先使用供应商侧稳定的 change_seq / event_id
  • 时间窗冗余:拉取 [last_cursor, now) 时向左重叠 2~5 分钟,再用幂等消化重复。
  • 对账补偿:每日一次按 supplier_id 做抽样全量校验(热点 SKU 全量),发现漂移自动修复。

Push 模式的工程清单
Webhook 不是 HTTP 回调这么简单,至少要覆盖:

  • 签名校验(HMAC / 公钥验签)与 时钟偏移容忍
  • 重放窗口:保存近期 message_id 去重表,防止攻击者重放历史回调。
  • 快速 ACK 与异步落库:网关先 ACK,再投递内部队列;否则供应商超时重试会放大流量。
  • 乱序处理:回调到达顺序未必与业务发生顺序一致,Upsert 必须以业务时间戳或版本号裁决。

10.4.2 幂等性设计

Upsert 的幂等主键建议稳定为:

  • UNIQUE(supplier_id, external_item_id)

同步消息层再叠加:

  • UNIQUE(supplier_id, message_id)dedupe_key(供应商若不能提供稳定 message id,则由 (supplier_id, external_item_id, updated_at_bucket) 退化,但要谨慎)

10.4.3 差异化审核

供应商同步不应复用「新上架全量人工审核」,否则会把运营资源烧穿。应当:

  • 字段级 diff:只对高风险字段触发审批单。
  • 阈值策略:如价格变动超过 30% 进入人工审核(阈值应可配置并按品类分层)。
  • 白名单供应商:降低审核等级,但仍保留日志与抽检。

10.4.4 冲突处理

当供应商同步与运营编辑并发时,推荐默认策略:

  1. 人工运营变更优先于供应商自动同步(在「内容类字段」上)。
  2. 供应商在「库存/可售状态」上优先(更接近真实供给)。
  3. 价格字段:可用「时间戳新者胜出 + 风险阈值审核」组合,避免运营锁价被无意覆盖。

字段级「锁」与合并策略(平台治理常见需求)
运营有时会明确标注「标题不允许供应商覆盖」「主图允许供应商更新」。实现上建议在商品主数据或扩展表中维护 field_locks(bitmap 或 JSON),同步管线在 diff 之后执行 merge_policy

  • LOCKED_BY_OPS:供应商变更到达时跳过该字段,并记录审计日志(不是静默吞掉,而是可查询)。
  • MERGE_IF_NEWER:比较 external_updated_at 与本地 supplier_projection.updated_at
  • ALWAYS_REVIEW:字段变更永远生成审批单(适合品牌、类目、合规属性)。

Upsert 处理流程(含审核分支)

flowchart TD
  A[接收供应商变更] --> K{幂等键已处理?}
  K -->|是| Z[ACK 返回重复]
  K -->|否| B{按 external_id 查找 item}
  B -->|不存在| C[创建 item + 映射 external_id]
  B -->|存在| D[计算字段 diff]

  C --> E{新创建是否需要审核?}
  E -->|是| F[创建审批单]
  E -->|否| G[落库 + 发布事件]

  D --> H{RiskEngine 路由}
  H -->|NONE| G
  H -->|AUTO| I{自动规则通过?}
  I -->|是| G
  I -->|否| F
  H -->|MANUAL/STRICT| F

  F --> J[等待人工/多级审核]
  J -->|通过| G
  J -->|驳回| R[记录原因 + 同步失败指标]

  G --> S[更新 supplier_sync_state]

Go:Upsert 入口(展示分支,不含具体 ORM)

package supplier

import (
	"context"
	"errors"
	"fmt"
)

type ExternalItem struct {
	ExternalID string
	Title      string
	PriceCent  int64
	Stock      int64
}

type ItemRepository interface {
	FindBySupplierExternal(ctx context.Context, supplierID int64, externalID string) (*Item, error)
	CreateFromExternal(ctx context.Context, supplierID int64, ext *ExternalItem) (*Item, error)
}

type RiskRouter interface {
	Route(ctx context.Context, item *Item, ext *ExternalItem) (Strategy, error)
}

type Strategy string

const (
	StrategyNone   Strategy = "NONE"
	StrategyAuto   Strategy = "AUTO"
	StrategyManual Strategy = "MANUAL"
)

type SyncService struct {
	items ItemRepository
	risk  RiskRouter
}

type Item struct {
	ItemID     int64
	SupplierID int64
	ExternalID string
}

func (s *SyncService) Upsert(ctx context.Context, supplierID int64, ext *ExternalItem) error {
	if ext.ExternalID == "" {
		return errors.New("external_id required")
	}

	item, err := s.items.FindBySupplierExternal(ctx, supplierID, ext.ExternalID)
	if errors.Is(err, ErrNotFound) {
		_, err := s.items.CreateFromExternal(ctx, supplierID, ext)
		return err
	}
	if err != nil {
		return err
	}

	strategy, err := s.risk.Route(ctx, item, ext)
	if err != nil {
		return err
	}

	switch strategy {
	case StrategyNone:
		return s.applyDirect(ctx, item, ext)
	case StrategyAuto:
		return s.applyAuto(ctx, item, ext)
	case StrategyManual:
		return s.enqueueApproval(ctx, item, ext)
	default:
		return fmt.Errorf("unknown strategy: %s", strategy)
	}
}

var ErrNotFound = errors.New("not found")

func (s *SyncService) applyDirect(ctx context.Context, item *Item, ext *ExternalItem) error {
	// TODO: txn + outbox + downstream projections
	return nil
}

func (s *SyncService) applyAuto(ctx context.Context, item *Item, ext *ExternalItem) error { return nil }

func (s *SyncService) enqueueApproval(ctx context.Context, item *Item, ext *ExternalItem) error {
	return nil
}

10.5 运营管理能力

10.5.1 单品编辑

单品编辑要支持:

  • 字段级变更预览(diff)
  • 灰度发布(先预览环境 / 白名单用户可见)
  • 强制备注(高风险字段变更必须填写原因)

单品编辑的「体验细节」往往是事故分水岭
很多系统只保存最新值,不保存修改意图与对比,导致客诉时无法解释「为什么昨天还能买今天不能买」。建议在 UI 层提供 diff,在服务端保存结构化变更单(即使最终免审直写),至少保留:变更前后摘要、策略路由结果、操作者、来源(Portal/OPS)、以及关联的 batch_id(若来自批量任务)。

对于高敏字段(类目、品牌、主图),推荐引入草稿预览态:先在副本聚合上验证规则与索引影响,再一次性提交,降低「半截修改」造成的中间不一致。

10.5.2 批量编辑

批量编辑必须引入 batch_id

  • 任务表:batch_operation(batch_id, operator_id, type, status, total, succeeded, failed)
  • 子任务表:batch_operation_item(batch_id, item_id, status, error)

批量编辑的「原子性」要面对现实
十万级商品不可能在一个数据库事务里完成。工程上应承诺的是:可追踪的最终一致性,而不是全有或全无。常见做法是:

  • 子任务粒度提交,每行独立事务;批次级别记录成功/失败统计。
  • 对「强一致需求」的字段(例如统一改错类目)提供补偿任务:自动扫描失败子任务并支持一键重试。

10.5.3 批量导入导出

导出常用于:

  • 大促前核对「活动圈品清单」
  • 与供应商对账(外部 id 映射)

导入导出都应异步化,并限制文件大小与行数。

导入模板版本化
Excel 导入的最大维护成本来自列变更。建议把模板当作契约:template_version 随文件上传,服务端按版本选择解析器;旧版本文件在宽限期内仍可导入,避免运营同学「一列调整全员停摆」。

10.5.4 任务编排与进度追踪

推荐统一任务框架能力:

  • 可暂停 / 可继续(checkpoint)
  • 可重试(按错误类型区分重试策略)
  • 可取消(合作传播取消信号到 worker)

10.5.5 权限与审计

最小权限模型(RBAC + 数据范围):

  • 类目负责人只能审批本类目
  • 运营专员可改长尾商品,但不可改「平台核心爆品」
  • 供应商账号只能操作映射到自己的 supplier_id

审计日志至少记录:who/when/what/before/after/reason/trace_id

Go:批量子任务状态更新(减少热点行)

package opsbatch

import (
	"context"
	"database/sql"
	"time"
)

type BatchItemRepo struct {
	db *sql.DB
}

func (r *BatchItemRepo) MarkProgress(ctx context.Context, batchID string, succeeded, failed int) error {
	_, err := r.db.ExecContext(ctx, `
UPDATE batch_operation
SET succeeded = succeeded + ?, failed = failed + ?, updated_at = ?
WHERE batch_id = ?
`, succeeded, failed, time.Now(), batchID)
	return err
}

说明:超大批次不要把进度累计在单行上形成热点,可按 shard = hash(item_id) % N 分片多张进度子表,周期聚合到总表。

导入导出的安全边界
导出接口是高危面:既能泄露商业数据,也能被用作 DoS(全表导出)。必须叠加:最小权限 + 异步生成 + 下载链接短期有效 + 水印与审计


10.6 商品审核系统

10.6.1 差异化审核策略

审核策略建议拆成四层,从「成本最低」到「成本最高」递进:

  1. NONE(免审直写):低敏字段、低幅度、低影响商品。
  2. AUTO(机审):敏感词、图片 OCR、价格阈值、黑白名单、供应商信誉分。
  3. MANUAL(人审):中高敏字段或 AUTO 失败兜底。
  4. STRICT(多级/会签):类目迁移、品牌变更、批量影响面大。

策略配置如何「可运营」
审核阈值不要写死在代码里,而要沉淀为可发布配置(建议分环境:dev/stage/prod),并至少支持:

  • 按类目覆盖:数码品类对标题与参数更敏感;虚拟商品对履约属性更敏感。
  • 按供应商等级覆盖:战略供应商在库存字段上可走快速通道,但在类目字段上仍应严格。
  • 影子模式:新策略先计算「如果生效会如何路由」,输出对比报表,再灰度放量。

与合规审核的边界
商品审核通常同时包含「业务风险审核」与「合规审核」。工程上建议拆队列:业务审核关注价格毛利与经营策略;合规审核关注内容与资质。混队列会导致 SLA 互相拖累,且难以后台化统计。

10.6.2 风险评估引擎

风险评估引擎输入:diff + item_profile + operator_profile + supplier_profile
输出:risk_scorestrategy,并附带可解释原因(给审核员与客诉)。

可用简化公式(示例,便于实现与调参):

risk_score = Σ_i w(field_i) × m(magnitude_i) × p(item_profile)

其中:

  • w(field):字段权重(类目、价格、标题通常更高)
  • m(magnitude):幅度函数(价格变动比例、标题相似度)
  • p(item_profile):商品热度因子(热销品变更更敏感)

Go:风险路由(示意)

package approval

import "math"

type FieldChange struct {
	Field      string
	OldFloat   float64
	NewFloat   float64
	ChangeRate float64
}

type ItemDiff struct {
	ItemID  int64
	Changes []FieldChange
}

type ItemProfile struct {
	MonthlySales int64
	IsHot        bool
}

type RiskEvaluator struct{}

func (e RiskEvaluator) Score(diff ItemDiff, prof ItemProfile) float64 {
	var score float64
	hot := 1.0
	if prof.IsHot || prof.MonthlySales > 1000 {
		hot = 1.5
	}

	for _, c := range diff.Changes {
		switch c.Field {
		case "price":
			mag := 1.0
			if math.Abs(c.ChangeRate) >= 0.5 {
				mag = 3.0
			} else if math.Abs(c.ChangeRate) >= 0.3 {
				mag = 2.0
			} else if math.Abs(c.ChangeRate) >= 0.1 {
				mag = 1.0
			} else {
				mag = 0.5
			}
			score += 3.0 * mag * hot
		case "category_id":
			score += 5.0 * 2.0 * hot
		case "title":
			score += 3.0 * 1.0 * hot
		case "stock":
			score += 1.0 * 1.0 * 1.0
		}
	}
	return score
}

func (e RiskEvaluator) Route(score float64) Strategy {
	switch {
	case score <= 3:
		return StrategyNone
	case score <= 6:
		return StrategyAuto
	case score <= 10:
		return StrategyManual
	default:
		return StrategyStrict
	}
}

10.6.3 人工审核工作流

人工审核建议 BPMN 能力最小集:

  • 认领(claim)避免重复审核
  • 转交(reassign)
  • 驳回理由模板化(减少「不同意」式无效信息)
  • 审核 SLA 与升级(超时升级到主管队列)

变更审批单自身的生命周期(与商品主状态解耦)
为避免把「审批中」误写到 item.status,建议对 change_request 单独建状态机:

stateDiagram-v2
  [*] --> OPEN: 创建审批单
  OPEN --> AUTO_PASSED: 机审通过
  OPEN --> MANUAL_PENDING: 需要人审
  MANUAL_PENDING --> APPROVED: 审核通过
  MANUAL_PENDING --> REJECTED: 审核驳回
  OPEN --> REJECTED: 规则直接拒绝
  APPROVED --> APPLIED: 变更已落库
  REJECTED --> CLOSED: 关闭
  AUTO_PASSED --> APPLIED: 自动应用
  APPLIED --> [*]
  CLOSED --> [*]

SLA、升级与「可解释的排队」
审核队列的产品体验,核心在可解释:审核员需要看到风险因子拆解(价格幅度、敏感词命中、历史违规、供应商评分),而不是只有一个「风险分」。SLA 建议与策略绑定:

策略目标处理时效超时动作
AUTO秒级转 MANUAL 或自动拒绝(按业务)
MANUAL分钟~小时级升级到主管池 + 通知申请人
STRICT小时级升级 + 限制相关商品营销投放(保护用户)

审核流程(从变更到落库)

sequenceDiagram
  participant S as 供给/运营服务
  participant R as RiskEngine
  participant DB as 审批单存储
  participant Q as 审核队列
  participant W as 审核员
  participant PC as 商品中心

  S->>R: evaluate(diff)
  R-->>S: strategy + risk_score
  alt NONE/AUTO 通过
    S->>PC: apply change (txn)
  else MANUAL/STRICT
    S->>DB: create change_request
    S->>Q: enqueue(request_id)
    W->>Q: claim
    W->>DB: decision(approve/reject)
    alt approve
      W->>PC: apply approved payload (txn)
    else reject
      W->>S: notify reject reason
    end
  end

10.6.4 快速通道

快速通道(Fast Lane)用于已建立信任的变更:

  • 旗舰供应商 + 低敏字段(库存)
  • 已通过机器学习模型打分的「低概率违规」标题微调

快速通道仍要保留:

  • 抽样复核(随机 1% 进人工)
  • 事后审计(T+1 扫描)

10.7 配置工具

配置工具的本质是:把「可运营参数」从代码里拽出来,并绑定审批与发布。

10.7.1 价格配置

  • 基础价:通常归属商品中心或计价的基础层(与第11章衔接)。
  • 临时锁价:必须带生效区间与原因,避免永久锁死供应链反应。

价格配置最容易踩的坑是把「运营想锁价」实现成「直接改商品表里的展示价」,这会让促销试算、对账与审计全部失真。更稳妥的做法是:

  • 在计价系统引入显式锁价策略对象(带生效区间、优先级、适用范围),商品中心只保存基础价事实或「锁价引用 ID」。
  • 任何锁价变更走与改价类似的审核路由,但审核重点从「幅度」转向毛利保护与合约合规(是否违反供应商协议价)。

10.7.2 库存配置

库存配置应落到库存系统;商品侧最多是「展示阈值 / 可售开关」,避免双写库存事实。

展示阈值与真实库存要区分语义
例如「剩余 3 件以下展示为紧张」属于 UX 配置,不应写回库存真值;而「可售开关」若由运营控制,应与库存系统的渠道可售策略对齐,避免出现「库存系统仍可卖,但商品中心显示不可售」的双真源。

10.7.3 营销配置

运营在商品上绑活动属于圈品动作,应由营销系统持有规则,商品侧保存 campaign_tags 类投影要谨慎:更推荐事件投影或查询时组装。

圈品与商品主数据的关系
实践中常见两条路线:

  1. 查询时组装:PDP/列表在 Hydrate 阶段调用营销服务计算标签与活动价展示,商品中心不存活动态。
  2. 异步投影:为降低在线 QPS,将「命中活动摘要」投影到只读字段,但必须明确投影延迟 SLA,并接受短暂不一致。

路线 1 更干净;路线 2 更省流量。无论哪条,都要避免运营在商品后台「手填活动价」——那是计价与营销域的职责溢出。

10.7.4 首页配置

首页位属于运营配置域(CMS / 投放系统),与商品主数据解耦,通过 slot + item_id + schedule 引用商品即可。

配置发布的灰度与回滚
首页配置变更往往是高频且高风险的(错误投放会带来巨额损失)。建议:

  • 配置版本化:config_version + 发布单。
  • 预演校验:引用 item_id 必须存在且 ONLINE,否则拒绝发布。
  • 一键回滚:保留上一版本快照与生效时间线。

10.8 稳定性保障

10.8.1 限流与降级

  • 按供应商限流:保护自身与供应商。
  • 按运营批量任务限流:避免大任务挤占在线交易连接池。
  • 降级:审核排队过长时,自动切换「更严格但可预测」的策略不如「停写只读」危险;更合理的是阻塞新批量任务而不是阻塞下单读链路。

10.8.2 熔断机制

对供应商接口熔断时:

  • Pull 同步进入 HALF_OPEN 试探恢复
  • Push 写入进入磁盘队列(注意顺序与背压)

10.8.3 灰度发布

上架与同步规则(阈值、字段权重)应支持:

  • 按供应商灰度
  • 按类目灰度
  • 按「读写分离影子模式」:只计算策略不落库,对比差异报表

10.8.4 故障隔离

  • 线程池/队列隔离:大导入与实时 Push 分队列,避免相互抢 worker。
  • Bulkhead:商品写入与索引构建拆不同资源池。

容量事故的一个典型路径(供自我对照)
大促前夜运营提交「10 万行批量改价」,与供应商夜间全量同步撞车,双方共用同一消费组与同一数据库连接池,导致:

  1. 消费延迟上升 → Outbox 堆积 → 搜索索引滞后;
  2. 在线交易读路径因连接池耗尽开始抖动;
  3. 团队开始「加机器」,但瓶颈在 DB 与锁竞争,扩容无效,反而放大重试风暴。

治理的关键不是事后加机器,而是事前把队列、连接池、线程池按租户/场景隔离,并把批量任务的默认并发调到「永远杀不死核心链路」。


10.9 数据看板与监控

10.9.1 运营指标

指标解释作用
上架成功率DONE / CREATED发现编排失败
审核时效 P95从创建到决策SLA 治理
同步延迟now - last_success_time供应商健康
冲突率并发写冲突次数调参乐观锁/队列

建议补充的三类「经营向」指标(可选但高价值)
技术指标能告诉你系统坏没坏,经营指标能告诉你业务卡在哪:

  1. 上架漏斗转化率:从创建任务到 ONLINE 各阶段停留时长分布,定位审核、初始化、索引哪一段最慢。
  2. 供应商数据质量分:字段缺失率、重复推送率、价格跳变率,驱动供应商治理与合作条款。
  3. 运营批量任务失败 Top 原因:把 error_code 聚类,推动模板校验、权限提示与培训材料迭代。

10.9.2 实时监控

必须对接:

  • 队列堆积、消费延迟
  • Outbox 未投递计数
  • 供应商错误码分布(签名失败 / 限流 / 超时)

把「可观测」嵌进领域对象
除了基础设施指标,建议在任务与审批单上直接暴露业务字段到指标标签(注意基数控制):supplier_idtask_typeapproval_strategy 等。这样报警通知里能直接定位「哪类供应商的哪种同步在恶化」,而不是只有 CPU 曲线。

10.9.3 报警机制

报警分级建议:

  • P0:上架编排大面积失败、Outbox 堆积导致事件断流
  • P1:同步失败率升高、审核队列 SLA 违约
  • P2:单供应商异常(可降级)

On-call 视角:把指标映射到「可操作手册」
监控的价值在于缩短 MTTR。建议为每类报警准备一页 runbook(可放在内部 wiki),至少回答四个问题:影响面是谁第一动作是什么可启用哪个开关如何验证恢复。例如 Outbox pending_count 持续升高:先区分是 DB 写入变慢还是 Dispatcher 假死;再检查 Kafka 集群健康;最后才考虑临时扩容与降级非核心消费者。

Prometheus 规则示例(片段)

groups:
  - name: supply_ops_slo
    rules:
      - alert: ListingOrchestrationFailureBurst
        expr: increase(listing_task_failed_total[10m]) > 50
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "上架编排失败突增"
          description: "10 分钟内失败任务超过阈值,优先检查库存/计价初始化与供应商回调签名"

      - alert: OutboxDispatchLag
        expr: outbox_pending_rows > 100000
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Outbox 堆积"
          description: "事件投递延迟将扩散到搜索/缓存,检查 dispatcher 与消息集群"

10.10 系统边界与职责

10.10.1 上架系统 vs 商品中心:边界划分

维度上架/供给运营域商品中心
职责流程、编排、审核、任务主数据存储、查询契约、版本
数据任务、审批单、同步水位item/spu/sku 及稳定属性
一致性Saga / 事务边界在应用层聚合内强一致

反模式:运营后台直连商品库表;短期快,长期必然审计与耦合灾难。

10.10.2 供给侧 vs 运营侧的职责

  • 供给侧:保证映射正确、增量可恢复、对供应商接口容错。
  • 运营侧:保证平台规则落地、内容合规、经营策略可执行。

10.10.3 数据主导权归属

推荐原则:

  • 外部事实(库存、供应商可售状态)以供应商为准,平台可缓存但要有对账。
  • 平台治理字段(类目、品牌授权、禁售标签)以平台为准,供应商不可静默覆盖。

10.10.4 审核权限边界

审核权属于风险域,不要散落在各业务服务 if-else 中。应沉淀为:

  • Policy(配置)
  • RiskEvaluator(计算)
  • ApprovalService(生命周期)

「谁能审什么」与「谁能改什么」要分开建模
很多团队把审核权限与运营编辑权限混在一个 RBAC 角色里,结果要么审核权过大(普通运营也能放行高风险变更),要么审核权过小(主管也被卡在细枝末节字段)。推荐做法:

  • 审核权限按风险域与类目维度授权(并可临时委派)。
  • 编辑权限按组织与店铺维度授权。
  • 二者交叉点用「二次确认」与「双人复核」解决,而不是把字段校验堆在前端。

10.11 与其他系统的集成

10.11.1 与商品中心集成(数据写入)

写入路径建议:

  • 同步 API:强一致的小范围字段更新(谨慎使用)
  • 命令模式UpsertItemCommand 由商品中心统一校验不变量

写入契约:命令应携带「意图」而非「表字段集合」
运营后台很容易把表单直接映射成 UPDATE item SET ...,这会让商品中心失去聚合边界。更推荐命令携带业务意图,例如 AdjustBasePriceChangeTitleForComplianceMoveCategoryWithReindex。商品中心在聚合根内做不变量校验(类目必填属性、品牌授权、禁限售),再决定生成哪些领域事件。

10.11.2 与库存系统集成(初始化库存)

上架 PUBLISHED → ONLINE 之前应确保库存记录存在;失败应停留在 PROVISIONING 并可重试。

初始化失败的分层处理
库存初始化失败可能是瞬时网络问题,也可能是供应商 SKU 映射错误。任务层应区分:

  • 可重试错误:指数退避重试,记录重试次数上限。
  • 不可重试错误:将任务标记失败并给出明确错误码(例如映射缺失),避免无限重试刷爆库存服务。

10.11.3 与计价系统集成(价格初始化)

基础价初始化失败应阻断上线或进入「不可售」保护态,避免用户看到不可结算价格。

与「试算」系统的衔接说明
用户看到的价格通常是计价系统基于基础价、活动、会员等因素试算结果。上架阶段初始化的是基础价事实与必要的价目表结构;不要把「活动价计算」塞进上架编排,否则会把营销耦合进供给链路,导致编排耗时不可控。

10.11.4 与搜索系统集成(索引更新)

索引更新可异步,但要监控「在线商品不可搜」窗口;可对热点商品同步刷新。

索引字段的分层
建议把索引字段分为「强一致必要字段」(标题、类目、可售状态)与「弱一致增强字段」(销量统计、标签)。弱一致字段允许更长延迟,必要时可走独立 topic,避免阻塞强一致更新。

10.11.5 与供应商系统集成(数据同步)

对接层单独做 Anti-Corruption Layer(防腐层),把供应商 DTO 映射为平台统一命令,避免供应商字段污染核心模型。

适配器治理:把「对接复杂度」关进笼子
供应商越多,适配器越容易变成垃圾场。建议:

  • 每个供应商独立模块(package),对外只暴露 IngestHandler
  • 映射规则版本化:mapping_version 与数据一起存储,便于回放与回滚。
  • 统一错误码翻译:把供应商千奇百怪的错误映射为平台内部枚举,便于监控聚合。

10.11.6 集成事件流与幂等性保证

端到端时序:从「编排完成」到「用户可感知」
下图强调两个事实:其一,商品中心仍是主数据写入枢纽;其二,搜索/缓存等读模型是异步最终一致,因此「商品已 ONLINE」与「用户可搜到」之间存在可度量时间窗,应用监控覆盖该窗口,而不是假设同步完成。

sequenceDiagram
  participant ORCH as 供给编排器
  participant PC as 商品中心
  participant INV as 库存系统
  participant PRC as 计价系统
  participant OB as Outbox Dispatcher
  participant BUS as Kafka
  participant ES as 搜索索引
  participant CDN as 缓存失效

  ORCH->>PC: UpsertItemCommand(事务内)
  ORCH->>INV: InitStock / Adjust(按场景)
  ORCH->>PRC: InitBasePrice(按场景)
  PC->>OB: 写入 outbox(同事务)
  ORCH-->>ORCH: 提交事务

  OB->>BUS: 发布 product.* 事件
  BUS->>ES: 消费建索引/更新
  BUS->>CDN: 消费删缓存键
flowchart LR
  subgraph write_path[写入路径]
    ORCH[编排器] --> PC[(商品中心)]
    ORCH --> INV[(库存)]
    ORCH --> PRC[(计价)]
    PC --> OB[(Outbox)]
  end

  OB --> BUS[Kafka / MQ]
  BUS --> C1[搜索消费者]
  BUS --> C2[推荐消费者]
  BUS --> C3[缓存失效消费者]
  BUS --> C4[营销投影消费者]

  C1 --> ES[(ES 索引)]
  C3 --> RD[(Redis)]

消费者幂等要点:

  • event_id 做 Redis SETNX 或 DB 唯一表去重
  • 处理逻辑尽量可重放(replay)而非依赖「刚好一次」网络
package integration

import (
	"context"
	"errors"
)

type Consumer interface {
	Handle(ctx context.Context, evt Event) error
}

type Event struct {
	EventID string
	Type    string
	ItemID  int64
}

type Deduper interface {
	Seen(ctx context.Context, eventID string) (bool, error)
	Mark(ctx context.Context, eventID string) error
}

type IdempotentConsumer struct {
	inner   Consumer
	deduper Deduper
}

func (c IdempotentConsumer) Handle(ctx context.Context, evt Event) error {
	seen, err := c.deduper.Seen(ctx, evt.EventID)
	if err != nil {
		return err
	}
	if seen {
		return nil
	}
	if err := c.inner.Handle(ctx, evt); err != nil {
		return err
	}
	if err := c.deduper.Mark(ctx, evt.EventID); err != nil {
		return err
	}
	return nil
}

var ErrRetry = errors.New("retryable failure")

10.12 工程实践

10.12.1 并发控制

  • 主数据更新:version 乐观锁 + 有限重试
  • 批量任务:分片(shard)避免热点行更新

10.12.2 性能优化

  • 批量 DB:多值 INSERT、必要时 COPY
  • 计算 diff:先哈希整行,再细 diff,减少 CPU

10.12.3 监控告警

把「业务失败」映射成可行动报警:

  • supplier_auth_error_rate 上升 → 证书/时钟问题
  • approval_queue_wait_p95 上升 → 人审容量不足

10.12.4 故障案例(典型复盘模板)

  1. 现象:索引延迟升高,搜索空窗扩大
  2. 直接原因:批量导入与实时变更共用同一 consumer group
  3. 根因:缺少队列隔离与背压
  4. 修复:拆分 topic / group,导入走独立链路
  5. 预防:容量基线与灰度开关纳入发布检查清单

案例 B:供应商重复推送导致「幽灵改价」
某供应商在网关超时后重试回调,平台侧未做 message_id 去重,导致短时间内价格被回滚到旧值,又触发自动审核通过,最终造成活动价与展示价不一致。

  • 修复点:Push 入口落「去重表 + 业务版本号」;应用变更前比较 external_version
  • 预防点:把供应商重试策略纳入联调验收清单(超时、重试间隔、幂等键)。

案例 C:乐观锁重试风暴
大促期间运营批量改标题,与搜索 Hydrate 触发的轻量回写并发,导致大量 version conflict,worker 无限重试放大 DB 压力。

  • 修复点:批量任务改为分片串行 per item;读路径回写避免触碰高频 version 字段。
  • 预防点:区分「内容字段版本」与「展示投影版本」,避免所有变更都 bump 同一 version。

Go:基于版本号的并发写(带退避)

package concurrency

import (
	"context"
	"errors"
	"time"
)

var ErrConflict = errors.New("version conflict")

type ItemWriter interface {
	Load(ctx context.Context, itemID int64) (price int64, version int64, err error)
	CASUpdatePrice(ctx context.Context, itemID int64, newPrice int64, fromVersion int64) (rows int64, err error)
}

func UpdatePriceWithRetry(ctx context.Context, w ItemWriter, itemID int64, newPrice int64) error {
	var last error
	for i := 0; i < 5; i++ {
		_, ver, err := w.Load(ctx, itemID)
		if err != nil {
			return err
		}
		n, err := w.CASUpdatePrice(ctx, itemID, newPrice, ver)
		if err != nil {
			last = err
		} else if n == 1 {
			return nil
		} else {
			last = ErrConflict
		}
		time.Sleep(time.Duration(10*(i+1)) * time.Millisecond)
	}
	return last
}

10.13 本章小结

本章围绕「商品如何进入平台并被持续运营」这一链路,给出了可落地的架构切分与关键机制:

  • Listing / Supplier Ingest / Ops 分离三种业务语义,避免 Create / Upsert / Update 混写。
  • 完整生命周期状态机 管理主数据状态,用 任务状态机 管理流程实例。
  • 风险评估引擎 + 差异化审核 在成本与风险之间取得运营可承受平衡。
  • 异步编排 + Outbox 保证跨系统写入可恢复、事件可投递。
  • 边界章节 明确商品中心、库存、计价、搜索、供应商适配层的职责与数据主导权。

落地建议(按优先级)
如果你只能做三件事:第一,把写入入口收敛为命令 API,消灭后台直连数据库;第二,把审核策略从代码搬到可灰度配置,并建立风险解释字段;第三,把跨系统编排做成可观测任务(可重试、可补偿、可报警)。其余优化(缓存、分片、影子对比)都应建立在这三板斧之上。

在下一章(第11章)中,我们将进入交易链路的基础能力:计价系统,把「上架时初始化价格」与「全链路试算」衔接到同一套价格模型之下。


延伸阅读建议

  • 第7章:商品中心模型(SPU/SKU、类目属性)
  • 第8章:库存系统(库存事实与预占)
  • 第6章:Saga、Outbox 与最终一致性策略

导航书籍主页 | 完整目录 | 上一章 | 下一章

导航书籍主页 | 完整目录 | 上一章 | 下一章


第11章 计价系统设计与实现

本章基于《电商系统设计(五):计价引擎》与《电商系统设计(六):计价系统 DDD 实践》整理扩展,聚焦交易链路中的统一计价能力:四层价格模型、场景化计算、DDD 战术建模与系统边界。

阅读提示:若你更熟悉「订单里直接存一个 total_amount」的朴素模型,可以先带着两个问题读完全章——第一,券与积分为何常常不进创单快照;第二,为什么支付阶段坚持校验而不是重算。搞清这两个问题,就能理解计价中心存在的必然性,而不是把它简单看成「又一个中台服务」。文中 Go 示例为教学裁剪版,省略了错误包装、观测字段与部分依赖注入,落地时请按项目规范补全。


11.1 背景与挑战

11.1.1 价格计算的复杂性

在电商系统中,用户看到的「价格」并非单一标量,而是多因子分层叠加的结果:基础售价、营销活动、订单级抵扣、运费与增值服务、支付渠道手续费等,共同决定订单应付最终支付。若各系统各自实现一套加减逻辑,极易出现「商详 99 元、下单 105 元」的体验问题,甚至引发重复优惠、二次扣券等资损。

典型分解可概括为:

  • 基础价格:市场价、日常折扣价、渠道价等;
  • 营销价格:秒杀、新人价、满减、Bundle 等;
  • 抵扣:优惠券、积分、支付立减等;
  • 费用与附加:运费、碎屏险等平台服务费、跨境或信用卡手续费等。

同一 SKU 在 PDP(商品详情页)购物车创单收银台支付 各阶段,对计算深度、一致性与性能的要求并不相同,这要求计价中心以场景驱动的方式暴露能力,而不是「一刀切」的全量重算。

在传统拆分里,商品服务算「标价」、营销服务算「活动价」、订单服务在创单时再算一遍总价、支付服务为渠道优惠再算一遍——表面看各团队各管一段,实际上同一业务概念被多处隐式定义,边界一模糊就会出现「券用了两次」「满减门槛按行算还是按单算各执一词」等问题。更隐蔽的是:浮点金额四舍五入顺序外币汇率取整点不一致,会在大规模订单下累积成对账差异。计价中心的意义,就是把「一次购物旅程中所有与钱有关的加减」收敛到同一套编排与同一套舍入规则,让其他系统变成数据供给方或执行方,而不是第二个计算器。

11.1.2 核心挑战

  1. 一致性:展示价、订单快照、支付金额必须可追溯、可校验;规则版本与快照版本应对齐。
  2. 准确性:金额以为单位的整数运算;订单级优惠需按比例分摊到行,否则退款无法闭合。
  3. 性能:PDP / 列表页高 QPS,需多级缓存与轻量路径;创单 / 收银台可接受更高延迟但不容错算
  4. 异构品类:Topup、酒店、机票等定价因子差异大,需要策略插件而非巨石 if-else
  5. 供应商品类实时价:报价可能在浏览至支付间变化,需要 BookingToken、支付前反查等机制。

此外有两类「软挑战」往往在事故后才被写入复盘:组织边界产品口径。前者表现为多个团队各维护一段计算逻辑,接口文档写「参考订单域」,实际上订单域又在调另一套历史脚本;后者表现为 PRD 写「到手价」,但未定义是否含运费、是否含可叠加券的上限——技术再完美的引擎也无法收敛未定义的业务。计价项目启动时,建议把统一语言表(见 11.5 与系列第六篇)作为需求评审门禁,与 SkipLayers 表双签,再进入排期。

从工程视角,上述挑战可以压成三条硬约束:算得对(正确性)、大家认(一致性)、扛得住(性能与可用性)。其中正确性又依赖两条底座:一是整数分与确定的舍入;二是快照——把某一刻的规则解释结果固化为事实,后续链路只解释事实,不再在暗处重算。一致性则依赖版本化:规则集、活动表、券批次、汇率表都带有业务版本或生效区间,计价请求必须携带「我按哪一版解释」的线索,否则 PDP 与创单永远可能对不齐。

11.1.3 设计目标

目标说明
准确性计价结果可审计,关键路径可空跑比对
一致性统一入口计算,订单 / 支付以快照为准
高性能前台场景高缓存命中;交易路径少 IO、可并发
可扩展新营销类型、新费用项以策略 / 配置扩展
可观测分层耗时、缓存命中、差异告警

上述目标之间存在天然张力:例如「极致缓存」与「强一致快照」方向相反,需要通过场景分流化解,而不是用一套参数打天下。团队 OKR 里若只写 P99 延迟而不写差异率 / 资损事件数,很容易把系统优化成「快但不准」。建议在质量看板上同时跟踪:快照校验失败次数空跑 diff 超标次数客服价格类工单占比,与延迟指标并列。

11.1.4 计价系统在交易链路中的位置

计价中心位于商品、营销、库存、订单、支付之间:向上读取商品基础价与营销规则,向下为购物车、结算、创单、支付提供试算快照服务。它是交易链路的横切基础模块,不宜承担订单状态机或支付渠道路由等非本域职责。

flowchart LR
  subgraph upstream[上游依赖]
    Item[商品中心]
    Promo[营销系统]
    User[用户 / 画像]
    Supplier[供应商报价]
  end
  Pricing[计价中心]
  subgraph downstream[下游消费方]
    PDP[商详 / 导购]
    Cart[购物车]
    Checkout[结算收银台]
    Order[订单系统]
    Pay[支付系统]
  end
  Item --> Pricing
  Promo --> Pricing
  User --> Pricing
  Supplier --> Pricing
  Pricing --> PDP
  Pricing --> Cart
  Pricing --> Checkout
  Pricing --> Order
  Pricing --> Pay

把计价中心画在枢纽位置,并不是鼓励它成为「上帝服务」,而是强调其 I/O 边界:对外是少量稳定的试算与快照 API,对内通过防腐层消化外部世界的变化。实践中常见反模式有两种:其一是计价服务直接读营销库的宽表,把对方存储模型当自己领域模型;其二是把订单状态推进、支付路由塞进计价——二者都会让团队在排障时无法回答「这一分钱到底是谁改的」。本章后续用 DDD 的聚合与 ACL 约束,正是为了避免这两种腐化。


11.2 计价引擎架构

11.2.1 分层架构

计价中心通常分为:场景入口层(API / Handler)编排与快照层核心计算引擎品类策略防腐适配层缓存与持久化。入口按 PricingScene 路由到不同 Handler,核心引擎以责任链顺序执行各 Layer,并结合 Calculator 做品类扩展。

flowchart TB
  subgraph api[统一入口层]
    GW[Pricing API / Gateway]
    Router[SceneRouter]
    Snap[SnapshotManager]
    GW --> Router
    Router --> Snap
  end
  subgraph engine[核心计算层]
    L1[BasePriceLayer]
    L2[PromotionLayer]
    L3[DeductionLayer]
    L4[ChargeLayer]
    L5[FinalAssemblyLayer]
    L1 --> L2 --> L3 --> L4 --> L5
  end
  subgraph strategy[品类策略]
    C1[DealCalculator]
    C2[TopupCalculator]
    C3[HotelCalculator]
  end
  subgraph infra[基础设施]
    ACL[防腐层适配器]
    Cache[(L1/L2 缓存)]
    DB[(快照 / 审计)]
  end
  Router --> engine
  engine --> strategy
  engine --> ACL
  engine --> Cache
  Snap --> DB

与五层实现的关系:实现上常把「最终汇总、尾差修正、安全校验」独立为 FinalAssemblyLayer,于是代码里会看到五段责任链。本书在业务模型上仍称四层,是因为前四层对应「可被业务方单独讨论的价格语义」,而 Final 层是技术组装层(把四层结果折叠成响应 DTO 与快照 schema),不参与对外营销话术。团队在评审架构图时,应对业务讲四层,对研发可展开五层,避免无谓争论。

SceneRouter 的职责不仅是转发:它要注入 PricingScene、解析租户 / 地区 / 渠道、挂载灰度与空跑开关,并在入口完成参数校验(例如购物车行是否含失效 SKU、供应商品类是否带预订 token)。SnapshotManager 则与订单域协作:创单成功后写入订单快照,收银台基于订单行再生成支付快照;二者生命周期不同,不可混用一张表、一个过期策略

11.2.2 四层价格模型

为与业务语言对齐,本书将可叠加的价格语义归纳为四层(不含最终的汇总展示层):基础层、营销层、抵扣层、费用层。引擎内部可再拆「最终汇总」为独立步骤,用于生成明细与快照版本。

层级名称典型内容出资方 / 备注
Layer 1基础价格市场价、折扣价、渠道价、供应商报价商家 / 平台标价
Layer 2营销价格秒杀、新人价、满减、活动价商家或平台营销预算
Layer 3抵扣优惠券、积分、部分支付立减用户权益
Layer 4费用与附加运费、增值服务费、平台服务费、支付手续费用户或平台规则
flowchart TB
  subgraph L1[Layer 1 基础价格]
    M[市场价]
    D[折扣价]
    M --> D
  end
  subgraph L2[Layer 2 营销价格]
    P[活动 / 秒杀 / 新人 / 满减]
  end
  subgraph L3[Layer 3 抵扣]
    V[券 / 积分 / 立减]
  end
  subgraph L4[Layer 4 费用与附加]
    F[运费 / 增值服务费 / 手续费]
  end
  L1 --> L2
  L2 --> L3
  L3 --> L4
  L4 --> Out[应付 / 实付口径由场景定义]

端到端走数示例(整数分):设某 SKU 日常折扣价 98000 分,限时抢购再减 8000 分(Layer 2),创单时加运费 1000 分、碎屏险 5000 分(Layer 4 中与履约相关部分),则订单应付为 98000 − 8000 + 1000 + 5000 = 96000 分。用户进入收银台选择满 500 减 100 的券(此处为 10000 分)与积分抵 10000 分(Layer 3),再选择会产生 2% 信用卡手续费的渠道,手续费基数若约定为「券与积分后的金额」,则应付变为 96000 − 10000 − 10000 = 76000 分,手续费 1520 分,实付 77520 分——具体基数以公司业务规则为准,关键是 Layer 顺序与基数必须在规则文档与代码注释中一致,并在快照里记录「手续费按哪一版基数计算」。

口径说明

  • 创单(CreateOrder):常见做法是 Layer 1 + Layer 2 + Layer 4 中与订单履约相关的费用(如运费、增值服务费),不包含 Layer 3 的券与积分,也不包含支付渠道手续费(手续费依赖用户所选渠道,放在收银台)。
  • 收银台(Checkout):完整执行 Layer 1–4,生成支付快照
  • 支付(Payment):以快照为准做校验,避免再次「全量重算」引入漂移。

Layer 的顺序不可随意调换:必须先有「可减的基准」,再谈营销减免,再谈用户权益抵扣,最后才叠加履约与支付相关费用。若把券提前到营销之前,会出现「用券改变满减门槛」这类循环依赖,规则引擎与测试用例都会爆炸。Layer 4 内部也建议再分子阶段:先算与履约相关的运费与增值服务费,再在收银台根据用户所选支付渠道计算手续费,这样创单快照不会错误地绑定某一渠道费率。

11.2.3 计算流程

计算流程可抽象为:构建 PricingContext → 按场景得到 skipLayers → 责任链逐层改写 PricingState → 品类 Calculator 参与行级计算 → Final 汇总明细 →(交易路径)持久化快照

flowchart LR
  A[请求 + Scene] --> B[加载商品 / 规则版本]
  B --> C[初始化 PricingState]
  C --> D{遍历 Layer}
  D -->|未跳过| E[更新行金额 / 明细]
  D -->|跳过| F[保持上层结果]
  E --> G{还有 Layer?}
  F --> G
  G -->|是| D
  G -->|否| H[分摊 / 取整 / 保护校验]
  H --> I[响应 + 可选快照]

PricingState 建议携带的内容包括:行级中间价、已选营销命中列表、已锁定券批次、供应商报价引用 ID、舍入审计数组、以及每层产生的结构化 PriceComponent(类型、金额、出资方、关联业务单号)。Final 之前的各层应尽量避免「只写一个整数总价」——客服与财务追问时,只有明细才能解释为什么少了一分钱。供应商品类还要在 state 中携带 报价过期时刻预订 token,以便支付校验阶段做二次确认或优雅失败。


11.3 核心实现

11.3.1 价格计算器设计

引擎对外暴露稳定接口,对内使用 Layer 责任链 + Calculator 策略

package pricing

import "context"

// Engine 计价引擎对外接口。
type Engine interface {
	CalculatePrice(ctx context.Context, req *PricingRequest) (*PricingResponse, error)
	CalculateWithDryRun(ctx context.Context, req *PricingRequest) (*PricingResponse, *DryRunResult, error)
	BatchCalculate(ctx context.Context, reqs []*PricingRequest) ([]*PricingResponse, error)
}

// Layer 单层计算:可读写 PricingState。
type Layer interface {
	Name() string
	Order() int
	Process(ctx context.Context, req *PricingRequest, st *PricingState) error
}

// Calculator 品类策略:在单层或多层之间参与行级公式。
type Calculator interface {
	Support(categoryID int64) bool
	Priority() int
	Calculate(ctx context.Context, req *PricingRequest, st *PricingState) error
}

责任链与策略的协作方式可以概括为:Layer 负责「这一类变价因子在何时进入总式」,Calculator 负责「这一品类如何解释基础输入」。例如酒店品类在 Layer 1 需要把「间夜 × 日历价 × 税费」折叠成一行基准 Money;Topup 在 Layer 1 只需要「面额 × 折扣率」。若把品类差异全写进 Layer 1 的 switch,Layer 将迅速膨胀;若把 Layer 2 的营销叠加规则写进 Calculator,又会导致营销变更需要改多个品类文件。推荐做法是:Layer 保持与品类无关的通用语义,Calculator 只处理「如何得到 Layer 1 接受的基准结构」以及少数「品类特有附加费」钩子。

错误语义:引擎对外错误应分层——参数非法(4xx)、依赖不可用(5xx 可重试)、规则冲突(4xx 业务码)、资损风险(4xx 拒绝 + 告警)。不要把「营销返回空列表」与「内部 panic」混用同一码,否则 SLO 统计会被污染。对创单路径,任何未分类错误都应默认 fail-close,避免生成半张快照。

initLayers 中按 Order() 排序注册:BasePricePromotionDeductionChargeFinal,与 11.2.2 的四层语义一致,Final 负责尾差、分摊与明细输出。

场景到层的映射在代码里常表为「跳过列表」,与业务文档交叉对照便于测试覆盖:

func SkipLayersForScene(scene PricingScene) []string {
	switch scene {
	case ScenePDP, SceneAddToCart:
		return []string{"deduction", "charge"}
	case SceneCart:
		return []string{"charge"} // 购物车可预估券;运费常缺省或按默认地址估算
	case SceneCreateOrder:
		// 创单:基础 + 营销 + 与订单绑定的附加费;不含券积分与支付手续费
		return []string{"deduction", "payment_handling_fee"}
	case SceneCheckout:
		return nil
	case ScenePayment:
		return []string{"base_price", "promotion", "deduction", "charge", "final"}
	default:
		return nil
	}
}

注:payment_handling_fee 是否从 Layer 4 拆出,取决于实现里是否将「订单附加费」与「支付渠道费」分为两个子处理器;关键是创单口径不包含随渠道变化的费率

11.3.2 快照生成

快照是防资损的关键:创单生成订单价格快照(含行明细、规则版本、供应商 BookingToken 等),收银台生成支付快照(含券积分与手续费)。快照应包含:

  • snapshot_idversioncalculated_atexpire_at
  • 各层贡献的结构化明细(便于对账与客服解释);
  • 可选:rule_bundle_hash 用于比对「当时用的是什么规则集」。

快照与订单数据的关系:订单表应保存 snapshot_id 或内嵌只读 JSON,但不建议在订单域再实现一套价格公式去「验算」——验算应回调计价或读快照服务,否则双实现又会分叉。快照表建议支持只追加:修正价格走新快照版本(v2),旧版本保留审计;支付失败回滚不应删除历史快照记录。TTL:订单快照常对齐库存锁定时间(如 30 分钟);支付快照对齐收银台支付超时(如 15 分钟),二者解耦。

11.3.3 试算接口

试算与正式计算共用同一套 Layer,通过 Scene 控制深度:PDP 试算只读展示;购物车允许预估券(标注 estimated=true);创单 / 收银台必须明确用户已选权益(券码、积分数量、渠道)。

试算响应里应显式区分三类字段:事实(已锁定、写入快照)、建议(系统推荐最优券但用户未确认)、估算(缺地址导致运费按默认规则猜)。前端展示时必须用不同标签,避免用户把「估算运费」当成承诺。对于 DryRun(空跑比对):上线新引擎时,生产流量旁路调用新旧两套,只在差异超阈值时采样上报,可在 PricingResponse 中附加 diff_summary 而不影响主路径延迟。

11.3.4 幂等性保证

计价接口常被上游重试。建议:

  • 请求携带 Idempotency-Key 或业务侧 request_id
  • 服务端以「用户 + 场景 + 关键购物车指纹」为维度短 TTL 缓存响应副本
  • 生成快照类写操作与订单号 / 结算单号绑定,防止重复生成两套有效快照。
type SnapshotRepository interface {
	Save(ctx context.Context, s *PriceSnapshot) error
	GetByOrderID(ctx context.Context, orderID string) (*PriceSnapshot, error)
}

func (s *PricingAppService) CreateOrderSnapshot(ctx context.Context, cmd CreateOrderSnapshotCmd) (*PriceSnapshot, error) {
	if snap, err := s.repo.GetByOrderID(ctx, cmd.OrderID); err == nil && snap != nil {
		return snap, nil
	}
	// ... 首次计算后落库
	return s.repo.SaveAndReturn(ctx, cmd)
}

安全校验器(Safety Checker) 常与幂等一起出现在创单 / 收银台路径:在返回快照前检查「总价不为负」「折扣不超过品类阈值」「优惠不超过商品应付之和」等。校验失败应拒绝生成快照而不是静默裁剪,否则会把业务错误伪装成成功交易。对于前端上送金额与后端计算金额的比对,建议以后端为准,前端金额仅作 UX 提示;若必须比对,应使用宽松阈值防浮点,或统一为整数分。


11.4 多级缓存与降级

11.4.1 缓存策略

场景是否缓存TTL 思路
PDP / 列表批量L1 短 TTL + L2 较长;命中要求高于展示 SLA
购物车部分自营可中等 TTL;供应商品类报价短 TTL
创单 / 收银台 / 支付否(结果可落快照表)以强一致计算为主

缓存 key 设计要同时防击穿脏读:key 中应包含 item_idsku_idregionchanneluser_segment(若价随人群变化)、以及规则版本摘要。大促时热门商品可采用**单飞(singleflight)**合并回源。对购物车这类高 churn 场景,可缓存「行哈希 → 计价结果」短 TTL,而不是整购物车超长缓存,避免用户改数量后长期读到旧价。

11.4.2 降级方案

  • 依赖超时:返回上一版本缓存并打标 stale=true(仅允许非交易路径);
  • 营销服务不可用:PDP 可降级为仅 Layer 1;创单路径应失败快速而非静默吞错;
  • 供应商报价失败:使用 DB 缓存价并限制最大陈旧度,超阈值则拦截创单。

降级策略要与法务与用户协议对齐:若页面上承诺了「展示价即购买价」,则任何返回陈旧价的降级路径都必须附带明确提示或干脆失败;否则可能构成虚假宣传风险。技术团队常忽略这一点,把「能卖出去」置于「合规展示」之上。开关治理上,降级与熔断配置应纳入配置中心审计,谁在什么时间打开「允许陈旧价」,需要可追溯。

11.4.3 性能优化要点

  • 批量场景用 errgroup 并发拉取多 SKU 基础价与活动;
  • 热点 SKU 预热
  • 对 Layer 内 RPC 设置独立超时与熔断,避免一层拖垮整条链。
package pricing

import (
	"context"
	"sync"
	"time"
)

type CacheManager struct {
	mu   sync.Mutex
	l1   map[string]cacheEntry
	l1TTL time.Duration
}

type cacheEntry struct {
	val       *PricingResponse
	expiresAt time.Time
}

func NewCacheManager(l1TTL time.Duration) *CacheManager {
	return &CacheManager{l1: make(map[string]cacheEntry), l1TTL: l1TTL}
}

func (c *CacheManager) GetOrCompute(ctx context.Context, key string, fn func(context.Context) (*PricingResponse, error)) (*PricingResponse, error) {
	now := time.Now()
	c.mu.Lock()
	if e, ok := c.l1[key]; ok && now.Before(e.expiresAt) {
		c.mu.Unlock()
		return e.val, nil
	}
	c.mu.Unlock()

	val, err := fn(ctx)
	if err != nil {
		return nil, err
	}
	c.mu.Lock()
	c.l1[key] = cacheEntry{val: val, expiresAt: now.Add(c.l1TTL)}
	c.mu.Unlock()
	return val, nil
}

生产环境可在 L1 之上再接 Redis、并接入 singleflight;此处展示「先读内存、未命中再计算回写」的最小闭环。


11.5 DDD 建模实践(重点)

DDD 在计价系统中的价值,在于用统一语言消除 originalPrice / salePrice / actualPay 混用,并用聚合边界保证「基础价 + 选中营销 + 费用 − 抵扣」在同一事务语义内一致。

11.5.1 领域模型设计

限界上下文:计价上下文(Pricing Context)与营销、商品、用户、支付等上下文通过 ACL(防腐层) 交互。计价上下文中,核心概念包括:Price(一次可报价单元)、PriceLayer(单层结果)、Money(金额值对象)、PriceSnapshot(不可变结果事实)、PricingPolicy(来自外部的规则投影)。

classDiagram
  class PriceAggregate {
    +string LineID
    +ReconstructFromSnapshot()
    +ApplyLayers()
    +ToSnapshot()
  }
  class Money {
    +int64 cents
    +string currency
    +Add(Money) Money
    +Sub(Money) Money
  }
  class PriceLayer {
    +LayerKind kind
    +Money delta
    +map meta
  }
  class PriceSnapshot {
    +string SnapshotID
    +[]LineBreakdown lines
    +string RuleVersion
  }
  class PricingContextVO {
    +int64 UserID
    +string Region
    +PricingScene Scene
  }
  PriceAggregate --> Money
  PriceAggregate --> PriceLayer : layers
  PriceAggregate --> PricingContextVO
  PriceSnapshot --> Money

限界上下文关系(战略视图):计价上下文处于下游消费位,对商品、营销、用户、支付等上下文均通过 ACL 取数;这些上下文互不直接依赖计价模型,避免「改一个 proto 全仓库编译失败」的耦合。下图省略防腐层实现类,只保留协作方向,便于与架构评审中的上下文地图对照。

flowchart LR
  subgraph peers[相邻上下文]
    IC[商品 Item Catalog]
    MC[营销 Marketing]
    UC[用户 User]
    PCtx[支付 Payment]
  end
  subgraph pricing_ctx[计价 Pricing]
    AR[Price 聚合 / Layer 编排]
    SN[PriceSnapshot]
  end
  IC -->|基础价 DTO| pricing_ctx
  MC -->|活动命中 / 券面额| pricing_ctx
  UC -->|人群 / 新客标记| pricing_ctx
  PCtx -->|渠道费率投影| pricing_ctx
  pricing_ctx -->|试算结果 / 快照 ID| PCtx

防腐层(ACL) 在计价落地中几乎与引擎同等重要:营销侧可能叫 activity_price,商品侧叫 sale_price,支付侧叫 payable_amount——计价域只接受自己的 MoneyPriceLayer。下面是一个最小对照:应用服务只依赖计价域接口 PromotionPort,基础设施里实现适配器,把 RPC DTO 转成值对象。

// domain/ports.go — 由定价上下文定义,由基础设施实现。
type PromotionPort interface {
	ActivePromotions(ctx context.Context, q PromotionQuery) ([]PromotionOffer, error)
}

// domain/promotion_offer.go — 定价上下文内的只读投影。
type PromotionOffer struct {
	ActivityID int64
	Kind       string
	Price      Money
}

// infra/promotion_acl.go
type promotionACL struct{ /* rpc client */ }

func (a *promotionACL) ActivePromotions(ctx context.Context, q PromotionQuery) ([]PromotionOffer, error) {
	// resp := a.client.Query(...)
	// return toOffers(resp),字段映射、枚举归一、金额转分,全部在此完成
	return nil, nil
}

11.5.2 聚合根:Price(行级报价聚合)

订单行 / 购物车行为粒度定义聚合根 Price(本书与实现中可与 PricingAggregate 等价命名),保证:

  1. 同一行上互斥营销的选择规则在一个聚合内完成;
  2. 行小计层明细同步更新;
  3. 对外只暴露已完成校验的结果。
package domain

import "errors"

type LayerKind int

const (
	LayerBase LayerKind = iota
	LayerPromotion
	LayerDeduction
	LayerCharge
)

// Price 聚合根:表示「一行 SKU 在一次请求下」的可报价过程。
type Price struct {
	lineID   string
	skuID    int64
	quantity int64
	layers   []PriceLayer
	ctx      PricingContext
	version  int64
}

func NewPrice(lineID string, skuID, qty int64, ctx PricingContext) (*Price, error) {
	if qty <= 0 {
		return nil, errors.New("quantity must be positive")
	}
	return &Price{lineID: lineID, skuID: skuID, quantity: qty, ctx: ctx}, nil
}

func (p *Price) ReplaceLayer(kind LayerKind, delta Money, meta map[string]string) error {
	if delta.IsNegative() && kind == LayerBase {
		return errors.New("base layer cannot go negative")
	}
	// 同类层覆盖或追加策略由领域规则决定,此处示意「按 kind 幂等替换」
	p.layers = upsertLayer(p.layers, kind, delta, meta)
	return nil
}

func upsertLayer(existing []PriceLayer, kind LayerKind, delta Money, meta map[string]string) []PriceLayer {
	nl := make([]PriceLayer, 0, len(existing)+1)
	replaced := false
	for _, l := range existing {
		if l.Kind == kind {
			nl = append(nl, PriceLayer{Kind: kind, Delta: delta, Meta: cloneMeta(meta)})
			replaced = true
			continue
		}
		nl = append(nl, l)
	}
	if !replaced {
		nl = append(nl, PriceLayer{Kind: kind, Delta: delta, Meta: cloneMeta(meta)})
	}
	return nl
}

func cloneMeta(m map[string]string) map[string]string {
	if m == nil {
		return nil
	}
	out := make(map[string]string, len(m))
	for k, v := range m {
		out[k] = v
	}
	return out
}

func (p *Price) Subtotal() (Money, error) {
	var sum Money
	for _, l := range p.layers {
		var err error
		sum, err = sum.Add(l.Delta)
		if err != nil {
			return Money{}, err
		}
	}
	return sum, nil
}

聚合边界Price 内不直接修改「券库存」「活动预算」——这些属于营销聚合,由应用服务先预留 / 锁定后再传入 Price 已选结果。

若团队纠结「一行 SKU 是否太小」:可以从一致性边界反推——任何「必须在同一事务里决定且一起成功或失败」的价格要素,应处于同一聚合;若某些营销是平台级自动领取、失败可静默降级,则不必纳入 Price 聚合,而可作为 Layer 2 的只读输入。购物车多行场景下,行级 Price 聚合 + 订单级领域服务是常见组合:行内互斥活动放在行聚合,跨行满减分摊放在服务。

11.5.3 值对象:MoneyPriceLayer

Money:用 int64 分与 currency 表达,不可变,所有运算返回新值,避免浮点误差。跨境时可在值对象内同时保存「展示币种金额」与「清算币种金额」,但比较与快照持久化必须指定其中一种为权威口径,另一种仅作参考字段。舍入规则(银行家舍入 vs 向上取整)应配置化,并在快照中记录 rounding_mode,否则三年后审计很难解释「为什么当年这样舍」。

PriceLayer:描述单层对金额的增量贡献(可为负表示减免),并携带 meta(活动 ID、费用类型、出资方 source=platform|merchant|channel)供对账。一个实用技巧是为每个 PriceLayer 分配稳定 component_id(UUID 或雪花),在退款回收、部分开票时直接引用,而不是靠数组下标——订单行重排或合并时,下标并不可靠。

// 与上文 Price 同属 domain 包。

type Money struct {
	cents    int64
	currency string
}

func (m Money) Add(o Money) (Money, error) {
	if m.currency != o.currency {
		return Money{}, errors.New("currency mismatch")
	}
	return Money{cents: m.cents + o.cents, currency: m.currency}, nil
}

// Multiply 单价 × 数量;若数量非法应由调用方先校验。
func (m Money) Multiply(qty int64) (Money, error) {
	if qty <= 0 {
		return Money{}, errors.New("quantity must be positive")
	}
	return Money{cents: m.cents * qty, currency: m.currency}, nil
}

func (m Money) IsNegative() bool { return m.cents < 0 }

type PriceLayer struct {
	Kind  LayerKind
	Delta Money
	Meta  map[string]string
}

11.5.4 领域服务

当逻辑跨多行不适合放入单一 Price 时,使用领域服务,例如:

  • 订单级满减分摊:余额递减法处理尾差;
  • 互斥活动择优:跨多个候选活动比较用户实付;
  • Bundle 计价:买 N 享 M 折等。
package domain

import "errors"

// LineAmount 表示一行在分摊前的可参与金额(通常为 Layer1+2+4 之后的行小计,单位:分)。
type LineAmount struct {
	LineID string
	Cents  int64
}

// ApportionmentService:订单级优惠按行权重分摊(余额递减 + 尾差落末行)。
type ApportionmentService struct{}

func (ApportionmentService) Allocate(orderDiscountCents int64, lines []LineAmount) ([]int64, error) {
	if orderDiscountCents < 0 {
		return nil, errors.New("discount must be non-negative")
	}
	if len(lines) == 0 {
		return nil, errors.New("no lines")
	}
	var total int64
	for _, l := range lines {
		if l.Cents < 0 {
			return nil, errors.New("line amount cannot be negative")
		}
		total += l.Cents
	}
	if total == 0 {
		return nil, errors.New("total weight is zero")
	}
	out := make([]int64, len(lines))
	var allocated int64
	for i := 0; i < len(lines)-1; i++ {
		// 按比例向下取整到分
		part := orderDiscountCents * lines[i].Cents / total
		out[i] = part
		allocated += part
	}
	out[len(lines)-1] = orderDiscountCents - allocated
	return out, nil
}

领域服务无状态,入参出参均为领域对象或值对象。

11.5.5 仓储与工厂

  • 工厂:从商品 / 营销 DTO 通过 ACL 组装 Price 初始状态;
  • 仓储PriceSnapshotRepository 持久化快照;不写聚合根运行态,避免贫血往返;
  • 应用服务:开启事务、调用营销锁定、调用引擎、保存快照、发布「快照已生成」领域事件。

工厂的职责是把「外部世界的行项目」翻译成领域可计算的初始不变式:数量为正、币种一致、基础层已填入「未乘数量的单价」或「已乘数量的行基准」——二者只能选一种约定,并在团队 wiki 中写死。工厂内不做营销择优,只做数据完备性与 ACL 映射;择优属于领域服务或 Layer 2 策略,避免工厂膨胀成第二个引擎。

package domain

// ItemPort 由商品上下文经 ACL 实现。
type ItemPort interface {
	BaseUnitPrice(ctx context.Context, skuID int64) (Money, error)
}

// PriceFactory 从商品行构造聚合根(示意:仅 Layer1 基准)。
type PriceFactory struct {
	items ItemPort
}

type CartLineInput struct {
	LineID string
	SkuID  int64
	Qty    int64
}

func (f *PriceFactory) NewPriceFromLine(ctx context.Context, in CartLineInput, pc PricingContext) (*Price, error) {
	unit, err := f.items.BaseUnitPrice(ctx, in.SkuID)
	if err != nil {
		return nil, err
	}
	p, err := NewPrice(in.LineID, in.SkuID, in.Qty, pc)
	if err != nil {
		return nil, err
	}
	lineBase, err := unit.Multiply(in.Qty)
	if err != nil {
		return nil, err
	}
	if err := p.ReplaceLayer(LayerBase, lineBase, map[string]string{"source": "item_catalog"}); err != nil {
		return nil, err
	}
	return p, nil
}

上例中 Multiply 可作为 Money 上的方法,与「单价 × 数量」语义绑定;若品类要求按「件数阶梯」重算基准,则在工厂之后交给对应 Calculator,而不是在工厂里写 switch category

应用服务与领域层的调用顺序(创单示例):校验入参 → 通过工厂构建每行 Price 聚合(仅含基础层)→ 调用领域服务选出互斥活动 → 各 Layer 在应用层编排下逐步调用 ReplaceLayerApportionmentService 处理订单级减免 → Price 聚合生成行视图 → 组装 PriceSnapshot 持久化。注意:券锁定属于应用层编排步骤,领域层只接收「锁定成功后的面额」作为事实输入,这样聚合不变式才不会依赖远程 RPC 的副作用。

充血 / 贫血混合策略:行级 Price 与分摊服务采用充血模型承载规则;快照 PO、HTTP DTO 保持贫血,避免把序列化细节泄漏进领域。测试金字塔上,领域单测覆盖互斥、尾差、货币错误;契约测试覆盖 ACL 与外部服务的字段映射;端到端只保留少量黄金用例,防止全链路测试过慢导致无人运行。


11.6 不同场景的价格计算

11.6.1 PDP 场景(商品详情页 / 加购试算)

PDP 的首要 KPI 是转化,技术侧对应的是极低延迟与稳定展示。计算上通常停留在 Layer 1 与 Layer 2:用户需要知道「日常卖多少、活动卖多少、我是否命中新人/秒杀」。券与积分如果在 PDP 就做全量最优解,RPC 扇出会爆炸,因此常见做法是:主路径同步返回展示价,券预估走异步任务或边缘计算,并在 UI 上用弱提示展示「领券最高可再减 X 元」。

加购(AddToCart) 与 PDP 类似,往往不锁任何资源;若要做「凑满减」提示,可在服务端维护轻量规则缓存,仍以 Layer 1–2 为主。PDP 与创单的价格差异若不可避免,必须在交互上降级为「以结算页为准」,同时在日志里记录 rule_bundle_hash,便于客诉时复盘。

11.6.2 购物车场景

购物车是多品聚合用户频繁编辑的交集:行增删、数量变化、地址切换都会触发重算。技术上通常批量拉取基础价与活动,再对共享的订单级优惠做编排;Layer 3 在购物车阶段多为试算而非锁定,返回体应用 estimated 标记。运费若无默认地址,可返回区间或按城市模板估算,并在进入结算页时用真实地址覆盖。

供应商品类在购物车仍需注意外部报价抖动:可短时缓存供应商返回,但 TTL 要显著短于自营;用户停留过久时,结算页应主动提示「价格已更新」。

11.6.3 创单场景(订单金额与快照)

创单是价格从「展示」走向「事实」的分水岭:此时应完成与履约相关的费用(运费、服务费等),并生成订单快照。不包含券与积分并非技术偷懒,而是业务上常把「用户尚未进入收银台选择的支付权益」排除在订单应付之外,避免订单应付随用户换券剧烈波动;若业务要求订单应付即含券,应在需求层显式调整 Layer 映射,而不是在代码里硬塞。

创单路径还要与库存预占、营销库存锁定同事务或同 Saga 编排:计价不负责预占,但要在预占成功之后再冻结快照,否则会出现「快照有了库存没了」的僵尸数据。供应商品类在创单常同步拉取供应商报价并生成 BookingToken,写入快照供支付确认。

11.6.4 支付场景

支付侧理想状态是 O(1) 查表校验:读取 snapshot_id 对应金额、币种、过期时间,与支付请求比对;供应商品类增加「预订确认」RPC。任何在支付路径重新跑全量 Layer 的做法,都应视为技术债:渠道回调重复、用户重复点击支付,都会让重算路径产生非确定性。若必须重算(极少数风控场景),应产生新快照版本并阻断旧支付单。

11.6.5 场景间的价格一致性保证

  1. 规则版本对齐:请求携带 rule_version / activity_bundle_id
  2. 快照链:创单快照 → 收银台在快照之上仅计算「增量」(券、渠道费)或全量重算后对比差异;
  3. 强提醒:当收银台结果与创单快照差异超过业务阈值,阻断或用户确认。

下图从同一用户旅程抽象各场景「算到哪一层、是否落快照」:箭头表示时间顺序,方框内为与本章 SkipLayersForScene 相呼应的语义(具体跳过列表以实现为准)。把它挂在团队 wiki 上,可减少「购物车为什么和创单差一块运费」的重复解释成本。

flowchart LR
  PDP[PDP / 加购] -->|Layer1+2| A[展示价]
  Cart[购物车] -->|Layer1+2 + 预估3| B[预估小计]
  CO[创单] -->|Layer1+2+4履约段| C[订单快照]
  CH[收银台] -->|Layer1-4 全量| D[支付快照]
  Pay[支付] -->|读快照校验| E[渠道扣款]
  A -.->|规则版本对齐| B
  B -.->|强一致重算| C
  C -.->|增量或 diff 门禁| D
  D -.->|禁止全量暗算| E

时间维度的一致性常被忽略:活动配置可能在用户浏览与创单之间切换生效状态,因此仅有「价格」数值不够,还要记录解释价格的规则时间戳。另一个角度是货币与税费:跨境场景下 PDP 可能只展示本币参考,创单必须锁定报关与税费口径,避免支付阶段因汇率刷新产生合规争议。

测试策略:应为每条主路径维护「黄金 JSON」——给定固定输入(商品、活动版本、用户身份、地址),期望输出快照哈希固定;任何引擎重构先跑黄金用例再灰度。对购物车预估与创单事实的差异,产品需定义可接受区间(如绝对值 ≤ 1 元或 ≤0.5%),超出即前端强提示,避免客诉升级。

场景主要 Layer是否生成快照典型 SLA 心态
PDP1 + 2极快、可缓存
购物车1 + 2(+3 预估)快、可部分预估
创单1 + 2 + 4(部分)订单快照强一致
收银台1 + 2 + 3 + 4支付快照强一致
支付校验快照强一致、少 IO

收银台与创单的时序:常见用户路径是先创单再进收银台选券,因此支付快照往往晚于订单快照生成;若业务允许「未创单先预览收银台」,则要定义预览快照不落库或落短 TTL 缓存,避免用户反复刷新产生大量孤儿快照占满存储。另一个易错点是部分失败:创单成功但写快照失败时,必须有补偿任务阻断支付或自动关单,否则会出现「订单存在却无快照」的不可恢复状态。


11.7 系统边界与职责

11.7.1 计价系统的职责边界

计价中心负责

  • 统一编排各层价格;
  • 输出明细与快照版本;
  • 金额校验、尾差、分摊与安全阈值(如最大折扣率)。

不负责

  • 营销活动配置与圈品 CRUD;
  • 券的发放与库存扣减(由营销执行),计价仅消费「已锁定 / 已选中」结果;
  • 支付路由与渠道签约。

11.7.2 计价 vs 营销:谁算什么

维度营销系统计价系统
规则定义✅ 活动、券模板、互斥叠加
最优券搜索(可选)✅ 或协同推荐服务可消费候选集
金额编排提供命中规则与减免额✅ 汇总为价格事实
执行扣减✅ 锁定 / 核销

边界口诀:营销回答「能不能用、用哪条」;计价回答「用了以后多少钱」

进一步细化:**「最优券推荐」**可以放在营销、推荐或独立优惠参谋服务里,但「用户已勾选某张券后的应付」必须由计价统一给出,避免前端本地算法与后端不一致。支付渠道立减有时由渠道 SDK 返回,计价需约定是「事前写入快照」还是「支付回调后补记账」——两种模式都能做,但不能混用两种口径于同一报表周期。

11.7.3 试算 vs 订单价格快照

  • 试算:可重复、可缓存、允许短暂不一致(需标注);
  • 快照:一次创单事实,不可变(修正走补差单、客服单等流程)。

11.7.4 基础价 vs 促销价 vs 支付价

  • 基础价:商品域维护的标价体系经 ACL 投影;
  • 促销价:营销规则作用后的价格带;
  • 支付价:在订单应付基础上叠加用户支付相关抵扣与手续费后的渠道实扣口径。

争议场景举例:若平台补贴在营销侧记账,但支付渠道又有「立减」,需要明确支付价是否含渠道补贴、财务对账时GMV 与实收各扣哪一段——这属于清结算域的规则,但计价必须在 PriceComponent 上打好 sourceledger_account 类标签,否则报表会对不齐。再如「部分退款是否回收满减」:这是营销与订单策略,计价提供按行分摊的实付结构即可支撑多种回收算法。


11.8 与交易链路各系统的集成

11.8.1 与商品系统集成(基础价读取)

商品中心提供 SPU/SKU 主数据、规格价、渠道价;计价通过 ACL 转为 Money 与可选的「划线价」展示字段。约定:商品系统不实现营销价,避免双源;若商品侧已有「日常售价」字段,应在数据字典中与计价的 Layer 1 对齐命名。批量接口应支持按 sku_id IN (...) 拉取,减少 N+1。

11.8.2 与营销系统集成(营销规则应用)

营销系统输出「命中了哪些活动、互斥关系、是否可叠加、券批次剩余」等;计价把这些投影为 PromotionOffer 再进入 Layer 2。锁定 / 核销仍由营销执行:创单前调用营销「预占」,失败则整单创单失败;计价只消费预占成功后的面额事实。若营销 RPC 慢,优先考虑异步刷新购物车缓存而非缩短创单超时。

11.8.3 与 PDP 集成(加购试算)

PDP 网关调用 GetItemPrice 类接口,应带齐 regionplatform、用户分群键;CDN 上只能缓存匿名价时,登录态价需回源或边缘二次请求。对 SEO 落地页,注意缓存穿透:热门失效 SKU 要有布隆过滤或空值短缓存。

11.8.4 与购物车集成(实时试算)

购物车服务维护行表,计价侧接收「行快照 + 指纹」;指纹变化(数量、选中券)即缓存失效。购物车合并(登录前后)要以服务端合并结果为准重新试算,避免客户端本地算价。

11.8.5 与结算系统集成(确认价格)

结算编排地址、配送方式、可用券列表,调用计价生成支付快照;结算页展示的每一项优惠,都应在快照明细中有对应 component_id,方便客服追溯。

11.8.6 与订单系统集成(创单金额计算)

订单系统保存 order_snapshot_id 与行级分摊明细;后续改价(客服改运费)应走订单变更流程并生成新快照或差值单,而不是直接 UPDATE 金额字段。订单取消释放营销锁时,计价一般不参与,但要保证幂等释放

11.8.7 与支付系统集成(支付金额校验)

支付创建时上传 snapshot_id 与应付总额;支付核心对比快照与渠道金额(含外币换算规则)。重复支付回调通过支付单号幂等;部分支付、合并支付等高级场景要在协议层约定快照粒度(整单 vs 子单)。

11.8.8 集成调用链路与时序

下图刻意省略了库存、地址、风控等横向调用,只保留价格相关主干,便于新人建立心智模型;真实链路可用同一 trace_id 把多次计价调用(结算预览、创单、支付校验)串成一棵树,观察是否出现「同一次用户操作重复计算三次」的浪费——若有,应通过快照传递减少重复扇出。

以下以「用户从结算提交支付」为例展示典型同步调用(简化):

sequenceDiagram
  participant U as 用户端
  participant Ch as 结算系统
  participant P as 计价中心
  participant I as 商品中心
  participant M as 营销系统
  participant O as 订单系统
  participant Pay as 支付系统

  U->>Ch: 确认结算页
  Ch->>P: GetCheckoutPrice(行项目, 券, 渠道)
  P->>I: 批量基础价
  P->>M: 活动命中 + 券试算
  P-->>Ch: 支付快照 snapshot_id
  Ch->>O: 创单(带 snapshot_id)
  O->>P: 可选:校验 / 冻结快照
  O-->>Ch: order_id
  Ch->>Pay: 发起支付(order_id, 金额, snapshot_id)
  Pay->>P: GetPaymentPrice 校验
  P-->>Pay: OK / 差额拒绝
  Pay-->>U: 收银台支付

11.8.9 降级与容错策略

  • 计价依赖故障时,交易路径默认 fail-fast;展示路径可降级;
  • 重试需配合幂等键避免双快照;
  • 全链路 trace id 贯通,便于按 snapshot_id 定位规则版本与下游返回。

超时配置建议:PDP 调用链应「短超时 + 部分降级」,创单链可「较长超时 + 严格失败」。熔断打开时,要有人工开关把流量切到备用集群或旧版本引擎,而不是无限重试。对供应商报价,超时后是否允许用缓存价创单属于业务决策:机票酒店类往往不允许,实物自营类可能允许——决策应写在品类策略配置里而不是写死在代码分支。

审计与合规:计价日志应能重建「当时为什么是这个价」,包括各层输入输出哈希;日志中避免打印完整用户 PII,但需保留 user_idorder_id 关联键。对外部监管或商家对账,常导出快照明细而非实时重算结果。


11.9 工程实践

11.9.1 性能优化

  • 分层埋点:每层耗时、RPC 次数、跳过率;
  • 批量接口上限(如 100 SKU)与背压;
  • 大促前预热与限流按 scene + category 维度配置。

除指标外,建议在引擎内建自适应批大小:当单次购物车行数超过阈值时自动拆批并发,再合并结果,防止单次请求拖垮 GC。对 Go 服务,注意 context 超时传递:上游取消时应中断未完成的供应商调用。内存方面,PricingState 可能持有大切片,必要时在返回后显式重置对象池复用缓冲区,降低大促分配压力。

11.9.2 监控告警

  • 空跑 diff 金额 / 比例阈值告警;
  • 快照校验失败率;
  • 供应商报价失败与降级占比。

告警应区分用户可感知失败(创单失败率)与后台差异(空跑 diff)。对后者可采用采样 + 自动建 JIRA/工单。另建议监控 「创单成功但快照写入失败」 这类罕见组合——往往来自数据库半成功状态,需要补偿任务修复。

11.9.3 故障处理

  • 回滚:灰度开关切回旧引擎;快照已落库则不以新逻辑改写历史
  • 数据修复:通过补差、退款重算由财务域流程驱动,而非直接改库内金额。

演练层面,每季度做一次**「营销配置误发」桌面推演**:若运营错误配置了叠加券,计价能否通过安全校验器拦截?若不能,规则引擎侧也要有发布前仿真。事故后复盘要输出「哪一层本应挡住」的改进行项,而不是只修数据。


11.10 本章小结

本章从交易链路视角定义了计价中心的定位:以四层价格模型统一基础、营销、抵扣与费用语义,用场景驱动的责任链控制计算深度与性能,并以 DDDPrice 聚合、Money / PriceLayer 值对象与领域服务结合,保障边界清晰快照一致。与营销系统的分工上,应坚持「营销定义规则与执行权益,计价产出可审计的价格事实」。落地时务必配套幂等、分摊、空跑比对与可观测性,才能在复杂促销与高并发下同时满足体验与资损防控。

延伸阅读建议:读完本章可对照第 9 章营销系统边界与第 14 章订单价格快照设计,把「试算 → 创单 → 收银台 → 支付」四个时间点的口径表画在团队 wiki 上,作为跨团队评审的检查清单。实现上新加一层价格或一类费用时,先更新该表,再写代码,能显著降低联调返工。

落地检查清单(摘录):① 各场景 skipLayers 与产品 PRD 是否逐条签字;② 快照表是否支持版本与只追加;③ 金额是否全链路整数分;④ 分摊单测是否覆盖「末行尾差」与「单行边界」;⑤ ACL 是否禁止领域层引用外部 proto;⑥ 空跑比对是否在灰度期全量开启;⑦ 支付校验失败是否有客服可查的 snapshot_id 与规则哈希。团队可在发版前用此清单做十分钟走查,把「架构上正确」落实为「发布时可控」。

术语说明:文中 PDP 指商品详情与导购详情类页面;创单指订单创建请求在服务端落库的关键步骤;收银台泛指用户确认支付前选择券、积分与支付方式的交互阶段。不同公司团队命名可能为 Checkout、Cashier 或 Payment Preview,本书统一以「收银台」称呼,重在语义阶段而非具体页面 URL。

与博客原文的映射关系:四层模型与场景跳过表对应系列第五篇中的分层示例与 getSkipLayers 思路;统一语言、子域划分、聚合与防腐层示例对应第六篇中的战略 / 战术设计。本书将两篇合并为交易链路章节写法,删去了部分供应商 YAML 配置与大段业界对比,读者若需查阅更长的配置样例与演进史,可回到博客原文细读。

稿约说明:本章正文汉字约一万字以上(随修订浮动),配套多幅 Mermaid 图与若干 Go 代码块;若纸质排版,请注意图宽与代码等宽字体设置,以免版心溢出。引用的接口名为示例,与任何生产代码仓库无强制对应关系。

编辑备忘:仓库中对应原文为 24-ecommerce-pricing-engine.md25-ecommerce-pricing-ddd.md(若他处写作「25-ecommerce-pricing-practice.md」,以仓库内 25-ecommerce-pricing-ddd.md 为准)。合并时已将 DDD 篇中的事务脚本反例与上下文映射图转写为本书叙述体例。后续若博客原文更新,请同步修订本章「走数示例」与 skipLayers 对照表,以免书籍与线上实现漂移。读者若在落地中遇到本章未覆盖的品类,可沿用「先补 Calculator、再补 Layer 子处理器」的扩展顺序,避免破坏四层语义的一致性。批量计价接口建议对请求体大小与 SKU 个数双限流,并在响应头返回 X-Pricing-Partial: true 以标记降级后的不完整结果,便于上游决定是否重试或裁剪展示。此做法在搜索 Hydrate 与推荐列表页尤为实用,可作为默认工程约定。


导航书籍主页 | 完整目录 | 上一章 | 下一章

导航书籍主页 | 完整目录 | 上一章 | 下一章


第12章 搜索与导购

本章定位:搜索与结构化导购(类目列表、店铺内浏览)是电商平台最主要的 读流量入口 之一,直接影响转化与 GMV。本章在「统一导购查询服务」主叙事下,串起 Query → Recall → Rank → Hydrate 全链路,并以 Elasticsearch 作为召回与粗排主引擎,厘清与商品中心、计价、库存、营销、推荐等系统的 边界与契约。内容基于《电商系统设计(十二):搜索与导购》扩展,面向中大型团队的工程落地与系统设计面试。

阅读提示:若你习惯把「列表页」简单等同于「查 ES 返回 JSON」,建议带着三个问题读完全章——第一,搜索与导购在产品目标上与推荐有何不同;第二,哪些字段必须进索引、哪些必须 Hydrate;第三,索引滞后与 Hydrate 超时时,列表弱一致如何不与交易强一致冲突。搞清这三点,就能把读路径工程化讲清楚,而不是停留在「调个 DSL」的层面。文中 Go 示例为教学裁剪版,落地时请补全观测、注入与错误包装。


12.1 系统定位

12.1.1 搜索与导购的区别

在日常口语里,「搜索」「列表」「导购」常被混用;在架构文档里,建议用 用户意图约束形态 区分:

维度关键词搜索(Search)结构化导购(Browse / Merchandising)
用户输入显式 query,可能含糊、多义通常无文本 query,或 query 为辅助
主约束文本相关性 + 硬 filter类目 / 品牌 / 店铺 / 多维筛选项
失败体验零结果、纠错、同义词空列表、Facet 互斥错误
典型 scenekeywordcategoryshop
引擎侧重分析链、改写、BM25 / 向量(可选)聚合导航、稳定排序、强 filter

二者在工程上应 共享同一套流水线内核(召回 → 排序 → Hydrate),否则极易出现「同一批商品在搜索与类目列表排序不一致」的线上事故。产品层面,搜索偏意图检索:用户带着问题来,系统要回答「最相关的候选集」;导购偏可控陈列:运营希望用户在既定类目树与筛选体系内高效浏览。推荐系统(Feed)则偏 个性化发现,目标函数、特征 freshness、在线学习与搜索不同,本章在 12.5.2 单独划界。

12.1.2 核心挑战

挑战根因设计方向
相关性同义词多、类目错挂、拼写噪声可控词典与改写 + 埋点闭环
列表价与索引不一致促销、会员、渠道价变化快于索引Hydrate + 产品话术;结算强一致
高并发读大促与热搜集中ES 扩展、缓存、限流、降级
深分页from/size 成本随页数上升search_after + 产品限制
跨系统编排Hydrate 依赖多、尾延迟叠加并发上限、独立超时、部分降级
索引与主数据漂移异步链路、至少一次消费幂等 version、对账与补偿任务

与订单、支付等 写路径 不同,导购链路往往 QPS 高、容忍短暂最终一致,但必须处理好 相关性、价格与库存展示口径、营销露出、以及索引滞后 带来的用户预期落差。详情页应以 商品中心读模型 为准做强一致或近实时;列表页承认 弱一致,并在创单 / 结算阶段由库存、计价、营销再次校验。

组织协作 视角看,导购链路往往是「商品、搜索、推荐、营销、前端、数据」多条职能线的交汇点:任何一方在接口里多塞一点排序逻辑,短期能加快需求交付,长期会把 归因、回滚、实验 变成不可能任务。因此本章反复强调 统一查询内核 + 版本化 rank,并不是架构洁癖,而是把 变更半径 收敛到可治理的边界内。另一个常被低估的协作点是 口径对齐:运营口中的「到手价」、产品文档里的「列表价」、计价服务返回的字段名,若不能在数据字典层统一,Hydrate 再快也只能放大混乱。

风险 视角看,导购事故通常不是「ES 挂了」这种单点,而是 组合型:索引滞后叠加 Hydrate 超时,再叠加前端把「展示价」当成「下单价」渲染,最终在社交媒体被放大成「平台偷偷涨价」。工程上要用 字段语义 + UI 文案 + 快照校验 三道闸兜底;单纯优化 ES 延迟并不能消灭这类问题。

12.1.3 系统架构

下图给出 搜索与导购 在全局中的位置:写入侧 不拥有商品主数据,仅消费事件维护 派生索引读取侧 负责召回与排序,卡片动态字段由 Hydrate 编排 多系统补齐。

graph TB
    subgraph UserLayer["用户层"]
        User[商城用户 Web/App]
    end

    subgraph Gateway["接入层"]
        APIGateway[API Gateway<br/>鉴权/限流/路由]
    end

    subgraph SearchDiscovery["搜索与导购域"]
        MQS[导购查询服务<br/>Query/Recall/Rank/Hydrate]
        IndexWorker[索引构建 Worker<br/>消费事件/幂等更新]
    end

    subgraph CoreStorage["核心存储"]
        ES[(Elasticsearch<br/>商品索引)]
    end

    subgraph WriteServices["写入侧数据来源"]
        ProductCenter[商品中心]
        ListingService[上架系统]
        LifecycleService[生命周期/审核]
        BOpsPlatform[B 端运营]
    end

    subgraph ReadServices["Hydrate 依赖"]
        PricingRead[计价只读]
        InventoryRead[库存摘要]
        MarketingRead[营销标签/圈品]
        OpConfig[运营配置/加权]
    end

    subgraph MessageBus["消息总线"]
        Kafka[Kafka / MQ]
    end

    User --> APIGateway --> MQS
    MQS --> ES
    MQS -.-> PricingRead
    MQS -.-> InventoryRead
    MQS -.-> MarketingRead
    MQS -.-> OpConfig

    ProductCenter --> Kafka
    ListingService --> Kafka
    LifecycleService --> Kafka
    BOpsPlatform --> Kafka
    Kafka --> IndexWorker --> ES

关键边界:搜索索引存放 相对静态或可容忍滞后 的字段(标题、类目、上架状态、部分排序特征);易变字段(展示价、库存紧张度、活动标)优先 Hydrate,或在索引中以「粗粒度信号 + 版本」形式存在并与 Hydrate 对齐。

非功能需求 上,建议把导购链路的 SLO 拆成「可分别报警」的三段:ES 召回 P99应用内排序 P99Hydrate 端到端成功率。很多团队只监控入口延迟,结果线上表现为「整体还不慢」,但 Hydrate 超时率缓慢爬升,直到大促才被计价或库存的连接池打爆一次性暴露。更稳妥的做法是把 Hydrate 每个依赖的 超时次数、空返回比例、批量大小分布 都做成 TopN 维度,并在压测脚本里显式模拟「半数依赖降级」。

容量估算(面试与立项常问)可以按乘法拆解:峰值 QPS × 每页条数 ×(Hydrate 下游 RPC 数)≈ 下游扇出;再叠加 重试风暴 系数(建议保守取 1.3~1.8)。ES 侧则关注 分片热点(超大店、超级品牌)与 聚合查询 对 CPU 的抢占;必要时对 shop 场景做 单独索引或单独副本组,把热点从全站检索隔离出去,成本换稳定性。


12.2 统一导购查询服务

12.2.1 场景识别(scene 设计)

对外推荐 单一主叙事导购查询服务(Merchandising Query Service) 暴露统一查询接口,用 scene 区分业务语义;内部共享 Query → Recall → Rank → Hydrate 流水线。网关可做鉴权、限流与字段裁剪,但 不要把排序规则散落在多个 BFF 中。

scene用户输入典型 filter召回主索引
keyword关键词 + 可选类目 / 品牌上架可售、合规、店铺黑名单全站商品索引(或按站点分片)
category无关键词或空 query固定 category_id + 同上同上
shop可选关键词固定 shop_id + 同上店铺子索引或单索引强 filter

店铺维度实现二选一:独立索引别名(写入侧按 shop 路由,查询简单)或 单索引 + 强 filter(运维简单,超大店需关注分片热点)。scene 应进入 日志、追踪与实验分桶,与 query_idrank_version 一并贯穿。

// Scene 为导购域的稳定枚举,避免魔法字符串散落。
type Scene string

const (
    SceneKeyword  Scene = "keyword"
    SceneCategory Scene = "category"
    SceneShop     Scene = "shop"
)

type UnifiedQuery struct {
    Scene         Scene
    SiteID        string
    UserID        string // 可选,用于会员价 Hydrate
    RawQuery      string // keyword 场景必填;category 可空
    CategoryID    string
    ShopID        string
    Filters       []Filter // 品牌、价格带、属性等
    Page          PageCursor
    ExpID         string
    RankVersion   string
}

func RouteScene(req UnifiedQuery) (Scene, error) {
    if req.Scene != "" {
        return req.Scene, nil
    }
    switch {
    case req.ShopID != "":
        return SceneShop, nil
    case req.CategoryID != "" && strings.TrimSpace(req.RawQuery) == "":
        return SceneCategory, nil
    case strings.TrimSpace(req.RawQuery) != "":
        return SceneKeyword, nil
    default:
        return "", errors.New("unable to route scene: missing query/category/shop")
    }
}

12.2.2 查询编排

编排(Orchestration) 负责:scene 路由 → Query 理解 → 构建 ES DSL → 执行召回 → 粗精重排 → 触发 Hydrate → 合并 DTO。编排层应保持 无业务状态的纯函数倾向:依赖通过接口注入,核心流水线可单测。

type MerchandisingQueryService struct {
    QU   QueryUnderstanding
    ES   SearchClient
    Rank Ranker
    Hydr Hydrator
    CFG  OpConfigClient
}

func (s *MerchandisingQueryService) Search(ctx context.Context, req UnifiedQuery) (*SearchResult, error) {
    scene, err := RouteScene(req)
    if err != nil {
        return nil, err
    }
    qctx, err := s.QU.Normalize(ctx, scene, req)
    if err != nil {
        return nil, err
    }
    recall, err := s.ES.Recall(ctx, qctx)
    if err != nil {
        return nil, err
    }
    ranked := s.Rank.Score(ctx, req, recall)
    reranked := s.Rank.Rerank(ctx, req, ranked, s.CFG) // 运营配置、打散、强插
    cards, err := s.Hydr.Hydrate(ctx, HydrateRequest{
        Scene:       scene,
        UserID:      req.UserID,
        DocIDs:      reranked.IDs(),
        RankVersion: req.RankVersion,
    })
    if err != nil {
        // Hydrate 全局失败应极少:通常部分降级
        return nil, err
    }
    return AssembleResult(reranked, cards), nil
}

演进注记:何时拆 BFF。当「列表卡片组装」与「搜索实验」发布节奏被不同团队强绑定时,常见折中是把 Hydrate 后的视图组装 下沉到 BFF,但 排序分数、实验桶、rank_version 仍应由导购查询内核产出并透传。否则会出现「实验只在 App 搜索生效、H5 列表不生效」的割裂,排查时日志还对不齐。另一个反模式是把 ES DSL 拼接散落在多个网关插件里:短期看似减少了一次 RPC,长期 DSL 变更无法回归测试,零结果率 波动也无法定位是改写问题还是索引问题。

编排层还应内置 最小可观测上下文query_id 应在进入 QU.Normalize 之前生成,并注入到 ES 查询注解(如 preference / custom header)与下游 RPC metadata 中,保证一次用户请求能在日志系统里 串起全链路。若你们使用 OpenTelemetry,建议把 scenerank_versionexp_id 作为 span attributes,而不是塞进自由文本日志。

12.2.3 结果聚合

结果聚合 关注三类合并:

  1. 多路召回合并(如关键词 BM25 + 可选向量):需 quota、去重、延迟预算;MVP 常单路 ES。
  2. 排序分与业务字段合并:ES _score、销量、上新等与 Hydrate 返回的展示价、库存标合并为统一 DTO。
  3. Facet 与列表一致性:侧边栏聚合必须与当前 filter 同一 query 范围,否则出现「互斥筛选仍显示有货计数」的体验问题;大流量下可 异步加载 facet近似聚合

对外响应建议显式携带 partialindex_versionprice_as_of**(时间戳),便于客诉定位与前端提示「价格以结算为准」。

多路召回合并(例如 BM25 + 向量)在工程上要提前写清 配额策略:两路各取多少、按什么键去重、合并后是否二次截断。没有配额时,最常见事故是「向量路召回大量泛化商品」把关键词路的相关性稀释掉,表现为 CTR 下降但延迟上升。若团队尚未建立向量索引运维与回放体系,MVP 阶段更建议 单路 ES + 强词典,把复杂度留给数据运营而不是平台第一天的 midnight。

Facet 与列表一致性 的实现细节是:用户每点击一次筛选,服务端应以 同一套 UnifiedQuery 生成 ES 请求体,其中 post_filteraggs 的嵌套关系必须遵循「先算子集再聚合」的语义。很多初版实现为了省事,把 facet 请求拆成第二次查询,若不在客户端做强一致串行,会出现 列表已空但 facet 仍显示有货 的短暂撕裂。工程上更推荐 单次 ES 往返(列表 + facet)或在产品层声明「facet 异步刷新」并做骨架屏。

DTO 稳定性:导购接口是前台最高频契约之一,字段增删应走 版本化 JSON schema 或 protobuf 的向后兼容规则。特别是 partial 语义一旦上线,就不应在无迁移的情况下改变含义(例如从「仅价格缺失」扩展成「任意字段缺失」),否则前端埋点与客服话术会同时失效。


12.3 主链路:Query → Recall → Rank → Hydrate

主链路是本章的「脊柱」。下图给出 端到端数据流(含实验与运营配置注入位点):

flowchart TB
    subgraph In["输入"]
        REQ[UnifiedQuery<br/>scene/filters/page/exp]
    end

    subgraph Q["Query 理解"]
        NORM[归一化/词典]
        RW[改写与同义词]
        TAG[intent_tags]
    end

    subgraph R["Recall 召回"]
        DSL[ES DSL 组装]
        ES[(Elasticsearch)]
    end

    subgraph P["Rank 排序"]
        COARSE[粗排截断 M]
        FINE[精排到 Top K]
        RERANK[重排:打散/合规/强插]
        CFG[运营配置 OpConfig]
    end

    subgraph H["Hydrate"]
        BATCH[批量并行获取]
        PRICE[计价只读]
        INV[库存摘要]
        MKT[营销标签]
        PC[商品读/主图标题补全]
    end

    subgraph Out["输出"]
        DTO[列表 DTO + partial 标记]
    end

    REQ --> NORM --> RW --> TAG --> DSL --> ES --> COARSE --> FINE
    CFG --> RERANK
    FINE --> RERANK --> BATCH
    BATCH --> PRICE
    BATCH --> INV
    BATCH --> MKT
    BATCH --> PC --> DTO

12.3.1 Query 理解

目标不是通用 NLP 搜索引擎,而是 可控、可解释、可回归

  • 归一化:全半角、大小写、去噪字符、重复空格。
  • 同义词 / 类目词典:运营可配表驱动;变更走 版本号,与排序实验解耦。
  • 拼写纠错:可选;需 限流 + 白名单,避免引入合规或品牌风险。

输出物建议固定为:normalized_queryintent_tagsrewrites[](有限条数),供 DSL 组装与埋点。

工程落地建议:把 Query 理解的输出定义成 不可变结构体(或值对象),并在日志里同时打印 raw_querynormalized_query,但注意隐私合规(手机号、地址片段误入搜索框并不少见)。改写表(同义词、类目映射)应支持 灰度发布:先 shadow 记录「若启用改写将变成什么」,再按桶启用,避免运营配置错误导致大面积零结果。

与「大模型改写」的边界:生成式改写很诱人,但在电商场景要先回答 责任归属:改写后的 query 若召回违规商品,谁承担合规责任?更稳妥的路径通常是 受控词典 + 小模型 / 规则纠错,把 LLM 放在离线挖掘与运营辅助,而不是在线默认链路的第一跳。

12.3.2 召回策略

召回阶段输出 候选 doc 列表(通常为 SPU 或展示单元 ID)及 ES 内已可用的排序分量。不要在召回阶段做重 CPU 的跨系统调用。硬条件(上架状态、类目、店铺、站点)应优先放在 filter 上下文以利用缓存与免评分。

bool 查询语义 是面试高频点:must 参与评分,适合承载关键词相关性;filter 不计分且可缓存,适合承载「硬门槛」。实践中常见错误是把「品牌=耐克」放在 must 里参与打分,导致品牌词意外影响相关性曲线;更推荐 品牌进 filter,把「品牌相关 boost」交给 function_score 或在精排阶段处理。另一个错误是把大量 低选择性 条件全部堆在 must,使 _score 退化为常数,精排阶段只能「白手起家」——这会放大后续服务压力。

召回截断 需要与后续粗排预算对齐:若 ES size 直接取 2000 返回全字段,网络与反序列化会先拖垮应用。更常见做法是 ES 侧 只回 id 与排序必要字段docvalue_fields / _source: false),把重字段留给 Hydrate 或商品读服务。对于「店铺内搜索」这类可能触发热点店铺的场景,可在 DSL 增加 routing 或独立索引,把查询分散到更小分片集合上。

可选扩展:向量召回。若引入向量,需要同步建设 向量更新延迟、ANN 参数、召回评测集 三件事;否则极易出现「文本搜得到、向量搜不到」的双轨撕裂。多数业务在规模化前,同义词 + 类目意图 + 运营纠错 的投入产出比更高。

12.3.3 粗精排序

阶段典型输入典型输出说明
粗排ES 召回前 N(如 500~2000)截断到 M(如 200)_score + function_score(销量、上新衰减等)
精排M 条 doc idTop K(如 50)转化率预估、价格带、店铺分;LTR 可替换此阶段
重排K 条页大小 P多样性、类目打散、疲劳度、合规过滤、运营强插

合规默认值明确违法禁售 应在索引写入侧即不可召回;审核「灰区」更适合 召回 filter;最后一道 重排后、返回前 再过滤,避免已排序商品在末尾被剔除导致 空洞位

AB 与配置版本(最小集)exp_id 贯穿日志;rank_version 绑定权重 / 规则 / 模型版本可快速回滚;query_id 关联 ES 与 Hydrate 子调用。发布建议 shadow traffic 双写日志对比,再按桶放量;与计价、营销大促窗口 错峰改排序,避免归因困难。

粗排放在 ES 还是应用内 没有银弹:ES 内 function_score 的好处是 少一次数据搬运;坏处是调试困难、权重爆炸、且与「精排模型特征」割裂。常见折中是:ES 负责 硬过滤 + 文本相关 + 少量可解释加权;应用内精排负责 复杂特征交叉业务规则解释。无论选哪条路径,都要保证 同一套 rank_version 能在离线回放数据集上复现,否则线上调参只能靠运气。

重排的业务含义 需要写清:多样性(同店铺打散、同品牌打散)、疲劳度(用户反复看到同一 SPU)、运营强插(置顶资源位)都属于「非相关性目标」,若不与相关性分层,就会出现「搜牙刷全是运营想卖的电器」。工程上建议把重排规则 配置化 + 可视化回归,并在报表里同时看 CTR 与 投诉率 / 零结果率

12.3.4 Hydrate 编排

列表卡片常需:展示价、原价划线、库存状态、营销标、店铺名。变化快于索引刷新时,必须由 Hydrate 补齐。契约建议:入参 doc_ids[](上限如 50)、user_id(可选)、scenerank_version;出参为 map[id]CardEnrichment,缺失键表示单卡失败。

Hydrate 编排 强调:批量、限时、可降级、可观测。下图描述 并行依赖超时隔离(示意):

flowchart LR
    subgraph Req["HydrateRequest"]
        IDS[doc_ids 上限 N]
    end

    subgraph Orch["编排器 Hydrator"]
        SPL[拆分批次/限流]
        EG[errgroup 并发池]
        MERGE[合并 map 结果]
        DEF[缺省策略/占位]
    end

    subgraph Deps["下游只读依赖"]
        A[计价批量]
        B[库存摘要批量]
        C[营销标签批量]
        D[商品读批量]
    end

    IDS --> SPL --> EG
    EG --> A
    EG --> B
    EG --> C
    EG --> D
    A --> MERGE
    B --> MERGE
    C --> MERGE
    D --> MERGE
    MERGE --> DEF
type CardEnrichment struct {
    ListPriceCents   *int64 // 展示价(分);nil 表示 Hydrate 未取到
    StrikePriceCents *int64 // 划线价(分)
    StockLevel       string // 如 IN_STOCK / LOW / UNKNOWN
    PromoTags     []string
    Title         string
    MainImageURL  string
}

type HydrateRequest struct {
    Scene       Scene
    UserID      string
    SiteID      string
    DocIDs      []int64
    RankVersion string
}

type Hydrator struct {
    Pricing  PricingBatchClient
    Inv      InventoryBatchClient
    Mkt      MarketingBatchClient
    Product  ProductBatchClient
    Parallel int
    PerDep   time.Duration
}

func (h *Hydrator) Hydrate(ctx context.Context, req HydrateRequest) (map[int64]CardEnrichment, error) {
    g, ctx := errgroup.WithContext(ctx)
    if h.Parallel > 0 {
        g.SetLimit(h.Parallel)
    }

    out := sync.Map{} // map[int64]CardEnrichment

    run := func(fn func(context.Context) map[int64]CardEnrichment) {
        g.Go(func() error {
            cctx, cancel := context.WithTimeout(ctx, h.PerDep)
            defer cancel()
            partial := fn(cctx)
            for id, card := range partial {
                v, _ := out.LoadOrStore(id, CardEnrichment{})
                base := v.(CardEnrichment)
                out.Store(id, mergeCard(base, card))
            }
            return nil // 单依赖失败不失败整页:在 fn 内部吞错
        })
    }

    run(func(c context.Context) map[int64]CardEnrichment {
        m, _ := h.Pricing.BatchListPrices(c, req.SiteID, req.UserID, req.DocIDs)
        return m
    })
    run(func(c context.Context) map[int64]CardEnrichment {
        m, _ := h.Inv.BatchStockSummary(c, req.SiteID, req.DocIDs)
        return m
    })
    run(func(c context.Context) map[int64]CardEnrichment {
        m, _ := h.Mkt.BatchPromoTags(c, req.SiteID, req.UserID, req.DocIDs)
        return m
    })
    run(func(c context.Context) map[int64]CardEnrichment {
        m, _ := h.Product.BatchCardFields(c, req.SiteID, req.DocIDs)
        return m
    })

    _ = g.Wait()

    merged := make(map[int64]CardEnrichment, len(req.DocIDs))
    for _, id := range req.DocIDs {
        if v, ok := out.Load(id); ok {
            merged[id] = v.(CardEnrichment)
        }
    }
    return merged, nil
}

func mergeCard(a, b CardEnrichment) CardEnrichment {
    // 教学示例:按字段非空合并
    if b.ListPriceCents != nil {
        a.ListPriceCents = b.ListPriceCents
    }
    if b.StrikePriceCents != nil {
        a.StrikePriceCents = b.StrikePriceCents
    }
    if b.StockLevel != "" {
        a.StockLevel = b.StockLevel
    }
    if len(b.PromoTags) > 0 {
        a.PromoTags = append(a.PromoTags, b.PromoTags...)
    }
    if b.Title != "" {
        a.Title = b.Title
    }
    if b.MainImageURL != "" {
        a.MainImageURL = b.MainImageURL
    }
    return a
}

Hydrate 与「列表一致性」:当某个 SPU 在 ES 中仍存在,但商品中心已下架或不可售时,应以 商品读返回的状态 为准做最终过滤,并在必要时 剔除该位显示不可用(取决于产品策略)。这意味着 Hydrate 不只是「加字段」,也可能反向改变 可展示集合;若发生剔除,需要在前端处理 页大小不足 的补位逻辑(例如自动补拉一条),否则会出现末尾空洞。

连接池与超时:Hydrate 依赖往往共享连接池,若列表 QPS 高且每页 50 条,极易把下游 最大并发 顶满。除了限制 Parallel 与批量大小,还应对 同一用户 做轻量节流(例如滑动窗口),防止脚本或异常客户端发起「并发多页请求」放大扇出。


12.4 Elasticsearch 专题

与商品中心的分工:索引字段清单、nested 取舍、商品变更如何进索引,以商品中心相关章节为权威叙述。本节聚焦 查询侧契约、典型 DSL、深分页与性能调优,并给出 索引生命周期与 mapping 视角 的架构图。

12.4.1 索引设计

索引设计 要在「召回质量」「写入吞吐」「运维成本」三者间折中。下图从 写入投影查询路径 两侧展示(与 IndexWorker 呼应):

flowchart LR
    subgraph Sources["领域事件源"]
        P[商品中心]
        L[上架状态]
        C[生命周期/审核]
        O[运营批量]
    end

    subgraph Pipe["索引管道"]
        MQ[Kafka]
        W[IndexWorker<br/>幂等/version]
        BULK[Bulk Processor]
    end

    subgraph Index["ES 索引族"]
        ALIAS[read_alias 指向物理索引]
        IDX[(product_search_vN)]
    end

    subgraph Query["查询侧"]
        DSL[bool + filter + sort]
        FACET[aggs 导航]
    end

    P --> MQ
    L --> MQ
    C --> MQ
    O --> MQ
    MQ --> W --> BULK --> IDX
    ALIAS --> IDX
    DSL --> ALIAS
    FACET --> ALIAS

文档建模的两种典型切分:一是以 SPU 为展示单元(服装、标品多规格常如此),SKU 维度属性用 nested 或扁平化字段表达;二是以 SKU 为展示单元(强价格/库存差异的品类),索引文档更细但写入放大。切分没有绝对正确,关键是 列表页用户心智下单单元 一致:若用户认为自己在买「一款多规格商品」,却以 SKU 文档展示,容易出现 重复占位排序抖动(同一 SPU 多个 SKU 同时出现在列表)。切分确定后,Hydrate 的 doc_ids 语义也要固定,否则计价批量接口的入参会频繁返工。

nested 的决策树(落地版):当查询必须表达「父文档条件 ∧ 子文档条件」且无法通过扁平化字段无损表达时,才引入 nested;否则优先 写入侧展开(例如把可检索属性汇总到父级 attrs.searchable)以降低查询成本。nested 还会让 聚合 更复杂:facet 若需要 SKU 级分布,必须清楚产品是否真的需要,很多类目列表只需要 SPU 级导航。

mapping 要点(查询视角)

实践说明
筛选 / 聚合 / 排序字段优先 keyword 或数值类型,保证 doc_values
全文检索text + 子字段 keyword 谨慎用于排序
nested仅当 SKU 级属性必须父子联合约束时使用;滥用会放大成本
反模式对大文本无意义排序;高基数深度聚合默认全开

12.4.2 查询 DSL 模式

分析链与中文分词:索引与查询使用 同一分析链(或查询链为索引链的有意子集)。filter 不参与评分且可缓存,适合 站点、上架状态、类目、店铺、价格区间 等硬条件。

{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "无线耳机",
            "fields": ["title^3", "brand^2", "attrs.searchable"],
            "type": "best_fields"
          }
        }
      ],
      "filter": [
        { "term": { "site_id": "SG" } },
        { "term": { "listing_status": "ONLINE" } },
        { "term": { "category_id": "cat-3c-audio" } },
        { "range": { "list_price": { "gte": 50, "lte": 500 } } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "sales_30d": "desc" },
    { "spu_id": "asc" }
  ],
  "_source": false,
  "docvalue_fields": ["spu_id", "list_price", "shop_id"]
}

列表页应 裁剪 _source,避免返回大段正文。生产可用 docvalue_fields_source 组合权衡包大小。

高亮与摘要:高亮字段应控制在 title 等短字段;对大段描述开启高亮会显著增加响应体与序列化成本。若产品需要「摘要片段」,更推荐由商品中心提供 预生成摘要 或在索引中维护 short_description 的受控长度字段,而不是在查询时从正文动态截取。

聚合导航(facets)与筛选互斥:当用户选择 brand=A 后,其它 facet 的桶计数应基于「已选条件下的子集」重算,否则会出现互斥筛选仍显示「有库存计数」的错觉。实现上可以用 filter aggs、post_filter 组合,或在单次请求中拆成「列表 query」与「facet query」两段但由同一 UnifiedQuery 生成,避免前后端各自拼装 filter 造成漂移。

// Go 侧可用结构体拼装 DSL(示意字段,非完整客户端)
type BoolQuery struct {
    Must   []any `json:"must,omitempty"`
    Filter []any `json:"filter,omitempty"`
}

type SearchBody struct {
    Query  map[string]any `json:"query"`
    Sort   []any          `json:"sort,omitempty"`
    Size   int            `json:"size"`
    Source any            `json:"_source,omitempty"`
}

func BuildKeywordDSL(site, cat, q string, from, size int) SearchBody {
    return SearchBody{
        Query: map[string]any{
            "bool": BoolQuery{
                Must: []any{
                    map[string]any{
                        "multi_match": map[string]any{
                            "query":  q,
                            "fields": []string{"title^3", "brand^2", "attrs.searchable"},
                            "type":   "best_fields",
                        },
                    },
                },
                Filter: []any{
                    map[string]any{"term": map[string]any{"site_id": site}},
                    map[string]any{"term": map[string]any{"listing_status": "ONLINE"}},
                    map[string]any{"term": map[string]any{"category_id": cat}},
                },
            },
        },
        Sort: []any{
            map[string]any{"_score": "desc"},
            map[string]any{"sales_30d": "desc"},
            map[string]any{"spu_id": "asc"},
        },
        Size: size,
        Source: false,
    }
}

12.4.3 深分页问题

方式适用风险
from + size前若干页from 过大时全局排序,内存与延迟陡增
search_after深度翻页 / 连续浏览需稳定 sort key;不适合随机跳页
scroll离线导出、对账不适合 C 端高并发

面试标准答法:C 端深分页用 search_after + 稳定 tie-breaker(如 spu_id);随机跳页用产品约束(最多第 N 页)或改写交互。

search_after 的工程细节:sort 数组必须 全链路稳定,任何「仅用于展示」的字段都不应参与 tie-break,否则会出现翻页跳变。常见做法是 _score + 业务排序字段 + 主键升序。客户端需要缓存上一页最后一条的 sort 值;若中间发生 索引刷新 导致顺序变化,产品上要接受「轻微抖动」或通过 会话级快照(成本更高)解决。

随机跳页的产品替代:电商 C 端常见替代是「跳到第 N 页」改为「继续浏览 / 相似推荐 / 细化筛选」,把深分页需求转化为 更强的约束更相关的子集,既保护集群也提升转化。

12.4.4 性能调优

  • Profile:区分评分、聚合、function_score 热点。
  • 分片与副本:分片数与数据量、查询并发匹配;副本换读吞吐但写入放大。
  • 段合并与冷热:写入高峰观察 merge;冷热索引降副本或迁移。
  • function_score 粗排:销量 log1p、上新高斯衰减等可进 ES,注意权重爆炸与可调试性。

Facet 性能:限制桶数、min_doc_count;首屏列表优先,facet 可异步。Suggest(completion / search_as_you_type)应 单独限流,并与 Query 纠错 二选一主路径 以防延迟放大。

慢查询清单(发布前自检)

症状可能原因处理方向
P99 随页数线性变差from/size 深分页search_after + 产品限制
CPU 尖刺大聚合 / 高基数 terms降桶、采样、异步 facet
写入延迟升高分片过大 / merge 压力分片再规划、冷热分层
命中不稳定分析链不一致 / synonym 热更统一分析链 + 版本化词表
结果「看起来对但排序怪」function_score 权重叠加归一化与离线回放

索引别名与零停机切换:大版本 mapping 变更往往需要 reindex。生产上应使用 双写 / 回填 + read_alias 原子切换,并在切换后保留旧索引一段时间用于回滚与对账。切换窗口内还要特别注意 缓存层(CDN、应用本地缓存)是否仍指向旧索引版本,否则会出现「列表已新、详情仍旧」的短暂错觉。

运行时字段与脚本:能用 mapping 解决的不建议长期依赖脚本字段;脚本会把成本从索引时挪到查询时,且更难做 成本预算。若必须使用脚本,务必加 采样 profile熔断(例如限制每节点脚本编译频率)。


12.5 系统边界与职责

12.5.1 搜索系统的职责边界

搜索与导购系统应负责:

  • 派生索引的构建与查询(含增量、幂等、版本对齐)。
  • 召回与(可配置)排序,以及 列表读模型的编排(Hydrate)
  • 观测与实验 位点(query_idrank_versionexp_id)。

不应负责:

  • 商品主数据真相源、订单事务、营销算价与券扣减、库存预占。

12.5.2 搜索 vs 推荐:边界划分

维度搜索 / 导购推荐(Feed)
主信号query + 筛选意图用户行为序列与画像
目标相关性 + 平台规则下的转化发现与时长 / GMV 组合目标
失败模式零结果、相关性差信息茧房、疲劳
架构检索引擎 + 规则/LTR特征平台 + 在线排序 + 重排

二者可 共享埋点、特征与实验平台,但服务边界建议解耦,避免「搜索里偷偷塞推荐」导致可解释性与合规审计困难。

落地协作模式:推荐团队常希望复用搜索召回做「候选池」,这在技术上是可行的,但要把契约写清:候选池的版本、过滤条件、以及责任边界(例如禁售过滤由谁兜底)。更推荐的方式是推荐系统维护 自己的候选生成链路,在特征层复用搜索的 类目、品牌、文本 embedding 等中间产物,而不是在运行时强耦合调用搜索 HTTP 接口——否则搜索一旦降级,推荐会被连带拖死,故障半径不可控。

12.5.3 索引数据 vs 实时数据

数据类型放索引Hydrate / 实时读
标题、主图、类目、品牌可选补全
上架状态、站点强一致场景再二次校验
展示价、会员价粗粒度 / 索引价是(列表口径)
可售 / 紧张粗信号是(更准确)
活动标、圈品命中可缓存只读是(失败可降级)

12.5.4 召回 vs 排序 vs Hydrate 的职责

  • 召回:在高召回率前提下控延迟;避免跨系统调用。
  • 排序:可解释、可版本化、可回滚;重排承载运营与合规。
  • Hydrate:补齐易变展示字段;单卡失败不拖死整页

面试追问小结(边界类):「搜索是不是商品中心的一部分?」标准回答是 :商品中心是主数据真相源;搜索维护 派生读模型 以优化检索与排序特征。「为什么不在 ES 里算最终价?」因为价格解释依赖会员、渠道、动态规则与版本,索引天然滞后;列表允许弱一致,交易必须强一致。「推荐能否直接调用搜索接口拿候选?」不建议默认耦合:推荐候选集生成逻辑与搜索意图不同,但可以在 特征与埋点层复用


12.6 与上下游系统集成

12.6.1 与商品中心集成(索引数据来源)

商品中心提供 主数据与读模型版本;索引 Worker 消费 product.changed 等事件,比较 version / updated_at 后 bulk upsert。删除语义需显式:HARD_DELETE vs UNSEARCHABLE(保留文档但 filter 掉)。

批量读接口的契约:商品中心面向 Hydrate 的批量接口应返回 卡片级最小字段集(标题、主图、类目路径、店铺名等),并携带 content_version 便于与索引对齐。切忌让导购服务在列表场景调用「详情级大对象」接口,否则会把商品中心的 详情缓存击穿 间接变成搜索事故。

图片与多媒体:主图 URL 是否进索引取决于列表是否必须在 ES 故障时仍能展示基本内容;更常见是把图片放在 Hydrate,索引只存 image_id 或稳定 CDN key,避免 URL 频繁变更触发无意义 reindex。

func ApplyProductEvent(doc ProductDoc, evt ProductEvent) (bool, error) {
    if evt.Version < doc.Version {
        return false, nil
    }
    return true, UpsertES(doc.Merge(evt))
}

12.6.2 与计价系统集成(列表价 Hydrate)

计价只读接口建议 批量 + 站点 + 会员等级 维度;字段命名与 PDP / 结算 严格区分「列表价」与「应付价」,避免客户端误用。超时策略见 12.7.2。

会员价与未登录态:未登录用户可能只能看到「起售价」或「公开价」,此时 Hydrate 请求不应隐式携带会员身份;登录态切换时要小心 前端缓存 造成「登录后列表仍显示旧价」,可通过 price_as_of 或短 TTL 缓存失效解决。对「登录看价」类降级,建议同时返回 可解释的降级原因码(如 PRICING_TIMEOUT),便于埋点区分转化率下降根因。

12.6.3 与库存系统集成(可售状态)

列表展示 弱一致 库存摘要即可;下单前以库存服务 强校验 为准(与订单、库存章节衔接)。降级策略需业务拍板:偏保守利于防客诉,偏乐观利于转化。

12.6.4 与营销系统集成(活动标签)

营销在列表侧 只读展示:活动标、圈品是否命中;不算价、不锁券。资格与叠加仍以结算与创单为准。

活动标与合规:列表展示「满减」「券」等文案时,要避免暗示用户已领取或已满足门槛。活动标更像 广告露出,不是 权益状态;否则容易与营销执行域产生口径冲突并引发投诉。对敏感类目(医疗、金融类比商品),活动文案可能需要额外 合规审核字段 控制展示。

12.6.5 Hydrate 编排与降级策略

失败建议降级
计价超时展示索引价或「登录看价」
库存超时保守文案或 UNKNOWN,不断言有货
营销标签失败隐藏活动标,不影响下单资格判定
ES 集群故障短时返回缓存快照 / 简化查询 / 明确提示

批量契约建议:doc_ids 上限 20~60;并行度 4~16;单依赖超时 30~120ms;响应带 partial=true

列表请求时序(典型)

sequenceDiagram
    participant U as 用户
    participant G as Gateway
    participant M as 导购查询服务
    participant ES as Elasticsearch
    participant H as 计价/库存/营销只读

    U->>G: 搜索/列表请求 + query_id
    G->>M: 鉴权、限流、透传实验桶
    M->>M: Query 理解
    M->>ES: DSL 召回 + 粗排字段
    ES-->>M: hits + sort keys
    M->>M: 精排 / 重排
    M->>H: batch hydrate(限时)
    H-->>M: 部分成功 / 超时降级
    M-->>G: 列表 DTO + rank_version + partial
    G-->>U: 响应

索引更新时序(典型)

sequenceDiagram
    participant L as 上架/商品领域服务
    participant MQ as 消息总线
    participant W as 索引 Worker
    participant ES as Elasticsearch

    L->>MQ: 商品或上架状态变更事件
    MQ->>W: 至少一次投递
    W->>W: 幂等:比较 version
    W->>ES: bulk upsert/delete
    ES-->>W: ack

12.6.6 集成性能优化

  • 批量 RPC 合并网络往返;连接池按 QPS × 每页条数 估算。
  • 热门 SPU 短 TTL 缓存 + singleflight 防击穿;注意 个性化价 与缓存 key 冲突。
  • 重试 指数退避 + 用户维度熔断,避免拖垮计价 / 库存。

依赖拓扑与故障隔离:Hydrate 依赖建议按 关键路径分级:标题主图属于「强展示」;活动标属于「弱展示」;价格属于「强体验但可降级」。分级后可以定义 不同的超时与重试策略,避免弱依赖拖长尾。若使用服务网格,可为营销只读设置更小的超时与更激进的熔断,把尾延迟从全链路中剥离出去。


12.7 一致性与降级

12.7.1 索引延迟处理

现象:上架后短暂搜不到;改价后列表旧价。组合手段:详情强一致读商品中心;列表展示 数据时间戳 或「价格以结算为准」;大促关键池可走 强制刷新队列(与商品中心刷新策略对齐)。

运营活动窗口的同步策略:大促「清单商品」往往要求更高新鲜度,技术上可采用 活动商品白名单 + 更高优先级消费队列,甚至短时 双写直刷(写入路径旁路触发索引更新)。但要警惕:旁路越多,幂等与对账越复杂;因此白名单规模必须可控,并在活动结束后及时回收,避免把临时机制固化成永久债务。

12.7.2 降级策略

与 12.6.5 呼应:Hydrate 独立超时;ES 故障 缓存 / 简化查询;suggest 与主搜 配额隔离

12.7.3 缓存策略

  • 查询结果缓存:key 需含站点、筛选 hash、排序版本;个性化价场景慎用或细分桶。
  • Facet 缓存:更短 TTL 或异步;注意筛选变更失效。
  • 索引别名切换:蓝绿 reindex 后一次性切 read_alias,缩短双读不一致窗口。

索引延迟的「产品 + 技术」组合拳:除了技术手段(强制刷新队列、提高消费并行、热点分片治理),还需要 产品话术与 UI 引导:例如「刚刚上架,正在全网同步」或提供 直达详情 的链接入口。否则用户会把「搜不到」理解为「平台没货」,对转化伤害更大。对价格类客诉,客服工具应能输入 spu_id 查到 index_versionprice_as_of,否则只能复读「以结算为准」。

Hydrate 风暴的防护:当 ES 变慢时,应用层往往会 放大重试拉长等待,进而把计价与库存拖入雪崩。防护要点是:入口限流先于下游扩容;超时短于下游默认;失败快速返回 partial;并对同一 query_id 的重复提交做 去重(幂等键 + 短窗缓存)。


12.8 工程实践

12.8.1 性能优化

  • 网关按 用户 / IP / 设备 限流;异常流量对接风控。
  • 压测覆盖:大 filter + 多排序键 + search_afterHydrate 半数超时
  • 零结果率P99 端到端hydrate 超时率 建立 SLO。

热点治理:除店铺维度外,还要关注 超级品牌、超级类目、大促会场 的查询模式是否会把 ES 查询打成「同一 filter 反复出现」的形状。此类热点更适合 边缘缓存查询结果短缓存,并把缓存 key 与 rank_version 绑定,避免实验回滚后缓存污染。对 suggest 接口要单独做 更低配额,否则 App 输入框的每个字符都会放大成 ES 压力。

12.8.2 可观测性

日志与追踪携带 query_idsceneexp_idrank_version;ES 查询记录 归一化 query(注意隐私脱敏)。对慢查询 profile 采样

「可回放」是搜索排障的生命线:建议在测试环境保存 DSL 生成器版本fixture query 集,线上问题能一键回放同一 DSL(脱敏后)到预发集群对比 hits。没有回放能力时,团队只能依赖工程师记忆改写了什么,排障周期会从小时级变成天级。

12.8.3 AB 实验

实验桶进请求上下文;指标按桶对比 CTR / CVR;与排序配置 版本绑定,支持快速回滚与 shadow diff。

发布前自检清单(摘录)

  • 索引别名切换:reindex 完成后一次性切 read_alias,并验证读写两侧别名一致。
  • mapping 变更评审:是否需要全量重建;是否影响排序字段 doc_values
  • 压测:覆盖「大 filter + 多排序键 + search_after」与「Hydrate 半数超时」。
  • 降级开关:ES 故障、hydrate 超时、实验回滚在配置中心可一键切换,并有演练记录。
  • 对账任务:抽样对比 ES 文档版本与商品中心版本,差异进入修复队列。

指标面板建议(与稳定性并列):零结果率、Top query 延迟、hydrate 成功率分依赖、ES 慢查询计数、实验分桶 CTR/CVR、以及 客服价格类工单占比。最后一项能把「体验问题」翻译成管理层听得懂的损失函数。


12.9 本章小结

搜索与导购是电商 读模型工程化 的主战场:统一 scene 与编排 降低系统熵;Elasticsearch 承担召回与部分粗排,但必须与商品、上架、生命周期、计价、库存、营销的 契约 清晰划分;Query → Recall → Rank → Hydrate 主链路上,一致性与体验通过 索引版本化 + Hydrate 限时降级 + 产品话术 组合兜底。与推荐系统保持 目标与架构边界 上的解耦,在特征与实验平台上 复用能力,是多数中大型平台的务实演进路径。

若你用一句话向面试官总结本章,可以这样说:搜索索引解决「找得到与排得动」,Hydrate 解决「展示得像且不太假」;交易链路再用强一致服务解决「买得对」。 三条链路各司其职,边界清晰,才能把高并发读路径做成 可演进、可回滚、可观测 的工程系统,而不是一堆 DSL 拼接技巧。

演进路线 看,多数团队会经历「ES 直出 → 引入 Hydrate → 引入统一 scene 与 rank 版本 → 引入完整观测与对账」四阶段;每一阶段都能单独带来收益,但不要把四阶段压缩成一次「大爆炸重构」,否则会在大促窗口付出惨痛代价。最稳妥的切分是:先统一排序内核与日志字段,再逐步把易变字段迁出索引。


参考资料

  1. Elasticsearch 官方文档 — 查询 DSL、分页、profile、聚合。
  2. 本书相关章节:商品中心(索引与缓存)、库存系统、营销系统、计价系统、商品供给与运营管理。
  3. 博客原文:电商系统设计(十二):搜索与导购(Search & Discovery)

导航书籍主页 | 完整目录 | 上一章 | 下一章


第13章 购物车与结算

本章基于《电商系统设计(十三):购物车与结算域》整理扩展,聚焦转化漏斗中的意愿暂存交易前置校验两段能力:购物车弱一致、不锁资源;结算页强一致、编排计价 / 库存 / 营销 / 地址,并通过 Saga 补偿幂等键 保证可重入与可回滚。文中 Go 示例为教学裁剪版,落地时请补全超时、观测、注入与错误语义。

阅读提示:若你习惯把「购物车、结算、创单」写在同一个服务里,可以带着三个问题读完全章——第一,预占库存为何不应出现在购物车;第二,试算与扣券为何必须拆开两个系统时刻;第三,拆单预览与真正拆单的边界应落在哪。把这三件事想清楚,就能把本章与第 8 章(库存)、第 11 章(计价)、第 14 章(订单)自然衔接起来。

面试映射:面试官若问「购物车要不要用分布式锁」,优先回答「行级乐观锁 + Redis 原子写足够,锁购物车会放大死锁与热点」;若问「结算页是不是微服务必须的拆分点」,可以回答「逻辑边界必须清晰,物理部署可渐进」——先把包边界与数据边界立住,比一上来拆两个集群更有性价比。


13.1 系统定位

13.1.1 购物车与结算域

在典型电商链路 浏览 → 加购 → 结算 → 下单 → 支付 中,购物车域承担「意愿篮」:长期暂存 SKU 与数量、支持跨端查看、允许展示价与可售状态弱一致滞后结算域(Checkout)承担「交易前的最后一次强校验」:价格试算拿到 price_snapshot_id、库存预占拿到 reserve_ids、营销只做可用性校验、地址与运费可短时缓存;用户点击提交后,把上述凭证交给订单系统创单,自身不推进订单状态机、不执行支付。

二者哲学差异可概括为:

维度购物车结算页
一致性弱一致可接受价格 / 库存 / 优惠需实时
资源锁定不锁定预占库存(如 15 分钟)
生命周期可长期保留用完即焚或极短会话
失败策略标记失效、不阻断浏览关键依赖失败应阻断或明确降级

13.1.2 核心职责

购物车服务应内聚的职责包括:匿名 cart_token 发放与校验、登录后合并、Redis 主存储与 DB 异步备份、批量选择与数量修改(乐观锁)、列表 Hydrate(批量读商品、可选读展示价与库存状态)、失效商品标记。不应承担:计价规则、库存预占、券扣减、拆单履约路由。

结算服务应内聚:进入结算时的 Saga 编排(并发试算 / 预占 / 校验 / 地址运费)、提交订单前的 幂等去重、订单创建失败时的 显式释放预占、拆单与运费的轻量预览不应承担:订单持久化与状态机、营销扣券事务、支付渠道路由。

限界上下文(Bounded Context) 视角看,购物车与结算可以部署为两个服务,也可以先合在一个进程里用包级边界隔离,但语言层边界要先立住:购物车领域的聚合通常是「购物车行集合」;结算领域的聚合更接近「一次结算尝试(CheckoutAttempt)」——它甚至不一定要落库,可以以请求上下文 + 外部系统返回的凭证组合存在。把这两个聚合混在一个 Order 聚合根里,是单体时代最常见的腐化起点:你会看到订单服务里长出「顺便改下购物车」的私有 API,最后谁也不敢删。

团队分工建议:购物车更接近 增长与体验团队(关注转化、列表性能、推荐插卡);结算更接近 交易与资金安全团队(关注幂等、补偿、风控)。若组织上同属一个小组,也应在代码评审里用不同的 OWNERS 文件与 SLO 分栏,避免用购物车的发布节奏去承载结算的严谨性,反之亦然。

13.1.3 系统架构

下图给出购物车与结算在全局中的位置,以及读写依赖分层(只读展示 vs 强一致编排)。部署上,购物车服务与结算服务可共享网关与部分中间件,但建议 独立扩容曲线:大促往往是「加购 QPS」先于「结算 QPS」暴涨,混布会让结算的尾延迟拖慢加购。数据库侧购物车备份表与订单库也应物理隔离,避免创单洪峰影响购物车异步刷盘。

flowchart TB
  subgraph user[用户层]
    Web[Web / App]
  end
  subgraph gw[接入层]
    API[API Gateway]
  end
  subgraph domain[购物车与结算域]
    CartS[购物车服务]
    Chk[结算编排服务]
    Wkr[购物车清理 Worker]
  end
  subgraph store[本域存储]
    Redis[(Redis 购物车主存)]
    DB[(MySQL 备份 / 会话可选)]
  end
  subgraph weak[弱一致只读依赖]
    Prod[商品读服务]
    Price[计价展示价 可选]
    InvS[库存状态 可选]
  end
  subgraph strong[强一致依赖]
    Trial[计价试算]
    Resv[库存预占]
    Mkt[营销校验]
    Addr[地址 / 运费]
  end
  subgraph down[下游]
    Ord[订单系统]
    Pay[支付系统]
    Bus[消息总线]
  end
  Web --> API
  API --> CartS
  API --> Chk
  CartS --> Redis
  CartS -.-> DB
  CartS -.-> Prod
  CartS -.-> Price
  CartS -.-> InvS
  Chk --> Trial
  Chk --> Resv
  Chk --> Mkt
  Chk --> Addr
  Chk --> Ord
  Ord --> Pay
  Ord --> Bus
  Bus --> Wkr
  Wkr --> CartS

架构要点:购物车路径以 Redis HASH 为主键模型(cart:{user_id}cart:token:{token}),结算路径以 编排器 为中心。默认推荐 无状态结算:每次进入结算重新试算与预占,前端仅持有上一次的 snapshot_id / reserve_ids 直到提交或超时,这样可以把复杂度压到可接受范围。若产品强需求「刷新页面仍保留勾选与券选择」,可在 13.8 引入轻量 checkout_session 并严格对齐预占 TTL 与快照过期时间,否则极易出现「页面看到的是 A 价、提交时已是 B 价」的认知冲突。

本章显式非目标:不把支付路由、支付渠道对账、订单履约全状态机纳入结算服务;不展开秒杀极端优化(仅在后文工程小节点到为止);不把计价规则引擎、库存 Lua 细节、营销券批次台账重写一遍——这些分别归属第 11、8、9 章及订单第 14 章。

与第 6 章(Saga 总论)的关系:第 6 章给出编排 / 协同、补偿幂等与事件驱动的一般模式;本章把它落到「购物车弱一致 + 结算强编排」这一条具体链路上。你在评审架构时可以用一句话自检:购物车里永远不该出现 Saga,因为那里没有跨系统资源需要一致回滚;结算页几乎必然出现 Saga,因为试算、预占、创单分布在不同限界上下文。


13.2 购物车设计

除「能加购、能合并」外,购物车还需要回答四个体验问题:加购后价格变了怎么办商品下架了怎么办跨端是否一致风控与刷单边界在哪。下面分小节把模型与工程一次说透。

13.2.1 未登录加购

未登录加购的本质是在没有稳定用户主键的前提下,为浏览器会话分配一个可验证、可过期、可合并的购物车标识。推荐由后端签发 cart_token(UUID),前端写入 HttpOnly Cookie 或受控存储,并与 Redis TTL(常见 7~30 天)对齐。

流程要点:首次加购若本地无 token,则调用匿名创建接口,服务端生成 token 并 HSET;后续请求携带 token 走 HINCRBY 或覆盖写入。

sequenceDiagram
  participant U as 用户
  participant F as 前端
  participant C as 购物车服务
  participant R as Redis
  U->>F: 加入购物车
  F->>F: 读取 cart_token
  alt 无 token
    F->>C: POST /cart/anonymous/init
    C->>C: 生成 UUID
    C->>R: HSET cart:token:{token} sku qty
    C-->>F: Set-Cookie cart_token
  else 有 token
    F->>C: POST /cart/add token + sku + qty
    C->>R: HINCRBY cart:token:{token} sku delta
  end
  C-->>U: 成功

服务端应对 cart_token签名校验或存储侧校验,避免伪造 token 横向遍历他人购物车(常见做法:token 即随机高熵 ID,Redis 中不存在则拒绝;或对 token 做 HMAC 绑定设备指纹,视安全等级取舍)。

// AddAnonymousCart 首次匿名加购:创建 token 并写入 Redis
func (s *CartService) AddAnonymousCart(ctx context.Context, skuID int64, qty int) (token string, err error) {
	token = uuid.NewString()
	key := "cart:token:" + token
	if err = s.rdb.HSet(ctx, key, strconv.FormatInt(skuID, 10), qty).Err(); err != nil {
		return "", err
	}
	_ = s.rdb.Expire(ctx, key, 30*24*time.Hour).Err()
	return token, nil
}

安全与滥用面:匿名桶没有账号体系背书,必须配合 频控(同 IP / 同设备加购 QPS)、购物车行数上限(例如单桶 120~200 个 SKU)、以及异常 token 批量探测的风控策略。否则黑产可以用海量 token 刷 Redis 与下游 Hydrate,把商品读服务拖成「另一个 DDoS 入口」。

商品失效在购物车层的语义:购物车不保证「可结算」,只保证「用户曾表达的意愿可追溯」。典型变化与展示策略如下(结算页会再次强校验):

变化购物车展示是否允许去结算
价格上涨 / 下降展示最新参考价 + 轻提示允许尝试进入结算
下架 / 禁售行置灰 + 标签不允许勾选结算
售罄置灰 +「到货提醒」可选不允许勾选结算
SKU 被删除 / 查无此品「商品失效」占位不允许勾选结算

13.2.2 登录后合并

登录合并要解决三类冲突:同 SKU 数量合并不同 SKU 追加业务约束(限购、下架、售罄标记)。合并完成后应失效匿名桶(或保留短 TTL 供排障),并把前端 Cookie 清理或覆盖为用户态。

flowchart TD
  A[登录成功] --> B[读取匿名 cart:token]
  B --> C[读取用户 cart:user]
  C --> D{遍历匿名行}
  D --> E{用户侧是否已有 SKU}
  E -->|是| F[数量相加并限购截断]
  E -->|否| G[追加新行 selected 默认 true]
  F --> H[Upsert Redis + 异步刷 DB]
  G --> H
  H --> I[删除匿名 key 或缩短 TTL]
  I --> J[返回合并结果摘要]
// MergeCart 登录后合并:相同 SKU 数量相加,尊重限购上限
func (s *CartService) MergeCart(ctx context.Context, userID int64, cartToken string) error {
	anonKey := "cart:token:" + cartToken
	userKey := "cart:user:" + strconv.FormatInt(userID, 10)

	pipe := s.rdb.TxPipeline()
	anon, err := s.rdb.HGetAll(ctx, anonKey).Result()
	if err != nil {
		return err
	}
	for skuStr, qtyStr := range anon {
		skuID, _ := strconv.ParseInt(skuStr, 10, 64)
		addQty, _ := strconv.Atoi(qtyStr)
		cur, _ := s.rdb.HGet(ctx, userKey, skuStr).Int()
		newQty := cur + addQty
		if lim := s.limits.MaxQty(ctx, skuID); lim > 0 && newQty > lim {
			newQty = lim
		}
		pipe.HSet(ctx, userKey, skuStr, newQty)
	}
	pipe.Del(ctx, anonKey)
	_, err = pipe.Exec(ctx)
	return err
}

合并冲突的决策表(实现与产品需一致):

场景处理备注
同 SKU数量相加合并后再跑限购
仅匿名有下架 SKU保留并标记让用户手动删
限购截断调到上限并 toast记录审计日志
选中态默认选中新并入 SKU也可继承匿名侧选中态

跨端一致:Web 与 App 只要最终都映射到 user_id 或同一 cart_token,Redis 即单一事实来源;DB 异步略滞后通常可接受。若业务强诉求「一端改数量另一端秒开即见」,可在用户维度加可选的 cart.updated 推送,但不要反向把推送当成库存真相。

13.2.3 Redis + DB 双写

关系库备份层推荐保留「行模型」而非把购物车 JSON blob 一塞了之,便于对账、客服查询与审计。匿名与用户共用一张表时,用 user_id = 0 + cart_token 组合唯一索引:

CREATE TABLE shopping_cart (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示匿名',
    cart_token VARCHAR(64) DEFAULT NULL,
    spu_id BIGINT NOT NULL,
    sku_id BIGINT NOT NULL,
    quantity INT NOT NULL DEFAULT 1,
    selected TINYINT NOT NULL DEFAULT 1,
    version INT NOT NULL DEFAULT 1,
    added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_user_sku (user_id, sku_id),
    UNIQUE KEY uk_token_sku (cart_token, sku_id),
    INDEX idx_user (user_id),
    INDEX idx_token (cart_token)
) COMMENT='购物车备份表';

不存成交价:购物车行只存 sku_id、数量、选中态等「意愿」,不在行上持久化价格。展示价来自商品标价或计价展示接口;否则一旦促销回溯,你会在库里同时存两种真相,客服与技术将无法争论哪一种才是「用户当时看到的意思」。

推荐主路径:写 Redis 同步成功即对用户返回成功;DB 通过 异步队列延迟批量刷盘 落库,并配 周期对账(例如每 5 分钟扫描变更桶)以防 Redis 丢数据。读路径:优先 HGETALL Redis;miss 时读 DB 回填 Redis。

双写要避免「先 DB 后 Redis」导致的高延迟写路径;也要避免「只写 Redis 永不落库」带来的容灾空洞。工程上常采用 Outbox变更版本号:每次写携带 updated_at / cart_version,Worker 按版本增量同步。

故障切换剧本(建议在运维手册一页纸写清):当 Redis 集群大面积不可用时,购物车服务应能降级到 只读 DB 或只接受写队列暂存 两种模式之一——前者读慢但可用,后者写入排队、返回「稍后在购物车查看」类文案。无论哪种,都要避免「写请求默默丢失」。恢复后应有 回填工具 把 DB 最新版本同步到 Redis,并记录一次对账报告。

对账视角:定期抽样比对 Redis 与 DB 的行数与数量合计,差异超过阈值触发告警。差异来源通常是异步延迟、Outbox 堆积或历史 bug;不要用手工改 Redis「修数据」作为常规手段,除非同时修 DB 并留审计。

// PersistCartItemAsync 异步落库示例:写 Redis 成功后投递 Outbox
func (s *CartService) PersistCartItemAsync(ctx context.Context, userID, skuID int64, qty int) error {
	key := "cart:user:" + strconv.FormatInt(userID, 10)
	if err := s.rdb.HSet(ctx, key, strconv.FormatInt(skuID, 10), qty).Err(); err != nil {
		return err
	}
	return s.outbox.Enqueue(ctx, CartChangedEvent{UserID: userID, SKUID: skuID, Qty: qty, TS: time.Now().UnixMilli()})
}

13.2.4 批量操作

批量全选 / 取消、批量删除、批量改数量,建议提供 单次 RPC 批量接口,减少往返。并发修改数量时使用 乐观锁(DB 表 version 字段)或 Redis Lua 脚本保证「读改写」原子性。

UPDATE shopping_cart
SET quantity = ?, version = version + 1, updated_at = NOW()
WHERE user_id = ? AND sku_id = ? AND version = ?;

RowsAffected = 0,返回冲突码让前端重试或刷新列表。批量接口内部仍可按 SKU 分片并行,但要对总耗时设上限,避免长尾拖垮网关。

购物车列表 Hydrate(只读聚合):从 Redis 取出 sku_id -> qty 后,批量查询商品中心;展示价与库存状态为可选增强。部分失败应 降级为占位文案 而不是整页 500,否则转化率会被技术细节直接打掉。

func (s *CartService) ListVO(ctx context.Context, userID int64) ([]LineVO, error) {
	key := "cart:user:" + strconv.FormatInt(userID, 10)
	raw, err := s.rdb.HGetAll(ctx, key).Result()
	if err != nil {
		return nil, err
	}
	ids := make([]int64, 0, len(raw))
	for k := range raw {
		id, _ := strconv.ParseInt(k, 10, 64)
		ids = append(ids, id)
	}
	prod, _ := s.product.BatchGet(ctx, ids)
	out := make([]LineVO, 0, len(raw))
	for skuStr, qtyStr := range raw {
		skuID, _ := strconv.ParseInt(skuStr, 10, 64)
		qty, _ := strconv.Atoi(qtyStr)
		p := prod[skuID]
		out = append(out, LineVO{SKUID: skuID, Qty: qty, Title: p.Title, Image: p.Image, Shelf: p.Status})
	}
	return out, nil
}

13.3 结算页设计

13.3.1 Saga 编排

结算页是典型的 编排型 Saga(Orchestrated Saga):结算服务作为编排器逐步调用子系统,并在失败时执行逆向补偿(如释放预占)。它不追求 2PC 的强一致提交,而追求 可观测、可补偿、幂等 的业务闭环。

协同式 Saga(Choreography) 相比:结算链路强依赖「用户此刻在结算页」这一交互闭环,需要集中式的超时、降级与错误文案,编排器模式更利于排障与 SLA 治理;协同式更适合订单创建之后、履约与供应商之间那种长链路、多参与方且希望减少中心耦合的场景(第 6 章对比过二者,这里只强调落地选择)。

进入结算 vs 提交订单是两段 Saga:前者可以失败重试、可以部分降级;后者必须短、幂等、尽量少分支。实践中常见反模式是把两段逻辑写进同一个「上帝函数」,导致 Init 阶段的并发优化污染了 Submit 的可证明性。建议代码层拆 CheckoutInitSagaCheckoutSubmitSaga 两个入口,共用领域服务但不同超时与指标。

stateDiagram-v2
  [*] --> Init: 进入结算
  Init --> PricingOK: 试算成功
  Init --> Fail: 试算失败
  PricingOK --> Reserved: 预占成功
  PricingOK --> Fail: 预占失败 / 释放快照无关资源
  Reserved --> Validated: 营销校验完成(可降级跳过)
  Reserved --> Compensate: 致命失败
  Validated --> Ready: 地址运费就绪(可降级默认)
  Ready --> Submitted: 提交订单成功
  Ready --> Compensate: 提交失败
  Submitted --> [*]
  Compensate --> Released: 释放预占(幂等)
  Released --> Fail
  Fail --> [*]

编排顺序的工程权衡:试算与预占可否并行?若营销结果影响可售组合,可能需要串行;默认实践中常见做法是 试算与预占并行以换取时延,失败时按依赖关系补偿:若试算失败但预占已成功,应释放预占;若试算成功预占失败,一般无需回滚试算(快照由计价系统管理生命周期)。下图给出进入结算阶段的并发扇出。

sequenceDiagram
  participant U as 用户
  participant O as 结算编排器
  participant P as 计价试算
  participant I as 库存预占
  participant M as 营销校验
  participant A as 地址运费
  U->>O: InitCheckout(cart, address, coupons)
  par 扇出
    O->>P: Trial(scene=checkout)
    O->>I: Reserve(TTL=900s)
    O->>M: ValidateCoupons
    O->>A: ListAddress + Freight
  end
  P-->>O: snapshot_id + 明细
  I-->>O: reserve_ids
  M-->>O: 可用券列表(可空)
  A-->>O: 运费(可默认)
  O-->>U: 结算页聚合结果

13.3.2 价格试算

结算页必须调用计价中心的 试算接口scene=checkout),拿到 应付总额、分项明细、快照 ID 与过期时间。购物车列表上的价格只能是「参考价」,产品话术需统一为 「以结算页为准」,否则客服与舆情成本极高。

试算失败属于 P0 阻断:不允许进入可提交状态。可选优化是快照过期后由订单系统二次校验或拒绝创单,但不应在结算页静默使用陈旧价。

触发重新试算的事件(与前端埋点一一对应,便于解释「为什么总价跳了」):

事件是否必须重算说明
首次进入结算建立基准快照
切换收货地址通常要运费与可达店铺集合可能变化
切换 / 取消优惠券影响分层抵扣
修改数量(仍在结算页)行金额与门槛类活动联动
仅切换发票抬头视税制可能不影响含税价

快照过期的产品策略:常见做法是快照 30~60 分钟内有效,过期提示用户刷新;订单系统在创单时再做一次 硬校验,防止「结算页停留过久」绕过。不要试图在结算服务内「续命」快照,那会把计价系统的版本语义搅浑。

13.3.3 库存预占

预占解决的是「从结算到支付窗口内库存被抢走」的体验与超卖风险。预占时长常用 900 秒,由库存服务维护 TTL 与释放任务;结算服务在 订单创建失败 时显式调用 release-reserve,避免等待 TTL 造成的资源浪费。

预占与试算的失败组合处理见 13.6 节补偿表。核心原则:结算页不实现扣减,只持有 reserve_ids 凭证。

用户在结算页改数量:应走「释放旧预占 → 按新数量重新预占」的两段调用,中间态要对前端屏蔽或短锁按钮,避免双份预占。若释放成功而重新预占失败,应整体回退到「请返回购物车重选」的确定语义,而不是半提交。

stateDiagram-v2
  [*] --> 可售
  可售 --> 预占中: Reserve
  预占中 --> 已扣减: ConfirmReserve
  预占中 --> 可售: TTL 到期或 Release
  已扣减 --> [*]: 关单回补等由订单域处理

13.3.4 营销校验

结算页调用营销 只读校验:判断券是否可用、圈品是否命中、互斥规则是否满足。不扣券。扣券放在订单创建事务路径(或订单 Saga 的下一步),避免「结算扣券成功、创单失败」带来的复杂回滚与客诉。

营销超时可 降级:隐藏优惠入口,以原价试算结果继续(需产品同意);若业务不允许无券结算,则应阻断。

券在结算与订单之间的「两段式」价值:结算阶段输出的是 可解释性(为什么这张券灰掉),订单阶段输出的是 事实(券批次余额少了一次)。中间没有第三段「半锁定券」,除非你单独引入锁券服务——那会把领域模型再劈一叉,一般不值得。

可选:有状态结算会话(复杂度权衡):默认仍建议无状态;若产品要求「刷新保留勾选与券」,需要额外持久化会话,并与预占 TTL、快照过期严格对齐,否则会出现「页面展示与提交凭证不一致」。表结构示例见 13.8.3。


13.4 拆单与地址运费

13.4.1 拆单预览

拆单维度通常包括:跨店铺跨仓自营 / POP不同履约 SLA。结算页只做 split-preview:返回预计子单分组、每组 SKU、预估运费与送达时间;不生成子订单 ID,不调重度履约路由。

预览要回答的用户问题是「我会收到几个包裹、各自多少钱」,而不是「仓库拣货路径怎么走」。因此预览计算应使用 与创单一致的拆分规则版本号(例如 split_ruleset=2026Q2),在响应里透传;当订单系统发现规则升级导致结果变化时,可以返回可读错误码,让用户刷新结算页,而不是静默改单。

对于 同一店铺多仓可发 的场景,预览可能给出「可能拆」的灰色提示:真正选仓在订单或履约系统完成,预览只基于默认策略做估计。产品文案上建议用「预计」二字,技术文档里要写清楚 估计误差允许的边界,避免法务与客服在「预览两包裹实发合一」场景下无解。

性能:拆单预览输入是购物车选中行的结构化列表,复杂度通常在 O(n)O(n log n)(按店铺、类目排序);不要在预览里调用供应商实时询价类接口,否则结算页会被第三方 SLA 绑架。需要供应商参与的场景,应折叠为「下单后再确认」的异步路径,并在结算页显著提示。

flowchart LR
  subgraph cart[购物车选中行]
    s1[SKU1 shopA]
    s2[SKU2 shopA]
    s3[SKU3 shopB]
  end
  subgraph pv[拆单预览]
    o1[预览单1 shopA 运费 f1]
    o2[预览单2 shopB 运费 f2]
  end
  s1 --> o1
  s2 --> o1
  s3 --> o2

预览接口建议由 订单域 提供只读计算(与真正拆单共享规则内核),避免结算域复制一套拆单逻辑。

13.4.2 地址选择

地址列表由用户域或履约子域提供。结算页缓存默认地址 ID,切换地址时触发 运费重算 与可选的 试算重算(运费是否进快照取决于计价模型)。需防止用户用「切换地址」刷爆运费服务:对 (user_id, address_id, cart_hash) 做频控与短 TTL 缓存。

跨境与身份证 / 通关信息:若地址切换会触发额外字段(实名、税号),不要把敏感信息长期缓存在结算会话里;遵循最小留存原则,提交创单时一次性写入订单快照或合规存储。地址校验失败(不可达、风控拦截)应区分「硬失败」与「软提示」:硬失败直接阻断;软提示允许用户继续但要在支付前再次确认。

默认地址漂移:用户可能在结算过程中于「地址管理页」修改默认地址。结算服务应以 进入结算时锁定 address_id 为主策略;若产品要求实时联动,需要 WebSocket 或轮询刷新,并重新跑试算与预占,复杂度会迅速上升——这是有状态会话最容易踩的坑之一。

13.4.3 运费计算

运费计算输入至少包含:地址结构化信息店铺维度SKU 体积重量模板促销包邮规则。缓存 Key 示例:freight:{address_id}:{cart_hash},TTL 20~60 秒。购物车变更或地址变更必须使 cart_hash 失效。

运费与试算的关系要在一开始就写进契约:如果运费进入计价快照,则切换地址必须同时触发试算 + 运费;如果运费独立,则订单系统创单时也要携带运费版本号,否则会出现「结算看到 10 元运费、订单变成 12 元」的纠纷。B2B2C 下常见是 计价统一收口的应付金额 已含运费,这时地址服务只作为试算的输入因子,而不是第二套计算器。

拆单与运费的耦合:跨店场景下,预览接口宜返回 按店铺分组的运费数组,前端展示「每店一笔运费」;不要在前端把多段运费硬加成单一标量,否则与后续子订单对账困难。冷链、大件、送货上门加价等,可作为 运费模板扩展字段 由地址 / 履约服务解释,结算域只展示结果不做规则。


13.5 系统边界与职责

13.5.1 购物车域与结算域

能力购物车域结算域
暂存 SKU / 数量否(用购物车快照或请求体)
展示 Hydrate仅必要时复用
试算 / 快照否(可选展示价)
预占
营销扣减否(仅校验)

13.5.2 结算与订单

结算服务在提交阶段只做三件事:幂等闸门、组装创单请求、调用订单 Create。订单系统负责:真正拆单、写订单与明细、确认预占转扣减、扣券、发布 order.created 事件。结算服务不应写订单表,也不应持有订单状态机。

为何不能把「创单」继续留在结算服务里:短期看少一次 RPC,长期看你会得到「结算发布 order.created、订单服务也发布 order.created」的双头龙,消费者不知道以谁为准;更糟的是版本升级时,两个团队对「部分失败是否算创单成功」理解不一致,线上会出现只有结算库有记录、订单库没有的幽灵交易。边界一旦划给订单,就要让订单成为 订单事实的唯一写入者

BFF(Backend for Frontend)与结算编排器的分工:移动端 BFF 可以做字段裁剪、聚合多个读接口、甚至缓存用户地址列表;但不要把试算与预占藏在 BFF 里「顺便算一下」,否则 Web 与 App 会各自实现半套结算逻辑。推荐做法是 BFF 薄、结算编排厚、领域服务更厚。

13.5.3 资源锁定的归属

资源锁定发生地释放 / 确认
库存预占结算进入时TTL 自动释放;创单确认;失败显式释放
价格快照计价系统生成订单校验快照有效性
营销券未锁定订单创建时扣减

13.5.4 谁负责拆单预览

建议归属 订单域只读 API(或拆单内核库被订单服务托管)。结算域仅编排调用。若预览放在结算服务内,极易与履约变更耦合,出现「预览两单、创单变三单」的舆情风险——需版本化规则与免责声明。

反模式速查(评审清单可直接复用):

反模式为何糟糕正确方向
购物车预占库存长期占用,利用率差只在结算预占
购物车存成交价与促销回溯冲突行上不存价,展示时拉价
结算页扣券创单失败要回滚券订单扣券
结算页内嵌拆单履约变更面爆炸预览与真正拆单分离
结算服务写订单表双写一致性与职责越界只调订单 API

13.6 与其他系统集成

13.6.1 与订单系统衔接(提交订单)

创单请求应携带:idempotency_keyuser_idcart_itemsprice_snapshot_idreserve_idscoupon_idsaddress_idshipping_method。订单系统内部再驱动库存确认与营销扣减(详见第 14 章)。

边界:结算服务 不得 根据创单结果去修改订单状态;支付 URL 的拼装可以放在 BFF,但支付单创建仍应由支付域根据订单事实驱动。结算返回给前端的应是 订单 ID + 下一步跳转参数,而不是「假装自己是订单库」。

func (s *CheckoutService) Submit(ctx context.Context, r SubmitRequest) (*SubmitResult, error) {
	orderID, err := s.submitOnce(ctx, r.UserID, r.IdempotencyKey, func(c context.Context) (string, error) {
		return s.orders.Create(c, CreateOrderDTO{
			UserID: r.UserID, Items: r.Items, SnapshotID: r.SnapshotID,
			ReserveIDs: r.ReserveIDs, Coupons: r.Coupons, AddressID: r.AddressID,
		})
	})
	if err != nil {
		_ = s.inv.Release(context.Background(), r.ReserveIDs)
		return nil, err
	}
	return &SubmitResult{OrderID: orderID}, nil
}

13.6.2 与计价系统集成(价格试算)

结算只认计价返回的 price_snapshot_id 与过期时间;不在本地拼接促销表达式。

契约要点:试算请求应携带 场景枚举用户身份地址因子已选券列表购物车行;响应必须包含 可审计明细快照过期时间。结算服务侧禁止缓存「最终应付」超过秒级,否则与风控频控冲突。

13.6.3 与库存系统集成(库存预占)

调用 POST /inventory/reserve,设置 expire_seconds;保存返回的 reserve_ids[] 直至创单成功或失败释放。

幂等与重试:预占接口在超时重试场景下必须由库存侧保证 同一业务重放键 不产生双倍占用(常见做法是基于 user_id + checkout_tracerequest_token 去重)。结算侧则要把 reserve_ids 当作 opaque handle,不在本地推断库存数量。

13.6.4 与营销系统集成(优惠校验)

调用 validate-coupons;返回不可用原因用于前端提示。扣减走订单。

购物车域为什么不调用营销:购物车阶段引入营销,会把「意愿篮」变成「半个交易」,用户未表达购买意图就要承担券解释成本;更麻烦的是券规则与圈品频繁变更,购物车 Hydrate 会变成 O(N×规则) 的热点路径。

13.6.5 集成调用链路与补偿

下图从「进入结算」到「提交订单」画出主路径与补偿关注点(虚线为异步或失败回退)。

flowchart TB
  subgraph client[客户端]
    FE[结算前端]
  end
  subgraph checkout[结算域]
    CH[Checkout Orchestrator]
    IDM[幂等闸门 Redis]
  end
  subgraph deps[依赖系统]
    PR[Pricing Trial]
    IV[Inventory Reserve]
    MK[Marketing Validate]
    AD[Address Freight]
    OR[Order Create]
  end
  FE -->|Init| CH
  CH --> PR
  CH --> IV
  CH --> MK
  CH --> AD
  FE -->|Submit| CH
  CH --> IDM
  IDM -->|首次| OR
  OR -.->|失败释放| IV
  CH -.->|补偿调用| IV

补偿表(节选)

失败点已完成补偿
试算失败可能已预占释放预占
预占失败试算成功无需释放价快照
创单失败预占在释放预占;营销未扣券则无需回券
支付超时订单进入关单流由订单 / 库存回补(不在本章展开)

购物车域边界表(只读展示)

下游调用购物车不做
商品中心POST /product/batch-get不缓存详情、不判定可售真相
计价(可选)batch-display-price不锁价、不算复杂规则
库存(可选)batch-status不预占、不扣减
营销不调用不算券

结算域边界表(强一致编排)

下游调用结算不做
计价trial-calculate不实现规则引擎
库存reserve / 触发 release不确认扣减
营销validate-coupons不扣券
地址list + freight/calculate不持久化地址
订单create不拆单、不推进状态机

13.6.6 Saga 编排实现

用 Go 的 errgroup 控制并发与 context.WithTimeout 控制尾延迟,再在汇聚点做决策:

func (s *CheckoutService) InitCheckout(ctx context.Context, req InitRequest) (*InitResult, error) {
	g, ctx := errgroup.WithContext(ctx)
	var trial *TrialResult
	var resv *ReserveResult

	g.Go(func() error {
		c, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
		defer cancel()
		r, err := s.pricing.Trial(c, TrialInput{UserID: req.UserID, Items: req.Items, Scene: "checkout"})
		if err != nil {
			return err
		}
		trial = r
		return nil
	})
	g.Go(func() error {
		c, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
		defer cancel()
		r, err := s.inv.Reserve(c, ReserveInput{UserID: req.UserID, Items: req.Items, TTL: 900 * time.Second})
		if err != nil {
			return err
		}
		resv = r
		return nil
	})
	if err := g.Wait(); err != nil {
		if resv != nil {
			_, _ = s.inv.Release(context.Background(), resv.IDs)
		}
		return nil, err
	}
	return &InitResult{SnapshotID: trial.SnapshotID, Payable: trial.Payable, ReserveIDs: resv.IDs}, nil
}

提交阶段保持 单线程顺序:幂等 → 创单 → 返回 order_id

可观测性补充:为每一次 InitCheckout 生成 checkout_trace_id,贯穿所有下游 RPC 的 baggage;在日志中打印各依赖耗时直方图标签(pricing_msreserve_ms 等)。Submit 路径额外打印 idempotency_key 与返回 order_id。这样当「只有某个地区的用户预占失败率升高」时,你可以快速判断是库存分片热点还是地址服务区域路由问题。

集成测试建议:至少三类用例要在 CI 里跑通:Init 成功 + Submit 成功Init 成功后订单返回冲突(模拟幂等)Init 成功后订单失败触发释放。第四类 Init 部分依赖超时 可以放在 nightly,以免拖慢 PR 流水线,但不能没有。


13.7 幂等性与去重

13.7.1 idempotency_key 设计

推荐由前端生成 UUIDv4 作为 Idempotency-Key 请求头或 JSON 字段,并在用户点击「提交订单」的第一次交互即固定,重试与自动重连复用同一键。服务端在结算网关或结算服务使用 Redis:

SET idempotency:{user_id}:{key} -> processing NX EX 120

成功后写入 order_id 作为值;重复请求直接返回缓存结果。订单表保留唯一索引 (user_id, idempotency_key) 作为最终兜底。

键空间建议包含 user_id,避免跨用户碰撞;TTL 覆盖「用户犹豫 + 网络抖动」窗口即可。

键的生命周期与返回语义:第一次提交进行中时,Redis 里可以是 processing 占位;成功后写入 order_id 并延长 TTL,重复请求应返回 同一 order_id同一支付跳转参数,HTTP 层可用 200409+业务体,但务必前后端约定一致。若创单失败删除了 Redis 键,客户端重试会生成新 UUID——这是允许的,但要评估「用户连点导致多笔预占」的极端情况;更好的 UX 是在失败提示里保留「重试同一单」入口,由前端复用旧键。

与订单系统幂等的叠床架屋是否有必要:有必要。网关 Redis 去重解决 极短时间窗内的风暴重放;数据库唯一索引解决 跨进程、跨机房、Redis 丢失 的慢变量问题。二者不是重复建设,而是不同时间尺度的防线。评审时如果有人问「只留 DB 行不行」,答案是行,但你会在高峰期看到大量创单请求把订单库打满冲突重试;「只留 Redis 行不行」,答案是也行,直到某次故障切换丢键。

func (s *CheckoutService) submitOnce(ctx context.Context, user int64, key string, fn func(context.Context) (string, error)) (string, error) {
	rk := fmt.Sprintf("idem:%d:%s", user, key)
	ok, err := s.rdb.SetNX(ctx, rk, "processing", 2*time.Minute).Result()
	if err != nil {
		return "", err
	}
	if !ok {
		return s.rdb.Get(ctx, rk).Result()
	}
	orderID, err := fn(ctx)
	if err != nil {
		_ = s.rdb.Del(ctx, rk).Err()
		return "", err
	}
	_ = s.rdb.Set(ctx, rk, orderID, 24*time.Hour).Err()
	return orderID, nil
}

13.7.2 重复提交防护

三层组合:前端按钮禁用 + 请求级幂等键 + 订单唯一索引。仅依赖前端不可靠;仅依赖 Redis 可能因过期导致双单,因此 DB 唯一约束不可或缺。

移动端弱网:重试库(例如自动重放 POST)必须与业务幂等键协同,否则会在用户无感知的情况下放大写压力。建议移动端网络层对 写操作 默认关闭盲重试,或仅在收到明确可重试错误码时重放,并始终携带同一 Idempotency-Key

网关层去重与业务层去重的边界:API Gateway 可以做粗粒度 IP + path 频控,但不要把「业务幂等」全部交给网关规则引擎;网关不知道 reserve_ids 是否已被使用,也不知道订单是否已支付。网关负责 削峰,结算与订单负责 正确性

13.7.3 补偿机制

补偿分 自动显式:库存 TTL 属于自动;创单失败触发结算服务显式 release。所有释放接口必须 幂等,重复调用不产生副作用。补偿任务应记录 结构化日志 + metric,便于统计「创单失败率 × 预占释放成功率」。

补偿与重试的观测字段:建议在日志与 Trace 中固定携带 checkout_trace_id(一次 Init 生成)、idempotency_keyreserve_ids 哈希、snapshot_id。当客服工单进来时,可以分钟级还原「当时为什么失败」,而不是靠 grep 多台机器。

订单创建后的购物车清理:推荐消费 order.created 事件异步删除已购 SKU,且以 order_id 做消费幂等。清理非强一致:即使延迟,用户最多看到「购物车还多一件已买商品」,用 UI 提示即可,不应阻塞支付跳转。

func (w *CartCleaner) OnOrderCreated(ctx context.Context, e OrderCreated) error {
	if ok, _ := w.idem.Seen(ctx, "cart_clean", e.OrderID); ok {
		return nil
	}
	for _, it := range e.Lines {
		_ = w.rdb.HDel(ctx, "cart:user:"+strconv.FormatInt(e.UserID, 10), strconv.FormatInt(it.SKUID, 10)).Err()
	}
	return w.idem.Mark(ctx, "cart_clean", e.OrderID, 7*24*time.Hour)
}

13.8 工程实践

13.8.1 性能优化

  1. 购物车列表 Hydrate 使用 批量接口,商品中心一次拉全 SKU;可选并行拉取展示价与库存状态。
  2. 结算 Init 使用 errgroup + 独立超时;对非关键依赖(营销、地址)允许降级。
  3. 热点用户桶考虑 Hash Tag(Redis Cluster 场景)与本地微缓存(谨慎,防击穿)。

购物车写放大HINCRBY 是 O(1),但每一次加购若都同步触发 DB Outbox,会在大促预热期形成写放大。常见做法是 合并窗口(200ms 内多次变更合并为一条 Outbox)或按用户维度微批刷盘。读放大主要来自 Hydrate:务必限制 sku_ids 批量大小(例如每页 50),并对商品中心失败做部分成功返回,避免整页超时。

结算页 P99 与转化率:经验上,结算 Init 超过 1.5s 会显著伤害「进入结算 → 提交」转化。除并发扇出外,还应检查 是否无意中串行化了可并行步骤(例如把拆单预览放在试算之前且强依赖网络);更隐蔽的是 大 JSON 响应体前端重复渲染 造成的体感慢,这要靠前端性能与网关压缩共治。

13.8.2 转化漏斗监控

建议埋点维度:scene(搜索 / 活动 / 推荐)、deviceregion。核心比率:加购率、进入结算率、结算 Init 成功率、提交成功率、支付成功率。对 幂等拦截率 单独监控:异常升高可能意味着前端重复提交或网络重试策略错误。

分层漏斗与告警:除全站均值外,建议对 新客 / 沉默唤醒 / 高客单 分桶,否则会被大盘平均掩盖。告警上至少拆三条:结算 Init 错误率、预占失败占比、创单失败占比——三者根因不同,混在一个「下单失败率」里会排障困难。

与业务运营协同:漏斗面板应能下钻到 错误码分布(库存不足、券不可用、地址不可达、快照过期),否则运营只会看到「转化率掉了」,技术只会说「系统没挂」。把错误码映射到「可行动项」(补货、调整券门槛、修正运费模板)是平台化团队的工作方式。

13.8.3 降级策略

依赖故障策略风险
营销隐藏优惠客单价下降
地址使用默认地址错发风险需产品接受
计价 / 库存不建议静默继续体验与资损

大促期间可启用 排队结算削峰队列,把 Submit 变异步(需改变产品交互,谨慎)。

结算依赖超时与重试(落地参考)

依赖超时重试说明
计价试算700~900ms0~1 次失败即阻断
库存预占400~600ms1 次注意幂等键
营销校验250~350ms0 次可降级
地址运费150~250ms0 次可默认地址

有状态结算会话表(可选)

CREATE TABLE checkout_session (
  session_id VARCHAR(64) PRIMARY KEY,
  user_id BIGINT NOT NULL,
  cart_snapshot JSON,
  price_snapshot_id VARCHAR(64),
  reserve_ids JSON,
  address_id BIGINT,
  expires_at TIMESTAMP,
  INDEX idx_user (user_id),
  INDEX idx_expires (expires_at)
);

启用会话时,要在 expires_at 到达后 主动释放预占 或依赖库存 TTL,并在前端显著提示「剩余有效时间」。

Worker 清单:Redis → DB 购物车增量同步;匿名桶过期清理;order.created 购物车清理;预占释放巡检(备份补偿)。每一项都要有 可观测执行次数与失败率


13.9 本章小结

购物车与结算域分别回答 「想买什么」「现在能不能买」 两个问题:前者弱一致、不锁资源,以 Redis + DB 双写与匿名合并保障体验;后者以 Saga 编排把计价试算、库存预占、营销校验、地址运费组合为可提交凭证,并通过幂等键与补偿释放保证韧性。清晰划分 拆单预览 vs 真正拆单试算 vs 扣券结算 vs 订单状态机,是避免边界腐化的关键。

如果把全章压成三条工程戒律,它们分别是:购物车 never lock结算 always orchestrate with timeoutssubmit always idempotent end-to-end。前两条保证体验与资源利用率,最后一条保证「用户只点一次,系统只落一单」这一最低限度的交易正义。

与全书其他章节的衔接:库存预占细节见第 8 章;计价与快照见第 11 章;订单创建、拆单与状态推进见第 14 章;支付见后续支付章节。

最后一页检查清单(发布前自问):是否在 PRD 里写清了「价格以结算为准」;是否在接口契约里禁止购物车预占;是否在订单创单接口上强制 idempotency_key;是否为 release-reserve 写了幂等测试;是否在监控里拆分 Init 与 Submit 的成功率;是否为大促准备了预占 TTL 与线程池隔离参数。六项都打勾,这一章才算真正「从文章走进了系统」。若还能补充一页 故障演练剧本(依赖逐个超时、Redis 丢键、订单重复返回),团队在真实大促里会少很多「第一次见」的慌乱。


延伸阅读与引用

  • 本书第 6 章:Saga 与幂等通用模式。
  • 本书第 8 章:库存预占、确认与释放。
  • 本书第 11 章:试算场景与快照校验。
  • 本书第 14 章:订单创建与分布式事务实践。
  • 外部参考:Microsoft Azure Architecture Center — Saga pattern;Redis Hashes 文档。

落地阅读顺序建议:先读第 11 章理解「快照从哪来」,再读第 8 章理解「预占与确认的语言」,最后读第 14 章看「订单如何把券与库存变成事实」。本章处在三者的交汇处:最容易写成「什么都能调一点的脚本服务」,也最考验你是否坚持用 编排 + 凭证 + 幂等 把复杂度关在门内。读完若只能记住一句话,建议记住:购物车是缓存意志,结算是换取凭证,订单是写下事实——三者顺序不可倒置;任何把「事实」前移到购物车或结算持久层的 shortcut,都会在客诉与对账里连本带息还回来,务必警惕为好。

导航书籍主页 | 完整目录 | 上一章 | 下一章


第14章 订单系统

本章定位:订单系统是交易链路的编排中枢。本章在《电商系统设计(七):订单系统》基础上按书籍体例重组,覆盖数据模型、状态机、创单 Saga、分布式事务(TCC / Saga)、幂等、特殊单类型、履约与系统边界;Go 示例为教学裁剪版,落地时请补全观测、鉴权与错误包装。

阅读提示:若你已读完第 11 章计价与第 13 章结算,可带着三个问题阅读——第一,订单快照与计价快照如何对齐版本;第二,库存预占发生在结算还是创单,失败时谁补偿;第三,支付域与订单域的状态机如何解耦又不丢一致性。搞清边界后,订单服务才不会长成「上帝对象」。

与第 16 章案例的衔接:在 B2B2C 聚合平台中,订单往往还要承载 供应商订单号、供应商错误码、重试策略版本 等跨域信息;本章给出的是「平台自营电商」的主干模型,落地到机票、酒店等品类时,应把供应商差异收敛到 履约网关与扩展表,避免主表字段爆炸。第 16 章 16.5.3 从全景角度回顾订单在微服务拓扑中的位置,可与本章边界小节交叉阅读。


14.1 系统概览

14.1.1 业务场景

订单系统连接用户、商品、库存、计价、营销、支付、履约与售后,典型职责包括:

  • 创单编排:校验、拆单、落库、驱动库存 / 营销等资源侧执行;
  • 状态管理:主单状态机与子域(支付、履约、售后)协同;
  • 事件发布OrderCreatedOrderPaid 等驱动异步履约与数据分析;
  • 可追溯:行级快照、金额快照、状态流水满足审计与对账。

订单系统负责流程编排与订单事实的持久化;不负责库存原子扣减实现、支付渠道路由、物流轨迹计算——这些属于各自限界上下文。

从用户旅程看,订单是「一次购买意图」在系统中的物化载体:购物车表达意图,结算页收敛约束,订单把约束写成不可抵赖的事实(谁、在什么时间、以什么价格、买了什么、用了哪些权益)。因此订单域的 API 设计应偏向命令式与幂等CreateOrderCancelOrder),而不是把计价规则或库存脚本再暴露一遍。否则网关层会出现大量「为了创单临时拼出来的 DTO」,后续每次改营销口径都要联动发版。

在多租户或 B2B2C 场景下,订单还要携带租户、店铺、销售渠道等维度,这些字段会直接影响分库键、对账口径与发票主体。建议在模型早期就固定「主单维度集合」,避免后期把店铺 ID 塞进备注字段。对客服与风控而言,订单号是串联各系统的公共关联键,应在日志规范中强制全链路透传。

14.1.2 核心挑战

挑战表现设计抓手
高并发大促创单峰值、热点用户分库分表、异步削峰、限流熔断
一致性跨库存 / 营销 / 计价多写Saga + 补偿表 + 对账
状态复杂主状态 + 子流程并行子状态机 + 显式事件 + 审计日志
类型多样实物、虚拟、O2O、预售策略模式 + 扩展点
幂等重试、回调、用户连点幂等表 + 状态机 + 业务唯一键
可追溯客诉、风控、财务快照版本、Outbox、状态历史

组织层面的补充:订单团队常被拉去「顺便」做营销试算或支付路由,短期能救火,长期会造成循环依赖与发布耦合。建议在架构评审中把「编排」与「计算 / 执行」拆开画依赖图:订单只依赖稳定的 RPC 契约,不依赖对方存储模型。

容量视角:订单创建往往是洪峰「汇聚点」——上游购物车、结算已经做过一次编排,创单仍需在短时间内完成多次 RPC。此时瓶颈常在下游库存热键、营销锁券、DB 事务持锁时间。压测时应区分「创单接口自身 QPS」与「端到端成功率」,并单独观测 Saga 每步 P99补偿队列深度,否则容易出现「接口没超时但用户看到一直转圈」的体验问题。

一致性视角:订单域最容易犯的错误,是把「用户看到的状态」与「内部资源状态」混在一张表里频繁互转。更稳妥的做法是:主单状态只表达对用户承诺的阶段;资源是否释放、券是否退回,由子域与补偿任务保证,主单只记录结果事件。这样客服解释成本更低,报表口径也更稳定。

14.1.3 系统架构

订单域内部可拆为:Order Core(创单与查询)Order State(状态机服务)Fulfillment Orchestrator(履约编排,可与核心同进程或独立部署)Projection Worker(读模型 / 搜索同步)。存储上 MySQL 承载权威订单数据,Redis 做详情缓存与短时防重,Kafka 承载领域事件;搜索侧可由 Elasticsearch 异步投影。

安全与合规:订单表包含地址、电话等个人信息,需按最小权限原则控制导出接口;对客服脱敏展示与完整审计应分角色授权。对外部合作伙伴(如供应商、物流)同步订单数据时,应通过 网关字段级裁剪 而非直接暴露内部宽表。日志中禁止打印完整支付卡号与证件号,必要时使用 tokenization 后的引用 ID。

多活与容灾:订单写路径强依赖主库可用性,跨机房多活通常采用 单元化 + 用户分片路由主从切换 策略;事件总线需配置跨集群复制与 消费者位点备份。演练时要验证:主库故障切换后 Outbox 是否重复投递、消费者是否幂等。

版本化 API:订单查询接口应对外标注 响应 schema 版本,并在字段弃用时保留兼容期;创单请求亦应携带 客户端版本号,便于回溯「哪一版 App 产生了异常参数」。在契约测试中,把 错误码表幂等语义 一并纳入回归范围,可显著降低联调返工率。

成本与存储治理:订单明细与快照会随时间线性增长,需制定 冷热分层(热数据 SSD、冷数据归档到对象存储)与 压缩策略(对大 JSON 快照启用压缩列或拆表)。同时评估 法务保留周期,避免无限期囤积个人地址数据带来合规风险;到期匿名化应与状态机终态联动触发。

开发者体验:订单域建议提供 本地夹具(fixtures)一键造单脚本,让前端与测试可在秒级生成处于各主状态的样例单;并在 Swagger / Buf 文档中写清每个错误码对应的用户提示与重试建议,减少「联调靠吼」的沟通成本。

本章与第 6 章关系:第 6 章从全局角度归纳 Saga、事件驱动与幂等模式;本章把它们落到订单这一 busiest 的编排点上,可作为第 6 章的案例化深读材料。

阅读顺序建议:若时间有限,可优先精读 14.3、14.4、14.5、14.6、14.9、14.10,其余小节作为查阅索引。

术语提示:文中英文术语如 Saga、Outbox、Process Manager 等在团队内首次落地时,请在词汇表写明中文对照与缩写规则,避免口头沟通歧义。

flowchart TB
  subgraph clients[接入层]
    GW[API Gateway]
    App[移动端 / Web]
  end
  subgraph order_bc[订单限界上下文]
    OC[Order Core 创单与查询]
    SM[State Machine 状态推进]
    FO[Fulfillment Orchestrator]
    OB[Outbox Relay]
  end
  subgraph deps[下游依赖]
    Inv[库存]
    Pr[计价]
    Mk[营销]
    Pay[支付]
    Log[物流 / 供应商履约]
  end
  subgraph data[数据面]
    DB[(MySQL)]
    R[(Redis)]
    K[Kafka]
    ES[(Elasticsearch)]
  end
  App --> GW --> OC
  OC --> Inv
  OC --> Pr
  OC --> Mk
  SM --> Pay
  FO --> Log
  OC --> DB
  OC --> R
  OB --> K
  K --> ES

读路径与写路径分流:创单、支付回调、履约回传属于写模型;「我的订单列表」高 QPS 查询可走投影表或 ES,以 eventual lag 换取扩展性,但订单详情页强一致读仍建议回源主库或带版本号的缓存。

部署与弹性:订单核心通常按「有状态尽量避免」原则设计——会话态放在 Redis 或客户端 request_id,服务端保持无状态水平扩展。Outbox Relay 与履约 Worker 可独立扩缩容,避免与大盘创单抢 CPU。跨机房时,优先保证写主就近Kafka 多副本,读副本延迟通过版本号或「刷新」按钮产品化,而不是在接口层偷偷强读从库。


14.2 订单数据模型

数据模型是订单域与周边系统对话的「共同语言」。表结构一旦轻率扩展,会在数年后以 报表口径漂移、对账困难、索引失效 的方式反噬团队。设计时建议坚持三条约束:第一,主表保持瘦——只放跨行、跨流程的汇总与阶段字段;行级细节、履约扩展、风控标签下沉子表。第二,金额字段语义单一——每个金额列在数据字典中绑定唯一业务定义(含税 / 不含税、含运费 / 不含运费),禁止复用同一列表达多种口径。第三,时间字段成体系——created_atpaid_atclosed_at 与状态机一一对应,避免用 updated_at 推断业务时刻。

在国际化与多店铺场景,模型还要提前容纳 税号、发票类型、买家身份(C 端 / B 端) 等字段,即使首期不上线,也应预留扩展位或使用 JSON 扩展表,以免后续 ALTER 大表锁死业务。与数据仓库的衔接上,建议把订单变更以 CDC 或领域事件 方式同步到 ODS,避免数仓直接扫主库大表。

14.2.1 订单表设计

主表保存订单级事实:用户、类型、主状态、金额汇总、版本号、时间戳。金额一律用**分(int64)**存储,避免浮点误差。

// Order 主表 ORM 示意
type Order struct {
	OrderID         string    `json:"order_id"`
	UserID          int64     `json:"user_id"`
	OrderType       int8      `json:"order_type"` // 1 实物 2 虚拟 3 O2O 4 预售
	Status          int16     `json:"status"`
	TotalAmount     int64     `json:"total_amount"`     // 商品应付合计(含运费等按口径)
	PaymentAmount   int64     `json:"payment_amount"`   // 用户实付
	DiscountAmount  int64     `json:"discount_amount"`  // 优惠合计
	FreightAmount   int64     `json:"freight_amount"`
	Currency        string    `json:"currency"`
	PricingSnapshot string    `json:"pricing_snapshot"` // JSON:计价中心快照 ID / 版本
	CASVersion      int64     `json:"cas_version"`
	CreatedAt       time.Time `json:"created_at"`
	UpdatedAt       time.Time `json:"updated_at"`
}

索引建议

  • 主键:order_id(Snowflake 或分段号段);
  • 查询:(user_id, created_at DESC) 支撑列表;
  • 运营:(status, updated_at) 支撑关单扫描、履约队列。

分库分表注意:若以 user_id 为分片键,需保证 订单号到分片的路由可逆(例如 order_id 内嵌分片号,或维护全局路由表)。否则仅凭 order_id 查询会退化为全分片广播。另一种常见做法是按 order_id 哈希分片、列表查询走 ES 投影表,主库只服务按单号点查与写路径。

软删除 vs 状态终态:交易订单通常不物理删除,以 CancelledClosed 等终态表达关闭;合规与审计要求下,敏感字段脱敏也应保留主键与流水。若业务误用 is_deleted 与状态机双轨,会出现「状态已支付但记录被软删」的灾难组合。

14.2.2 订单明细

明细表一行对应一个 SKU 实例,保存数量、行小计、关联商品快照 ID,可选存 分摊后的优惠 以便部分退款闭合。

type OrderLine struct {
	LineID       int64  `json:"line_id"`
	OrderID      string `json:"order_id"`
	ProductID    int64  `json:"product_id"`
	SkuID        int64  `json:"sku_id"`
	Quantity     int32  `json:"quantity"`
	SalePrice    int64  `json:"sale_price"`    // 行单价(已含当时营销结果视口径)
	LineAmount   int64  `json:"line_amount"`   // sale_price * quantity - line_discount
	LineDiscount int64  `json:"line_discount"`
	SnapshotID   string `json:"snapshot_id"`   // 商品展示快照
}

拆单:若业务要求「不同仓 / 不同商家」拆子单,可引入 shipment_group_id 或子订单表;主单与子单的支付关系需在模型层一次性说清,避免支付回调时无法定位行。

行级扩展字段:跨境、大宗或企业采购常在行上携带 税率、HS 编码、采购合同号 等。建议用 line_extra JSON 或独立扩展表,并在写入时做 schema 版本号,避免无约束 JSON 变成「第二个代码仓库」。部分退款时,需能根据行级快照与分摊规则还原「可退金额」,这与第 11 章的分摊口径强相关。

赠品与换购:赠品行可标记 line_type=gift,金额为 0 但仍占用库存与履约单元;换购行则绑定主行 parent_line_id。建模时务必让履约与库存识别「最小履约单元」,否则会出现赠品未发但主单已完成的口径争议。

14.2.3 价格快照

价格快照解决事后解释问题:用户下单后商品改价,应以快照为准。实践中常并存两层:

  1. 计价中心快照(第 11 章):规则解释结果、分层明细、舍入版本;
  2. 商品展示快照:标题、主图、规格文案,用于客服与纠纷处理。
type OrderPricingSnapshot struct {
	SnapshotID   string `json:"snapshot_id"`
	PricingJSON  []byte `json:"pricing_json"`  // 来自计价中心
	RuleVersion  string `json:"rule_version"`  // 活动 / 券批次版本
	CreatedAt    time.Time `json:"created_at"`
}

创单事务内写入 order.pricing_snapshot 外键或内嵌 JSON,支付校验阶段只校验不重新全量试算(与第 11 章口径一致),避免渠道变化导致「可支付金额漂移」。

快照与客服工单:客服系统应能按 snapshot_id 拉取当时的展示文案与分层价格,而不是实时读商品中心。否则用户截图与后台看到的不一致,纠纷成本极高。若快照体积过大,可采用 对象存储 + 哈希引用,MySQL 仅存指针与版本。

多币种Currency 与汇率快照需同事务写入;支付若支持用户切换币种,应生成新的支付上下文而不是覆盖原订单金额字段,除非业务流程明确允许改价。

14.2.4 状态历史

order_state_log 记录 from / to / operator / reason / event_name,可选挂 payment_idfulfillment_idrefund_id。与 Outbox 同事务写入,可投影 OrderStateChanged 供风控与 BI。

type OrderStateLog struct {
	LogID      int64     `json:"log_id"`
	OrderID    string    `json:"order_id"`
	FromStatus int16     `json:"from_status"`
	ToStatus   int16     `json:"to_status"`
	Event      string    `json:"event"` // PaySuccess, Ship, Delivered...
	Operator   string    `json:"operator"`
	Reason     string    `json:"reason"`
	CreatedAt  time.Time `json:"created_at"`
}

幂等辅助表(可与通用幂等表合并):idempotent_record(idempotent_key UNIQUE, biz_type, biz_id, status, expire_at),支撑创单、回调、补偿。

事件字段规范event 建议使用 过去式英文枚举(如 PaySucceeded)并在网关层统一大小写,避免日志检索分裂。operator 区分 systemuser:{id}admin:{id}payment_channel,便于审计。若与 GDPR / 个人信息保护法合规,展示层再脱敏,但底层链路保留可追责 ID。


14.3 订单状态机

14.3.1 全局状态机

线上常用「主状态 + 子状态机」:主状态面向用户与报表;支付、履约、售后各自维护子状态,通过领域事件驱动主状态迁移。

stateDiagram-v2
  direction LR
  [*] --> PendingPay: 创单成功
  PendingPay --> Paid: 支付成功
  PendingPay --> Cancelled: 超时 / 用户取消
  Paid --> Fulfilling: 进入履约
  Fulfilling --> Completed: 履约完结
  Paid --> AfterSale: 发起售后
  Fulfilling --> AfterSale: 履约中售后
  AfterSale --> Fulfilling: 售后关闭继续履约
  AfterSale --> Cancelled: 全额退款关单
  Cancelled --> [*]
  Completed --> [*]

子状态机(支付侧示意):主单进入 PendingPay 后,支付域独立维护尝试次数、渠道、风控结果。主单不应细化为「微信处理中 / 支付宝处理中」,否则订单表会被渠道维度污染;必要时用 payment_sub_status 扩展列或独立 order_payment 表承载。

stateDiagram-v2
  direction LR
  [*] --> PayInit: 创建支付单
  PayInit --> PayTrying: Try 成功
  PayTrying --> PayConfirmed: Confirm 成功
  PayTrying --> PayCancelled: Cancel / 超时
  PayConfirmed --> [*]
  PayCancelled --> [*]

PayConfirmed 事件到达订单应用服务时,才触发主单 PendingPay → Paid,并写入状态日志 event=PaySucceeded。若只有主单状态没有子单记录,排障时很难解释「用户看到支付成功但订单仍待支付」的短暂不一致窗口。

14.3.2 状态转换规则

显式白名单 表达合法迁移,禁止散落 if 隐式跳转。下表为示意矩阵(行:from,列:to, 合法)。

from \ toPendingPayPaidFulfillingCompletedCancelledAfterSale
PendingPay
Paid
Fulfilling
Completed视业务
Cancelled
AfterSale

回退约束:默认不允许 Paid → PendingPay;若支付风控冲正,应走 支付域事件 PaymentReversed,由订单编排显式迁移到 Cancelled 或特殊 PaymentFailed 终态,并触发资源回补 Saga。

并发与乱序:支付回调可能晚于用户主动取消;履约回调可能早于支付成功(脏数据)。统一策略是:任何迁移前读取当前主状态 + CAS;非法迁移记录审计并打指标,而不是「静默成功」。对疑似乱序消息可进入 延迟队列 二次投递,但要有上限避免永远悬挂。

超时关单PendingPay 超时迁移到 Cancelled 时,应同时触发 库存释放与营销解锁;若关单任务与支付回调并发,必须以数据库行锁或 CAS 决定只有一个赢家,失败方进入补偿或幂等返回。

14.3.3 状态机实现

推荐 表驱动 + CAS 更新:引擎根据 (from, event)to,再执行带 WHERE status = ? AND cas_version = ? 的更新,失败则视为并发抢占或重复事件。

Guard 条件:除 (from, event) 外,真实系统常需要额外守卫,例如「仅当 pay_expire_at 未到期才允许 PendingPay → Paid」「仅当所有子单已发货才允许 Fulfilling → Completed」。守卫逻辑建议写成 纯函数 func Guard(order *Order, ev Event) error,便于单测与可视化文档同步维护。

批量状态推进:运营后台「批量关闭异常单」属于高危操作,应走 审批流 + 异步任务,每条订单仍执行单条 CAS 与日志,避免一条 SQL 批量 UPDATE 丢失审计信息。

type Event string

const (
	EvtPayOK   Event = "PaySuccess"
	EvtTimeout Event = "PayTimeout"
	EvtShip    Event = "Shipped"
)

var transitionTable = map[int16]map[Event]int16{
	StatusPendingPay: {EvtPayOK: StatusPaid, EvtTimeout: StatusCancelled},
	StatusPaid:       {EvtShip: StatusFulfilling},
}

func ApplyEvent(ctx context.Context, repo OrderRepo, orderID string, from int16, ev Event, actor, reason string) error {
	to, ok := transitionTable[from][ev]
	if !ok {
		return ErrIllegalTransition
	}
	if err := repo.UpdateStatusCAS(ctx, orderID, from, to); err != nil {
		return err
	}
	return repo.AppendStateLog(ctx, orderID, from, to, string(ev), actor, reason)
}

14.3.4 状态变更历史

历史表与主表 CAS 同事务提交;若需 对外通知,使用 Outbox 保证「日志落库 ⇒ 消息必达」。监控上应对 illegal_transition_totalfrom,to 聚合,异常飙升多为回调乱序或重复投放。

回放与对账:状态日志是订单域最重要的法务与财务证据链之一。导出报表时,应能按时间序重放 from → toevent,并与支付流水、物流轨迹交叉验证。若仅保存终态而无过程日志,出现「用户声称未收到货」时将难以举证。

测试策略:为 transitionTable 维护单元测试矩阵,覆盖所有 (from, event);对并发场景增加 模糊测试(随机交叉回调与关单任务)。生产灰度时,可短暂打开「非法迁移采样日志」定位历史脏规则。


14.4 订单创建流程

创单可拆四步:参数校验 → 资源侧扣减 / 锁定 → 订单持久化 → 异步事件

14.4.1 参数校验

  • 用户风控、地址、发票抬头;
  • SKU 可售、限购、黑白名单;
  • 计价快照版本与当前试算差异阈值(超限则拒绝创单提示刷新);
  • 营销资格(券批次、人群标签)。

校验分层:建议拆为 语法校验(网关)权限校验(用户会话)业务不变式校验(订单域)。不变式包括:行数上限、单用户并发创单上限、敏感 SKU 需二次验证等。不要把所有校验堆在单个「上帝函数」里,可按 Pipeline 组织,便于单测与观测每步耗时。

与结算一致性:若用户从结算页提交,创单请求应携带 结算会话 ID 或 booking_token(第 11 章),订单域校验其未过期且未被消费。否则会出现「结算页显示可买,创单失败」的合理但体验差场景——需用明确错误码驱动前端刷新。

拆单预览:若创单前已展示拆单结果,创单请求应携带 拆单版本号;服务端重新计算拆单,不一致则拒绝以保护用户预期。

14.4.2 库存扣减

与第 8 章对齐:创单常用 Reserve(预占)Deduct(直接扣),取决于品类 deduct_timing。失败快速返回,不必进入订单落库。

失败语义:库存返回 INSUFFICIENT_STOCKHOTKEY_THROTTLED 应对前端不同提示;后者可触发排队或重试策略。订单域不应把供应商超时简单映射为库存不足,以免误导用户。

跨仓:多仓 reserve 应按「子单顺序」或「固定 SKU 字典序」调用,降低死锁概率;任一子仓失败应整体失败并释放已成功部分。

14.4.3 营销扣减

营销侧提供 Lock / Deduct + Compensate 语义;订单携带 promotion_trace_id,保证券与积分操作可回滚。

券与积分顺序:若同时用券与积分,营销接口应保证 原子锁;订单侧避免先发两次 RPC 再本地补偿。常见实现是营销提供 BatchLockBenefits 单接口,内部保证事务。

风控联动:营销返回「疑似黄牛」时,订单应快速失败并记录 risk_trace_id,避免进入复杂 Saga 后再被风控拦截,浪费库存预占窗口。

14.4.4 订单持久化

主单 + 明细 + 快照同事务写入;PendingPay 之前可存在短暂 Draft 态用于灰度,但对外接口应合并为一次成功语义。

Saga 编排总览

flowchart TD
  A[开始 CreateOrder] --> B[校验与组装上下文]
  B --> C[库存 Reserve / Deduct]
  C -->|失败| Z[结束失败]
  C --> D[营销 Lock / Deduct]
  D -->|失败| R1[补偿: 库存释放]
  D --> E[写订单 Draft/PendingPay]
  E -->|失败| R2[补偿: 营销回滚]
  E -->|失败| R3[补偿: 库存释放]
  E --> F[提交事务 + Outbox]
  F --> G[发布 OrderCreated]
  G --> H[结束成功]
  R1 --> Z
  R2 --> Z
  R3 --> Z

与结算(第 13 章)边界:若结算页已预占库存,创单应携带 reservation_token幂等转正式占,避免二次扣减。

完整 Saga 编排(Go 示意):下列代码将库存、营销、落库串为编排型 Saga;InsertOrder 与 Outbox 应在同一数据库事务内,保证事件与订单一致。

type CreateOrderSaga struct {
	Inv InventoryClient
	Mk  MarketingClient
	Repo OrderRepo
	Req  *CreateRequest
}

func (s *CreateOrderSaga) Run(ctx context.Context) error {
	steps := []struct {
		name string
		do   func(context.Context) error
		undo func(context.Context) error
	}{
		{"reserve_stock", func(ctx context.Context) error {
			return s.Inv.Reserve(ctx, &ReserveInput{Token: s.Req.ReservationToken, Lines: s.Req.Lines})
		}, func(ctx context.Context) error {
			return s.Inv.Release(ctx, s.Req.ReservationToken)
		}},
		{"lock_promo", func(ctx context.Context) error {
			return s.Mk.Lock(ctx, &LockInput{OrderDraftID: s.Req.DraftID, UserID: s.Req.UserID})
		}, func(ctx context.Context) error {
			return s.Mk.Unlock(ctx, s.Req.DraftID)
		}},
		{"persist_order", func(ctx context.Context) error {
			return s.Repo.InsertOrderWithOutbox(ctx, buildOrder(s.Req))
		}, func(ctx context.Context) error {
			return s.Repo.DeleteDraft(ctx, s.Req.DraftID)
		}},
	}
	var done int
	for i, st := range steps {
		if err := st.do(ctx); err != nil {
			for j := done - 1; j >= 0; j-- {
				_ = steps[j].undo(ctx)
			}
			return fmt.Errorf("step %s: %w", st.name, err)
		}
		done = i + 1
	}
	return nil
}

Outbox 同事务InsertOrderWithOutbox 内应写入 outbox 表,relay 进程异步投递 OrderCreated。若先写 Kafka 再写 DB,崩溃窗口会导致「消息已发但单未落库」的幽灵订单。

典型故障复盘(示意):某次大促中,创单接口错误率飙升,监控显示库存 RPC P99 正常,但营销锁券出现大量超时。根因是订单线程池被 同步调用营销 + 同步写大事务 占满,健康检查仍返回 OK。改进包括:为营销调用单独 bulkhead 线程池;将非关键校验异步化;把 InsertOrder 事务拆小,先写主键占位再补全明细(需配合幂等与补偿)。该案例说明:编排层的背压与隔离 和下游性能同样重要。

灰度与压测:新营销规则上线前,应在预发环境用 影子流量 回放生产采样请求,对比新旧路径差异;创单接口要暴露 feature flag 控制是否启用新 Saga 步骤,避免一次性全量切换。

订单草稿(Draft)模式:高客单价或复杂 Bundling 场景,用户可能多次编辑地址与优惠券。可引入 草稿单 存储中间态,正式创单时把草稿 ID 作为幂等维度的一部分;草稿 TTL 与购物车 TTL 应协调,避免用户以为「已下单」实际草稿过期。草稿转正时仍需重新校验库存与价格,不可盲信草稿内缓存

拆单与父子单支付:一次支付覆盖多子单时,支付回调需携带 平台支付单号 → 子订单列表 的映射;若映射缺失,会出现一笔支付成功但部分子单仍处于待支付。建议在支付创建阶段由支付服务持久化该映射,订单服务只消费标准化结构。


14.5 分布式事务

14.5.1 TCC 模式

Try / Confirm / Cancel 三阶段,适合强一致资源预留,典型在支付(见第 15 章)。订单创单一般不强行 TCC 全链路,以免参与方过多拖垮可用性。

type PaymentTCC interface {
	Try(ctx context.Context, req *PaymentRequest) (*PaymentResource, error)
	Confirm(ctx context.Context, res *PaymentResource) error
	Cancel(ctx context.Context, res *PaymentResource) error
}

空回滚与幂等:Cancel 必须容忍 Try 未落地;Confirm / Cancel 重试需基于支付单状态短路。

悬挂与对账:Try 超时后业务可能已判定失败并走 Cancel,但晚到的 Try 成功仍可能落库,造成资源悬挂。需要 Try 超时撤销任务渠道对账 双向收敛;订单域记录 try_expires_at 与渠道流水号,便于夜间批处理纠偏。

func (p *PaymentCancel) Cancel(ctx context.Context, rid string) error {
	rec, err := p.store.GetReserve(ctx, rid)
	if errors.Is(err, ErrNotFound) {
		return nil
	}
	if rec.Phase == PhaseCanceled {
		return nil
	}
	if rec.Phase != PhaseTried {
		return ErrInvalidPhase
	}
	return p.store.MarkCanceled(ctx, rid)
}

14.5.2 Saga 模式

Saga 将长事务拆为可补偿的本地事务序列。订单创单、超时关单、售后退款均适合 Orchestration(编排):由订单服务顺序调用下游,失败则逆序补偿。

type Step struct {
	Name       string
	Try        func(ctx context.Context) error
	Compensate func(ctx context.Context) error
}

type Saga struct{ steps []Step }

func (s *Saga) Run(ctx context.Context) error {
	var done []Step
	for _, st := range s.steps {
		if err := st.Try(ctx); err != nil {
			for i := len(done) - 1; i >= 0; i-- {
				if cerr := done[i].Compensate(ctx); cerr != nil {
					// 记录补偿失败任务,进入异步重试
					_ = cerr
				}
			}
			return err
		}
		done = append(done, st)
	}
	return nil
}

编排 vs 协同:编排型 Saga 便于集中超时、重试、指标;协同型通过事件总线解耦,但排查「谁该补偿」困难。订单创建推荐 编排为主、事件为辅:同步阶段用编排保证用户得到明确成功 / 失败;异步阶段用事件驱动履约。协同式在售后长尾流程中更常见,但仍建议有 流程实例表 记录当前步骤,避免纯粹靠消息隐式状态。

隔离与雪崩:Saga 每步应设置 独立超时与熔断,避免单个下游拖垮整个创单线程池。失败返回时携带 标准化错误码(库存不足、券不可用、风控拒绝),网关映射为 HTTP 4xx,避免一律 500。

14.5.3 选型对比

维度TCCSaga
一致性更强,资源预留明确最终一致
改造参与方需三接口正向 + 补偿即可
适用支付、金融类创单、售后、长流程
复杂度中,高在补偿治理
flowchart TD
  Q1{资金 / 支付渠道是否核心参与方?}
  Q1 -->|是| TCC[TCC 或渠道原生两阶段]
  Q1 -->|否| Q2{步骤>3 且可接受中间态?}
  Q2 -->|是| SG[Saga 编排 + 补偿表]
  Q2 -->|否| LOCAL[单库事务 + Outbox 单步事件]

14.5.4 补偿机制

补偿分 同步逆序异步重试队列 两级:同步阶段只处理可快速失败回滚;库存 / 支付等慢 IO 失败写入 compensation_task,由 Worker 指数退避重试,超过阈值人工工单。

优先级:资金相关 > 库存 > 营销权益,降低「钱已退券未退」类客诉风险。

func EnqueueCompensation(ctx context.Context, store TaskStore, t *CompensationTask) error {
	switch t.BizType {
	case "payment":
		t.Priority = 1
	case "inventory":
		t.Priority = 2
	default:
		t.Priority = 3
	}
	return store.Insert(ctx, t)
}

人工介入:补偿失败超过阈值应生成工单,附带 Saga 上下文 JSON(每步请求 / 响应摘要、trace_id)。不要在告警里只写「补偿失败」,否则 on-call 无法快速判断是库存还是营销。

与消息一致性:若补偿需要发「回滚券」消息,仍建议走 Outbox,避免补偿 RPC 成功但消息丢失导致用户仍看到券被锁。

两阶段提交(2PC)在订单域的位置:强一致 2PC 在互联网大规模订单核心路径已较少采用,但在 与财务总账同一数据库集群 的小范围场景仍可能出现。若团队评估引入全局事务协调器,应充分评估 阻塞窗口与单点风险;多数电商场景仍以 Saga + 对账替代。

事务边界与领域事件:本地事务内应只包含「本聚合必须同事务成功」的最小集合。把「写订单 + 写审计 + 写 Outbox」放在同事务是合理的;把「写订单 + 调库存远程提交」放在同一本地事务则不可行。事件命名建议采用过去式领域语言(OrderCreated),订阅方据此触发投影或履约,而不是用命令式 CreateShipment 事件污染语义。


14.6 幂等性设计

14.6.1 幂等性原则

  • 技术幂等:HTTP 重试、唯一约束防双插;
  • 业务幂等:同一业务键多次提交得到同一业务结果(返回首单)。

14.6.2 实现方案

  1. 数据库唯一键idempotent_key
  2. Redis SETNX:短 TTL 防抖;
  3. 状态机短路:非法迁移直接返回成功或明确错误码;
  4. 业务外键order_id + coupon_id 唯一扣减记录。

处理中状态:幂等表应有 processing 态,防止客户端疯狂重试导致风暴;可配合 429 + Retry-Afterprocessing 超时由后台任务回收或允许用户重放(需业务决策)。

时钟与 TTLexpire_at 应略大于业务 SLA(如 24h),并定期清理历史幂等记录,避免表无限膨胀。清理前需确认该键不再可能被渠道重放。

14.6.3 各场景实现

场景幂等键说明
创单user_id + client_request_id插入抢占,成功返回已有订单
支付回调channel_order_idpayment_id + event防渠道重放
物流回调shipment_id + logistics_status防状态重复推进
售后退款refund_id 分步幂等每步独立去重表
func CreateOrderIdempotent(ctx context.Context, repo OrderRepo, req *CreateRequest) (*Order, error) {
	key := fmt.Sprintf("order:create:%d:%s", req.UserID, req.ClientToken)
	rec := &IdempotentRecord{Key: key, BizType: "order_create", Status: StatusProcessing}
	if err := repo.InsertIdempotent(ctx, rec); err != nil {
		if existing, e := repo.GetIdempotent(ctx, key); e == nil && existing.Status == StatusSuccess {
			return repo.GetOrder(ctx, existing.BizID)
		}
		return nil, ErrDuplicateInProgress
	}
	order, err := runCreateSaga(ctx, repo, req)
	if err != nil {
		_ = repo.MarkIdempotent(ctx, key, StatusFailed)
		return nil, err
	}
	_ = repo.MarkIdempotentSuccess(ctx, key, order.OrderID)
	return order, nil
}

支付回调三重防重(与第 15 章衔接):幂等表 + 主状态机 + Confirm 内部状态短路,缺一不可。只依赖状态机会在「重复回调但状态已前进」时误报失败;只依赖幂等表会在表损坏时放大风险。

监控:按 biz_type 统计幂等命中、冲突、处理中滞留;对 idem_conflict_rate 设定基线告警,异常上升通常意味着客户端重试策略变更或渠道重放策略调整。

客户端协作:移动端在弱网下会放大重试;应在 SDK 层统一生成 client_request_id 并持久化到本地,直到收到明确成功响应才清理。Web 端则可用 sessionStorage 暂存,刷新页面不丢失。服务端返回 409 + 已有订单号 优于静默成功,便于客户端跳转「订单详情」。

安全视角:幂等键不应可被枚举猜测;对匿名下单场景,应绑定 设备指纹或会话令牌,防止撞库式刷接口。对公开回调接口必须 验签 + IP 白名单 + Replay window


14.7 特殊订单类型

14.7.1 虚拟订单

无物流,支付后 即时履约(发券、开通权益)。状态机在 Paid 后可直接进入 Granting → Completed,失败重试必须 发放流水唯一约束

stateDiagram-v2
  [*] --> PendingPay
  PendingPay --> Paid: 支付成功
  Paid --> Granting: 触发履约
  Granting --> Completed: 权益到账
  Granting --> GrantFailed: 下游失败
  GrantFailed --> Granting: 重试 / 人工回放
  PendingPay --> Cancelled: 超时

资损防控:虚拟履约接口必须具备 可查询结果 能力(QueryGrantResult),以便在超时后判断是「已成功未回包」还是「确实失败」。否则重试可能造成重复发放,不重试又可能用户付款无权益。

与会员体系:若权益挂在账号维度,订单应记录 目标账号(本人 / 赠送人),避免客服手工改绑带来的审计缺口。

14.7.2 O2O 订单

强调 门店 / 骑手 / 超时。支付后进入 PendingAccept,商家超时未接单触发关单与补偿。

func CreateO2OOrder(ctx context.Context, lbs LBS, req *CreateRequest) error {
	storeID, err := lbs.ResolveStore(ctx, req.Lat, req.Lng, req.SKUs)
	if err != nil {
		return err
	}
	req.StoreID = storeID
	return runCreateSaga(ctx, orderRepo, req)
}

运力与派单:订单应保存 store_idexpected_arrival 等业务字段;配送系统回写骑手位置不属于订单核心表,可进入履约扩展表或实时查询接口。

取消与部分退款:O2O 常出现「商家缺货部分退款」,需要行级部分退与状态机协同;主单可能仍处于履约中,财务上已发生部分结算,报表需单独建模。

14.7.3 预售订单

定金 + 尾款 拆两张支付单;库存使用 Reserve 锁定到尾款结束。尾款超时释放预订并关单。

stateDiagram-v2
  [*] --> PendingDeposit
  PendingDeposit --> DepositPaid: 定金成功
  PendingDeposit --> Cancelled: 未付超时
  DepositPaid --> PendingBalance: 开启尾款期
  PendingBalance --> BalancePaid: 尾款成功
  PendingBalance --> Cancelled: 尾款超时
  BalancePaid --> Fulfilling: 进入发货流程

财务与税务:定金可能不计入收入确认节点,尾款成功后才触发 ERP 凭证;订单模型应能区分 两笔支付子单一笔主单 的映射,避免对账系统把定金当全款。

库存语义:定金阶段常用 软预留(不占可售库存但占名额)或 硬预留(直接减可售),需与供应链共识;尾款失败释放策略必须可观测,否则大促会出现「幽灵占用」。


14.8 订单履约

14.8.1 履约流程

支付成功发布 OrderPaid,履约 Worker 消费后创建物流单或调用供应商 API;主单状态从 Paid 进入 Fulfilling,再随物流事件细化。

stateDiagram-v2
  [*] --> PendingShipment: 支付成功
  PendingShipment --> Shipped: 仓库发货
  Shipped --> InTransit: 揽收
  InTransit --> Delivered: 签收
  Delivered --> Completed: 确认收货 / 超时自动确认

异步 Worker 设计:消费 OrderPaid 时应 幂等检查主状态,避免重复创建物流单;创建失败应区分可重试与不可重试。DLQ 消息要包含 order_idattempt 计数,支持人工重放。

拆包裹与多包裹:一单多包裹时,用子表 order_shipment 记录多个运单;主状态聚合规则需定义(例如全部签收才 Delivered,或第一件发货即 PartialShipped 子状态)。

14.8.2 供应商对接

对机票、酒店等供应商品类,履约即 供应商下单 / 出票 / 确认;需映射供应商返回码到统一 FulfillmentErrorCode,并支持 重试与人工工单

幂等与供应商:供应商接口常「同一请求重试返回同一确认号」,订单侧应保存 supplier_request_id 与响应指纹,避免重复下单造成双份资源。

降级:供应商大面积故障时,可暂停自动履约、进入 人工队列,并在订单详情展示「处理中」与预计时间,减少进线量。

14.8.3 状态同步

物流回调与供应商 Webhook 必须 签名验证 + 幂等表;异步链路用 Kafka 削峰,失败进 DLQ 并保留原始消息体。

func OnLogisticsEvent(ctx context.Context, repo OrderRepo, ev *LogisticsEvent) error {
	key := fmt.Sprintf("logistics:%s:%s", ev.ShipmentID, ev.Status)
	if err := repo.InsertIdempotent(ctx, key); err != nil {
		return nil
	}
	return repo.ApplyLogisticsMapping(ctx, ev)
}

时间语义:物流状态常带时间戳,订单应保存 供应商时间戳 + 接收时间,便于跨时区纠纷。自动确认收货的计时一般从 Delivered 事件时间起算,而非支付时间。

履约 SLA 与用户体验:对虚拟与 O2O,用户更敏感于「多久到账 / 多久送达」。应在订单详情展示 预计完成时间区间,该区间由履约服务根据历史分位数计算,而不是订单域拍脑袋写死常量。超时未履约应自动触发 补偿或客服工单,并给用户明确状态而非无限「处理中」。

仓配一体与自提:自提点、门店自提等模式会改变履约状态机,新增 ReadyForPickupPickedUp 等节点;主单聚合规则需重新定义「完成」含义(取货扫码 vs 离店)。这些差异最好通过 履约子状态机配置 注入,而不是复制一套订单主表。


14.9 系统边界与职责

14.9.1 订单系统的职责边界

负责:创单编排、主状态机、订单事实存储、领域事件、补偿编排入口。
不负责:支付渠道协议、库存原子脚本、营销规则解释、物流路由算法。

反模式清单:在订单库里维护「渠道费率表」、在订单服务里直连 Elasticsearch 做商品搜索、在订单进程里跑供应商长轮询——这些都会让订单成为最难部署的巨石。应通过 防腐层 + 专门服务 吸收。

14.9.2 订单 vs 支付:边界划分

订单提供 应付金额事实与支付上下文;支付服务生成 支付单 并对接渠道。回调默认进入 支付域,由其校验签名后调用订单 Application Service 推进状态,避免订单服务直接解析多渠道报文导致膨胀。

金额变更:除部分业务(邮费后补、税费调整)外,支付金额应以订单快照为准;若必须改价,应走 订单变更单 或关闭旧单重建,而不是在支付回调里悄悄改 payment_amount

14.9.3 订单 vs 物流:边界划分

订单保存 运单号快照 与履约状态;轨迹查询走物流查询服务。订单不应存储全量轨迹点。

异常协同:丢件、拒收、改址属于物流域流程,结果以事件通知订单;订单负责更新主状态与触发售后入口,而不是在订单服务内嵌物流公司客服规则。

14.9.4 订单作为编排者的角色

订单是 Process Manager:定义 Saga 顺序、超时、补偿策略;各子域提供幂等 API。编排者不缓存子域权威数据副本(除快照外)。

可观测性:编排者应输出 结构化 Saga 日志saga_idsteplatencyresult),并在 Trace 中把下游 Span 串为子节点,否则大促排障只能看「创单慢」而无法定位瓶颈环节。

14.9.5 订单 vs 履约:职责划分

订单给出「应履约什么」;履约服务决定「如何履约」:拆包裹、选承运商、对接供应商 API。虚拟品可将履约内嵌 Worker,但仍建议独立模块便于扩缩容。

售后交错:履约中发起售后时,应冻结部分发运或拦截未发商品;订单主状态进入 AfterSale 后,履约 Worker 需识别 继续履约 / 中止 的策略位,避免「已退款仍发货」的严重事故。


14.10 与其他系统的集成

14.10.1 与商品中心集成(快照读取)

创单只读 商品只读视图 + 快照构建器,禁止直接 join 商品运营库。快照哈希可复用第 7 章策略。

变更传播:商品标题、类目变更不应反写历史订单;若发生合规下架,应通过 风控事件 拦截新创单,而不是修改已落库快照。对 SEO 友好的长描述可不入库,仅保留客服需要的最小字段集以控存储。

14.10.2 与库存系统集成(扣减与回退)

统一使用库存暴露的 Reserve / Commit / ReleaseDeduct / Restore 语义;订单保存 reservation_id 便于超时释放。

支付后确认:若品类要求支付后才向供应商下单,创单阶段可能是 软占,支付成功后再 Commit;订单需记录两阶段 token,关单时只释放对应阶段。

14.10.3 与计价系统集成(价格快照)

创单请求携带 pricing_token,计价服务返回 不可变快照;订单落库字段与支付校验字段保持一致。

舍入与分摊:快照应包含 行级分摊结果尾差处理规则版本(第 11 章),否则部分退款时营销与财务会对不上。支付校验只验证「渠道应付 == 快照应付」在允许误差内。

14.10.4 与营销系统集成(锁定与扣减)

先锁后付:创单锁券,支付成功转实扣;关单 / 超时统一走营销回滚接口,携带 order_id 幂等。

平台与商家券:若一单混合出资,营销回滚需支持 按比例撤销;订单应保存 subsidy_split 片段,避免退款时无法拆分平台补贴与商家让利。

14.10.5 与支付系统集成(支付触发)

订单在 PendingPay 调用支付 创建支付单 API;之后状态迁移以支付回调为准,前端轮询仅作体验辅助。

前端轮询:轮询间隔应指数退避并设上限;服务端对同一用户并发轮询限流,防止把订单库打挂。更优方案是 SSE / WebSocket 推送支付结果,但仍要以回调为准。

14.10.6 与供应商系统集成(履约)

供应商网关隔离协议差异;订单只面向网关请求 CreateSupplierOrder,不感知对方 SOAP / REST 细节。

契约版本:网关应携带 供应商契约版本号,订单落库保存该版本,便于供应商升级后追溯「老单走老协议」。

14.10.7 集成编排:Saga 模式实践

下图展示创单阶段与外部系统的 时序编排(示意,省略签名与重试)。

sequenceDiagram
  participant U as 用户
  participant O as 订单服务
  participant P as 计价
  participant I as 库存
  participant M as 营销
  participant D as DB
  participant K as Kafka
  U->>O: CreateOrder(req)
  O->>P: ValidatePricingToken
  P-->>O: PricingSnapshotOK
  O->>I: ReserveStock
  I-->>O: reservation_id
  O->>M: LockPromotions
  M-->>O: lock_token
  O->>D: Tx: Insert Order + Lines + Outbox
  D-->>O: OK
  O->>K: OrderCreated(event)
  O-->>U: 201 + order_id
  Note over O,I: 任一步失败则逆序 Release / Unlock 并返回错误码

14.10.8 失败补偿与重试策略

  • 可重试错误(网络抖动):有限次重试 + 指数退避;
  • 业务拒绝(库存不足):立即失败,不做无意义重试;
  • 补偿失败:入补偿表,定时拉升 + 人工兜底;
  • 监控compensation_backlogsaga_step_latencyduplicate_callback_total

跨系统对账:每日按 订单支付成功总额 与支付系统、营销补贴账、供应商结算单进行三方抽样对账;出现差异时,以支付渠道流水为资金真相,以订单行快照为业务真相,营销与库存走补偿闭环。

集成测试建议:在 CI 中维护 合约测试(Pact 类)对库存 / 计价 / 营销的关键响应做快照校验;订单编排层的集成测试应覆盖「任一步失败」与「补偿失败入队」两条主线。


14.11 工程实践

14.11.1 订单 ID 生成

Snowflake 为主流:趋势递增、无 DB 往返。需处理 时钟回拨(拒绝生成并告警)与 workerId 分配(etcd / DB 租约)。

type Snowflake struct {
	mu        sync.Mutex
	epochMs   int64
	workerID  int64
	sequence  int64
	lastMs    int64
}

func (s *Snowflake) Next() (int64, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	now := time.Now().UnixMilli()
	if now < s.lastMs {
		return 0, ErrClockMovedBackwards
	}
	if now == s.lastMs {
		s.sequence = (s.sequence + 1) & 0xFFF
		if s.sequence == 0 {
			for now <= s.lastMs {
				now = time.Now().UnixMilli()
			}
		}
	} else {
		s.sequence = 0
	}
	s.lastMs = now
	id := ((now - s.epochMs) << 22) | (s.workerID << 12) | s.sequence
	return id, nil
}

号段模式:部分银行或票据场景要求数字更短,可采用 DB / Redis 号段批量领取;代价是需要容灾切换时的 跳号容忍 与对账。

14.11.2 性能优化

  • 分库分表键:优先 user_idorder_id 哈希;
  • 热点用户:队列合并写、缓存扇出;
  • 读多写少:详情 Redis + 短 TTL,写后删缓存;
  • 异步:Outbox relay 批量发送。

批量写与合并:秒杀场景可用 请求合并(coalesce)将短时间窗口内的创单请求排队合并调用库存;需评估公平性与尾延迟,并设最大等待时间。

func PublishOutbox(ctx context.Context, tx Tx, topic string, payload []byte) error {
	msg := OutboxMessage{Topic: topic, Payload: payload, Status: StatusPending}
	return tx.InsertOutbox(ctx, &msg)
}

14.11.3 监控告警

指标示例:order_create_success_ratepending_pay_timeout_countpay_callback_latencyfulfillment_retry_countidem_conflict_rate。日志必须带 order_idtrace_ididempotent_key

告警分级compensation_backlog 连续升高为 P0;illegal_transition_total 小量抖动可为 P2 观察。夜间告警需合并同根因,避免「短信轰炸」导致真正 P0 被忽略。

容量演练:定期做 限流阈值演练Kafka 分区迁移演练,验证订单写路径在极端情况下的降级开关是否生效(例如暂停非核心消息投影)。


14.12 本章小结

本章从数据模型出发,强调订单主表、明细、快照与状态历史四位一体:主表表达用户可见阶段,明细绑定商品快照与分摊信息,计价快照锁定金额解释,状态日志提供审计证据链。在状态机层面,主单与子域(支付、履约、售后)协同,所有迁移走白名单 + CAS,乱序与并发通过幂等与行级竞争消解。

创单流程中,我们以 Saga 编排库存、营销与本地落库,以 Outbox 保证事件可靠投递;分布式事务层面区分 TCC(支付资金)与 Saga(长流程资源),并配套补偿优先级与人工工单。幂等贯穿创单、回调与履约,三重防重降低资损概率。特殊订单类型通过差异化状态机与策略扩展点接入,避免污染核心路径。履约侧则强调异步、幂等与供应商契约版本。

系统边界看,订单是交易编排者而非执行者:计价解释、库存原子、支付渠道、物流轨迹各有其主;订单负责把它们的结果事实串成可审计的业务故事。掌握这些原则后,阅读第 15 章支付系统时,可重点关注 支付单状态机如何回调驱动订单、以及 清结算如何与订单快照对齐

面试与答辩串联:若需要在白板前讲解订单系统,推荐顺序为:先画主状态机,再画创单 Saga 时序,再补幂等键与补偿表,最后点出「订单不直连渠道」。评委追问超卖时,回到库存 Reserve 与 CAS;追问重复支付时,回到支付幂等与订单状态机;追问消息丢失时,回到 Outbox。把四条线闭合,通常比堆技术名词更有说服力。

演进建议:早期团队可用「单服务 + 清晰包边界」模拟限界上下文,待调用链路过长再拆 Order / Payment / Fulfillment。拆分时优先把 回调入口定时任务 迁出,因为它们最容易与核心创单抢资源。无论是否微服务,本章强调的 快照、状态机、Saga、幂等 四件套都仍然成立。

下一章将进入支付系统,重点讨论 渠道回调、清结算与资金安全,请保持「订单事实不变,支付驱动状态」的心智模型继续阅读。


导航书籍主页 | 完整目录 | 上一章 | 下一章

导航书籍主页 | 完整目录 | 上一章 | 下一章


第15章 支付系统

本章定位:支付系统是交易链路的资金收口外部渠道适配中心。本章在《电商系统设计(八):支付系统深度解析》的基础上,按书籍体例展开:分层架构、主链路时序、状态机、退款与营销核算、清结算与对账、幂等与一致性、系统边界与集成。文中 Go 示例为教学裁剪版,落地时请补全观测、鉴权、错误包装与依赖注入。

阅读提示:读本章时建议始终带着三个问题——谁拥有支付单的真相状态回调与主动查询以谁为准闭合差错入账如何可审计回滚。把这三个问题回答清楚,就能把「渠道差异」与「分布式一致性」从口号落到可执行的工程清单。

与全书脉络的关系:第 11 章计价给出「应付金额的合理解释」,第 13 章结算完成「资源锁定」,第 14 章订单沉淀「业务承诺」,本章则把承诺兑现为外部世界的扣款事实;第 16 章会把支付放回 B2B2C 聚合场景,观察供应商履约与渠道结算如何共同挤压支付边界。


15.1 业务背景

15.1.1 支付系统的定位

在电商交易链路中,订单系统负责「承诺与履约编排」,计价系统负责「金额解释与快照」,而支付系统负责把应付金额转化为真实资金转移(或渠道侧支付承诺),并把结果以可审计、可幂等、可对账的方式回写给订单、营销、财务等协作方。

支付系统通常同时服务三类角色:

  • 消费者(C 端):收银台展示、组合支付、支付结果通知、退款进度查询;
  • 商家与平台(B 端):结算、提现、分账视图、对账差错工单;
  • 内部中台:风控、财务核算、客服调账、数据仓库离线口径。

因此,支付系统既不是「订单的子模块」,也不是「财务系统的渠道壳」。更合理的边界是:支付域管理支付单、渠道交互、资金事实;订单域管理履约状态机;财务域管理会计科目与总账口径。三者通过事件与对账任务最终对齐。

交易形态看,实物电商更强调「支付成功 → 库存扣减 / 发货」的串联;虚拟商品与 B2B2C 聚合平台则更强调「支付成功 → 供应商下单 / 出票」的外部 API 依赖。支付系统不应把供应商履约细节写进支付单,但必须在扩展字段中保留可追溯的外联单号(例如渠道子商户号、分账接收方),否则一旦出现拒付或 chargeback,运营与风控很难在分钟级定位问题根因。

组织协作看,支付团队往往与财务、风控、客服、数据治理交叉。接口契约里最容易被忽略的不是「字段类型」,而是语义口径:例如「成功」到底指渠道受理成功、银行扣款成功,还是可结算到账。建议在支付域建立统一语言表(状态枚举、事件名、对账字段含义),并在评审门禁中与订单、财务双签,避免线上出现「订单已支付但财务未入账」的真空地带。

15.1.2 核心业务场景

场景说明工程要点
标准支付微信 / 支付宝 / 银行卡 / 余额等渠道路由、渠道限额、组合支付顺序
退款全额 / 多次部分退款可退金额闭合、营销资金回冲、渠道退款单号
清结算平台佣金、商家应收、营销补贴分账模型版本、结算周期、冻结与解冻
对账交易对账、资金对账、差错闭环文件解析幂等、长短款分类、人工复核 SLA
风控与合规限额、黑白名单、反洗钱报送(视地区)规则引擎、审计日志、敏感字段脱敏
争议与拒付chargeback、调单、凭证上传证据链留存、订单快照关联、工单闭环
企业采购对公转账、账期支付、开票衔接支付单与应收单映射、核销流程

场景串联示例:用户在结算页确认应付 199 元 → 订单系统生成待支付订单并携带计价快照 → 支付系统创建支付单并把金额与快照版本绑定 → 用户选择微信 → 网关路由到指定商户号与费率套餐 → 回调成功后 Outbox 投递 ORDER_PAID → 库存与营销在各自消费者内幂等确认。任何一步回滚都必须明确补偿顺序(先释放营销锁定还是先关支付单),否则会出现「钱没付但券没了」的资损路径。

15.1.3 核心挑战

维度具体问题典型技术回应
资金安全重复支付、伪造回调、金额篡改验签、幂等键、乐观锁、不可变流水
高并发大促峰值、热点商户限流、异步化、渠道 QPS 配额与降级
最终一致性支付成功与订单已支付不同步Outbox、本地消息表、补偿扫描
渠道异构字段、状态、回调时序不一致网关适配器、统一渠道模型、对账闭合
可运营性差错处理、调账、追溯工单系统、操作审计、双人复核
观测与排障渠道抖动、跨系统扯皮TraceID 贯通、支付会话回放、原始报文留存策略
合规与隐私PCI、卡号、证件信息最小采集、字段加密、密钥托管、脱敏展示

挑战之间的耦合需要提前说清:例如「极致性能」往往鼓励异步化与缓存,但「资金安全」又要求强审计与落库完整性;「渠道快速接入」若缺少网关隔离,会把各渠道 if-else 泄漏到支付核心,最终形成不可测试的巨石。折中办法通常是:热路径短事务 + 冷数据异步落盘 + 严格分层单测与对账兜底——性能靠结构与容量规划解决,而不是靠跳过幂等校验解决。


15.2 整体架构

15.2.1 分层架构

支付平台常见分层如下:接入层(多端统一鉴权与防重)、应用服务层(支付编排、退款编排、运营查询)、领域核心层(支付单聚合、账户、清结算规则)、渠道适配层(网关与 SPI)、基础设施层(数据库、缓存、消息、任务调度)。分层的目标不是画框,而是让变更原因单一:换渠道不应驱动清结算规则重写,改分账比例不应污染回调验签逻辑。

flowchart TB
  subgraph access[接入层]
    A1[商城 App / H5]
    A2[商家后台]
    A3[Open API / Webhook 入口]
  end

  subgraph app[应用服务层]
    B1[支付编排服务]
    B2[退款编排服务]
    B3[对账与差错服务]
    B4[运营查询 / 工单]
  end

  subgraph core[领域核心层]
    C1[支付核心:支付单 / 状态机]
    C2[账户:余额 / 冻结 / 流水]
    C3[清结算引擎:分账 / 批次]
    C4[风控:规则 / 名单 / 限额]
  end

  subgraph channel[渠道适配层]
    G1[支付网关]
    G2[微信适配器]
    G3[支付宝适配器]
    G4[银行 / 其他]
  end

  subgraph infra[基础设施层]
    D1[(MySQL)]
    D2[(Redis)]
    D3[Kafka]
    D4[定时调度 / 队列 Worker]
  end

  subgraph third[第三方]
    E1[微信支付]
    E2[支付宝]
    E3[网联 / 银联 / 其他]
  end

  A1 --> B1
  A2 --> B2
  A3 --> B1
  B1 --> C1
  B1 --> C4
  B1 --> G1
  B2 --> C1
  B2 --> G1
  B3 --> D1
  C1 --> D1
  C2 --> D1
  C2 --> D2
  C3 --> D4
  G1 --> D3
  G1 --> G2
  G1 --> G3
  G1 --> G4
  G2 --> E1
  G3 --> E2
  G4 --> E3

15.2.2 支付网关

支付网关对外暴露统一的 CreatePaymentQueryPaymentRefundParseNotify 等接口,对内以策略 + 适配器组合消化渠道差异。网关层应内聚以下能力:

  • 渠道路由:按支付方式、费率、成功率、灰度比例、商户号维度选择具体适配器;
  • 报文转换:统一内部 ChannelCommand 与外部 API 字段映射;
  • 签名与加密:各渠道证书、公钥轮换、时钟偏移容错;
  • 超时与重试策略:仅对可安全重试的读操作与幂等写操作重试;
  • 观测:按 channelmerchant_noscene 打点,便于大促排障。

15.2.3 支付核心

支付核心维护支付单聚合根、状态机、流水与幂等索引。它不应直接调用「原始 HTTP 渠道 SDK」,而应依赖网关接口,从而保证领域模型不被渠道 JSON 污染。支付核心还应产出领域事件(如 PaymentSucceeded),通过 Outbox 保证与数据库状态同事务。

15.2.4 支付渠道

渠道层实现的是「如何把统一命令变成第三方可理解请求」。实践中建议每个渠道独立模块(Go package),统一实现小接口集合,例如:

// ChannelClient 描述支付网关对单个渠道的抽象。
// 真实系统还应包含上下文、超时控制、指标与可观测字段。
type ChannelClient interface {
	PreCreate(ctx context.Context, in PreCreateInput) (PreCreateOutput, error)
	QueryTrade(ctx context.Context, in QueryTradeInput) (TradeStatus, error)
	Refund(ctx context.Context, in RefundInput) (RefundOutput, error)
	ParseNotify(ctx context.Context, httpHeader map[string][]string, body []byte) (NotifyEvent, error)
}

渠道差异集中在:同步返回是否可信(多数场景仅表示受理)、回调到达顺序退款是否同步到账对账文件粒度。网关要把这些差异折叠为有限的内部枚举(如 ACCEPTEDSUCCEEDEDFAILEDUNKNOWN),避免把渠道字符串透传到订单域。

子系统职责对照(面试与评审常用):

子系统核心职责关键数据典型反模式
账户系统余额、冻结、流水、充值提现账户余额、冻结单、账务流水把渠道手续费写进用户余额宽表
支付网关路由、报文、签名、重试渠道请求 / 响应摘要、路由决策在网关里改支付单状态机
支付核心支付单、退款单、幂等、OutboxpaymentrefundoutboxHandler 直连第三方 SDK
清结算引擎分账、批次、结算周期分账明细、结算单、提现单清结算回调里直接操作支付单
风控系统限额、名单、挑战风控决策流水风控结果不落库导致无法复盘

数据落库建议:支付单主表保持「窄」——只放状态、金额、币种、渠道标识、幂等键、版本号;大报文、扩展参数、渠道原始回调进入扩展表或对象存储,查询走异步索引。这样可以在大促时显著降低行更新放大效应,同时满足合规留存。


15.3 支付流程

主链路可以概括为:创建支付单(幂等)→ 渠道路由与预下单 → 用户交互 → 异步回调 / 主动查单 → 发布成功事实 → 下游投影。其中「回调」与「查单」是双保险:回调丢包时必须靠主动查询 + 补偿任务闭合。

15.3.1 支付创建

创建支付单的关键是稳定幂等键金额校验。幂等键建议至少覆盖:order_id + payer_id + pay_scene(如收银台二次发起)。数据库层以唯一索引兜底,缓存锁仅作为热点优化而非唯一手段。

type CreatePaymentCmd struct {
	OrderID         int64
	PayerID         int64
	PayScene        string
	PayableAmount   int64 // 分
	IdempotencyKey  string
	ExpireAt        time.Time
}

func (s *PaymentService) CreatePayment(ctx context.Context, cmd CreatePaymentCmd) (*Payment, error) {
	if cmd.IdempotencyKey == "" {
		return nil, fmt.Errorf("missing idempotency key")
	}
	// 1) 先查:快速幂等返回
	if p, err := s.repo.FindByIdempotencyKey(ctx, cmd.IdempotencyKey); err == nil && p != nil {
		return p, nil
	}
	// 2) 插入:唯一约束冲突则回查
	p := NewPayment(cmd)
	if err := s.repo.Insert(ctx, p); err != nil {
		if IsDuplicateKey(err) {
			return s.repo.FindByIdempotencyKey(ctx, cmd.IdempotencyKey)
		}
		return nil, err
	}
	return p, nil
}

金额校验应读取订单侧「应付快照」或「支付试算结果」,在支付核心内做二次比对(容忍 0 还是容忍营销舍入误差需产品口径明确)。禁止仅依赖前端传参。

创单后常见并发展示:用户双击支付、客户端重试、网关超时重放。除了数据库唯一索引,仍建议在应用层返回明确可理解的幂等响应(HTTP 409 或业务码 PAYMENT_ALREADY_CREATED),让前端可以稳定切换到「轮询支付结果」而不是再次创单。

15.3.2 渠道路由

路由策略常见维度:用户支付方式偏好余额是否充足渠道费率渠道健康度商户号维度黑白名单。路由输出应写入支付单扩展字段,便于事后追溯「为何走了该渠道」。

路由在工程上可抽象为「评分函数」:对每个候选渠道计算加权分,选择最高分。下面示例演示余额优先 + 渠道兜底的简化策略(真实系统还需接入风控否决与渠道健康度面板):

type RouteInput struct {
	BalanceFen      int64
	PayableFen      int64
	PreferredMethod string // WECHAT / ALIPAY / BALANCE
}

type RouteDecision struct {
	UseBalance int64
	UseChannel string
}

func Route(in RouteInput) RouteDecision {
	if in.PreferredMethod == "BALANCE" && in.BalanceFen >= in.PayableFen {
		return RouteDecision{UseBalance: in.PayableFen, UseChannel: ""}
	}
	if in.BalanceFen > 0 && in.BalanceFen < in.PayableFen {
		// 组合支付:余额抵扣一部分,剩余金额走渠道侧收银台。
		return RouteDecision{UseBalance: in.BalanceFen, UseChannel: in.PreferredMethod}
	}
	return RouteDecision{UseBalance: 0, UseChannel: in.PreferredMethod}
}

灰度与容灾:路由层应能按百分比切流到新渠道、在新渠道错误率超阈值时自动回滚到旧渠道。此类「动态路由」必须有审计记录,否则财务对账会发现同一商户号在不同日期走了不同费率套餐却无法解释。

15.3.3 支付回调

回调处理必须「先验签、再幂等、再状态机推进、再副作用」。副作用包括:写流水、记渠道交易号、插入 Outbox 事件。任何一步失败都要有可重试明确失败码,避免渠道端无限重试雪崩。

回调入口建议独立部署(甚至独立集群),与创单读多路径隔离,避免大促时查询流量挤占回调写路径。入口层完成 TLS、限流、IP 白名单后,应尽快把报文写入原始回调表(append-only),再异步处理——这样即使后续逻辑发布回滚,也不会丢凭证。

func firstHeader(headers map[string][]string, key string) string {
	v := headers[key]
	if len(v) == 0 {
		return ""
	}
	return v[0]
}

func VerifyNotify(channel string, headers map[string][]string, body []byte, pubKey string) error {
	// 伪代码:不同 channel 选择不同验签算法与字段拼接顺序。
	sig := firstHeader(headers, "X-Signature")
	if sig == "" {
		return fmt.Errorf("missing signature")
	}
	ok, err := verifyChannelSignature(channel, body, sig, pubKey)
	if err != nil {
		return err
	}
	if !ok {
		return fmt.Errorf("bad signature")
	}
	return nil
}

func verifyChannelSignature(channel string, body []byte, sig string, pubKey string) (bool, error) {
	// 落地时在此处分发到各渠道验签实现。
	return true, nil
}

15.3.4 状态同步

支付成功后,订单系统需要进入「已支付 / 待发货」等状态。推荐用 Transactional Outbox:支付成功与 order_paid 事件同事务提交,再由 Dispatcher 投递到消息系统,订单消费者重试直至成功或进入死信人工处理。

为什么不推荐支付直接 RPC 订单同步更新:回调线程会被订单可用性绑架;一旦订单服务抖动,容易出现「支付已成功但本地事务回滚」的灾难组合。Outbox 把跨系统写入变成同库同事务,失败面显著收敛。

同步查询路径:用户在收银台返回 App 后,前端会高频轮询支付结果。轮询应读取本地支付单状态缓存(短 TTL),命中失败再穿透数据库;穿透时要防止缓存击穿打爆主库。

sequenceDiagram
    autonumber
    participant U as 用户终端
    participant O as 订单服务
    participant P as 支付核心
    participant G as 支付网关
    participant C as 第三方支付

    U->>O: 提交订单 / 请求收银台
    O->>P: CreatePayment(含应付金额、幂等键)
    P->>P: 校验金额与订单状态
    P->>P: 持久化支付单(PENDING)
    P->>G: PreCreate(路由后调用具体渠道)
    G->>C: 统一下单 / 预支付
    C-->>G: 返回支付参数 / 跳转信息
    G-->>P: 标准化受理结果
    P-->>U: 返回收银台渲染数据
    U->>C: 用户完成鉴权与支付
    C-->>G: 异步回调(notify)
    G->>P: ParseNotify + 验签
    P->>P: 状态机:PAYING -> SUCCESS(幂等)
    P->>P: 同事务写入 Outbox(ORDER_PAID)
    P-->>G: 响应 SUCCESS(按渠道要求)
    Note over P,O: Dispatcher 读取 Outbox
    P-->>O: 投递 ORDER_PAID 事件
    O->>O: 订单状态 -> PAID(幂等)

15.4 支付状态机

15.4.1 支付状态

建议将支付单状态控制在可理解且可枚举的集合内。示例(可按业务增删):

  • PENDING:已创单,尚未唤起渠道;
  • PAYING:已唤起渠道,等待最终结果;
  • SUCCESS:支付成功;
  • FAILED:明确失败,可重新发起(是否允许换渠道由产品决定);
  • CLOSED:超时关单或业务关闭;
  • PARTIAL_REFUNDED:仍存在可退余额;
  • REFUNDED:已无可退余额(含全额退款累计闭合)。

15.4.2 状态转换

状态转换必须集中校验,禁止在 Handler 内随手 UPDATE status='SUCCESS'。下面给出集中规则表思路(节选):

var allowed = map[string][]string{
	"PENDING":  {"PAYING", "CLOSED"},
	"PAYING":   {"SUCCESS", "FAILED", "CLOSED"},
	"SUCCESS":  {"PARTIAL_REFUNDED", "REFUNDED"},
	"PARTIAL_REFUNDED": {"PARTIAL_REFUNDED", "REFUNDED"},
}

func CanTransit(from, to string) bool {
	nexts, ok := allowed[from]
	if !ok {
		return false
	}
	for _, n := range nexts {
		if n == to {
			return true
		}
	}
	return false
}

15.4.3 超时处理

PAYING 状态建议配置支付超时时间(与渠道侧 TTL 对齐),由定时任务扫描:

  1. QueryTrade 主动确认,防止「用户已付但回调丢失」;
  2. 若渠道仍返回处理中,推迟下次扫描(指数退避);
  3. 若超过最大等待仍不明,标记 UNKNOWN 或保持 PAYING 并提升告警,禁止直接 SUCCESS

状态历史表强烈建议与业务表解耦:每次迁移插入 payment_status_history(old,new,actor,reason)。客服在工单系统里追问「谁把支付单改成 SUCCESS」时,历史表比 grep 日志可靠得多。若还需满足合规审计,可对历史表做只追加与定期归档。

退款单状态机(与支付单联动,字段命名示例):

stateDiagram-v2
    [*] --> RF_PENDING: CreateRefund
    RF_PENDING --> RF_PROCESSING: 调用渠道退款
    RF_PROCESSING --> RF_SUCCESS: 渠道确认成功
    RF_PROCESSING --> RF_FAILED: 明确失败
    RF_FAILED --> RF_PROCESSING: 人工重试 / 改派渠道
    RF_SUCCESS --> [*]
type RefundStatus string

const (
	RefundPending    RefundStatus = "PENDING"
	RefundProcessing RefundStatus = "PROCESSING"
	RefundSuccess    RefundStatus = "SUCCESS"
	RefundFailed     RefundStatus = "FAILED"
)

func RefundCanTransit(from, to RefundStatus) bool {
	switch from {
	case RefundPending:
		return to == RefundProcessing
	case RefundProcessing:
		return to == RefundSuccess || to == RefundFailed
	case RefundFailed:
		return to == RefundProcessing
	default:
		return false
	}
}
stateDiagram-v2
    [*] --> PENDING: CreatePayment
    PENDING --> PAYING: PreCreate 成功
    PENDING --> CLOSED: 主动关单 / 订单取消
    PAYING --> SUCCESS: 回调或查单确认成功
    PAYING --> FAILED: 明确失败
    PAYING --> CLOSED: 超时 + 查单无成功
    SUCCESS --> PARTIAL_REFUNDED: 部分退款成功累计
    PARTIAL_REFUNDED --> PARTIAL_REFUNDED: 继续部分退款
    PARTIAL_REFUNDED --> REFUNDED: 剩余可退为 0
    SUCCESS --> REFUNDED: 全额退款完成

15.5 退款流程

15.5.1 退款创建

退款单应独立建模,关联 payment_idorder_id,并具备自己的幂等键(如 order_id + refund_batch_no)。创建退款单时需要:

  • 校验支付单处于可退状态;
  • 校验退款权限(售后窗口、履约状态由订单域返回或事件驱动);
  • 锁定「可退余额」计算,防止并发双退。

并发双退的典型漏洞是「两次请求同时读到相同已退金额」。工程上可用数据库行锁SELECT ... FOR UPDATE 锁支付单)或原子 SQLUPDATE payment SET refunded = refunded + ? WHERE id=? AND paid-refunded>=?)保证上限。若退款跨多个支付单(组合支付),要么在订单域生成退款编排单一次性下发,要么在支付域引入分布式锁 / 事务消息串行化。

// 以下为退款创建事务骨架:CreateRefundCmd / Refund / lockPaymentForRefund 由项目定义。
func (s *RefundService) CreateRefund(ctx context.Context, cmd CreateRefundCmd) (*Refund, error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()

	p, err := lockPaymentForRefund(ctx, tx, cmd.PaymentID)
	if err != nil {
		return nil, err
	}
	if p.Status != StatusSuccess && p.Status != StatusPartialRefunded {
		return nil, fmt.Errorf("payment not refundable")
	}
	refundable := p.PaidFen - p.RefundedFen
	if cmd.AmountFen <= 0 || cmd.AmountFen > refundable {
		return nil, fmt.Errorf("invalid refund amount")
	}
	r := NewRefund(cmd)
	if err := insertRefund(ctx, tx, r); err != nil {
		return nil, err
	}
	return r, tx.Commit()
}

15.5.2 可退金额计算

可退金额应以支付成功时的实付为上限,扣减已成功退款单金额,并处理营销侧「平台承担 / 商家承担 / 用户让渡」的拆分。教学示例:

type Money = int64 // 分

func Refundable(paid Money, refunded Money) (Money, error) {
	if paid < 0 || refunded < 0 {
		return 0, fmt.Errorf("invalid money")
	}
	if refunded > paid {
		return 0, fmt.Errorf("refunded overflow")
	}
	return paid - refunded, nil
}

真实系统还要处理:运费是否可退行级分摊是否已闭合跨境税额等,这些规则应读取订单退款域算好的结构化结果,而不是在支付服务里拍脑袋重算。

当订单存在「平台券抵扣」时,常见业务口径是:按本次退款占实付比例回冲营销账。下面给出与博客一致的比例思路(教学版,舍入策略需统一):

type RefundBreakdown struct {
	RefundCashFen      int64
	RefundPromotionFen int64
}

// 假设 promotion 由平台承担,需要单独记账回冲;现金部分走渠道退款。
func AllocatePromotionRefund(paidFen, promotionFen, refundCashFen int64) RefundBreakdown {
	if paidFen <= 0 || refundCashFen <= 0 {
		return RefundBreakdown{RefundCashFen: refundCashFen}
	}
	if promotionFen <= 0 {
		return RefundBreakdown{RefundCashFen: refundCashFen}
	}
	// 按比例拆分营销回冲;生产请使用 decimal 或整数比避免累积误差。
	promo := (promotionFen * refundCashFen) / paidFen
	cash := refundCashFen - promo
	return RefundBreakdown{RefundCashFen: cash, RefundPromotionFen: promo}
}

15.5.3 部分退款

每一次部分退款生成独立退款单,记录渠道退款单号。支付单维度的 refunded_amount 单调递增,直到等于 paid_amount 才进入 REFUNDED。若业务需要展示「第 N 次退款」,应对退款单列表做分页查询。

15.5.4 营销退款

当订单存在平台券、满减、积分抵现时,退款往往不仅是「把钱退回支付渠道」,还包括:

  • 营销资产回冲:券是否退回、积分是否返还;
  • 补贴冲销:平台补贴在清结算层的冲减分录。

支付系统应消费订单域提供的退款分解单(RefundBreakdown),将其映射为支付退款 + 财务应收应付调整。不要在支付回调里直接调用营销扣减接口的长链路同步调用,避免放大故障半径。

sequenceDiagram
    participant U as 用户
    participant O as 订单服务
    participant P as 支付核心
    participant G as 支付网关
    participant C as 第三方支付
    participant M as 营销系统

    U->>O: 申请退款
    O->>O: 售后校验 / 生成退款分解
    O->>P: CreateRefund(幂等键 + 分解单)
    P->>P: 计算可退并落退款单
    P->>G: 渠道退款
    G->>C: Refund API
    C-->>G: 受理 / 同步结果
    G-->>P: 标准化结果
    P->>P: 更新退款单与支付单累计
    P-->>O: RefundSucceeded 事件
    O->>M: 异步冲销补贴 / 退券(可编排)

15.6 清结算与对账

15.6.1 分账模型

清结算层把单笔支付成功事实拆成多方应收应付:平台佣金商家货款渠道手续费营销补贴等。模型要点:

  • 分账版本:规则应版本化,支付单引用 split_rule_version
  • 最小粒度:通常到「子订单 / 明细行」级别,避免汇总误差;
  • 冻结与解冻:未到结算日期的资金先记入「待结算余额」,防止重复提现。

示例(简化,不含税与渠道费):用户实付 100 元,平台佣金 5%,商家货款 95%,营销补贴由平台另行记账,不重复从商家侧扣减。

角色口径金额(元)说明
用户实付100支付单记录
平台佣金5清结算生成应收
商家货款95进入待结算余额
渠道手续费按渠道账单往往单独维度对账

分账计算输入应来自支付成功事件 + 订单行快照,而不是实时去读商品中心促销价,否则会出现「支付按 A 规则、结算按 B 规则」的结构性差错。

15.6.2 T+N结算

T+N 表示在交易发生日 T 之后第 N 个工作日完成可提现或完成渠道结算。工程上要区分:

  • 渠道结算周期(微信支付宝对平台);
  • 平台对商家账期(业务合同)。

两者不一致时,现金流收入确认可能不同步,财务口径由会计政策决定,技术侧提供可追溯批次与明细即可。

提现限额属于清结算风控交叉域:既要满足合规,又要避免误伤正常商家。可参考如下校验骨架:

func ValidateWithdraw(ctx context.Context, merchantID int64, amountFen int64, sumToday int64) error {
	const singleLimit = 50_000_00 // 50 万(分)
	const dailyLimit = 200_000_00
	if amountFen > singleLimit {
		return fmt.Errorf("single withdraw limit exceeded")
	}
	if sumToday+amountFen > dailyLimit {
		return fmt.Errorf("daily withdraw limit exceeded")
	}
	return nil
}

sumToday 由仓储层查询当日已提现金额后传入,避免示例函数隐式依赖未定义符号。

15.6.3 对账流程

对账的本质是:用第三方权威数据校准本地事实。本地事实应至少包括支付单、渠道流水号、金额、手续费、清算日期。对账任务必须幂等:同一日的文件重复拉取不应产生重复分录。

实现要点:先把第三方文件标准化为 ReconRow{trade_no, amount, fee, currency, trade_time},再与本地 payment_channel_log 做外连接。Join 键应优先使用渠道交易号,其次才是商户订单号(部分渠道存在换单号)。对账批任务写入 recon_batch 表,明细写入 recon_diff,避免直接在支付单上打补丁丢失审计链。

func DiffOne(local, remote ReconRow) string {
	switch {
	case local.TradeNo == "":
		return "LONG"
	case remote.TradeNo == "":
		return "SHORT"
	case local.AmountFen != remote.AmountFen:
		return "AMOUNT_MISMATCH"
	default:
		return "OK"
	}
}
flowchart TD
  A[定时触发 T 日对账] --> B[拉取渠道对账文件 / API]
  B --> C[解析入库 staging]
  C --> D[按 channel_trade_no join 本地流水]
  D --> E{差异检测}
  E -->|无差异| F[生成对账成功批次]
  E -->|长款| G[第三方有本地无]
  E -->|短款| H[本地有第三方无]
  E -->|金额不一致| I[金额 / 币种 / 手续费差异]
  G --> J[差错工单 + 自动修复策略评审]
  H --> J
  I --> J
  J --> K[人工复核 / 调账 / 补单]
  K --> F

15.6.4 差错处理

差错应分类闭环:数据修复类(补记支付成功)、重复记账类(幂等破坏,需冻结)、金额差异类(舍入、币种转换、部分退款叠加)。下面示例演示「将差错写入不可变表 + 状态机」的思路:

type ReconIssueType string

const (
	IssueLong  ReconIssueType = "LONG"  // 渠道多
	IssueShort ReconIssueType = "SHORT" // 本地多
	IssueAmt   ReconIssueType = "AMOUNT_MISMATCH"
)

type ReconIssue struct {
	ID               int64
	Type             ReconIssueType
	ChannelTradeNo   string
	LocalPaymentID   int64
	ExpectedAmountFen int64
	ActualAmountFen   int64
	Status           string // OPEN / APPROVED / FIXED
	CreatedAt        time.Time
}

func (s *ReconService) OpenIssue(ctx context.Context, issue ReconIssue) error {
	// 插入唯一键:(channel, channel_trade_no, type) 防止重复开单
	return s.repo.InsertIssue(ctx, issue)
}

自动修复只应对极少数确定性场景开放(例如「回调晚到导致短款」且查单已证实成功),且必须双人复核或二次审批,避免自动化把资金风险放大。

人工复核材料包应一键生成:本地流水、渠道流水、原始回调、查单响应、订单快照、客服沟通记录链接。没有材料包的差错工单往往会在团队之间空转数日,最后靠「某个老员工记得当时切了灰度」来收场——这是可复用的技术债。

长短款的业务含义也要培训到位:长款不等于立刻给用户加余额,短款也不等于立刻从商家扣回;它们首先是对账系统的待确认差异项,必须经过规则引擎与人工阈值判断,避免把运营操作变成新的资金风险源。


15.7 幂等性与一致性

15.7.1 支付幂等

幂等键分层建议:

场景幂等键实现要点
创建支付单业务方传入 Idempotency-KeyDB 唯一索引 + 冲突回查
渠道预下单payment_id 映射 out_trade_no渠道侧 out_trade_no 唯一
回调处理channel_trade_no + 支付单 version验签后乐观锁更新
退款创建refund_idempotency_key与订单退款单绑定
消息消费event_id / 业务唯一键consumer 侧去重表或状态条件更新

幂等与「恰好一次」:分布式系统里更现实的目标是效果幂等——重复执行不会产生额外副作用。消息系统通常是至少一次投递,因此消费者必须能扛重复。

与第6章的衔接:支付是幂等设计「压力最大的考场」,因为它同时承受用户重试、渠道重放、内部补偿三路冲击。建议把幂等键规范写成跨团队接口标准(HTTP Header 命名、长度、字符集、过期策略),否则订单、支付、营销各自发明一套键,联调阶段会指数级爆炸。

15.7.2 重试机制

重试划分为三类:

  1. 用户端重试:按钮防抖 + 服务端幂等;
  2. 同步调用重试:仅对幂等读、幂等写(带键)执行有限次退避;
  3. 异步补偿重试:Outbox、消息队列、定时查单,需要最大重试次数 + 死信队列
func Retry(ctx context.Context, attempts int, base time.Duration, fn func() error) error {
	var err error
	for i := 0; i < attempts; i++ {
		if err = fn(); err == nil {
			return nil
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(base * time.Duration(1<<i)):
		}
	}
	return err
}

退避与抖动:对渠道主动查询类任务,应加随机抖动,避免整点对渠道形成查询尖峰。对内部消息重试,应区分可重试错误(网络)与业务错误(余额不足),后者重试只会放大噪音。

15.7.3 补偿任务

补偿任务清单建议包括:

  • 支付结果补偿PAYING 超时查单;
  • 通知订单补偿:Outbox 未投递;
  • 退款结果补偿:退款受理中查单;
  • 对账补偿:文件拉取失败重试。

补偿任务要有全局锁或分区调度,避免多实例重复打满渠道 QPS。

Saga + 本地消息表(Outbox 的等价实现):当团队尚未引入独立 Outbox 组件时,可用「支付成功 + 本地消息」同事务,定时任务扫描投递。

import (
	"database/sql"
	"encoding/json"
)

type LocalMessage struct {
	ID         int64
	Topic      string
	Payload    []byte
	Status     string // PENDING/SENT/FAILED
	RetryCount int
	CreatedAt  time.Time
}

func marshalOrderPaid(orderID, paymentID int64) ([]byte, error) {
	return json.Marshal(map[string]any{
		"order_id":   orderID,
		"payment_id": paymentID,
	})
}

func OnPaymentSuccessTx(tx *sql.Tx, paymentID, orderID int64) error {
	if _, err := tx.Exec(`UPDATE payment SET status='SUCCESS' WHERE id=?`, paymentID); err != nil {
		return err
	}
	payload, err := marshalOrderPaid(orderID, paymentID)
	if err != nil {
		return err
	}
	_, err = tx.Exec(`
		INSERT INTO local_message(topic,payload,status,retry_count,created_at)
		VALUES('ORDER_PAID', ?, 'PENDING', 0, NOW())
	`, payload)
	return err
}
sequenceDiagram
    participant P as 支付核心
    participant DB as 数据库
    participant JOB as 投递任务
    participant MQ as 消息队列
    participant O as 订单服务

    P->>DB: BEGIN
    P->>DB: UPDATE payment SUCCESS
    P->>DB: INSERT local_message PENDING
    P->>DB: COMMIT
    JOB->>DB: SELECT PENDING LIMIT N
    JOB->>MQ: Publish ORDER_PAID
    MQ-->>O: deliver
    O->>O: 幂等更新订单 PAID
    JOB->>DB: UPDATE local_message SENT

TCC 何时值得:当余额类扣减与渠道退款需要短窗口内强一致,且参与者可控(内部服务)时,TCC 仍有一席之地。但其运维成本、悬挂事务处理、监控接入都显著高于 Saga。多数电商支付主链路仍以 Saga + 对账 为主,TCC 用于账户冻结 / 营销锁定等局部。

一致性小结:支付与订单之间优先接受最终一致,用「事务边界内的状态 + Outbox」保证至少一次投递;消费者侧必须幂等。强一致场景(如余额 + 渠道同时扣减)谨慎使用 TCC,成本高且难维护。

账户余额与缓存(常见追问):若余额读走 Redis,必须定义回源与修复策略(定时对账或以 MySQL 为准覆盖)。支付扣减建议「数据库为权威 + Redis 仅作加速」,否则容易出现 Redis 与 DB 长时间分叉不自知。


15.8 系统边界与职责

边界章节的判据很简单:如果某个需求改动会让支付团队与订单团队同时大改表结构,通常说明边界画错了。好的边界让「最常变」的渠道差异停在网关,让「最不该变」的资金状态机停在支付核心。

15.8.1 支付系统的职责边界

支付系统应拥有:支付单与退款单渠道交互与回调验签支付侧流水与幂等索引触发清结算批次的事实渠道对账原始凭证关联。不应拥有:订单履约、物流、商品库存数量真相(除非余额支付与账户强绑定)。

反模式清单(面试与评审可直用):在支付服务里写供应商下单、在支付服务里计算运费、在支付回调里直接改 SKU 库存、在支付库里维护商品税率版本。它们共同症状是:支付发布频率被迫与业务域绑定,任何小改动都触碰资金链路。

15.8.2 支付 vs 订单:谁负责什么

主题订单域支付域
应付金额解释引用计价快照校验快照与支付单金额
支付状态PAID 等业务状态SUCCESS 等资金状态
关单 / 取消驱动是否允许继续支付执行关单并同步渠道撤销(若支持)
售后退款策略是否允许退、退多少(业务规则)执行资金退回与累计已退
发票与税务展示订单展示口径提供支付流水号、渠道单号

关单竞态:用户支付最后一秒订单被取消,或支付成功回调晚于关单。必须在订单状态机定义终态优先级(例如「已支付优先于待支付关闭」),支付侧也要能识别「订单已关但支付已成功」并进入异常工单而不是静默吞掉。

15.8.3 支付 vs 财务清结算

支付系统产出资金事实与分账明细,财务系统将其映射为会计凭证税务口径。不要在支付库直接记总账。

技术团队常低估的点:财务需要期间币种折算信息,而支付系统常只存「展示币种」。若平台做多币种,应在支付成功事件中固化清算币种与汇率来源,否则月末调账会演变成跨团队扯皮。

15.8.4 平台支付 vs 第三方支付渠道

平台支付(余额、礼品卡)往往走账户系统闭环,仍需流水与对账;第三方支付走渠道。组合支付要定义失败回滚顺序(例如先渠道后余额或相反),并在状态机里显式建模。

部分成功是组合支付的最大坑:渠道成功、余额扣减失败如何处理?常见策略是:先扣内部可控资源,再调渠道(降低外部不可控失败面),或在产品层直接禁止某些组合。无论哪种,都要写进用户可见的错误文案与客服话术。

15.8.5 支付 vs 钱包 / 余额

余额属于预付价值,涉及充值、提现、冻结、监管要求(视地区)。建议独立「账户子域」,支付核心通过账户服务完成扣减,避免把账户表与支付单表强耦合在同一张宽表。

钱包若支持「零钱 + 银行卡」混合,仍建议把零钱视作内部渠道走同一套路由与对账框架,这样运营监控可以统一看「渠道成功率」,而不是另起炉灶一套报表。


15.9 与其他系统的集成

集成章节的共同目标是:把支付系统变成可替换、可观测、可回滚的协作节点,而不是「所有系统都要在支付回调里串一圈」的上帝节点。

15.9.1 与订单系统集成(状态同步)

订单系统应订阅 ORDER_PAID 事件或通过同步 API(弱不推荐)更新状态。无论哪种,订单更新接口必须幂等:重复 payment_id 不应推进到非法状态。

推荐事件载荷至少包含:order_idpayment_idpaid_amount_fencurrencypricing_snapshot_versionpaid_at。订单侧据此做二次校验(金额是否与创单快照一致),不一致进入人工工单而不是静默成功。

15.9.2 与营销系统集成(补贴核算)

营销补贴如果是支付时分账,需要明确分账参与方与失败重试;如果是事后结算,支付成功事件应携带可被清结算消费的补贴分解标识。

若营销侧需要「支付成功后才真正扣减预算」,必须定义失败回滚语义:支付关单时发送 PAYMENT_CLOSED,营销消费者释放锁定;若营销扣减失败但支付已成功,应进入异步补扣或人工处理,绝不能反向把支付单改成失败。

15.9.3 与用户系统集成(余额 / 积分)

余额支付应走 Deduct -> ConfirmTry -> Confirm/Cancel 的可补偿路径,并与支付单状态机关联。积分抵现建议由订单 / 计价域先行锁定,支付成功后再确认扣减,失败则释放。

余额账户建议提供可查询的冻结单号与支付单关联,客服排障时可以直接回答「这笔钱对应哪笔冻结」。积分系统若延迟较高,应避免在支付回调线程同步等待。

15.9.4 与第三方支付渠道集成

渠道集成要点:证书轮换时钟同步回调 IP 白名单沙箱与生产隔离配置。网关层提供模拟器(mock)支撑联调。

生产环境还需准备:渠道公告订阅(费率、维护窗口)、密钥到期提醒多商户号容灾(主商户异常时切备用)。渠道 SDK 升级应走灰度,并用回放样本验证验签与解析路径。

15.9.5 与财务系统集成(分账与对账)

向财务导出结算批次明细行,并保证金额字段为整数分、附带币种与汇率快照。任何手工调账必须留下审计记录。

财务更关心会计期间科目映射:技术侧输出应携带 biz_datesettlement_batch_idmerchant_idfee_item。避免让财务同学从 JSON 大字段里手工抠数。

15.9.6 集成异常处理与重试

对下游失败应区分:可重试(网络抖动)不可重试(业务拒绝)需要人工(数据不一致)。消息消费者应使用幂等处理表或业务唯一键防重复消费。

典型异常:订单服务短暂不可用 → Outbox 堆积;解决思路是扩容消费者、限流非核心订阅、并对核心 ORDER_PAID 单独 topic 保障 SLA。另一类异常是订单返回成功但内部逻辑部分失败(例如库存服务超时)——这属于订单域自己的 Saga,支付侧不应「自作主张退款」,除非产品明确配置自动拒单策略。

15.9.7 回调幂等性保证

回调幂等的工程清单:

  1. 验签失败直接拒绝,记录原始报文哈希;
  2. 以渠道交易号为天然幂等键,数据库唯一索引;
  3. 状态推进使用乐观锁versionstatus + updated_at 条件更新);
  4. 成功响应只在本地事务提交后返回,避免「渠道认为成功、本地实际失败」;
  5. 对重复回调返回与首次一致的业务成功响应,避免渠道无限重试。
func (s *NotifyHandler) Handle(ctx context.Context, ev NotifyEvent) error {
	return s.tx.Run(ctx, func(tx Tx) error {
		p, err := tx.LockPayment(ctx, ev.OutTradeNo)
		if err != nil {
			return err
		}
		if p.Status == StatusSuccess {
			return nil
		}
		if !CanTransit(string(p.Status), string(StatusSuccess)) {
			return fmt.Errorf("invalid transit: %s -> SUCCESS", p.Status)
		}
		if err := tx.UpdatePaymentSuccess(ctx, p.ID, p.Version, ev); err != nil {
			return err
		}
		return tx.EnqueueOutbox(ctx, OutboxOrderPaid{OrderID: p.OrderID, PaymentID: p.ID})
	})
}

15.10 工程实践

15.10.1 多渠道接入

新渠道接入建议清单:沙箱对齐用例集、字段映射表、对账文件样本、异常码枚举、回调重放工具、灰度开关(按商户 / 百分比)。

建议为每个渠道维护兼容性矩阵:API 版本、最低 SDK 版本、已知缺陷列表(例如某版本退款接口延迟)。上线前用「同一批黄金用例」在沙箱回放,避免只在 happy path 自测。

15.10.2 性能优化

热点路径:创单读多写少可用缓存;回调写路径应短事务,只更新必要列;大字段(原始报文)异步落对象存储。避免在回调线程同步调用多个下游。

数据库层可对 payment(status,updated_at)refund(payment_id,status) 建立合适组合索引;对 channel_trade_no 建立唯一索引支撑幂等。大促前做容量评估:预估回调峰值、写入 QPS、消息投递延迟,并准备只读副本承载客服查询。

15.10.3 监控告警

最低限度指标:notify_latencynotify_fail_ratepaying_timeout_countoutbox_backlogrecon_open_issuesrefund_unknown_count。每条指标应能下钻到 payment_id

告警阈值建议分层:页面级(影响用户支付成功率)、资金级(对账差异、短款)、运维级(证书到期、磁盘满)。资金级告警必须带跳转链接到工单或 Runbook,减少 On-call 临场检索成本。

15.10.4 资金安全

原则:最小权限密钥双人复核调账不可变审计日志敏感信息脱敏展示关键操作二次验证。技术方案之外,运营流程同样是系统的一部分。

建议每年至少进行一次红队演练或渗透测试,覆盖伪造回调、重放报文、越权查询他人支付单等路径;密钥使用 KMS / HSM 托管,开发人员默认不应接触生产明文私钥。


15.11 本章小结

本章从业务背景出发,给出了支付平台的分层架构图,并以时序图贯穿「创单 → 路由 → 回调 → Outbox 同步订单」的主链路;用状态机图约束支付单生命周期,并补充退款单状态联动;在清结算与对账部分给出分账示例、T+N 口径区分、对账 join 思路与差错闭环流程图;在幂等与一致性部分拆分创建、回调、退款、消息消费等幂等键,并给出指数退避重试、补偿任务调度、Saga + 本地消息表与 TCC 选型边界;最后通过系统边界对外集成回扣订单、营销、用户、渠道、财务协作中的异常分层与回调幂等清单。

若把本章压缩成上线前检查表,可以只保留八条:唯一幂等键验签先于业务状态机集中校验成功响应晚于提交Outbox 同事务消费者幂等对账可回放密钥与权限最小化。这八条都做到,未必能保证「永不故障」,但能保证故障可定位、可止血、可复盘

把支付系统做好,本质上是在持续回答一句话:在不可靠的网络与不可控的第三方之上,如何让用户与平台都相信「这笔钱的状态是真的」。下一章将进入全书综合案例(第16章),从平台视角回看支付在整体架构中的位置与演进路径。


导航书籍主页 | 完整目录 | 上一章 | 下一章

导航书籍主页 | 完整目录 | 上一章 | 下一章


第16章 B2B2C平台完整架构

综合案例:一个中大型B2B2C电商平台的完整架构设计,从品类分析到技术选型,从系统设计到团队协作,覆盖200+人团队、日订单200万级的实战经验与架构决策。


16.1 项目背景

16.1.1 业务模式

核心定位:B2B2C聚合电商平台

本平台采用"聚合供应商"模式,连接50+外部供应商(机票、酒店、充值、电影票等),同时支持自营业务(优惠券、礼品卡)。关键特征是「无物流场景」——所有商品均为虚拟数字商品,履约通过API调用完成,无需物流配送环节。

业务范围

  • B2B2C聚合模式:对接机票(航司/GDS)、酒店(OTA/PMS)、充值(运营商)、电影票(院线)等50+供应商
  • 自营模式:自有优惠券(e-voucher)、线下券、礼品卡等虚拟商品
  • 数字履约:所有商品通过API调用完成履约(出票、确认、充值、发券码)

业务特点

  • 供应商接口高度碎片化(实时查询 + 定时同步 + 推送混合)
  • 核心品类(机票/酒店)零超卖容忍——直接影响用户体验与平台信誉
  • 长尾品类(充值/礼品卡)可事后补偿——供应商侧库存无限

16.1.2 团队规模

团队组成(200+人):

团队人数职责
前台团队60人搜索、详情、购物车、结算、订单、用户中心
中台团队80人商品中心、库存、计价、营销、支付、供应商网关
基础设施30人DevOps、监控、中间件、数据库、安全
数据团队20人数据仓库、BI、用户画像、推荐算法
测试团队10人自动化测试、性能测试、安全测试

技术栈统一

  • 后端语言:Go(统一技术栈,降低维护成本)
  • 数据库:MySQL(主库)+ Redis(缓存)+ Elasticsearch(搜索)
  • 消息队列:Kafka(异步解耦)
  • 服务治理:gRPC + Consul + Envoy
  • 监控:Prometheus + Grafana + Jaeger

16.1.3 技术目标

性能指标

指标正常值大促峰值说明
日订单量200万1000万大促5倍流量
搜索QPS3000150005倍流量
详情页QPS5000250005倍流量
下单QPS100050005倍流量
P99延迟200ms500ms大促允许适当降级

可用性目标

  • 核心链路SLA:99.95%(订单创建、支付、履约)
  • 搜索/详情SLA:99.9%(可降级展示)
  • RTO(故障恢复时间):< 5分钟
  • RPO(数据丢失容忍):0(核心交易数据零丢失)

扩展性目标

  • 支持新品类接入:< 2周(标准化供应商适配)
  • 支持新供应商:< 1周(适配器模式)
  • 支持新营销玩法:< 3天(规则引擎)

16.2 品类业务模型分析

不同品类的业务模型存在显著差异,直接影响架构设计决策。理解这些差异是系统设计的基础。

16.2.1 机票业务模型

业务特点

• 库存模型:实时库存(供应商侧),强依赖供应商实时查询
• 价格模型:动态定价,实时波动(可能秒级变化)
• SKU复杂度:极高(航司+航班号+舱位+日期+...组合)
• 库存单位:座位数量(不可超卖)
• 扣减时机:下单即扣(预占)→ 支付确认 → 出票
• 履约流程:下单 → 支付 → 出票(调用GDS/供应商API)→ 发送电子票

架构影响

  • ✓ 必须支持实时库存查询(高频调用供应商API)
  • ✓ 价格快照必须精确到秒级,防止价格变动纠纷
  • ✓ 超卖零容忍 → 下单前二次确认库存
  • ✓ 供应商故障需快速切换到备用供应商
  • ✓ 订单状态复杂(待出票、出票中、出票失败、已出票)

技术要点

// 机票库存查询策略
type FlightStockStrategy struct {
    supplierClient rpc.SupplierClient
    redis          redis.Client
    config         *FlightConfig
}

func (s *FlightStockStrategy) CheckStock(ctx context.Context, req *StockRequest) (*StockResponse, error) {
    // Step 1: 尝试从Redis获取缓存(TTL=5分钟)
    cacheKey := fmt.Sprintf("flight:stock:%s:%s", req.FlightNo, req.Date)
    cached, err := s.redis.Get(ctx, cacheKey).Result()
    if err == nil {
        return parseStockFromCache(cached), nil
    }
    
    // Step 2: 缓存未命中,调用供应商实时查询
    ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)  // 800ms超时
    defer cancel()
    
    stock, err := s.supplierClient.QueryStock(ctx, req)
    if err != nil {
        // 供应商故障,切换备用供应商
        return s.fallbackToSecondarySupplier(ctx, req)
    }
    
    // Step 3: 缓存结果(短TTL,机票价格变化快)
    s.redis.Set(ctx, cacheKey, marshal(stock), 5*time.Minute)
    
    return stock, nil
}

监控指标

  • 供应商调用超时率:< 1%
  • 缓存命中率:> 70%
  • 出票成功率:> 99.5%
  • 出票平均时长:< 30秒

16.2.2 酒店业务模型

业务特点

• 库存模型:房间数量(按日期维度管理)
• 价格模型:日历房价(每个日期不同价格)
• SKU复杂度:高(酒店ID+房型+日期范围+早餐+...)
• 库存单位:房间数/间夜数
• 扣减时机:下单预占 → 支付确认 → 供应商确认
• 履约流程:下单 → 支付 → 提交供应商 → 确认单 → 入住凭证

架构影响

  • ✓ 支持日期范围查询(check-in到check-out)
  • ✓ 日历价格存储(每个日期一条记录)
  • ✓ 库存按日期维度管理(某天无房不影响其他日期)
  • ✓ 支持"担保"模式(先占房,入住时结算)
  • ✓ 需处理"确认单延迟"(供应商异步确认)

数据模型

// 酒店日历价格表(宽表存储)
type HotelCalendarPrice struct {
    HotelID      int64     `gorm:"primaryKey"`
    RoomTypeID   int64     `gorm:"primaryKey"`
    Date         time.Time `gorm:"primaryKey;index"`  // 日期维度
    BasePrice    int64     // 基础价格(分)
    WeekendPrice int64     // 周末价格
    Stock        int       // 当日库存
    Status       string    // 可售状态(AVAILABLE/SOLD_OUT/CLOSED)
}

// 查询日期范围内的价格与库存
func (r *HotelRepo) GetCalendarPrice(hotelID, roomTypeID int64, checkIn, checkOut time.Time) ([]*HotelCalendarPrice, error) {
    var prices []*HotelCalendarPrice
    err := r.db.Where("hotel_id = ? AND room_type_id = ? AND date >= ? AND date < ?",
        hotelID, roomTypeID, checkIn, checkOut).
        Order("date ASC").
        Find(&prices).Error
    return prices, err
}

缓存策略

  • 热门酒店:30分钟缓存
  • 长尾酒店:1小时缓存
  • 价格变更:主动失效缓存

16.2.3 充值业务模型

业务特点

• 库存模型:无限库存(供应商侧无限制)
• 价格模型:固定面额(10元、50元、100元)
• SKU复杂度:低(运营商+面额)
• 库存单位:无限
• 扣减时机:支付后
• 履约流程:下单 → 支付 → 调用供应商API → 充值成功/失败

架构影响

  • ✓ 无需库存管理(库存类型=无限)
  • ✓ 价格简单(基础价+平台服务费)
  • ✓ 超卖可接受(事后补偿)
  • ✓ 供应商调用简单(同步API,3秒内返回)
  • ✓ 失败重试友好(幂等性强)

技术要点

// 充值库存策略(无限库存)
type RechargeStockStrategy struct{}

func (s *RechargeStockStrategy) CheckStock(ctx context.Context, req *StockRequest) (*StockResponse, error) {
    // 充值类商品无需检查库存,直接返回"可售"
    return &StockResponse{
        Available: true,
        Quantity:  999999,  // 虚拟无限库存
        Message:   "充值类商品,库存充足",
    }, nil
}

func (s *RechargeStockStrategy) Reserve(ctx context.Context, req *ReserveRequest) (*ReserveResponse, error) {
    // 充值类商品无需预占,直接返回成功
    return &ReserveResponse{
        ReserveID: "",  // 无预占ID
        Success:   true,
    }, nil
}

16.2.4 电子券业务模型

业务特点

• 库存模型:固定库存(券码池)
• 价格模型:固定折扣价
• SKU复杂度:中(商户+门店+商品+...)
• 库存单位:券码(一券一码)
• 扣减时机:支付后
• 履约流程:下单 → 支付 → 发券码 → 到店核销

架构影响

  • ✓ 券码池管理(预生成10万个券码)
  • ✓ 券码发放(支付后随机分配)
  • ✓ 核销系统(商户扫码核销)
  • ✓ 过期管理(券有效期7天-180天)
  • ✓ 退款逻辑(未核销可退,已核销不可退)

技术要点

// 券码池管理(Redis实现)
type VoucherCodePool struct {
    redis redis.Client
}

func (p *VoucherCodePool) AssignCode(ctx context.Context, skuID int64, orderID int64) (string, error) {
    // Step 1: 从Redis Set中原子弹出一个未使用的券码
    poolKey := fmt.Sprintf("voucher:pool:%d", skuID)
    code, err := p.redis.SPop(ctx, poolKey).Result()
    if err == redis.Nil {
        return "", errors.New("券码已售罄")
    }
    
    // Step 2: 记录券码分配关系(券码 → 订单号)
    assignKey := fmt.Sprintf("voucher:assign:%s", code)
    p.redis.Set(ctx, assignKey, orderID, 0)  // 永久存储
    
    // Step 3: 设置券码有效期(ZSet按过期时间排序)
    expiresAt := time.Now().Add(90 * 24 * time.Hour)  // 90天有效期
    expiryKey := fmt.Sprintf("voucher:expiry:%d", skuID)
    p.redis.ZAdd(ctx, expiryKey, redis.Z{
        Score:  float64(expiresAt.Unix()),
        Member: code,
    })
    
    return code, nil
}

16.2.5 差异化设计策略

通过上述品类分析,我们提炼出三个核心设计维度:

维度1:库存管理类型

类型典型品类库存来源预占策略
实时库存机票、酒店、电影票供应商实时查询下单即预占,超时释放
池化库存优惠券、礼品卡平台自有(券码池)支付后扣减
无限库存充值、SaaS服务无库存概念无需预占

维度2:价格模型

类型典型品类缓存策略快照策略
动态定价机票5分钟TTL秒级快照
日历定价酒店30分钟TTL日期维度快照
固定定价充值、礼品卡1小时TTL简单快照

维度3:履约模式

类型典型品类调用方式失败处理
同步履约充值同步API(3秒超时)立即重试3次
异步履约机票、酒店异步轮询(30秒/次)补偿任务
券码发放优惠券本地分配(无外部调用)券码池补充

统一抽象

// 品类策略接口(策略模式)
type CategoryStrategy interface {
    // 库存检查
    CheckStock(ctx context.Context, req *StockRequest) (*StockResponse, error)
    // 库存预占
    ReserveStock(ctx context.Context, req *ReserveRequest) (*ReserveResponse, error)
    // 价格计算
    CalculatePrice(ctx context.Context, req *PriceRequest) (*PriceResponse, error)
    // 订单履约
    Fulfill(ctx context.Context, order *Order) (*FulfillResult, error)
}

// 策略工厂(根据品类选择策略)
type CategoryStrategyFactory struct {
    strategies map[CategoryType]CategoryStrategy
}

func (f *CategoryStrategyFactory) GetStrategy(categoryType CategoryType) CategoryStrategy {
    return f.strategies[categoryType]
}

设计原则

  1. 策略模式:每个品类一个策略实现,避免 if-else 地狱
  2. 适配器模式:统一供应商接口差异,降低耦合
  3. 模板方法:下单流程统一,具体步骤由策略实现
  4. 可扩展性:新增品类只需新增策略,不影响主流程

16.3 整体架构设计

16.3.1 分层架构

采用经典的四层架构,确保职责清晰、易于维护。

┌──────────────────────────────────────────────────────┐
│              接入层(API Gateway)                    │
│  • 鉴权、限流、路由、协议转换                         │
│  • Web/App/小程序统一接入                            │
└──────────────────────────────────────────────────────┘
                          ↓
┌──────────────────────────────────────────────────────┐
│             聚合层(Aggregation Service)             │
│  • 数据编排:并发调用多个微服务                       │
│  • 降级策略:服务故障时的降级处理                     │
│  • 缓存优化:聚合结果缓存                            │
└──────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────────┐
│                   业务服务层(Microservices)                │
│  ┌────────┬────────┬────────┬────────┬────────┬────────┐   │
│  │ Product│Inventory│ Pricing│Marketing│ Order │ Payment│   │
│  │  商品  │  库存  │  计价  │  营销  │  订单 │  支付  │   │
│  └────────┴────────┴────────┴────────┴────────┴────────┘   │
└─────────────────────────────────────────────────────────────┘
                          ↓
┌──────────────────────────────────────────────────────┐
│           基础设施层(Infrastructure)                │
│  • MySQL、Redis、Elasticsearch、Kafka               │
│  • 服务发现(Consul)、服务网格(Envoy)             │
│  • 监控告警(Prometheus、Grafana、Jaeger)          │
└──────────────────────────────────────────────────────┘

分层职责

层级服务职责不负责
接入层API Gateway鉴权、限流、路由业务逻辑、数据编排
聚合层Aggregation数据获取、编排、降级具体业务计算
业务层Microservices单一业务领域逻辑跨域数据获取
基础层Infra存储、消息、监控业务规则

16.3.2 微服务拆分

拆分原则

  1. 按业务能力拆分(而非技术层次)
  2. 单一职责:每个服务只负责一个限界上下文
  3. 数据所有权:每个服务拥有自己的数据库
  4. API优先:服务间只通过API或事件通信

核心服务清单

服务名称职责数据库QPS(峰值)团队规模
Product Center商品信息、类目、属性MySQL(4分库)2000012人
Inventory Service库存管理、预占、扣减MySQL+Redis800010人
Pricing Service价格计算、试算、快照MySQL150008人
Marketing Service营销规则、优惠券、活动MySQL+Redis1000012人
Order Service订单创建、状态机、履约MySQL(8分库64表)500015人
Payment Service支付、退款、对账MySQL600010人
Search Service商品搜索、筛选、排序Elasticsearch150008人
User Service用户信息、登录、权限MySQL80006人
Supplier Gateway供应商对接、适配、熔断MySQL+Redis1200015人

聚合服务

服务职责依赖服务
Search Aggregation搜索结果聚合Search + Product + Inventory + Pricing
Detail Aggregation详情页聚合Product + Inventory + Pricing + Marketing
Checkout Aggregation结算页聚合Product + Inventory + Pricing + Marketing

16.3.3 服务依赖关系

graph TB
    subgraph 接入层
        Gateway[API Gateway]
    end
    
    subgraph 聚合层
        SearchAgg[搜索聚合]
        DetailAgg[详情聚合]
        CheckoutAgg[结算聚合]
    end
    
    subgraph 业务服务层
        Product[商品中心]
        Inventory[库存服务]
        Pricing[计价服务]
        Marketing[营销服务]
        Order[订单服务]
        Payment[支付服务]
        Search[搜索服务]
    end
    
    subgraph 基础服务
        Supplier[供应商网关]
        User[用户服务]
    end
    
    Gateway --> SearchAgg
    Gateway --> DetailAgg
    Gateway --> CheckoutAgg
    Gateway --> Order
    
    SearchAgg --> Search
    SearchAgg --> Product
    SearchAgg --> Inventory
    SearchAgg --> Pricing
    
    DetailAgg --> Product
    DetailAgg --> Inventory
    DetailAgg --> Pricing
    DetailAgg --> Marketing
    
    CheckoutAgg --> Product
    CheckoutAgg --> Inventory
    CheckoutAgg --> Pricing
    CheckoutAgg --> Marketing
    
    Order --> Inventory
    Order --> Payment
    Order --> Supplier
    
    Inventory --> Supplier
    Product --> Supplier

依赖原则

  1. 上游 → 下游:聚合层调用业务层,不反向依赖
  2. 避免循环依赖:严格禁止服务间循环调用
  3. 异步解耦:非核心路径使用Kafka事件异步
  4. 降级友好:下游故障不影响上游核心功能

16.3.4 数据流转

同步数据流(关键路径)

用户搜索商品:
API Gateway → Search Aggregation 
            → Search Service(ES查询)
            → Product Service(批量获取基础信息)
            → Inventory Service(批量查库存)
            → Pricing Service(批量计算价格)
            ← 返回聚合结果

响应时间:< 200ms(P99)

异步数据流(非关键路径)

订单创建成功 → Kafka Event:OrderCreated
            → 订阅者1:Inventory Service(确认扣减)
            → 订阅者2:Search Service(更新销量)
            → 订阅者3:User Service(积分增加)
            → 订阅者4:Data Team(数据分析)

最终一致性:< 5秒

16.4 技术选型决策

16.4.1 选型原则

原则1:成熟度优先

  • 优先选择生产级成熟技术(避免踩坑)
  • 社区活跃、文档完善、案例丰富
  • 避免使用 alpha/beta 版本

原则2:团队能力匹配

  • 技术栈与团队技能对齐
  • 学习曲线可控(新技术培训 < 1个月)
  • 有内部专家支持

原则3:生态完整性

  • 工具链完善(测试、监控、部署)
  • 第三方库丰富
  • 云服务支持(AWS/GCP/阿里云)

原则4:成本可控

  • 开源优先(降低License成本)
  • 云服务按需使用(避免自建中间件)
  • 运维成本可接受

16.4.2 Go生态选型

语言选择:Go

维度GoJava理由
性能⭐⭐⭐⭐⭐⭐⭐⭐⭐协程模型,高并发性能优异
开发效率⭐⭐⭐⭐⭐⭐⭐编译快,部署简单(单一二进制)
学习曲线⭐⭐⭐⭐⭐⭐⭐⭐语法简洁,容易上手
生态⭐⭐⭐⭐⭐⭐⭐⭐⭐微服务生态完善(gRPC/Consul/Envoy)
团队能力⭐⭐⭐⭐⭐⭐⭐⭐团队有Go经验

Web框架:Gin

// 理由:
// 1. 性能优异(httprouter,零内存分配)
// 2. 中间件丰富(鉴权、限流、日志)
// 3. 社区活跃(GitHub 70k+ stars)

router := gin.Default()
router.Use(middleware.Auth())
router.Use(middleware.RateLimit(1000))
router.GET("/products/:id", handler.GetProduct)

ORM:GORM

// 理由:
// 1. 支持MySQL、PostgreSQL、SQLite
// 2. 关联查询、预加载、Hook机制完善
// 3. 自动迁移(开发环境)

type Product struct {
    ID       int64  `gorm:"primaryKey"`
    Title    string `gorm:"size:255;not null"`
    Price    int64  `gorm:"not null"`
}

RPC:gRPC + Protobuf

// 理由:
// 1. 二进制序列化(性能优于JSON)
// 2. 强类型(编译期检查)
// 3. 支持流式调用(双向流)

service ProductService {
    rpc GetProduct(GetProductRequest) returns (GetProductResponse);
    rpc BatchGetProduct(BatchGetProductRequest) returns (stream Product);
}

依赖注入:Google Wire

// 理由:
// 1. 编译时生成(无反射,性能高)
// 2. 类型安全(编译期检查依赖)
// 3. 官方支持(Google开源)

//go:generate wire
func InitializeApp() (*App, error) {
    wire.Build(
        NewDB,
        NewRedis,
        NewProductRepo,
        NewProductService,
        NewApp,
    )
    return nil, nil
}

16.4.3 数据库选型

MySQL(主库)

场景选择理由配置
订单表ACID保证、事务支持InnoDB,8分库64表
商品表关联查询、JOIN支持InnoDB,4分库
支付表强一致性、金融级可靠性InnoDB,双主互备

Redis(缓存 + 库存)

场景数据结构TTL
商品详情Hash30分钟
库存数量String(Lua原子扣减)永久
券码池Set(SPOP原子弹出)永久
用户SessionString2小时

Elasticsearch(搜索 + 日志)

场景索引设计刷新间隔
商品搜索product_index(标题、类目、属性)30秒
订单查询order_index(订单号、用户ID、状态)1分钟
日志搜索log-{date}(按日分索引)5秒

16.4.4 中间件选型

Kafka(消息队列)

场景TopicPartitionReplication
订单事件order-events163
库存事件inventory-events83
日志采集logs322

Consul(服务发现)

  • 健康检查:HTTP/TCP/gRPC
  • 配置中心:动态配置热更新
  • KV存储:Feature Flag

Envoy(Service Mesh)

  • 流量管理:灰度发布、A/B测试
  • 可观测性:自动生成Trace
  • 安全:mTLS加密

16.5 核心系统设计

16.5.1 商品中心设计

职责边界

  • ✅ 负责:商品基础信息、类目、属性、多媒体素材
  • ✅ 负责:SPU/SKU管理、上架下架
  • ❌ 不负责:价格(由Pricing Service管理)
  • ❌ 不负责:库存(由Inventory Service管理)

数据模型

// SPU(Standard Product Unit)
type SPU struct {
    ID          int64
    Title       string
    CategoryID  int64
    BrandID     int64
    Attributes  JSONB  // 动态属性(颜色、尺寸、...)
    Images      []string
    Status      string  // DRAFT/ON_SHELF/OFF_SHELF
}

// SKU(Stock Keeping Unit)
type SKU struct {
    ID          int64
    SPUID       int64
    SkuCode     string  // 唯一编码
    Specs       JSONB   // 规格值({"颜色":"红色","尺寸":"L"})
    Status      string
}

分库策略

-- 按 category_id 分库(4分库)
-- 理由:同品类商品通常一起查询
db_index = category_id % 4

-- 商品表不分表
-- 理由:单品类商品数量可控(< 100万)

缓存策略

// L1: 本地缓存(1分钟)
localCache.Set(sku_id, product, 1*time.Minute)

// L2: Redis缓存(30分钟)
redis.Set("product:"+sku_id, marshal(product), 30*time.Minute)

// L3: MySQL(源数据)
db.QueryOne("SELECT * FROM product WHERE sku_id = ?", sku_id)

16.5.2 库存系统设计

二维库存模型(参考16.2.5):

// 库存策略接口
type StockStrategy interface {
    CheckStock(ctx context.Context, req *StockRequest) (*StockResponse, error)
    Reserve(ctx context.Context, req *ReserveRequest) (*ReserveResponse, error)
    Deduct(ctx context.Context, req *DeductRequest) error
    Release(ctx context.Context, reserveID string) error
}

// 策略工厂
func NewStockStrategy(managementType ManagementType) StockStrategy {
    switch managementType {
    case Realtime:
        return &RealtimeStockStrategy{}  // 机票、酒店
    case Pooled:
        return &PooledStockStrategy{}    // 优惠券
    case Unlimited:
        return &UnlimitedStockStrategy{} // 充值
    }
}

预占机制

// Redis Lua脚本(原子预占)
const reserveScript = `
local stock_key = KEYS[1]
local reserve_key = KEYS[2]
local qty = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])

local stock = tonumber(redis.call('GET', stock_key) or 0)
if stock >= qty then
    redis.call('DECRBY', stock_key, qty)
    redis.call('SET', reserve_key, qty, 'EX', ttl)
    return 1
else
    return 0
end
`

func (r *StockRepo) Reserve(ctx context.Context, skuID int64, qty int, ttl time.Duration) (string, error) {
    reserveID := generateReserveID()
    stockKey := fmt.Sprintf("stock:%d", skuID)
    reserveKey := fmt.Sprintf("reserve:%s", reserveID)
    
    result, err := r.redis.Eval(ctx, reserveScript, 
        []string{stockKey, reserveKey}, 
        qty, int(ttl.Seconds())).Result()
    
    if result == int64(1) {
        return reserveID, nil
    }
    return "", errors.New("库存不足")
}

16.5.3 订单系统设计

状态机

type OrderStatus string

const (
    StatusCreated          OrderStatus = "CREATED"           // 已创建
    StatusPendingPayment   OrderStatus = "PENDING_PAYMENT"   // 待支付
    StatusPaid             OrderStatus = "PAID"              // 已支付
    StatusFulfilling       OrderStatus = "FULFILLING"        // 履约中
    StatusFulfilled        OrderStatus = "FULFILLED"         // 已履约
    StatusCanceled         OrderStatus = "CANCELED"          // 已取消
    StatusRefunded         OrderStatus = "REFUNDED"          // 已退款
)

// 状态转换规则
var transitions = map[OrderStatus][]OrderStatus{
    StatusCreated:        {StatusPendingPayment, StatusCanceled},
    StatusPendingPayment: {StatusPaid, StatusCanceled},
    StatusPaid:           {StatusFulfilling, StatusRefunded},
    StatusFulfilling:     {StatusFulfilled, StatusRefunded},
    StatusFulfilled:      {StatusRefunded},  // 已履约可申请退款
}

func (o *Order) TransitionTo(newStatus OrderStatus) error {
    allowed, ok := transitions[o.Status]
    if !ok || !contains(allowed, newStatus) {
        return fmt.Errorf("不允许从 %s 转换到 %s", o.Status, newStatus)
    }
    o.Status = newStatus
    return nil
}

分库分表(参考ADR-007):

• 分库:按 user_id % 8(用户维度查询最频繁)
• 分表:按 create_time 分表(按月归档,64表)
• 路由表:order_route(order_id → db_index, table_index)

16.5.4 支付系统设计

支付流程

// Step 1: 创建支付单
func (s *PaymentService) CreatePayment(ctx context.Context, orderID int64, amount int64) (*Payment, error) {
    payment := &Payment{
        ID:      generatePaymentID(),
        OrderID: orderID,
        Amount:  amount,
        Status:  PaymentStatusCreated,
    }
    s.repo.Save(ctx, payment)
    return payment, nil
}

// Step 2: 调用支付渠道(支付宝/微信)
func (s *PaymentService) Pay(ctx context.Context, paymentID int64, channel string) (*PayURL, error) {
    gateway := s.gatewayFactory.Get(channel)
    payURL, err := gateway.CreateOrder(ctx, payment)
    return payURL, err
}

// Step 3: 接收支付回调(幂等处理)
func (s *PaymentService) HandleCallback(ctx context.Context, callbackData *CallbackData) error {
    // 幂等性检查
    payment, err := s.repo.GetByPaymentID(ctx, callbackData.PaymentID)
    if payment.Status == PaymentStatusPaid {
        return nil  // 已处理,幂等返回
    }
    
    // 验签
    if !s.verifySign(callbackData) {
        return errors.New("签名验证失败")
    }
    
    // 更新支付状态(乐观锁)
    affected, err := s.repo.UpdateStatus(ctx, callbackData.PaymentID, 
        PaymentStatusCreated, PaymentStatusPaid)
    if affected == 0 {
        return errors.New("支付单状态已变更")
    }
    
    // 发布支付成功事件
    s.eventPublisher.Publish(ctx, &PaymentPaidEvent{
        OrderID:   payment.OrderID,
        PaymentID: payment.ID,
        Amount:    payment.Amount,
    })
    
    return nil
}

对账流程

// 每小时对账任务
func (s *PaymentService) ReconcileHourly(ctx context.Context, hour time.Time) error {
    // Step 1: 获取本地支付记录
    localPayments, _ := s.repo.GetByHour(ctx, hour)
    
    // Step 2: 获取支付渠道对账单
    remotePayments, _ := s.gatewayClient.DownloadBill(ctx, hour)
    
    // Step 3: 比对差异
    diff := s.compare(localPayments, remotePayments)
    
    // Step 4: 处理差异
    for _, d := range diff {
        if d.Type == Missing {
            // 本地有,渠道无 → 可能是渠道延迟
            s.alertService.Alert("支付对账差异", d)
        } else if d.Type == Extra {
            // 本地无,渠道有 → 可能是回调丢失
            s.补单处理(d)
        }
    }
    
    return nil
}

16.5.5 供应商集成设计

适配器模式

// 供应商接口(统一抽象)
type SupplierAdapter interface {
    QueryStock(ctx context.Context, req *StockQueryRequest) (*StockQueryResponse, error)
    ReserveStock(ctx context.Context, req *ReserveRequest) (*ReserveResponse, error)
    CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error)
    QueryOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
}

// 机票供应商适配器
type FlightSupplierAdapter struct {
    client *FlightSupplierClient
    config *Config
}

func (a *FlightSupplierAdapter) QueryStock(ctx context.Context, req *StockQueryRequest) (*StockQueryResponse, error) {
    // Step 1: 参数转换(平台模型 → 供应商模型)
    supplierReq := a.transformRequest(req)
    
    // Step 2: 调用供应商API(熔断保护)
    supplierResp, err := a.client.QueryAvailability(ctx, supplierReq)
    if err != nil {
        return nil, fmt.Errorf("供应商调用失败: %w", err)
    }
    
    // Step 3: 响应转换(供应商模型 → 平台模型)
    resp := a.transformResponse(supplierResp)
    return resp, nil
}

熔断机制

import "github.com/sony/gobreaker"

func NewSupplierClientWithCircuitBreaker(client *http.Client) *SupplierClient {
    cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:        "SupplierAPI",
        MaxRequests: 3,
        Interval:    10 * time.Second,
        Timeout:     30 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
            return counts.Requests >= 3 && failureRatio >= 0.5
        },
        OnStateChange: func(name string, from, to gobreaker.State) {
            log.Printf("熔断器 %s 状态变更: %s -> %s", name, from, to)
        },
    })
    
    return &SupplierClient{
        client: client,
        cb:     cb,
    }
}

func (c *SupplierClient) QueryStock(ctx context.Context, req *Request) (*Response, error) {
    result, err := c.cb.Execute(func() (interface{}, error) {
        return c.client.Do(buildHTTPRequest(req))
    })
    if err != nil {
        return nil, err
    }
    return parseResponse(result), nil
}

16.5.6 商品供给与运营系统

供给运营是电商平台的核心能力,决定了"商品如何进入平台"。本节展示供给侧和运营侧的完整设计。

商品上架系统(从无到有)

业务场景

  • 运营人员手动上传新商品
  • 商家通过Portal批量导入
  • Excel批量上架(节假日大促前)

核心设计

// 上架任务状态机
type ListingStatus string

const (
    ListingDraft      ListingStatus = "DRAFT"       // 草稿
    ListingPending    ListingStatus = "PENDING"     // 待审核
    ListingApproved   ListingStatus = "APPROVED"    // 审核通过
    ListingRejected   ListingStatus = "REJECTED"    // 审核驳回
    ListingPublished  ListingStatus = "PUBLISHED"   // 已发布
)

// 上架任务
type ListingTask struct {
    TaskCode    string        // 幂等性标识
    ItemInfo    ItemInfo      // 商品信息
    SupplierID  int64         // 供应商ID
    Status      ListingStatus
    ReviewerID  int64         // 审核人
    RejectReason string       // 驳回原因
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

// 创建上架任务(幂等性保证)
func (s *ListingService) CreateListingTask(ctx context.Context, req *ListingRequest) (*ListingTask, error) {
    // Step 1: 生成幂等性标识符
    taskCode := s.generateTaskCode(req)
    
    // Step 2: FirstOrCreate(幂等性)
    task := &ListingTask{
        TaskCode:   taskCode,
        ItemInfo:   req.ItemInfo,
        SupplierID: req.SupplierID,
        Status:     ListingDraft,
    }
    
    result := s.db.Where("task_code = ?", taskCode).FirstOrCreate(task)
    if result.RowsAffected > 0 {
        // 首次创建,发布事件
        s.eventPublisher.Publish(ctx, &ListingTaskCreatedEvent{
            TaskCode: taskCode,
            ItemInfo: req.ItemInfo,
        })
    }
    
    return task, nil
}

// 提交审核
func (s *ListingService) SubmitForReview(ctx context.Context, taskCode string) error {
    // 状态转换:DRAFT → PENDING
    return s.updateStatus(ctx, taskCode, ListingDraft, ListingPending)
}

// 审核通过
func (s *ListingService) Approve(ctx context.Context, taskCode string, reviewerID int64) error {
    // Step 1: 状态转换:PENDING → APPROVED
    if err := s.updateStatus(ctx, taskCode, ListingPending, ListingApproved); err != nil {
        return err
    }
    
    // Step 2: 创建商品记录(写入商品中心)
    task, _ := s.getTask(ctx, taskCode)
    itemID, err := s.productCenter.CreateProduct(ctx, &CreateProductRequest{
        ItemInfo:   task.ItemInfo,
        SupplierID: task.SupplierID,
    })
    if err != nil {
        return fmt.Errorf("create product failed: %w", err)
    }
    
    // Step 3: 初始化库存(调用库存服务)
    s.inventoryClient.InitStock(ctx, itemID, task.ItemInfo.InitStock)
    
    // Step 4: 初始化价格(调用计价服务)
    s.pricingClient.InitPrice(ctx, itemID, task.ItemInfo.BasePrice)
    
    // Step 5: 更新搜索索引(异步)
    s.eventPublisher.Publish(ctx, &ProductCreatedEvent{
        ItemID:     itemID,
        ItemInfo:   task.ItemInfo,
        SupplierID: task.SupplierID,
    })
    
    return nil
}

审核策略

审核维度检查项风险等级
合规性违禁词检测、敏感内容
完整性必填字段、图片数量
准确性价格合理性、类目匹配
一致性SPU/SKU关系、属性匹配

供应商同步系统(Upsert场景)

业务场景

  • 供应商定时推送商品数据(每小时/每天)
  • 供应商实时推送价格/库存变更
  • 供应商商品可能已存在,也可能不存在

核心挑战:Upsert语义

如果商品存在 → 更新
如果商品不存在 → 创建

实现方案

// 供应商同步任务
type SyncTask struct {
    SyncID       string    // 同步批次ID
    SupplierID   int64     // 供应商ID
    SupplierSkuID string   // 供应商SKU ID
    SyncData     SyncData  // 同步数据
    SyncType     string    // FULL/INCREMENTAL
    Status       string    // PENDING/SUCCESS/FAILED
}

// Upsert处理(幂等性保证)
func (s *SyncService) UpsertProduct(ctx context.Context, req *SyncRequest) error {
    // Step 1: 根据供应商SKU ID查询平台商品ID
    mapping, err := s.repo.GetMapping(ctx, req.SupplierID, req.SupplierSkuID)
    
    if err == ErrNotFound {
        // 场景1:商品不存在 → 创建(走上架流程)
        return s.createNewProduct(ctx, req)
    } else {
        // 场景2:商品存在 → 更新(走同步流程)
        return s.updateExistingProduct(ctx, mapping.ItemID, req)
    }
}

// 创建新商品(供应商同步触发的上架)
func (s *SyncService) createNewProduct(ctx context.Context, req *SyncRequest) error {
    // Step 1: 创建上架任务
    task, err := s.listingService.CreateListingTask(ctx, &ListingRequest{
        ItemInfo:   transformToItemInfo(req.SyncData),
        SupplierID: req.SupplierID,
        Source:     "SUPPLIER_SYNC",  // 标记来源
    })
    
    // Step 2: 根据供应商信用等级,决定是否需要审核
    if s.needReview(req.SupplierID) {
        // 低信用供应商:需要人工审核
        task.Status = ListingPending
    } else {
        // 高信用供应商:自动通过
        task.Status = ListingApproved
        s.listingService.Approve(ctx, task.TaskCode, SYSTEM_REVIEWER_ID)
    }
    
    return nil
}

// 更新现有商品(供应商同步)
func (s *SyncService) updateExistingProduct(ctx context.Context, itemID int64, req *SyncRequest) error {
    // Step 1: 对比差异
    existing, _ := s.productCenter.GetProduct(ctx, itemID)
    diff := s.compareDiff(existing, req.SyncData)
    
    // Step 2: 根据差异类型决定是否需要审核
    if diff.HasHighRiskChange() {
        // 高风险变更(价格变化>50%、类目变更)→ 需要审核
        return s.createReviewTask(ctx, itemID, diff)
    } else {
        // 低风险变更(库存、图片)→ 直接更新
        return s.productCenter.UpdateProduct(ctx, itemID, diff)
    }
}

// 判断供应商是否需要审核
func (s *SyncService) needReview(supplierID int64) bool {
    supplier, _ := s.supplierRepo.Get(ctx, supplierID)
    
    // 根据供应商信用等级和历史表现决定
    return supplier.CreditLevel < 3 || supplier.RejectRate > 0.1
}

差异化审核策略

变更类型变更范围审核策略理由
价格变更< 10%自动通过正常波动
价格变更10-50%需要审核防止错误
价格变更> 50%必须审核 + 告警高风险
库存变更任意自动通过实时性要求高
标题变更轻微修改自动通过低风险
类目变更任意必须审核影响搜索
图片变更任意自动通过低风险

运营编辑系统(日常维护)

业务场景

  • 单品编辑(修改标题、描述、图片)
  • 批量编辑(批量调价、批量上下架)
  • 批量导入导出(Excel操作)

核心设计

// 运营编辑任务
type EditTask struct {
    TaskID      string       // 任务ID
    ItemIDs     []int64      // 商品ID列表(支持批量)
    EditType    string       // SINGLE/BATCH
    Changes     []Change     // 变更内容
    Status      string       // PENDING/EXECUTING/SUCCESS/FAILED
    Progress    int          // 进度(0-100)
    TotalCount  int          // 总数
    SuccessCount int         // 成功数
    FailedCount int          // 失败数
}

// 批量编辑(异步任务)
func (s *EditService) BatchEdit(ctx context.Context, req *BatchEditRequest) (*EditTask, error) {
    // Step 1: 创建批量编辑任务
    task := &EditTask{
        TaskID:     generateTaskID(),
        ItemIDs:    req.ItemIDs,
        EditType:   "BATCH",
        Changes:    req.Changes,
        Status:     "PENDING",
        TotalCount: len(req.ItemIDs),
    }
    s.taskRepo.Save(ctx, task)
    
    // Step 2: 发布异步任务
    s.taskQueue.Publish(ctx, &BatchEditTaskEvent{
        TaskID: task.TaskID,
    })
    
    return task, nil
}

// 批量编辑执行器(异步)
func (w *BatchEditWorker) Execute(ctx context.Context, taskID string) error {
    task, _ := w.taskRepo.Get(ctx, taskID)
    
    // 逐个处理商品
    for i, itemID := range task.ItemIDs {
        err := w.editSingleItem(ctx, itemID, task.Changes)
        
        if err == nil {
            task.SuccessCount++
        } else {
            task.FailedCount++
            log.Errorf("edit item %d failed: %v", itemID, err)
        }
        
        // 更新进度
        task.Progress = (i + 1) * 100 / task.TotalCount
        w.taskRepo.Update(ctx, task)
    }
    
    // 更新任务状态
    if task.FailedCount == 0 {
        task.Status = "SUCCESS"
    } else if task.SuccessCount == 0 {
        task.Status = "FAILED"
    } else {
        task.Status = "PARTIAL_SUCCESS"
    }
    
    return nil
}

进度追踪

// 查询任务进度
func (s *EditService) GetTaskProgress(ctx context.Context, taskID string) (*TaskProgress, error) {
    task, _ := s.taskRepo.Get(ctx, taskID)
    
    return &TaskProgress{
        TaskID:       task.TaskID,
        Status:       task.Status,
        Progress:     task.Progress,
        TotalCount:   task.TotalCount,
        SuccessCount: task.SuccessCount,
        FailedCount:  task.FailedCount,
        EstimateLeft: s.estimateTimeLeft(task),
    }, nil
}

16.5.7 C端交易流完整链路

交易流是电商的核心价值链,从用户搜索到完成支付的完整路径。本节展示五个阶段的设计与集成。

阶段1:搜索与导购

业务场景:用户搜索"iPhone 15"

系统架构

用户输入关键词
    ↓
API Gateway → Search Aggregation
    ↓
Query理解(分词、纠错、意图识别)
    ↓
Elasticsearch召回(相关性排序)
    ↓
Hydrate编排(并发调用多个服务)
    ├─ Product Service(商品信息)
    ├─ Inventory Service(库存状态)
    ├─ Pricing Service(价格计算)
    └─ Marketing Service(活动标签)
    ↓
返回搜索结果

核心代码

// 搜索聚合服务
type SearchAggregation struct {
    esClient        *elasticsearch.Client
    productClient   rpc.ProductClient
    inventoryClient rpc.InventoryClient
    pricingClient   rpc.PricingClient
    marketingClient rpc.MarketingClient
}

func (a *SearchAggregation) Search(ctx context.Context, req *SearchRequest) (*SearchResponse, error) {
    // Step 1: Query理解(分词、意图识别)
    query := a.parseQuery(req.Keyword)
    
    // Step 2: ES召回(按相关性排序)
    hits, err := a.esClient.Search(ctx, query)
    if err != nil {
        return nil, err
    }
    
    skuIDs := extractSkuIDs(hits)
    
    // Step 3: Hydrate编排(并发调用)
    var products map[int64]*Product
    var stocks map[int64]*Stock
    var prices map[int64]*Price
    var promos map[int64]*PromoInfo
    
    g, ctx := errgroup.WithContext(ctx)
    
    // 并发调用4个服务
    g.Go(func() error {
        products, _ = a.productClient.BatchGet(ctx, skuIDs)
        return nil
    })
    g.Go(func() error {
        stocks, _ = a.inventoryClient.BatchCheck(ctx, skuIDs)
        return nil
    })
    g.Go(func() error {
        priceItems := buildPriceItems(skuIDs)
        prices, _ = a.pricingClient.BatchCalculate(ctx, priceItems)
        return nil
    })
    g.Go(func() error {
        promos, _ = a.marketingClient.BatchGet(ctx, skuIDs, req.UserID)
        // 降级:Marketing故障时使用空促销
        if promos == nil {
            promos = make(map[int64]*PromoInfo)
        }
        return nil
    })
    
    g.Wait()
    
    // Step 4: 聚合结果
    return a.buildSearchResponse(hits, products, stocks, prices, promos), nil
}

性能优化

  • ES查询:P99 < 50ms
  • Hydrate并发:4个服务并发调用,总耗时 < 200ms
  • 缓存策略:热门搜索词缓存5分钟

阶段2:商品详情页(PDP)

业务场景:用户点击商品进入详情页

核心设计

// 详情页聚合服务
func (a *DetailAggregation) GetDetail(ctx context.Context, skuID int64, userID int64) (*DetailResponse, error) {
    // 并发调用5个服务
    var product *Product
    var stock *Stock
    var price *Price
    var promos []*Promotion
    var reviews []*Review
    
    g, ctx := errgroup.WithContext(ctx)
    
    g.Go(func() error {
        product, _ = a.productClient.Get(ctx, skuID)
        return nil
    })
    g.Go(func() error {
        stock, _ = a.inventoryClient.Check(ctx, skuID)
        return nil
    })
    g.Go(func() error {
        price, _ = a.pricingClient.Calculate(ctx, skuID, userID)
        return nil
    })
    g.Go(func() error {
        promos, _ = a.marketingClient.GetPromotions(ctx, skuID, userID)
        return nil
    })
    g.Go(func() error {
        reviews, _ = a.reviewClient.GetTopReviews(ctx, skuID, 5)
        return nil
    })
    
    g.Wait()
    
    // 生成快照(用于后续试算)
    snapshot := a.generateSnapshot(product, price, promos)
    
    return &DetailResponse{
        Product:   product,
        Stock:     stock,
        Price:     price,
        Promos:    promos,
        Reviews:   reviews,
        Snapshot:  snapshot,  // 快照ID,5分钟有效
    }, nil
}

阶段3:购物车

业务场景:用户加购商品

未登录加购

// 未登录用户(Cookie存储)
func (c *CartService) AddToCartAnonymous(ctx context.Context, req *AddCartRequest) error {
    // Step 1: 获取匿名cartID(存储在Cookie)
    cartID := req.AnonymousCartID
    if cartID == "" {
        cartID = generateCartID()
    }
    
    // Step 2: 存储到Redis(TTL=7天)
    cartKey := fmt.Sprintf("cart:anon:%s", cartID)
    cartData, _ := c.redis.Get(ctx, cartKey).Result()
    
    cart := parseCart(cartData)
    cart.AddItem(req.SkuID, req.Quantity)
    
    c.redis.Set(ctx, cartKey, marshal(cart), 7*24*time.Hour)
    
    return nil
}

登录后合并

// 用户登录后合并购物车
func (c *CartService) MergeCartOnLogin(ctx context.Context, userID int64, anonymousCartID string) error {
    // Step 1: 获取匿名购物车
    anonCartKey := fmt.Sprintf("cart:anon:%s", anonymousCartID)
    anonCart, _ := c.redis.Get(ctx, anonCartKey).Result()
    
    // Step 2: 获取用户购物车
    userCartKey := fmt.Sprintf("cart:user:%d", userID)
    userCart, _ := c.redis.Get(ctx, userCartKey).Result()
    
    // Step 3: 合并(相同商品累加数量)
    merged := mergeCarts(parseCart(anonCart), parseCart(userCart))
    
    // Step 4: 保存到用户购物车
    c.redis.Set(ctx, userCartKey, marshal(merged), 0)  // 永久存储
    
    // Step 5: 删除匿名购物车
    c.redis.Del(ctx, anonCartKey)
    
    // Step 6: 异步持久化到MySQL(防止Redis丢失)
    c.eventPublisher.Publish(ctx, &CartMergedEvent{
        UserID: userID,
        Items:  merged.Items,
    })
    
    return nil
}

阶段4:结算页试算

业务场景:用户点击"去结算"

核心设计

// 结算页聚合服务
func (a *CheckoutAggregation) Calculate(ctx context.Context, req *CalculateRequest) (*CalculateResponse, error) {
    // Step 1: 判断是否使用快照(ADR-008)
    var products []*Product
    var promos []*Promotion
    
    if req.Snapshot != nil && !req.Snapshot.IsExpired() {
        // 快照未过期,使用快照数据(性能优先)
        products = req.Snapshot.Products
        promos = req.Snapshot.Promos
    } else {
        // 快照过期,实时查询
        products, _ = a.productClient.BatchGet(ctx, req.SkuIDs)
        promos, _ = a.marketingClient.GetPromotions(ctx, req.SkuIDs, req.UserID)
    }
    
    // Step 2: 实时查询库存(不能用快照)
    stocks, _ := a.inventoryClient.BatchCheck(ctx, req.SkuIDs)
    
    // Step 3: 计算价格
    prices, _ := a.pricingClient.BatchCalculate(ctx, products, promos)
    
    // Step 4: 检查可下单性
    canCheckout := a.checkCanCheckout(stocks, req.Items)
    
    return &CalculateResponse{
        Items:       buildItems(products, stocks, prices),
        TotalPrice:  calculateTotal(prices),
        CanCheckout: canCheckout,
        Warnings:    a.generateWarnings(stocks, promos),
    }, nil
}

阶段5:下单与支付

完整下单流程(Saga模式):

// 订单创建Saga(编排多个服务调用)
type CreateOrderSaga struct {
    productClient   rpc.ProductClient
    inventoryClient rpc.InventoryClient
    pricingClient   rpc.PricingClient
    marketingClient rpc.MarketingClient
    orderRepo       *OrderRepo
}

func (s *CreateOrderSaga) Execute(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    var err error
    var reserved *ReserveResult
    var couponLock *CouponLock
    
    // Step 1: 实时查询商品信息(ADR-009:不使用快照)
    products, err := s.productClient.BatchGet(ctx, req.SkuIDs)
    if err != nil {
        return nil, fmt.Errorf("query products failed: %w", err)
    }
    
    // Step 2: 实时查询营销信息
    promos, err := s.marketingClient.GetPromotions(ctx, req.SkuIDs, req.UserID)
    if err != nil {
        return nil, fmt.Errorf("query promotions failed: %w", err)
    }
    
    // Step 3: 校验营销活动有效性
    for _, promo := range promos {
        if !s.validatePromotion(promo) {
            return nil, fmt.Errorf("promotion %s expired", promo.ID)
        }
    }
    
    // Step 4: 库存预占(CAS操作)
    reserved, err = s.inventoryClient.Reserve(ctx, req.Items)
    if err != nil {
        return nil, fmt.Errorf("库存不足: %w", err)
    }
    defer func() {
        if err != nil {
            // 补偿:释放库存
            s.inventoryClient.Release(ctx, reserved.ReserveID)
        }
    }()
    
    // Step 5: 优惠券锁定
    if req.CouponCode != "" {
        couponLock, err = s.marketingClient.LockCoupon(ctx, req.CouponCode, req.UserID)
        if err != nil {
            return nil, fmt.Errorf("优惠券锁定失败: %w", err)
        }
        defer func() {
            if err != nil {
                // 补偿:释放优惠券
                s.marketingClient.UnlockCoupon(ctx, couponLock.LockID)
            }
        }()
    }
    
    // Step 6: 实时计算价格
    price, err := s.pricingClient.Calculate(ctx, products, promos)
    if err != nil {
        return nil, fmt.Errorf("价格计算失败: %w", err)
    }
    
    // Step 7: 价格校验(ADR-011)
    if req.ExpectedPrice > 0 {
        if err := s.validatePriceChange(req.ExpectedPrice, price.FinalPrice); err != nil {
            return nil, err
        }
    }
    
    // Step 8: 生成商品快照
    snapshot := s.generateProductSnapshot(products, promos, price)
    
    // Step 9: 创建订单
    order := &Order{
        OrderID:         s.generateOrderID(),
        UserID:          req.UserID,
        Items:           req.Items,
        TotalPrice:      price.FinalPrice,
        ProductSnapshot: marshal(snapshot),
        ReserveID:       reserved.ReserveID,
        CouponLockID:    couponLock.LockID,
        Status:          StatusPendingPayment,
        ExpireTime:      time.Now().Add(15 * time.Minute),
    }
    
    err = s.orderRepo.Create(ctx, order)
    if err != nil {
        return nil, fmt.Errorf("订单创建失败: %w", err)
    }
    
    // Step 10: 发布订单创建事件(异步)
    s.eventPublisher.Publish(ctx, &OrderCreatedEvent{
        OrderID: order.OrderID,
        UserID:  order.UserID,
        Items:   order.Items,
    })
    
    return order, nil
}

交易流监控

阶段关键指标目标值
搜索搜索→点击转化率> 15%
详情页详情→加购转化率> 8%
购物车加购→结算转化率> 30%
结算页结算→下单转化率> 60%
支付下单→支付转化率> 85%
整体搜索→支付转化率> 2%

16.5.8 DDD战术设计实践

领域模型是系统设计的核心。本节展示如何在订单域应用DDD战术模式。

聚合设计:Order聚合根

// Order聚合根
type Order struct {
    // 聚合根ID
    orderID OrderID  // 值对象
    
    // 基本信息
    userID    int64
    shopID    int64
    
    // 订单明细(实体集合)
    items []*OrderItem
    
    // 价格信息(值对象)
    pricing *OrderPricing
    
    // 状态(值对象)
    status OrderStatus
    
    // 时间戳
    createdAt time.Time
    updatedAt time.Time
    
    // 领域事件(未提交)
    domainEvents []DomainEvent
}

// 值对象:OrderID
type OrderID struct {
    value string
}

func NewOrderID() OrderID {
    return OrderID{value: generateSnowflakeID()}
}

func (id OrderID) String() string {
    return id.value
}

// 值对象:OrderPricing
type OrderPricing struct {
    subtotal       int64  // 商品总价
    discount       int64  // 折扣金额
    couponDiscount int64  // 优惠券
    payableAmount  int64  // 应付金额
}

func (p *OrderPricing) Calculate() int64 {
    return p.subtotal - p.discount - p.couponDiscount
}

// 实体:OrderItem
type OrderItem struct {
    itemID    int64
    skuID     int64
    quantity  int
    unitPrice int64
    
    // 快照
    snapshot *ItemSnapshot
}

// 聚合根方法:状态转换
func (o *Order) TransitionTo(newStatus OrderStatus) error {
    // 检查状态转换是否合法
    if !o.status.CanTransitionTo(newStatus) {
        return fmt.Errorf("不允许从 %s 转换到 %s", o.status, newStatus)
    }
    
    oldStatus := o.status
    o.status = newStatus
    o.updatedAt = time.Now()
    
    // 发布领域事件
    o.addDomainEvent(&OrderStatusChangedEvent{
        OrderID:   o.orderID,
        OldStatus: oldStatus,
        NewStatus: newStatus,
        ChangedAt: o.updatedAt,
    })
    
    return nil
}

// 聚合根方法:添加商品项
func (o *Order) AddItem(item *OrderItem) error {
    // 不变量检查:订单金额不能超过限额
    if o.calculateTotal()+item.Total() > MAX_ORDER_AMOUNT {
        return errors.New("订单金额超过限额")
    }
    
    o.items = append(o.items, item)
    
    // 发布领域事件
    o.addDomainEvent(&OrderItemAddedEvent{
        OrderID: o.orderID,
        Item:    item,
    })
    
    return nil
}

// 不变量:订单金额 = 所有商品项之和
func (o *Order) calculateTotal() int64 {
    total := int64(0)
    for _, item := range o.items {
        total += item.Total()
    }
    return total
}

// 领域事件管理
func (o *Order) addDomainEvent(event DomainEvent) {
    o.domainEvents = append(o.domainEvents, event)
}

func (o *Order) DomainEvents() []DomainEvent {
    return o.domainEvents
}

func (o *Order) ClearDomainEvents() {
    o.domainEvents = nil
}

Repository模式

// OrderRepository接口(领域层定义)
type OrderRepository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, orderID OrderID) (*Order, error)
    FindByUserID(ctx context.Context, userID int64, limit int) ([]*Order, error)
}

// OrderRepositoryImpl实现(基础设施层)
type OrderRepositoryImpl struct {
    db            *gorm.DB
    eventPublisher EventPublisher
}

func (r *OrderRepositoryImpl) Save(ctx context.Context, order *Order) error {
    // Step 1: 转换聚合根 → 数据模型
    orderDO := r.toDataObject(order)
    
    // Step 2: 保存到数据库
    err := r.db.Transaction(func(tx *gorm.DB) error {
        // 保存订单主表
        if err := tx.Create(orderDO).Error; err != nil {
            return err
        }
        
        // 保存订单明细表
        for _, item := range order.Items() {
            itemDO := r.toItemDataObject(item, orderDO.ID)
            if err := tx.Create(itemDO).Error; err != nil {
                return err
            }
        }
        
        return nil
    })
    
    if err != nil {
        return err
    }
    
    // Step 3: 发布领域事件(事务提交后)
    for _, event := range order.DomainEvents() {
        r.eventPublisher.Publish(ctx, event)
    }
    order.ClearDomainEvents()
    
    return nil
}

领域事件与Outbox模式

// Outbox表(确保事件必达)
type Outbox struct {
    ID          int64
    EventType   string
    EventData   string  // JSON
    Status      string  // PENDING/PUBLISHED/FAILED
    RetryCount  int
    CreatedAt   time.Time
}

// 发布领域事件(Outbox模式)
func (p *EventPublisher) Publish(ctx context.Context, event DomainEvent) error {
    // Step 1: 序列化事件
    eventData, _ := json.Marshal(event)
    
    // Step 2: 写入Outbox表(与业务在同一事务)
    outbox := &Outbox{
        EventType: event.Type(),
        EventData: string(eventData),
        Status:    "PENDING",
        CreatedAt: time.Now(),
    }
    
    return p.db.Create(outbox).Error
}

// Outbox轮询器(定时扫描未发布的事件)
func (w *OutboxWorker) Run() {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    
    for range ticker.C {
        // Step 1: 查询待发布事件(PENDING状态)
        var outboxes []*Outbox
        w.db.Where("status = ? AND retry_count < ?", "PENDING", 3).
            Limit(100).
            Find(&outboxes)
        
        // Step 2: 发布到Kafka
        for _, outbox := range outboxes {
            err := w.kafkaProducer.Send(outbox.EventType, outbox.EventData)
            
            if err == nil {
                // 发布成功,标记为PUBLISHED
                w.db.Model(outbox).Update("status", "PUBLISHED")
            } else {
                // 发布失败,重试计数+1
                w.db.Model(outbox).Updates(map[string]interface{}{
                    "retry_count": gorm.Expr("retry_count + 1"),
                    "status":      "FAILED",
                })
            }
        }
    }
}

16.5.6 架构决策记录(ADR)

本节记录系统设计过程中的关键架构决策,包括决策背景、备选方案、最终决策及理由。ADR是架构演进的重要资产,帮助团队理解「为什么这样设计」,避免重复讨论。

ADR-001: 计价中心数据输入方式

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:计价中心需要营销信息(促销规则、优惠券等)来计算最终价格,有两种方案:

  • 方案1:计价中心自己调用Marketing Service获取营销信息
  • 方案2:聚合服务获取营销信息后传递给计价中心

决策:采用方案2,由聚合服务获取营销信息后传递给计价中心。

理由

  1. 单一职责原则(SRP)

    • Pricing Service专注于价格计算逻辑(纯函数)
    • Aggregation Service负责数据编排和获取
    • 职责边界清晰,符合微服务设计原则
  2. 依赖解耦

    方案1依赖链:Aggregation → Pricing → Marketing(传递性依赖)
    方案2依赖链:Aggregation → Pricing | Marketing(平行依赖)✓
    
  3. 性能优化空间更大

    • 聚合层可以并发调用Marketing和其他服务(Product、Inventory)
    • Pricing变成纯计算,无IO等待
    • 减少网络调用层级(2层 vs 3层)
  4. 易于测试

    // 方案2:Pricing是纯函数,测试简单
    func TestCalculatePrice(t *testing.T) {
        priceItem := &PriceCalculateItem{
            SkuID:     1001,
            BasePrice: 2399.00,
            PromoInfo: &PromoInfo{DiscountRate: 0.9},  // Mock数据
        }
        result := pricingService.Calculate(priceItem)
        assert.Equal(t, 2159.10, result.FinalPrice)
    }
    
  5. 统一降级处理

    • 聚合层统一处理各服务失败(Marketing、Product、Inventory)
    • Pricing Service无感知,始终收到完整输入数据
    • 降级逻辑不混入业务计算

代码示例

// SearchOrchestrator(聚合服务)
func (o *SearchOrchestrator) Search(ctx context.Context, req *SearchRequest) (*SearchResponse, error) {
    // Step 1: 获取sku_ids(从ES)
    skuIDs, _ := o.searchClient.QuerySkuIDs(ctx, req.Keyword)
    
    // Step 2: 并发调用Product + Inventory + Marketing
    var products []*Product
    var stocks []*Stock
    var promos map[int64]*PromoInfo
    
    g, ctx := errgroup.WithContext(ctx)
    g.Go(func() error {
        products, _ = o.productClient.BatchGet(ctx, skuIDs)
        return nil
    })
    g.Go(func() error {
        stocks, _ = o.inventoryClient.BatchCheck(ctx, skuIDs)
        return nil
    })
    g.Go(func() error {
        promos, _ = o.marketingClient.BatchGet(ctx, skuIDs, req.UserID)
        // 降级:Marketing故障时使用空促销
        if promos == nil {
            promos = make(map[int64]*PromoInfo)
        }
        return nil
    })
    g.Wait()
    
    // Step 3: 调用Pricing计算价格(传入营销信息)
    priceItems := buildPriceItems(products, promos)
    prices, _ := o.pricingClient.BatchCalculate(ctx, priceItems)
    
    return buildSearchResponse(products, stocks, prices), nil
}

// PricingService(计价中心)- 纯函数,只负责计算
func (s *PricingService) Calculate(item *PriceItem) *PriceResult {
    finalPrice := item.BasePrice
    
    // 应用促销折扣(数据来自聚合层)
    if item.PromoInfo != nil {
        finalPrice = finalPrice * item.PromoInfo.DiscountRate
    }
    
    return &PriceResult{
        OriginalPrice: item.BasePrice,
        FinalPrice:    finalPrice,
        Discount:      item.BasePrice - finalPrice,
    }
}

影响范围

  • Aggregation Service:增加Marketing Service调用
  • Pricing Service:接收PromoInfo作为输入参数
  • Marketing Service:无影响

ADR-002: 库存预占时机

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:在下单流程中,库存预占的时机有两种选择:

  • 方案1:结算试算时预占(早期锁定)
  • 方案2:确认下单时预占(延迟锁定)

决策:采用方案2,在确认下单时预占库存。

理由

  1. 减少无效预占

    • 用户在试算阶段可能多次修改商品、数量、优惠券
    • 早期预占会导致大量无效锁定(用户未真正下单)
    • 试算到下单的转化率通常只有20-30%
  2. 提升库存利用率

    • 避免库存被长时间预占(用户可能犹豫、放弃)
    • 预占时长控制在15分钟内(支付超时自动释放)
  3. 降低系统压力

    • 试算接口QPS高(用户多次试算),预占会导致Redis压力大
    • 确认下单QPS相对较低,预占操作更可控
  4. 用户体验

    • 试算快速返回(不需要等待预占操作)
    • 确认下单时再预占,用户心理准备更充分

权衡

  • ✓ 优点:提升库存利用率、减少无效预占、降低系统压力
  • ✗ 缺点:确认下单时可能库存不足(需要前端提示)

降低缺点的措施

  • 试算时展示实时库存状态("仅剩N件")
  • 确认下单时二次校验库存,失败友好提示
  • 热门商品提前告知"库存紧张,请尽快下单"

ADR-003: 聚合服务 vs BFF

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:在API Gateway和微服务之间,是使用BFF(Backend For Frontend)还是Aggregation Service?

决策:采用Aggregation Service,而不是传统BFF。

理由

  1. 业务导向 vs 端导向

    • BFF按端划分(Web BFF、App BFF、小程序 BFF)
    • Aggregation按业务场景划分(搜索聚合、详情聚合、结算聚合)✓
    • 本系统多个端(Web、App)的业务逻辑高度一致,按端拆分会导致重复代码
  2. 代码复用

    BFF模式:
    ├─ Web BFF(搜索逻辑)
    ├─ App BFF(搜索逻辑)    ← 重复代码
    └─ 小程序 BFF(搜索逻辑) ← 重复代码
    
    Aggregation模式:✓
    ├─ Search Aggregation(Web/App/小程序共用)
    └─ Detail Aggregation(Web/App/小程序共用)
    
  3. 维护成本

    • BFF需要维护多个端的代码一致性
    • Aggregation只需维护一套业务逻辑
  4. 适配端差异的方式

    • API Gateway层处理端协议差异(HTTP、WebSocket、gRPC)
    • Aggregation返回标准数据格式,前端各端按需裁剪

适用场景

  • ✓ 多端业务逻辑高度一致(如本系统)
  • ✗ 不适用:各端业务逻辑差异大(如社交产品,Feed流算法不同)

ADR-004: 虚拟商品库存模型

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:虚拟商品(机票、充值卡、优惠券)的库存模型和实物商品差异大,应该如何设计?

决策:采用二维库存模型(ManagementType + UnitType)。

库存管理类型(ManagementType)

类型说明典型品类库存来源
实时库存强依赖供应商实时查询机票、酒店供应商API
池化库存自有库存,可超卖后补偿充值卡、优惠券平台采购
无限库存虚拟商品,无库存限制SaaS服务、数字内容

库存单位类型(UnitType)

类型说明典型品类
SKU级别每个规格独立库存充电器(颜色、规格)
批次级别按批次管理(有效期)优惠券、礼品卡
座位级别唯一标识(座位号)机票、电影票

理由

  1. 不同品类的库存特性差异极大,无法用统一模型
  2. 二维模型提供灵活性,支持策略模式动态选择
  3. 便于扩展新品类(只需添加新策略)

ADR-005: 同步 vs 异步数据流

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:下单流程中,哪些操作应该同步执行,哪些应该异步执行?

决策:采用同步+异步混合模式

同步操作(用户等待)

  1. 库存预占(必须成功,否则无法下单)
  2. 优惠券扣减(避免超发)
  3. 订单创建(生成order_id)

异步操作(Kafka事件)

  1. 库存确认扣减(预占成功后,异步确认)
  2. 搜索索引更新(销量、热度)
  3. 购物车清理
  4. 用户行为分析
  5. 消息通知(订单确认、物流更新)

理由

  1. 用户体验

    • 同步操作<500ms,用户可接受
    • 非核心操作异步化,不阻塞下单
  2. 系统解耦

    • 异步事件降低服务间强依赖
    • 消费者故障不影响下单流程
  3. 性能优化

    • 减少下单接口响应时间
    • 异步操作可批量处理(提升吞吐)
  4. 容错能力

    • 异步操作支持重试(Kafka消费者重试机制)
    • 同步操作失败可立即回滚(Saga模式)

ADR-009: 创单时是否使用快照数据(核心安全决策)

决策日期:2026-04-15
状态:已采纳 ✓

问题描述:用户从详情页到提交订单期间,前端已经缓存了商品信息、价格、活动等快照数据。在用户点击"提交订单"创建订单时,后端是否可以使用这些快照数据来提升性能,避免重复查询?

备选方案

方案描述优点缺点
方案A:使用快照创单时直接使用前端传递的快照数据✅ 性能好(无需查询)
✅ 响应快(200ms → 50ms)
❌ 安全风险高(快照可能被篡改)
❌ 资损风险
方案B:强制实时查询创单时强制调用商品服务、营销服务查询最新数据✅ 数据绝对准确
✅ 安全性高(防篡改)
✅ 无资损风险
❌ 性能稍差(多次RPC调用)
❌ RT增加100-200ms
方案C:混合模式普通商品用快照,营销商品强制查询⚠️ 复杂度高
⚠️ 容易出错
❌ 维护成本高
❌ 边界不清晰

决策:采用方案B(强制实时查询)

决策理由

  1. 安全性优先于性能

    风险分析:
    - 如果用快照,活动结束但快照未更新 → 用户用秒杀价下单 → 资损
    - 如果用快照,用户篡改价格 → 恶意低价下单 → 资损
    - 性能损失:100-200ms
    - 资损风险:每单可能损失数百至数千元
    
    结论:100ms的性能代价 << 资损风险
    
  2. 涉及资金的操作必须实时校验

    创单 = 锁定库存 + 锁定价格 + 准备扣款
    → 必须基于最新、最准确的数据
    → 不能因为性能优化而妥协安全性
    
  3. 防止恶意篡改

    场景:黑产抓包修改快照数据
    快照:{"expected_payable": 799900}  // 原价 ¥7,999
    篡改:{"expected_payable": 1}       // 改成 ¥0.01
    
    如果后端使用快照:
    → 按 ¥0.01 创单 → 公司巨额损失!
    
    强制实时查询:
    → 后端查到实际价格 ¥7,999
    → 对比快照 ¥0.01 vs 实际 ¥7,999
    → 差异巨大,拒绝创单!
    
  4. 活动可能随时变化

    10:00  秒杀价 ¥7,999,生成快照
    10:04  秒杀活动提前结束(库存售罄)
    10:05  用户提交订单
    
    如果用快照:
    → 按 ¥7,999 创单(活动已结束!)
    → 资损
    
    强制查询:
    → 查到活动已结束,价格 ¥8,999
    → 提示用户价格变化
    → 避免资损
    

实现方案

// OrderService.CreateOrder - 确认下单接口(准确性优先)
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    // ⚠️ 关键:创单时不使用任何前端传递的快照数据,全部实时查询
    
    // Step 1: 实时查询商品信息(不使用前端快照)
    products, err := s.productClient.BatchGetProducts(ctx, req.SkuIDs)
    if err != nil {
        return nil, fmt.Errorf("query products failed: %w", err)
    }
    
    // Step 2: 实时查询营销活动(强制最新数据)
    promos, err := s.marketingClient.BatchGetPromotions(ctx, req.SkuIDs, req.UserID)
    if err != nil {
        return nil, fmt.Errorf("query promotions failed: %w", err)
    }
    
    // Step 3: 校验营销活动有效性(关键:防止使用过期活动)
    for _, promo := range promos {
        if !s.validatePromotion(promo) {
            return nil, fmt.Errorf("promotion %s is invalid or expired", promo.ID)
        }
    }
    
    // Step 4: 实时计算价格(基于最新营销数据)
    price, err := s.pricingClient.CalculateFinalPrice(ctx, products, promos)
    if err != nil {
        return nil, fmt.Errorf("calculate price failed: %w", err)
    }
    
    // Step 5: 价格校验(对比前端传递的期望价格)
    if req.ExpectedPrice > 0 {
        if err := s.validatePriceChange(req.ExpectedPrice, price.FinalPrice); err != nil {
            return nil, err  // 价格变化过大,拒绝创单
        }
    }
    
    // Step 6: 预占库存
    reserved, err := s.inventoryClient.ReserveStock(ctx, req.Items)
    if err != nil {
        return nil, fmt.Errorf("reserve stock failed: %w", err)
    }
    
    // Step 7: 生成商品快照(基于实时查询的数据)
    snapshot := s.generateProductSnapshot(products, promos, price)
    
    // Step 8: 创建订单(保存快照)
    order := &Order{
        OrderID:         s.generateOrderID(),
        UserID:          req.UserID,
        Items:           req.Items,
        TotalPrice:      price.FinalPrice,
        ProductSnapshot: marshal(snapshot),  // 💾 保存商品快照
        Status:          OrderStatusPendingPayment,
        ExpireTime:      time.Now().Add(15 * time.Minute),
        ReserveIDs:      reserved,
    }
    
    return s.orderRepo.Create(ctx, order)
}

// 价格校验逻辑(防止用户感知差)
func (s *OrderService) validatePriceChange(expected, actual int64) error {
    diff := actual - expected
    diffPercent := float64(diff) / float64(expected) * 100
    
    // 场景1: 价格降低 → 允许(对用户有利)
    if diff < 0 {
        return nil
    }
    
    // 场景2: 价格上涨 < 1元 → 允许(误差容忍)
    if diff <= 100 { // 100分 = 1元
        return nil
    }
    
    // 场景3: 价格上涨 >= 1元 且 < 5% → 允许但记录日志
    if diffPercent < 5.0 {
        log.Warnf("price increased: expected=%d, actual=%d", expected, actual)
        return nil
    }
    
    // 场景4: 价格上涨 >= 5% → 拒绝,要求用户重新确认
    return &PriceChangedError{
        Expected: expected,
        Actual:   actual,
        Message:  fmt.Sprintf("价格已变化,请重新确认"),
    }
}

核心原则

┌────────────────────────────────────────────────────────┐
│ 试算阶段:性能优先 → 可用快照(5分钟缓存)              │
│ 创单阶段:准确性优先 → 强制实时查询                     │
│ 历史查询:可追溯性 → 保存快照到订单表                   │
└────────────────────────────────────────────────────────┘

ADR-010: 创单与支付的时序关系

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:在订单流程中,"创建订单"和"支付"这两个动作的时序关系有两种模式:

  1. 创单即支付:用户点击"立即购买"后,先支付,支付成功后再创建订单
  2. 先创单后支付:用户点击"提交订单"后,先创建订单(资源扣减),然后再支付

决策:采用"先创单后支付"模式

理由

1. 防止超卖(关键)

【创单即支付模式的问题】:
1. 用户A看到库存=1
2. 用户B也看到库存=1
3. 用户A点击支付(此时库存未扣减)
4. 用户B也点击支付(库存仍未扣减)
5. 两人同时支付成功 → 超卖!

【先创单后支付模式的解决方案】:
1. 用户A点击"提交订单" → 库存预占:1 → 0(剩余可用)
2. 用户B点击"提交订单" → 库存不足,下单失败
3. 用户A有15分钟支付窗口
4. 如果用户A超时未支付 → 释放库存:0 → 1(其他人可下单)

2. 用户体验更好

  • ✅ 用户点击"提交订单"后,订单立即生成,库存被锁定
  • ✅ 用户可以慢慢选择支付方式(支付宝、微信、银行卡)
  • ✅ 用户可以在支付环节选择优惠券、支付渠道优惠
  • ✅ 用户可以先下单占位,稍后再支付(适合机票、酒店)

3. 价格计算灵活性

  • 创单时计算:商品基础价格 + 营销优惠(折扣、满减)
  • 支付时计算:支付渠道费(信用卡手续费、花呗分期费)+ 支付渠道优惠

权衡

维度优势劣势
用户体验✅ 先锁定库存,再支付
✅ 支付环节更灵活
⚠️ 15分钟内库存被占用
防止超卖✅ 创单时锁定库存(零超卖)⚠️ 需要处理超时释放逻辑
库存利用率⚠️ 预占库存可能被浪费(10-20%未支付率)✅ 可通过缩短支付窗口优化
系统复杂度⚠️ 需要库存预占机制
⚠️ 需要超时释放定时任务
⚠️ 状态机更复杂

超时未支付处理

// OrderTimeoutJob - 定时扫描超时未支付订单
func (j *OrderTimeoutJob) Run() {
    // 查询超时订单(创建时间 > 15分钟,状态=PENDING_PAYMENT)
    expiredOrders := j.orderRepo.FindExpiredPendingPayment(15 * time.Minute)
    
    for _, order := range expiredOrders {
        // 1. 更新订单状态:PENDING_PAYMENT → CANCELLED
        order.Status = OrderStatusCancelled
        order.CancelReason = "超时未支付"
        j.orderRepo.Update(ctx, order)
        
        // 2. 释放库存
        j.inventoryClient.ReleaseStock(ctx, order.ReserveIDs)
        
        // 3. 回退优惠券
        if order.CouponID != "" {
            j.marketingClient.ReleaseCoupon(ctx, order.CouponID, order.UserID)
        }
        
        // 4. 发布订单取消事件
        j.eventPublisher.Publish(ctx, &OrderCancelledEvent{
            OrderID: order.OrderID,
            Reason:  "超时未支付",
        })
    }
}

ADR-011: 创单时前后端价格校验策略

决策日期:2026-04-15
状态:已采纳 ✓

问题描述:创单时后端实时查询得到的价格,可能和前端展示的价格不一致(活动变化、价格调整)。应该如何处理这种差异?

决策:采用差异容忍 + 提示机制

价格对比规则

场景差异情况处理策略理由
场景1价格降低✅ 直接通过对用户有利
场景2价格上涨 < 1元✅ 允许(容忍误差)微小差异,可接受
场景3价格上涨 >= 1元 且 < 5%✅ 允许但记录日志合理波动范围
场景4价格上涨 >= 5%❌ 拒绝,要求重新确认差异过大,影响用户决策

实现代码

func (s *OrderService) validatePriceChange(expected, actual int64) error {
    diff := actual - expected
    diffPercent := float64(diff) / float64(expected) * 100
    
    // 场景1: 价格降低 → 允许(对用户有利)
    if diff < 0 {
        return nil
    }
    
    // 场景2: 价格上涨 < 1元 → 允许
    if diff <= 100 {
        return nil
    }
    
    // 场景3: 价格上涨 < 5% → 允许但记录
    if diffPercent < 5.0 {
        log.Warnf("price increased: expected=%d, actual=%d, diff=%d", 
            expected, actual, diff)
        return nil
    }
    
    // 场景4: 价格上涨 >= 5% → 拒绝
    return &PriceChangedError{
        Expected: expected,
        Actual:   actual,
        Message:  fmt.Sprintf("价格已变化:原价%.2f元,现价%.2f元", 
            float64(expected)/100, float64(actual)/100),
    }
}

前端交互

// 前端处理价格变化错误
try {
    const order = await api.createOrder(orderData);
} catch (error) {
    if (error.code === 'PRICE_CHANGED') {
        // 弹窗提示用户
        showConfirmDialog({
            title: '价格已变化',
            message: error.message,
            confirm: '接受新价格并下单',
            cancel: '返回重新选择'
        }).then((confirmed) => {
            if (confirmed) {
                // 用户接受新价格,使用新价格重新下单
                api.createOrder({
                    ...orderData,
                    acceptNewPrice: true,
                    expectedPrice: error.actualPrice
                });
            }
        });
    }
}

ADR-012: 试算价格计算与创单价格计算的统一与差异

决策日期:2026-04-15
状态:已采纳 ✓

问题描述:试算接口(/checkout/calculate)和创单接口(/order/create)都需要计算价格,两者的价格计算逻辑应该如何设计?

决策统一计价服务 + 差异化数据输入

核心设计

接口数据输入计算逻辑快照策略
试算接口可使用快照(5分钟)调用统一计价服务允许快照数据
创单接口强制实时查询调用统一计价服务禁止快照数据

理由

  1. 计价逻辑统一

    • 试算和创单使用同一个 PricingService.Calculate
    • 避免"试算价格"与"订单价格"不一致
    • 营销规则变更只需更新一处
  2. 数据输入差异化

    • 试算:允许使用缓存/快照数据(性能优先)
    • 创单:强制实时查询(准确性优先)
  3. 最终一致性保证

    • 试算阶段可能使用过期快照
    • 创单阶段的实时查询是最后防线
    • 价格差异会被拦截并提示用户

架构图

graph TB
    subgraph 试算接口
        A1[Checkout.Calculate]
        A2[使用快照数据<br/>性能优先]
    end
    
    subgraph 创单接口
        B1[Order.Create]
        B2[强制实时查询<br/>准确性优先]
    end
    
    subgraph 计价服务
        C[PricingService.Calculate<br/>统一计算逻辑]
    end
    
    A1 --> A2
    A2 --> C
    B1 --> B2
    B2 --> C

ADR-013: 价格在整个交易链路中的流转与计算策略

决策日期:2026-04-15
状态:已采纳 ✓

问题描述:从用户搜索商品到最终支付,价格会经历多个阶段(搜索列表 → 商品详情 → 加购试算 → 创单 → 支付)。每个阶段的价格计算范围、数据来源、系统交互都不同。需要一个全局视角来理解价格是如何流转的,以及各阶段的相同点和不同点。

核心挑战

业务困惑:
• 为什么搜索列表的价格和详情页不一样?
• 详情页显示的价格和试算价格能保证一致吗?
• 试算价格和最终支付价格可能不同吗?
• 每个阶段都要调用Pricing Service吗?
• 基础价格、营销折扣、优惠券、Coin、支付渠道费分别在哪个阶段计算?

决策:采用**"分阶段计算 + 逐步扩展价格维度 + 最终强制校验"**策略


价格流转全局图

用户旅程:搜索 → 详情 → 试算 → 创单 → 支付
           ↓      ↓      ↓      ↓      ↓
价格计算: 基础价  +营销  +营销  +营销  +Coin+Voucher+渠道费
           ↓      ↓      ↓      ↓      ↓
数据来源: ES缓存  实时   快照   强制   强制实时
                         (可选) 实时
           ↓      ↓      ↓      ↓      ↓
性能目标: 30ms   150ms  230ms  500ms  200ms

五个阶段对比

阶段价格维度数据来源性能目标计算复杂度资损风险
搜索列表基础价(最低价)ES缓存(延迟1-5分钟)P95 < 30ms低(只查ES)
商品详情基础价 + 营销折扣实时查询 + 生成快照P95 < 150ms中(3个服务)
结算试算基础价 + 营销 + 数量快照 OR 实时查询P95 < 230ms中(可能3个服务)
确认下单基础价 + 营销 + 数量 + 券强制实时查询P95 < 500ms高(4个服务 + 预占)
支付确认上述 + Coin + Voucher + 渠道费强制实时查询P95 < 200ms高(多维度计算)极高

核心设计原则

  1. 逐步扩展价格维度

    搜索:最低价(吸引用户)
    详情:折扣价(展示营销)
    试算:总价(含数量、券)
    创单:锁定价(预占资源)
    支付:最终价(含所有优惠与费用)
    
  2. 数据来源分级

    搜索/详情:允许缓存(性能优先)
    试算:允许快照(性能与准确性平衡)
    创单/支付:强制实时(安全优先)
    
  3. 多道防线保证准确性

    详情页:生成快照(用于试算)
    试算:对比快照与实时(发现变化)
    创单:强制实时 + 价格校验(最后防线)
    支付:二次校验 + Coin/Voucher锁定(终极防线)
    

监控指标

  • 各阶段P95响应时间
  • 快照命中率(目标 > 80%)
  • 价格差异率(试算vs创单,目标 < 5%)
  • 价格变化拦截率(创单价格校验触发频率)

16.6 系统边界与集成实践

16.6.1 边界划分的实际案例

案例1:计价系统的边界重构

初始问题

  • 价格计算逻辑分散在订单、营销、商品三个域
  • 购物车、订单创建、支付确认三处价格计算不一致
  • 无法支持"PDP加购试算"场景

重构方案

  1. 新建计价上下文:职责是提供统一的试算接口
  2. 定义边界
    • 计价上下文不拥有商品基础价、营销规则、订单状态
    • 对外提供 Calculate(items, promotions, context) -> PriceBreakdown
    • 各场景通过统一接口获取价格
  3. 收益
    • 价格一致性得到保证
    • 营销规则变更只需在营销域发布事件
    • 支持了试算、价格预览、价格审计等新需求

案例2:库存预占的归属

争议:库存预占应该放在订单域还是库存域?

决策:放在库存域

理由

  • 库存域拥有库存数据所有权
  • 预占是库存的一种状态(可售 → 预占 → 扣减)
  • 订单域只需调用库存域的 Reserve 接口
  • 降低耦合:订单域不需要了解库存的存储结构

16.6.2 集成模式选择

集成场景模式理由
订单 → 商品同步RPC需要实时获取商品信息,延迟<100ms
订单 → 库存同步RPC库存预占是核心路径,必须同步
订单 → 支付同步RPC支付创建需要同步返回支付URL
订单成功 → 搜索异步事件销量更新非核心路径,可最终一致
订单成功 → 积分异步事件积分增加非核心路径

事件驱动示例

// 订单域发布事件
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    // 创建订单...
    order := &Order{...}
    s.repo.Save(ctx, order)
    
    // 发布事件(Outbox模式)
    event := &OrderCreatedEvent{
        OrderID:    order.ID,
        UserID:     order.UserID,
        TotalPrice: order.TotalPrice,
        Items:      order.Items,
    }
    s.outbox.Publish(ctx, "order-events", event)
    
    return order, nil
}

// 搜索域订阅事件
func (s *SearchService) HandleOrderCreated(ctx context.Context, event *OrderCreatedEvent) error {
    // 更新商品销量(用于排序)
    for _, item := range event.Items {
        s.incrementSales(ctx, item.SkuID, item.Quantity)
    }
    return nil
}

16.6.3 跨系统事务处理

Saga模式(编排)

// 订单创建Saga
type CreateOrderSaga struct {
    inventoryClient rpc.InventoryClient
    marketingClient rpc.MarketingClient
    orderRepo       *OrderRepo
}

func (s *CreateOrderSaga) Execute(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    var reserveID string
    var couponLockID string
    
    // Step 1: 库存预占
    reserve, err := s.inventoryClient.ReserveStock(ctx, req.Items)
    if err != nil {
        return nil, fmt.Errorf("库存预占失败: %w", err)
    }
    reserveID = reserve.ReserveID
    defer func() {
        if err != nil {
            // 补偿:释放库存
            s.inventoryClient.ReleaseStock(ctx, reserveID)
        }
    }()
    
    // Step 2: 优惠券锁定
    couponLock, err := s.marketingClient.LockCoupon(ctx, req.CouponCode, req.UserID)
    if err != nil {
        return nil, fmt.Errorf("优惠券锁定失败: %w", err)
    }
    couponLockID = couponLock.LockID
    defer func() {
        if err != nil {
            // 补偿:释放优惠券
            s.marketingClient.UnlockCoupon(ctx, couponLockID)
        }
    }()
    
    // Step 3: 创建订单
    order := &Order{
        ID:           generateOrderID(),
        UserID:       req.UserID,
        Items:        req.Items,
        ReserveID:    reserveID,
        CouponLockID: couponLockID,
        Status:       StatusPendingPayment,
    }
    err = s.orderRepo.Save(ctx, order)
    if err != nil {
        return nil, fmt.Errorf("订单创建失败: %w", err)
    }
    
    return order, nil
}

16.6.4 集成层设计

防腐层(Anti-Corruption Layer)

// 供应商响应模型(外部)
type SupplierFlightResponse struct {
    Code    string  `json:"code"`
    Message string  `json:"message"`
    Data    struct {
        FlightNo  string  `json:"flight_no"`
        Available int     `json:"available"`
        Price     float64 `json:"price"`
    } `json:"data"`
}

// 平台库存模型(内部)
type StockResponse struct {
    Available bool
    Quantity  int
    Message   string
}

// 防腐层:翻译外部模型 → 内部模型
func (a *FlightSupplierACL) TranslateStock(supplierResp *SupplierFlightResponse) *StockResponse {
    return &StockResponse{
        Available: supplierResp.Code == "SUCCESS" && supplierResp.Data.Available > 0,
        Quantity:  supplierResp.Data.Available,
        Message:   supplierResp.Message,
    }
}

收益

  • 领域层不被供应商模型污染
  • 供应商接口变更时,修改集中在ACL
  • 测试时可以使用Fake实现替代真实供应商

16.7 高可用与性能优化

16.7.1 高可用设计

服务多副本部署

服务正常副本大促副本扩容策略
Product Center618CPU > 70% 自动扩容
Inventory618QPS > 5000 扩容
Order824QPS > 3000 扩容
Payment412QPS > 2000 扩容

数据库高可用

MySQL:
• 主从复制(1主2从)
• 双主互备(支付库)
• 自动故障转移(MHA)

Redis:
• Sentinel模式(1主2从3哨兵)
• 自动故障转移

Kafka:
• 3副本
• ISR机制

熔断与降级

// 熔断配置
type CircuitBreakerConfig struct {
    MaxRequests       uint32        // 半开状态最大请求数
    Interval          time.Duration // 统计窗口
    Timeout           time.Duration // 熔断超时时间
    FailureThreshold  float64       // 失败率阈值(0-1)
}

// 降级策略
func (s *SearchAggregation) Search(ctx context.Context, req *SearchRequest) (*SearchResponse, error) {
    // 尝试调用Marketing Service
    promos, err := s.marketingClient.GetPromotions(ctx, req.SkuIDs)
    if err != nil {
        // 降级:使用基础价格(不展示营销信息)
        log.Warn("Marketing Service故障,降级为基础价格")
        promos = make(map[int64]*PromoInfo)  // 空促销
    }
    
    // 继续后续流程...
    return s.buildResponse(products, promos)
}

16.7.2 性能优化

缓存策略(多级缓存):

// L1: 本地缓存(进程内)
type LocalCache struct {
    cache *bigcache.BigCache
}

func (c *LocalCache) Get(key string) (interface{}, error) {
    data, err := c.cache.Get(key)
    if err == nil {
        return unmarshal(data), nil
    }
    return nil, err
}

// L2: Redis缓存
// L3: MySQL数据库

func (s *ProductService) GetProduct(ctx context.Context, skuID int64) (*Product, error) {
    // L1: 本地缓存
    if product, err := s.localCache.Get(skuID); err == nil {
        return product, nil
    }
    
    // L2: Redis缓存
    if product, err := s.redis.Get(ctx, fmt.Sprintf("product:%d", skuID)); err == nil {
        s.localCache.Set(skuID, product)  // 回填L1
        return product, nil
    }
    
    // L3: MySQL数据库
    product, err := s.repo.GetByID(ctx, skuID)
    if err != nil {
        return nil, err
    }
    
    // 回填缓存
    s.redis.Set(ctx, fmt.Sprintf("product:%d", skuID), product, 30*time.Minute)
    s.localCache.Set(skuID, product)
    
    return product, nil
}

批量查询优化

// 批量获取商品信息(减少RPC调用)
func (s *ProductService) BatchGetProducts(ctx context.Context, skuIDs []int64) (map[int64]*Product, error) {
    // Step 1: 尝试从缓存批量获取
    cached := s.redis.MGet(ctx, toCacheKeys(skuIDs))
    
    // Step 2: 找出缺失的ID
    missingIDs := findMissing(skuIDs, cached)
    
    // Step 3: 批量查询数据库(IN查询)
    if len(missingIDs) > 0 {
        missing, _ := s.repo.GetByIDs(ctx, missingIDs)
        // 回填缓存
        s.redis.MSet(ctx, missing, 30*time.Minute)
        cached = merge(cached, missing)
    }
    
    return cached, nil
}

数据库优化

-- 索引优化
CREATE INDEX idx_order_user_create ON `order` (user_id, create_time DESC);
CREATE INDEX idx_order_status ON `order` (status, create_time DESC);

-- 避免SELECT *(只查询需要的字段)
SELECT order_id, status, total_price FROM `order` WHERE user_id = ?;

-- 分页优化(使用索引覆盖)
SELECT order_id FROM `order` 
WHERE user_id = ? AND create_time > ?
ORDER BY create_time DESC
LIMIT 20;

16.7.3 容灾与降级

多机房部署

Region A(主):
• 写流量:100%
• 读流量:70%

Region B(备):
• 写流量:0%(只读副本)
• 读流量:30%

灾难切换:
• 自动故障检测(3秒)
• 流量切换到Region B(30秒)
• RTO:< 2分钟

降级开关

// Feature Flag控制降级
func (s *CheckoutService) Calculate(ctx context.Context, req *CalculateRequest) (*CalculateResponse, error) {
    // 检查Feature Flag
    if s.featureFlag.IsEnabled(ctx, "marketing.enabled") {
        // 正常逻辑:调用Marketing Service
        promos, _ := s.marketingClient.GetPromotions(ctx, req)
        return s.calculateWithPromos(req, promos)
    } else {
        // 降级逻辑:不使用营销信息
        return s.calculateBasic(req)
    }
}

16.8 团队组织与协作

16.8.1 团队结构

康威定律实践:系统架构反映组织沟通结构。

订单团队(15人)
├─ 订单核心(5人):订单创建、状态机
├─ 订单查询(3人):我的订单、订单详情
├─ 履约对接(4人):供应商履约、异常处理
└─ 测试(3人)

商品团队(12人)
├─ 商品中心(6人):SPU/SKU管理
├─ 类目属性(3人):类目树、属性模板
└─ 测试(3人)

库存团队(10人)
├─ 库存核心(5人):预占、扣减、释放
├─ 供应商同步(3人):实时查询、定时同步
└─ 测试(2人)

跨团队协作

场景协作方式工具
API契约OpenAPI/Proto定义Swagger、Buf
事件契约Schema RegistryConfluent Schema Registry
联调测试契约测试Pact
故障处理On-call轮值PagerDuty

16.8.2 协作流程

需求评审流程

1. 产品提需求(PRD)
   ↓
2. 技术评审(架构师+各团队Lead)
   • 是否需要新增服务?
   • 是否需要修改API契约?
   • 是否需要数据库迁移?
   ↓
3. API契约评审(上下游团队)
   • 定义Request/Response
   • 明确超时、重试策略
   • 确认降级方案
   ↓
4. 开发排期
   • 各团队独立开发
   • 契约测试通过后联调
   ↓
5. 集成测试
   • 端到端测试
   • 性能测试
   ↓
6. 灰度发布
   • 5% → 20% → 50% → 100%

实际案例:新增"拼团"功能的完整协作流程

第1周:需求评审与技术方案

【产品需求】
- 用户发起拼团(3人成团,24小时有效)
- 拼团价格比正常价格低20%
- 成团后统一发货,不成团退款

【技术评审会议】(2小时,架构师+6个团队Lead)
问题1:拼团功能是否需要新增服务?
  → 决策:新增"拼团服务"(GroupBuy Service)
  → 理由:拼团逻辑复杂(成团判断、超时处理),独立服务便于维护

问题2:拼团价格如何计算?
  → 决策:在Pricing Service中新增"拼团价格策略"
  → 理由:价格计算逻辑应该统一管理

问题3:拼团成功后如何扣减库存?
  → 决策:拼团成功时批量预占库存(3人份)
  → 理由:避免成团后库存不足

【输出物】
- 技术方案文档(15页)
- 服务依赖图(Mermaid图)
- 数据库设计(ER图)
- 时序图(成团流程、超时处理)

第2周:API契约评审

【API契约】
// 创建拼团
POST /groupbuy/create
Request:
{
  "sku_id": 1001,
  "original_price": 299.00,
  "groupbuy_price": 239.00,  // 8折
  "required_count": 3,        // 3人成团
  "expire_hours": 24          // 24小时有效
}
Response:
{
  "groupbuy_id": "GB20260501123456",
  "status": "waiting",        // 等待中
  "current_count": 1,         // 当前人数
  "required_count": 3,
  "expires_at": 1744633200
}

// 参与拼团
POST /groupbuy/join
Request:
{
  "groupbuy_id": "GB20260501123456",
  "user_id": 67890
}
Response:
{
  "status": "success",        // 成功 or 团满
  "order_id": "ORD123456",    // 如果成团,返回订单号
  "current_count": 3
}

【契约测试】
- 上游:前端团队(Web、App)
- 下游:Pricing Service、Inventory Service、Order Service
- 测试工具:Pact
- 测试覆盖:100%(所有API)

第3-4周:并行开发

【团队分工】
拼团团队(5人):
  - 拼团服务核心逻辑
  - 超时任务(15分钟扫描一次)
  - 数据库表设计(groupbuy、groupbuy_participant)

计价团队(2人):
  - 新增拼团价格策略
  - 拼团价格校验

库存团队(2人):
  - 批量预占库存接口

订单团队(3人):
  - 拼团成团后批量创建订单
  - 拼团失败后退款

前端团队(4人):
  - 拼团页面(发起、参与、分享)
  - 倒计时组件

【每日站会】(15分钟)
- 各团队汇报进度
- 识别阻塞点
- 协调资源

【契约测试通过率】
- 第3周末:70%
- 第4周末:100% ✅

第5周:集成测试

【测试场景】
场景1:正常成团
  1. 用户A发起拼团(3人成团)
  2. 用户B、C参与拼团
  3. 成团 → 创建3个订单 → 预占库存(3份)
  4. 用户A、B、C支付 → 确认扣减库存

场景2:超时未成团
  1. 用户A发起拼团(3人成团)
  2. 只有用户B参与(2人)
  3. 24小时后超时 → 标记拼团失败 → 退款

场景3:库存不足
  1. 用户A发起拼团(3人成团)
  2. 用户B、C参与拼团
  3. 成团时库存不足(只剩2个)→ 拼团失败 → 退款

【性能测试】
- 并发创建拼团:1000 TPS
- 并发参与拼团:5000 TPS
- 超时扫描任务:1000个拼团/秒
- P99延迟:< 300ms ✅

第6周:灰度发布

【灰度策略】
阶段1(5%):内部员工 + 白名单用户(1000人)
  → 观察1天:成团率、退款率、投诉数

阶段2(20%):北京、上海用户
  → 观察3天:性能指标、业务指标

阶段3(50%):全国用户
  → 观察1周

阶段4(100%):全量发布
  → 持续监控1个月

【关键指标】
- 成团率:65%(目标 > 60%)✅
- 退款率:5%(目标 < 10%)✅
- 用户投诉:3起/天(目标 < 10起)✅
- P99延迟:280ms(目标 < 300ms)✅

协作关键点

阶段关键协作点工具/机制
需求评审架构师+各团队Lead对齐技术方案会议+文档
API契约上下游团队明确接口定义OpenAPI + Pact
并行开发各团队独立开发,通过契约测试联调Pact + Mock Server
集成测试端到端测试,验证完整流程自动化测试平台
灰度发布分阶段发布,持续监控Feature Flag + Grafana

变更管理

// ADR(Architecture Decision Record)
// 记录重大架构决策

## ADR-014: 拼团功能是否复用订单服务

**决策日期**:2026-05-01
**状态**:已采纳 ✓

**问题描述**:
拼团功能需要创建订单,是在订单服务中新增拼团逻辑,还是新建拼团服务?

**备选方案**:
A. 在订单服务中新增拼团逻辑
   ✓ 复用订单创建逻辑
   ✗ 订单服务变得臃肿
   ✗ 拼团逻辑与订单逻辑耦合

B. 新建拼团服务
   ✓ 拼团逻辑独立,便于维护
   ✓ 订单服务保持单一职责
   ✗ 需要新建服务(增加运维成本)

**决策**:采用方案B,新建拼团服务

**理由**:
1. 拼团逻辑复杂(成团判断、超时处理、退款逻辑)
2. 拼团是营销活动,不是订单核心流程
3. 未来可能有"砍价""秒杀"等类似活动,独立服务便于扩展

**影响范围**:
- 新增服务:GroupBuy Service
- QPS估算:2000(正常)/ 10000(大促)
- 部署规模:4副本(正常)/ 12副本(大促)

**后续行动**:
- ✓ 已完成:GroupBuy Service开发
- ✓ 已完成:与订单服务集成
- ✓ 已完成:灰度上线

16.8.3 技术治理

代码评审清单

  • 是否符合分层架构(依赖方向正确)
  • 是否有单元测试(覆盖率 > 80%)
  • 是否有集成测试(核心路径)
  • 是否有性能测试(Benchmark)
  • 是否有监控指标(Prometheus Metrics)
  • 是否有日志(结构化日志)
  • 是否有文档(API文档、设计文档)
  • 是否考虑降级方案

技术债管理

## 技术债清单

| 优先级 | 类型 | 描述 | 负责人 | 预计工作量 |
|-------|------|------|--------|-----------|
| P0 | 性能 | 订单查询慢查询优化 | @张三 | 2天 |
| P1 | 安全 | 支付回调签名验证 | @李四 | 1天 |
| P2 | 代码 | 商品中心重复代码重构 | @王五 | 3天 |

16.9 上线与演进

16.9.1 上线策略

分阶段上线

阶段1:基础功能(2周)
• 商品中心、库存服务、订单服务
• 支持机票、酒店两个品类
• 单机房部署

阶段2:营销功能(2周)
• 营销服务、计价服务
• 支持优惠券、活动

阶段3:新品类(每周1个)
• 充值、电影票、优惠券、礼品卡

阶段4:多机房(4周)
• 双机房部署
• 流量灰度切换

16.9.2 灰度发布

灰度策略

// 灰度规则
type GrayReleaseRule struct {
    Version    string   // 新版本号
    Percentage int      // 流量比例(0-100)
    Whitelist  []int64  // 白名单用户ID
    Regions    []string // 灰度地区
}

func (r *GrayRouter) Route(userID int64, region string) string {
    // 白名单用户直接路由到新版本
    if contains(r.rule.Whitelist, userID) {
        return r.rule.Version
    }
    
    // 按地区灰度
    if !contains(r.rule.Regions, region) {
        return "stable"  // 老版本
    }
    
    // 按百分比灰度
    if hash(userID) % 100 < r.rule.Percentage {
        return r.rule.Version  // 新版本
    }
    
    return "stable"  // 老版本
}

灰度步骤

1. 5%流量(白名单用户 + 内部员工)
   观察1小时:错误率、延迟、业务指标

2. 20%流量(特定地区)
   观察2小时

3. 50%流量
   观察4小时

4. 100%流量(全量发布)
   观察24小时

5. 下线老版本

16.9.3 监控告警

三级监控体系

层级监控对象工具告警阈值
业务监控订单量、GMV、转化率Grafana + ClickHouse同比下降20%
应用监控QPS、延迟、错误率Prometheus + GrafanaP99延迟>500ms
基础设施监控CPU、内存、磁盘、网络Prometheus + Node ExporterCPU>80%

核心指标

// Prometheus Metrics
package metrics

import "github.com/prometheus/client_golang/prometheus"

var (
    // 业务指标
    orderCreatedTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "order_created_total",
            Help: "订单创建总数",
        },
        []string{"category", "status"},  // 标签:品类、状态
    )
    
    orderCreatedLatency = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "order_created_latency_seconds",
            Help:    "订单创建延迟",
            Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
        },
        []string{"category"},
    )
    
    orderGMV = prometheus.NewGaugeVec(
        prometheus.GaugeOpts{
            Name: "order_gmv_total",
            Help: "订单GMV(元)",
        },
        []string{"date"},
    )
    
    // 系统指标
    httpRequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP请求延迟",
            Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
        },
        []string{"method", "endpoint", "status"},
    )
    
    httpRequestTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_request_total",
            Help: "HTTP请求总数",
        },
        []string{"method", "endpoint", "status"},
    )
    
    // 依赖服务指标
    rpcCallDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "rpc_call_duration_seconds",
            Help:    "RPC调用延迟",
            Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
        },
        []string{"service", "method", "status"},
    )
    
    rpcCallTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "rpc_call_total",
            Help: "RPC调用总数",
        },
        []string{"service", "method", "status"},
    )
    
    // 数据库指标
    dbQueryDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "db_query_duration_seconds",
            Help:    "数据库查询延迟",
            Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1},
        },
        []string{"query_type", "table"},
    )
    
    // Redis指标
    redisCommandDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "redis_command_duration_seconds",
            Help:    "Redis命令延迟",
            Buckets: []float64{.0001, .0005, .001, .005, .01, .025, .05, .1},
        },
        []string{"command"},
    )
)

// 使用示例
func CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    startTime := time.Now()
    
    // 业务逻辑...
    order, err := createOrderInternal(ctx, req)
    
    // 记录指标
    duration := time.Since(startTime).Seconds()
    category := req.Category
    status := "success"
    if err != nil {
        status = "failed"
    }
    
    // 记录订单创建总数
    orderCreatedTotal.WithLabelValues(category, status).Inc()
    
    // 记录订单创建延迟
    orderCreatedLatency.WithLabelValues(category).Observe(duration)
    
    // 记录GMV
    if err == nil {
        orderGMV.WithLabelValues(time.Now().Format("2006-01-02")).Add(float64(order.TotalPrice))
    }
    
    return order, err
}

告警规则

# Prometheus AlertManager规则
groups:
  - name: order-service-alerts
    rules:
      # P99延迟告警
      - alert: OrderCreateLatencyHigh
        expr: histogram_quantile(0.99, order_created_latency_seconds) > 1
        for: 5m
        labels:
          severity: warning
          service: order-service
        annotations:
          summary: "订单创建延迟过高"
          description: "P99延迟 {{ $value }}s > 1s(持续5分钟)"
          dashboard: "https://grafana.example.com/d/order-service"
      
      # 错误率告警
      - alert: OrderCreateErrorRateHigh
        expr: |
          sum(rate(order_created_total{status="failed"}[5m])) 
          / sum(rate(order_created_total[5m])) > 0.01
        for: 5m
        labels:
          severity: critical
          service: order-service
        annotations:
          summary: "订单创建失败率过高"
          description: "失败率 {{ $value | humanizePercentage }} > 1%"
          runbook: "https://wiki.example.com/runbook/order-create-error"
      
      # QPS下降告警(业务异常)
      - alert: OrderCreateQPSDrop
        expr: |
          (sum(rate(order_created_total[5m])) 
          / sum(rate(order_created_total[5m] offset 1h))) < 0.5
        for: 10m
        labels:
          severity: warning
          service: order-service
        annotations:
          summary: "订单创建QPS骤降"
          description: "当前QPS {{ $value }},比1小时前下降50%以上"
      
      # GMV下降告警(业务异常)
      - alert: OrderGMVDrop
        expr: |
          (sum(rate(order_gmv_total[1h])) 
          / sum(rate(order_gmv_total[1h] offset 24h))) < 0.8
        for: 30m
        labels:
          severity: critical
          service: order-service
        annotations:
          summary: "订单GMV大幅下降"
          description: "当前GMV {{ $value }},比昨天同期下降20%以上"
      
      # RPC调用失败率告警
      - alert: RPCCallErrorRateHigh
        expr: |
          sum(rate(rpc_call_total{status!="success"}[5m])) by (service) 
          / sum(rate(rpc_call_total[5m])) by (service) > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "RPC调用失败率过高:{{ $labels.service }}"
          description: "失败率 {{ $value | humanizePercentage }} > 5%"
      
      # 数据库慢查询告警
      - alert: DBSlowQuery
        expr: histogram_quantile(0.99, db_query_duration_seconds) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "数据库慢查询"
          description: "P99延迟 {{ $value }}s > 100ms"
      
      # Redis延迟告警
      - alert: RedisLatencyHigh
        expr: histogram_quantile(0.99, redis_command_duration_seconds) > 0.01
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Redis延迟过高"
          description: "P99延迟 {{ $value }}s > 10ms"
      
      # 服务实例Down告警
      - alert: ServiceInstanceDown
        expr: up{job="order-service"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "服务实例宕机"
          description: "实例 {{ $labels.instance }} 已宕机超过1分钟"

告警分级与处理

级别触发条件通知方式响应时间处理人
P0(紧急)GMV下降>20%、服务全部宕机电话+短信+企业微信< 5分钟On-call工程师+经理
P1(严重)错误率>1%、P99延迟>1s企业微信+短信< 15分钟On-call工程师
P2(警告)QPS下降>50%、数据库慢查询企业微信< 30分钟值班工程师
P3(提示)磁盘使用>80%、内存使用>80%邮件< 2小时运维团队

监控大屏

┌─────────────────────────────────────────────────────────┐
│                   订单服务实时监控大屏                    │
├─────────────────────────────────────────────────────────┤
│  今日订单量: 1,234,567  ↑ 12.3%   今日GMV: ¥456,789,012  │
│  当前QPS: 2,345         P99延迟: 234ms    错误率: 0.12%  │
├─────────────────────────────────────────────────────────┤
│  订单创建趋势(24小时)             QPS & P99延迟         │
│  ███████████████████████████████   ███████████████████   │
│  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓   ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒     │
│  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░     │
├─────────────────────────────────────────────────────────┤
│  品类分布               服务依赖健康度                   │
│  机票: 45%  ████████   Product Service:   ✅ 正常        │
│  酒店: 30%  ██████     Inventory Service: ✅ 正常        │
│  充值: 15%  ███        Pricing Service:   ⚠️  延迟高     │
│  其他: 10%  ██         Marketing Service: ✅ 正常        │
├─────────────────────────────────────────────────────────┤
│  活跃告警(3条)                                         │
│  ⚠️  P1 Pricing Service P99延迟>500ms(持续10分钟)      │
│  📊 P2 订单QPS比昨天同期下降15%                          │
│  💾 P3 MySQL主库连接数>80%                              │
└─────────────────────────────────────────────────────────┘

On-call值班机制

【值班表】(7x24小时)
周一:张三(订单团队)
周二:李四(商品团队)
周三:王五(库存团队)
...

【值班职责】
1. 响应P0/P1告警(5分钟内)
2. 排查问题根因(15分钟内定位)
3. 协调资源修复(30分钟内恢复)
4. 事后复盘(24小时内)

【升级机制】
On-call工程师无法处理 → 升级到Team Lead
Team Lead无法处理 → 升级到架构师
架构师无法处理 → 升级到CTO

16.9.4 系统演进路径

已完成

  • ✅ 基础架构搭建(微服务、服务发现、监控)
  • ✅ 核心品类上线(机票、酒店、充值)
  • ✅ 营销系统(优惠券、活动)
  • ✅ 双机房部署

进行中

  • 🚧 性能优化(P99延迟 < 200ms)
  • 🚧 新品类接入(电影票、礼品卡)
  • 🚧 供应商扩展(50+ → 100+)

规划中

  • 📅 国际化(多语言、多币种)
  • 📅 推荐系统(AI推荐)
  • 📅 智能客服(NLP)
  • 📅 区块链溯源(高端商品)

16.10 经验总结

16.10.1 成功经验

1. 架构决策记录(ADR)制度

价值:

  • 重大决策留痕,新人可快速了解背景
  • 避免重复讨论已解决的问题
  • 架构演进有据可查

建议:

  • 每个ADR包含:问题、决策、理由、权衡、影响范围
  • 定期Review(每季度)
  • 与代码一起版本管理

2. 品类差异化设计

价值:

  • 避免"一刀切"架构(机票与充值差异大)
  • 策略模式让新品类接入成本降低80%
  • 适配器模式让供应商集成周期从4周缩短到1周

建议:

  • 先分析业务模型差异,再设计技术方案
  • 抽象共性,策略处理差异
  • 避免过度抽象(YAGNI原则)

3. 聚合层编排模式

价值:

  • API Gateway职责单一(鉴权、限流、路由)
  • 业务编排集中在聚合层,易于优化
  • 降级策略统一管理

建议:

  • 聚合层只做数据获取与编排,不做业务计算
  • 支持并发调用(提升性能)
  • 统一降级策略(Marketing故障降级为基础价)

4. 多级缓存策略

价值:

  • P99延迟从500ms降低到200ms
  • Redis QPS降低60%(本地缓存命中率30%)
  • 大促期间扛住5倍流量

建议:

  • L1(本地):热点数据,1分钟TTL
  • L2(Redis):通用数据,30分钟TTL
  • L3(MySQL):源数据
  • 缓存失效策略:主动失效 + TTL兜底

5. 契约测试

价值:

  • 上下游团队并行开发(不等联调)
  • API变更影响提前发现
  • 集成测试成本降低70%

建议:

  • 使用Pact等契约测试工具
  • API契约与代码一起版本管理
  • CI自动运行契约测试

16.10.2 踩过的坑

坑1:过早引入Event Sourcing

问题

  • 初期为了"追求架构完美"引入Event Sourcing
  • 团队对ES理解不足,查询复杂,运维困难
  • 投影重建耗时长(大促后修复bug需要重建投影,耗时4小时)

教训

  • Event Sourcing不是银弹,适用于审计要求极高的场景
  • 对于大部分电商场景,CQRS(不带ES)足够
  • 先用简单方案(CRUD),待确认瓶颈后再演进

坑2:供应商接口未做熔断

问题

  • 某供应商故障,接口超时(30秒)
  • 大量请求堆积,线程池耗尽
  • 整个订单服务不可用(影响其他供应商)

教训

  • 所有外部调用必须熔断(gobreaker)
  • 超时时间合理设置(不超过1秒)
  • 故障隔离(某个供应商故障不影响其他)

坑3:分库分表过早

问题

  • 订单量100万时就分库分表(8库64表)
  • 运维复杂度激增(扩容、迁移、对账)
  • 跨库查询需要路由表,增加延迟

教训

  • 单表500万以下不分表(MySQL性能足够)
  • 单库3000万以下不分库
  • 分库分表需要充分评估成本收益

坑4:忽视数据一致性对账

问题

  • 库存预占后未释放(代码bug)
  • 累积1个月后,库存数据严重不准确
  • 影响用户体验(明明有库存却提示"已售罄")

教训

  • 异步操作必须有对账机制(每小时/每天)
  • 对账发现差异要有自动补偿
  • 监控库存准确率(定期抽查)

坑5:缓存穿透导致雪崩

问题

  • 恶意请求查询不存在的商品(skuID=0)
  • 缓存未命中,直接打到数据库
  • 数据库连接池耗尽,服务雪崩

教训

  • 布隆过滤器(Bloom Filter)拦截不存在的Key
  • 缓存空值(TTL=1分钟)
  • 请求参数校验(前置拦截非法请求)

16.10.3 改进方向

短期改进(3个月内)

  1. 性能优化

    • 目标:P99延迟从200ms降低到150ms
    • 措施
      • 热点数据预加载:大促前提前加载10万+热门商品到Redis
      • 数据库慢查询优化:全部慢查询(<50ms),添加复合索引
      • 连接池优化:MySQL连接池从100提升到500
      • 批量查询优化:单次查询支持100+商品(原50个)
    • 预期收益:QPS提升30%,响应时间降低25%
  2. 稳定性提升

    • 混沌工程实践
      • 每周定期故障演练(随机Kill Pod、网络延迟、数据库主从切换)
      • 自动化故障注入工具(Chaos Mesh)
      • 故障恢复时间目标:< 3分钟
    • 降级开关完善
      • 所有非核心功能支持降级(营销、推荐、评论)
      • Feature Flag平台(实时开关,无需重启)
      • 降级决策自动化(根据错误率自动降级)
    • 容量规划
      • 提前3个月预估资源需求(基于历史数据+增长率)
      • 大促前1个月进行压测(验证容量)
      • 弹性扩容策略(CPU > 70%自动扩容)
  3. 开发效率

    • 统一脚手架
      • 一键创建新服务(包含标准目录结构、配置文件、CI/CD)
      • 内置最佳实践(监控、日志、链路追踪)
      • 代码生成工具(Proto → Go代码自动生成)
    • 自动化测试
      • 单元测试覆盖率 > 90%(核心业务逻辑100%覆盖)
      • 集成测试自动化(每次提交自动运行)
      • 性能测试定期执行(每周一次,P99延迟不能退化)
    • CI/CD优化
      • 构建时间 < 5分钟(并行构建、增量构建、缓存优化)
      • 自动化部署(合并到main分支自动部署到生产)
      • 灰度发布流程标准化(5% → 20% → 50% → 100%)

中期改进(6-12个月)

  1. 智能化

    • 推荐系统

      • 协同过滤(基于用户行为相似度)
      • 深度学习模型(基于用户画像+商品属性)
      • 实时推荐(用户浏览行为实时调整推荐结果)
      • A/B测试(对比推荐效果,持续优化)
      • 预期提升:点击率+15%,转化率+10%
    • 动态定价

      • 根据供需关系自动调价(库存少+需求高 → 涨价)
      • 竞品价格监控(爬虫+算法,自动调整价格)
      • 用户画像定价(VIP用户优惠力度更大)
      • 时段定价(早上价格高,晚上价格低)
      • 预期提升:毛利率+8%,订单量+12%
    • 智能客服

      • FAQ自动回复(NLP模型识别用户问题)
      • 订单查询自动化(用户输入订单号,自动查询状态)
      • 售后自动化(退款、换货流程自动化)
      • 人工客服辅助(AI推荐回复话术)
      • 预期收益:客服成本降低40%,响应速度提升50%
  2. 国际化

    • 多语言支持(i18n)

      • 支持英语、中文、日语、韩语、泰语
      • 翻译管理平台(统一管理翻译资源)
      • 动态语言切换(用户可随时切换语言)
      • 本地化适配(日期格式、货币符号、文化差异)
    • 多币种支持

      • 支持USD、EUR、JPY、CNY等10+币种
      • 汇率实时转换(接入外汇API,每分钟更新)
      • 价格展示优化(根据用户地区自动选择币种)
      • 结算币种选择(支持多币种支付)
    • 跨境支付

      • 接入PayPal、Stripe(国际信用卡)
      • 本地化支付(日本:Pay-easy,韩国:KakaoPay)
      • 外汇结算(自动结汇,降低汇率风险)
  3. 数据驱动

    • 实时数据大屏

      • GMV实时展示(今日/本周/本月)
      • 订单量、转化率、客单价实时监控
      • 品类TOP10、商品TOP100
      • 地域分布、用户画像
      • 技术栈:Flink + ClickHouse + Grafana
    • A/B测试平台

      • 灰度实验(新功能A/B测试)
      • 流量分配(按用户ID哈希,保证一致性)
      • 效果评估(点击率、转化率、收入对比)
      • 自动化决策(效果好的方案自动全量)
    • 用户画像

      • 行为标签(浏览、加购、下单、复购)
      • 偏好标签(品类偏好、价格敏感度、优惠敏感度)
      • 生命周期标签(新用户、活跃用户、流失用户)
      • 精准营销(根据画像推送个性化优惠)

长期愿景(1-3年)

  1. 平台化

    • 开放API

      • 商品API(第三方接入商品数据)
      • 订单API(第三方接入订单流程)
      • 支付API(第三方接入支付能力)
      • API网关(统一鉴权、限流、监控)
      • 预期收益:生态规模扩大3倍
    • SaaS化

      • 中小企业独立部署(提供SaaS服务)
      • 多租户隔离(数据隔离、资源隔离)
      • 按需付费(按订单量或GMV收费)
      • 自助配置(商家自助配置商品、营销)
    • 生态建设

      • 开发者社区(技术文档、SDK、Demo)
      • 第三方插件市场(营销插件、支付插件)
      • 合作伙伴计划(供应商、物流商、支付商)
  2. 技术创新

    • Serverless架构

      • 函数计算(FaaS)替代部分微服务
      • 按需计费(降低运维成本50%)
      • 自动扩容(无需手动扩容)
      • 适用场景:短信通知、数据清洗、报表生成
    • Edge Computing

      • CDN边缘计算(静态资源、动态渲染)
      • 边缘缓存(用户就近访问,降低延迟)
      • 边缘函数(简单业务逻辑在边缘执行)
      • 预期收益:首屏加载时间降低60%
    • 区块链溯源

      • 高端商品防伪(奢侈品、珠宝)
      • 全链路追溯(生产、流通、销售)
      • 不可篡改(区块链存证)
      • 增强用户信任

改进路线图

gantt
    title 系统改进路线图
    dateFormat YYYY-MM
    section 短期(3个月)
    性能优化           :2026-05, 3M
    稳定性提升         :2026-05, 3M
    开发效率           :2026-05, 3M
    
    section 中期(6-12个月)
    智能化             :2026-08, 12M
    国际化             :2026-08, 12M
    数据驱动           :2026-08, 12M
    
    section 长期(1-3年)
    平台化             :2027-08, 24M
    技术创新           :2027-08, 24M

关键里程碑

时间里程碑成功标准
2026-08性能优化完成P99延迟 < 150ms,QPS提升30%
2026-11稳定性提升完成故障恢复时间 < 3分钟,可用性 > 99.99%
2027-02智能化上线推荐点击率+15%,动态定价毛利率+8%
2027-05国际化完成支持5种语言,10种币种,海外订单占比20%
2027-08数据驱动成熟A/B测试平台日活10万+,用户画像覆盖率100%
2028-08平台化初步完成开放API日调用100万+,接入第三方100+
2029-08技术创新落地Serverless占比30%,边缘计算覆盖80%流量

16.11 本章小结

本章通过一个中大型B2B2C电商平台的完整案例,展示了从业务分析到技术落地的全过程,是全书知识点的综合实践验证。本章不仅覆盖了架构方法论(第1-4章),还深入展示了**供给运营系统(第10章)C端核心交易流(第11-15章)**的完整实现,真正做到了"理论→实践→落地"的闭环。


核心要点回顾

1. 品类差异化设计是关键

不同品类的业务模型存在本质差异,这是架构设计的基础:

品类库存模型价格模型履约模式超卖容忍度
机票实时库存(供应商)动态定价异步出票零容忍
酒店日历库存日历定价异步确认零容忍
充值无限库存固定面额同步充值可补偿
优惠券券码池固定折扣即时发放可补偿

设计启示

  • ✅ 使用策略模式处理品类差异(避免 if-else 地狱)
  • ✅ 使用适配器模式统一供应商接口(降低耦合)
  • ✅ 模板方法定义统一流程(具体步骤由策略实现)
  • ❌ 避免"一刀切"架构(机票与充值差异巨大,不能用同一套逻辑)

2. 聚合层解决跨服务编排问题

API Gateway(职责单一)
   ↓ 鉴权、限流、路由
Aggregation Service(编排层)
   ↓ 并发调用、数据聚合、降级处理
Business Services(业务层)
   ↓ 单一职责、独立部署
Infrastructure(基础设施层)

为什么需要聚合层?

  • ✅ API Gateway保持职责单一(鉴权、限流、路由)
  • ✅ 复杂编排逻辑集中管理(搜索场景:ES → Product → Inventory → Marketing → Pricing)
  • ✅ 统一降级策略(Marketing故障降级为基础价)
  • ✅ 性能优化空间大(并发调用、批量查询、缓存聚合结果)

3. 架构决策记录(ADR)是宝贵资产

本章记录了13个关键ADR决策:

ADR编号决策主题核心价值
ADR-001计价中心数据输入方式聚合层传入 vs 计价层自己调用
ADR-002库存预占时机试算 vs 创单
ADR-003聚合服务 vs BFF按业务场景 vs 按端
ADR-004虚拟商品库存模型二维模型(ManagementType + UnitType)
ADR-005同步 vs 异步数据流核心路径同步,非核心异步
ADR-009创单时是否使用快照强制实时查询(安全优先)
ADR-010创单与支付的时序先创单后支付(防止超卖)
ADR-011前后端价格校验策略差异容忍 + 提示机制
ADR-012试算与创单价格计算统一引擎 + 差异化数据来源
ADR-013价格流转全局策略分阶段计算 + 逐步扩展维度

ADR的价值

  • ✅ 记录决策背景(新人快速了解"为什么这样设计")
  • ✅ 避免重复讨论(已解决的问题有文档可查)
  • ✅ 架构演进有据可查(回顾历史决策,持续优化)
  • ✅ 与代码一起版本管理(决策与实现同步演进)

4. 系统边界清晰至关重要

案例1:计价系统的边界重构

  • 问题:价格计算逻辑分散在订单、营销、商品三个域
  • 重构:新建计价上下文,提供统一试算接口
  • 收益:价格一致性得到保证,营销规则变更只需在营销域发布事件

案例2:库存预占的归属

  • 争议:库存预占应该放在订单域还是库存域?
  • 决策:放在库存域
  • 理由:库存域拥有库存数据所有权,预占是库存的一种状态,订单域只需调用库存域的 Reserve 接口

案例3:防腐层保护领域模型

// 供应商响应模型(外部)
type SupplierFlightResponse struct {
    Code    string
    Message string
    Data    struct {...}
}

// 平台库存模型(内部)
type StockResponse struct {
    Available bool
    Quantity  int
    Message   string
}

// 防腐层:翻译外部模型 → 内部模型
func (a *FlightSupplierACL) TranslateStock(supplierResp) *StockResponse {
    // 领域层不被供应商模型污染
}

5. 高可用需要多层防护

层级措施工具/技术
应用层服务多副本、自动扩容Kubernetes HPA
接口层熔断、降级、限流gobreaker、Feature Flag
缓存层多级缓存(本地+Redis+DB)BigCache + Redis
数据层主从复制、读写分离MySQL Replication
机房层多机房部署、灰度发布Multi-Region + Canary

稳定性三板斧

  • 熔断:供应商调用失败率>50%,熔断10秒
  • 降级:Marketing Service故障,降级为基础价
  • 限流:令牌桶算法,QPS=500

6. 供给运营是平台的核心能力(新增16.5.6)

三种核心场景

场景业务语义处理逻辑审核策略
商品上架新商品首次进入平台Create完整审核流程
供应商同步供应商数据变更Upsert差异化审核
运营编辑已上线商品维护Update差异化审核

设计要点

  • 幂等性保证:task_code唯一索引(上架)、sync_id唯一索引(同步)
  • 差异化审核:高风险变更(价格变化>50%、类目变更)必须审核
  • 批量操作:异步任务 + 进度追踪(100+ SKU批量编辑)
  • 状态机:DRAFT → PENDING → APPROVED → PUBLISHED
  • 与商品中心集成:审核通过后写入商品中心、初始化库存/价格

7. C端交易流贯穿整个业务链路(新增16.5.7)

五个阶段完整设计

搜索(Query理解+ES召回+Hydrate)
   ↓ 转化率 > 15%
详情页(多服务聚合+快照生成)
   ↓ 转化率 > 8%
购物车(未登录加购+登录合并+双写)
   ↓ 转化率 > 30%
结算页(价格试算+库存检查+优惠校验)
   ↓ 转化率 > 60%
下单支付(Saga编排+实时查询+价格校验)
   ↓ 转化率 > 85%

关键技术

  • Hydrate编排:并发调用4-5个服务(Product、Inventory、Pricing、Marketing)
  • 快照机制:详情页生成快照(5分钟TTL),结算页可选使用(性能优先)
  • 购物车合并:未登录Redis存储,登录后合并到用户购物车
  • Saga编排:下单时依次执行库存预占、优惠券锁定、价格计算、订单创建
  • 强制实时查询:创单时不使用任何快照(ADR-009,安全优先)

8. DDD战术设计落地实践(新增16.5.8)

Order聚合根设计

// 聚合根
type Order struct {
    orderID OrderID          // 值对象(聚合根ID)
    items   []*OrderItem     // 实体集合
    pricing *OrderPricing    // 值对象
    status  OrderStatus      // 值对象
    domainEvents []DomainEvent // 领域事件
}

// 值对象:OrderID(不可变)
// 值对象:OrderPricing(无ID,通过属性比较相等性)
// 实体:OrderItem(有ID,可变)

Repository + Outbox模式

  • Repository接口在领域层定义(不依赖基础设施)
  • 领域事件与业务在同一事务(Outbox表)
  • Outbox轮询器:定时扫描未发布事件,发布到Kafka

领域事件

// OrderStatusChangedEvent(订单状态变更)
// OrderItemAddedEvent(商品项添加)
// OrderCreatedEvent(订单创建)

9. 团队协作与技术治理同等重要

康威定律实践

订单团队(15人)→ 订单服务
商品团队(12人)→ 商品中心
库存团队(10人)→ 库存服务
...

契约测试加速并行开发

  • ✅ 上下游团队定义API契约(OpenAPI/Proto)
  • ✅ 消费者编写契约测试(Pact)
  • ✅ 提供者验证契约(契约测试通过后联调)
  • ✅ 契约变更影响提前发现(CI自动运行)

技术治理机制

  • ✅ ADR记录重大决策
  • ✅ 代码评审清单(架构、设计、代码、测试)
  • ✅ 技术债管理(优先级、负责人、工作量)
  • ✅ 定期架构Review(每季度)

实战价值

本章不是空洞的理论,而是200+人团队、日订单200万级的真实实践总结:

成功经验(值得借鉴):

  1. ADR制度:让架构演进有据可查,新人快速上手
  2. 品类差异化:策略模式让新品类接入成本降低80%
  3. 聚合编排:API Gateway职责单一,性能优化空间大
  4. 多级缓存:P99延迟从500ms降低到200ms
  5. 契约测试:团队并行开发,集成测试成本降低70%

踩过的坑(避坑指南):

  1. 过早引入Event Sourcing:团队理解不足,查询复杂,运维困难
  2. 供应商接口未做熔断:某供应商故障,整个订单服务不可用
  3. 分库分表过早:订单量100万就分库分表,运维复杂度激增
  4. 忽视数据一致性对账:库存预占后未释放,累积1个月后严重不准确
  5. 缓存穿透导致雪崩:恶意请求查询不存在的商品,数据库连接池耗尽

改进方向(持续演进):

  • 短期(3个月):性能优化、稳定性提升、开发效率
  • 中期(6-12个月):智能化、国际化、数据驱动
  • 长期(1-3年):平台化、技术创新

与其他章节的关系

本章是全书知识点的综合应用与实践验证:

前置章节在本章的应用
第1章(架构方法论)Clean Architecture分层、DDD战略设计(16.5.8)、CQRS读写分离
第2章(领域驱动设计)12个限界上下文划分、上下文映射、防腐层(16.6.4)
第3章(代码整洁)策略模式(品类策略)、适配器模式(供应商集成)、SOLID原则
第4章(质量保障)ADR(13个决策)、代码评审清单、测试策略
第7章(商品中心)SPU/SKU模型、类目属性、商品快照
第8章(库存系统)二维库存模型、预占机制、超时释放(16.5.2)
第9章(营销系统)营销规则引擎、优惠券锁定、最优解求解
第10章(供给运营)商品上架、供应商同步、运营编辑(16.5.6 新增)
第11章(计价系统)四层价格模型、试算接口、快照生成
第12章(搜索导购)Query→Recall→Rank→Hydrate链路(16.5.7 新增)
第13章(购物车结算)未登录加购、登录合并、Saga编排(16.5.7 新增)
第14章(订单系统)状态机、Saga模式、幂等性(16.5.7、16.5.8 新增)
第15章(支付系统)支付创建、回调处理、对账流程

第17章预告:将继续讲解系统演进与重构,本章的演进路径是铺垫。


给读者的建议

  1. 不要盲目照搬架构

    • 根据团队规模调整(10人团队不需要12个微服务)
    • 根据业务特点优化(B2C和B2B2C差异大)
    • 根据发展阶段选择(初创期先单体,成熟期再拆分)
  2. 架构是演进出来的

    • 先简单方案(单体应用、MySQL单表)
    • 再根据瓶颈优化(QPS瓶颈→缓存,数据量瓶颈→分库分表)
    • 避免过度设计(YAGNI原则:You Aren't Gonna Need It)
  3. ADR是宝贵财富

    • 记录决策过程(不只是结果)
    • 记录备选方案(为什么不选A而选B)
    • 记录权衡取舍(有什么优点和缺点)
    • 定期Review(每季度回顾,持续优化)
  4. 从错误中学习

    • 本章的"踩过的坑"是避坑指南
    • 建立错误知识库(每个错误都是学习机会)
    • 持续改进(错误 → 规则 → 自动化检查)
  5. 关注业务价值

    • 技术服务于业务(不是为了炫技)
    • 优先解决业务痛点(性能瓶颈、稳定性问题)
    • 量化技术收益(P99延迟降低、QPS提升、成本节省)

关键数据回顾

指标数值说明
团队规模200+人前台60、中台80、基础设施30、数据20、测试10
日订单量200万(正常)/ 1000万(大促)大促5倍流量
服务数量12个核心服务 + 3个聚合服务按业务能力拆分,单一职责
ADR数量13个记录重大架构决策
响应时间P99 < 200ms(正常)/ 500ms(大促)多级缓存优化
可用性99.95%(核心链路)多层防护
代码覆盖率> 80%单元测试 + 集成测试

导航返回目录 | 上一章 | 书籍主页 | 下一章

附录A 技术栈选型指南

附录B 面试题精选

附录C 系统集成模式速查表

附录D 术语表

附录E 参考资料