D-WINDOW —— 窗口指标扩展规范¶
扩展
datus-ext/1。已在 DuckDB 和真实数仓语料上做过执行级验证 (各方言状态见design/window-metrics-v2.md§1)。
版本: 1.3(datus-ext)
目标¶
- 声明式窗口指标:在已有的 OSI 指标定义之上表达期间对比、滚动聚合, 以及累计(running / period-to-date)值 —— 而不必在指标表达式里写裸窗口 SQL(引擎会拒绝,见边界)。
- 100% 合法的 OSI 文档:扩展搭载在一个
Metric的custom_extensions里、DATUS厂商信封之下。一个标准 OSI 的消费方看到的就是一个普通聚合指标;dosi --osi-basic会忽略该扩展并给出告警。 - 四个规范族,三种语法糖:每个窗口指标都归一化为
offset(日历移位的对比,向前或向后)、
frame(有序的再聚合 —— 按时间或按值排序,ROWS 或 RANGE)、
rank(对指标值做 SQL:2003 排名),
或 value(SQL:2011 的首/末/第 n 个导航)。
pop/rolling/cumulative这三种语法糖纯粹是书写便利, 它们展开成同样的内部形态。 - 重算语义:窗口指标是派生值。查询的维度或时间粒度一变, 引擎就在新粒度上重新计算基础聚合并重新求值窗口, 它绝不会对窗口输出再做聚合。
目录¶
- 载体与信封
- 枚举
window载荷- 通用形态 ——
offset - 通用形态 ——
frame - 通用形态 ——
rank - 通用形态 ——
value - 共享修饰符 ——
order/partition - 语法糖 ——
pop - 语法糖 ——
rolling - 语法糖 ——
cumulative - 时间轴解析
- 编译语义
- 查询窗口指标
- 校验
- 限制
- 可扩展性模型与路线图
- 边界
- 版本历史
载体与信封¶
该扩展承载在 Metric 的 custom_extensions 上,厂商为 DATUS,
载荷键为 window。指标自己的 expression 就是窗口据以派生的基础聚合:
SQL 只写一次,窗口叠加在它之上。
- name: revenue_mom_growth
description: Month-over-month revenue growth rate
expression:
dialects:
- dialect: ANSI_SQL
expression: SUM(orders.amount)
custom_extensions:
- vendor_name: DATUS
data: '{"v": 1, "window": {"type": "pop", "offset": "1 month"}}'
默认(键不存在时): 该指标就是它朴素的基础聚合,不做任何派生。
指标的基础表达式必须能推断为单个朴素聚合(MetricKind::Aggregate)。
比率型和表达式型指标在 v1 里不能携带 window(见校验)。
枚举¶
粒度¶
时间粒度,与 D-TIME / D-GRAIN 共用。
| 取值 | 说明 |
|---|---|
day |
|
week |
周的起点遵循执行方言的 DATE_TRUNC('week', …) 约定(DuckDB 上是 ISO 周一)。 |
month |
|
quarter |
|
year |
计算方式(offset 族)¶
当前值与偏移(参照)值之间的关系。
设 cur = 当前分桶的基础值,prev = count × granularity 之前那个值。
| 取值 | 结果 | NULL 行为 |
|---|---|---|
value |
prev |
移位键处不存在分桶时为 NULL |
delta |
cur - prev |
prev 为 NULL 时为 NULL |
percent_change |
(cur - prev) / prev,空值安全 |
prev 为 NULL 或 0 时为 NULL |
ratio |
cur / prev,空值安全 |
prev 为 NULL 或 0 时为 NULL |
除法一律是非整数除法(CAST(… AS DOUBLE)),分母为零时产出 NULL
(NULLIF(prev, 0))。窗口输出为 NULL 在语义上是有意义的
("没有可比的上一期")—— 引擎的 D-FILL fill_nulls_with 不会作用于窗口输出。
函数(frame 族)¶
作用在按序排列的各分桶基础值构成的窗框之上的再聚合。 这是登记表层级 W1 —— 可移植的核心,而不是上限; SQL:2003 对齐的各层级(统计聚合、排名、值/导航) 以及各自解锁了什么,见函数登记表。
| 取值 | 层级 | 说明 |
|---|---|---|
sum |
W1 | |
avg |
W1 | |
min |
W1 | |
max |
W1 | |
count |
W1 | 计的是窗框行数(窗框上的 COUNT(*);在 units: range 下,并列的行互为 peer 并共享同一计数)。基础值不是它的参数。 |
stddev_pop / stddev_samp |
W2 (1.3) | 只接受显式的 _pop/_samp 形式 —— 裸的 STDDEV/VARIANCE 在不同引擎上含义不同,会被拒绝。 |
var_pop / var_samp |
W2 (1.3) | |
covar_pop / covar_samp / corr |
W3 (1.3) | 双参数:需要 second(仅通用 frame 形态)。渲染为 CORR(second, primary)。在 Redshift 上不作为窗口函数提供 —— 结构化的 dialect_unsupported_window_function。 |
指标序列语义:窗框聚合的是每个分桶的指标值,而不是底层的源数据行。 一个去重计数的 3 个月滚动
sum是三个月度去重计数之和 —— 一个在两个月里都活跃的实体会被算两次。 窗框层面的再去重(source_rows求值方式)不在 v1 范围内。
window 载荷¶
window 是一个 JSON 对象。它的形态由可选的 type 判别式来选择:
type |
含义 | 归一化到 |
|---|---|---|
| (不存在) | 通用形态 —— offset / frame 中恰好一个 |
它自己 |
pop |
期间对比语法糖 | offset 族 |
rolling |
尾随 N 个分桶的语法糖 | frame 族 |
cumulative |
累计 / 期初至今的语法糖 | frame 族 |
顶层 schema(所有形态):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type |
string | 否 | pop | rolling | cumulative;不写 = 通用形态 |
offset |
object/string | 视形态而定 | 偏移规格(通用形态 + pop) |
calculation |
string | 视形态而定 | 计算方式(通用 offset 形态 + pop) |
frame |
object | 视形态而定 | 窗框规格(仅通用形态) |
rank |
object | 视形态而定 | 排名规格(仅通用形态,1.3) |
value |
object | 视形态而定 | 值导航规格(仅通用形态,1.3) |
function |
string | 视形态而定 | 函数(rolling / cumulative) |
periods |
integer | 视形态而定 | 以分桶数计的窗框宽度(rolling) |
reset |
string | 视形态而定 | 累计的重置边界(cumulative) |
通用形态恰好设置 offset / frame / rank / value 中的一个。
前向兼容性在构造上就是 fail-closed 的:
window顶层的未知键会被忽略 —— 一个未来的族键(比如rank) 在 v1 引擎上被忽略是安全的,因为那样一来文档里既没有offset也没有frame, 会以结构化错误违反"恰好一个族"的规则,而不是算出错误的值。 未知的type取值则直接拒绝。offset/frame内部的未知键会被拒绝(deny_unknown_fields)—— 一个未来的修饰符键(比如partition或order)会改变某个已有族的语义, 悄悄忽略它就会返回错误数字。拒绝迫使"这个引擎对这份文档来说太旧了" 以invalid_datus_extension的形式浮出水面。
属于另一种形态而非当前所选形态的键同样会被拒绝 ——
例如 type: pop 配上 periods,或者通用形态里出现顶层的 function。
通用形态 —— offset¶
把每个时间分桶与 count × granularity 之前的那个分桶做对比。
Schema¶
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
offset.count |
integer ≥ 1 | 是 | 移位的粒度单位个数 |
offset.granularity |
string | 是 | 移位的粒度 |
offset.direction |
string | 否 | "back"(默认 —— 上一期,LAG)| "forward"(下一期,LEAD;1.3)。仅对象形态可用:"1 month" 这种字符串糖永远是向后。向前的偏移放宽的是时间范围的上界而不是下界,输出也相应地被裁剪回去。 |
calculation |
string | 是 | 计算方式;在通用形态里必填(默认值属于语法糖) |
这个移位在有缺口时也是日历正确的:参照值取恰好落在移位后日历键上的那个分桶,
若该分桶没有数据则为 NULL,绝不会取成"上一条存在的行"。
granularity 可以比查询粒度更粗(在月度分桶上做同比:
DATE_TRUNC('month', t) - INTERVAL 1 YEAR 恰好落在去年同月)。
通用形态 —— frame¶
在一个以当前分桶结尾的尾随窗框上,对按序排列的各分桶基础值序列做再聚合。
Schema¶
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
frame.function |
string | 是 | 函数 |
frame.preceding |
integer ≥ 0 | "unbounded" |
是 | 窗框起点:n = n PRECEDING;"unbounded" = UNBOUNDED PRECEDING。窗框总是结束于 CURRENT ROW。 |
frame.reset |
string | 否 | week | month | quarter | year —— 在该粒度的每个边界处重新开始累计。只有配合 "unbounded" 才有意义;也接受配合数值型 preceding(那时窗框会在边界处被裁断)。不写 = 永不重置。 |
frame.require_full_window |
boolean | 否(默认 false) |
对窗框内行数少于 preceding + 1 的分桶输出 NULL,而不是对一个不完整的头部窗框做聚合。仅限有限的 preceding —— 配合 "unbounded" 且为 true 时载荷会被拒绝(无界窗框永远不会不完整)。在 units: "range" 下会被拒绝(在并列 peer 之上数窗框行数是未定义的)。这是族内的 1.2 新增:早于它的引擎会拒绝这个键(invalid_datus_extension),而绝不会丢弃它。 |
frame.order |
object | 否 | 共享修饰符(1.3)。默认 {"by": "time", "direction": "asc"} —— 即 v1 的行为。order.by: "value" 表示按指标自身的分桶值排序,并禁止 reset(没有时间排序的时间分区是不自洽的)。 |
frame.partition |
object | 否 | 共享修饰符(1.3)。默认模式 query_dimensions —— 即 v1 的行为。 |
frame.units |
string | 否 | "rows"(默认)| "range"(1.3)。RANGE 要求 preceding: "unbounded" 且 order.by: "value":这是一个按值排序的累计窗框,其中并列的值互为 peer 并被一起聚合(按时间排序的序列每个分桶至多一行,那里 RANGE 等同于 ROWS)。 |
frame.second |
string | 当且仅当是双参数时 | (1.3)covar_pop / covar_samp / corr 的第二个输入序列,写作同一模型中某个朴素单聚合指标的名字(不带窗口,也不是比率型/表达式型)。在编译期解析;基础阶段会同时计算两个序列。仅通用 frame 形态可用。 |
窗框是在聚合后的序列上按行计的(ROWS BETWEEN … AND CURRENT ROW),
而按构造,该序列在每个分区 × 时间分桶上至多一行。
v1 不对缺失的分桶做补齐:一个 "3 preceding" 的窗框覆盖的是前三个已存在的分桶。
offset 没有这个注意事项。
reset: day 会被拒绝:day 已经是最细的粒度,
所以按天重置不可能比任何查询粒度更粗。
通用形态 —— rank¶
每一行的指标值在其分区内的位置 —— 也就是 SQL:2003 的排名函数, 下降时不带 frame 子句。(1.3。)
{"window": {"rank": {"function": "row_number",
"partition": {"mode": "query_dimensions_except",
"exclude": ["activities.ac_code"]}}}}
{"window": {"rank": {"function": "ntile", "buckets": 4}}}
Schema¶
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
rank.function |
string | 是 | row_number | rank | dense_rank | ntile | percent_rank | cume_dist |
rank.buckets |
integer ≥ 1 | 当且仅当是 ntile |
NTILE 的桶数 |
rank.order |
object | 否 | 共享修饰符。默认 {"by": "value", "direction": "desc"} —— 也就是口语里的"取前 N"。 |
rank.partition |
object | 否 | 共享修饰符。默认是 none(一个全局排名)—— 而不是 query_dimensions:一旦实体维度全都落进分区里,在所有非时间维度内部做排名就等于让每一行只跟自己比。 |
确定性:位置型函数(row_number、ntile)会用上引擎的维度决胜键
(非分区、非时间的那些维度,升序 —— 与基准测试的惯例
ORDER BY value DESC, entity 一致);而感知 peer 的函数
(rank、dense_rank、percent_rank、cume_dist)不能这么做 ——
决胜会瓦解掉它们正要排名的那些 peer 组。
在默认的按值排序下,排名指标是不需要时间轴的:查询里可以完全没有时间维度。
order.by: "time" 会把标准的时间轴要求重新打开(即按分桶序号的那种形态)。
排名指标是分区全局的 —— 见限制。
cume_dist 在 ClickHouse 上不可用
(dialect_unsupported_window_function,结构化的 —— 绝不会产出非法 SQL)。
通用形态 —— value¶
导航到分区的第一个/最后一个/第 n 个指标值 —— 也就是 SQL:2011 的值函数,
下降时总是带上显式的完整窗框
(ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING):
做的是分区全域的导航,从而埋掉那个经典陷阱 ——
默认窗框下的 LAST_VALUE 只会停在当前行。(1.3。)
{"window": {"value": {"function": "first_value"}}}
{"window": {"value": {"function": "nth_value", "n": 2}}}
Schema¶
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
value.function |
string | 是 | first_value | last_value | nth_value |
value.n |
integer ≥ 1 | 当且仅当是 nth_value |
导航到的位置 |
value.order |
object | 否 | 共享修饰符。默认 {"by": "time", "direction": "asc"} —— 在指标序列上导航。 |
value.partition |
object | 否 | 共享修饰符。默认 query_dimensions。 |
NULL 的处理一律是 RESPECT NULLS;ignore_nulls 仍是保留字
(SQL:2011 的 IGNORE NULLS 在目标方言之间不可移植)。
导航最终落在一行上,所以维度决胜键总会被追加上去。
值指标是分区全局的 —— 见限制。
维度导航("最早那次活动的编码"):给指标一个在每个分桶内确定的、
作用于该维度的基础聚合(当每个分桶只含一次活动时,MIN(activities.ac_code)
是精确的),然后对它做导航。
共享修饰符 —— order / partition¶
两个修饰符对象打开了 v1 冻结掉的那些自由度
(可扩展性)。
两者都是族内的 1.3 新增:1.2 的引擎会拒绝它们(deny_unknown_fields),
绝不会悄悄算错。
"order": {"by": "time" | "value", "direction": "asc" | "desc"}
"partition": {"mode": "query_dimensions" | "query_dimensions_except"
| "time_bucket" | "none",
"exclude": ["dataset.field", ...]}
order.by:时间轴(time)或指标自身的分桶值(value)。 省略direction时按键取默认:time→asc,value→desc。partition.mode:哪些查询维度构成 PARTITION BY ——query_dimensions(所有非时间维度)、query_dimensions_except(减去exclude)、time_bucket(时间分桶本身 —— 每个分桶自成一个总体), 或none(一个全局窗口)。exclude只能与query_dimensions_except搭配, 必须非空,且每一项都必须是限定的dataset.field形式 (编译期会对照模型校验;裸字段的写法仍是保留的)。- 重算语义:某次查询的 group_by 里本来就没有的被排除维度是个空操作 —— 窗口就在实际被查询到的那些维度上重算。
语法糖 —— pop¶
期间对比。
{"window": {"type": "pop", "offset": "1 month"}}
{"window": {"type": "pop", "offset": "1 year", "calculation": "delta"}}
{"window": {"type": "pop", "offset": {"count": 12, "granularity": "month"}, "calculation": "value"}}
Schema¶
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
offset |
string | object | 是 | "<count> <granularity>"(例如 "1 month"、"2 weeks" —— 允许一个尾随的 s),或对象形态 {count, granularity} |
calculation |
string | 否 | 默认 percent_change —— 也就是"环比/同比"的口语含义 |
禁止的键:frame、function、periods、reset。
展开¶
{type: pop, offset: "1 month"} ≡ {offset: {count: 1, granularity: month},
calculation: percent_change}
{type: pop, offset: "1 year",
calculation: delta} ≡ {offset: {count: 1, granularity: year},
calculation: delta}
语法糖 —— rolling¶
最近 periods 个分桶(含当前分桶)构成的尾随窗口。
Schema¶
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
function |
string | 是 | 函数 |
periods |
integer ≥ 1 | 是 | 窗框内的分桶总数,含当前分桶 |
require_full_window |
boolean | 否(默认 false) |
对窗框内行数少于 periods 的分桶输出 NULL(见通用 frame 形态) |
禁止的键:offset、calculation、frame、reset。
展开¶
{type: rolling, function: avg, periods: 3} ≡ {frame: {function: avg, preceding: 2}}
-- ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
periods: 1 是退化的恒等窗框(只有当前分桶)。
语法糖 —— cumulative¶
从序列起点开始的累计 —— 或者从每个重置边界开始(期初至今:YTD / QTD / MTD)。
{"window": {"type": "cumulative", "function": "sum"}}
{"window": {"type": "cumulative", "function": "sum", "reset": "year"}}
Schema¶
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
function |
string | 是 | 函数 |
reset |
string | 否 | week | month | quarter | year;不写 = 永不重置(一路累计) |
禁止的键:offset、calculation、frame、periods。
展开¶
{type: cumulative, function: sum} ≡ {frame: {function: sum, preceding: "unbounded"}}
{type: cumulative, function: sum, reset: year} ≡ {frame: {function: sum, preceding: "unbounded",
reset: year}}
时间轴解析¶
D-WINDOW 自己不声明任何时间轴 —— 它与 D-TIME 组合:
- 该指标的 D-TIME
time_dimension载荷键(同一个 DATUS 信封),如果有的话。 - 否则取该指标所涉数据集中唯一的那个
primary_time_dimension(数据集的 D-TIME 声明,或它唯一的is_time字段)。 - 否则查询以结构化错误
no_primary_time_dimension失败 —— 发生在查询时而非模型编译时,所以之后再补上时间轴就能直接生效。
因此,一个有多个 is_time 字段的数据集,
要么需要一条数据集级的 D-TIME 声明,要么需要在 window 旁边写一个指标级的
time_dimension:
custom_extensions:
- vendor_name: DATUS
data: '{"v": 1,
"time_dimension": "activities.start_date",
"window": {"type": "pop", "offset": "1 month"}}'
编译语义¶
两个族都编译成一条 SQL 语句,包裹在引擎本来就会为基础指标发出的那个
分组聚合之外(base CTE:所请求的维度 + DATE_TRUNC(grain, axis) + 基础聚合)。
offset 族 —— 移位键自连接¶
WITH base AS (SELECT <dims>, DATE_TRUNC('month', t) AS t__month,
<base_agg> AS v
FROM …
WHERE t >= DATE '<start>' - INTERVAL <count> <granularity> -- input expansion
AND t < DATE '<end>'
GROUP BY <dims>, DATE_TRUNC('month', t))
SELECT c.<dims>, c.t__month, c.v AS <base_metric>,
CAST((c.v - p.v) AS DOUBLE) / NULLIF(p.v, 0) AS <window_metric> -- percent_change
FROM base AS c
LEFT JOIN base AS p
ON p.<dim> = c.<dim> AND … -- partition equality
AND p.t__month = c.t__month - INTERVAL <count> <granularity>
WHERE c.t__month >= DATE '<start>' -- output trim
- 输入扩展/输出裁剪:基础扫描的下界按偏移量放宽, 好让第一个被请求的分桶能加载到它的参照值;最终输出再裁剪回所请求的范围。 上界永远不会被放宽。
- 分区相等性用的是普通的
=:取值为 NULL 的维度没有可比的前驱(这是有意的)。 - 用自连接(而不是
LAG)正是让这个对比在有缺口时也保持日历正确的原因。
frame 族 —— 窗口函数¶
WITH base AS (SELECT DATE_TRUNC('month', t) AS t__month, <base_agg> AS v
FROM … GROUP BY …)
SELECT t__month, v AS <base_metric>,
SUM(v) OVER (PARTITION BY <dims> -- query dims minus the time axis
ORDER BY t__month
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS <window_metric>
FROM base
带上 reset 时,分区里会额外包含重置分桶,
而扫描/输出会得到扩展与裁剪的待遇,好让一次期中查询仍然从期初开始累计:
WITH base AS (SELECT … WHERE t >= DATE_TRUNC('year', DATE '<start>') AND t < DATE '<end>' …),
win AS (SELECT t__month, v,
SUM(v) OVER (PARTITION BY <dims>, DATE_TRUNC('year', t__month)
ORDER BY t__month
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS ytd
FROM base)
SELECT … FROM win WHERE t__month >= DATE '<start>' -- trim AFTER the window stage
裁剪必须位于带窗口的 SELECT 之外(SQL 在窗口函数之前求值 WHERE;
放在里面的裁剪会把累计所需的回看行删掉)。
重置的对齐是在 SQL 里算的(DATE_TRUNC(reset, start_literal)),
所以在每个方言上,边界和分区键都按构造保持一致 ——
包括起点由方言定义的 week。
上卷契约¶
窗口指标是重算的。去掉一个维度或改变粒度, 会在新的查询粒度上重新计算基础聚合,并在其上重跑窗口。 窗口输出绝不会被向上求和/求平均,也不能出现在其它指标的表达式里。
查询窗口指标¶
没有新的查询接口。一个窗口指标就像任何指标一样按名字请求; 窗口的排序和分区都从查询本身推导:
- 时间轴必须带粒度出现在
group_by里 —— 要么是具体字段 (activities.start_date:month),要么是保留名metric_time:month—— 否则查询会以unknown_dimension失败,并给出点名了该加哪一项的重试提示。 - 其余每一个 group-by 维度都会成为窗口的分区(offset 的连接键 /
PARTITION BY),所以group_by: [region, start_date:month]比较的是每个大区内部的 5 月对 4 月。 time_range约束的是输出;引擎会按需放宽输入扫描 (偏移回看、重置对齐),并在窗口阶段之后做裁剪。
{"metrics": ["revenue", "revenue_mom_growth"],
"group_by": ["orders.region", "metric_time:month"],
"time_range": {"start": "2025-05-01", "end": "2025-11-01"}}
校验¶
模型编译期(除非另有说明,均为 invalid_datus_extension):
window必须是一个 JSON 对象,且形态与它的type相符(见上文各 schema); 未知的type取值会把三种语法糖列出来。offset/frame对象内部的未知键 会被拒绝(fail-closed 的前向兼容,见window载荷)。- 恰好一个族:通用形态要求
offset/frame中恰好一个; 语法糖形态禁止出现另一个族的键(见上文各表)。 offset.count≥ 1(字符串和对象两种形态都是); 偏移字符串必须是"<count> <granularity>"。periods≥ 1;frame.preceding是非负整数或"unbounded"。reset∈ {week, month, quarter, year}。- 基础表达式必须能推断为单个朴素聚合 —— 比率型/表达式型指标会被拒绝。
- 任何指标
expression里的裸窗口 SQL 仍然被拒绝(window_in_metric)—— D-WINDOW 是唯一的窗口通路。
查询期(结构化的 QueryError):
| 条件 | 错误 |
|---|---|
| 无法解析出时间轴 | no_primary_time_dimension |
时间轴没有带粒度出现在 group_by 里 |
unknown_dimension + 重试提示 |
reset 比所查询的时间粒度更细 |
window_reset_too_fine(粒度相等是允许的 —— 恒等) |
| 撞上 v1 的限制(见下) | not_implemented |
引擎模式:在 --osi-basic 下整个 DATUS 信封都是惰性的 ——
指标按它朴素的基础聚合编译,并给出一条 ignored_vendor_extension 告警,
说明没有任何窗口派生生效。
限制¶
以下全部报告为结构化的 not_implemented 错误,绝不会被悄悄忽略:
- 一条查询里所有需要时间轴的窗口指标共用同一条时间轴
(offset 总是需要;frame 在按时间排序/有
reset/time_bucket时需要; rank/value 在按时间排序时需要)。按值排序的指标不需要时间轴, 可以在查询里完全没有时间维度的情况下运行(1.3 的按族区分的时间轴要求)。 - 窗口查询是单分支的:所请求的全部指标的度量必须都从同一个基础数据集计算 (窗口之下不能有扇出合并)。
- 在带
time_range.start时,一个不重置且按时间排序的累计指标 不能与会扩展扫描范围的指标(偏移回看或重置对齐)共处一条查询: 被放宽的扫描会悄悄改变它累计的内容。请单独查询它、给它一个reset, 或者去掉起始时间。 - 一个分区全局的指标(rank 族、value 族、任何按值排序的 frame)
在
time_range之下不能与会扩展扫描范围的指标共处一条查询 —— 被放宽的扫描会悄悄改变它排名或聚合所依据的总体。 单独配time_range则没有问题:那时它被定义的含义就是"在所查询的窗口之内"。 - 不支持跨指标的基础引用(
window派生自指标自己的表达式 —— W3 的second点名的是一个输入序列,不是基础), 不支持嵌套窗口,不支持补齐/时间轴表。
自 v1 以来已放宽的部分(混合族、混合移位 ——
每个不同的 (count, granularity) 各一次自连接 ——
以及带起始时间的混合重置,它们把扫描对齐到最粗的那个重置;
每个指标自己的重置分区能在这个过度放宽的扫描下保持更细的重置依然精确;
组合出的 >= 下界把 DATE_TRUNC(最粗重置, start) 与每个不同的偏移移位串起来,
这可能把扫描放得过宽 —— 那是成本问题,绝不是正确性问题)。
其余的放宽顺序记录在 design/window-metrics-v2.md。
可扩展性模型与路线图¶
背景:SQL:2003(ISO/IEC 9075-2:2003)以两组的形式引入了窗口函数 ——
排名函数(RANK、DENSE_RANK、PERCENT_RANK、CUME_DIST、ROW_NUMBER)
和窗口化聚合(所有聚合,包括统计类的
STDDEV_POP/STDDEV_SAMP/VAR_POP/VAR_SAMP 和双参数的
COVAR_*/CORR/REGR_* 一套);SQL:2008/2011 又加入了值/导航函数
(FIRST_VALUE/LAST_VALUE/NTH_VALUE、LEAD/LAG)和具名窗口。
v1 的能力面刻意只取了能覆盖派生指标语料的最小切片 ——
这是地板,不是天花板。本节是在不破坏文档和语义的前提下扩张它的契约。
扩展机制¶
一种窗口模式以具名族的形式进入规范:一个新的 type 取值
(外加通用形态的键)、一个新的内部 WindowKind 变体,
以及一套内建了正确性规则(分区、输入范围扩展、输出裁剪)的专属下降逻辑。
引擎在每一层的接缝都是增量式的:type 判别式在老引擎上 fail closed
(见 window 载荷),IR 是一个开放枚举,
每个族自成一个计划节点,而 SQL 层本来就能组合多阶段 CTE ——
一个 gaps-and-islands 式的下降所需要的机器今天就已经存在。
采用具名族、而不是让用户自己编写窗口表达式 DAG,是一种刻意的取舍:
streak(见下)即便在 DAG 模型里也无法用单个窗口表达出来,
但它可以作为一个族、配一套预制的两阶段下降来表达。
语义化的名字让校验、上卷时重算和范围扩展都是可判定的;裸窗口规格则做不到。
被冻结的自由度(以及打开它们的修饰符)¶
v1 写死了三个自由度;1.3 恰好沿着预留的接缝把它们打开了 —— 因为族内的未知键本来就会被拒绝,所以这些新增是非破坏性的, 也绝不会在老引擎上悄悄算错:
| 自由度 | v1 默认 | 1.3 打开的部分 | 仍然保留的部分 |
|---|---|---|---|
| 分区 | 所有非时间的 group-by 维度 | partition: {"mode": "query_dimensions" \| "query_dimensions_except"(+exclude)\| "time_bucket" \| "none"} |
— |
| 排序 | 时间轴,升序 | order: {"by": "time" \| "value", "direction": "asc" \| "desc"} |
NULLS FIRST/LAST 的位置 |
| 窗框形状 | 尾随的 ROWS … CURRENT ROW |
units: "range"(按值排序的累计)、value 族的显式完整窗框 |
following(居中窗口)、GROUPS 单位、窗框排除 |
族路线图¶
| 族 | 状态 | 语义 | 下降形态 |
|---|---|---|---|
rank |
1.3 已发布 | 在一个分区内对指标值做 rank / dense_rank / row_number / ntile / percent_rank / cume_dist(分布类函数也归在同一族) | 窗口函数,无 frame(依 SQL:2003 的排名规则) |
value |
1.3 已发布 | first_value / last_value / nth_value 导航 |
强制的显式完整窗框;RESPECT NULLS(ignore_nulls 保留 —— SQL:2011 的 IGNORE NULLS 不可移植) |
share |
未来 | 每个分组对总量的贡献:v / SUM(v) OVER (PARTITION BY time_bucket) |
窗口化的总量 + 安全除法(分区模式现已具备) |
streak |
未来 | 当前满足某条件的连续分桶长度(比如连续增长的月份数) | 预制的 gaps-and-islands:打标 → SUM 重置键 → 在 island 内计数(多两个 CTE 阶段)—— 最难的是那个下降,而不是一个新机制 |
跨指标的基础引用(base: <metric>)、补齐/时间轴表,
以及 require_full_window 是正交的扩展,跟踪在
design/window-metrics-v2.md 里。
函数登记表¶
frame 族的函数按层级登记;只有当某一层的下降被验证过
(DuckDB 上的执行级 oracle,其它引擎上的快照),
它才会在对应引擎版本上启用。使用高于引擎层级的函数会是
invalid_datus_extension,绝不会静默降级。
| 层级 | 函数 | 标准 | 状态 |
|---|---|---|---|
| W1(v1) | sum、avg、min、max、count |
SQL:2003 窗口化聚合(核心) | 已实现 |
| W2 统计类 | stddev_pop、stddev_samp、var_pop、var_samp |
SQL:2003 | 1.3 已实现 —— 按语义渲染各方言的写法(Snowflake 的 STDDEV/VARIANCE_POP,ClickHouse 的小写别名) |
| W3 双参数 | 经由 frame.second 的 covar_pop/samp、corr |
SQL:2003 | 1.3 已实现 —— Redshift 有开关(Dialect::supports_two_arg_stat_window);regr_*、percentile_cont/disc、median 仍属未来 |
| 排名类 | rank、dense_rank、row_number、ntile(n)、percent_rank、cume_dist |
SQL:2003 | 1.3 已实现,作为 rank 族 —— ClickHouse 的 cume_dist 有开关 |
| 导航类 | lag/lead(已被 offset 族的自连接内化 —— direction 决定取哪一侧)、first_value、last_value、nth_value |
SQL:2011 | 1.3 已实现(offset.direction、value 族) |
边界¶
- 方言启用情况:已在 DuckDB(参考实现)和真实数仓语料上执行并做过 oracle 验证;
各方言的 DateSub 下降和执行状态跟踪在
design/window-metrics-v2.md§1 里。reset: week遵循每个引擎自己的周起点 —— 内部是自洽的, 但不以周一作为一周开始的引擎会与参考实现有差异 (在语料里按方言逐一声明,绝不悄悄吸收掉)。 - 截至 1.3 不在范围内(见上文路线图):
share和streak族、regr_*/ 百分位 / 中位数函数、ignore_nulls、窗框排除、following边界和GROUPS窗框、source_rows求值方式、 财年日历、week_start覆盖。(自 v1 以来已发布:排名、值导航、 W2/W3 统计、按值排序的RANGE窗框、向前偏移、require_full_window。) - 结构上不在范围内(它们不是窗口指标;如有需要请单独建模):
漏斗/事件链形态、留存/同期群矩阵、会话切分、
行模式识别(
MATCH_RECOGNIZE)。 - 本规范取代了那份 ChatGPT 草案(
window-metircs-spec.md); 每个被舍弃机制的理由记录在design/window-metrics-v2.md里。
版本历史¶
- 1.0.0.dev0(2026-08-05):初版草案 —— offset + frame 两族, pop/rolling/cumulative 语法糖,累计重置,DuckDB 优先。
- 2026-08-06:全方言启用(按方言的 offset 下降、真机语料验证 —— design/window-metrics-v2.md §1)。
- 2026-08-06:放宽了混合族/混合移位/带起始的混合重置;
新增
require_full_window(族内的 1.2 新增,在更旧的引擎上 fail-closed)。 - 1.3(2026-08-10):可扩展性路线图的第一波,
收官了 datus-benchmark 的窗口语料(Q1–Q11)——
rank族(SQL:2003 排名,无 frame 子句)、value族 (在显式完整窗框之上做首/末/第 n 个导航)、offset.direction: "forward"(LEAD —— 上界扫描扩展 + 裁剪)、 W2 统计类 + W3 双参数窗框函数(frame.second)、 共享的order/partition修饰符,以及按值排序的units: "range"窗框(并列互为 peer)。方言开关:ClickHouse 的cume_dist、Redshift 的窗口化COVAR/CORR—— 结构化的dialect_unsupported_window_function,绝不会产出非法 SQL。 每一项新增在 1.2 引擎上都按构造 fail closed。