购物车是两份状态第一次相遇的地方
用户在未登录时加了三件商品,登录后看到购物车里多了一件,或者少了一件,或者数量从 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 件。
第二类更严重。某商品在用户游客浏览期间下架,本地车仍然保留且默认勾选。登录合并时端上直接把本地行追加进去,勾选状态原样带过来。用户点结算,服务端返回「商品已下架」,整单失败。用户不理解:既然不能买,为什么还让我勾上?
复盘发现两个问题叠加:
- 合并没有按 SKU 聚合,同一商品出现多行。
- 合并没有重新判定可售状态,端上把过期的本地状态当成了当前事实。
修复对应两点:同 SKU 按数量相加并截断;合并时以服务端返回的状态为准,下架行保留但强制取消勾选。上线后,登录后购物车行数异常与「结算才发现下架」的工单明显下降。
这个复盘留下的规则是:合并不是拼接列表,是一次状态收敛。 任何来自本地的字段,只要服务端有对应事实,就必须在合并时重新判定。
多端并发为什么不能只靠「后到赢」
「后到赢」听起来公平,实际上取决于哪个请求先到服务端,而网络顺序与用户操作顺序并不一致。用户在手机上加了一件,平板上的旧请求晚到,就会把手机上的修改覆盖掉。
更稳的做法是两层:
- 整份购物车用版本号做乐观并发控制,旧版本写入被拒绝。
- 行级用
updatedAt标记最后一次修改,合并时只保留真正更晚的那一行。
这两层都不能省。只有版本号,会把整份购物车退回旧快照;只有行时间戳,无法发现「另一台设备删除了某行」这种整行消失的情况。
小结
购物车归属不是「数据放哪」的问题,而是「两份状态怎么收敛」的问题。游客车负责不丢用户刚加的商品,服务端车负责多端一致,合并函数负责在两者之间做一次有记录的收敛。
mergeCarts 定下四个产品决策:数量相加、仅本地保留、下架不可选、删除丢弃;syncFromServer 用版本号与行时间戳处理多端并发。两者都是纯函数,时间注入,结果可复现。
下一节进入购物车下方的价格明细:五段金额分别由谁计算,端上重算的边界在哪里。
阅读导航:上一节:2.3 库存展示:可售不等于有货 · 下一节:3.2 价格明细的端上重算边界 。
常见错误与边界
- 登录合并直接拼接两份列表,同一 SKU 出现多行。
- 同 SKU 数量相加后不截断,超过限购,结算时才被服务端拒绝。
- 把本地车的过期可售状态当成当前状态,下架商品仍可勾选。
- 合并后版本号不变,其他设备继续使用旧快照。
- 多端同步用「后到赢」,网络顺序覆盖用户真实操作顺序。
- 服务端删除与本地删除不区分,误删用户在另一台设备上保留的商品。
- 合并函数直接修改入参,调用方持有的旧购物车被静默改写。
- 把合并与同步写成一个函数,登录场景与多端场景互相污染策略。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。