Java中大数据实时归集与指标汇总方案
文章目录
- Java中大数据实时归集与指标汇总方案
-
- 要解决的问题与结论
- [为什么"预聚合 + 实时扫明细"仍会失速](#为什么“预聚合 + 实时扫明细”仍会失速)
- 目标架构:把明细变化变成可重放的增量事件
- 核心机制:增量、幂等与迟到修正如何同时成立
- 采集和存储如何选:先按一致性与规模,而不是按技术热度
- 查询服务为什么必须只读小结果集
- [端到端 Demo:一笔订单如何安全变成实时 GMV](#端到端 Demo:一笔订单如何安全变成实时 GMV)
-
- [1. 建表:状态表保存旧贡献,桶表保存展示结果](#1. 建表:状态表保存旧贡献,桶表保存展示结果)
- [2. 输入事件:统一为"订单有效快照"而不是各来源 SQL](#2. 输入事件:统一为“订单有效快照”而不是各来源 SQL)
- [3. 聚合器:锁住订单状态,先减旧贡献再加新贡献](#3. 聚合器:锁住订单状态,先减旧贡献再加新贡献)
- [4. API:写入口模拟消费者,读入口只扫桶表](#4. API:写入口模拟消费者,读入口只扫桶表)
- [5. 用四次请求验证重复、改价和退款](#5. 用四次请求验证重复、改价和退款)
- [6. 接入真实源库:用复合游标替换 HTTP 输入](#6. 接入真实源库:用复合游标替换 HTTP 输入)
- [标准 Java 工程闭环:接口、Outbox、归集、查询和测试](#标准 Java 工程闭环:接口、Outbox、归集、查询和测试)
-
- 工程边界与目录
- 一笔真实订单的闭环时序
- [1. 依赖、配置和迁移组织](#1. 依赖、配置和迁移组织)
- [2. 数据库迁移:七张表分别承担事实、投递、投影和历史汇总](#2. 数据库迁移:七张表分别承担事实、投递、投影和历史汇总)
- [订单事件契约:两端只共享稳定 JSON,不共享数据库实体](#订单事件契约:两端只共享稳定 JSON,不共享数据库实体)
- [3. 订单接口:业务状态和 Outbox 必须同事务提交](#3. 订单接口:业务状态和 Outbox 必须同事务提交)
- [4. Outbox 发布器:认领、发布、确认三阶段可重试](#4. Outbox 发布器:认领、发布、确认三阶段可重试)
- [4.5 日桶固化:让历史查询与实时分钟桶保持互斥](#4.5 日桶固化:让历史查询与实时分钟桶保持互斥)
- [5. 归集消费者:收件箱先去重,再锁订单状态并回退旧桶](#5. 归集消费者:收件箱先去重,再锁订单状态并回退旧桶)
- [6. 查询、缓存和水位:接口返回值必须表明数据新鲜度](#6. 查询、缓存和水位:接口返回值必须表明数据新鲜度)
- [7. HTTP 契约:同步命令与异步投影不可混用](#7. HTTP 契约:同步命令与异步投影不可混用)
- [8. 自动化验证:用真实 MySQL 验证事务,而非只用 curl](#8. 自动化验证:用真实 MySQL 验证事务,而非只用 curl)
- [9. 启动顺序与本地验收脚本](#9. 启动顺序与本地验收脚本)
- [10. 标准闭环的测试矩阵与上线边界](#10. 标准闭环的测试矩阵与上线边界)
- 失败路径、对账与边界:快不等于数据可信
- 面向存量系统的最小迁移路径
- 验证、容量验收与上线门槛
- 结论:先将"计算一次"变成系统不变量,再追求秒级展示
内容摘要:本文面向订单、GMV、库存等需要秒级至分钟级展示的大数据归集场景。核心判断是:高频查询不能反复跨库扫描原始明细;应将变化转换为可重放、可幂等的增量事件,持续写入分钟级聚合结果,并让接口只读该结果与短缓存。文中给出数据契约、失败恢复、选型边界、迁移步骤和验收方法。
要解决的问题与结论
典型诉求是"每隔几秒展示最新 GMV,同时保留全历史累计、按门店/渠道拆分,并且数据量持续增长"。这里最容易出现的错误前提是:把刷新频率等同于汇总能力。刷新得更快只会按比例放大原始表扫描、跨库网络往返、GROUP BY 临时表和连接占用;它不会让一条全量聚合 SQL 变快。
通用结论:查询路径必须与明细规模解耦。在线请求只读取预聚合结果;数据归集通过增量事件驱动,按可修正的时间桶累积。若不能立刻引入 CDC 或消息队列,也应先用游标拉取实现增量化,而不是缩短全量 SQL 的定时周期。
本文采用一个虚构的订单 GMV 场景说明设计,适用于订单金额、支付笔数、库存变化、物流状态计数等加法型或可按维度汇总的指标。它不承诺替代需要逐笔明细审计、任意筛选或强事务一致读的业务查询。
为什么"预聚合 + 实时扫明细"仍会失速
预聚合将历史数据从"按订单行求和"缩小为"按时间桶和维度求和",这是正确方向;问题在于不少系统仍会在每次页面刷新时,跨库读取当前小时或当前分钟的原始订单。设原始订单到达速率为 R 行/秒、实时窗口为 W 秒、接口并发刷新数为 Q 次/秒,则重复参与当前窗口聚合的行读取量近似为:
text
重复扫描量 ≈ R × W × Q
当窗口按当前小时累计时,W 会随时间变大;当大屏、多个用户或重试同时轮询时,Q 也会上升。即便时间列有索引,按小时格式化再分组仍需要额外计算和聚合。根因不是调度器线程数不足,而是"同一批原始数据被不同请求反复计算"。
另有一个必须先核对的口径风险:如果月、日、小时汇总表覆盖的时间范围彼此重叠,再与当前明细直接相加,会重复计入同一订单。聚合层级不是天然互斥的;必须由时间边界或统一的权威汇总表保证互斥。
这是一条通用的架构推导,不是对某个线上系统性能的判断。是否真的出现慢查询,应通过 EXPLAIN ANALYZE、扫描行数、p95 延迟、CPU 与锁等待等数据验证。
目标架构:把明细变化变成可重放的增量事件
#mermaid-svg-pTquJmWqURbCrvTr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pTquJmWqURbCrvTr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pTquJmWqURbCrvTr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pTquJmWqURbCrvTr .error-icon{fill:#552222;}#mermaid-svg-pTquJmWqURbCrvTr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pTquJmWqURbCrvTr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pTquJmWqURbCrvTr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pTquJmWqURbCrvTr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pTquJmWqURbCrvTr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pTquJmWqURbCrvTr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pTquJmWqURbCrvTr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pTquJmWqURbCrvTr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pTquJmWqURbCrvTr .marker.cross{stroke:#333333;}#mermaid-svg-pTquJmWqURbCrvTr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pTquJmWqURbCrvTr p{margin:0;}#mermaid-svg-pTquJmWqURbCrvTr .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pTquJmWqURbCrvTr .cluster-label text{fill:#333;}#mermaid-svg-pTquJmWqURbCrvTr .cluster-label span{color:#333;}#mermaid-svg-pTquJmWqURbCrvTr .cluster-label span p{background-color:transparent;}#mermaid-svg-pTquJmWqURbCrvTr .label text,#mermaid-svg-pTquJmWqURbCrvTr span{fill:#333;color:#333;}#mermaid-svg-pTquJmWqURbCrvTr .node rect,#mermaid-svg-pTquJmWqURbCrvTr .node circle,#mermaid-svg-pTquJmWqURbCrvTr .node ellipse,#mermaid-svg-pTquJmWqURbCrvTr .node polygon,#mermaid-svg-pTquJmWqURbCrvTr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pTquJmWqURbCrvTr .rough-node .label text,#mermaid-svg-pTquJmWqURbCrvTr .node .label text,#mermaid-svg-pTquJmWqURbCrvTr .image-shape .label,#mermaid-svg-pTquJmWqURbCrvTr .icon-shape .label{text-anchor:middle;}#mermaid-svg-pTquJmWqURbCrvTr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pTquJmWqURbCrvTr .rough-node .label,#mermaid-svg-pTquJmWqURbCrvTr .node .label,#mermaid-svg-pTquJmWqURbCrvTr .image-shape .label,#mermaid-svg-pTquJmWqURbCrvTr .icon-shape .label{text-align:center;}#mermaid-svg-pTquJmWqURbCrvTr .node.clickable{cursor:pointer;}#mermaid-svg-pTquJmWqURbCrvTr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pTquJmWqURbCrvTr .arrowheadPath{fill:#333333;}#mermaid-svg-pTquJmWqURbCrvTr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pTquJmWqURbCrvTr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pTquJmWqURbCrvTr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pTquJmWqURbCrvTr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pTquJmWqURbCrvTr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pTquJmWqURbCrvTr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pTquJmWqURbCrvTr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pTquJmWqURbCrvTr .cluster text{fill:#333;}#mermaid-svg-pTquJmWqURbCrvTr .cluster span{color:#333;}#mermaid-svg-pTquJmWqURbCrvTr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pTquJmWqURbCrvTr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pTquJmWqURbCrvTr rect.text{fill:none;stroke-width:0;}#mermaid-svg-pTquJmWqURbCrvTr .icon-shape,#mermaid-svg-pTquJmWqURbCrvTr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pTquJmWqURbCrvTr .icon-shape p,#mermaid-svg-pTquJmWqURbCrvTr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pTquJmWqURbCrvTr .icon-shape .label rect,#mermaid-svg-pTquJmWqURbCrvTr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pTquJmWqURbCrvTr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pTquJmWqURbCrvTr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pTquJmWqURbCrvTr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 优先:CDC 或 Outbox
过渡:时间游标拉取
订单/支付/退款源库
变化采集
事件流或持久化事件表
标准化与幂等去重
分钟级聚合器
gmv_realtime_bucket
聚合水位与处理状态
Redis 短 TTL 缓存
小时/天/月离线汇总
GMV 查询 API
读图要点:明细数据只在归集链路中处理一次或在故障后有限重放;API 不再访问业务原始订单表。分钟桶是查询与写入之间的隔离层,因此数据规模增长主要影响归集吞吐,而不会线性拖慢所有读请求。
归集链路的职责边界
| 组件 | 唯一职责 | 不应承担的职责 |
|---|---|---|
| 变化采集器 | 获取新增或变更订单,记录来源位点 | 对外提供 GMV |
| 标准化器 | 将不同库字段映射为统一事件和金额口径 | 永久保存任意原始业务字段 |
| 幂等层 | 判断同一订单版本是否已处理 | 用内存集合临时去重 |
| 聚合器 | 将事件增量写入时间桶 | 每次从全部订单重新统计 |
| 服务层 | 合并少量桶、缓存并返回展示模型 | 跨业务库 UNION ALL 原始明细 |
| 对账任务 | 按可控窗口重算并修正 | 作为实时展示的常规路径 |
推荐的数据契约
不同来源不能只靠"创建时间 + 金额"合并,因为补发、退款、状态变更和来源时钟偏差都会造成重复或漏算。统一事件至少需要以下字段:
| 字段 | 约束 | 用途 |
|---|---|---|
source |
非空,稳定来源标识 | 多库隔离与幂等键的一部分 |
business_order_id |
非空 | 业务订单身份 |
version |
单订单内单调递增;没有版本时使用可靠变更时间和内容哈希 | 识别后到、重放和修正 |
event_id |
全局唯一,或由来源位点生成 | 消息去重、追踪 |
event_type |
创建、支付、取消、退款、修正 | 明确金额是否需要反向计入 |
occurred_at |
业务发生时间,统一时区 | 归属到分钟桶 |
ingested_at |
采集时间 | 衡量延迟与排障 |
amount_delta |
DECIMAL(18,2),可正可负 |
聚合时直接累加 |
store_id、channel_id |
维度键,空值有明确兜底 | 维度聚合 |
金额不应使用浮点数或字符串。GMV 的会计口径还需在归集前定义:统计支付成功、扣除退款,还是统计下单金额;每种口径应生成独立指标或明确的事件转换规则,不能在查询时临时猜测。
核心机制:增量、幂等与迟到修正如何同时成立
以"有效一次"代替不现实的端到端恰好一次
网络超时、消费者重启和数据库提交成功但确认失败都可能导致同一事件被再次投递。与其假设消息只到一次,不如把结果写入设计成"重复执行结果不变"。
建议在归集库保存订单处理状态,并在同一事务内更新状态与时间桶:
sql
-- 示例结构,可按业务维度扩展
CREATE TABLE gmv_order_state (
source VARCHAR(32) NOT NULL,
business_order_id VARCHAR(64) NOT NULL,
version BIGINT NOT NULL,
applied_amount DECIMAL(18,2) NOT NULL,
bucket_start DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (source, business_order_id)
);
CREATE TABLE gmv_realtime_bucket (
bucket_start DATETIME NOT NULL,
source VARCHAR(32) NOT NULL,
store_id VARCHAR(64) NOT NULL,
channel_id VARCHAR(64) NOT NULL,
order_count BIGINT NOT NULL,
amount DECIMAL(18,2) NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (bucket_start, source, store_id, channel_id),
KEY idx_bucket_dimension (bucket_start, channel_id, store_id)
);
处理一条事件时先锁定或条件更新 gmv_order_state:新版本才计算增量 new_amount - applied_amount,再对新旧桶分别 UPSERT。这样重复事件因版本未增加而无副作用;退款、取消和跨分钟修正则通过负增量或旧桶回退得到正确结果。这里的关键不变量是:订单状态中的已应用金额,必须等于该订单所有已提交聚合变更的总和。
如果只用 INSERT ... ON DUPLICATE KEY UPDATE amount = amount + 新金额,重复投递仍会重复累计,不能称为幂等。
迟到事件不能靠"关闭窗口后丢弃"处理
订单创建时间、支付回调和 CDC 到达时间可能不同。应区分事件时间与处理时间:
- 当前开放窗口:例如最近 10 分钟,允许持续修正;
- 延迟容忍窗口:例如最近 24 小时,允许迟到事件回写历史分钟桶;
- 已封账窗口:正常事件不再直接改写,改写时记录修正流水并触发日级对账。
窗口长度不是固定最佳实践,而是由上游最大延迟、退款时效、展示新鲜度和存储成本共同决定。若退款允许 T+7,不能宣称日桶永久不可变;应明确"展示值"和"财务结算值"是否使用相同口径。
位点推进必须与落库结果绑定
无论是 CDC 位点还是轮询游标,都只能在事件状态和聚合结果成功提交后推进。反过来,先推进游标再异步写聚合,会在进程崩溃时永久漏数。
最小状态机如下:
#mermaid-svg-jaV64NwKsMAwYRp0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jaV64NwKsMAwYRp0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jaV64NwKsMAwYRp0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jaV64NwKsMAwYRp0 .error-icon{fill:#552222;}#mermaid-svg-jaV64NwKsMAwYRp0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jaV64NwKsMAwYRp0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jaV64NwKsMAwYRp0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jaV64NwKsMAwYRp0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jaV64NwKsMAwYRp0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jaV64NwKsMAwYRp0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jaV64NwKsMAwYRp0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jaV64NwKsMAwYRp0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jaV64NwKsMAwYRp0 .marker.cross{stroke:#333333;}#mermaid-svg-jaV64NwKsMAwYRp0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jaV64NwKsMAwYRp0 p{margin:0;}#mermaid-svg-jaV64NwKsMAwYRp0 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-jaV64NwKsMAwYRp0 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-jaV64NwKsMAwYRp0 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-jaV64NwKsMAwYRp0 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-jaV64NwKsMAwYRp0 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-jaV64NwKsMAwYRp0 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-jaV64NwKsMAwYRp0 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-jaV64NwKsMAwYRp0 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-jaV64NwKsMAwYRp0 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-jaV64NwKsMAwYRp0 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-jaV64NwKsMAwYRp0 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-jaV64NwKsMAwYRp0 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-jaV64NwKsMAwYRp0 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-jaV64NwKsMAwYRp0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jaV64NwKsMAwYRp0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jaV64NwKsMAwYRp0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jaV64NwKsMAwYRp0 .edgeLabel .label text{fill:#333;}#mermaid-svg-jaV64NwKsMAwYRp0 .label div .edgeLabel{color:#333;}#mermaid-svg-jaV64NwKsMAwYRp0 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-jaV64NwKsMAwYRp0 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-jaV64NwKsMAwYRp0 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-jaV64NwKsMAwYRp0 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-jaV64NwKsMAwYRp0 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-jaV64NwKsMAwYRp0 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jaV64NwKsMAwYRp0 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jaV64NwKsMAwYRp0 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jaV64NwKsMAwYRp0 .cluster-label,#mermaid-svg-jaV64NwKsMAwYRp0 .nodeLabel{color:#131300;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-jaV64NwKsMAwYRp0 .note-edge{stroke-dasharray:5;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-note text{fill:black;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagram .edgeLabel{color:red;}#mermaid-svg-jaV64NwKsMAwYRp0 #dependencyStart,#mermaid-svg-jaV64NwKsMAwYRp0 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-jaV64NwKsMAwYRp0 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jaV64NwKsMAwYRp0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 事件状态与桶写入同一事务成功
网络、锁冲突或临时数据库错误
指数退避后重试
安全推进来源位点
字段非法或无法解析
Fetched
Applied
Retryable
Checkpointed
DeadLetter
这张图揭示的约束是:Checkpointed 不能先于 Applied。无法解析的数据进入死信或人工处理队列,而不是被静默跳过;否则"实时 GMV 正常返回"会掩盖数据缺口。
采集和存储如何选:先按一致性与规模,而不是按技术热度
| 方案 | 适用条件 | 优点 | 主要失败模式 | 建议 |
|---|---|---|---|---|
| 高频全表/大窗口轮询 | 只有很小数据量、短期排障 | 实现最少 | 重复扫描、源库受压、延迟随窗口增长 | 不作为长期方案 |
| 增量游标轮询 | 无 CDC 权限、源表有可靠递增键或更新时间 | 可快速改造、低侵入 | 时间相同排序不稳定、更新覆盖、游标提前推进 | 过渡首选 |
| Binlog CDC | 多库订单持续增长、可获得数据库权限 | 低扫描、延迟低、可重放 | 位点管理、表结构变更、删除/更新语义 | 中长期首选 |
| 业务 Outbox + 消息队列 | 可改交易服务且要求较强业务语义 | 事件含义最清晰、与事务可协同 | 改造交易链路、补历史复杂 | 新建系统优先 |
| 流计算框架 | 多指标、多维度、吞吐量高 | 状态、窗口、扩缩容能力强 | 运维与状态一致性成本高 | 事件量和指标复杂度足够时采用 |
决策顺序应是:先确认源端能否产生可靠变更,再选采集方式;先确定订单修正语义,再选聚合技术。Kafka、Flink 或 ClickHouse 都不是修复重复计数的前提条件,缺少事件版本和幂等约束时,它们只会更快地放大错误。
查询服务为什么必须只读小结果集
在线 API 的目标是读取规模稳定的结果,而不是展示"数据库此刻还能算出多少"。建议按展示粒度查询:
text
历史累计 = 已封账的日/月汇总
+ 当日已封账分钟桶
+ 当前开放分钟桶
三个集合必须时间互斥。若展示只要总 GMV,可以把每分钟桶再滚动维护为当日总桶;若需要渠道、门店或分时曲线,则按必要维度读取有限数量的分钟桶后在归集库汇总。绝不在 API 路径重扫各业务库订单表。
Redis 的职责是削峰而不是数据真相:可按 metric + tenant + dimensions + metric_version 缓存 1~5 秒,并在桶写成功后删除或更新对应键。缓存失效时回源 gmv_realtime_bucket,而不是回源原始订单表。对于同一个热点键,应使用请求合并或短互斥锁避免缓存失效瞬间的并发回源。
API 返回建议显式包含 as_of、watermark、data_delay_seconds 和 is_degraded。这样采集器滞后时,客户端能区分"GMV 为零"和"数据还未归集完成"。
端到端 Demo:一笔订单如何安全变成实时 GMV
这一节给出一个单实例下用于解释核心算法 的 Spring Boot 3 / Java 17 最小 Demo。它不依赖 Kafka:POST /demo/gmv/events 模拟 CDC 或消息消费者收到的"订单当前有效快照"。替换 HTTP 输入为 Kafka、Debezium 或定时游标采集,并不改变幂等聚合核心。
重要边界:这段最小代码没有事件收件箱和订单锁行;同一订单在多个消费者实例并发首次写入时,不能作为生产实现。下一节的"标准 Java 工程闭环"补齐该缺口,生产应以后一节为准。
Demo 口径 :仅统计支付后的净有效金额;创建、改价、全额退款和部分退款都以订单最新 effectiveAmount 快照表示。version 必须在同一 source + orderId 下递增。一个订单的同版本消息可以重复到达。
GET /demo/gmv?day=... gmv_realtime_bucket gmv_order_state GmvAggregationService POST /demo/gmv/events CDC/轮询器 GET /demo/gmv?day=... gmv_realtime_bucket gmv_order_state GmvAggregationService POST /demo/gmv/events CDC/轮询器 #mermaid-svg-5DnTo60ubhZn7vCj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5DnTo60ubhZn7vCj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5DnTo60ubhZn7vCj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5DnTo60ubhZn7vCj .error-icon{fill:#552222;}#mermaid-svg-5DnTo60ubhZn7vCj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5DnTo60ubhZn7vCj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5DnTo60ubhZn7vCj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5DnTo60ubhZn7vCj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5DnTo60ubhZn7vCj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5DnTo60ubhZn7vCj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5DnTo60ubhZn7vCj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5DnTo60ubhZn7vCj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5DnTo60ubhZn7vCj .marker.cross{stroke:#333333;}#mermaid-svg-5DnTo60ubhZn7vCj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5DnTo60ubhZn7vCj p{margin:0;}#mermaid-svg-5DnTo60ubhZn7vCj .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5DnTo60ubhZn7vCj text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-5DnTo60ubhZn7vCj .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5DnTo60ubhZn7vCj .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-5DnTo60ubhZn7vCj .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-5DnTo60ubhZn7vCj .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-5DnTo60ubhZn7vCj #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-5DnTo60ubhZn7vCj .sequenceNumber{fill:white;}#mermaid-svg-5DnTo60ubhZn7vCj #sequencenumber{fill:#333;}#mermaid-svg-5DnTo60ubhZn7vCj #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-5DnTo60ubhZn7vCj .messageText{fill:#333;stroke:none;}#mermaid-svg-5DnTo60ubhZn7vCj .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5DnTo60ubhZn7vCj .labelText,#mermaid-svg-5DnTo60ubhZn7vCj .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-5DnTo60ubhZn7vCj .loopText,#mermaid-svg-5DnTo60ubhZn7vCj .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-5DnTo60ubhZn7vCj .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5DnTo60ubhZn7vCj .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-5DnTo60ubhZn7vCj .noteText,#mermaid-svg-5DnTo60ubhZn7vCj .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-5DnTo60ubhZn7vCj .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5DnTo60ubhZn7vCj .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5DnTo60ubhZn7vCj .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5DnTo60ubhZn7vCj .actorPopupMenu{position:absolute;}#mermaid-svg-5DnTo60ubhZn7vCj .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-5DnTo60ubhZn7vCj .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5DnTo60ubhZn7vCj .actor-man circle,#mermaid-svg-5DnTo60ubhZn7vCj line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-5DnTo60ubhZn7vCj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 订单 O-100,version=1,effectiveAmount=100 apply(event) SELECT FOR UPDATE +1 笔,+100.00 保存 version=1 的贡献 同一事件重投 version=1 已处理,忽略 version=2,effectiveAmount=120 回退旧贡献 -100,再写入 +120 只汇总分钟桶 订单数和 GMV
读图要点:聚合器不需要记住"本次是加 20 元还是退款 20 元";它只比较订单的旧有效快照与新有效快照。这样消息重复、改价和部分退款遵循同一条处理规则。
1. 建表:状态表保存旧贡献,桶表保存展示结果
以下 DDL 是 Demo 的建议结构。相比前文的简化结构,gmv_order_state 必须保留旧门店、旧渠道和旧时间桶;否则订单跨维度修正时无法准确回退旧桶。
sql
CREATE TABLE gmv_order_state (
source VARCHAR(32) NOT NULL,
business_order_id VARCHAR(64) NOT NULL,
version BIGINT NOT NULL,
effective_amount DECIMAL(18,2) NOT NULL,
bucket_start DATETIME NOT NULL,
store_id VARCHAR(64) NOT NULL,
channel_id VARCHAR(64) NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (source, business_order_id)
) ENGINE=InnoDB;
CREATE TABLE gmv_realtime_bucket (
bucket_start DATETIME NOT NULL,
source VARCHAR(32) NOT NULL,
store_id VARCHAR(64) NOT NULL,
channel_id VARCHAR(64) NOT NULL,
order_count BIGINT NOT NULL,
amount DECIMAL(18,2) NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (bucket_start, source, store_id, channel_id),
KEY idx_bucket_query (bucket_start, channel_id, store_id)
) ENGINE=InnoDB;
CREATE TABLE gmv_source_checkpoint (
source VARCHAR(32) NOT NULL PRIMARY KEY,
last_updated_at DATETIME NOT NULL,
last_order_id VARCHAR(64) NOT NULL,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB;
gmv_order_state 是幂等判断的权威,不能只放 Redis;服务重启、缓存淘汰或多实例部署后,内存去重会失效。gmv_realtime_bucket 只保存可展示的聚合值,主键同时限制了同一分钟、来源、门店和渠道只有一行。
2. 输入事件:统一为"订单有效快照"而不是各来源 SQL
java
// 建议代码:demo/gmv/GmvEvent.java
package com.example.demo.gmv;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record GmvEvent(
String source,
String orderId,
long version,
String storeId,
String channelId,
BigDecimal effectiveAmount,
LocalDateTime occurredAt) {
public GmvEvent {
if (source == null || source.isBlank() || orderId == null || orderId.isBlank()) {
throw new IllegalArgumentException("source 和 orderId 不能为空");
}
if (version <= 0 || effectiveAmount == null || effectiveAmount.signum() < 0) {
throw new IllegalArgumentException("version 必须大于 0,effectiveAmount 不能为负数");
}
if (storeId == null || channelId == null || occurredAt == null) {
throw new IllegalArgumentException("门店、渠道和业务时间不能为空");
}
}
}
| 项目 | Demo 约定 |
|---|---|
| 输入 | 一个来源订单的当前有效金额快照;金额用 BigDecimal |
| 输出 | 对状态表和一个或两个分钟桶的原子修正 |
| 不变量 | 只有更高 version 可改变该订单的聚合贡献 |
| 失败 | 参数错误返回 400;数据库临时失败使事务回滚,调用方重投 |
| 边界 | 来源没有可靠版本时,先构造 (update_time, 内容哈希) 版本并保留对账 |
3. 聚合器:锁住订单状态,先减旧贡献再加新贡献
下面是完整核心服务。Demo 使用 Spring JDBC 突出事务语义;实际项目可替换为 MyBatis Mapper 或 JPA,但必须保留同一数据库事务和 FOR UPDATE 锁。
java
// 建议代码:demo/gmv/GmvAggregationService.java
package com.example.demo.gmv;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
@Service
public class GmvAggregationService {
private final JdbcTemplate jdbcTemplate;
public GmvAggregationService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* 将订单最新快照同步到分钟桶。重复或旧版本无副作用。
*
* @param event 同一订单内 version 单调递增的有效金额快照
*/
@Transactional(rollbackFor = Exception.class)
public void apply(GmvEvent event) {
OrderState oldState = findForUpdate(event.source(), event.orderId());
if (oldState != null && oldState.version() >= event.version()) {
return;
}
if (oldState != null && oldState.effectiveAmount().signum() > 0) {
upsertBucket(oldState.bucketStart(), oldState.source(), oldState.storeId(),
oldState.channelId(), -1, oldState.effectiveAmount().negate());
}
LocalDateTime newBucket = event.occurredAt().truncatedTo(ChronoUnit.MINUTES);
if (event.effectiveAmount().signum() > 0) {
upsertBucket(newBucket, event.source(), event.storeId(), event.channelId(),
1, event.effectiveAmount());
}
jdbcTemplate.update("""
INSERT INTO gmv_order_state
(source, business_order_id, version, effective_amount, bucket_start,
store_id, channel_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
version = VALUES(version),
effective_amount = VALUES(effective_amount),
bucket_start = VALUES(bucket_start),
store_id = VALUES(store_id),
channel_id = VALUES(channel_id),
updated_at = NOW()
""",
event.source(), event.orderId(), event.version(), event.effectiveAmount(),
Timestamp.valueOf(newBucket), event.storeId(), event.channelId());
}
private void upsertBucket(LocalDateTime bucketStart, String source, String storeId,
String channelId, long orderCountDelta, BigDecimal amountDelta) {
jdbcTemplate.update("""
INSERT INTO gmv_realtime_bucket
(bucket_start, source, store_id, channel_id, order_count, amount, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
order_count = order_count + VALUES(order_count),
amount = amount + VALUES(amount),
updated_at = NOW()
""",
Timestamp.valueOf(bucketStart), source, storeId, channelId,
orderCountDelta, amountDelta);
}
private OrderState findForUpdate(String source, String orderId) {
List<OrderState> states = jdbcTemplate.query("""
SELECT source, business_order_id, version, effective_amount,
bucket_start, store_id, channel_id
FROM gmv_order_state
WHERE source = ? AND business_order_id = ?
FOR UPDATE
""", (resultSet, rowNum) -> new OrderState(
resultSet.getString("source"),
resultSet.getString("business_order_id"),
resultSet.getLong("version"),
resultSet.getBigDecimal("effective_amount"),
resultSet.getTimestamp("bucket_start").toLocalDateTime(),
resultSet.getString("store_id"),
resultSet.getString("channel_id")), source, orderId);
return states.isEmpty() ? null : states.get(0);
}
private record OrderState(String source, String orderId, long version,
BigDecimal effectiveAmount, LocalDateTime bucketStart,
String storeId, String channelId) {
}
}
这段代码的关键不是 UPSERT 本身,而是顺序:先以订单主键锁住旧状态,确认版本更新后回退旧贡献,再写入新贡献,最后更新状态。三个写操作在同一事务中;任何一个失败都会回滚,因此不会出现"状态已更新但桶未更新"或相反的永久不一致。
一个细节需要保留:订单变成全额退款后,effectiveAmount=0,服务只执行旧桶负向回退,不写新桶。部分退款则输入退款后的净额,例如 120 元改成 100 元,结果是 -120 + 100,GMV 净减 20 元而订单数保持 1。
4. API:写入口模拟消费者,读入口只扫桶表
java
// 建议代码:demo/gmv/GmvDemoController.java
package com.example.demo.gmv;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.Map;
@RestController
@RequestMapping("/demo/gmv")
public class GmvDemoController {
private final GmvAggregationService aggregationService;
private final JdbcTemplate jdbcTemplate;
public GmvDemoController(GmvAggregationService aggregationService, JdbcTemplate jdbcTemplate) {
this.aggregationService = aggregationService;
this.jdbcTemplate = jdbcTemplate;
}
@PostMapping("/events")
public ResponseEntity<Void> consume(@RequestBody GmvEvent event) {
aggregationService.apply(event);
return ResponseEntity.noContent().build();
}
@GetMapping
public Map<String, Object> query(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate day) {
Map<String, Object> result = jdbcTemplate.queryForMap("""
SELECT COALESCE(SUM(order_count), 0) AS orderCount,
COALESCE(SUM(amount), 0) AS totalGmv,
MAX(updated_at) AS asOf
FROM gmv_realtime_bucket
WHERE bucket_start >= ? AND bucket_start < ?
""", day.atStartOfDay(), day.plusDays(1).atStartOfDay());
result.put("day", day);
result.put("isDegraded", false);
return result;
}
}
查询 SQL 的扫描范围只与"当天分钟桶 × 实际维度数"有关,不与所有历史订单明细行数直接相关。这正是高频展示能扩展的原因。真正上线时,isDegraded 应由来源水位计算,不能固定为 false;本 Demo 仅展示主流程。
5. 用四次请求验证重复、改价和退款
假设时间均为 2026-09-02T10:01:20,门店为 S-001、渠道为 MT。按以下顺序调用:
bash
curl -X POST http://localhost:${APP_PORT}/demo/gmv/events \
-H 'Content-Type: application/json' \
-d '{"source":"demo-order","orderId":"O-100","version":1,"storeId":"S-001","channelId":"MT","effectiveAmount":100.00,"occurredAt":"2026-09-02T10:01:20"}'
# 模拟网络超时后的完全相同重投:结果不应变化
curl -X POST http://localhost:${APP_PORT}/demo/gmv/events \
-H 'Content-Type: application/json' \
-d '{"source":"demo-order","orderId":"O-100","version":1,"storeId":"S-001","channelId":"MT","effectiveAmount":100.00,"occurredAt":"2026-09-02T10:01:20"}'
# 改价到 120 元:结果从 100 变成 120,不是 220
curl -X POST http://localhost:${APP_PORT}/demo/gmv/events \
-H 'Content-Type: application/json' \
-d '{"source":"demo-order","orderId":"O-100","version":2,"storeId":"S-001","channelId":"MT","effectiveAmount":120.00,"occurredAt":"2026-09-02T10:01:20"}'
# 部分退款 20 元:最新有效金额为 100,结果从 120 回到 100
curl -X POST http://localhost:${APP_PORT}/demo/gmv/events \
-H 'Content-Type: application/json' \
-d '{"source":"demo-order","orderId":"O-100","version":3,"storeId":"S-001","channelId":"MT","effectiveAmount":100.00,"occurredAt":"2026-09-02T10:01:20"}'
curl "http://localhost:${APP_PORT}/demo/gmv?day=2026-09-02"
最后一次查询的关键结果应为:orderCount = 1、totalGmv = 100.00。如果改价后得到 220,说明旧贡献没有回退;如果重复请求后得到 200,说明版本判断或事务边界错误;如果退款后订单数变成 0,则不应把"净金额为零"和"订单从未出现"混为一谈,需按产品口径另行维护支付订单数、退款订单数等指标。
6. 接入真实源库:用复合游标替换 HTTP 输入
当没有 CDC 时,采集器可每 5 秒读取源表中游标之后的订单快照,再逐行调用同一个 apply 方法。游标必须按 (updated_at, order_id) 比较,不能只按时间;多个订单可能拥有相同更新时间。
sql
SELECT order_id, version, store_id, channel_id, effective_amount, occurred_at, updated_at
FROM source_order
WHERE updated_at > :lastUpdatedAt
OR (updated_at = :lastUpdatedAt AND order_id > :lastOrderId)
ORDER BY updated_at, order_id
LIMIT :batchSize;
每批完成后,只有在该批事件的 apply 全部提交成功时,才能在同一归集库事务或可恢复的批次状态中推进 gmv_source_checkpoint。采集 SQL 的字段名会因来源不同而变化,但输出必须统一映射成 GmvEvent;不要让业务库字段和特殊渠道规则扩散到查询 API。
标准 Java 工程闭环:接口、Outbox、归集、查询和测试
前面的最小 Demo 用于建立"新快照回退旧贡献"的心智模型。本节给出可作为 Spring Boot 3 / Java 17 落地基线的标准闭环:业务接口写订单与 Outbox 使用同一事务;发布器可重试地投递事件;归集端以收件箱防止消息重投,以订单状态锁避免同订单并发修正;查询端只读桶和缓存;测试使用真实 MySQL 容器验证事务行为。
工程边界与目录
第一阶段不必拆成多个部署服务,可以作为同一个代码库内的三个 Maven 模块或一个应用内的三个包。模块拆分的是依赖和职责,不是为了增加网络调用。
text
gmv-platform/
├── gmv-contract/ # 事件 DTO、枚举、共享校验
├── order-service/ # 订单 HTTP 接口、订单表、Outbox 发布器
├── gmv-aggregation-service/ # 消费、幂等聚合、查询 API、缓存、对账
└── gmv-integration-test/ # Testcontainers MySQL 端到端测试
| 模块 | 对外接口 | 权威数据 | 不能做什么 |
|---|---|---|---|
order-service |
创建/支付/退款订单 | 订单当前状态和 Outbox | 不直接维护展示 GMV |
gmv-aggregation-service |
消费订单事件、查询 GMV | 已消费事件、订单贡献状态、聚合桶 | 不回查订单库完成在线统计 |
gmv-contract |
Java 类型或 JSON Schema | 无 | 不携带数据库实体和 Mapper |
| 测试模块 | 无 | 测试环境 | 不替代生产对账 |
读图要点:订单服务对"订单事实"负责,归集服务对"指标投影"负责。两者不共享表写权限,因此展示重算不会反向篡改订单;订单提交也不等待大屏聚合完成。
一笔真实订单的闭环时序
GmvQueryController Redis analytics_db GmvEventConsumer Kafka/可靠队列 OutboxPublisher order_db OrderController 调用方 GmvQueryController Redis analytics_db GmvEventConsumer Kafka/可靠队列 OutboxPublisher order_db OrderController 调用方 #mermaid-svg-XtB8j5lydXjDVlGA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XtB8j5lydXjDVlGA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XtB8j5lydXjDVlGA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XtB8j5lydXjDVlGA .error-icon{fill:#552222;}#mermaid-svg-XtB8j5lydXjDVlGA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XtB8j5lydXjDVlGA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XtB8j5lydXjDVlGA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XtB8j5lydXjDVlGA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XtB8j5lydXjDVlGA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XtB8j5lydXjDVlGA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XtB8j5lydXjDVlGA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XtB8j5lydXjDVlGA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XtB8j5lydXjDVlGA .marker.cross{stroke:#333333;}#mermaid-svg-XtB8j5lydXjDVlGA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XtB8j5lydXjDVlGA p{margin:0;}#mermaid-svg-XtB8j5lydXjDVlGA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XtB8j5lydXjDVlGA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-XtB8j5lydXjDVlGA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XtB8j5lydXjDVlGA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-XtB8j5lydXjDVlGA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-XtB8j5lydXjDVlGA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-XtB8j5lydXjDVlGA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-XtB8j5lydXjDVlGA .sequenceNumber{fill:white;}#mermaid-svg-XtB8j5lydXjDVlGA #sequencenumber{fill:#333;}#mermaid-svg-XtB8j5lydXjDVlGA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-XtB8j5lydXjDVlGA .messageText{fill:#333;stroke:none;}#mermaid-svg-XtB8j5lydXjDVlGA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XtB8j5lydXjDVlGA .labelText,#mermaid-svg-XtB8j5lydXjDVlGA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-XtB8j5lydXjDVlGA .loopText,#mermaid-svg-XtB8j5lydXjDVlGA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-XtB8j5lydXjDVlGA .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XtB8j5lydXjDVlGA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-XtB8j5lydXjDVlGA .noteText,#mermaid-svg-XtB8j5lydXjDVlGA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-XtB8j5lydXjDVlGA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XtB8j5lydXjDVlGA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XtB8j5lydXjDVlGA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XtB8j5lydXjDVlGA .actorPopupMenu{position:absolute;}#mermaid-svg-XtB8j5lydXjDVlGA .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-XtB8j5lydXjDVlGA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XtB8j5lydXjDVlGA .actor-man circle,#mermaid-svg-XtB8j5lydXjDVlGA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-XtB8j5lydXjDVlGA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 缓存未命中 POST /orders/{id}/pay 事务:更新订单 + INSERT outbox_event commit 204 No Content 认领未发布 outbox publish(eventId, version, snapshot) 至少一次投递 事务:收件箱去重 + 锁订单状态 + 回退旧桶 + 写新桶 事务提交后删除热点缓存键 GET gmv cache 只聚合分钟/日桶 缓存 2 秒 GMV + watermark + degraded
这个时序刻意接受"订单已支付,但大屏可能在数秒内尚未更新"的最终一致性;换来的是订单事务不依赖下游、消息可重投、在线查询不扫描订单库。若业务要求支付成功响应内必须返回更新后的 GMV,则需要把聚合写入纳入同一数据库事务,这会失去服务边界且通常不值得。
1. 依赖、配置和迁移组织
以 Spring Boot 3.x、Java 17 为例,归集模块需要的依赖如下。Flyway、缓存和 Testcontainers 是建议依赖,版本交由 Spring Boot BOM 管理。
xml
<!-- gmv-aggregation-service/pom.xml 的建议依赖片段 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
yaml
# application.yml
spring:
datasource:
url: ${GMV_JDBC_URL}
username: ${GMV_DB_USERNAME}
password: ${GMV_DB_PASSWORD}
flyway:
locations: classpath:db/migration
data:
redis:
host: ${GMV_REDIS_HOST:127.0.0.1}
port: ${GMV_REDIS_PORT:6379}
gmv:
cache-ttl: 2s
max-watermark-delay: 10s
outbox:
batch-size: 200
lease-seconds: 30
不把地址、密码或队列凭据写进源码。GMV_JDBC_URL 只指向归集库;订单源库与归集库要使用不同最小权限账号。测试环境由 Testcontainers 注入地址,不依赖共享开发库。
2. 数据库迁移:七张表分别承担事实、投递、投影和历史汇总
以下 V1__gmv_realtime.sql 是建议的 Flyway 迁移。所有金额使用 DECIMAL;所有业务时间以 UTC 写入,API 再按业务时区展示。示例为 MySQL 8 语法。
sql
-- order-service: 订单事实与事务 Outbox
CREATE TABLE orders (
order_id VARCHAR(64) NOT NULL PRIMARY KEY,
store_id VARCHAR(64) NOT NULL,
channel_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
effective_amount DECIMAL(18,2) NOT NULL,
version BIGINT NOT NULL,
occurred_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
CHECK (effective_amount >= 0)
) ENGINE=InnoDB;
CREATE TABLE outbox_event (
event_id CHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
aggregate_version BIGINT NOT NULL,
event_type VARCHAR(32) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'NEW',
lease_owner VARCHAR(64) NULL,
lease_until DATETIME(3) NULL,
published_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL,
KEY idx_outbox_claim (status, lease_until, created_at),
UNIQUE KEY uk_outbox_order_version (aggregate_id, aggregate_version)
) ENGINE=InnoDB;
-- gmv-aggregation-service: 投递去重、订单贡献、分钟桶和水位
CREATE TABLE gmv_consumed_event (
event_id CHAR(36) NOT NULL PRIMARY KEY,
consumed_at DATETIME(3) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE gmv_order_state (
source VARCHAR(32) NOT NULL,
business_order_id VARCHAR(64) NOT NULL,
version BIGINT NOT NULL,
effective_amount DECIMAL(18,2) NOT NULL,
bucket_start DATETIME NOT NULL,
store_id VARCHAR(64) NOT NULL,
channel_id VARCHAR(64) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (source, business_order_id),
CHECK (effective_amount >= 0)
) ENGINE=InnoDB;
CREATE TABLE gmv_realtime_bucket (
bucket_start DATETIME NOT NULL,
source VARCHAR(32) NOT NULL,
store_id VARCHAR(64) NOT NULL,
channel_id VARCHAR(64) NOT NULL,
paid_order_count BIGINT NOT NULL,
amount DECIMAL(18,2) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (bucket_start, source, store_id, channel_id),
KEY idx_bucket_day (bucket_start, channel_id, store_id)
) ENGINE=InnoDB;
CREATE TABLE gmv_daily_bucket (
bucket_day DATE NOT NULL,
source VARCHAR(32) NOT NULL,
store_id VARCHAR(64) NOT NULL,
channel_id VARCHAR(64) NOT NULL,
paid_order_count BIGINT NOT NULL,
amount DECIMAL(18,2) NOT NULL,
finalized_at DATETIME(3) NOT NULL,
PRIMARY KEY (bucket_day, source, store_id, channel_id),
KEY idx_daily_range (bucket_day, channel_id, store_id)
) ENGINE=InnoDB;
CREATE TABLE gmv_source_watermark (
source VARCHAR(32) NOT NULL PRIMARY KEY,
last_event_at DATETIME(3) NOT NULL,
last_event_id CHAR(36) NOT NULL,
updated_at DATETIME(3) NOT NULL
) ENGINE=InnoDB;
| 表 | 必须成立的事实 | 对应失败场景 |
|---|---|---|
orders |
当前订单快照和版本真实存在 | 支付、退款、改价的业务事实 |
outbox_event |
提交过的订单变更最终可再次发布 | 数据库提交成功但 MQ 暂不可用 |
gmv_consumed_event |
同一消息不会第二次改变结果 | 消息至少一次投递 |
gmv_order_state |
能找到该订单上一次贡献 | 订单改价、改渠道、退款、迟到 |
gmv_realtime_bucket |
查询只面对有限聚合行 | 大屏每秒刷新 |
gmv_daily_bucket |
历史区间不扫描分钟桶 | 多天/多月累计查询 |
gmv_source_watermark |
可判断数据滞后 | 下游阻塞但接口仍返回 |
orders、outbox_event 位于订单库;其余四张表位于归集库。不要为了让跨库 SQL 看起来方便而把它们混在一个账号或一个库中;服务边界的目的就是避免查询服务获得订单写权限。
订单事件契约:两端只共享稳定 JSON,不共享数据库实体
java
// 示例:GmvEvent.java
public record GmvEvent(
UUID eventId,
String source,
String orderId,
long version,
String storeId,
String channelId,
BigDecimal effectiveAmount,
Instant occurredAt) {
public static GmvEvent paid(String source, UUID eventId, String orderId, long version,
String storeId, String channelId, BigDecimal effectiveAmount,
Instant occurredAt) {
return new GmvEvent(eventId, source, orderId, version, storeId, channelId,
effectiveAmount, occurredAt);
}
}
这个事件是"订单当前有效快照",而不是"金额变化量"。版本是订单服务生成的业务版本;消费者从旧快照推导增量。消息头可带 trace ID、schemaVersion 和幂等键,但不能替代事件体中的 eventId 与 version。
3. 订单接口:业务状态和 Outbox 必须同事务提交
以下示例展示支付接口的关键事务。它不直接调用 Kafka;若 Kafka 发送失败,Outbox 仍在数据库中,发布器可继续重试。事件 eventId 由订单服务生成,事件载荷是归集服务所需的标准快照,而不是订单表整行 JSON。
java
// 示例:OrderCommandService.java
@Service
public class OrderCommandService {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public OrderCommandService(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper;
}
/** 支付订单,并写入可重试发布的 GMV 快照事件。 */
@Transactional(rollbackFor = Exception.class)
public void pay(String orderId, BigDecimal paidAmount, Instant occurredAt) {
OrderSnapshot before = findForUpdate(orderId);
if (before.status() == OrderStatus.PAID) {
return;
}
long nextVersion = before.version() + 1;
jdbcTemplate.update("""
UPDATE orders
SET status = 'PAID', effective_amount = ?, version = ?,
occurred_at = ?, updated_at = UTC_TIMESTAMP(3)
WHERE order_id = ?
""", paidAmount, nextVersion, Timestamp.from(occurredAt), orderId);
GmvEvent event = GmvEvent.paid("order-service", UUID.randomUUID(), orderId,
nextVersion, before.storeId(), before.channelId(), paidAmount, occurredAt);
jdbcTemplate.update("""
INSERT INTO outbox_event
(event_id, aggregate_id, aggregate_version, event_type, payload, created_at)
VALUES (?, ?, ?, 'ORDER_SNAPSHOT', ?, UTC_TIMESTAMP(3))
""", event.eventId().toString(), orderId, nextVersion,
writeJson(event));
}
private OrderSnapshot findForUpdate(String orderId) {
return jdbcTemplate.queryForObject("""
SELECT order_id, store_id, channel_id, status, version
FROM orders WHERE order_id = ? FOR UPDATE
""", (rs, rowNum) -> new OrderSnapshot(
rs.getString("order_id"), rs.getString("store_id"), rs.getString("channel_id"),
OrderStatus.valueOf(rs.getString("status")), rs.getLong("version")), orderId);
}
private String writeJson(GmvEvent event) {
try {
return objectMapper.writeValueAsString(event);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("GMV 事件序列化失败", exception);
}
}
private record OrderSnapshot(String orderId, String storeId, String channelId,
OrderStatus status, long version) {
}
}
| 项目 | 契约 |
|---|---|
| 输入 | orderId、支付金额、发生时间;调用方必须通过鉴权和订单状态校验 |
| 输出 | 订单变为 PAID;同事务新增唯一的 Outbox 事件 |
| 不变量 | 任意已经提交的订单版本,都有可重试发布的事件 |
| 失败 | 订单更新或事件写入任一失败,整个事务回滚,HTTP 返回错误 |
| 不做什么 | 不等待下游聚合完成,也不在订单库查询总 GMV |
退款、取消、改渠道与改价复用同一模式:订单版本递增,并发布最新 effectiveAmount。全额退款写 0;归集端按旧快照回退,不需要事件类型分支决定加减法。
4. Outbox 发布器:认领、发布、确认三阶段可重试
Outbox 发布器可用 @Scheduled(fixedDelay = 500) 轮询,也可以由专用线程运行。关键不在 500 毫秒,而在于多实例不会同时发布同一行:先用租约原子认领,再发布,成功后标记 PUBLISHED;发布成功但标记失败时,消息会重投,由下游收件箱去重。
java
// 示例:OutboxPublisher.java
@Component
public class OutboxPublisher {
private final OutboxRepository outboxRepository;
private final EventPublisher eventPublisher;
public OutboxPublisher(OutboxRepository outboxRepository, EventPublisher eventPublisher) {
this.outboxRepository = outboxRepository;
this.eventPublisher = eventPublisher;
}
@Scheduled(fixedDelayString = "${gmv.outbox.poll-delay:500}")
public void publishBatch() {
for (OutboxEvent event : outboxRepository.claimBatch(200, Duration.ofSeconds(30))) {
try {
eventPublisher.publish(event.eventId(), event.payload());
outboxRepository.markPublished(event.eventId());
} catch (RuntimeException exception) {
outboxRepository.release(event.eventId());
}
}
}
}
EventPublisher 可适配 Kafka、RocketMQ、RabbitMQ 或受控 HTTP;它的语义必须是"允许至少一次投递"。不要在 catch 中吞掉异常后把事件标记成功;也不要让一条持续失败的消息阻塞整批,应记录失败次数并转入告警或死信处理。
claimBatch 不能先普通 SELECT 再逐条更新,否则多实例会重复认领。MySQL 8 可在一个事务内先 SELECT ... FOR UPDATE SKIP LOCKED,再将已锁记录标记为本实例的租约:
sql
START TRANSACTION;
SELECT event_id, payload
FROM outbox_event
WHERE status = 'NEW'
OR (status = 'LEASED' AND lease_until < UTC_TIMESTAMP(3))
ORDER BY created_at
LIMIT :batchSize
FOR UPDATE SKIP LOCKED;
UPDATE outbox_event
SET status = 'LEASED', lease_owner = :instanceId,
lease_until = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 30 SECOND)
WHERE event_id IN (:claimedEventIds);
COMMIT;
markPublished 必须限定 lease_owner,防止租约过期后旧实例覆盖新实例结果。数据库或中间件的具体批量认领能力不同;若使用 Kafka Connect/Debezium Outbox,可将发布器替换掉,但订单表与 Outbox 同事务这一约束不变。
4.5 日桶固化:让历史查询与实时分钟桶保持互斥
日桶不是简单把分钟桶复制一份,而是历史查询的权威投影。以下任务仅在前一天超过延迟容忍窗口后执行;INSERT ... ON DUPLICATE KEY UPDATE 使同一天重算安全。生产中应以分布式租约或 SKIP LOCKED 认领日期,避免多实例同时固化。
sql
INSERT INTO gmv_daily_bucket
(bucket_day, source, store_id, channel_id, paid_order_count, amount, finalized_at)
SELECT DATE(bucket_start), source, store_id, channel_id,
SUM(paid_order_count), SUM(amount), UTC_TIMESTAMP(3)
FROM gmv_realtime_bucket
WHERE bucket_start >= :dayStart
AND bucket_start < :nextDayStart
GROUP BY DATE(bucket_start), source, store_id, channel_id
ON DUPLICATE KEY UPDATE
paid_order_count = VALUES(paid_order_count),
amount = VALUES(amount),
finalized_at = VALUES(finalized_at);
固化成功后是否删除旧分钟桶取决于迟到修正与分时曲线留存需求。较稳妥的默认是保留 7~30 天分钟桶,并将迟到修正窗口设置得不超过该保留期;超过保留期的修正进入日级对账任务。删除分钟桶是数据生命周期操作,必须通过分区过期或可恢复归档执行,不能由应用查询代码顺手清理。
5. 归集消费者:收件箱先去重,再锁订单状态并回退旧桶
生产聚合的并发安全需要两个层次:gmv_consumed_event 防同一个 eventId 重投;gmv_order_state 的行锁序列化同一个订单的不同版本。对于"两个消费者第一次同时处理同一订单、状态行尚不存在"的竞态,先用 INSERT IGNORE 建立占位状态行,再 SELECT ... FOR UPDATE,避免仅查询不到行时发生幻读竞争。
java
// 示例:GmvEventConsumer.java
@Service
public class GmvEventConsumer {
private final JdbcTemplate jdbcTemplate;
private final GmvCacheInvalidator cacheInvalidator;
public GmvEventConsumer(JdbcTemplate jdbcTemplate, GmvCacheInvalidator cacheInvalidator) {
this.jdbcTemplate = jdbcTemplate;
this.cacheInvalidator = cacheInvalidator;
}
/**
* 由消息监听器调用。仅在数据库事务提交后失效缓存;事务回滚时允许消息重投。
*/
@Transactional(rollbackFor = Exception.class)
public void consume(GmvEvent event) {
if (jdbcTemplate.update("""
INSERT IGNORE INTO gmv_consumed_event(event_id, consumed_at)
VALUES (?, UTC_TIMESTAMP(3))
""", event.eventId().toString()) == 0) {
return;
}
jdbcTemplate.update("""
INSERT IGNORE INTO gmv_order_state
(source, business_order_id, version, effective_amount, bucket_start,
store_id, channel_id, updated_at)
VALUES (?, ?, 0, 0, '1970-01-01 00:00:00', '', '', UTC_TIMESTAMP(3))
""", event.source(), event.orderId());
OrderContribution old = findForUpdate(event.source(), event.orderId());
if (old.version() >= event.version()) {
return;
}
if (old.effectiveAmount().signum() > 0) {
changeBucket(old.bucketStart(), old.source(), old.storeId(), old.channelId(),
-1, old.effectiveAmount().negate());
}
LocalDateTime newBucket = event.occurredAt()
.atZone(ZoneOffset.UTC)
.truncatedTo(ChronoUnit.MINUTES)
.toLocalDateTime();
if (event.effectiveAmount().signum() > 0) {
changeBucket(newBucket, event.source(), event.storeId(), event.channelId(),
1, event.effectiveAmount());
}
jdbcTemplate.update("""
UPDATE gmv_order_state
SET version = ?, effective_amount = ?, bucket_start = ?, store_id = ?,
channel_id = ?, updated_at = UTC_TIMESTAMP(3)
WHERE source = ? AND business_order_id = ?
""", event.version(), event.effectiveAmount(), Timestamp.valueOf(newBucket),
event.storeId(), event.channelId(), event.source(), event.orderId());
jdbcTemplate.update("""
INSERT INTO gmv_source_watermark(source, last_event_at, last_event_id, updated_at)
VALUES (?, ?, ?, UTC_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE
last_event_at = GREATEST(last_event_at, VALUES(last_event_at)),
last_event_id = IF(last_event_at <= VALUES(last_event_at), VALUES(last_event_id), last_event_id),
updated_at = UTC_TIMESTAMP(3)
""", event.source(), Timestamp.from(event.occurredAt()), event.eventId().toString());
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
cacheInvalidator.evictDay(event.occurredAt().atZone(ZoneOffset.UTC).toLocalDate());
}
});
}
private void changeBucket(LocalDateTime bucketStart, String source, String storeId,
String channelId, long countDelta, BigDecimal amountDelta) {
jdbcTemplate.update("""
INSERT INTO gmv_realtime_bucket
(bucket_start, source, store_id, channel_id, paid_order_count, amount, updated_at)
VALUES (?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE
paid_order_count = paid_order_count + VALUES(paid_order_count),
amount = amount + VALUES(amount),
updated_at = UTC_TIMESTAMP(3)
""", Timestamp.valueOf(bucketStart), source, storeId, channelId,
countDelta, amountDelta);
}
private OrderContribution findForUpdate(String source, String orderId) {
return jdbcTemplate.queryForObject("""
SELECT source, business_order_id, version, effective_amount,
bucket_start, store_id, channel_id
FROM gmv_order_state
WHERE source = ? AND business_order_id = ? FOR UPDATE
""", (rs, rowNum) -> new OrderContribution(
rs.getString("source"), rs.getString("business_order_id"), rs.getLong("version"),
rs.getBigDecimal("effective_amount"), rs.getTimestamp("bucket_start").toLocalDateTime(),
rs.getString("store_id"), rs.getString("channel_id")), source, orderId);
}
private record OrderContribution(String source, String orderId, long version,
BigDecimal effectiveAmount, LocalDateTime bucketStart,
String storeId, String channelId) {
}
}
| 项目 | 契约 |
|---|---|
| 输入 | eventId 全局唯一、订单内 version 单调递增、金额为当前有效净额 |
| 输出 | 一次成功消费最多修改旧桶和新桶各一行,并更新订单贡献和水位 |
| 不变量 | 同 eventId 重投无副作用;低版本不覆盖高版本;状态、桶和水位同事务提交 |
| 失败 | SQL 失败时收件箱插入一并回滚,消息可重投;解析失败送死信而不是确认成功 |
| 并发边界 | 同订单由状态行锁串行;不同订单可并行;高热点单订单会自然串行化 |
注意:发生"先收到 version=2、后收到 version=1"时,version=1 会被 old.version() >= event.version() 丢弃;但它的 eventId 已经进入收件箱。这是正确的,因为该事件对当前投影已不再产生贡献。未来如需回放全部历史,应使用新的归集库或显式清空对应收件箱与投影状态,不要删除单条收件箱记录后重放。
6. 查询、缓存和水位:接口返回值必须表明数据新鲜度
查询服务需要把"展示值"和"数据是否新鲜"一起返回。当前业务日只读 gmv_realtime_bucket;已封账日期只读 gmv_daily_bucket;跨日区间按日期拆成"历史日桶 + 当前分钟桶"两个互斥集合。分钟桶不能无限保留后被跨月 API 全表求和,否则只是把订单明细扫描换成了另一种随时间增长的扫描。短缓存以当天、租户、筛选维度和指标版本组成键。这里用 2 秒 TTL 是示例值,应由压测与业务刷新要求确定。
日终固化任务应在延迟容忍窗口结束后执行:按分钟桶重算前一天的日桶,然后标记 finalized_at。迟到修正若仍在允许窗口内,要同时修正分钟桶和已受影响的日桶,或者将日桶标记为待重算;两者只能择一且需可观察。这样查询范围保持互斥:
text
查询 [startDay, endDay] =
SUM(gmv_daily_bucket, startDay 到 yesterday)
+ SUM(gmv_realtime_bucket, today 00:00 到当前分钟)
java
// 示例:GmvQueryService.java
@Service
public class GmvQueryService {
private final JdbcTemplate jdbcTemplate;
private final StringRedisTemplate redisTemplate;
private final Duration cacheTtl;
private final Duration maxWatermarkDelay;
public GmvQueryService(JdbcTemplate jdbcTemplate, StringRedisTemplate redisTemplate,
@Value("${gmv.cache-ttl}") Duration cacheTtl,
@Value("${gmv.max-watermark-delay}") Duration maxWatermarkDelay) {
this.jdbcTemplate = jdbcTemplate;
this.redisTemplate = redisTemplate;
this.cacheTtl = cacheTtl;
this.maxWatermarkDelay = maxWatermarkDelay;
}
public GmvSummary query(LocalDate day, String source, String channelId) {
String key = "gmv:v1:" + day + ":" + source + ":" + channelId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return GmvSummary.fromJson(cached);
}
GmvSummary summary = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(paid_order_count), 0) AS paidOrderCount,
COALESCE(SUM(amount), 0) AS totalGmv,
MAX(updated_at) AS asOf
FROM gmv_realtime_bucket
WHERE bucket_start >= ? AND bucket_start < ?
AND (? IS NULL OR source = ?)
AND (? IS NULL OR channel_id = ?)
""", (rs, rowNum) -> {
Timestamp asOf = rs.getTimestamp("asOf");
return GmvSummary.of(day, rs.getLong("paidOrderCount"),
rs.getBigDecimal("totalGmv"), asOf == null ? null : asOf.toInstant());
},
day.atStartOfDay(), day.plusDays(1).atStartOfDay(), source, source, channelId, channelId);
Instant watermark = queryWatermark(source);
boolean degraded = watermark == null
|| Duration.between(watermark, Instant.now()).compareTo(maxWatermarkDelay) > 0;
summary = summary.withWatermark(watermark, degraded);
redisTemplate.opsForValue().set(key, summary.toJson(), cacheTtl);
return summary;
}
private Instant queryWatermark(String source) {
return jdbcTemplate.queryForObject("""
SELECT MAX(last_event_at) FROM gmv_source_watermark
WHERE (? IS NULL OR source = ?)
""", Instant.class, source, source);
}
}
对可选筛选条件使用绑定参数,不能用 ${} 拼 SQL。示例已对 asOf 和水位为空做了降级处理:没有任何事件时返回空时间、isDegraded=true,而不是对 null 调用 Duration.between。缓存仅在事务提交后失效;如果失效失败,最多读取 2 秒旧值,不会破坏数据库投影。
7. HTTP 契约:同步命令与异步投影不可混用
| HTTP 接口 | 成功状态 | 语义 |
|---|---|---|
POST /orders/{id}/pay |
204 |
订单和 Outbox 已提交;GMV 投影稍后可见 |
POST /orders/{id}/refund |
204 |
订单净额已更新,退款事件可重试发布 |
GET /gmv?day=2026-09-02&source=order-service&channelId=MT |
200 |
返回聚合 GMV 与水位 |
POST /internal/gmv/events |
204 |
仅给消息适配器或测试使用;已经同步完成消费 |
json
{
"day": "2026-09-02",
"paidOrderCount": 1842,
"totalGmv": "185220.75",
"asOf": "2026-09-02T10:01:20.135Z",
"watermark": "2026-09-02T10:01:19.900Z",
"dataDelaySeconds": 1,
"isDegraded": false
}
使用 204 的写接口不会假装 GMV 已完成刷新;使用 202 只能表示服务已经受理、但本服务尚未完成同步处理的工作。之前最小 Demo 的 POST 是同步写库,因此应使用 204,已在前文修正。
8. 自动化验证:用真实 MySQL 验证事务,而非只用 curl
单元测试可以验证金额差值函数,但无法证明 FOR UPDATE、INSERT IGNORE、唯一键和事务回滚按预期工作。核心用例应使用 Testcontainers MySQL;每个测试通过公开消费者入口注入事件,再直接查询桶表断言。
java
// gmv-integration-test/src/test/java/.../GmvEventConsumerIT.java
@SpringBootTest
@Testcontainers
class GmvEventConsumerIT {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private GmvEventConsumer consumer;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void duplicatePriceChangeAndRefundShouldConvergeToLatestSnapshot() {
Instant at = Instant.parse("2026-09-02T10:01:20Z");
consumer.consume(event("e1", 1, "100.00", at));
consumer.consume(event("e1", 1, "100.00", at));
consumer.consume(event("e2", 2, "120.00", at));
consumer.consume(event("e3", 3, "100.00", at));
Map<String, Object> bucket = jdbcTemplate.queryForMap("""
SELECT SUM(paid_order_count) AS orders, SUM(amount) AS amount
FROM gmv_realtime_bucket
WHERE source = 'order-service' AND store_id = 'S-001' AND channel_id = 'MT'
""");
assertThat(bucket.get("orders")).isEqualTo(1L);
assertThat(bucket.get("amount")).isEqualByComparingTo("100.00");
}
@Test
void olderVersionAfterNewerVersionShouldNotChangeBucket() {
Instant at = Instant.parse("2026-09-02T10:01:20Z");
consumer.consume(event("e2", 2, "120.00", at));
consumer.consume(event("e1", 1, "100.00", at));
BigDecimal amount = jdbcTemplate.queryForObject("""
SELECT SUM(amount) FROM gmv_realtime_bucket WHERE source = 'order-service'
""", BigDecimal.class);
assertThat(amount).isEqualByComparingTo("120.00");
}
}
此测试片段省略 event(...) 工厂方法及 Redis 容器配置。测试真正证明的是 MySQL 中"重复、改价、退款、乱序版本"最终收敛;它没有证明 Kafka/RocketMQ 配置、生产权限、跨库网络延迟或峰值吞吐。还应补充:事务中途故障回滚、相同订单双线程并发、Outbox 发布失败重试、缓存失效失败与日级对账差异的测试。
9. 启动顺序与本地验收脚本
本地最小环境需要 MySQL 8 与 Redis,消息适配器可先用内部 HTTP 消费入口替代。先执行 Flyway,再启动订单服务和归集服务;禁止为了演示而直接手工改桶表。
bash
# 仅示例:变量由本地安全配置提供,不在命令或文档中填写口令
export GMV_JDBC_URL='jdbc:mysql://127.0.0.1:3306/gmv_analytics'
export GMV_DB_USERNAME='gmv_app'
export GMV_DB_PASSWORD='***'
mvn -pl gmv-aggregation-service -am test
mvn -pl order-service -am spring-boot:run
mvn -pl gmv-aggregation-service -am spring-boot:run
# 先调用订单支付接口;Outbox 发布器将事件送入适配器,再查询投影
curl -X POST 'http://localhost:8080/orders/O-100/pay' \
-H 'Content-Type: application/json' \
-d '{"amount":"100.00","occurredAt":"2026-09-02T10:01:20Z"}'
curl "http://localhost:${APP_PORT}/gmv?day=2026-09-02&source=order-service&channelId=MT"
成功判据不是"接口返回 200",而是:订单表存在新版本、Outbox 有对应事件且最终 PUBLISHED、收件箱有该 eventId、订单贡献状态与最新快照一致、分钟桶金额正确、查询水位不超过阈值。任一环节缺失都应沿事件 ID 追踪,而不是重新扫描全量订单。
10. 标准闭环的测试矩阵与上线边界
| 层级 | 必测场景 | 通过判据 |
|---|---|---|
| 订单事务 | 支付写订单后 Outbox 插入失败 | 订单状态和版本一起回滚 |
| Outbox | 发布成功但 markPublished 失败 |
允许重投,下游聚合不重复 |
| 消费幂等 | 同一 eventId 连续/并发投递 |
桶金额和订单数只变化一次 |
| 版本修正 | 改价、改门店、改渠道、全额/部分退款、乱序版本 | 旧维度被回退,新维度写入,最终等于最新快照 |
| 事务原子性 | 在"旧桶已回退、新桶未写入"时注入异常 | 状态、收件箱、旧桶都回滚,重投后收敛 |
| 缓存 | 事务提交后删除缓存失败 | 最多返回 TTL 内旧数据,数据库值不受影响 |
| 日桶 | 日终重跑、迟到修正 | 日桶与分钟桶口径在互斥范围内一致 |
| 容量 | 峰值两倍事件速率 + 目标轮询并发 | 水位、p95、数据库扫描行数均在预算内 |
完成这些集成测试只能证明隔离环境中的数据库语义和核心逻辑;它不能证明生产消息权限、源端 Binlog 保留期、真实网络抖动和峰值数据分布。因此上线仍需要灰度来源、按事件 ID 的链路日志、日级对账阈值和可回退到旧只读查询的开关。开关只用于查询回退,不能把实时主路径重新指向跨库全量扫描。
失败路径、对账与边界:快不等于数据可信
常见失败路径
| 失败或反例 | 错误做法 | 应有处理 |
|---|---|---|
| 消费者提交后重启 | 假定消息不会重投 | 订单版本状态 + 原子更新,保证重复无副作用 |
| 订单退款或取消 | 只追加支付金额 | 写入负增量或按版本计算新旧差值 |
| 迟到事件进入上一小时 | 关闭旧桶后直接丢弃 | 在延迟窗口内回写,超窗进入修正和对账 |
| 单一来源阻塞 | 整批任务失败或跳过来源 | 来源独立位点、隔离重试、暴露降级状态 |
| 源库时间相同 | 仅以 update_time > last_time 拉取 |
使用 (update_time, id) 复合游标或 CDC 位点 |
| 多实例同时归集 | 仅依靠本地定时器 | 分区消费、租约/分布式锁或数据库原子认领 |
| 聚合表重复重跑 | 只做追加插入 | 唯一键、版本状态和可重放修正任务 |
对账不是失败补丁,而是设计的一部分
实时归集追求低延迟,源端数据可能晚到或被人工修复;因此必须保留有限周期的重算能力。建议每日在低峰期按来源、日期、门店、渠道重新核对"原始订单口径"和"聚合桶口径",将差异写入 gmv_reconcile_diff。
对账结果应包含差异金额、差异订单数、最早/最晚事件时间、来源位点和修正批次。只有差异可追踪,才可以安全自动修复。全表重算可以作为离线对账路径,但不能作为每几秒刷新的在线路径。
面向存量系统的最小迁移路径
不建议先把现有定时器改成每 5 秒执行一次。最小迁移应保留已有离线汇总作为对账基准,同时旁路建立实时归集:
- 定义口径与边界:确认 GMV 是否包含退款、取消与不同渠道;画出月、日、小时、分钟四层的互斥时间范围。
- 补齐现场事实 :对原始订单表执行
SHOW CREATE TABLE、EXPLAIN ANALYZE,确认时间列、订单主键、更新时间、退款字段和索引;不要凭 ORM 实体或代码注释假定索引存在。 - 建立实时桶和处理状态表 :金额使用
DECIMAL,定义唯一键和按分钟/维度的索引;先以单一来源、小流量灰度。 - 实现游标采集器 :以
(update_time, order_id)或 CDC 位点读取增量;状态与桶在同一事务提交后才更新位点。 - 旁路比对:新桶结果与离线统计按分钟、渠道、门店对比;差异未解释前不切换查询流量。
- 切换查询:高频展示接口只读取新聚合表;旧查询仅用于管理员对账,不再暴露给高频页面。
- 扩大来源和容量:每增加一个来源,单独观察延迟、重复率和源库压力;不要把所有库同时接入后才定位问题。
常见遗留风险是金额字段被保存为字符串,导致数据库聚合产生隐式转换;应统一为数据库 DECIMAL 和 Java BigDecimal。动态库表名必须采用受控白名单,日期、比例等值参数应使用绑定参数,避免注入风险与执行计划缓存失效。
验证、容量验收与上线门槛
必须先测量的指标
| 指标 | 计算方式 | 建议验收表达 |
|---|---|---|
| 端到端延迟 | now - occurred_at 的 p50/p95/p99 |
例如 p95 小于业务允许的新鲜度 |
| 归集吞吐 | 每秒成功应用事件数 | 高于峰值事件速率并留出扩容余量 |
| 重复率 | 重复事件数 / 接收事件数 | 重放时可升高,但聚合结果不得变化 |
| 漏数差异 | 对账差异金额与订单数 | 在约定延迟窗口收敛到阈值内 |
| API 延迟 | 聚合接口 p95/p99 | 与原始订单总行数增长尽量解耦 |
| 源库影响 | 慢查询、CPU、扫描行数 | 高频展示不得新增大范围扫描 |
| 水位滞后 | now - checkpoint_time |
超阈值时 API 明确降级 |
可复现的验证清单
- 为同一订单连续注入创建、重复创建、支付修正、退款和迟到事件,断言最终桶金额等于订单最新有效金额,而不是事件金额之和。
- 在"状态更新成功、位点尚未推进"之间模拟进程中断,重启后验证事件重放不改变聚合结果。
- 在多实例同时消费同一分区或同一游标范围时,验证只有一个实例成功认领,或并发更新仍保持幂等。
- 对峰值两倍事件速率压测,并同时让客户端按目标频率查询;观察 API p95、归集滞后、数据库扫描行数和缓存命中率。
- 按来源和维度执行日级对账;故意制造一笔漏事件,验证告警、差异记录和修正闭环均可观察。
上述是验收方法,不代表任何特定系统已经通过这些测试。真实索引、行数、执行计划、生产延迟和压测结果仍需在隔离环境取得。
结论:先将"计算一次"变成系统不变量,再追求秒级展示
对于大数据量指标归集,正确的扩展方向不是把全量 SQL 调度得更勤,而是让每一笔变化只进入聚合链路一次、允许安全重放一次,并让每一次在线查询只面对有限的桶数据。推荐长期架构为 CDC/Outbox → 可重放事件 → 订单版本幂等 → 分钟级聚合桶 → Redis 短缓存 → 查询 API;无法立即引入 CDC 时,先用复合游标增量拉取并保留对账修正。
上线前必须确认三件事:GMV 口径是否清晰,订单版本/退款是否可表达,位点与聚合是否能原子收敛。任一项未解决时,即使接口很快,也不能将结果视为可信的实时 GMV。