62.6 实时数仓

一句话

实时数仓不是「把离线 SQL 改成 Flink SQL」,而是把 ODS→DWD→DWS→ADS 的分层、口径、幂等和回刷搬到持续更新的链路上:每条结果都要能被迟到数据修正,也要能被离线重算对齐。

Lambda 还是 Kappa

Lambda Kappa
链路 批处理(全量/历史)+ 流处理(增量)两条 只有流处理一条,历史靠重放
一致性 两套代码、两套口径,容易不一致 一套代码,口径天然一致
延迟 批链路 T+1,流链路分钟级 全链路分钟级
成本 两套集群/两套逻辑 一套逻辑,但重放成本高
适合 批逻辑复杂、流逻辑简单 流逻辑能覆盖全量、Kafka/Iceberg 可重放

🔑 选 Lambda 还是 Kappa,判据不是「新不新」,而是历史重放的成本:如果 Kafka 保留期 + 对象存储能支撑「从 0 重放」,Kappa 更简单;如果历史要跑复杂的批 SQL、重放一次要几小时,Lambda 更现实。很多团队实际是「Kappa 的实时链路 + 批处理做对账/回刷」的混合形态。

实时数仓的四层

层 存什么 更新方式 典型存储 保留
ODS 原始事件(订单、支付、退款) append-only Kafka 3–7 天(按重放需求)
DWD 去重后的明细、最新状态 upsert(按业务主键) Kafka compacted / Iceberg / Hudi 数月–数年
DWS 窗口聚合(1min/5min/1h) upsert(窗口+维度) OLAP / Kafka 数月
ADS 应用指标/宽表 upsert(应用主键) ClickHouse / Doris / StarRocks 按合规
Kafka(ODS) ──> Flink(DWD: 去重/维表 join) ──> Flink(DWS: 窗口聚合)
                                                   │
                                                   ├──> OLAP(ADS, upsert) ──> BI/API
                                                   └──> Iceberg(明细/回放) ──> 离线对账/回刷

🔑 实时数仓和离线数仓的分层是同一套逻辑,区别在「更新方式」:离线按分区覆盖,实时按主键 upsert。分层与口径的主场在 60.2 数仓分层 (ODS / DWD / DWS / ADS),这里只讲实时链路的增量部分。

四个必须

窗口与水位见 62.2 窗口与水位线,端到端提交见 62.4 Exactly-once 语义。

① 事件时间,不是处理时间

实时指标必须按事件时间聚合,否则迟到/积压会让结果漂移(见 62.1 流处理模型与时间语义)。水位延迟决定「等多久」,allowed lateness 决定「关窗后还能不能修」。

② 去重:业务主键 + 版本

实时链路至少一次投递是常态:Kafka 重试、Flink 重启、上游重复推送都会带来重复。去重键要用业务主键(order_id),版本用 updated_at/CDC LSN/版本号;不能用作消息 ID(每次重投都变)。

③ Upsert:同一主键覆盖旧值

OLAP 表必须支持按主键 upsert;append-only 表会把「同一窗口的旧版本 + 新版本」都算进去。下面的实测会看到:append-only 的 gross 是 1120,upsert 是 920,差的 200 就是旧版本。

④ 对账:实时结果必须能被离线重算验证

实时链路再 exactly-once,也可能有口径 bug、维表过期、迟到丢失。必须有一条离线链路,从原始事件重算同一批窗口,与实时 OLAP 对比;差异为 0 才敢说「实时数仓可信」。

代码:实时聚合 + 迟到退款 + 去重 + 对账

package main

import (
	"fmt"
	"sort"
)

type Order struct {
	ID        string
	User      string
	Amount    int
	Status    string
	EventTime int
	Arrive    int
	Version   int
}

type Window struct {
	Start, End         int
	Region             string
	Gross, Refund, Net int
	Version            int
	First, Last        int
	Closed             int
}

var regionOf = map[string]string{"u1": "east", "u2": "east", "u3": "west", "u4": "west"}

// 到达顺序:o1 的重复投递在 100s;o2 的退款在 150s 才到(事件时间仍是 15s)。
var events = []Order{
	{"o1", "u1", 100, "paid", 10, 12, 1},
	{"o2", "u3", 200, "paid", 15, 16, 1},
	{"o3", "u2", 50, "paid", 65, 70, 1},
	{"o4", "u4", 300, "paid", 80, 82, 1},
	{"o1", "u1", 100, "paid", 10, 100, 1}, // 重复投递
	{"o5", "u1", 120, "paid", 130, 132, 1},
	{"o2", "u3", 200, "refunded", 15, 150, 2}, // 迟到退款
	{"o6", "u3", 80, "paid", 190, 195, 1},
	{"o7", "u2", 70, "paid", 250, 252, 1},
}

const watermarkDelay = 30

type Pipeline struct {
	orders     map[string]Order
	olap       map[string]*Window
	appendRows []Window
	maxET      int
	dupSkipped int
}

func key(start int, region string) string { return fmt.Sprintf("%02d|%s", start, region) }

func (p *Pipeline) accept(o Order) bool {
	old, ok := p.orders[o.ID]
	if ok && o.Version <= old.Version {
		p.dupSkipped++
		return false
	}
	p.orders[o.ID] = o
	p.recompute(o)
	if o.EventTime > p.maxET {
		p.maxET = o.EventTime
	}
	p.closeWindows(o.Arrive)
	return true
}

func (p *Pipeline) recompute(o Order) {
	start := o.EventTime / 60 * 60
	region := regionOf[o.User]
	var gross, refund int
	for _, x := range p.orders {
		if x.EventTime/60*60 != start || regionOf[x.User] != region {
			continue
		}
		gross += x.Amount
		if x.Status == "refunded" {
			refund += x.Amount
		}
	}
	k := key(start, region)
	w, ok := p.olap[k]
	if !ok {
		w = &Window{Start: start, End: start + 60, Region: region, First: o.Arrive}
		p.olap[k] = w
	}
	w.Gross, w.Refund, w.Net = gross, refund, gross-refund
	w.Version++
	w.Last = o.Arrive
	cp := *w
	p.appendRows = append(p.appendRows, cp)
}

func (p *Pipeline) closeWindows(now int) {
	wm := p.maxET - watermarkDelay
	for _, w := range p.olap {
		if w.Closed == 0 && w.End <= wm {
			w.Closed = now
		}
	}
}

func (p *Pipeline) flush(at int) {
	for _, w := range p.olap {
		if w.Closed == 0 {
			w.Closed = at // 流结束:把剩余窗口按最终水位刷出
		}
	}
}

func (p *Pipeline) dump(title string) {
	fmt.Printf("\n=== %s ===\n", title)
	fmt.Printf("%-10s %-7s %-7s %-7s %-6s %-5s %-6s %-6s %-6s\n",
		"window", "region", "gross", "refund", "net", "ver", "first", "last", "closed")
	ws := make([]*Window, 0, len(p.olap))
	for _, w := range p.olap {
		ws = append(ws, w)
	}
	sort.Slice(ws, func(i, j int) bool {
		if ws[i].Start != ws[j].Start {
			return ws[i].Start < ws[j].Start
		}
		return ws[i].Region < ws[j].Region
	})
	for _, w := range ws {
		fmt.Printf("[%02d,%02d)  %-7s %-7d %-7d %-7d %-6d %-5d %-6d %-6d\n",
			w.Start, w.End, w.Region, w.Gross, w.Refund, w.Net, w.Version, w.First, w.Last, w.Closed)
	}
}

func (p *Pipeline) batch() map[string][3]int {
	m := map[string][3]int{}
	for _, o := range p.orders {
		k := key(o.EventTime/60*60, regionOf[o.User])
		v := m[k]
		v[0] += o.Amount
		if o.Status == "refunded" {
			v[1] += o.Amount
		}
		v[2] = v[0] - v[1]
		m[k] = v
	}
	return m
}

func main() {
	p := &Pipeline{orders: map[string]Order{}, olap: map[string]*Window{}}

	for _, o := range events[:6] { // 到 o5 为止
		p.accept(o)
	}
	p.dump("迟到退款前(o5 刚写入,[0,60) 已按水位关闭)")

	p.accept(events[6]) // o2 退款
	p.dump("迟到退款后(o2 从 paid 改为 refunded,upsert 覆盖旧版本)")

	for _, o := range events[7:] {
		p.accept(o)
	}
	p.flush(252)
	p.dump("最终结果(与批处理重算对比)")

	batch := p.batch()
	diff := 0
	for k, v := range batch {
		w := p.olap[k]
		if w == nil || w.Gross != v[0] || w.Refund != v[1] || w.Net != v[2] {
			diff++
		}
	}
	gross, net := 0, 0
	for _, w := range p.olap {
		gross += w.Gross
		net += w.Net
	}
	fmt.Printf("\n批处理重算:%d 个窗口,与实时 OLAP 差异 %d 个;实时总 gross=%d net=%d\n", len(batch), diff, gross, net)

	ag, an := 0, 0
	for _, w := range p.appendRows {
		ag += w.Gross
		an += w.Net
	}
	fmt.Printf("append-only 反例:写入 %d 行,按行求和 gross=%d net=%d(重复计算旧版本)\n",
		len(p.appendRows), ag, an)
	fmt.Printf("去重:跳过 %d 条重复投递;若不跳过,gross 还会多 100\n", p.dupSkipped)
	fmt.Println("迟到修正:o2 的事件时间是 15s,但直到 150s 才到;[0,60) west 的版本从 v1 升到 v2。")
}

本机实测(macOS,Apple M1,Go 1.27.1):

=== 迟到退款前(o5 刚写入,[0,60) 已按水位关闭) ===
window     region  gross   refund  net    ver   first  last   closed
[00,60)  east    100     0       100     1      12    12     132   
[00,60)  west    200     0       200     1      16    16     132   
[60,120)  east    50      0       50      1      70    70     0     
[60,120)  west    300     0       300     1      82    82     0     
[120,180)  east    120     0       120     1      132   132    0     

=== 迟到退款后(o2 从 paid 改为 refunded,upsert 覆盖旧版本) ===
window     region  gross   refund  net    ver   first  last   closed
[00,60)  east    100     0       100     1      12    12     132   
[00,60)  west    200     200     0       2      16    150    132   
[60,120)  east    50      0       50      1      70    70     0     
[60,120)  west    300     0       300     1      82    82     0     
[120,180)  east    120     0       120     1      132   132    0     

=== 最终结果(与批处理重算对比) ===
window     region  gross   refund  net    ver   first  last   closed
[00,60)  east    100     0       100     1      12    12     132   
[00,60)  west    200     200     0       2      16    150    132   
[60,120)  east    50      0       50      1      70    70     195   
[60,120)  west    300     0       300     1      82    82     195   
[120,180)  east    120     0       120     1      132   132    252   
[180,240)  west    80      0       80      1      195   195    252   
[240,300)  east    70      0       70      1      252   252    252   

批处理重算:7 个窗口,与实时 OLAP 差异 0 个;实时总 gross=920 net=720
append-only 反例:写入 8 行,按行求和 gross=1120 net=920(重复计算旧版本)
去重:跳过 1 条重复投递;若不跳过,gross 还会多 100
迟到修正:o2 的事件时间是 15s,但直到 150s 才到;[0,60) west 的版本从 v1 升到 v2。

读数:

现象 数字 说明
退款前 [0,60) west gross 200 / net 200 只有 o2 的支付,尚未退款
退款后同窗口 gross 200 / refund 200 / net 0,v2 迟到 135s 的退款仍被接受(allowed lateness)
最终对账 7 个窗口,差异 0 实时 OLAP 与批处理重算一致
总量 gross 920 / net 720 只有 o2 一笔退款 200;o5 是 paid,不是退款
append-only 8 行,gross 1120 / net 920 旧版本被重复相加,gross 多 200
去重 跳过 1 条重复投递 若不跳过,gross 再多 100

⚠️ 上表的净额 720 只扣了 o2 的 200 退款;[120,180) 的 o5 是 paid,没有退款。如果业务把「退款」定义成独立负向流水,而不是改订单状态,实时链路要按负向金额累加,不能只改状态——这是实时数仓最常见的口径分歧之一。

迟到数据:关窗后还能不能修

本例 [0,60) 在 132s 关闭(水位 100s),o2 的退款在 150s 才到,仍然被接受并 upsert 成 v2。原因是代码没有硬性拒绝「关窗后的迟到」,而是保留了窗口状态;生产上对应 allowed lateness:窗口关闭后状态再保留一段时间(比如 5 分钟),期间到达的数据继续更新结果。

超过 allowed lateness 的三种处理:

策略 做法 适用
丢弃 + 指标 记录 late_dropped 计数与样本 可接受少量丢失
侧输出 写到迟到流,离线修正 必须完整、可对账
重放 从 Kafka/Iceberg 重算受影响窗口 大范围错误、回刷

🔑 实时数仓的「实时」不是「一锤定音」,而是「先给一个可用的近似值,再持续修正到最终值」。下游必须能接受同一窗口的多次更新(upsert/retract),否则只能等最终值,失去实时意义。

Serving:OLAP 表怎么接住 upsert

引擎 表模型 去重/更新 查询注意
ClickHouse ReplacingMergeTree(version) 后台合并按 version 保留最新;查询 FINAL 或 argMax FINAL 有性能代价;用 argMax 更可控
Apache Doris UNIQUE KEY(Merge-on-Write) 按主键覆盖,导入即合并 主键要包含窗口+维度,否则覆盖错行
StarRocks PRIMARY KEY 按主键 upsert 主键顺序影响前缀索引

ClickHouse 示例(未验证:本机未安装 ClickHouse):

CREATE TABLE gmv_1m (
  window_start DateTime,
  region       String,
  gross        Decimal(18,2),
  refund       Decimal(18,2),
  net          Decimal(18,2),
  version      UInt64
) ENGINE = ReplacingMergeTree(version)
ORDER BY (window_start, region);

-- 查询:同一主键取最新版本
SELECT window_start, region,
       argMax(gross, version)  AS gross,
       argMax(refund, version) AS refund,
       argMax(net, version)    AS net
FROM gmv_1m
GROUP BY window_start, region;

⚠️ upsert 的主键必须包含「窗口 + 所有查询维度 + 租户」。少一个维度,不同维度的结果会互相覆盖;多一个高基数列(比如 order_id),表会膨胀成明细表,失去聚合表的意义。

对账与回刷:实时链路的刹车

对账 比什么 频率 差异处理
实时 vs 批处理 同窗口 gross/refund/net 每小时/每天 差异 > 0 先查口径,再查迟到/重复
实时 vs 源系统 订单数、支付金额 每天 漏采、重复、状态未同步
DWD vs ODS 去重后行数、主键唯一 每小时 去重键/版本错

回刷(backfill)的步骤:

  1. 确定受影响窗口(按事件时间,不是处理时间);
  2. 从 Kafka(保留期内)或 Iceberg(长期)重放;
  3. 用同一套 Flink SQL/代码,写同一张 OLAP 表(upsert 幂等);
  4. 回刷前后各跑一次对账,差异为 0 才算完成。

🔑 回刷能力是实时数仓的「后悔药」:没有原始事件留存、没有可重放的链路,口径错了只能全量重做。Kafka 保留期与 Iceberg 明细表,至少要有一个能覆盖业务的最大回溯窗口。

生产落地:指名 → 可抄 → 可验 → 可退

指名:Flink(实时计算)+ Kafka(ODS/明细)+ ClickHouse / Apache Doris / StarRocks(ADS serving)+ Iceberg(明细/回放/对账);调度与回刷用 Airflow/DolphinScheduler。

可抄(Doris 3.x,未验证:本机未安装 Doris):

CREATE TABLE gmv_1m (
  window_start DATETIME,
  region       VARCHAR(32),
  gross        DECIMAL(18,2),
  refund       DECIMAL(18,2),
  net          DECIMAL(18,2),
  version      BIGINT
) UNIQUE KEY(window_start, region)
DISTRIBUTED BY HASH(region) BUCKETS 8
PROPERTIES ("replication_num" = "3");

可验:本节程序输出「实时 OLAP vs 批处理差异 0」;生产上跑对账 SQL:

-- 同一窗口,实时与离线 gross 的差异(应为 0)
SELECT r.window_start, r.region, r.gross AS rt_gross, b.gross AS batch_gross,
       r.gross - b.gross AS diff
FROM gmv_1m r
JOIN gmv_1m_batch b USING (window_start, region)
WHERE r.gross != b.gross;

可退:OLAP 表按窗口分区,可整分区重刷;Flink 作业用 savepoint 回滚;关闭实时链路时,BI 切到离线表并标注「数据延迟」;保留 Iceberg 明细,任何口径错误都能重算。

反模式

反模式 后果
ADS 用 append-only 表 同一窗口旧版本 + 新版本都算,gross 虚高(本节 1120 vs 920)
不去重,靠「应该不会重复」 Kafka 重试/重启就重复,指标偏高
用消息 ID 去重 重投时 ID 变了,去重失效
维表 join 不带时间版本 退款/改地址后历史窗口被「现在」的维度改写
迟到数据直接丢且不计数 丢了多少不知道,对账对不上
实时与离线两套口径 同一个 GMV 两个数,争议时无法证明谁对
OLAP 主键漏维度/租户 不同维度互相覆盖,数据串行
没有原始事件/回放能力 口径错了只能全量重做

自检

Q1 Lambda 与 Kappa 怎么选?判据是什么?

▸ 答案:判据是「历史重放的成本」与「批逻辑复杂度」。如果 Kafka/Iceberg 能在可接受时间内从 0 重放,且流逻辑能覆盖全量,选 Kappa(一套代码、口径一致);如果历史要跑复杂批 SQL、重放一次几小时,选 Lambda(实时链路 + 批处理对账/回刷)。很多生产系统是混合:实时 Kappa + 离线批对账。

Q2 实时数仓的四层各存什么、怎么更新?

▸ 答案:ODS 存原始事件,append-only(Kafka);DWD 存去重后的明细/最新状态,按业务主键 upsert(Kafka compacted/Iceberg/Hudi);DWS 存窗口聚合,按「窗口+维度」upsert;ADS 存应用指标/宽表,按应用主键 upsert 到 OLAP。与离线数仓的分层相同,区别是「分区覆盖」变成「主键 upsert」。

Q3 本节 append-only 的 gross 为什么是 1120,而 upsert 是 920?

▸ 答案:[0,60) west 先写入 gross 200(v1),退款后又写入 gross 200 / refund 200(v2)。append-only 把两行都相加,gross 变 400;upsert 按 (window, region) 覆盖,只保留 v2 的 200。总量差 200。append-only 的 8 行里有一行是旧版本,查询端不按版本去重就会重复计数。

Q4 实时链路的去重键和版本应该怎么选?

▸ 答案:去重键用业务主键(order_id),版本用业务可比较的字段(updated_at、CDC LSN、版本号)。不能用消息 ID(每次重投都变),也不能用处理时间(同一事件重放时间不同)。同一主键只保留版本最大的记录;版本相同视为重复,跳过。

Q5 迟到数据在窗口关闭后到达,有哪几种处理方式?

▸ 答案:① allowed lateness:窗口状态保留一段时间,迟到数据继续更新并重新触发;② 侧输出:超过 lateness 的数据写到迟到流,离线修正;③ 丢弃并计数;④ 从 Kafka/Iceberg 重放受影响窗口。选择依据是「能不能接受少量丢失」与「是否必须对账到 0 差异」。本例 o2 在关窗后 18s 到达,仍在 lateness 内,所以 upsert 成 v2。

Q6 实时数仓的维表 join 为什么要带时间版本?

▸ 答案:维表属性会变(用户等级、商品类目、门店归属)。如果实时 join 只用「当前维表」,历史窗口会被新属性改写,同一个订单在不同时间跑出的结果不同。要用 temporal join(按事件时间找当时有效的维度版本),或把维度属性快照进事实表,保证结果可重放。

Q7 实时结果怎么对账、怎么回刷?

▸ 答案:对账用离线链路从原始事件重算同一批窗口,与实时 OLAP 比 gross/refund/net,差异应为 0;差异先查口径,再查迟到/重复/维表。回刷按事件时间确定受影响窗口,从 Kafka(保留期内)或 Iceberg 重放,用同一套逻辑 upsert 回 OLAP,回刷前后各跑一次对账。没有原始事件留存就没有回刷能力。

关联