62.2 窗口与水位线
一句话
窗口决定「怎么切数据」,水位线决定「什么时候切完」;窗口类型是业务语义,水位线是正确性与延迟的权衡——两者混在一起调,最后一定说不清「为什么这个数少了」。
四种窗口
| 窗口 | 切法 | 一条事件属于几个窗口 | 状态成本 | 典型用途 |
|---|---|---|---|---|
| 滚动(tumbling) | 固定长度、不重叠 | 1 | 低 | 每分钟 GMV、每小时订单量 |
| 滑动(sliding / hop) | 固定长度、按步长滑动、可重叠 | size / slide(近似) | 中–高 | 5 分钟移动平均、最近 10 分钟失败率 |
| 会话(session) | 按「活动间隔」合并,长度不固定 | 1 | 中(需合并) | 用户行为会话、设备在线段 |
| 全局(global) | 一个无限窗口,靠触发器切分 | 1 | 取决于触发器 | 自定义触发、全局累计 |
时间语义(事件时间/处理时间/摄入时间)的定义与取舍见 62.1 流处理模型与时间语义。
🔑 窗口不是「时间切分函数」这么简单:滚动窗口回答「这一分钟发生了什么」,滑动窗口回答「最近一段时间怎么样」,会话窗口回答「一次行为从哪开始到哪结束」。选错窗口,指标本身就没有业务含义。
水位线的三个要素
| 要素 | 做什么 | 生产上要盯什么 |
|---|---|---|
| 生成 | 从事件时间算 W = max(event_time) - delay | 乱序分布、迟到比例、时钟漂移 |
| 传播 | 多输入算子取各输入水位的 min | 空闲分区、慢分区、背压 |
| 触发 | window_end <= W 时触发 | 触发频率、迟到更新、状态清理时机 |
水位线是单调递增的:一旦声明「≤ W 的数据到齐了」,就不能回退。它也是每个分区独立推进的;算子有多个输入时,输出水位取所有输入的最小值,否则快分区会把水位推过慢分区,慢分区的数据会被误判迟到。
⚠️ 一个空闲分区会让整个作业的水位停住。Flink 的
table.exec.source.idle-timeout或 DataStream 的withIdleness就是把「长时间没有数据的分区」标记为空闲,让它不再拖住全局水位。
代码:三种延迟,三种完整度
事件时间与到达时间故意错开:A14 比 A11 先到,B3 迟到,A9 更晚。
package main
import (
"fmt"
"sort"
"strings"
)
type Ev struct {
ID string
User string
Ts int // 事件时间(秒)
Arr int // 到达时间(秒)
}
// 到达顺序和事件时间不一致:A14 比 A11 先到,B3 迟到,A9 更晚。
var events = []Ev{
{"A2", "A", 2, 2},
{"A4", "A", 4, 4},
{"A14", "A", 14, 15},
{"A11", "A", 11, 16},
{"B3", "B", 3, 17},
{"B12", "B", 12, 18},
{"A19", "A", 19, 19},
{"A9", "A", 9, 24},
{"A21", "A", 21, 22},
{"B22", "B", 22, 23},
{"A33", "A", 33, 34},
{"B35", "B", 35, 36},
}
const (
size = 10
maxWin = 40
)
type result struct {
counts map[int]int
dropped []string
emitAt map[int]int
maxDelay int
accepted []Ev
}
func winStart(ts int) int { return ts / size * size }
func run(delay int) result {
order := append([]Ev(nil), events...)
sort.Slice(order, func(i, j int) bool { return order[i].Arr < order[j].Arr })
r := result{counts: map[int]int{}, emitAt: map[int]int{}}
closed := map[int]bool{}
maxTS := -1
closeUntil := func(wm, now int) {
for s := 0; s < maxWin; s += size {
if !closed[s] && s+size <= wm {
closed[s] = true
r.emitAt[s] = now
if d := now - (s + size); d > r.maxDelay {
r.maxDelay = d
}
}
}
}
for _, e := range order {
w := winStart(e.Ts)
if closed[w] {
r.dropped = append(r.dropped, e.ID)
continue
}
r.counts[w]++
r.accepted = append(r.accepted, e)
if e.Ts > maxTS {
maxTS = e.Ts
}
closeUntil(maxTS-delay, e.Arr)
}
// 流结束:把所有窗口按最终水位刷出
for s := 0; s < maxWin; s += size {
if !closed[s] {
closed[s] = true
r.emitAt[s] = order[len(order)-1].Arr
if d := r.emitAt[s] - (s + size); d > r.maxDelay {
r.maxDelay = d
}
}
}
return r
}
func main() {
fmt.Println("=== 水位延迟对「完整度 vs 延迟」的影响(10s 滚动窗口)===")
fmt.Printf("%-8s %-18s %-10s %-12s\n", "delay", "counts(0,10,20,30)", "dropped", "max-emit-delay")
for _, d := range []int{0, 5, 20} {
r := run(d)
var cs []string
for s := 0; s < maxWin; s += size {
cs = append(cs, fmt.Sprint(r.counts[s]))
}
fmt.Printf("%-8d %-18s %-10d %ds\n", d, strings.Join(cs, ","), len(r.dropped), r.maxDelay)
}
r5 := run(5)
fmt.Println("\n=== delay=5s 的窗口产出明细 ===")
fmt.Printf("%-10s %-6s %-8s %-5s %s\n", "window", "count", "emit-at", "end", "events")
for s := 0; s < maxWin; s += size {
var ids []string
for _, e := range r5.accepted {
if winStart(e.Ts) == s {
ids = append(ids, e.ID)
}
}
fmt.Printf("%-10s %-6d %-8d %-5d %s\n", fmt.Sprintf("[%02d,%02d)", s, s+size), r5.counts[s], r5.emitAt[s], s+size, strings.Join(ids, ","))
}
fmt.Printf("迟到丢弃:%v\n", r5.dropped)
fmt.Println("\n=== 滑动窗口(size=10s, slide=5s,按事件时间)===")
fmt.Printf("%-14s %s\n", "window", "count")
for s := 0; s < maxWin; s += 5 {
c := 0
for _, e := range r5.accepted {
if e.Ts >= s && e.Ts < s+size {
c++
}
}
fmt.Printf("[%02d,%02d) %d\n", s, s+size, c)
}
fmt.Println("\n=== 会话窗口(gap=5s,按用户,按事件时间)===")
for _, user := range []string{"A", "B"} {
var ts []int
for _, e := range r5.accepted {
if e.User == user {
ts = append(ts, e.Ts)
}
}
sort.Ints(ts)
sessions := [][2]int{}
for _, t := range ts {
if len(sessions) == 0 || t-sessions[len(sessions)-1][1] > 5 {
sessions = append(sessions, [2]int{t, t})
} else {
sessions[len(sessions)-1][1] = t
}
}
fmt.Printf("user %s: %d 个会话 %v\n", user, len(sessions), sessions)
}
fmt.Println("\n结论:水位延迟 0 时 [0,10) 只有 2 条(B3 与 A9 被丢);延迟 5s 补回 B3,但 A9 仍丢;")
fmt.Println(" 延迟 20s 全部收齐,代价是 [0,10) 的结果要等到 34s 才最终确定。")
}
本机实测(macOS,Apple M1,Go 1.27.1):
=== 水位延迟对「完整度 vs 延迟」的影响(10s 滚动窗口)===
delay counts(0,10,20,30) dropped max-emit-delay
0 2,4,2,2 2 5s
5 3,4,2,2 1 14s
20 4,4,2,2 0 24s
=== delay=5s 的窗口产出明细 ===
window count emit-at end events
[00,10) 3 19 10 A2,A4,B3
[10,20) 4 34 20 A14,A11,B12,A19
[20,30) 2 36 30 A21,B22
[30,40) 2 36 40 A33,B35
迟到丢弃:[A9]
=== 滑动窗口(size=10s, slide=5s,按事件时间)===
window count
[00,10) 3
[05,15) 3
[10,20) 4
[15,25) 3
[20,30) 2
[25,35) 1
[30,40) 2
[35,45) 1
=== 会话窗口(gap=5s,按用户,按事件时间)===
user A: 3 个会话 [[2 4] [11 21] [33 33]]
user B: 4 个会话 [[3 3] [12 12] [22 22] [35 35]]
结论:水位延迟 0 时 [0,10) 只有 2 条(B3 与 A9 被丢);延迟 5s 补回 B3,但 A9 仍丢;
延迟 20s 全部收齐,代价是 [0,10) 的结果要等到 34s 才最终确定。
读数:
| 延迟 | [0,10) 计数 | 丢弃 | 最大产出延迟 | 为什么 |
|---|---|---|---|---|
| 0s | 2 | B3、A9 | 5s | A14 到达时 W=14,[0,10) 立刻关闭,B3(17s 才到)被丢 |
| 5s | 3 | A9 | 14s | A14 时 W=9,窗口保持打开;A19 到(W=14)才关闭,B3 被收进来 |
| 20s | 4 | 0 | 24s | A33 到达(34s)才关窗;代价是 [0,10) 等到 34s 才最终确定 |
🔑 水位线是用延迟换完整度的旋钮。本例把延迟从 0 调到 20s,
[0,10)从 2 条变成 4 条,代价是结果晚 24s。关键是先量出「乱序/迟到分布」,再定延迟,而不是拍一个「5 秒」。
滑动窗口:重叠带来的状态放大
| 窗口起点 | 计数 | 包含 |
|---|---|---|
| [0,10) | 3 | A2,A4,B3 |
| [5,15) | 3 | A11,A14,B12 |
| [10,20) | 4 | A11,A14,B12,A19 |
| [15,25) | 3 | A19,A21,B22 |
| [20,30) | 2 | A21,B22 |
| [25,35) | 1 | A33 |
| [30,40) | 2 | A33,B35 |
| [35,45) | 1 | B35 |
一条事件会落在 size/slide 个窗口里(本例 10/5 = 2)。如果 size=1h, slide=1s,每条事件要更新 3600 个窗口——状态和计算量都会爆炸。生产上滑动窗口的步长通常不小于分钟级,且要配状态 TTL。
窗口状态属于 Keyed State,状态后端、checkpoint 与 TTL 见 62.3 状态管理与容错。
会话窗口:合并才是难点
| 用户 | 事件时间 | 会话 |
|---|---|---|
| A | 2,4,11,14,19,21,33 | [2,4]、[11,21]、[33,33] |
| B | 3,12,22,35 | [3,3]、[12,12]、[22,22]、[35,35] |
会话窗口的状态要支持合并:新事件到来时,可能把左右两个会话连起来(例如 gap=5,事件 16 会把 [11,14] 与 [19,21] 合并)。这比滚动窗口的「一个 key 一个计数器」复杂得多,状态后端要支持按 key 的 list/map 合并。
触发、迟到与清理
| 机制 | 作用 | 坑 |
|---|---|---|
| on-time 触发 | 水位越过窗口结束时发结果 | 迟到数据还没到,结果可能被修正 |
| early 触发 | 水位未到先发「早期结果」 | 下游要能处理同一窗口多次更新 |
| late 触发 | allowed lateness 内迟到数据再触发 | 状态不能立刻清,保留时间 = lateness |
| side output | 超过 lateness 的数据旁路输出 | 不配置就静默丢,无法对账 |
| 状态 TTL | 过期清理窗口状态 | TTL 太短,迟到数据无法更新;太长,状态膨胀 |
⚠️ 窗口关闭后立刻删状态,是最常见的「迟到数据修不了」原因。allowed lateness 的意义不是「多等一会」,而是状态多保留一会;保留期结束前,窗口必须还能被更新。
生产落地:指名 → 可抄 → 可验 → 可退
分区内有序、跨分区乱序的来源见 43.3 顺序与分区。
指名:Flink SQL / DataStream(窗口与水位的主场);Kafka Streams 的 TimeWindows / SessionWindows(Kafka 生态内轻量方案);ksqlDB(SQL 化)。
可抄(Flink SQL 1.17+,未验证:本机未安装 Flink):
-- 滚动 10s
SELECT window_start, window_end, COUNT(*) AS cnt
FROM TABLE(TUMBLE(TABLE events, DESCRIPTOR(ts), INTERVAL '10' SECOND))
GROUP BY window_start, window_end;
-- 滑动 10s / 步长 5s
SELECT window_start, window_end, COUNT(*) AS cnt
FROM TABLE(HOP(TABLE events, DESCRIPTOR(ts), INTERVAL '5' SECOND, INTERVAL '10' SECOND))
GROUP BY window_start, window_end;
-- 会话 gap 5s
SELECT window_start, window_end, user_id, COUNT(*) AS cnt
FROM TABLE(SESSION(TABLE events, DESCRIPTOR(ts), INTERVAL '5' SECOND))
GROUP BY window_start, window_end, user_id;
Kafka Streams 侧:
TimeWindows.ofSizeAndGrace(Duration.ofSeconds(10), Duration.ofSeconds(5)); // 10s 窗口 + 5s 允许迟到
SessionWindows.ofInactivityGapWithNoGrace(Duration.ofSeconds(5)); // 5s 会话间隔
可验:本节程序输出 delay 0/5/20 三行,直接看「丢弃数 / 最大产出延迟」;Flink 侧看 Web UI 的 watermark 与 numLateRecordsDropped 指标,迟到率突然上升通常意味着上游乱序变大或水位延迟设小了。可退:调大 WATERMARK ... INTERVAL / allowed lateness,或把迟到数据侧输出到补偿链路;窗口大小与步长变更要重跑历史(60.2 数仓分层 (ODS / DWD / DWS / ADS) 的可重算原则同样适用)。
反模式
| 反模式 | 后果 |
|---|---|
| 水位延迟拍脑袋设 1s | 乱序事件大量丢弃,指标系统性偏低 |
| 水位延迟设成小时级 | 实时链路退化成批处理,告警永远慢半拍 |
| 多分区水位取 max | 快分区推过慢分区,慢分区数据被丢 |
| 滑动窗口 slide 设成 1s | 每条事件更新上千个窗口,状态与 CPU 爆炸 |
| 窗口关闭立即清状态 | 迟到数据无法更新,历史窗口永远错 |
| 会话窗口不支持合并 | 同一个用户的会话被切成多段 |
| 窗口结果只 append | 同一窗口多条记录,查询重复计数 |
| 只看窗口大小不看业务周期 | 1 分钟窗口跨不过结算周期,指标无意义 |
自检
Q1 滚动、滑动、会话窗口分别回答什么业务问题?各举一个例子。
▸ 答案:滚动窗口回答「固定周期内发生了什么」(每分钟 GMV、每小时订单量);滑动窗口回答「最近一段时间怎么样」(最近 10 分钟失败率、5 分钟移动平均);会话窗口回答「一次行为从哪里开始、到哪里结束」(用户点击流、设备在线段)。选窗口的依据是业务问题的语义,不是「哪个实现简单」。
Q2 本节把水位延迟从 0 调到 5s,[0,10) 的计数为什么从 2 变成 3?
▸ 答案:延迟 0 时,A14(到达 15s)把最大事件时间推到 14,水位 W=14,[0,10) 立即关闭;B3 在 17s 才到,已被判迟到丢弃。延迟 5s 时,A14 到达后 W=9,[0,10) 仍开着;直到 A19 到达(W=14)才关闭,此时 B3 已经进来,所以计数从 2 变 3。
Q3 延迟 20s 时为什么 [0,10) 能收到 4 条,代价是什么?
▸ 答案:延迟 20s 让水位推进很慢,[0,10) 直到 A33 到达(34s,水位 13s)才关闭,A9(24s 到)也被收进来,所以是 A2、A4、B3、A9 四条。代价是结果产出延迟 24s(窗口结束 10s,最终确定 34s),实时性下降。
Q4 多输入算子的水位为什么要取 min?空闲分区会怎样?
▸ 答案:输出水位代表「所有输入都至少到这里」,取 max 会让快输入把水位推过慢输入,慢输入中事件时间较早的数据会被误判迟到。取 min 保证没有输入被落下。空闲分区没有新事件,水位不推进,会把全局水位拖住;要用 idle source 标记(Flink 的 withIdleness / table.exec.source.idle-timeout)把它排除。
Q5 allowed lateness 与 side output 的区别是什么?
▸ 答案:allowed lateness 是「窗口关闭后仍保留状态一段时间」,迟到数据可以继续更新窗口并重新触发结果;side output 是「超过 lateness 的数据不再进入主窗口,而是写到旁路流」。前者修正主结果,后者保留证据/离线补偿。生产上通常两者都要:lateness 覆盖常见迟到,side output 兜底极端迟到。
Q6 滑动窗口 size=1h, slide=1s 为什么会把状态打爆?
▸ 答案:每条事件属于 size/slide = 3600 个窗口,每个窗口都要维护计数/聚合状态;一个 key 每秒一条事件,就要更新 3600 个窗口,状态条目数和 CPU 都按 3600 倍放大。滑动窗口的步长要按业务精度定,通常分钟级;更细的滑动需求用预聚合/降采样,而不是直接开小 slide。
Q7 窗口大小和会话 gap 应该怎么定?
▸ 答案:窗口大小由业务周期和决策频率决定(日报用天、实时监控用分钟),且要能整除业务周期,避免跨周期边界;会话 gap 由「用户两次行为间隔」的分布决定,通常取 P90–P95 的间隔,太小会把一次会话切碎,太大则把无关行为合并。定完要用历史数据回放验证,而不是拍一个整数。