商品价格与优惠中台

商品价格与优惠中台

image

商品价格与优惠中台

目录导航

展开 / 收起目录

支持文档大纲的阅读器会根据下面的标题自动生成侧边导航;上面的目录适用于直接阅读 Markdown 文件。

开场:电商比价平台的核心挑战

电商比价平台表面上是在回答“哪里更便宜”,实际需要同时保证四件事:

flowchart LR USER["用户看到一个价格"] --> TRUST["这个价格是否真实、有效、可解释?"] TRUST --> FRESH["价格是否足够新"] TRUST --> VALID["优惠是否确实适用"] TRUST --> TIME["指定时间是否命中正确价格"] TRUST --> EXPLAIN["为什么是这个到手价"]

商品价格并不是一个静态字段。开放接口渠道价格变化、优惠券关系变化、活动开始和结束、预热时间、会员身份、首单资格、地区税费以及凑单条件,都会改变同一个 SKU 的最终价格。比价平台如果只保存一个“当前最低价”,会遇到几个直接的业务问题:

比价平台的核心痛点 用户和业务会遇到的结果
价格来源和优惠关系分散 同一 SKU 在不同页面出现不同价格,平台难以形成统一口径
价格和优惠实时变化 价格刚展示就可能失效,用户点击后发现条件不成立
活动存在预热、生效和结束时间 当前价、未来活动价和活动预告容易混在一起
不同商城的门槛、互斥、叠加规则不同 简单相减会得到错误到手价,尤其是定金、跨店和凑单场景
用户需要理解“为什么便宜” 只有一个数字,没有优惠明细和价格公式,难以核验
变更可能同时影响大量 SKU 在线请求临时计算会放大 RPC、数据库和缓存压力

这个服务的重点价值,是把分散且持续变化的价格规则,沉淀成统一的 SKU 价格结果:

flowchart TB SOURCE["开放接口渠道价格、优惠、活动、税费和用户权益"] --> MODEL["统一价格规则模型"] MODEL --> ENGINE["Calculate 价格计算引擎"] ENGINE --> TIMELINE["SKU 全生命周期价格区间"] TIMELINE --> MATERIALIZE["Job 结果物化"] MATERIALIZE --> ONLINE["API / RPC 按目标时间快速查询"] MATERIALIZE --> DOWNSTREAM["商品、搜索、推荐和活动下游复用"] TIMELINE --> FORMULA["优惠明细与可解释价格公式"]

它解决的核心问题可以归纳为四个层面:

  1. 统一价格口径:把 SKU、渠道、店铺和 URL 等不同影响范围的规则,最终收敛到 SKU 级计算结果。
  2. 解决价格时效问题:把价格、优惠和活动的时间边界切成连续区间,传入不同时间就能命中不同价格,支撑当前价、未来价和秒切。
  3. 保证价格计算正确:通过 PriceByStore 和 PricePayByStore 保留不同商城的叠加、互斥、定金、税费和凑单规则。
  4. 支撑大规模价格变更:通过 Kafka、批量消费、业务 ID 合并、errgroup 并发闸门、事务写入和缓存失效,把复杂计算从在线请求中隔离出来。

所以,这个服务在比价平台中的位置,可以理解为“价格规则的统一计算和分发中心”:上游价格与优惠发生变化时,它负责重新计算;用户查询时,API 直接读取已经物化的结果;下游业务则复用同一份价格口径和优惠解释。

规模背景:大促期间每秒处理 3000+ SKU 价格

在双十一大促期间,价格计算链路的峰值达到:

指标 峰值规模
价格计算处理速度 3000+ SKU / 秒
日处理 SKU 价格量 数千万级
flowchart LR PEAK["双十一价格计算峰值"] --> QPS["3000+ SKU / 秒"] PEAK --> DAILY["日处理数千万 SKU 价格"] QPS --> JOB["Job 批量计算、并发控制、结果物化"] DAILY --> JOB

这个量级决定了价格计算需要采用异步任务、批量消费、分级并发和结果预计算模式,在线请求直接读取已经生成的价格结果。

1. 业务背景:用户看到一个价格,系统要回答一组问题

商品详情页展示的价格,通常同时受商品基础价、开放接口渠道价格、优惠关系、活动时间、用户身份、地区和税费影响。用户只看到一个数字,系统需要先回答:

flowchart LR U["用户打开商品详情"] --> Q["这个时间点能买到多少钱?"] Q --> P1["商品原价"] Q --> P2["开放接口渠道价格"] Q --> P3["优惠券是否适用"] Q --> P4["用户身份与地区"] Q --> P5["运费与税费"] Q --> P6["当前、未来或指定时间"]

价格问题集中在三个变化上:

flowchart LR C1["规则来源多"] --> R1["同一 SKU 需要统一口径"] C2["时间变化快"] --> R2["同一 SKU 在不同时间命中不同价格"] C3["展示场景多"] --> R3["详情、列表、会场需要复用结果"]

中台的价值可以落成一条闭环:

flowchart LR INPUT["开放接口价格、优惠、活动、税费"] --> CENTER["统一模型与规则治理"] CENTER --> CALC["计算 SKU 全生命周期价格"] CALC --> RESULT["按目标时间查询价格结果"] RESULT --> BUSINESS["详情、列表、搜索、会场、领券"] BUSINESS -. "核验与反馈" .-> CENTER

2. 业务架构:三个核心模块

flowchart LR ADMIN["Admin 后台<br/>提报、拉黑、配置"] --> DATA["结构化规则数据"] DATA --> JOB["Job<br/>标准化、计算、落库、分发"] JOB --> RESULT["SKU 价格结果"] RESULT --> API["API / RPC<br/>价格查询"] API --> BIZ["商品业务"]

2.1 Admin:运营管规则

flowchart TB ADMIN["Admin 后台"] ADMIN --> A1["优惠券提报"] ADMIN --> A2["优惠关系维护"] ADMIN --> A3["优惠券 / 关系拉黑"] ADMIN --> A4["活动价 / 定金配置"] ADMIN --> A5["开放接口价格同步、预热、物料"] ADMIN --> A6["模拟、核验、导出"] A1 --> RULE[("规则数据")] A2 --> RULE A3 --> RULE A4 --> RULE A5 --> EVENT["变更事件 / 计算任务"] A6 --> EVENT

2.2 API / RPC:业务查价格

flowchart LR CLIENT["商品业务请求<br/>SKU + 用户 + 地区 + 场景"] --> QUERY["API / RPC"] QUERY --> Q1["价格助手"] QUERY --> Q2["当前价 / 未来价"] QUERY --> Q3["优惠券列表"] QUERY --> Q4["最低价 / 活动价"] QUERY --> Q5["凑单 / 补贴 / 用户权益"] Q1 --> FORMULA["可解释的到手价公式"] Q2 --> FORMULA Q3 --> FORMULA Q4 --> FORMULA Q5 --> FORMULA FORMULA --> CLIENT

2.3 Job:后台算价格

flowchart LR EVENT["数据变更 / 开放接口价格变动 / 定时任务"] --> DTS["DTS<br/>同步基础变更"] EVENT --> SYNC["数据接入与同步 Worker<br/>价格标准化与关系维护"] EVENT --> CAL["Calculate<br/>优惠与价格计算"] DTS --> CAL SYNC --> CAL CAL --> SKU["SKU 价格结果"] SKU --> PERSIST["价格落库 / 缓存失效"] PERSIST --> PUB["消息分发"]

2.4 Job 的进程拆分

源码中的 job/build/main.go 通过 conf.Svr 支持四种启动模式:

flowchart TB JOB["Job 统一代码仓库"] --> DTS["dts<br/>数据变更同步"] JOB --> SYNC["数据接入与同步<br/>标准化与关系维护"] JOB --> CALCULATE["calculate<br/>价格计算引擎"] JOB --> ACTIVITY["activity<br/>价格结果物化"]
启动模式 主要职责 典型输入 典型输出
dts 监听优惠券、优惠关系、活动和税费变更 数据变更事件 SKU / 渠道 / 店铺计算任务
数据接入与同步 接收开放接口价格和优惠信息,做标准化、关系维护和变化触发 开放接口同步事件、运营任务 业务数据、计算任务
calculate 加载 SKU 上下文,计算全生命周期价格 SKU / 渠道 / 店铺计算任务 SKU 价格计算结果
activity 保存价格区间,生成当前价、未来价和活动结果事件 SKU 价格计算结果 价格表、缓存、下游消息

这里有两个部署事实需要分开讲:

  • 代码层面已经按四个服务角色组织,并且可以通过 Svr 单独启动;
  • Svr 未指定时,main.go 会在同一个进程中启动多个角色;指定 Svr 后,可以单独启动对应角色。

3. 业务主链路

flowchart LR S1["开放接口渠道价格"] --> N["统一模型"] S2["开放接口优惠信息"] --> N S3["活动 / 税费"] --> N S4["Admin 提报 / 拉黑"] --> N N --> R["建立优惠关系"] R --> T["按时间边界切分价格区间"] T --> RULE["渠道规则计算"] RULE --> USER["叠加用户 / 地区因素"] USER --> SKU["统一收敛到 SKU"] SKU --> NOW["当前价"] SKU --> FUTURE["未来价 / 预热价"] SKU --> EXPLAIN["优惠公式 / 展示文案"] NOW --> RESULT["结果落库与消息分发"] FUTURE --> RESULT EXPLAIN --> RESULT

分享时可以先用一个简化公式理解:

到手价 = 商品价或活动价 - 可用优惠 - 用户权益 + 运费 + 预估税费

系统会依次判断时间、关系和用户条件:

flowchart TB BASE["商品 / 活动价"] --> CHECK{"是否满足使用条件?"} COUPON["优惠券"] --> CHECK RELA["适用关系"] --> CHECK TIME["开始、结束、预热时间"] --> CHECK BLACK["黑名单规则"] --> CHECK USER["用户资格"] --> CHECK CHECK -- "是" --> APPLY["应用渠道策略<br/>叠加 / 互斥 / 凑单 / 定金"] CHECK -- "否" --> DROP["不参与本次价格"] APPLY --> ADD["运费 / 税费 / 身份权益"] ADD --> FINAL["SKU 到手价"]

3.1 秒切:全生命周期价格模型

秒切的核心,是提前得到一个 SKU 在当前计算窗口内的连续价格区间。查询时传入目标时间,系统匹配该时间所在的区间,直接返回对应的价格、优惠和活动信息。

flowchart LR RULE["价格、优惠、活动、预热和定金规则"] --> SPLIT["按时间边界切分"] SPLIT --> PERIOD["SKU 全生命周期价格区间"] PERIOD --> QUERY["传入目标时间"] QUERY --> MATCH["匹配 PreAt / StartAt / EndAt"] MATCH --> PRICE["返回该时间点价格与优惠"]

同一个 SKU,传入不同时间,会得到不同区间结果:

flowchart TB SKU["同一个 SKU"] --> T1["目标时间 T1"] SKU --> T2["目标时间 T2"] SKU --> T3["目标时间 T3"] T1 --> P1["区间 A:基础价"] T2 --> P2["区间 B:活动预热价"] T3 --> P3["区间 C:活动生效价"]
全生命周期价格 = 多个连续的时间区间结果
当前价 = 目标时间为当前时刻的查询视图
未来价 = 目标时间落在后续区间的查询视图
活动预告价 / 会场目标价 = 传入指定时间后的查询视图

业务上,这让活动预告、会场展示、价格核验和实时切换复用同一套价格结果。系统不需要在每次请求中重新计算整套优惠规则,而是做一次时间区间匹配。

4. 技术架构:以 Job 为核心的在线查询与异步计算

flowchart TB subgraph ONLINE["在线链路:低延迟查询"] REQ["商品详情 / 列表请求"] --> API["API / RPC"] API --> CACHE[("缓存")] API --> PRICE[("SKU 价格存储")] API --> DEP["商品 / 用户 / 运费 / 税费 RPC"] CACHE --> RESP["价格与优惠响应"] PRICE --> RESP DEP --> RESP end subgraph OFFLINE["异步链路:高吞吐计算"] CHANGE["DTS / 开放接口渠道价格变动 / Admin / 定时事件"] --> MQ["消息队列"] MQ --> JOB["Job Worker"] JOB --> DB[("业务数据")] JOB --> CALC["SKU 价格计算"] CALC --> PRICE2[("价格存储")] CALC --> MQ2["价格结果事件"] end MQ2 --> DOWN["商品 / 搜索 / 推荐 / 会场下游"] PRICE2 --> ONLINE
flowchart LR ONLINE["在线查询链路"] --> O1["请求进入 API / RPC"] O1 --> O2["缓存 / 价格存储 / 外部 RPC"] O2 --> O3["返回当前价格与优惠公式"] OFFLINE["异步计算链路"] --> F1["变更事件 / 开放接口价格变动 / 定时任务"] F1 --> F2["消息队列"] F2 --> F3["Job 批量消费与计算"] F3 --> F4["价格落库与结果分发"]

4.1 Job 位于价格链路中心

flowchart TB subgraph READ["在线读取层"] R1["HTTP / gRPC 请求"] --> R2["Redis 批量读取"] R2 --> R3["价格表回源"] R3 --> R4["按目标时间匹配区间"] end subgraph JOBCORE["Job 核心计算层"] C1["价格或规则变更"] --> C2["Kafka 排队"] C2 --> C3["Calculate 批量消费"] C3 --> C4["按 SKU 计算全生命周期价格"] C4 --> C5["activity 结果物化"] end subgraph PUBLISH["结果分发层"] P1["MySQL 持久化"] --> P2["Redis 失效 / 回填"] P2 --> P3["价格变化事件"] P3 --> P4["下游业务"] end C5 --> P1 P1 --> R3
层次 主要动作 关注点
在线读取层 批量读缓存、按商品分表回源、按目标时间选区间 延迟、缓存命中率、依赖超时
Job 核心计算层 任务缓冲、业务 ID 合并、SKU 价格计算、结果物化 吞吐、并发、规则正确性
结果分发层 事务写入、缓存失效、价格事件广播 一致性、幂等、下游可消费

Job 位于系统中心:上游变化最终都要形成计算任务,Calculate 生成价格区间,activity 将结果转换成价格表和下游事件,API 只读取已经物化的结果。这样在线请求和复杂价格计算被隔离,业务侧又能通过目标时间复用同一套结果。

5. Job 设计:计算中台的执行中枢

Job 的核心逻辑可以概括为四个阶段:

flowchart LR CHANGE["价格、优惠、活动、税费变化"] --> TRIGGER["变更同步与任务触发"] TRIGGER --> BUFFER["Kafka 计算任务缓冲"] BUFFER --> ENGINE["Calculate 价格计算引擎"] ENGINE --> MATERIALIZE["Activity / SKU Price 结果物化"] MATERIALIZE --> OUTPUT["价格表、缓存、下游事件"]

dts 和数据接入与同步服务负责把外部变化转换成可计算的数据与任务;calculate 负责真正生成价格;activity 负责接收价格结果并整理成在线查询和下游消费需要的形态。Job 的价值在于把“变化处理”和“价格计算”从在线请求中拿出来,形成可批量、可重试、可限速的后台计算链路。

5.1 四个逻辑服务的边界

flowchart TB subgraph JOB["Job"] DTS["dts<br/>变更接入"] SYNC["数据接入与同步<br/>开放接口价格标准化"] CAL["calculate<br/>价格计算"] ACT["activity<br/>结果物化"] end DTS --> TASK["SKU / 渠道 / 店铺计算任务"] SYNC --> TASK TASK --> CAL CAL --> RESULT["SKU 全生命周期价格"] RESULT --> ACT ACT --> STORE["价格表 / Redis / 下游 Topic"]
逻辑服务 核心问题 源码中的关键职责
dts 数据变更如何进入系统 解析优惠券、关系、活动、税费变更;清理相关缓存;投递计算任务
数据接入与同步 外部价格和优惠如何统一 标准化价格与优惠信息;更新业务数据;根据变化触发后续任务
calculate 一个 SKU 在不同时间如何得到正确价格 加载商品、渠道、活动、优惠、税费和分类;生成时间轴;按商城规则计算
activity 计算结果如何供在线和下游使用 接收 SKU 价格结果;保存区间;生成当前、未来、会员、首单等事件

5.2 任务入口:不同影响范围最终收敛到 SKU

flowchart TB TASK["价格计算任务"] TASK --> SKU_TASK["SKU 维度"] TASK --> SUP_TASK["渠道维度"] TASK --> SHOP_TASK["店铺维度"] TASK --> URL_TASK["渠道 URL 维度"] SUP_TASK --> FIND1["查询渠道下的 SKU"] SHOP_TASK --> FIND2["查询店铺下的渠道"] URL_TASK --> FIND3["解析 URL 对应对象"] FIND1 --> SKU_TASK FIND2 --> SUP_TASK FIND3 --> SKU_TASK SKU_TASK --> CORE["BusinessSku<br/>统一计算核心"] CORE --> OUT["全生命周期 / 当前 / 未来 / 身份价格"]

Calculate 接收到的任务不只有 SKU 维度:

  • SKU 维度:直接计算一个渠道 SKU;
  • 渠道维度:先通过商品服务查询渠道下的 SKU,再逐个进入 SKU 计算;
  • 店铺维度:先展开店铺下的渠道,再投递渠道维度任务;
  • 渠道 URL 维度:先解析 URL 对应的渠道或 SKU,再继续投递标准任务。

这是一种“任务范围逐级展开、计算逻辑集中在 SKU”的设计。上层任务只负责找到影响范围,避免为店铺、渠道、URL 各写一套价格算法;核心价格口径只维护在 BusinessSku -> Price 这条路径上。

5.3 Calculate 消费与任务去重

sequenceDiagram participant S as 任务生产方 participant K as Kafka 计算任务 Topic participant C as Calculate Consumer participant M as 批次合并 participant P as Process participant E as 价格引擎 S->>K: Business + BusinessId + RuleId K->>C: 批量消息 C->>M: JSON 解析 M->>M: 按业务类型和 BusinessId 去重 M->>P: 受控并发处理 P->>E: SKU 级计算 E-->>P: SkuPrice

计算消费者通过 KafkaIterator 先取批次,再由 mergeSkuToCouponMsgs 按 Business 和 BusinessId 合并。它解决的是同一批中多个变化事件重复命中同一对象的问题;它不是跨批次的全局去重,因此最终结果仍依赖幂等写入和变化检测。

5.4 两层计算队列:先缓冲,再进入引擎

源码中存在一层独立的计算任务缓冲:

flowchart LR PRODUCER["DTS / 数据接入与同步 / 定时任务"] --> POOL["计算任务缓冲 Topic"] POOL --> RELAY["任务缓冲消费者"] RELAY --> CHECK{"Kafka Lag 是否过高?"} CHECK -- "是" --> DELAY["降低转发并发<br/>并等待一段时间"] CHECK -- "否" --> FORWARD["转发到 SKU 计算 Topic"] DELAY --> FORWARD FORWARD --> CALC["Calculate Consumer"]

这层缓冲由数据接入与同步服务中的计算池消费者负责:批量读取任务,检查相关 Topic 的消费延迟,在积压较高时降低转发并发并等待,再将任务转发给 calculate。它把“任务生产速度”和“价格引擎处理速度”隔开,避免变化集中发生时直接把计算引擎、数据库和外部 RPC 同时打满。

5.5 结果物化:计算结果和在线结果是两步

sequenceDiagram participant E as Calculate participant K1 as SKU 价格结果 Topic participant A as activity participant DB as SKU 价格表 participant K2 as 下游价格 Topic E->>K1: CouponSku(SkuPrice) K1->>A: 批量消费并按 SKU 合并 A->>DB: 事务写入全部价格区间 A->>K2: 当前价 / 未来价 / 会员价 / 首单价事件

calculate 产出的是面向计算的 SkuPrice,其中包含普通价格区间、会员价格区间、首单价格区间和拉新补贴价格区间。activity 再把时间字符串还原成时间字段、按 SKU 去重,写入价格表,并根据当前时间和未来配置选择要发送给不同下游的结果。

这一步拆开后,计算引擎可以专注于规则正确性,结果服务可以专注于持久化、缓存和消息格式;某个下游事件发送变慢,也不会改变已经生成的价格区间。

6. 高并发设计:Job 如何扛住计算量

6.1 消息批量消费 + 业务主键合并

flowchart LR STREAM["Kafka 消息流"] --> BATCH["批量拉取"] BATCH --> GROUP["按业务类型分组"] GROUP --> KEY["按 BusinessId 去重 / 合并"] KEY --> LIMIT["受控并发"] LIMIT --> HANDLE["处理业务对象"]

计算消费者会先批量读取消息,再按业务类型和业务 ID 合并,减少同一对象在一个批次内的重复计算。

6.2 并发闸门:不是收到多少就开多少协程

flowchart TB MSG["一批消息"] --> MERGE["批量解析与合并"] MERGE --> GATE{"并发上限"} GATE --> W1["Worker 1"] GATE --> W2["Worker 2"] GATE --> W3["Worker ..."] GATE --> WAIT["等待本批完成"] W1 --> WAIT W2 --> WAIT W3 --> WAIT WAIT --> NEXT["继续下一批"]

源码中可以看到:

  • Calculate 消费批次有可配置的批量大小;
  • SKU 计算阶段通过 GOMAXPROCS 控制并发;
  • SKU Price 阶段也有独立的并发上限;
  • 开放接口同步、Admin 和通用 DAO 使用不同的 fanout worker 数;
  • 部分店铺扩散任务主动限速,避免瞬时放大。

代码中的默认值可以抽象成下面这组“并发闸门”:

flowchart LR C["Calculate 消费"] --> C1["批量读取<br/>默认约 500 条"] C1 --> C2["业务 ID 合并<br/>同一批减少重复计算"] C2 --> C3["SKU 并发闸门<br/>默认约 5,可动态配置"] R["数据接入与同步"] --> R1["开放接口事件消费 fanout<br/>约 5 个 Worker"] R1 --> R2["标准化结果推送 fanout<br/>约 30 个 Worker"] P["SKU Price"] --> P1["价格结果并发闸门<br/>默认约 15,可动态配置"] P1 --> P2["落库 + 缓存失效 + 消息"] T["全量 / 时间任务"] --> L["Redis 分布式锁"] L --> T1["同一任务窗口只允许一个实例执行"]

6.3 业务粒度下沉,控制大任务扩散

flowchart LR SHOP["店铺变更"] --> SUPPLIERS["多个渠道"] SUPPLIERS --> Q["计算任务队列"] Q --> SKU1["SKU 1"] Q --> SKU2["SKU 2"] Q --> SKUN["SKU N"] SKU1 --> CALC["统一 SKU 计算"] SKU2 --> CALC SKUN --> CALC

店铺或渠道级任务不会在一个调用栈里同步计算全部 SKU,而是继续投递到计算队列。这样可以把大任务拆开,让队列和并发闸门承担削峰作用。

6.4 分布式锁避免重复全量任务

flowchart LR POD1["实例 A"] --> LOCK{"Redis 分布式锁"} POD2["实例 B"] --> LOCK POD3["实例 C"] --> LOCK LOCK -- "获得锁" --> RUN["执行全量 / 时间窗口任务"] LOCK -- "未获得锁" --> SKIP["跳过本次执行"] RUN --> UNLOCK["执行完释放锁"]

定时全量计算、时间切换和部分运营任务会使用分布式锁,避免多实例同时扫描或重复推送。

6.5 结果变化检测,减少无效写入和广播

flowchart TB INPUT["新计算结果"] --> DIFF{"与上次结果比较"} DIFF -- "无变化" --> CACHE["保持或刷新必要缓存"] DIFF -- "有变化" --> DB["更新价格存储"] DB --> EVENT["发送价格变化事件"] FORCE["强制推送标记"] --> EVENT

开放接口价格变动、活动和 SKU 价格处理都存在变化判断。这样可以减少:

  • 重复数据库写入;
  • 重复缓存失效;
  • 重复消息广播;
  • 下游无意义的索引刷新。

7. 价格计算核心图

flowchart TB SKUINFO["SKU / 渠道 / 店铺信息"] --> LOAD["加载价格、活动、优惠关系、税费"] LOAD --> AXIS["按优惠与活动起止时间建立时间轴"] AXIS --> PERIOD["多个连续价格区间"] PERIOD --> STORE_RULE["按渠道选择计算策略"] STORE_RULE --> COUPON["普通券 / 身份券 / 补贴"] STORE_RULE --> PROMO["活动价 / 预售 / 定金"] STORE_RULE --> GROUP["跨店 / 凑单 / 组合优惠"] COUPON --> MERGE["合并有效优惠"] PROMO --> MERGE GROUP --> MERGE MERGE --> USERCTX["用户 / 地区 / 展示模式"] USERCTX --> CURRENT["当前价"] USERCTX --> FUTURE["未来价"] USERCTX --> FORMULA["可解释价格公式"]

7.1 Calculate 引擎的主流程

Calculate 的入口是 BusinessSku。它先补齐 SKU 上下文,再进入 Price;Price 负责规则分类、时间区间计算和不同价格轨道的编排。

flowchart TB INPUT["BusinessSku(skuId, activity, taxRate)"] --> VALIDATE["校验 SKU 与渠道信息"] VALIDATE --> CONTEXT["补齐商品分类、活动、税费和渠道扩展"] CONTEXT --> RELATION["加载 SKU / 渠道 / 店铺 / URL 适用关系"] RELATION --> FILTER["过滤删除、拉黑、过期和不满足时间范围的优惠"] FILTER --> CLASSIFY["按规则拆分优惠券集合"] CLASSIFY --> AXIS["建立全生命周期时间轴"] AXIS --> STRATEGY["按商城选择价格策略"] STRATEGY --> TRACKS["普通 / 会员 / 首单 / 拉新补贴价格轨道"] TRACKS --> ENRICH["补充预热、活动、税费、凑单和优惠说明"] ENRICH --> OUTPUT["SkuPrice"]

可以把这个引擎理解成四个连续动作:

动作 代码中的主要职责 解决的业务问题
上下文装配 BusinessSku、SkuActivity、SkuTaxRate、GoodsCategory 价格计算不能只看一个券或一个商品价
规则收敛 AllCouponsByTime、OrdinaryCoupons 统一 SKU、渠道、店铺和 URL 维度的优惠关系
时间切片 AllTimePrices 同一 SKU 在不同时间返回不同价格,支持秒切
策略执行与输出 PriceByStore、PricePayByStore、SkuPrice 兼容不同商城规则,同时保留价格公式和优惠明细

7.2 从规则变化到连续价格区间

每次计算的产物不是一个孤立的当前价,而是一组带起止时间的价格记录。规则发生变化时,系统重新切分受影响时间段,再保存新的区间结果。

flowchart LR INPUT["价格、优惠、活动、税费规则"] --> BOUNDARY["收集时间边界"] BOUNDARY --> SEGMENT["切分连续区间"] SEGMENT --> CALC2["按区间计算 SKU 价格"] CALC2 --> SAVE["保存 StartTime / EndTime / PreTime"] SAVE --> VIEW["当前价、未来价、活动价等查询视图"]
例如:
T0 <= t < T1 -> 基础价
T1 <= t < T2 -> 活动预热价
T2 <= t < T3 -> 活动生效价
T3 <= t       -> 活动结束后的价格

API 侧接收 SKU 和目标时间,从全生命周期结果中选出对应区间;因此“秒切”体现为查询视图快速切换,价格计算仍然在 Job 阶段完成。

7.3 多商城策略适配:统一入口,不强行统一算法

Calculate 通过 PriceByStore 按商城分派策略:

flowchart LR PRICE["PriceByStore"] --> TB["淘宝 / 天猫<br/>普通券、定金、满减、凑单"] PRICE --> JD["京东<br/>不可叠加、可叠加、互斥券组、税费"] PRICE --> PDD["拼多多<br/>平台优惠与商城规则"] PRICE --> VIP["唯品会<br/>会员、活动和预热"] PRICE --> OTHER["其他商城策略<br/>税费、立减、活动券"] TB --> RESULT["统一 Price 结构"] JD --> RESULT PDD --> RESULT VIP --> RESULT OTHER --> RESULT

策略层共享 Price、RelationAndCoupons 和 CalculateParam 等数据结构,但保留每个商城的门槛、互斥、叠加、定金和税费算法。例如:

  • 淘宝 / 天猫会处理定金、普通券、满折、跨店凑单和囤货价;
  • 京东会把不可叠加券、可叠加券和互斥券组分别计算,再选取有效组合;
  • 会员、首单和拉新补贴不是把普通价覆盖掉,而是分别生成独立价格轨道;
  • 每个时间区间都会保留 CouponIds、优惠明细、活动 ID、税费和凑单信息,供后续展示和核验。

这种设计解决了两个冲突:业务需要统一的 SKU 价格结果,商城规则又不能被压缩成一套错误的通用公式。

7.4 Calculate 如何对应前面的业务痛点

flowchart TB PAIN1["规则来源多"] --> SOLVE1["按 SKU、渠道、店铺、URL 汇聚适用关系"] PAIN2["价格随时间变化"] --> SOLVE2["收集边界并生成连续价格区间"] PAIN3["不同商城规则不同"] --> SOLVE3["PriceByStore 分派商城策略"] PAIN4["优惠存在门槛与互斥"] --> SOLVE4["按时间、金额、数量和券组筛选"] PAIN5["用户需要看到为什么便宜"] --> SOLVE5["保留券 ID、优惠明细、活动和价格公式"] PAIN6["变更可能瞬时影响大量 SKU"] --> SOLVE6["Kafka 批量任务、去重、受控并发"]
业务痛点 Calculate 的处理方式 形成的结果
同一 SKU 可能关联多层优惠关系 并行读取 SKU、渠道、店铺和 URL 维度关系,再合并券信息 价格计算使用统一输入
活动开始和结束会改变价格 AllTimePrices 收集优惠券、活动、抢先价和特殊活动的时间边界 一个 SKU 形成多个连续区间
预热价需要在正式开始前展示 ActivityInfo / ActivityInfoPre 计算 PreTime,必要时提前调整区间展示起点 预热价、正式价和活动标签可以分开表达
各商城的叠加和互斥规则不同 PriceByStore 进入淘宝、京东、拼多多、唯品会等独立策略 统一输出结构,保留商城差异
一张券不一定适用于当前 SKU 校验删除状态、黑名单、时间、门槛、数量、标签和关系状态 CouponsValid 与实际参与计算的 Coupons 分离
凑单、囤货、定金会改变到手价 在单件价格基础上继续执行商城凑单和支付阶段计算 同时输出单件价、凑单价和优惠说明
会员、首单、拉新补贴不能污染普通价格 分别生成 VipPrices、FirstOrderPrices 和 LaxinButiePrices 不同用户轨道独立消费
价格变化需要同步多个下游 只在结果变化或强制推送时发送对应 Kafka 事件 减少无效写入和广播

因此,Calculate 解决业务痛点的关键不是“把优惠券减掉”,而是把一组带时间、范围、门槛和互斥关系的规则,编排成可查询、可解释、可分发的 SKU 价格结果。

8. 技术架构总图

flowchart LR subgraph INPUT["输入"] I1["商品 / 渠道 / SKU 变更"] I2["优惠券 / 关系 / 活动 / 税费变更"] I3["开放接口渠道价格变动"] I4["Admin 提报、拉黑、预热、定时任务"] end subgraph SERVICE["服务层"] ADMIN["Admin<br/>治理与运营"] API["API / RPC<br/>同步查询"] subgraph JOB["Job"] DTS["dts<br/>变更同步"] SYNC["开放接口同步<br/>标准化与关系维护"] POOL["计算任务缓冲"] CALC["calculate<br/>价格计算引擎"] PRICE["activity<br/>结果物化"] end end subgraph MIDDLE["中间件与存储"] CHANGEQ[("变更 Topic")] CALQ[("计算任务 Topic")] PRICEQ[("SKU 价格结果 Topic")] DOWNQ[("下游价格事件 Topic")] MYSQL[("关系 / 优惠 / 活动数据")] PRICEDB[("SKU 价格数据")] REDIS[("缓存 / 分布式锁 / 变化标记")] end subgraph DEP["外部依赖"] GOODS["商品 / 渠道 RPC"] USER["用户 / 地区 / 权益 RPC"] FREIGHT["运费 / 税费 / 活动 RPC"] SYNCAPI["开放接口渠道价格服务"] end subgraph OUTPUT["下游"] O1["商品详情 / 列表"] O2["搜索 / 推荐 / 索引"] O3["活动会场 / 价格中心"] O4["领券 / 用户权益"] end I1 --> CHANGEQ I2 --> CHANGEQ I3 --> CHANGEQ I4 --> ADMIN I4 --> CHANGEQ ADMIN --> MYSQL ADMIN --> REDIS ADMIN --> CHANGEQ CHANGEQ --> DTS CHANGEQ --> SYNC DTS --> MYSQL DTS --> REDIS DTS --> CALQ SYNC --> SYNCAPI SYNC --> MYSQL SYNC --> REDIS SYNC --> POOL POOL --> CALQ CALQ --> CALC MYSQL --> CALC GOODS --> CALC FREIGHT --> CALC CALC --> PRICEQ PRICEQ --> PRICE PRICE --> PRICEDB PRICE --> REDIS PRICE --> DOWNQ API --> PRICEDB API --> MYSQL API --> REDIS API --> GOODS API --> USER API --> FREIGHT DOWNQ --> O1 DOWNQ --> O2 DOWNQ --> O3 DOWNQ --> O4 API --> O1 API --> O3 API --> O4

9. 中间件选型与联动:为什么这样保证稳定性

这个系统面对的是两种不同的压力:

  • 在线请求在短时间内集中访问价格和优惠;
  • 开放接口渠道价格变动、活动切换和关系变化可能在短时间内影响大量 SKU。

因此,中间件不是简单堆叠,而是按照“在线请求低延迟、后台变更可削峰、数据结果可追溯、故障影响可控”来分工。

9.1 使用了哪些中间件

中间件 / 组件 在系统中的职责 为什么需要它
Gins / Blademaster HTTP 提供 HTTP 接口、路由和客户端访问能力 承接商品业务的同步查询与后台接口
Micro / gRPC 提供服务注册、内部 RPC 调用和 Protobuf 接口 复用商品、渠道、用户、运费和权益等领域能力
MySQL + GORM / Raw SQL 保存优惠券、关系、活动、税费和 SKU 价格结果 作为可追溯的业务事实与计算结果存储
Redis / Redigo / Redis V8 缓存热点价格和优惠详情,保存锁与变化标记 降低 MySQL 读压力,承接短时状态和任务互斥
Kafka + Sarama 封装 传递变更事件、计算任务和价格结果事件 解耦服务、削峰填谷、支持消费者独立扩展
Cron 定时刷新配置、执行价格时间切换和周期任务 补足消息链路无法覆盖的时间驱动场景
fanout 将缓存回填、批量发送等非主流程工作异步化 避免非核心动作阻塞主链路
errgroup / WaitGroup 管理并发任务、取消信号和优雅退出 控制并发规模,保证服务停止时任务可收敛
配置中心 / 动态配置 加载基础配置,并动态调整部分 Worker 与批量参数 在不重启服务的情况下调整处理能力
Jaeger / OpenTracing、xlog、业务指标 链路追踪、结构化日志和 Kafka / 缓存指标 让延迟、错误、消费积压和热点问题可定位

其中,Kafka、Redis、MySQL、HTTP 和 Micro/gRPC 属于主要基础设施;Cron、fanout、errgroup 和 WaitGroup 更偏向进程内调度与并发协同。它们共同组成服务的运行基础,但解决的问题不同。

flowchart TB subgraph ACCESS["访问层"] HTTP["Gins / Blademaster HTTP"] RPC["Micro / gRPC"] end subgraph CORE["核心中间件"] REDIS[("Redis<br/>缓存、锁、变化标记")] MYSQL[("MySQL<br/>事实数据与价格结果")] KAFKA[("Kafka<br/>事件、任务、结果")] end subgraph RUNTIME["进程内协同"] CRON["Cron"] FANOUT["fanout"] CONCURRENCY["errgroup / WaitGroup"] CONFIG["配置中心 / 动态配置"] end subgraph OBSERVE["稳定性与可观测性"] TRACE["Jaeger / OpenTracing"] LOG["xlog"] METRIC["Kafka / 缓存指标"] end HTTP --> REDIS HTTP --> MYSQL RPC --> REDIS RPC --> MYSQL RPC --> KAFKA CRON --> REDIS CRON --> KAFKA FANOUT --> REDIS FANOUT --> KAFKA CONCURRENCY --> MYSQL CONCURRENCY --> RPC CONFIG --> CONCURRENCY CONFIG --> KAFKA HTTP -. "记录" .-> TRACE RPC -. "记录" .-> TRACE KAFKA -. "采集" .-> METRIC REDIS -. "采集" .-> METRIC MYSQL -. "记录" .-> LOG KAFKA -. "记录" .-> LOG

9.2 在线请求链路:缓存、数据库和 RPC 各自承担一段压力

在线请求的目标是尽快返回已经计算好的 SKU 价格,不把完整优惠计算放进每次请求。典型链路如下:

sequenceDiagram participant B as 商品业务 participant H as HTTP / gRPC 接口 participant R as Redis participant D as MySQL 价格表 participant S as 商品 / 用户 / 运费 RPC B->>H: 请求 SKU 价格与优惠 H->>R: 批量读取热点结果 alt 缓存命中 R-->>H: 返回价格结果 else 缓存未命中 H->>D: 按商品分表批量查询 D-->>H: 返回价格结果 H->>R: 异步回填缓存 end H->>S: 补充商品、用户、地区或运费上下文 S-->>H: 返回上下文 H-->>B: 组装价格与优惠响应

这样设计有三个原因:

  1. 重复读取优先由 Redis 承接,MySQL 只处理缓存未命中和后台写入;
  2. 复杂计算在 Job 阶段完成,在线接口主要做读取、补充上下文和展示组装;
  3. 外部 RPC 通过请求级 context 和超时设置控制等待时间,避免单个依赖无限拖长请求。

9.3 变更链路:Kafka 把突发变化和在线请求隔离开

价格变动不直接同步触发所有下游动作,而是通过 Kafka 将事件传给不同消费者。消费者再按批次读取、按业务 ID 合并、展开到 SKU、计算并落库。

flowchart LR SOURCE["开放接口渠道价格变动<br/>优惠关系 / 活动 / 税费变化"] --> PRODUCER["Kafka Producer"] PRODUCER --> TOPIC1[("任务 Topic")] TOPIC1 --> CONSUMER["Consumer Group"] CONSUMER --> ITER["KafkaIterator 批量读取"] ITER --> MERGE["BusinessId 合并"] MERGE --> LIMIT["errgroup / fanout 并发闸门"] LIMIT --> RPC2["外部 RPC 与规则计算"] RPC2 --> DB2[("MySQL")] DB2 --> CACHE2[("Redis 缓存失效")] CACHE2 --> RESULT["价格结果事件"] RESULT --> TOPIC2[("结果 Topic")] TOPIC2 --> DOWN["搜索、商品、活动等下游"]

Kafka 在这里解决的是时间和流量的解耦:

  • 上游变化突增时,消息先在 Kafka 中排队,Job 按自身处理能力消费;
  • 不同业务用不同 Topic 和 Consumer Group,互不共享消费进度;
  • 计算、价格落库和下游分发可以拆成独立阶段,某一阶段变慢时不会把在线 HTTP 请求同步阻塞;
  • 消费者通过批量读取、业务 ID 合并和并发上限减少重复计算与瞬时资源占用。

9.4 Redis 不只是缓存:还承担互斥和变化判断

Redis 在系统中有三类用途:

flowchart TB REDIS[("Redis")] REDIS --> HOT["热点缓存<br/>SKU 价格、优惠详情、活动数据"] REDIS --> LOCK["分布式锁<br/>SET NX EX"] REDIS --> CHANGE["变化标记<br/>比较前后价格或优惠"] HOT --> READ["在线请求快速读取"] LOCK --> CRON2["多个实例竞争定时任务"] CHANGE --> EVENT2["只对有效变化发送事件"]

为什么把这三类状态放在 Redis:

  • 热点价格读取需要低延迟和批量命令,适合 MGET、MSET 和 EXPIRE;
  • 定时任务的互斥状态需要多个实例共享,进程内锁无法覆盖多副本;
  • 变化标记只需要保存短时比较状态,不适合作为 MySQL 的核心业务记录。

Redis 也存在明确的一致性边界:数据库提交和缓存删除不是跨存储原子事务,价格写入完成后再执行独立的 Redis 删除操作。

9.5 MySQL 连接层:事务、批量写入和访问保护

MySQL 侧同时使用 GORM、GORM2、Raw SQL 等访问封装,分别服务于模型查询、事务操作和批量 SQL。价格结果写入时,会在事务中完成旧结果失效和新结果批量 upsert;优惠关系使用业务维度冲突更新;税费同步按 SKU 分批,并拆分新增、更新和删除。

数据库访问的稳定性边界可以这样理解:

flowchart TB DBREQ["数据库请求"] --> POOL["连接池"] POOL --> TIMEOUT["查询 / 执行 / 事务超时"] TIMEOUT --> BREAKER["部分 Raw SQL 配置的数据库熔断"] BREAKER --> QUERY["索引查询 / 批量 SQL / 事务"] QUERY --> SHARD["SKU 价格分表"] SHARD --> RESULT3["价格结果"]
  • 数据库连接使用连接池,并区分不同数据库和读写用途;
  • Raw SQL 配置中存在查询、执行、事务超时和 Breaker 参数;
  • 价格表按商品维度分表;
  • 价格写入使用事务和批量 upsert;
  • 部分数据库操作通过 Retry3、Retry5 或带退避的重试执行。

9.6 Cron 与 Kafka 的配合:时间驱动任务也进入统一计算链路

Cron 主要负责时间驱动的动作,例如刷新活动配置、加载未来价格配置、执行价格时间切换、全量校准和黑名单刷新。它不直接承担大规模同步计算,而是将扫描出的业务对象继续交给 Kafka 或受控的 Job 流程。

sequenceDiagram participant C as Cron participant R as Redis Lock participant DB as MySQL participant K as Kafka participant J as Job Worker C->>R: SET NX EX 竞争任务锁 alt 获得锁 R-->>C: 执行任务 C->>DB: 查询时间窗口或配置 C->>K: 投递计算任务 K->>J: 批量消费 J->>DB: 计算并保存价格结果 else 未获得锁 R-->>C: 跳过本次执行 end

设计重点是把“触发任务”和“处理任务”分开:Cron 只负责按时间触发和扫描,Kafka 负责排队,Job 负责按并发能力处理。这样多个服务副本同时运行时,Redis 锁避免重复触发,Kafka 又避免一次定时扫描把数据库和 RPC 瞬间打满。

9.7 稳定性保障不是单个中间件完成的

从调用链看,稳定性来自多层控制叠加:

flowchart TB REQUEST["请求或变更进入"] --> DEADLINE["context 超时边界"] DEADLINE --> CACHE_GATE["Redis 缓存优先"] CACHE_GATE --> MQ_GATE["Kafka 异步削峰"] MQ_GATE --> BATCH_GATE["批量读取与业务合并"] BATCH_GATE --> CONCURRENCY_GATE["errgroup / fanout 并发上限"] CONCURRENCY_GATE --> DB_GATE["连接池、超时、事务与分表"] DB_GATE --> RETRY_GATE["短暂失败重试"] RETRY_GATE --> CHANGE_GATE["变化检测"] CHANGE_GATE --> OBSERVE["日志、链路和指标"]
风险 主要控制手段 稳定性收益
在线重复读取过多 Redis MGET 与结果缓存 减少 MySQL 和外部 RPC 请求
价格变更瞬时放大 Kafka Topic 与 Consumer Group 将突发流量转为可消费的队列积压
同一对象重复计算 批次内 BusinessId 合并、变化标记 减少重复计算和无效广播
外部依赖响应慢 context、RPC 超时、数据库超时 限制单次请求占用资源的时间
消费者协程过多 errgroup.GOMAXPROCS、fanout、动态参数 控制 CPU、连接池和下游访问压力
定时任务多副本重复执行 Redis SET NX EX 保证同一任务窗口只由一个实例推进
短暂网络或存储错误 Retry3、Retry5、退避重试 提高瞬时故障下的恢复能力
错误难以定位 Jaeger / OpenTracing、xlog、业务指标 能区分请求慢、消费慢、数据库慢和下游慢

API 启动配置显式设置了 micro.Retries(0) 和 micro.Breaker(false);API 侧使用请求超时、缓存和业务层错误处理,数据库 Raw SQL 配置、Job 重试和 Kafka 解耦分别承担数据库访问保护、短暂失败重试和异步削峰。

9.8 为什么选择这套组合

这套组合的核心取舍是把不同类型的问题交给不同工具:

低延迟读取 -> Redis
可靠保存与事务 -> MySQL
突发变化削峰 -> Kafka
跨服务能力复用 -> Micro / gRPC
时间窗口触发 -> Cron
进程内并发与退出 -> errgroup / fanout / WaitGroup
问题定位与容量判断 -> Trace / Log / Metric

这样设计后,在线链路通过缓存读取价格结果,异步链路通过 Kafka 承接变更任务,数据库负责持久化,Job 负责批量计算、落库和结果分发。

10. Go 实现细节:Job 如何落到代码

Job 的 Go 实现可以按“批次边界、并发边界、时间边界、退出边界”来理解:

flowchart LR MSG["Kafka 消息"] --> BATCH["KafkaIterator 批量读取"] BATCH --> DEDUP["Business + BusinessId 合并"] DEDUP --> CONCURRENCY["errgroup / fanout 受控并发"] CONCURRENCY --> CONTEXT["context 超时与取消"] CONTEXT --> RETRY["Retry3 / Retry5"] RETRY --> RESULT["计算、落库、消息发布"] RESULT --> WAIT["WaitGroup 等待退出"]

10.1 一批消息,一次处理

Job 的消息消费不是单条读取后立即开协程,而是先用批量迭代器取出一批消息:

func KafkaIterator(
    session *kafka.ConsumerSession,
    msgs <-chan *sarama.ConsumerMessage,
    num int,
) func() ([]*sarama.ConsumerMessage, bool)

业务侧的使用方式可以抽象为:

next := KafkaIterator(session, msgs, batchSize)

for {
    messages, ok := next()
    if !ok {
        return nil
    }

    grouped := mergeByBusinessID(messages)
    processBatch(grouped)
}

对应的处理链路:

flowchart LR STREAM["Kafka 消息流"] --> ITER["KafkaIterator"] ITER --> BATCH["批量消息"] BATCH --> PARSE["反序列化"] PARSE --> GROUP["按业务类型 + BusinessId 合并"] GROUP --> HANDLE["并发处理业务对象"] HANDLE --> COMMIT["消费进度提交"]

这样做的重点是:

  • 减少单条消息的调度和序列化开销;
  • 同一批内合并同一个业务对象的重复事件;
  • 把并发控制放在批次处理层;
  • 保持消息消费、业务处理和进度提交的边界。

KafkaIterator 的提交时机也值得讲清楚:当前批次处理完成后,下一次调用迭代器时才提交上一批的最后一条消息;批次最多取满 num 条,或者等待 5 秒后提前返回。消费者重启时可能重复处理,价格写入和结果变化检测用于处理重复结果。

10.2 errgroup:并行执行,也能统一等待

多类 Worker 都使用 errgroup 组织并发任务:

eg := errgroup.WithContext(ctx)
eg.GOMAXPROCS(workerNum)

for _, item := range items {
    item := item
    eg.Go(func(ctx context.Context) error {
        return handle(ctx, item)
    })
}

if err := eg.Wait(); err != nil {
    return err
}

在这个模式下:

flowchart TB BATCH["一批业务对象"] --> EG["errgroup"] EG --> W1["并发任务 1"] EG --> W2["并发任务 2"] EG --> W3["并发任务 N"] W1 --> WAIT["Wait 统一等待"] W2 --> WAIT W3 --> WAIT WAIT --> NEXT["进入下一批"]
  • WithContext 让任务共享取消信号;
  • GOMAXPROCS 为这一组任务设置并发上限;
  • Wait 形成批次边界,避免无限制堆积;
  • SkuToCouponHandler 的闭包会记录 Process 错误并返回 nil,因此单个业务对象处理失败时,当前批次会继续等待其他任务完成。

10.3 多级并发控制

这套 Job 不是只有一个并发参数,而是多级控制:

flowchart TB EVENT["开放接口价格变动事件"] --> BATCH["Kafka 批量大小"] BATCH --> DEDUP["业务 ID 合并"] DEDUP --> EG["errgroup 并发上限"] EG --> FANOUT["fanout Worker 数"] FANOUT --> RPC["外部 RPC / 数据库访问"] RPC --> RESULT["价格结果"] RESULT --> OUT["消息分发并发上限"]

代码中可以看到的典型组合:

fanout.New("worker-name", fanout.Worker(workerNum))
eg.GOMAXPROCS(dynamicWorkerNum)
time.Sleep(backoff)
环节 批量 / 并发控制
计算任务缓冲 默认批量约 50;积压时可等待 calPoolSleep 秒,并降低转发并发
Calculate 默认批量约 500;skuChanCount 默认约 5
activity 结果物化 默认批量约 360;价格结果并发默认约 15
开放接口同步 事件处理 fanout 约 5,结果推送 fanout 约 30

这些机制共同控制消息批量、协程数量、外部访问压力和结果发布速度。

10.4 context:给 RPC 和批处理设置边界

在线价格查询会给请求设置超时:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

异步任务则沿着消息处理链传递 context:

flowchart LR ROOT["消费回调 Context"] --> CHILD["errgroup.WithContext"] CHILD --> RPC1["商品 / 渠道 RPC"] CHILD --> RPC2["运费 / 税费 RPC"] CHILD --> DB["数据库访问"] CHILD --> CANCEL["超时或取消"] CANCEL -. "停止后续工作" .-> RPC1 CANCEL -. "停止后续工作" .-> RPC2 CANCEL -. "停止后续工作" .-> DB

10.5 重试:处理短暂性失败

公共重试方法采用固定次数重试,并在两次尝试之间等待:

func Retry3(callback func() error) error
func Retry5(callback func() error) error

使用场景包括数据库写入、消息发送、外部服务调用和锁释放:

flowchart LR CALL["数据库 / RPC / 消息调用"] --> OK{"成功?"} OK -- "是" --> DONE["继续流程"] OK -- "否" --> RETRY{"是否还有重试次数?"} RETRY -- "是" --> WAIT["等待后重试"] WAIT --> CALL RETRY -- "否" --> ERROR["记录错误并结束当前处理"]

10.6 WaitGroup 与优雅退出

消息回调进入时增加计数,退出时释放计数;服务关闭时先停止消费者,再等待正在执行的任务结束:

sequenceDiagram participant S as Service participant C as Consumer participant W as WaitGroup participant H as Handler S->>C: Start C->>H: 收到开放接口价格变动事件 H->>W: Add(1) S->>C: Close,停止接收新消息 H->>H: 完成当前批次 H->>W: Done() S->>W: Wait() W-->>S: 所有任务退出

10.7 Cron + Redis 锁:定时任务只允许一个实例执行

flowchart LR CRON["Cron 定时触发"] --> LOCK{"Redis 分布式锁"} LOCK -- "获得锁" --> TASK["扫描时间窗口 / 发送计算任务"] LOCK -- "未获得锁" --> SKIP["本次跳过"] TASK --> RETRY["失败重试"] RETRY --> RELEASE["释放锁"]

这类设计用于全量校准、价格时间切换、黑名单刷新和其他需要避免重复执行的任务。

11. 数据库设计与高并发承接

数据库在这个系统里承担的是“业务事实与计算结果的可靠保存”,在线请求和突发变更不会直接全部压到数据库上。整体思路可以概括为:

MySQL 保存可追溯的数据,Redis 承接热点读取,Kafka 隔离变更洪峰,Job 负责批量计算与写入。

这四个组件分别解决不同问题:

组件 主要职责 访问特点
MySQL 优惠券、优惠关系、活动、税费、SKU 价格结果等权威数据 事务写入、条件查询、结果持久化
Redis SKU 价格缓存、优惠详情缓存、分布式锁、变化标记 高频读取、短时状态、任务互斥
Kafka 变更事件、计算任务、价格结果事件 异步解耦、削峰、可重放
Job 标准化、关系维护、价格计算、结果落库与分发 批量消费、受控并发、失败重试

11.1 数据域模型:事实数据、关系数据、结果数据分开

业务数据可以按用途拆成四个数据域。优惠券本身描述“有什么优惠”,优惠关系描述“适用于谁”,活动和税费描述“在什么条件下计算”,SKU 价格结果描述“计算后给在线业务使用什么”。

flowchart TB subgraph FACT["业务事实数据"] COUPON[("优惠券主数据<br/>类型、门槛、面额、时间")] RELATION[("优惠关系<br/>Business + BusinessId + CouponId")] ACTIVITY[("活动配置<br/>活动价、定金、预热时间")] TAX[("税费数据<br/>渠道、SKU、税率")] end subgraph RESULT["计算结果数据"] SKU_PRICE[("SKU 价格结果<br/>当前价、未来价、身份价")] EXPLAIN[("优惠明细<br/>券 ID、优惠公式、凑单信息")] end subgraph SERVE["在线服务数据"] CACHE[("Redis<br/>热点价格与优惠详情")] EVENT[("Kafka<br/>计算任务与价格事件")] end COUPON --> RELATION RELATION --> SKU_PRICE ACTIVITY --> SKU_PRICE TAX --> SKU_PRICE SKU_PRICE --> EXPLAIN SKU_PRICE --> CACHE SKU_PRICE --> EVENT
  • SKU 价格结果不是只保存一个数值,还包含原价、当前价、优惠券 ID、活动时间、预热时间、税费和凑单扩展信息;
  • 优惠关系以 business + business_id + coupon_id 作为冲突处理维度,写入时采用批量创建和冲突更新;
  • 删除多采用状态字段保留数据生命周期,在线查询通过 is_deleted 过滤;
  • 价格查询的主键入口是 SKU,价格表通过商品维度进行分表。

这里的分层很重要:在线服务不需要每次重新组合全部优惠规则,Job 先把复杂计算收敛为 SKU 级结果,API 主要负责读取、补充上下文和组装响应。

11.2 价格表分表:把数据规模和查询范围同时收窄

价格数据的访问天然以商品和 SKU 为入口。源码通过 PriceTableByGoodsId(goodsId, tableNum) 计算价格表名,读取时先按商品 ID 将 SKU 分组,再对不同价格表并行查询。

flowchart LR REQUEST["批量 SKU 查询"] --> GROUP["按商品 ID 计算目标价格表"] GROUP --> T1[("价格表 00")] GROUP --> T2[("价格表 01")] GROUP --> T3[("价格表 ...")] T1 --> MERGE["合并 SKU 价格结果"] T2 --> MERGE T3 --> MERGE MERGE --> RESPONSE["返回在线服务"]

分表带来的收益可以从三个层面理解:

  1. 单表数据规模被拆开,单表索引和扫描范围更可控;
  2. 一次批量请求只访问实际涉及的价格表,避免全库扫描;
  3. 不同价格表可以在受控并发下并行读取,提高批量查询的整体效率。

11.3 在线读链路:Redis 先挡住热点流量

当前价格查询的核心路径是批量读缓存,只有未命中的 SKU 才回源价格表。数据库回源完成后,再通过 fanout 异步批量回填 Redis。

sequenceDiagram participant API as API / RPC participant R as Redis participant G as Go 查询层 participant DB as SKU 价格表 participant F as Fanout API->>G: 批量查询 SKU 价格 G->>R: MGET 多个 SKU Key R-->>G: 命中结果 + 未命中 SKU G->>G: 按商品维度分组价格表 par 多分表并行回源 G->>DB: 查询价格表 00 G->>DB: 查询价格表 01 G->>DB: 查询价格表 ... end DB-->>G: 返回未命中价格 G->>F: 异步回填 F->>R: MSET + EXPIRE G-->>API: 合并后的价格结果

源码中已经体现的承压手段:

  • 使用 Redis MGET,减少逐个 SKU 访问 Redis 的网络往返;
  • 只对缓存未命中的 SKU 查询 MySQL,降低数据库读放大;
  • 用 tableNameGroup 将 SKU 分配到目标价格表;
  • 使用 errgroup.WithCancel 和受控并发并行读取多个价格表;
  • 使用批量 MSET 和过期时间回填缓存,避免把回填动作串行放在主请求里。
请求压力 -> Redis 批量命中 -> 少量未命中回源 -> 多表并行读取 -> 异步回填

这条链路中,Redis 通过 MGET 承接批量价格读取,缓存未命中的 SKU 再按商品维度分组查询价格表,查询结果通过 MSET 和 EXPIRE 回填 Redis。

11.4 异步写链路:Kafka 负责削峰,Job 负责落库

开放接口渠道价格变动、活动调整、优惠关系变化和定时切换,都可能在短时间内影响大量 SKU。如果每个变更都同步触发完整计算,数据库和外部 RPC 会同时出现突刺。当前架构把这些变化先转成消息,再由 Job 批量消费。

flowchart TB CHANGE["开放接口渠道价格变动<br/>活动 / 关系 / 税费变化"] --> KAFKA[("Kafka")] KAFKA --> CONSUME["批量消费"] CONSUME --> MERGE["按业务类型 + BusinessId 合并"] MERGE --> EXPAND["展开到受影响 SKU"] EXPAND --> CALC["受控并发计算"] CALC --> TX["MySQL 事务写入"] TX --> INVALIDATE["删除 SKU 价格缓存"] INVALIDATE --> PRICE_EVENT["发布价格变化事件"] PRICE_EVENT --> DOWNSTREAM["搜索、商品、活动等下游"]

数据库写入的关键边界是:

sequenceDiagram participant J as Job participant DB as MySQL participant R as Redis participant K as Kafka J->>DB: 开启 SKU 价格事务 J->>DB: 标记该 SKU 旧的有效价格为删除 J->>DB: 批量 INSERT ... ON DUPLICATE KEY UPDATE DB-->>J: 提交事务 J->>R: 删除该 SKU 价格缓存 J->>K: 发送价格结果与下游事件

价格落库采用“旧结果失效 + 新结果批量 upsert + 调用事务提交 + 删除缓存”的处理顺序。正常提交场景下,它带来两个实际效果:

  • 同一 SKU 的多个时间区间可以一次性写入,减少逐行 SQL;
  • 缓存删除动作位于事务提交调用之后,降低数据库已更新但缓存仍长期保留旧结果的概率。

数据库提交和 Redis 删除属于两个独立操作,不构成跨存储原子事务;Redis 删除失败时,旧缓存可能继续存在。

11.5 批量写入与幂等:让重复事件变得可控

大流量场景中,消息重复、同一对象短时间多次变化和消费者重启都属于正常情况。系统通过三层手段降低重复处理成本:

flowchart LR MESSAGES["重复或密集的变更消息"] --> BATCH["Kafka 批量读取"] BATCH --> DEDUP["同一批按 BusinessId 合并"] DEDUP --> UPSERT["数据库批量 upsert"] UPSERT --> DIFF["变化标记"] DIFF -- "无变化" --> QUIET["减少缓存失效与下游广播"] DIFF -- "有变化" --> PUBLISH["发布价格变化事件"]

对应的数据库设计原则:

场景 设计方式 作用
优惠关系重复写入 业务维度唯一冲突更新 重复消息不会无限产生重复关系
SKU 价格重复计算 旧有效记录状态化,再批量 upsert 结果结构稳定,便于按 SKU 查询
价格无实际变化 Redis 变化标记比较 减少无效消息和下游刷新
大批量税费同步 SKU ID 分批读取,新增/更新/删除拆分 控制 SQL 参数量和单次事务规模
多实例定时任务 Redis 分布式锁 避免同一时间窗口重复扫描
posted @ 2026-09-04 15:50  alin_qu  阅读(24)  评论(0)    收藏  举报