《互联网产品的客户端架构·交易、增长与平台化》3.1 购物车归属:本地车、服务端车与合并

游客购物车与服务端购物车合并,是交易端最容易被低估的一次状态冲突。本节把同 SKU 的数量策略、下架与删除的处理、时间戳与多端并发写成可测试的纯函数,验证合并顺序、上限截断与本地未同步修改的保留,并复盘一次登录合并把失效商品带进结算的事故。

购物车是两份状态第一次相遇的地方

用户在未登录时加了三件商品,登录后看到购物车里多了一件,或者少了一件,或者数量从 2 变成 1——这类问题的根因很少是「接口慢」,而是两份购物车合并时没有明确规则。

购物车有两份来源:

  • 本地车:未登录时存在端上,可能是内存、本地存储或两者兼有。它没有账号,换设备就丢。
  • 服务端车:登录后与账号绑定,多端共享,是结算时真正被读取的那一份。

登录动作把两份状态撞在一起。此时端上必须回答四个问题:同一个 SKU 出现两次怎么办?本地有、服务端没有的商品要不要保留?下架或删除的商品怎么处理?两台设备同时改数量,谁覆盖谁?

这些问题不涉及网络编程,却直接决定用户看到的商品与金额。它们适合做成 L1 纯函数:输入两份购物车与一个注入的时间,输出合并结果与差异清单。

三种车,三种失败

先把购物车的几种形态拆开,避免把「合并」和「同步」混成一件事。

形态数据在哪谁写典型失败
游客车端上端上换设备丢失、清缓存丢失
账号车服务端服务端存、端上改多端并发覆盖、登录合并冲突
合并结果服务端服务端数量重复、失效商品被勾选

游客车可以随便写,因为它没有共享者;账号车必须经过服务端,因为另一台设备可能正在看同一份数据;合并结果只能有一个版本,否则两台设备会各自看到不同的购物车。

这三者对应两个不同的函数:

  • mergeCarts:登录时把游客车并进账号车,只发生一次。
  • syncFromServer:登录后每次拉取服务端车,都可能发生。

把它们写成两个函数,而不是一个「同步」函数,是因为失败策略不同。合并是「尽量保留用户加过的商品」,同步是「服务端事实优先,本地未提交的修改另算」。

合并前必须定下的四个决策

合并策略不是技术细节,是产品规则。下面四个决策必须在写代码前定下来,并且写进文档。

决策常见选项建议
同 SKU 数量相加 / 取大 / 以服务端为准相加,再按限购截断
仅本地存在并入 / 丢弃并入,用户加过就该保留
已下架删除 / 保留不可选保留不可选,让用户看到原因
已删除保留 / 丢弃丢弃,删除是明确意图

「数量相加」符合直觉:用户在游客车加 2 件,登录后发现账号车里已有 1 件,合并成 3 件。但相加会撞上限购,所以合并函数必须接收 maxQuantity,超过就截断,并返回一条可展示的说明。

「下架保留不可选」比「直接删除」好。直接删除会让用户以为商品凭空消失;保留并标灰,用户能理解「这件不能买了」,也方便后续恢复上架时重新勾选。

「删除丢弃」要区分来源:服务端删除是用户在另一台设备上的明确动作,应该尊重;本地删除只是游客车里的临时状态,合并时以服务端为准更安全。

合并顺序与版本号

合并结果的行顺序也有讲究。约定是:服务端车的顺序在前,游客车独有的行按本地顺序追加在后。这样用户登录后看到的列表不会整体重排,只在末尾多出自己刚加的商品。

版本号则是多端协同的基础。合并成功后版本号必须递增,其他设备拉取时才能发现「购物车变了」。如果合并前后版本号不变,另一台设备会继续用旧快照,直到用户手动刷新。

行级时间戳负责更细的冲突:同一个 SKU 在两台设备上被修改,谁的时间更晚,谁的数量保留。版本号回答「整份购物车有没有变」,行时间戳回答「这一行谁更新」。两者不能互相替代。

一个可运行的合并模块

下面的模块属于 L1:没有网络、没有定时器,时间通过参数注入。它同时提供登录合并与多端同步,并把每一次非平凡的处理记进 notes。

// file: src/cart/cart-merge.ts
// tier: L1
// verify: node --test test/cart/cart-merge.test.ts

export type LineStatus = "active" | "offline" | "deleted";
export type MergePolicy = "sum" | "max";
export type MergeAction = "from-local" | "from-remote" | "summed" | "kept-max"
  | "clamped" | "offline" | "dropped-deleted" | "dropped-remote-deleted"
  | "dropped-local-only" | "kept-local-dirty" | "ignored-stale";

export interface CartLine {
  skuId: string; quantity: number; selected: boolean;
  status: LineStatus; updatedAt: number;
}
export interface Cart { lines: CartLine[]; version: number; updatedAt: number; }
export interface MergeOptions { now: number; policy: MergePolicy; maxQuantity: number; }
export interface MergeNote { skuId: string; action: MergeAction; detail: string; }
export interface MergeResult { cart: Cart; notes: MergeNote[]; }

const cloneLine = (line: CartLine): CartLine => ({ ...line });

function assertInt(value: number, name: string): void {
  if (!Number.isSafeInteger(value) || value < 0) {
    throw new Error(`${name} 必须是非负安全整数`);
  }
}

function assertCart(cart: Cart): void {
  if (!Number.isInteger(cart.version) || cart.version < 0) {
    throw new Error("购物车版本号非法");
  }
  assertInt(cart.updatedAt, "购物车时间戳");
  const seen = new Set<string>();
  for (const line of cart.lines) {
    assertInt(line.updatedAt, `行时间戳 ${line.skuId}`);
    if (!line.skuId || seen.has(line.skuId)) {
      throw new Error(`SKU 非法或重复:${line.skuId}`);
    }
    seen.add(line.skuId);
    if (!Number.isInteger(line.quantity) || line.quantity < 0) {
      throw new Error(`数量非法:${line.skuId}`);
    }
    if (line.status !== "deleted" && line.quantity < 1) {
      throw new Error(`在售行数量必须大于 0:${line.skuId}`);
    }
  }
}

export function emptyCart(now: number): Cart {
  assertInt(now, "now");
  return { lines: [], version: 0, updatedAt: now };
}

// 登录合并:游客车与服务端车取并集,同 SKU 按策略合并数量。
export function mergeCarts(
  local: Cart | null,
  remote: Cart,
  options: MergeOptions,
): MergeResult {
  assertCart(remote);
  if (local) assertCart(local);
  assertInt(options.now, "now");
  assertInt(options.maxQuantity, "maxQuantity");
  if (options.policy !== "sum" && options.policy !== "max") {
    throw new Error("未知的合并策略");
  }

  const lines: CartLine[] = [];
  const notes: MergeNote[] = [];
  for (const remoteLine of remote.lines) {
    if (remoteLine.status === "deleted") {
      notes.push({ skuId: remoteLine.skuId, action: "dropped-deleted", detail: "服务端已删除" });
      continue;
    }
    const localLine = local?.lines.find((line) => line.skuId === remoteLine.skuId);
    if (!localLine || localLine.status === "deleted") {
      const line = cloneLine(remoteLine);
      line.selected = line.status === "active" && line.selected;
      lines.push(line);
      notes.push({ skuId: line.skuId, action: "from-remote", detail: "仅服务端车存在" });
      continue;
    }
    const status =
      localLine.status === "offline" || remoteLine.status === "offline"
        ? "offline"
        : "active";
    const raw =
      options.policy === "sum"
        ? localLine.quantity + remoteLine.quantity
        : Math.max(localLine.quantity, remoteLine.quantity);
    const quantity = Math.min(raw, options.maxQuantity);
    if (quantity !== raw) {
      notes.push({
        skuId: remoteLine.skuId,
        action: "clamped",
        detail: `数量 ${raw} 超过上限 ${options.maxQuantity}`,
      });
    }
    lines.push({
      skuId: remoteLine.skuId,
      quantity,
      selected: status === "active" && remoteLine.selected,
      status,
      updatedAt: Math.max(localLine.updatedAt, remoteLine.updatedAt),
    });
    notes.push({
      skuId: remoteLine.skuId,
      action:
        status === "offline"
          ? "offline"
          : options.policy === "sum"
            ? "summed"
            : "kept-max",
      detail: `本地 ${localLine.quantity} / 服务端 ${remoteLine.quantity} -> ${quantity}`,
    });
  }

  for (const localLine of local?.lines ?? []) {
    if (localLine.status === "deleted") continue;
    if (remote.lines.some((line) => line.skuId === localLine.skuId)) continue;
    const line = cloneLine(localLine);
    line.selected = line.status === "active" && line.selected;
    lines.push(line);
    notes.push({ skuId: line.skuId, action: "from-local", detail: "仅游客车存在" });
  }

  return {
    cart: {
      lines,
      version: remote.version + 1,
      updatedAt: Math.max(
        options.now,
        remote.updatedAt,
        ...lines.map((line) => line.updatedAt),
      ),
    },
    notes,
  };
}

// 多端同步:远端版本更新时覆盖本地,但本地更晚的修改保留为待同步。
export function syncFromServer(
  local: Cart,
  remote: Cart,
  maxQuantity: number,
  now: number,
): MergeResult {
  assertCart(local);
  assertCart(remote);
  assertInt(maxQuantity, "maxQuantity");
  assertInt(now, "now");
  if (remote.version <= local.version) {
    return {
      cart: { lines: local.lines.map(cloneLine), version: local.version, updatedAt: local.updatedAt },
      notes: [
        {
          skuId: "*",
          action: "ignored-stale",
          detail: `远端 ${remote.version} 不新于本地 ${local.version}`,
        },
      ],
    };
  }

  const lines: CartLine[] = [];
  const notes: MergeNote[] = [];
  const remoteIds = new Set(remote.lines.map((line) => line.skuId));
  for (const remoteLine of remote.lines) {
    if (remoteLine.status === "deleted") {
      notes.push({ skuId: remoteLine.skuId, action: "dropped-remote-deleted", detail: "其他端已删除" });
      continue;
    }
    const localLine = local.lines.find((line) => line.skuId === remoteLine.skuId);
    if (localLine && localLine.status !== "deleted" && localLine.updatedAt > remoteLine.updatedAt) {
      const quantity = Math.min(localLine.quantity, maxQuantity);
      if (quantity !== localLine.quantity) {
        notes.push({ skuId: localLine.skuId, action: "clamped", detail: `超过上限 ${maxQuantity}` });
      }
      lines.push({
        ...localLine,
        quantity,
        status: remoteLine.status,
        selected: remoteLine.status === "active" && localLine.selected,
      });
      notes.push({ skuId: localLine.skuId, action: "kept-local-dirty", detail: "本地更新更晚" });
      continue;
    }
    lines.push(cloneLine(remoteLine));
  }

  for (const localLine of local.lines) {
    if (localLine.status === "deleted" || remoteIds.has(localLine.skuId)) continue;
    if (localLine.updatedAt > remote.updatedAt) {
      lines.push({ ...localLine, quantity: Math.min(localLine.quantity, maxQuantity) });
      notes.push({ skuId: localLine.skuId, action: "kept-local-dirty", detail: "本地新增晚于快照" });
    } else {
      notes.push({ skuId: localLine.skuId, action: "dropped-local-only", detail: "其他端已删除" });
    }
  }

  return {
    cart: { lines, version: remote.version, updatedAt: Math.max(now, remote.updatedAt) },
    notes,
  };
}

四个设计点值得单独说明。

合并只处理并集,不猜用户意图。 服务端没有、本地有的行一律并入;服务端有、本地没有的行原样保留。唯一例外是服务端标记 deleted 的行,它是明确删除,不再合并。

下架状态是「或」关系。 任意一侧下架,合并结果就是下架,且强制取消勾选。金额计算只读取可售且勾选的行,所以这一步同时保护了结算。

勾选以服务端车为准。 勾选是端上状态,但登录后服务端车里已经有一份;合并时采用服务端值,避免游客车的临时勾选覆盖用户在账号里的选择。

同步不回退本地更晚的修改。 syncFromServer 在远端版本更新时覆盖本地,但如果某行的本地 updatedAt 晚于远端,就保留本地数量,只接受服务端的商品状态。这样用户在弱网下改的数量不会被一次旧快照抹掉。

测试与真实输出

本机运行结果如下:

$ node --test test/cart/cart-merge.test.ts
✔ 空购物车从版本 0 开始 (1.026833ms)
✔ 登录合并把游客车独有商品并入,服务端顺序在前 (0.326458ms)
✔ 同 SKU 按 sum 策略数量相加 (0.091625ms)
✔ 合并数量超过上限时截断并记录 (0.087083ms)
✔ max 策略取两侧较大数量 (0.085334ms)
✔ 下架商品保留但不可勾选 (0.08775ms)
✔ 远端已删除的行不进入合并结果 (0.096625ms)
✔ 合并后行时间取两侧较新值 (0.081708ms)
✔ 合并后的勾选以服务端车为准 (0.070542ms)
✔ 重复 SKU 的购物车被拒绝 (0.123416ms)
✔ 合并时数量非整数被拒绝 (0.07875ms)
✔ 多端同步忽略不新于本地的远端版本 (0.164542ms)
✔ 多端同步用远端覆盖非脏行 (0.066ms)
✔ 多端同步保留本地更晚的修改 (0.064125ms)
✔ 多端同步移除其他端删除的行 (0.080209ms)
✔ 多端同步保留快照之后本地新增的行 (0.089916ms)
✔ 多端同步丢弃早于快照的本地独有行 (0.069958ms)
✔ 合并与同步都不修改入参 (0.10725ms)
ℹ tests 18
ℹ suites 0
ℹ pass 18
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 142.038958

十八条用例覆盖:空车、并集、相加、截断、取大、下架、删除、时间戳、勾选来源、重复 SKU、非法数量、旧版本忽略、远端覆盖、本地脏行保留、远端删除、本地新增、早于快照的本地行、入参不可变。

一次「登录合并把失效商品带进结算」的复盘

「回声」早期的登录合并只做了一件事:把本地车的行追加到服务端车后面,同 SKU 不合并。上线后出现两类投诉。

第一类是多件商品。用户在游客车加了 2 件,账号车里本来有 1 件,登录后看到两条相同的商品,各 2 件和 1 件。结算时服务端只认一条,用户以为买 3 件,实际只下了 2 件。

第二类更严重。某商品在用户游客浏览期间下架,本地车仍然保留且默认勾选。登录合并时端上直接把本地行追加进去,勾选状态原样带过来。用户点结算,服务端返回「商品已下架」,整单失败。用户不理解:既然不能买,为什么还让我勾上?

复盘发现两个问题叠加:

  1. 合并没有按 SKU 聚合,同一商品出现多行。
  2. 合并没有重新判定可售状态,端上把过期的本地状态当成了当前事实。

修复对应两点:同 SKU 按数量相加并截断;合并时以服务端返回的状态为准,下架行保留但强制取消勾选。上线后,登录后购物车行数异常与「结算才发现下架」的工单明显下降。

这个复盘留下的规则是:合并不是拼接列表,是一次状态收敛。 任何来自本地的字段,只要服务端有对应事实,就必须在合并时重新判定。

多端并发为什么不能只靠「后到赢」

「后到赢」听起来公平,实际上取决于哪个请求先到服务端,而网络顺序与用户操作顺序并不一致。用户在手机上加了一件,平板上的旧请求晚到,就会把手机上的修改覆盖掉。

更稳的做法是两层:

  • 整份购物车用版本号做乐观并发控制,旧版本写入被拒绝。
  • 行级用 updatedAt 标记最后一次修改,合并时只保留真正更晚的那一行。

这两层都不能省。只有版本号,会把整份购物车退回旧快照;只有行时间戳,无法发现「另一台设备删除了某行」这种整行消失的情况。

小结

购物车归属不是「数据放哪」的问题,而是「两份状态怎么收敛」的问题。游客车负责不丢用户刚加的商品,服务端车负责多端一致,合并函数负责在两者之间做一次有记录的收敛。

mergeCarts 定下四个产品决策:数量相加、仅本地保留、下架不可选、删除丢弃;syncFromServer 用版本号与行时间戳处理多端并发。两者都是纯函数,时间注入,结果可复现。

下一节进入购物车下方的价格明细:五段金额分别由谁计算,端上重算的边界在哪里。

阅读导航:上一节:2.3 库存展示:可售不等于有货 · 下一节:3.2 价格明细的端上重算边界 。

常见错误与边界

  • 登录合并直接拼接两份列表,同一 SKU 出现多行。
  • 同 SKU 数量相加后不截断,超过限购,结算时才被服务端拒绝。
  • 把本地车的过期可售状态当成当前状态,下架商品仍可勾选。
  • 合并后版本号不变,其他设备继续使用旧快照。
  • 多端同步用「后到赢」,网络顺序覆盖用户真实操作顺序。
  • 服务端删除与本地删除不区分,误删用户在另一台设备上保留的商品。
  • 合并函数直接修改入参,调用方持有的旧购物车被静默改写。
  • 把合并与同步写成一个函数,登录场景与多端场景互相污染策略。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查