商品改价后的多端价格一致性设计
电商商品改价后,详情页、搜索、购物车、促销展示和结算价格如何保持一致?
阅读说明与事实边界
这是一篇设计案例,不是对某个真实电商平台生产架构的复述。
文中出现的商品、金额、版本号和时间都用于解释方案。例如“某款耳机从 199 元改为 179 元”只是贯穿全文的演示案例,不代表真实商品,也不是生产数据。
文中的“秒级传播”“允许的展示滞后”“告警阈值”等只能作为设计标尺。真正落地时,必须结合价格变更频率、SKU 数量、搜索索引规模、缓存拓扑、促销复杂度和业务承诺做压测与验收,不能把设计标尺描述成已经取得的线上成绩。
本文讨论的是下面这个具体问题:
运营把一个 SKU 的基础销售价从 199 元改为 179 元,并设置在今晚 20:00 生效。生效之后,商品详情页、搜索列表、购物车、促销标签和结算页分别应该显示什么?如果消息重复、乱序、搜索索引延迟、用户在改价前后跨页操作,系统怎样避免错价下单?
文章先定义业务价格口径和权威来源,再讨论版本、读模型、缓存、Elasticsearch、购物车快照、结算重算、事件乱序、灰度、回滚与对账。
全文需要始终守住三个事实边界:
- 多端一致不等于所有页面在同一微秒完成刷新。
- 展示价可以在受控窗口内最终收敛,但成交价必须由结算链路重新确认。
- 已创建订单的价格是订单事实,不能被后续商品改价静默覆盖。
一、项目基础:先把“价格”说清楚
1.1 一件商品并不只有一个价格
用户口中的“商品价格”,在系统里通常至少包含以下几种含义:
| 价格口径 | 示例 | 归属方 | 是否可以直接用于成交 |
|---|---|---|---|
| 划线价 | 199 元 | 商品价格域 | 否,只用于合规展示和对比 |
| 基础销售价 | 179 元 | 商品价格域 | 是定价输入,但不是最终应付价 |
| 活动价 | 限时 169 元 | 促销域 | 需要满足活动时间、渠道和库存规则 |
| 会员价 | 会员 165 元 | 会员与促销域 | 依赖用户身份和会员等级 |
| 券后预估价 | 预计 155 元 | 营销试算 | 依赖用户是否有券以及券能否叠加 |
| 购物车加入价 | 加购时 179 元 | 购物车快照 | 只用于价格变化提示 |
| 结算报价 | 本次结算应付 165 元 | 结算定价域 | 创建订单前的权威报价 |
| 订单成交价 | 订单商品实付 165 元 | 订单域 | 订单创建后的不可变事实 |
如果没有先区分这些口径,就会出现两个常见错误:
- 把搜索页的“券后预估价”直接传给订单服务,客户端篡改参数就可能低价下单;
- 商品服务改了基础价,却误以为促销价、购物车价和订单价也应该被统一覆盖。
1.2 本文案例的价格权威
本文采用下面的职责划分:
- 商品价格服务拥有 SKU 基础价、划线价、币种、渠道和区域价格版本;
- 促销服务拥有活动规则、叠加规则、门槛和促销规则版本;
- 会员服务提供会员身份与权益版本,不直接写商品基础价;
- 结算定价服务组合商品价、促销、会员、优惠券、运费和税费,生成报价;
- 订单服务只接受服务端报价,并固化订单价格快照;
- 支付服务只能按订单应付金额发起支付,不能重新读取商品当前价。
这里最重要的结论是:
商品价格服务是基础价的权威,结算定价服务是下单前应付价的权威,订单是下单后成交价的权威。
三个权威并不冲突,因为它们负责的是不同阶段的事实。
1.3 “保持一致”到底要保证什么
本设计不追求所有读模型瞬间强一致,而是按业务风险分级:
| 场景 | 一致性要求 | 允许的处理 |
|---|---|---|
| 运营发布价格 | 强一致地生成新价格版本和变更事件 | 数据库事务加 Outbox |
| 详情页读取基础价 | 优先读取当前有效版本 | 缓存可用,但必须携带版本和生效时间 |
| 搜索列表展示 | 最终一致 | 允许短暂滞后,点击详情后以详情为准 |
| 购物车展示 | 显式快照加刷新 | 展示加购价和当前价变化,不把快照当成交价 |
| 促销展示 | 版本化试算 | 展示结果携带基础价版本与促销规则版本 |
| 创建订单 | 强校验 | 服务端重新定价,涨价或权益变差时要求用户确认 |
| 已创建订单 | 不可变价格快照 | 后续改价不追溯覆盖 |
| 多端传播 | 单调收敛 | 旧版本事件不能覆盖新版本投影 |
因此,“详情页 179 元、搜索页短时间仍是 199 元”是需要监控和尽快修复的展示滞后,但不等于已经发生资损。
真正危险的是“搜索页显示 179 元,结算服务仍按错误的 159 元创建订单”,或者“订单创建后又被商品改价任务改写”。
1.4 核心业务不变量
整个方案围绕以下不变量展开:
- 同一个价格作用域在任意业务时刻只能解析出一个当前有效基础价版本,或显式的
NO_ACTIVE_PRICE,不能同时出现两个成交价。 - 回滚也必须生成带更高
activationSeq的新状态版本,不能让激活序号倒退。 - 任何读模型只能接受更高
activationSeq的状态事件,旧事件不能覆盖新数据。 - 购物车快照只用于比较,不能成为创建订单的可信金额来源。
- 结算报价必须记录所有关键依赖版本,不能只记录一个最终数字。
- 订单创建时必须固化价格明细、优惠明细和版本依赖。
- 商品改价不得静默改写已经创建的订单价格。
- 客户端传入的单价、优惠额和应付金额都不可信。
- 对账发现差异时必须从权威源重放或重建,不能直接手改多个读模型。
- 价格上下文、SKU 和价格类型共同确定唯一聚合;生效时间是版本事实,不能混入聚合键,也不能把 SKU 重复拼接两次。
- 普通改价只推进
activationSeq,不能撤销平台已经接受的短时价格承诺;只有显式HARD_STOP推进saleabilityEpoch,才会阻断尚未创建订单的旧 Guard。
1.5 一分钟项目介绍
如果要在面试中先给出整体答案,可以这样概括:
我不会先讨论删缓存还是发 MQ,而是先定义价格权威。商品价格服务把待生效计划和已激活价格版本分开:审批时保存计划修订和预期前驱,到真正激活时才在同一个本地事务里校验计划链、分配递增激活序号、切换当前指针并写 Outbox。详情缓存、搜索索引和促销展示都是带
activationSeq的读模型,消费者以可恢复 Inbox 和持久化检查点推进,所以重复、乱序和更新后宕机都不会让价格倒退。购物车保存加购时快照,但打开购物车会拉取当前价并提示涨跌。结算不信任任何页面价格,而是基于当前商品价和促销规则重新计算;更有利或等价的新报价可按明确策略自动接受,金额上涨或权益变差则要求用户确认。最终确认时,结算取得 price fence 和优惠预占、签发一次性pricingCommitId,订单库再在同一事务中消费 Guard 并固化不可变价格快照。普通改价不撤销已接受的短时承诺,显式HARD_STOP则通过独立saleabilityEpoch阻断未消费 Guard。灰度和回滚通过明确作用域与更高激活序号完成,最后用序号水位、金额摘要和订单抽样做对账。
二、业务闭环:一次定时改价怎样走完整条链路
2.1 贯穿全文的典型场景
运营准备把 SKU SKU-HEADSET-BLACK 的基础销售价从 199 元调整为 179 元。
改价单约定:
- 作用范围是中国大陆、自营 App 和 Web 渠道;
- 币种是人民币;
- 今晚 20:00 生效;
- 当前正在运行的满减活动继续有效;
- 搜索和详情需要展示新基础价;
- 已经加入购物车但尚未下单的用户要看到“价格已下降”;
- 已创建订单不追溯降价,是否提供价保由单独业务处理。
金额和时间只是演示口径,重点是同一次业务变更怎样贯穿不同系统。
2.2 从改价单到订单成交
这张图表达的是业务责任链,而不是要求所有下游在同一时刻完成更新。
价格权威在 20:00 切换后,新的结算请求必须能解析到 179 元版本;详情缓存、搜索和促销读模型可以异步更新,但必须携带版本并有滞后监控。
2.3 改价边界上的用户请求
这里的 42 是实际激活事务分配的示例序号,不是审批时提前占用的号码。如果排期期间又有紧急价格生效,原计划激活时必须重新校验基线,并分配当时最新的下一个序号。
即使搜索消费者此刻还没有处理 seq=42,结算也不能读取搜索索引中的价格。
搜索是发现商品的读模型,价格服务才是基础价权威。把 Elasticsearch 当结算价来源,会把索引延迟直接升级成资损风险。
2.4 各角色和系统的责任
| 角色或模块 | 负责什么 | 不负责什么 |
|---|---|---|
| 运营后台 | 创建、审批、撤销改价单 | 不直接更新 Redis 或 ES |
| 商品价格服务 | 价格版本、作用域、生效和权威查询 | 不决定用户最终优惠 |
| 详情价格读服务 | 低延迟返回当前版本与展示字段 | 不创建订单价格 |
| 搜索服务 | 商品召回、排序和价格筛选投影 | 不提供成交依据 |
| 促销服务 | 活动规则、叠加与优惠试算 | 不修改基础价历史 |
| 购物车服务 | 保存加购快照、刷新并提示变化 | 不承诺快照价成交 |
| 结算定价服务 | 组合所有定价输入并给出服务端报价 | 不永久保存订单事实 |
| 订单服务 | 固化成交价格快照和优惠分摊 | 不随商品现价更新 |
| 支付服务 | 按订单应付金额发起和核对支付 | 不接受客户端自报金额 |
2.5 页面动作与后端链路
| 用户或运营动作 | 页面/API | 核心模块 | 数据变化 | 异常反馈 |
|---|---|---|---|---|
| 创建改价单 | 运营价格管理页 | 改价命令服务 | 新增草稿和审计记录 | 价格越界、作用域冲突 |
| 提交审批 | 审批操作 | 审批与规则校验 | 状态进入审核中 | 缺少权限、规则不完整 |
| 设置定时生效 | 发布操作 | 价格计划服务 | 保存待生效计划和基线序号 | 时间已过、区间重叠 |
| 浏览搜索 | 搜索 API | ES 价格投影 | 无权威写入 | 可展示投影版本或刷新提示 |
| 打开详情 | 详情 API | 价格读服务 | 缓存按需回源 | 降级为权威查询或暂不可售 |
| 打开购物车 | 购物车 API | 购物车加价格读服务 | 更新最近观察价 | 展示涨跌和失效原因 |
| 进入结算 | 报价 API | 结算定价服务 | 生成短期报价 | 价格变化、促销失效 |
| 确认下单 | 创建订单 API | 订单与结算 | 固化订单快照 | 要求重新确认或库存不足 |
三、价格计划与激活版本:不能用一次普通 UPDATE 表达全部业务
3.1 待生效计划和激活版本是两个对象
SCHEDULED 对象保存计划金额、计划时间、计划修订号以及激活前置条件,它还不是可以传播给下游的当前价格版本。第一条未来计划可以记录审批时看到的 expected_base_activation_seq;已经排在另一条计划之后的计划,还要记录 expected_predecessor_plan_id + expected_predecessor_plan_revision。后继计划审批时不需要猜测前驱未来会拿到哪个 activationSeq,激活时只需验证当前版本确实来自声明的前驱修订。
例如计划 A 预计 20:00 生效,计划 B 预计 22:00 生效。B 审批时当前权威仍可能是 seq=41,但它声明 A revision 3 为前驱。A 正常激活为动态分配的 seq=42 后,B 不应因为“当前序号不再是 41”而误入复审;只要当前版本的 source_plan_id/source_plan_revision 正是 A revision 3,B 就可以继续激活。若 A 被取消、改成 revision 4,或者中间插入紧急计划 C,B 才进入 NEEDS_REVIEW。计划编辑、取消或重排也要向后检查依赖链,不能留下仍指向旧修订的已审批后继。
真正激活后生成另一类不可变的“价格事实版本”。下面的状态图是生命周期视图,不代表会原地修改版本中的金额、币种、作用域、来源计划或激活序号:
CURRENT/SUPERSEDED/EXPIRED 可以从当前指针、后继版本和到期动作推导;若为了查询效率物化 lifecycle_status,它只是带审计和 CAS 的可变元数据,不能修改版本的金额与身份事实。回滚不把旧版本改成 CURRENT,也不需要给旧版本设置容易误解的 ROLLED_BACK 状态。正确做法是创建一个新的补偿计划,激活后生成更高序号的新版本;新版本通过 rollback_of_version_id 指向被补偿版本。
若一个当前价格带有结束时间,到期事务必须原子切换到已审批后继版本;没有后继版本时,则创建 state_type=NO_ACTIVE_PRICE 的高序号 tombstone 并切换指针。tombstone 同样拥有全局 price_version_id 和更高 activation_seq,通过 PRICE_DEACTIVATED 传播,让缓存与 ES 清除金额并标记不可售。不能在没有后继版本时悄悄回退到更旧价格,也不能让过期版本继续无限期成交。
3.2 价格作用域
一个价格版本不能只有 sku_id + amount。至少还要明确:
- 价格簿或商家主体;
- SKU;
- 销售渠道;
- 国家或区域;
- 币种;
- 用户分群或业务客户类型;
- 价格类型,例如划线价或基础销售价。
本文统一使用两级键,避免 scopeKey 到底含不含 SKU、时间或价格类型的歧义:
pricingContextKey = region + channel + currency + customerSegment + priceBookType
aggregateKey = pricingContextKey + skuId + priceType
在共享库中,这两个键还必须位于明确的租户或商家命名空间内;priceBookId 是配置实体 ID,可以解析出同一组上下文字段,但不能让不同服务各自用不同顺序临时拼字符串。实际实现应使用规范化编码或稳定哈希,并保留原字段用于审计。
生效开始时间、结束时间、金额和来源计划都属于版本事实,不属于聚合键。同一个商品在同一上下文、同一价格类型下,无论今天 20:00 还是明天 08:00 生效,都在同一条价格时间线上比较先后。
同一个 aggregateKey 内的 activationSeq 只在实际激活事务中分配并单调递增;计划自己的 planRevision 使用另一套序列。aggregateKey 已经包含一次 skuId,缓存键、事件路由键和检查点主键都直接使用它,不能再追加一次 SKU。不同价格上下文或价格类型拥有独立激活序列,避免无关区域、客户分群或划线价变更导致基础销售价消费者错误比较版本。
这也意味着一个不可变价格版本只承载一种 priceType 的金额。本文主线里的 pv-42/seq=42 属于 BASE_SALE_PRICE;划线价属于另一个 LIST_PRICE 聚合,有自己的版本和序号。详情 API 可以把两类事实组合成一个展示 DTO,但不能给“基础价 + 划线价”共用一个含义不清的版本号。如果一次改价单在同一数据库分片内同时修改两类价格,激活事务按稳定顺序锁定两个聚合并分别生成版本;下游也分别按各自序号收敛。跨分片批量改价若没有额外的批次封口和发布屏障,只能保证每个聚合正确,不能宣称整批同一时刻原子生效。
3.3 时间为什么也是价格事实的一部分
定时价格至少会遇到四种时间问题:
- 运营浏览器时区和业务区域时区不同;
- 应用节点时钟存在偏差;
- 定时任务在生效点发生重启;
- 消息到达下游时已经晚于生效时间。
设计上统一存储 UTC 时间,同时记录业务时区用于审计展示;生效判断使用数据库或统一时间源,不相信客户端时间。
本文选择唯一线性化点:当前价格指针切换事务提交的时刻。scheduled_at 只是目标时间,不会让一条计划自动变成权威版本。
即使任务在 20:00 没有被准时唤醒,权威查询也不能绕过状态和指针,直接把计划金额当成当前价。它可以触发同一个幂等激活动作并重试读取;激活暂时无法完成时,浏览链路可按明确策略标记旧展示,结算与创建订单则返回 PRICE_ACTIVATION_PENDING 或失败关闭。这样不会同时存在“时间到了所以已生效”和“指针切换才生效”两个相互冲突的判定。
四、技术架构:权威写模型与多种读模型分开
4.1 总体架构
架构中有一条不能打破的依赖方向:
展示系统可以消费价格权威的结果,价格权威和结算不能反向依赖搜索索引或购物车快照。
Redis、Elasticsearch 和促销展示投影都可以重建;价格主库中的版本事实和订单中的成交快照不能靠这些读模型反推。
4.2 同步与异步边界
同步链路负责高风险决定:
- 改价命令校验与受理;
- 当前有效价格权威查询;
- 结算重新定价;
- 订单成交控制对
HARD_STOP和saleabilityEpoch的事务校验; - 创建订单并固化快照;
- 支付金额校验。
异步链路负责可重建的传播:
- 详情缓存预热;
- 搜索索引更新;
- 商品最低价和价格区间投影;
- 促销标签重算;
- 购物车价格变化通知;
- 对账与投影修复。
异步不等于可以不管一致性。它只是把“立刻完成所有下游更新”改成“事件至少一次传播,各下游按 activationSeq 或 scheduleRevision 幂等收敛,并用水位和对账证明”。
4.3 价格读接口应该返回什么
详情和内部结算读取基础价时,不应只返回 179.00。
建议至少返回:
| 字段 | 作用 |
|---|---|
skuId | 明确价格所属 SKU |
pricingContextKey | 规范化表示区域、渠道、币种、客户分群和价格簿类型 |
priceType | 区分基础销售价、划线价等独立时间线 |
aggregateKey | pricingContextKey + skuId + priceType 的稳定聚合标识 |
amount | 当前 priceType 对应的金额;主线查询中即基础销售价 |
currency | 防止跨币种误算 |
priceVersionId | 全局唯一地引用这条已激活价格事实 |
activationSeq | 在同一 aggregateKey 内比较传播先后顺序 |
activatedAt | 解释权威指针何时完成切换 |
nextScheduledAt | 缓存判断是否到达下一次计划激活边界 |
stateType | ACTIVE_PRICE 或 NO_ACTIVE_PRICE,表示是否存在当前价格 |
saleabilityEpoch/status | 独立表示成交入口是否 OPEN 或 HARD_STOP |
source | 缓存或权威源,仅用于诊断 |
详情需要同时展示基础价和划线价时,应组合两条带独立版本的事实,例如 basePriceFact={amount, priceVersionId, activationSeq} 与 listPriceFact={amount, priceVersionId, activationSeq}。划线价缺失不能借用基础价版本补齐。
nextScheduledAt 很关键。缓存条目即使 TTL 还有十分钟,只要当前时间已经到达下一个计划激活点,就不能继续无条件把旧版本当成当前价。它只是回源和触发激活检查的提示,不代表计划金额已经生效;最终仍以当前指针切换事务是否提交为准。
五、数据模型:当前指针、不可变版本和业务快照各司其职
5.1 核心实体关系
这张 ER 图刻意区分了七类数据:
SKU_PRICE_PLAN是仍可撤销、可能因前驱变化而需要复审的计划;一条计划通常产生一个ACTIVE_PRICE版本,到期动作还可能基于它产生NO_ACTIVE_PRICEtombstone,因此关系不是机械的一对零或一;SKU_PRICE_VERSION是已经进入权威时间线的价格事实,拥有全局唯一price_version_id和aggregate_key内递增的activation_seq;state_type为ACTIVE_PRICE时金额必填,为NO_ACTIVE_PRICE时金额为空并充当防旧值复活的 tombstone;CURRENT_SKU_PRICE是权威解析指针,也保存下一排期提示和当前排期修订;SALEABILITY_CONTROL是独立于价格序号的成交开关,HARD_STOP通过推进saleability_epoch让旧的未消费 Guard 失效,普通改价不推进它;单 SKU Guard 保存一个 epoch,多 SKU Guard 通过GUARD_SALEABILITY_ITEM保存 epoch 向量并在主表记录摘要;PRICE_PROJECTION_CHECKPOINT属于“投影名称 + 价格聚合 + 版本流”;PRICE_STATE、SCHEDULE、SALEABILITY三条流分别以activation_seq、schedule_revision、saleability_epoch作为applied_version,各自保存摘要,不能用一行共享摘要;Inbox 在对应流的检查点真正推进后才从RECEIVED进入APPLIED;CART_ITEM和各种投影是可刷新的观察结果;PRICE_QUOTE在最终确认后可变成短时价格承诺,订单库中的PRICING_COMMIT_GUARD负责一次性消费栅栏,ORDER_PRICE_SNAPSHOT则是订单创建后的交易事实。
三者都可能出现“179 元”,但生命周期和可信程度完全不同。
5.2 为什么价格版本尽量不可变
如果运营每次改价都执行:
UPDATE sku SET price = 179 WHERE sku_id = ...
系统会失去以下信息:
- 旧价格是多少;
- 谁在什么原因下修改;
- 哪个版本应该在什么时候生效;
- 搜索当前落后了几个版本;
- 某个订单当时依据哪个价格版本计算;
- 回滚到底是在恢复哪次变更。
这里的“不可变”特指会影响金额解释和版本身份的事实:aggregateKey、价格类型、金额、币种、生效事实时间、activationSeq、来源计划修订和摘要都不能原地改写。CURRENT/SUPERSEDED/EXPIRED 属于生命周期判断,可以由当前指针和后继动作推导;若物化成 lifecycle_status,只允许审计化 CAS 更新。修正错误仍然创建新版本,并通过关联字段说明补偿关系。
5.3 当前价格指针
为了避免每次读取都扫描全部历史版本,可以维护一张当前价格指针表或物化视图:
current_sku_price(
aggregate_key,
pricing_context_key,
sku_id,
price_type,
current_price_version_id,
current_activation_seq,
next_scheduled_at,
schedule_revision,
status,
row_version
)
其中 aggregate_key 已经唯一标识“价格上下文 + SKU + 价格类型”,current_price_version_id 保存全局唯一版本标识,current_activation_seq 保存该聚合内的传播顺序。激活条件分两种:
- 没有声明前驱计划时,当前指针必须仍等于审批时的
expected_base_activation_seq; - 声明了前驱计划时,当前版本的
source_plan_id + source_plan_revision必须等于expected_predecessor_plan_id + expected_predecessor_plan_revision,实际序号由前驱激活时动态分配。
任一条件不满足都进入 NEEDS_REVIEW。这既允许 A、B 两条未来计划按审批链连续生效,也能阻止计划外紧急改价被后继计划静默覆盖。
next_scheduled_at 是当前作用域最早一条可执行计划的目标时间,schedule_revision 是该作用域排期集合每次创建、改期、取消后递增的独立修订号。它们不参与成交版本比较,也不能提前占用 activation_seq。
并发发布的两张改价单不会互相静默覆盖;失败的一方重新读取当前状态,再决定撤销、重排还是在后续激活时生成更高序号的状态版本。
5.4 关键约束和索引
建议的约束包括:
price_version_id全局唯一,任何购物车、报价和订单引用都能独立解析;- 同一租户或商家命名空间内,区域、渠道、币种、客户分群和价格簿类型的规范化组合唯一映射到一个
pricing_context_key;大小写、空值和枚举别名必须在生成键前归一化; aggregate_key + activation_seq唯一,并且序号只在该聚合的激活事务中分配;plan_id + plan_revision组成激活动作的幂等令牌;计划编辑通过行版本 CAS 递增plan_revision,旧修订保留在审计历史中,计划修订号不能冒充下游价格版本;- 激活前置条件只能采用一种模式:直接基线计划要求
expected_base_activation_seq,链式计划要求完整的前驱计划 ID 与修订号,不能只填一半或同时让两套条件互相覆盖; - 后继计划引用的前驱修订必须存在、时间早于自己且不能形成环;前驱被编辑、取消或重排时,依赖旧修订的后继计划批量进入
NEEDS_REVIEW; - 同一
aggregate_key的有效时间区间不允许产生无法解释的重叠; event_id唯一,防止同一 Outbox 事件被重复创建;- 投影检查点以
projection_name + aggregate_key + stream_kind唯一;stream_kind至少包含PRICE_STATE、SCHEDULE、SALEABILITY,并分别解释activation_seq、schedule_revision、saleability_epoch,禁止跨流比较版本; - 投影 Inbox 以
projection_name + event_id唯一,并保存事件所属的stream_kind + incoming_version + incoming_digest;RECEIVED只表示已经受理,必须等对应版本流的目标投影和可恢复检查点完成后才能进入APPLIED; - 报价 ID 由服务端生成,并与用户、作用域、币种和有效期绑定;
replacement_of_quote_id唯一,旧报价通过行版本只允许写入一个successor_quote_id,使自动替代和要求确认两种重报价都能幂等恢复;pricing_commit_id全局唯一,ORDER_PRICE_SNAPSHOT.pricing_commit_id建唯一约束,防止换一个请求幂等键重复消费同一报价;PRICING_COMMIT_GUARD.quote_id和consumed_order_id均唯一;Guard 保存签发时的saleability_epoch向量及摘要,订单事务必须同时校验所有当前成交开关仍为OPEN且代次逐项相等;- 订单创建与 Guard 从
AVAILABLE -> CONSUMED在订单库同一事务完成;过期任务只能 CAS 为EXPIRED,止损恢复任务只能在更高 epoch 已提交后 CAS 为INVALIDATED; - 报价状态通过行版本从
OPEN -> COMMITTING -> COMMITTED -> CONSUMED条件推进,失败分支进入SUPERSEDED、EXPIRED或RELEASED;报价服务收到订单 Outbox 后从COMMITTED直接收敛到CONSUMED,不虚构自己无法观测的“消费中”状态; - 订单 ID 对应唯一价格快照,
quote_id也只能对应一个已创建订单快照; - 金额使用定点小数或最小货币单位整数,禁止二进制浮点运算。
查询索引要服务于真实访问:
- 按
aggregate_key读取当前权威指针、成交开关和投影检查点; - 按
status + scheduled_at扫描待激活计划; - 按
publish_status + created_at拉取未发布 Outbox; - 按
aggregate_key + activation_seq查传播和审计链; - 按
quote_id + user_id校验报价归属。
六、核心技术实现一:改价发布必须先形成权威事实
6.1 发布前的业务校验
运营输入 179 元之后,系统不能直接广播事件。至少要校验:
- SKU 和价格簿是否存在且允许销售;
- 币种和金额精度是否合法;
- 新基础价是否低于配置的安全底价;
- 划线价是否满足当地展示合规要求;
- 生效区间是否与已有版本冲突;
- 改价是否会让正在运行的促销出现负毛利;
- 变更幅度是否需要更高等级审批;
- 是否与另一张待生效改价单覆盖同一
aggregateKey; - 渠道、区域和分群是否完整,避免误伤全站;
- 操作人是否拥有对应商家和价格簿权限。
这些校验中,有些是强阻断,有些只生成风险提示。规则必须由业务明确,不能让代码凭经验猜测。
6.2 生效事务
一次立即生效或定时激活,建议在同一个本地事务中完成:
- 按
plan_id + plan_revision幂等锁定待激活计划; - 锁定
aggregateKey对应的当前价格指针和当前版本; - 无声明前驱时校验
expected_base_activation_seq;有声明前驱时校验当前版本的来源计划 ID 与修订号;前置条件不满足就把计划标记为NEEDS_REVIEW,不自动覆盖当前价; - 若基线匹配,分配
activation_seq = current_seq + 1和全局唯一price_version_id; - 创建
ACTIVE_PRICE不可变事实版本并切走旧指针;若物化生命周期状态,再以 CAS 把旧版本标记为SUPERSEDED,但不改写旧金额或版本身份; - 更新当前指针、下一排期时间和作用域状态;
- 把计划标记为
ACTIVATED,写价格审计流水; - 写
PRICE_ACTIVATEDOutbox 事件; - 提交事务。
数据库提交成功才表示价格权威已经改变。
MQ 发送成功只能说明事件进入传播链路,不能反过来代替数据库事实。
价格到期也执行同一类权威事务:锁定当前指针,分配更高 activation_seq,切换到后继 ACTIVE_PRICE 版本,或创建 NO_ACTIVE_PRICE tombstone,然后写 PRICE_ACTIVATED 或 PRICE_DEACTIVATED Outbox。只把旧记录的 expire_at 改成过去时间而不推进指针与序号,会让缓存淘汰后被迟到旧事件重新复活。
6.3 为什么需要 Outbox
不用 Outbox 时,最危险的是两个失败窗口:
- 先改数据库再发 MQ,进程在两步之间宕机,权威价已经改变但下游永远不知道;
- 先发 MQ 再改数据库,下游先展示新价,但数据库事务最终失败。
Outbox 不能保证消息永远只出现一次。它提供的是“数据库事实与待发送记录同事务”,再通过重复投递和消费者幂等实现状态收敛。
6.4 定时激活不能只靠一条延迟消息
如果只在审批时发一条“20:00 执行”的延迟消息,消息丢失、延迟级别限制或队列迁移都可能让价格永不生效。
更稳妥的组合是:
- 定时任务或延迟消息负责准点触发;
- 数据库保存
SCHEDULED计划事实; - 扫描任务按时间水位补捞遗漏计划;
- 激活动作按
plan_id + plan_revision幂等,并在事务里重新校验直接基线或声明的前驱计划修订; - 创建、改期或取消计划时,在同一数据库事务中保存计划修订、重算
CURRENT_SKU_PRICE.next_scheduled_at、递增schedule_revision并写PRICE_SCHEDULE_CHANGEDOutbox;该事件让读模型更新nextScheduledAt和scheduleRevision,但不携带虚构的价格版本 ID,也不提前改变当前价格序号; - 权威读取发现
nextScheduledAt已到但指针未更新时,只能触发同一个激活动作并重试,或拒绝危险成交,不能直接读取计划金额; - 告警关注“到点未生效”而不是只关注任务是否运行。
七、核心技术实现二:缓存与详情页怎样避免旧值回写
7.1 缓存值必须带版本
Redis 中建议保存结构化价格快照,而不是单独一个金额:
price:{aggregateKey} = {
priceType,
amount,
currency,
priceVersionId,
activationSeq,
activatedAt,
nextScheduledAt,
scheduleRevision,
status
}
saleability:{aggregateKey} = {
saleabilityEpoch,
saleabilityStatus,
stopExpiresAt
}
消费者收到激活或停用事件时,只有 incomingActivationSeq > cachedActivationSeq 才更新价格状态;PRICE_DEACTIVATED 不能简单删除 Key,而要写入带高序号的 NO_ACTIVE_PRICE tombstone。收到排期事件时,则独立比较 scheduleRevision 更新下一排期提示。HARD_STOP 事件只按 saleabilityEpoch 推进成交开关。计划修订号、排期修订号、价格激活序号和成交开关代次是四种不同语义,不能混用。
只和当前 Redis 值比较仍然不够。若新值、NO_ACTIVE_PRICE tombstone 或更高 epoch 的成交开关被淘汰,或主从切换丢失,迟到的旧事件会把一个空 Key 重新写成旧状态。因此每个投影还要在持久化的 PRICE_PROJECTION_CHECKPOINT 中按版本流分行保存下限,或使用等价的可靠状态库:
streamKind | appliedVersion 的语义 | 保护的状态 |
|---|---|---|
PRICE_STATE | activationSeq | 当前价格或 NO_ACTIVE_PRICE tombstone |
SCHEDULE | scheduleRevision | nextScheduledAt 等排期提示 |
SALEABILITY | saleabilityEpoch | OPEN/HARD_STOP 成交开关 |
每一行独立保存 contentDigest。例如价格 seq=42 和排期 revision=8 不能覆盖同一摘要,否则价格重复事件会被误判成“同版本异摘要”;持久化的 SALEABILITY/epoch=8 下限则能在缓存淘汰后拒绝迟到的 OPEN/epoch=7。
但“先更新 Redis/ES,再更新数据库检查点”还有一个崩溃窗口:目标投影已经是 seq=42,进程却在检查点仍为 41 时宕机。重试如果因为目标中已经存在相同序号就直接返回,持久化下限会永久停在 41;缓存再次丢失后,旧事件仍可能复活。
因此每个目标投影使用独立的可恢复 Inbox:先以 projectionName + eventId 幂等写入 RECEIVED,同时保存 streamKind + incomingVersion + incomingDigest,再读取同一 projectionName + aggregateKey + streamKind 的持久化检查点,对 Redis Lua 或 ES 脚本做条件更新。目标更新后必须回读或取得条件写结果,确认目标是“同版本同摘要”或已经处于可证明的更高版本,然后 CAS 推进该流检查点,最后把 Inbox 标记为 APPLIED 才 ACK。若在目标更新后宕机,重试走“同版本同摘要恢复”分支,仍然补推检查点;RECEIVED 绝不能当成“已处理”直接返回。缓存回源也只能使用目标字段所属版本流的持久化下限。
Redis、ES 和促销投影分别维护自己的 Inbox 与检查点,不能宣称一次数据库状态就原子覆盖三个外部系统。这里保证的是每个投影至少一次受理、幂等应用并可恢复地推进版本下限,不保证跨投影同一时刻更新。
7.2 详情读取决策
图中的降级策略必须按业务风险决定。
普通浏览可以展示最近一次价格并提示以结算为准;创建订单时不能因为价格权威不可用,就拿缓存旧价冒险成交。高风险链路应失败关闭或进入明确的保护策略。
7.3 为什么“删缓存”不够
简单的“改数据库后删除缓存”仍有几个问题:
- 删除消息可能失败;
- 缓存删除后,大量详情请求同时回源形成击穿;
- 一个在改价前读到旧数据库值的慢请求,可能在删除后把旧值重新写回;
- 定时价格在 TTL 未到时仍可能继续显示旧值;
- 搜索和促销投影并不会因为 Redis 删除而自动更新。
所以核心不是选择“更新缓存”还是“删除缓存”,而是让所有缓存值带版本,让回源和事件消费都遵守单调更新,并为生效边界准备主动预热与过期判断。
7.4 多 SKU 商品的价格区间
一个 SPU 可能有多个 SKU,详情和搜索常展示“最低 179 元起”。
当最低价 SKU 改价后,不能只修改 SPU 文档里的一个数字而不考虑其他 SKU。
可选方案有:
- 维护
SPU -> 各 SKU 当前价的有序集合,再计算最小值和最大值; - 价格变更后按 SPU 重建小范围价格区间;
- SKU 数量很少时,详情请求批量查询全部可售 SKU 当前价;
- 搜索投影维护价格区间版本和参与计算的 SKU 水位。
如果最低价 SKU 缺货或不适用于当前渠道,“179 元起”也可能是错误展示。因此价格区间投影需要结合可售状态,但结算仍按用户选择的具体 SKU 重新计算。
八、核心技术实现三:Elasticsearch 与促销展示如何收敛
8.1 搜索索引只保存投影
搜索文档可以保存:
- 当前基础价、全局价格版本 ID 和
aggregateKey内激活序号; - 展示用价格区间;
- 是否存在活动;
- 可用于筛选和排序的标准化价格;
- 投影更新时间和价格作用域;
- 促销预估版本与适用条件摘要。
但不能把 ES 中的金额作为订单成交依据。
搜索页为了排序和筛选,天然需要冗余价格字段;冗余不是问题,没有版本、没有归属、无法重建才是问题。
8.2 ES 条件更新
价格消费者对文档做局部更新:
- 如果事件
activationSeq高于文档baseActivationSeq,更新基础价、basePriceVersionId和序号; - 如果激活序号相等且版本 ID、内容摘要都相同,视为重复;
- 如果激活序号更低,记录乱序丢弃指标,不覆盖文档;
- 如果激活序号相等但版本 ID 或摘要不同,进入冲突告警,不能任意选一个;
- 价格事件只修改价格拥有的字段,不覆盖商品标题、库存和促销字段。
只有当一个 ES 文档完全由价格投影独占,且整份文档只使用 activationSeq 一个版本域时,才适合直接使用 ES external version。更常见的共享商品文档同时包含标题、库存和价格,各字段由不同版本域推进;此时必须使用更新脚本只比较 baseActivationSeq,并只修改价格拥有的字段。否则一次标题更新可能被价格 external version 拒绝,或者价格事件反过来覆盖更晚的库存文档。
8.3 促销展示为什么更难
基础价从 199 元改为 179 元后,“满 199 减 30”的促销可能从单件可用变成单件不可用。
这意味着促销标签不能只把金额替换成 179:
- “到手 169 元”可能已经不成立;
- “已满足满减”可能变成“还差 20 元”;
- 多件组合、跨店券和会员折扣需要用户上下文;
- 未登录搜索页只能给无用户或公共规则下的预估;
- 活动规则也有自己的版本和生效时间。
促销展示结果应保存依赖向量,例如:
basePriceVersionId=pv-42
baseActivationSeq=42
promotionRuleVersion=p17
membershipPolicyVersion=m8
calculationMode=ANONYMOUS_ESTIMATE
任何一个依赖改变,都可能需要重新计算。
8.4 乱序事件的处理流程
低版本事件可以在记录 OLD 后完成,因为同一 streamKind 的持久化检查点已经证明它不会推进状态;同版本同摘要事件却不能只看目标投影就返回,必须确保该流检查点也已补齐。若目标出现同版本不同摘要,说明同一业务版本出现了分叉,必须隔离,不能任选一个覆盖。不同 streamKind 的数字即使碰巧相等也没有比较意义。
若重试时发现目标投影已经高于当前事件,而数据库检查点仍落后,消费者只能在目标记录同时保存了可信 aggregateKey + streamKind + version + contentDigest 时,把对应流检查点推进到目标所证明的更高版本;不能拿当前旧事件的摘要冒充高版本摘要。目标元数据不完整时应从该流的权威源重建,再推进检查点。
并不是所有投影都要求逐个处理每个中间版本。
详情当前价只关心最新有效快照,可以从 activationSeq=41 直接跳到 activationSeq=43;审计流水则不能丢中间版本。消费者要根据自身职责决定“跳版本时局部更新”还是“回源重建”。
8.5 消息顺序能减少问题,但不能替代版本判断
价格事件直接按规范化 aggregateKey 作为路由键,可以让同一聚合尽量进入同一分区。由于 aggregateKey 已含 skuId 和 priceType,生产者不能再次拼接 SKU,也不能把生效时间加入路由键。
但以下情况仍会带来重复或乱序:
- 生产者重试;
- 消费者超时后重新投递;
- 分区重平衡;
- 回放历史事件;
- 手工修复重新发布;
- 多个事件源错误地使用不同路由规则。
所以分区有序是优化条件,activationSeq 条件更新才是最终防线。
九、核心技术实现四:购物车快照不是成交承诺
9.1 购物车为什么要保存加购价
用户把商品加入购物车时保存:
- SKU;
- 数量和规格;
- 加购时基础价;
- 加购时价格版本;
- 当时可见促销摘要;
- 最近一次刷新价和版本。
加购价的价值是解释变化:
- 当前价低于加购价,显示“已降 20 元”;
- 当前价高于加购价,显示“价格已上涨”;
- 促销失效,显示“优惠条件已变化”;
- 商品换币种、下架或渠道变化,标记不能结算。
如果购物车完全不保存快照,用户每次看到的都只有当前价,就无法解释为什么金额变了,也无法支持价格变化通知。
9.2 打开购物车时如何刷新
购物车服务不应该对每个商品串行调用一次价格接口。
更合理的方式是:
- 按价格作用域归组 SKU;
- 批量读取当前基础价格;
- 批量请求促销试算;
- 对比
addedPriceVersionId/addedActivationSeq、lastSeenPriceVersionId/lastSeenActivationSeq和当前状态版本; - 返回变化原因,而不只是新旧金额;
- 异步更新
lastSeenPrice,保留原始加购快照。
购物车数量很大时,可先刷新可视区域和已勾选商品,但进入结算时必须覆盖所有待结算项。
9.3 价格上涨与下降的产品策略
本文选择一个常见但不是唯一的业务规则:
- 价格下降或优惠增加:结算自动采用更有利的新报价,并明确展示节省金额;
- 价格上涨或优惠减少:不静默创建订单,返回新报价要求用户再次确认;
- 价格不变但版本变化:如果只是审计字段修正且金额与权益等价,可以继续;
- 商品不可售或币种变化:阻断结算。
“是否需要再次确认”不能只比较最终总额。
总额不变也可能是某件商品涨价、另一张券补回,影响退款分摊和用户理解。因此还应比较商品级金额、优惠来源和关键权益。
9.4 是否要给购物车用户保价
把商品加入购物车通常不代表锁价。
如果业务承诺“加入购物车后 30 分钟内保价”,系统就必须把它设计成真正的价格承诺:
- 生成服务端价格承诺记录;
- 明确适用用户、SKU、数量、币种和截止时间;
- 评估价格下跌和上涨时的处理;
- 防止用户批量占用低价承诺;
- 在结算时验证承诺并计入财务成本;
- 到期和取消后释放相关预算或名额。
仅仅把加购价存在购物车里,不足以承诺按该价格成交。
十、核心技术实现五:结算必须重新定价并固化订单
10.1 报价依赖向量
一次结算报价不只是 total=165,还要记录其依赖:
- 每个 SKU 的
price_book_id、aggregate_key、全局price_version_id和聚合内activation_seq; - 促销规则版本;
- 会员权益版本或本次身份快照;
- 优惠券实例和占用状态;
- 运费模板与配送地址摘要;
- 税费规则和币种;
- 商品数量与销售主体;
- 每个
aggregateKey的成交开关saleabilityEpoch,多 SKU 报价保存 epoch 向量及摘要; - 报价生成时间和失效时间;
- 各优惠在订单项上的分摊结果。
可以对这些依赖生成 dependencyDigest,便于创建订单时快速判断输入是否发生变化,但摘要不能替代明细审计。
10.2 从购物车到订单的完整时序
客户端只提交 quoteId、确认版本和用户确认动作,不提交可信单价。
服务端仍要验证报价属于当前用户、订单项未被替换、报价未过期,并按业务规则判断是否需要重新定价。commitRequestId 由服务端按 quoteId + confirmVersion 稳定生成,客户端即使换一个创建订单幂等键,也不会得到第二个提交凭证。优惠券、限量促销等稀缺权益先绑定 pricingCommitId 做幂等预占;订单成功后确认,提交凭证过期或订单明确失败后补偿释放。
“自动采用更优价格”不是把任意新结果静默塞进旧确认。替代报价必须保持用户、商家、SKU、数量、币种、配送方式和关键条款不变,总应付不增加,并逐项确认商品金额、优惠来源和退款权益没有变差;系统保存 replacementOfQuoteId、比较结果和 acceptanceSource=AUTO_BETTER。替代报价拥有新的稳定 commitRequestId/pricingCommitId,旧报价原子记录唯一 successorQuoteId;旧请求重试只恢复这张替代报价,不能再生成第二个后继。只要比较不完整、权益难以排序或合规条款变化,就按不利变更处理,返回页面再次确认。
报价生命周期需要独立建模:
COMMITTING 不是让请求无限等待,而是给崩溃恢复留下证据。订单库中的 Guard 才是“还能创建订单还是已经过期”的竞争点:创建订单事务把 AVAILABLE -> CONSUMED 与订单插入一起提交;过期任务只能把同一行 AVAILABLE -> EXPIRED。两者锁定并 CAS 同一行,因此不会出现过期任务刚查到“暂无订单”就释放,而另一事务随后又成功下单的窗口。
报价服务无法可靠观察订单事务“刚锁住 Guard、但还没有提交”的瞬间,因此不设置 CONSUMING。报价在订单事务期间仍是 COMMITTED;订单提交后,由 ORDER_CREATED Outbox 把它直接推进为 CONSUMED。如果通知延迟,Guard 和订单唯一约束仍是权威,报价状态暂时落后不允许生成第二个提交凭证。
恢复任务以同一个 commitRequestId 查询 price fence、优惠预占和 Guard:Guard 已 CONSUMED 就按其 consumedOrderId 推进报价并确认预占;Guard 已 EXPIRED 或已被更高 saleabilityEpoch 证明为 INVALIDATED 才释放;Guard 仍 AVAILABLE、未到期且 epoch 向量仍匹配时才继续接受同一个 pricingCommitId 重试。结果未知时不能另发提交凭证。
10.3 报价有效期不等于锁价
报价可以有五分钟有效期,但它有两种完全不同的业务语义:
- 校验窗口:五分钟内可以拿报价 ID 下单,但若依赖版本改变仍会重算;
- 承诺窗口:五分钟内平台承诺旧价,后续价格变化不影响该报价。
本文默认浏览阶段采用第一种,因为它不需要为所有打开结算页的用户承担价格敞口。
但从“最终校验通过”到“订单数据库提交”之间仍然存在竞态,所以用户点击确认后还需要一个很短、一次性的 pricingCommitId。它把价格权威签发的 priceFence、促销规则结果、稀缺优惠预占和 saleabilityEpoch 向量绑定到同一次提交,明确表示平台接受在短窗口内按这组快照创建一张订单。签名载荷至少覆盖用户、聚合键、价格版本向量、金额摘要、成交开关代次摘要和过期时间,任何字段改变都会导致验签失败。它不等于把浏览报价锁五分钟,也不能重复消费。
订单服务必须验证服务端签名、用户、聚合键、金额摘要、epoch 向量和 commitExpiresAt,并在创建订单的数据库事务中锁定 PRICING_COMMIT_GUARD 以及相关 SALEABILITY_CONTROL 行。只有 Guard 仍为 AVAILABLE、未过期、所有控制行都是 OPEN 且当前 epoch 与 Guard 向量逐项相等,才能同时写订单、不可变价格快照和 CONSUMED 状态。多 SKU 时按稳定的 aggregateKey 顺序加锁,避免死锁。仅在事务外调用一次“凭证校验接口”,再隔一段时间无条件插入订单,仍会留下过期或强制停售竞态。
如果产品选择第二种,就必须持久化承诺、做额度控制,并处理促销库存、优惠券占用和恶意囤报价,不能只把 expiresAt 改长。
10.4 订单价格快照保存什么
订单创建成功时至少固化:
- 商品原价、基础销售价和价格版本;
- 各类优惠来源、规则版本和金额;
- 订单项应付金额;
- 订单级优惠如何分摊到每个订单项;
- 运费、税费和币种;
- 结算报价 ID 与定价摘要;
- 一次性
pricingCommitId、价格 fence 和优惠预占标识; - 用户确认时看到的关键变化提示版本。
退款、售后、财务对账都依赖这份快照。
如果退款时再去读取商品当前价,商品涨跌会改变历史订单退款金额,造成严重错误。
10.5 改价与创建订单并发
不需要用一个覆盖全站商品和订单的大事务来阻止并发,但必须区分两个线性化点:Guard 已准备后,结算把报价从 COMMITTING CAS 为 COMMITTED,表示平台接受这组短时价格承诺;订单事务把 Guard 从 AVAILABLE 改为 CONSUMED 并唯一插入订单,表示这组承诺已经被一张订单消费。
- 浏览报价读取
seq=41; - 用户确认前运营让
seq=42生效; - 价格权威拒绝基于
seq=41的 fence 请求,结算重新计算; - 若价格下降则按规则使用更优新价,若上涨或权益变差则返回新报价并要求再次确认;
- 用户确认最新报价后,结算先 CAS 获得
COMMITTING提交权,再用稳定commitRequestId和候选pricingCommitId申请 price fence、预占券等稀缺权益; - 所有凭证仍有效时,结算先在订单库幂等准备 Guard,再 CAS 进入
COMMITTED;普通价格版本即使随后推进到seq=43,也不回头改写这次已接受的短时承诺; - 订单服务在同一事务中消费 Guard 并以
pricingCommitId建唯一约束,换请求幂等键重试也只能创建一张订单; - 订单 Outbox 幂等确认优惠预占;Guard 过期 CAS 成功后,到期任务才释放资源,不能用一次无锁的“订单不存在”查询代替。
价格 fence 的实现取决于价格权威的分片方式:同库可以在读取当前指针、成交开关和写 fence 时使用短事务与确定顺序锁;跨分片可以由各价格分片签发带版本、金额摘要、saleabilityEpoch、pricingCommitId 和过期时间的短时子 fence,再由结算组合,部分成功时按超时释放。无论实现怎样,不能只做一次普通查询,然后在几百毫秒后的另一个服务里假设价格仍未变化。普通 activationSeq 变化与 HARD_STOP 不同:前者遵守已经签发的短时承诺,后者推进 epoch,订单事务必须拒绝旧 Guard。
十一、核心技术实现六:灰度生效与回滚
11.1 先区分业务灰度和技术灰度
业务灰度会让不同合法作用域看到不同价格,例如:
- 先在一个区域价格簿生效;
- 先在 App 渠道生效,再扩到 Web;
- 只对明确签约的企业客户价格簿生效。
技术灰度则是验证新定价引擎:
- 旧引擎正常出价;
- 新引擎影子计算,不影响用户金额;
- 比较两套结果和原因;
- 达到验收标准后逐步切换权威计算。
对普通消费者随机展示不同价格可能涉及公平、合规和信任问题。除非产品与法务明确批准,不应把“按用户哈希随机改价”当作默认灰度方案。
11.2 灰度链路必须使用同一个分群上下文
同一个用户从搜索进入详情、购物车和结算时,要么传递服务端签发的 scopeToken,要么由各服务使用同一套可版本化规则解析作用域。
如果搜索按 App 渠道展示 179 元,结算却因为缺少渠道上下文回落到默认价格簿 199 元,问题不是缓存不一致,而是定价上下文丢失。
11.3 分阶段生效
一次高风险全量改价可以采用:
- 在发布前做离线影响分析;
- 新版本进入影子解析,不对用户生效;
- 内部测试账户验证详情、搜索、购物车和结算;
- 在明确区域或渠道价格簿生效;
- 观察投影版本差、报价变化率和订单金额异常;
- 扩大作用域;
- 全量后保留旧版本和回滚入口。
每一步都要记录版本、作用域、审批人和观察结果。灰度不是在多台机器上随便改不同配置。
11.4 回滚为什么仍要发布新版本
假设:
priceVersionId=pv-41, activationSeq=41的价格是 199 元;priceVersionId=pv-42, activationSeq=42的价格是 179 元,但运营发现作用域配置错误;- 需要恢复到 199 元。
运营提交补偿计划并完成紧急审批,激活事务才分配 priceVersionId=pv-43, activationSeq=43,金额为 199 元,并记录 rollbackOfVersionId=pv-42。补偿计划本身不能提前指定或占用 43 这个激活序号。
回滚以后:
- 新的详情和结算使用
pv-43; - 仍未处理
pv-42的消费者以后收到它,也会因为activationSeq较低而丢弃; - 已创建订单继续保留成交事实;
- 若错误价格造成实际成交,取消、补差、履约或赔付由业务和合规决策,不能直接改订单数据库。
11.5 紧急止损开关
如果无法迅速判断正确价格,可以暂停相关作用域的新成交,而不是猜一个金额继续卖。
普通回滚和紧急停售不能共用一个模糊的“发布新版本”动作。pv-43/seq=43 只是在价格时间线上替换当前金额,不会撤销已经签发的短时承诺;HARD_STOP 则作用于独立的成交时间线:
成交入口使用的 SALEABILITY_CONTROL 权威行由订单服务维护,因为最终是否允许插入订单必须在订单数据库事务里裁决。价格和页面可以缓存其投影,但即使停售事件传播延迟、价格服务误签了旧 epoch 的 fence,订单事务仍会拒绝。竞态只有两种可解释结果:订单事务先提交,则订单已经成立;HARD_STOP 事务先提交,则旧 epoch Guard 无法消费。
多 SKU Guard 保存 aggregateKey -> saleabilityEpoch 向量。订单事务按稳定顺序锁定所有控制行,只要任一商品已停售或 epoch 不相等,就拒绝整单。HARD_STOP 无需同步扫描并更新所有 Guard:epoch 推进已经让旧 Guard 立即逻辑失效;后台可再把 AVAILABLE 物化为 INVALIDATED,随后按幂等协议释放优惠预占。恢复销售也要再推进 epoch,例如从 8 到 9 并改回 OPEN,不能只把状态文本改回去,否则停售前的 Guard 可能复活。
止损开关还需要:
- 精确到商家、价格簿、SKU 或渠道;
- 有权限和双人审批;
- 有自动过期时间,但到期恢复必须执行“推进 epoch 并切回
OPEN”的权威事务,不能靠查询时忽略旧状态; - 详情明确显示暂不可售,而不是前端无限转圈;
- 已创建订单不受开关静默修改;
- 恢复后重新执行投影和价格对账。
十二、可靠性:系统保证什么,又不保证什么
12.1 保证与不保证
| 场景 | 目标保证 | 不保证 | 恢复证据 |
|---|---|---|---|
| 排期接口成功 | 计划修订、下一排期提示与 Outbox 已同事务持久化 | 计划价格已经生效 | 改价单、计划修订、scheduleRevision |
| 价格激活成功 | 权威指针可解析到新的 ACTIVE_PRICE 状态版本并有 Outbox | ES 已在同一时刻更新 | priceVersionId、activationSeq、当前指针 |
| 到期无后继价 | 权威指针切到更高序号的 NO_ACTIVE_PRICE tombstone | 继续沿用更旧价格成交 | tombstone、PRICE_DEACTIVATED、投影水位 |
| MQ 重复投递 | 消费者按事件与版本幂等收敛 | 消息只投递一次 | 消费日志、投影检查点 |
| MQ 乱序 | 低 activationSeq 不覆盖新投影 | 所有投影逐个看到每个中间状态版本 | 乱序丢弃指标、激活序号 |
| 投影目标写入后宕机 | 重试根据同序号同摘要结果补推检查点,再把 Inbox 置为 APPLIED | Redis/ES 与检查点跨系统原子提交 | Inbox、目标版本、持久化检查点 |
| 搜索展示价格 | 是可解释的价格投影 | 可作为成交承诺 | 文档版本、更新时间 |
| 购物车加购价 | 能解释加入时观察值 | 用户一定按加购价成交 | 加购快照和刷新记录 |
| 结算报价 | 服务端按本次依赖计算 | 默认锁价到报价失效时间 | quote 明细、依赖向量 |
| 提交凭证签发 | Guard、price fence、saleabilityEpoch 向量和优惠预占绑定同一 pricingCommitId | 已经创建订单 | quote、Guard、epoch item、reservation 记录 |
HARD_STOP 提交 | 订单成交控制推进 epoch,旧代次未消费 Guard 无法创建新订单 | 撤销已经提交的订单 | 成交控制行、审计与 Outbox |
| 订单创建成功 | Guard 与订单快照已同事务消费,成交金额和分摊已固化 | 优惠确认事件已经被每个下游处理 | Guard、订单快照、订单 Outbox |
| 投影修复 | 可从权威源重建当前状态 | 人工直接改 ES 后自然正确 | 重放批次、对账记录 |
12.2 结果未知窗口
调用价格服务或订单服务超时时,调用方不能简单认定失败。
例如订单创建请求超时,订单事务可能已经提交。如果客户端立即换一个幂等键、重新消费同一报价,既可能重复下单,也可能与优惠释放任务竞争。
恢复必须优先使用服务端事实,而不是客户端的新请求键:
- Finalize 超时时,按
quoteId + confirmVersion查询原commitRequestId,只恢复同一次COMMITTING,不能生成第二个pricingCommitId; - 创建订单超时时,先按
pricingCommitId查询 Guard 和订单,CONSUMED就返回原订单与原价格快照; - Guard 仍为
AVAILABLE且未过期时,还要重新校验所有成交控制仍为OPEN且 epoch 向量一致,才允许携带原签名凭证重试;即使客户端换了requestId,唯一约束也只能消费同一 Guard 一次; - 过期任务必须在订单库把 Guard 从
AVAILABLECAS 为EXPIRED成功后,才能释放 price fence 与优惠预占; HARD_STOP推进 epoch 后,旧 Guard 已逻辑失效;恢复任务可在校验更高 epoch 后将其从AVAILABLE物化为INVALIDATED,再幂等释放资源;- Guard 或订单结果未知时继续查询或进入人工核查,不能一边释放资源一边允许重试;
- 订单事务成功但通知丢失时,由订单 Outbox 重试推进报价
CONSUMED并确认优惠预占,不能重新按当前价生成另一张无关联订单。
12.3 事件契约演进
权威价格状态事件 PRICE_ACTIVATED 或 PRICE_DEACTIVATED 至少包含:
eventId;eventType;schemaVersion;occurredAt;pricingContextKey;skuId;priceType;aggregateKey;priceVersionId;activationSeq;stateType,即ACTIVE_PRICE或NO_ACTIVE_PRICE;activatedAt或deactivatedAt;priceType对应的金额和币种,tombstone 中金额为空;- 内容摘要和变更原因编号。
排期事件 PRICE_SCHEDULE_CHANGED 使用另一套契约:pricingContextKey、skuId、priceType、aggregateKey、scheduleRevision、可为空的 nextScheduledAt、changedAt 和原因编号。它不携带 priceVersionId 或 activationSeq,消费者只能用 scheduleRevision 更新排期提示,不能借此提前改变当前价格。
成交开关使用独立的 SALEABILITY_CHANGED 契约:aggregateKey、saleabilityEpoch、saleabilityStatus、可为空的 stopExpiresAt、原因编号和审计动作 ID。消费者不能拿 saleabilityEpoch 与 activationSeq 比大小;恢复销售也发布更高 epoch 的 OPEN 事件。
新增字段应保持旧消费者可忽略,破坏性变更需要新 schema 版本和兼容期。
消费者遇到无法识别的币种、精度或 schema 时,应隔离并告警,不能用默认值写入搜索或缓存。
十三、对账:区分应该相等的数据与合法不同的快照
13.1 对账对象
并不是看到两个金额不同就判错。
以下差异可能合法:
- 购物车加购价与当前价不同;
- 已创建订单价与商品现价不同;
- 匿名搜索预估价与登录用户会员价不同;
- 不同区域、渠道或币种价格不同。
对账必须先对齐 pricingContextKey、SKU、priceType、业务时间和价格语义,再比较 aggregateKey 内的版本与金额。
13.2 四类对账
四类对账分别回答:
- 权威源自身有没有重叠版本、错误指针或到点未生效;
- Redis、ES 和促销展示投影是否落后于权威版本;
- 促销结果引用的基础价和规则版本是否仍可解释;
- 订单快照是否确实来自当时有效且由用户确认的报价。
13.3 对账水位
事件正在传播时,权威 activationSeq=42 与 ES baseActivationSeq=41 的短暂差异可能只是正常在途。
对账任务需要记录:
- 本轮读取的权威快照时间;
- 事件流已确认水位;
- 各投影消费水位;
- 允许传播窗口;
- 重试和隔离状态;
- 上次修复版本。
只有超过安全窗口仍未收敛,才升级为需要修复的差异,避免对正常在途数据反复重建。
13.4 修复原则
投影差异的修复方式应该是:
- 从价格权威读取当前有效快照;
- 生成带修复批次号的重放任务;
- 按
activationSeq条件更新目标投影; - 记录修复前后版本与摘要;
- 再次对账确认收敛。
禁止运营直接登录 Redis 或 ES 手改金额。
直接手改没有权威版本和审计依据,下一条旧事件还可能再次覆盖它,也无法解释订单当时依据了什么。
十四、测试:怎样证明方案而不是只画架构图
以下内容是落地时必须执行的验收用例,不是本文已经取得的测试结果。当前证据状态统一为 NOT_RUN;只有接入真实代码、迁移脚本和可重复测试报告后,才能回填为 PASS 或 FAIL。
14.1 单元与状态机测试
- 草稿和计划修订可以修改,已激活版本不可原地改金额;
- 计划从 revision 2 改到 revision 3 不会占用
activationSeq; - 计划 B 声明计划 A revision 3 为前驱,A 动态取得
seq=42后,B 能以来源计划身份通过前置校验,而不是被旧expectedBase误伤; - A 被取消、改成 revision 4,或 A 与 B 之间插入紧急计划 C 时,B 进入
NEEDS_REVIEW,不能覆盖计划外基线; - 同一
aggregateKey的重叠有效期被拒绝; - 非法币种精度被拒绝;
- 普通运营无法审批超阈值改价;
activationSeq=42生效后不能用activationSeq=41覆盖当前指针;- 回滚计划激活为
pv-43/seq=43,而不是复用pv-41/seq=41; - 当前价到期且无后继时生成更高序号的
NO_ACTIVE_PRICEtombstone,不回退旧价; - 价格下降、上涨、权益变差分别触发正确确认策略;
- 订单创建后商品改价不改变订单快照。
14.2 数据库并发测试
- 两张改价单并发激活同一
aggregateKey,最多一个 CAS 成功; - 激活事务提交失败时,不产生已生效但无 Outbox 的状态;
- Outbox 发布器重复扫描,不重复改变权威版本;
- 定时任务执行一半重启,重试后状态收敛;
- 数据库时间到达生效点但调度延迟,补捞任务能发现遗漏;
- 创建、改期、取消计划时,计划、
next_scheduled_at、schedule_revision与 Outbox 要么全部提交,要么全部回滚; - 同一计划 revision 重复激活只产生一个
priceVersionId和一个激活序号; - Guard 过期与订单事务并发时,只有
AVAILABLE -> EXPIRED或AVAILABLE -> CONSUMED一条路径成功; HARD_STOP与订单事务并发时,按SALEABILITY_CONTROL的事务先后只会得到“订单先提交”或“epoch 先推进、订单被拒绝”两种结果;- 当前指针和历史版本之间不出现无法解释的断层。
14.3 消息与投影测试
设计一组明确的事件序列:
pv-41/seq=41 -> pv-42/seq=42 -> duplicate(seq=42)
-> delayed(seq=41) -> pv-43/seq=43 rollback
验证:
- Redis 最终是
pv-43/seq=43; - ES 最终是
pv-43/seq=43; - 重复
seq=42不产生重复副作用; - 迟到
seq=41被丢弃并记录; activationSeq相同但版本 ID 或摘要不同会告警;- Redis 或 ES 已写入
seq=42后、检查点提交前宕机,重试识别同序号同摘要并补推检查点,Inbox 最终才进入APPLIED; - Inbox 只有
RECEIVED时重复投递不能直接 ACK,检查点仍为 41 时缓存淘汰也不能接受迟到的seq=41; - 缺少中间版本的投影按职责选择跳跃或完整重建;
- 价格事件不覆盖 ES 中由商品或库存服务拥有的字段;
seq=44的NO_ACTIVE_PRICE先到、seq=43后到时,最终仍不可售;- 排期
scheduleRevision=8先到、revision 7 后到时,nextScheduledAt不倒退。 - 将
PRICE_STATE/seq=42、SCHEDULE/revision=8、SALEABILITY/epoch=8(HARD_STOP)交错投递并插入三类重复旧事件,三条检查点各自推进、摘要互不覆盖; - 上述事件收敛后同时淘汰价格、排期和成交开关缓存,再乱序重放
seq=41、revision 7 和OPEN/epoch=7,回源只能恢复三条权威新状态,旧事件不能借空 Key 复活。
14.4 缓存故障测试
- 缓存未命中时批量回源不会形成无界并发;
- 慢回源读取到
seq=41,但持久化检查点或缓存已有seq=42时,旧值写入失败; - Redis Key 被淘汰后,迟到
seq=41仍被持久化检查点拒绝; saleabilityKey 被淘汰后,迟到的OPEN/epoch=7仍被SALEABILITY/appliedVersion=8检查点拒绝,页面投影不会从HARD_STOP倒退;- tombstone Key 被淘汰后,旧
ACTIVE_PRICE事件不能复活已停售价格; - TTL 未过但
nextScheduledAt已到时强制回源检查激活状态; - Redis 主从切换后,结算不会无条件使用不确定旧价;
- 大量 SKU 同时改价时,预热有速率控制,不拖垮权威主库;
- 热门 SPU 最低价 SKU 变化后,价格区间能正确重建。
14.5 搜索和促销合同测试
- 搜索消费者能兼容事件 schema 的可选新字段;
- ES 文档按
baseActivationSeq条件局部更新; - 共享商品文档用脚本更新价格字段,不让价格 external version 阻断标题或库存自己的版本推进;只有价格独占文档才使用整文档 external version;
- 搜索价格筛选在投影更新后符合当前作用域;
- 基础价改变后,依赖旧价的促销展示被标记过期或重算;
- 匿名预估价不会被当作登录用户最终优惠;
- 促销规则与价格事件同时乱序时,依赖向量仍可解释;
- 货币、区域和渠道上下文不会在服务间丢失。
14.6 结算端到端测试
- 用户看到
seq=41报价后seq=42降价,系统生成带replacementOfQuoteId的更优替代报价、记录AUTO_BETTER,不要求第二次确认并继续 Finalize; - 用户看到
seq=41报价后seq=42涨价,创建订单返回价格变化并要求确认; - 总额下降但某一订单项退款权益变差时不能自动接受,仍要求再次确认;
- 报价过期但依赖未变化时,按产品策略重新生成报价;
- 报价属于用户 A,用户 B 无法使用;
- 客户端篡改单价、优惠额和总金额不会影响服务端计算;
- 同一创建订单幂等键重复提交,只返回同一订单快照;
- 同一报价并发 Finalize,只有一个请求从
OPENCAS 到COMMITTING,其他请求恢复同一commitRequestId; - price fence 或优惠预占完成后进程崩溃,恢复任务继续同一 attempt 或幂等释放,不生成第二个提交凭证;
- 客户端换一个创建订单幂等键重试同一
pricingCommitId,仍只能消费同一 Guard 并创建一张订单; - Guard 准备后报价 CAS 失败,Guard 与优惠预占最终被幂等过期和释放;
- 订单已提交但
ORDER_CREATED通知延迟时,Guard 的CONSUMED状态阻止释放,Outbox 恢复后再确认优惠; - 优惠确认消息重复或暂时失败时,reservation 以
pricingCommitId + orderId幂等收敛; pricingCommitId到期且 Guard 已EXPIRED后,迟到订单请求被拒绝;- 普通
activationSeq从 42 推进到 43 不会让仍在有效期内、epoch 相同的已接受 Guard 失效; - Guard 签发后发生
HARD_STOP,即使停售事件尚未到达结算服务,订单事务也因 epoch 不匹配拒绝并最终把 Guard 物化为INVALIDATED; - 创建订单超时后查询到已成功,不再创建第二张订单;
- 支付金额来自订单,而不是重新查询商品价格;
- 退款依据订单分摊快照,不依据商品当前价。
14.7 灰度、回滚和故障演练
- 影子引擎差异只记录,不影响正式报价;
- 同一用户跨搜索、详情、购物车和结算保持相同作用域;
- 一个区域灰度不会误改默认价格簿;
- 回滚事件先于原错误事件到达时,最终仍保持高
activationSeq补偿价; - MQ 暂停后恢复,投影按
activationSeq收敛; - ES 部分分片不可用时进入重试和对账,不假装全部更新成功;
- 错价止损开关只影响指定聚合或明确选中的聚合集合;恢复销售推进新 epoch,停售前 Guard 不会复活;
- 对账修复任务重复执行不会让
activationSeq倒退。
14.8 容量与性能验证
本文没有给出生产 QPS 结论。真正验收至少需要覆盖:
| 压测场景 | 需要观察的内容 |
|---|---|
| 热门商品详情洪峰 | 缓存命中、回源合并、权威查询负载 |
| 大批量定时改价 | 激活吞吐、Outbox 积压、预热速率 |
| 搜索索引批量更新 | 分片压力、版本冲突、投影收敛时间 |
| 大购物车刷新 | 批量接口大小、超时和降级范围 |
| 结算重新定价 | 价格、促销、会员依赖的整体分位延迟 |
| MQ 消费停顿后恢复 | 最老事件年龄、恢复速度、数据库压力 |
| 缓存全失效 | 回源保护、限流和核心结算隔离 |
| 多区域多币种 | 路由正确性和热点分布 |
报告要写明环境、实例规格、数据分布、SKU 热度、消息分区、缓存命中率、持续时间、错误比例和依赖故障条件。
只测一次 Redis GET 的吞吐,不能证明完整价格链路可靠。
十五、安全、可观测与运维排查
15.1 权限与审计安全
价格变更直接影响收入和用户权益,至少需要:
- 按商家、价格簿和区域授权;
- 创建与审批职责分离;
- 高风险变更双人复核;
- 价格底线和变更幅度保护;
- 批量改价文件做格式、范围和摘要确认;
- 所有改价、撤销、回滚和止损开关写审计日志;
- 审计记录包含操作人、审批人、原因、前后版本和作用域;
- 管理端接口防重放、越权和 CSRF;
- 敏感商业价格按权限脱敏展示。
客户端永远不能决定可信价格。
即使客户端提交的金额与服务端恰好相同,也只能把它用于前后端差异诊断,不能作为成交事实写入订单。
15.2 核心指标
| 层级 | 指标 |
|---|---|
| 改价业务 | 草稿、待审、待生效、到点未生效、回滚和拒绝原因 |
| 权威价格 | 当前状态解析失败、作用域冲突、activationSeq CAS 冲突、查询延迟 |
| 成交控制 | HARD_STOP 数、当前 saleabilityEpoch、旧 epoch Guard 拒绝、恢复超时 |
| Outbox/MQ | 未发布数、最老记录年龄、重复率、重试和隔离数 |
| 投影恢复 | 按 streamKind 统计 Inbox RECEIVED 年龄、目标版本与检查点差、补推成功、同版本异摘要冲突 |
| Redis | 命中率、低 activationSeq 条件写拒绝、回源量、热点与切换 |
| ES | baseActivationSeq 落后分布、更新失败、乱序丢弃、重建队列 |
| 促销 | 依赖过期、重算失败、匿名预估与结算差异 |
| 购物车 | 价格上涨率、下降率、失效商品和刷新失败 |
| 结算 | 重新定价率、自动接受更优报价率、要求确认率、COMMITTING 年龄、Guard 准备失败、预占释放与确认重试 |
| 订单 | Guard 消费/过期/止损竞争、epoch 不匹配、报价与快照摘要不一致、金额校验失败、pricingCommitId 唯一约束命中 |
| 对账 | 各投影版本差、差异年龄、修复成功与再次出现 |
只看 MQ 是否有积压不够。
消息没有积压,也可能是消费者把 activationSeq=43 错误地更新成 activationSeq=41;因此必须同时观察版本水位和业务摘要。
15.3 日志与 Trace
一次改价应能用以下字段串起来:
changeOrderId;planId与planRevision;pricingContextKey与aggregateKey;skuId;priceType;priceVersionId与activationSeq;scheduleRevision;eventId;projectionName;streamKind、incomingVersion与检查点appliedVersion;quoteId;commitRequestId与pricingCommitId;guardStatus、saleabilityEpochDigest与reservationIds;orderId;requestId;dependencyDigest。
日志记录版本、状态和原因码,不直接打印完整用户身份、敏感协议价或支付信息。
Trace 可以连接同步调用,但异步投影还需要事件 ID、聚合键和消费水位,不能只依赖一条 HTTP Trace。
15.4 一次具体故障怎样排查
现象:客服反馈搜索页显示 179 元,进入详情却显示 199 元;结算也是 199 元。
排查顺序:
- 取得 SKU、用户渠道、区域、币种和发生时间;
- 确认搜索、详情和结算是否使用同一个
pricingContextKey、priceType和由此生成的aggregateKey; - 查询价格权威的
stateType、priceVersionId、activationSeq和权威切换时间,确认到底应该是哪个价格; - 查询搜索文档
basePriceVersionId与baseActivationSeq,判断它是传播落后还是误用了其他价格簿; - 查询详情缓存的
activationSeq、nextScheduledAt、scheduleRevision和最近回源记录; - 检查是否存在技术灰度或作用域 token 丢失;
- 若权威是 199 元而搜索是 179 元,按版本和事件来源检查错误投影;
- 若权威是 179 元而详情、结算仍是 199 元,检查激活指针和权威路由;
- 修复时从权威源重建指定作用域,不直接手改页面数据;
- 对故障窗口内的订单做抽样核对,确认是否只有展示问题还是已经影响成交。
这条路径先确认“比较的是不是同一个价格”,再判断传播是否滞后,比一上来清空全站 Redis 更稳妥。
15.5 告警分级
可以按风险分级:
- P0:服务端结算金额与订单快照不一致、出现未经确认的涨价成交;
- P1:权威价格无法解析、到点未生效、错误作用域全量扩散;
- P2:搜索或详情投影超过设计窗口未收敛;
- P3:少量重复事件、可自动修复的旧版本丢弃增长。
具体等级和阈值必须由业务承诺、影响范围和监控基线确定,这里不提供虚构的生产数字。
十六、设计取舍与演进
16.1 为什么不做所有页面强一致
让搜索、详情、购物车、促销和结算都同步锁住同一价格事务,代价很高:
- 搜索索引更新不适合加入数据库事务;
- 一个下游故障会阻塞全站改价;
- 批量改价容易产生长事务和锁竞争;
- 所有浏览请求都访问权威主库会扩大故障面;
- 多区域部署很难获得低延迟的全局同步提交。
按风险分层更合理:权威写入强一致,展示读模型版本化最终一致,结算强校验,订单快照不可变。
16.2 为什么不只使用短 TTL
短 TTL 能缩短部分旧缓存时间,但不能解决:
- 定时生效点恰好落在 TTL 中间;
- 旧请求回写;
- 搜索和促销投影;
- MQ 乱序;
- 订单创建时的可信定价;
- 灰度作用域丢失。
TTL 是缓存回收工具,不是业务一致性协议。
16.3 推送更新还是删除缓存
| 方案 | 优点 | 风险 | 适用条件 |
|---|---|---|---|
| 事件直接写新值 | 生效后可主动预热,减少击穿 | 消费者逻辑更复杂 | 值小、事件包含完整快照 |
| 事件删除缓存 | 实现直观,回源拿权威值 | 删除风暴、击穿、旧值回写 | 低频改价且有完善回源保护 |
| 版本化 Key 加指针 | 回滚和并发更可控 | Key 管理和清理复杂 | 高风险价格、版本审计要求高 |
无论选哪种,都必须携带或校验业务版本。
16.4 何时需要独立价格服务
如果系统只有少量商品、单币种、没有定时价和复杂促销,商品表条件更新加订单重算可能已经足够。
当出现以下信号时,独立价格域更有价值:
- 多渠道、多区域或多币种价格簿;
- 定时价格和批量改价频繁;
- 商品、搜索、营销、购物车都依赖价格事件;
- 需要审批、审计、回滚和历史追溯;
- 价格读取与商品内容读取容量特征明显不同;
- 错价风险要求独立止损和对账。
不要因为面试题出现 Redis 和 MQ,就把一个简单商品系统强行拆成十个服务。
16.5 演进路线与触发条件
V1 可以是:
- 单库价格历史表;
- 当前指针;
- 本地事务加 Outbox;
- 详情缓存带版本;
- 结算重新定价;
- 订单不可变快照。
当搜索价格滞后成为主要体验问题,再增加投影水位和主动重建。
当多区域价格簿出现,再引入清晰的作用域路由和区域级事件分区。
当定价规则复杂、多个业务重复计算,再建设统一结算定价编排和依赖向量。
当新旧引擎迁移风险升高,再增加影子计算、差异归因和技术灰度。
每次演进都应由可观察条件推动,例如作用域数量、改价批次规模、投影积压、错误恢复时间和规则复用程度,而不是为了堆技术名词。
十七、面试复盘与职责边界
17.1 三分钟主回答
面对“商品改价后多端价格如何一致”,我会先澄清价格语义。商品价格服务负责基础价,促销和会员服务负责优惠规则,结算服务生成下单前权威报价,订单保存成交快照。搜索、详情、购物车看到的数字职责不同,不能要求它们都直接成为成交依据。
运营改价时先创建可撤销、可复审的计划,计划修订号不会提前占用价格序号。连续未来计划保存预期前驱计划及修订,避免后继因为不知道前驱未来序号而误判。到达目标时间后,激活事务重新校验直接基线或声明的前驱身份,才分配全局 priceVersionId 和 aggregateKey 内递增的 activationSeq,用 CAS 切换当前指针并同事务写审计与 Outbox。Redis、Elasticsearch 和促销投影按价格流的 activationSeq 单调收敛,排期提示和成交开关则分别按 scheduleRevision、saleabilityEpoch 收敛;三条流拥有独立摘要和持久化检查点。目标写成功但检查点未提交时,由 RECEIVED/APPLIED Inbox 在重试中补推对应流的检查点。到期无后继时发布更高序号的 NO_ACTIVE_PRICE tombstone。nextScheduledAt + scheduleRevision 只用于提示回源和补捞,不能代替权威指针。
购物车保存加购快照只是为了提示涨跌,进入结算必须批量读取当前权威价,并结合促销、会员、券、运费和税费生成带依赖向量的服务端报价。用户最终确认时,结算通过 priceFence + pricingCommitId + 优惠预占 固定一次短时承诺;金额更低且逐项权益不差时可生成可审计的替代报价并自动继续,价格上涨、权益变差或比较不确定则返回新报价再次确认。订单库用 Guard 把一次性凭证消费、saleabilityEpoch 校验和订单快照写入放在同一事务,支付和退款此后都依据订单,不再读取商品现价。普通改价不撤销已接受 Guard,显式 HARD_STOP 推进 epoch 后才阻断未创建订单的旧 Guard。
灰度要区分业务作用域灰度和新引擎影子计算,不能随意让同一用户跨页面落入不同价格簿。回滚也发布更高补偿版本,保证消费者版本单调。最后用权威内部、读模型投影、组合定价和订单抽样四类对账发现差异,并从权威源重放修复。
17.2 高频追问
-
为什么不能直接改商品表价格再删缓存?
因为搜索、促销和购物车不是同一个缓存,删除还存在失败、击穿和旧请求回写问题,也无法追踪版本和回滚。
-
是不是所有端都必须强一致?
不是。展示可以在受控窗口内最终一致,结算必须重新定价,订单成交价必须不可变。
-
搜索价格旧了怎么办?
搜索文档带
priceVersionId和baseActivationSeq,消费者按序号条件更新并监控水位差;点击详情和结算不依赖 ES 成交。 -
MQ 已经按 Key 有序,为什么还要
activationSeq?重试、回放、重平衡和人工修复仍可能重复乱序,分区有序不能代替业务条件更新。
-
回滚为什么不能直接恢复旧版本 ID?
复用旧版本 ID 或让序号倒退,会让消费者无法判断新旧。应通过补偿计划激活新的
priceVersionId和更高activationSeq,金额可以恢复旧值,权威时间线仍单调增加。 -
购物车为什么保存旧价格?
用于解释涨跌和通知,不是成交承诺;结算仍读取当前权威价。
-
报价有有效期,是否代表锁价?
不一定。浏览报价默认只是校验窗口,依赖变化时仍重算;只有最终确认后绑定 price fence、优惠预占和 Guard 的
pricingCommitId,才是一段很短且只能消费一次的价格承诺。 -
用户看到低价,确认时涨价怎么办?
返回新报价和变化原因,要求再次确认,不能静默按高价创建订单。
-
详情页和结算页作用域不一致怎么办?
使用统一定价上下文或服务端签发的作用域 token,并把作用域固化到报价和订单中。
-
怎样判断促销展示需要重算?
记录基础价、促销规则、会员权益等依赖版本,任一变化都可能使预估过期。
-
定时任务没执行怎么办?
数据库保留待生效事实,扫描补捞;权威读取发现生效点已到时触发修复或保护,不能只依赖一条延迟消息。
-
如何证明没有错价成交?
需要订单快照与报价依赖的抽样或全量对账、金额摘要校验、异常订单核查以及可重复测试证据,不能只看缓存命中率。
-
客户端换一个幂等键,为什么不会重复消费同一报价?
commitRequestId由服务端按报价和确认版本稳定生成,pricingCommitId对应订单库唯一 Guard。订单事务只能把 Guard 从AVAILABLE消费一次;相同或不同客户端请求键最终都返回同一订单,或在 Guard 已过期、已被止损代次失效时被拒绝。 -
普通回滚和紧急停售为什么不能只发同一种价格事件?
回滚推进
activationSeq,用于改变后续请求的当前价格,但仍遵守已经接受的短时承诺;HARD_STOP推进独立saleabilityEpoch,订单事务校验 epoch 后拒绝所有旧代次未消费 Guard。混用一条序列会让“正常改价是否撤销承诺”变得不可解释。
17.3 可选择的职责主线
如果真实项目中负责价格发布链路,可以重点讲:
- 改价单、计划修订与价格状态版本两套状态机;
- 当前指针 CAS、Outbox 和定时补捞;
- 证据应是数据库迁移、发布代码、并发测试和审计记录。
如果负责价格读模型,可以重点讲:
- Redis 版本化缓存和旧值回写防护;
- ES 局部条件更新、投影水位和重建;
- 证据应是消费者代码、故障测试、监控面板和对账任务。
如果负责结算链路,可以重点讲:
- 报价依赖向量、价格变化确认策略、price fence 与优惠预占;
pricingCommitId、订单库 Guard、订单价格快照和支付金额边界;- 证据应是接口契约、订单表结构、端到端测试和金额对账。
没有实现和验证证据时,只能称为设计理解或团队方案,不能把整条链路都说成个人独立完成。
17.4 面试自检
在讲完方案后,可以用这些问题检查自己是否真的理解:
- 基础价、促销价、购物车快照、报价和订单价分别由谁负责?
pricingContextKey和aggregateKey分别包含什么,为什么生效时间不能进入键、SKU 不能拼两次?- 后继未来计划为什么需要前驱计划 ID 与修订,不能只保存
expectedBaseActivationSeq? - 定时生效点为什么不能只依赖缓存 TTL?
- 改价事务和价格事件如何避免双写缺口?
- 到期没有后继价格时,为什么需要高序号 tombstone?
- 旧数据库读取为什么可能覆盖新缓存,怎样阻止?
- 目标投影已更新、检查点未提交时,Inbox 为什么不能把
RECEIVED当成已处理? - 促销展示为什么需要多个依赖版本?
- 用户从搜索到结算时,渠道和区域上下文如何保持?
- 价格上涨和下降为什么可以采用不同确认策略?
- 报价有效期和一次性价格承诺有什么区别?
pricingCommitId、Guard 与优惠预占分别解决什么竞态?- 回滚为什么创建新的价格状态版本?
- 已创建订单为什么不能跟随商品现价?
- 对账时哪些金额不同是合法的?
- 消息没有积压为什么仍可能价格错误?
- 普通
activationSeq与saleabilityEpoch分别控制什么,HARD_STOP怎样阻断旧 Guard 而不改已创建订单?
总结
电商商品改价后的多端一致,不是把一个金额复制到五个地方,也不是“数据库加 Redis 加 MQ”这几个组件的组合题。
完整方案从价格语义开始:商品价格服务维护基础价权威,促销和会员提供版本化优惠输入,结算产生下单前权威报价,订单保存不可变成交事实。运营先提交带直接基线或预期前驱的计划,激活事务才生成有规范化 aggregateKey、全局 ID 和单调序号的不可变价格事实版本;权威切换与 Outbox 同事务;Redis、Elasticsearch 和促销读模型借助条件更新、可恢复 Inbox 与持久化检查点按 activationSeq 收敛;购物车保留快照解释涨跌,但不把快照当成交承诺。
真正兜住资损的是结算重新定价、有利与不利变化分流、一次性提交 Guard、订单事务内 epoch 校验和订单快照。真正兜住传播故障的是激活序号条件更新、投影崩溃恢复、排期补捞、无价 tombstone、回滚新版本和分层对账。真正让方案可运营的是权限、审计、HARD_STOP、监控和从权威源重建。
因此这道场景题最有价值的回答不是“怎样让每个页面永远同时显示同一个数字”,而是讲清楚:哪些数据必须强一致,哪些读模型允许短暂滞后,用户在改价边界上如何被公平对待,以及系统在重复、乱序、超时和错误发布之后怎样恢复到可解释状态。