粒度:一行代表什么¶
Dosi 返回的每个数字,都是在某个粒度上算出来的。粒度回答的是一个问题:这里的一行代表什么?
orders 的一行是一个订单;按区域分组的结果里,一行是一个区域。大多数算错的指标,都是在这件事上
不知不觉出了错:一次 JOIN 悄悄把一个客户变成了三行,或者问题问的是客户,平均却是按订单取的。
本页给查询里的四种粒度起名字,讲清 Dosi 在它们之间执行的唯一一条规则,再介绍把多个粒度放进同一个答案的 两种办法:对齐两张事实表,以及用 LOD 让一个指标在自己的粒度上计算。
示例¶
三张小表。下面每个结果都是在它们上面真跑出来的。
customers |
region | sales_target |
|---|---|---|
| C1 | East | 500 |
| C2 | East | 300 |
| C3 | West | 600 |
orders |
customer | order_date | amount |
|---|---|---|---|
| O1 | C1 | 2024-01-05 | 100 |
| O2 | C1 | 2024-01-20 | 300 |
| O3 | C2 | 2024-01-12 | 200 |
| O4 | C3 | 2024-02-03 | 400 |
visits 记录了 8 次访问:1 月 5 次,2 月 3 次。每个订单、每次访问都属于一个客户,所以两张表都以多对一
关联到 customers。
一个查询里的四种粒度¶
按区域看 revenue(SUM(orders.amount)):
$ dosi query --model model.yaml --metrics revenue,order_count \
--group-by customers.region --execute --db shop.duckdb
region revenue order_count
East 600 3
West 400 1
2 rows
这个查询里有四种粒度在起作用:
| 粒度 | 这里的一行代表什么 | 由谁决定 |
|---|---|---|
| 行粒度 | orders:一个订单;customers:一个客户 |
数据集的 primary_key |
| 计算粒度 | 每个订单的 amount 进入 SUM 一次 |
指标;默认是它所聚合的数据集的行粒度 |
| 时间粒度 | 这里没用到;写成 orders.order_date:month 就是每月一个桶 |
时间维度上的 :day … :year |
| 查询粒度 | 一个区域 | group_by,明细查询里是 --from |
普通查询里,计算粒度就是行粒度,查询粒度就是 group-by,你根本不用去想它们。本页剩下的内容,讲的都是 它们不一致时会发生什么。
规则:只能从细粒度往粗粒度聚合¶
数据可以放心地从细粒度聚合到粗粒度:每一行细数据只落进一个粗分组,不会重复。反过来,如果要在更细的粒度上看 一个粗粒度的值,JOIN 时它就会被复制成好几行,一求和就重复计算了。这就是扇出(fan-out)。
沿多对一方向 JOIN 是安全的。 一个订单恰好对应一个客户,所以 orders JOIN customers 给每个订单行
带上一个区域。这只添了一列,没有添一行,所以上面按区域的 revenue 是对的。
反方向 JOIN 会扇出。 customers.sales_target 在客户粒度上。按订单月份去取它,每个客户都会被复制到
自己的每个订单上。手写 JOIN 会得到 1 月 1,300:C1 在 1 月有两个订单,它的目标 500 被算了两次。
Dosi 则直接拒绝:
$ dosi query --model model.yaml --metrics total_target \
--group-by orders.order_date:month --execute --db shop.duckdb
error: FanOutRisk: metric "total_target" aggregates Sum over dataset "customers", which has no many→one path to ["orders"]; evaluating a duplicate-sensitive aggregate (SUM/AVG/COUNT) across that join would double-count rows
hint: group by dimensions reachable from the metric's dataset, or restate the metric with a duplicate-insensitive aggregate (COUNT DISTINCT/MIN/MAX)
按 customers.region 分组,同一个指标就没问题(East 800,West 600),因为这个分组在客户粒度上或更粗。
精确规则见语义契约 —— 扇出保护。
时间粒度遵循同一条规则。 按天的数据可以汇总成月;按月存储的列(比如月末快照)没法拆回到天,所以对
声明了原生粒度为 month 的列请求 :day,会报 grain_too_fine。
两张事实表,一个答案:对齐,而不是直接 JOIN¶
orders 和 visits 都是事实表,谁也不从属于谁:一次访问并不属于某个订单。如果直接把两张表的明细行 JOIN
起来,每个订单都会被复制到同一客户的每次访问上。在这份数据上,1 月会算出 9 个订单、9 次访问,转化率 1.0;
实际是 3 个订单、5 次访问。
Dosi 的做法是先各自聚合,再 JOIN 结果:每张事实表先单独聚合到查询粒度(比如每月一行),再按共有的列把 两份结果 JOIN 起来。所以对齐之后还是要 JOIN,只不过 JOIN 的是聚合好的结果,一行对一行,不会扇出。 两张事实表都能连到同一张维表时,不需要任何额外声明:
$ dosi query --model model.yaml --metrics order_count,visit_count \
--group-by customers.region --execute --db shop.duckdb
region order_count visit_count
East 3 6
West 1 2
2 rows
日期是最常见的、中间没有维表的情况。 不加声明时,orders.order_date 对 visits 没有意义,同时需要
两边的指标会被拒绝:
error: UnconformedDimension: dimension "orders.order_date" is not conformed for derived metric "conversion": member(s) ["visit_count"] have no many→one path to "orders"
D-CONFORM 声明 visits.visit_date 和 orders.order_date 是同一个维度,只是在
两张事实表上拼写不同:
relationships:
- name: visits_orders_by_date
from: visits
to: orders
from_columns: [visit_date]
to_columns: [order_date]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.8", "requires": ["cardinality"], "cardinality": "many_to_many"}'
有了这个配对,每张事实表按自己的日期分组,各分支再按月合并:
$ dosi query --model model.yaml --metrics order_count,visit_count,conversion \
--group-by orders.order_date:month --execute --db shop.duckdb
order_date__month order_count visit_count conversion
2024-02-01 00:00:00 1 3 0.3333333333333333
2024-01-01 00:00:00 3 5 0.6
2 rows
看生成的 SQL 就很清楚:两张事实表各做一次 GROUP BY,再按月 FULL OUTER JOIN 到一起。订单明细和访问明细
从头到尾没有直接 JOIN 过。D-CONFORM 不改变任何粒度,它只是告诉 Dosi:两边聚合完之后,按哪一列对上。
指标在自己的粒度上算:LOD¶
有些问题需要的计算粒度,既不是行粒度,也不是查询粒度。「按区域看每个客户的平均终身收入」就是一例:先把 每个客户的订单加总,再在每个区域里对这些总额取平均。直接对订单金额取平均会得到 East 200,那回答的是另一个 问题,因为 C1 被算了两次。
LOD(level of detail)让指标的计算粒度和查询粒度分开声明。 查询照样只挑指标和 group-by, 中间那一步在哪一层算,由模型说了算。以查询粒度为参照,有三种移动:
| 声明 | 计算粒度 | 相对查询粒度 | 怎么回到查询粒度 | 示例结果 |
|---|---|---|---|---|
fixed(revenue, customers.customer_id) |
正好是列出的键 | 与它无关 | 作为字段:对它聚合、按它分组或过滤 | 按区域取它的 AVG:East 300,West 400 |
AVG(include(order_count, orders.customer_id)) |
查询粒度加上这些键 | 更细 | 必须再聚合回来:外层的 AVG |
按月看每个买家的订单数:1 月 1.5,2 月 1 |
exclude(revenue, orders.order_date) |
查询粒度去掉这些键 | 更粗 | 复制到每一行 | 按客户和日期分组,C1 的两行都是 400 |
「怎么回到查询粒度」这一列,都由上面那条规则决定:
include在更细的粒度上算,结果不合并就回不来,所以 Dosi 要求写出说明怎么合并的外层聚合。exclude在更粗的粒度上算,每个查询行只读到一个确定的值,复制过去是安全的。fixed的粒度是写死的,不跟着查询走。作为指标,查询的 group-by 必须包含它声明的全部键,而且不能加时间粒度 (比如:day);可以再加别的维度,这时同一个 fixed 值会在这些维度的每一行上重复出现。Dosi 不会替你把它再汇总 到更粗的粒度。作为字段,它就成了所属实体的一个属性(每个客户一个值),普通指标可以对它聚合、按它分组或过滤。
LOD 还会改变计算读取哪些记录:fixed 计算不受普通展示过滤的影响。这是范围问题,不是粒度问题,
见过滤决定哪些记录参与计算。
LOD 与对齐一起用¶
两种机制可以组合。每张事实表可以定义自己的 fixed 字段,比如 orders 上一个买家分类、visits 上另一个,
再用一个 D-CONFORM 配对把这两个字段声明成同一个维度。之后每张事实表各自算出分类和指标,分组结果再对齐,
和上面日期的做法完全一样。
行不通的是:一张事实表上的 fixed 字段直接聚合另一张事实表,再和两边的指标一起按它分组。这又成了两张事实表
之间逐行的路径,会被拒绝为 fan_out_risk。完整规则见 LOD 跨数据集。
看懂粒度类错误¶
规划阶段的错误,除了拼写错,大多是粒度没对上。错误码会告诉你是哪种粒度:
| 错误码 | 哪里没对上 | 怎么改 |
|---|---|---|
fan_out_risk |
指标会被复制到比它的行粒度更细的行上 | 按它的数据集多对一能到达的维度分组,或改用 COUNT DISTINCT / MIN / MAX |
unconformed_dimension |
两张事实表在请求的粒度上没有共有的列 | 按共有维度分组,或用 D-CONFORM 配对这些列 |
grain_too_fine |
时间粒度比列存储的更细 | 用存储粒度或更粗的粒度 |
grain_on_non_time_dimension |
在非时间维度的字段上用了时间粒度 | 去掉 :grain,或把字段标记为时间 |
lod_dimension_not_grouped |
LOD 指标的键不在查询粒度里 | 把这些键加进 group_by |
time_axis_not_grouped、window_reset_too_fine |
窗口的时间轴不在查询粒度里,或比查询粒度更细 | 按窗口的粒度对时间轴分组 |
这些错误都带 candidates 或 suggested_retry,见错误。
下一步¶
- 理解 LOD:什么时候需要它,如何在
fixed、include、exclude之间选择,附可运行的模型。 - 跨事实对齐(D-CONFORM):配对规则、跨配对事实的窗口、校验。
- 查询明细行:明细查询里,
--from决定查询粒度。 - 语义契约:精确的扇出与分支规则。