导航书籍主页 | 完整目录 | 上一章 | 下一章


第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,都会在客诉与对账里连本带息还回来,务必警惕为好。