商品价格与优惠中台
![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["优惠明细与可解释价格公式"]
它解决的核心问题可以归纳为四个层面:
- 统一价格口径:把 SKU、渠道、店铺和 URL 等不同影响范围的规则,最终收敛到 SKU 级计算结果。
- 解决价格时效问题:把价格、优惠和活动的时间边界切成连续区间,传入不同时间就能命中不同价格,支撑当前价、未来价和秒切。
- 保证价格计算正确:通过
PriceByStore 和 PricePayByStore 保留不同商城的叠加、互斥、定金、税费和凑单规则。
- 支撑大规模价格变更:通过 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: 组装价格与优惠响应
这样设计有三个原因:
- 重复读取优先由 Redis 承接,MySQL 只处理缓存未命中和后台写入;
- 复杂计算在 Job 阶段完成,在线接口主要做读取、补充上下文和展示组装;
- 外部 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["返回在线服务"]
分表带来的收益可以从三个层面理解:
- 单表数据规模被拆开,单表索引和扫描范围更可控;
- 一次批量请求只访问实际涉及的价格表,避免全库扫描;
- 不同价格表可以在受控并发下并行读取,提高批量查询的整体效率。
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 分布式锁 |
避免同一时间窗口重复扫描 |