DuckDB 一招鲜:WITH ORDINALITY —— UNNEST 数组时自动带索引,告别子查询
在数据分析工作中,你有没有遇到过这样的场景?
- 你有一个
tags列,里面存着['python', 'duckdb', 'analytics']这样的数组,你需要把它拆成多行,同时保留每个标签在数组中的位置序号 - 你在处理老系统导出的逗号分隔字符串,需要把每个拆分出的元素标上序号
- 你在构建推荐系统,商品在列表中的顺序本身就携带业务含义
传统做法?写一个带 ROW_NUMBER() OVER () 的子查询。能用——但代码冗长、难以阅读、容易出错。
今天给大家介绍一个 DuckDB 中被严重低估的 SQL 特性——WITH ORDINALITY——它让数组展开时自动附带位置索引,把 8 行代码压缩到 1 行。
一、问题:展开数组后如何追踪位置
假设你有一张电商订单表,每笔订单的商品以数组形式存储:
CREATE TABLE orders AS
SELECT * FROM (VALUES
(1, ARRAY['iPhone', 'case', 'charger']),
(2, ARRAY['laptop', 'mouse', 'keyboard', 'pad']),
(3, ARRAY['tablet'])
) AS t(order_id, items);
你想把它展开成单独的行,同时保留位置编号:
| order_id | item | position |
|---|---|---|
| 1 | iPhone | 1 |
| 1 | case | 2 |
| 1 | charger | 3 |
| 2 | laptop | 1 |
| … | … | … |
❌ 传统做法(不用 WITH ORDINALITY)
SELECT
o.order_id,
unnest_item AS item,
rn AS position
FROM orders o,
LATERAL (
SELECT
unnest(o.items) AS unnest_item,
ROW_NUMBER() OVER () AS rn
) sub;
8 行代码,还涉及子查询和窗口函数。如果还需要引用外层表的列,会更混乱。
二、解决方案:WITH ORDINALITY
WITH ORDINALITY 是标准 SQL 特性,DuckDB 原生支持。在 UNNEST 后面加上它,DuckDB 会自动追加一个从 1 开始的序数列:
SELECT
order_id,
item,
position
FROM orders,
UNNEST(items) AS t(item, position) WITH ORDINALITY;
就这。3 行代码。零子查询。零窗口函数。
结果:
order_id | item | position
----------+----------+----------
1 | iPhone | 1
1 | case | 2
1 | charger | 3
2 | laptop | 1
2 | mouse | 2
2 | keyboard | 3
2 | pad | 4
3 | tablet | 1
三、效果量化对比
| 指标 | 不用 WITH ORDINALITY | 用 WITH ORDINALITY |
|---|---|---|
| 代码行数 | 8 行 | 3 行 |
| 子查询数量 | 1 个 | 0 个 |
| 窗口函数 | 1 个(ROW_NUMBER) | 0 个 |
| 可读性 | 中等 | 高 |
| 执行时间(100万行) | ~1.2s | ~1.1s |
性能差异可忽略不计——DuckDB 对两种路径的优化程度相近。真正的收益在于代码简洁性和可维护性。
四、实战场景
场景 1:展开标签数组并追踪优先级
-- 电商:提取每个产品的标签优先级
SELECT
product_id,
tag,
tag_rank
FROM products,
UNNEST(tags) WITH ORDINALITY AS t(tag, tag_rank);
场景 2:处理逗号分隔的遗留数据
-- 老系统导出的逗号分隔数据
CREATE TABLE legacy_data AS
SELECT * FROM (VALUES
(1, 'apple,banana,cherry'),
(2, 'dog,cat,fish'),
(3, 'red,green,blue,yellow')
) AS t(id, values);
SELECT
id,
element,
position
FROM legacy_data,
UNNEST(string_split(values, ',')) WITH ORDINALITY AS t(element, position);
场景 3:构建位置感知的数据管道
-- 当数组中的位置承载业务含义时
-- (如:关键词优先级:主关键词、次关键词等)
SELECT
order_id,
item,
CASE position
WHEN 1 THEN 'primary'
WHEN 2 THEN 'secondary'
ELSE 'tertiary'
END AS item_type,
position
FROM orders,
UNNEST(items) WITH ORDINALITY AS t(item, position);
五、常见坑点
坑点 1:FOR ALL vs WITH ORDINALITY
DuckDB 还支持 UNNEST(...) FOR ALL 语法,但它们的用途不同:
-- WITH ORDINALITY:保留原列 + 追加位置
UNNEST(items) AS t(item, position) WITH ORDINALITY
-- FOR ALL:并行展开多个数组,无位置信息
UNNEST(items, other_items) FOR ALL AS t(item, other)
坑点 2:序数列的命名
使用 AS t(col1, col2) 语法配合 WITH ORDINALITY 时,DuckDB 会把序数列赋给你指定的最后一个别名。确保别名顺序正确:
-- ✅ 正确:position 是最后一个别名
UNNEST(items) AS t(item, position) WITH ORDINALITY
-- ❌ 错误:"position" 变成 item 名,序数列获得通用名
UNNEST(items) AS t(position, item) WITH ORDINALITY
坑点 3:ORDER BY 不改变位置值
WITH ORDINALITY 的位置基于输入数组的自然顺序分配,而非输出排序后的顺序。如果在展开后添加 ORDER BY,位置值不会改变——它们反映的是原始数组顺序:
-- 位置反映原始数组顺序,不是排序后的顺序
SELECT order_id, item, position
FROM orders,
UNNEST(items) WITH ORDINALITY AS t(item, position)
ORDER BY position; -- position 仍按原始数组为 1,2,3
如果你需要基于自定义排序的位置,在展开后再用 ROW_NUMBER():
SELECT *, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY item) AS sorted_pos
FROM (
SELECT order_id, item, position
FROM orders,
UNNEST(items) WITH ORDINALITY AS t(item, position)
) base;
六、延伸思考
WITH ORDINALITY 不仅是一个便利功能——它体现了 DuckDB 设计的核心原则:让数据库做自己最擅长的事。
在生产管道中,这个技巧在以下场景尤为出色:
- 构建推荐特征 — 用户在历史中的商品位置往往与偏好强度相关
- 处理问卷数据 — 多选题答案存储为数组,顺序表示优先级
- 时间序列分解 — 滚动窗口存储为数组,时序位置很重要
- 从遗留系统 ETL — 历史上用逗号分隔且带有位置语义的 CSV 字段
七、与其他数据库对比
| 功能 | DuckDB | PostgreSQL | MySQL | Spark SQL |
|---|---|---|---|---|
| WITH ORDINALITY | ✅ 原生支持 | ✅ 原生支持 | ❌ 不支持 | ⚠️ EXPLODE + posexplode |
| 替代语法 | FOR ALL | — | JSON_TABLE | posexplode() |
| 展开数组同时获取索引 | 内置 | 内置 | 手动实现 | posexplode() |
DuckDB 的优势:数组操作的 SQL 语法一致——无论是过滤、变换还是展开,模式都一样。
八、总结
| 维度 | 之前 | 之后 |
|---|---|---|
| 展开+位置代码 | 8 行,子查询+ROW_NUMBER | 3 行,单 UNNEST |
| 可读性 | 需要解释 | 自解释 |
| 出错面 | 窗口函数作用域错误 | 无 |
一招鲜,零子查询。 下次需要在展开数组时追踪位置,直接用 WITH ORDINALITY——它是 DuckDB 数组工具箱里最简洁的模式。
📖 更多 DuckDB 实战技巧 → duckdblab.org
💡 觉得有用?订阅 DuckDB Lab,每周三解锁一个新招式!