痛点:平行数组配对太繁琐
假设你有一份产品数据,配件名和价格分别存在两个平行数组中:
CREATE TABLE products AS SELECT * FROM (VALUES
(1, 'Laptop', ['CPU','RAM','SSD'], [4000, 2000, 1500]),
(2, 'Phone', ['Screen','Battery'], [3000, 1500]),
(3, 'Tablet', ['Screen','CPU','Battery'], [2000, 3000, 1000])
) AS t(id, name, parts, prices);
现在你需要把每个配件和对应的价格配对,生成结构化数据。传统做法是什么?
# Python 传统做法
for part, price in zip(row['parts'], row['prices']):
print(f"{part}: {price}")
在纯 SQL 中,很多人会尝试 UNNEST 两次然后 JOIN,但平行数组的索引对齐是个大坑——两个数组长度不同,JOIN 会产生笛卡尔积而不是逐行配对。
一招解决:LIST_ZIP
DuckDB 内置的 LIST_ZIP 函数专门解决这个问题:
SELECT id, name, list_zip(parts, prices) AS priced_parts
FROM products;
结果:
┌─────┬─────────┬────────────────────────────────────────────────┐
│ id │ name │ priced_parts │
├─────┼─────────┼────────────────────────────────────────────────┤
│ 1 │ Laptop │ [(CPU, 4000), (RAM, 2000), (SSD, 1500)] │
│ 2 │ Phone │ [(Screen, 3000), (Battery, 1500)] │
│ 3 │ Tablet │ [(Screen, 2000), (CPU, 3000), (Battery, 1000)] │
└─────┴─────────┴────────────────────────────────────────────────┘
每个元组自动配对,长度不同的数组自动截断。 一行 SQL 搞定。
效果量化
| 维度 | 传统 UNNEST + JOIN | LIST_ZIP |
|---|---|---|
| 代码行数 | 10-15 行(子查询 + JOIN + 过滤) | 1 行 |
| 长度不一处理 | 需要手动处理笛卡尔积陷阱 | ✅ 自动截断 |
| 可读性 | ❌ 复杂嵌套 | ✅ 一目了然 |
| 执行效率 | 中等(两次 UNNEST + JOIN) | ✅ 单次扫描 |
核心收益:15 行 SQL 压缩为 1 行,开发时间从 30 分钟降到 2 分钟。
进阶用法
1. 展开为独立行
配合 UNNEST 将配对结果展开:
SELECT p.id, p.name, z[1] AS part, z[2] AS price
FROM products p,
UNNEST(list_zip(p.parts, p.prices)) AS t(z);
┌─────┬─────────┬──────────┬───────┐
│ id │ name │ part │ price │
├─────┼─────────┼──────────┼───────┤
│ 1 │ Laptop │ CPU │ 4000 │
│ 1 │ Laptop │ RAM │ 2000 │
│ 1 │ Laptop │ SSD │ 1500 │
│ 2 │ Phone │ Screen │ 3000 │
│ 2 │ Phone │ Battery │ 1500 │
│ 3 │ Tablet │ Screen │ 2000 │
│ 3 │ Tablet │ CPU │ 3000 │
│ 3 │ Tablet │ Battery │ 1000 │
└─────┴─────────┴──────────┴───────┘
2. 自定义结构体字段名
用 LIST_TRANSFORM 配合 STRUCT_PACK 生成带字段名的结构体:
SELECT id, name,
list_transform(
list_zip(parts, prices),
lambda x: struct_pack(part => x[1], price => x[2])
) AS items
FROM products;
┌─────┬─────────┬─────────────────────────────────────────────────────┐
│ id │ name │ items │
├─────┼─────────┼─────────────────────────────────────────────────────┤
│ 1 │ Laptop │ [{part: CPU, price: 4000}, ...] │
│ 2 │ Phone │ [{part: Screen, price: 3000}, ...] │
│ 3 │ Tablet │ [{part: Screen, price: 2000}, ...] │
└─────┴─────────┴─────────────────────────────────────────────────────┘
3. 处理不等长数组
当两个数组长度不一致时,LIST_ZIP 默认截断到较短的数组。如果希望用 NULL 填充,传入 false:
-- 默认截断(较短的数组决定长度)
SELECT list_zip(['a','b','c'], [1, 2]) AS truncated;
-- 结果: [(a, 1), (b, 2)]
-- 用 NULL 填充较长数组的多余元素
SELECT list_zip(['a','b','c'], [1, 2], false) AS extended;
-- 结果: [(a, 1), (b, 2), (c, NULL)]
4. Python 中使用
import duckdb
con = duckdb.connect(":memory:")
con.execute("""
CREATE TABLE products AS SELECT * FROM (VALUES
(1, 'Laptop', ['CPU','RAM','SSD'], [4000, 2000, 1500]),
(2, 'Phone', ['Screen','Battery'], [3000, 1500]),
(3, 'Tablet', ['Screen','CPU','Battery'], [2000, 3000, 1000])
) AS t(id, name, parts, prices)
""")
# 一行查询搞定配对
df = con.execute("""
SELECT id, name, list_zip(parts, prices) AS priced_parts
FROM products
""").df()
print(df['priced_parts'].iloc[0])
# [(CPU, 4000), (RAM, 2000), (SSD, 1500)]
常见陷阱
陷阱 1:不要用 UNNEST 两次再 JOIN
很多人会这样写:
-- ❌ 错误:产生笛卡尔积而非逐行配对
SELECT a.id, unnest_a.val AS part, unnest_b.val AS price
FROM products a,
UNNEST(a.parts) AS unnest_a,
UNNEST(a.prices) AS unnest_b;
这会生成 3×2=6 行(Laptop)和 2×2=4 行(Phone)的笛卡尔积,而不是 3+2=5 行的正确配对。
陷阱 2:结构体字段访问
LIST_ZIP 返回的是无名结构体数组,访问时需要用数字索引 [1]、[2],不能用 z.part 这种点号语法。如果需要命名字段,配合 LIST_TRANSFORM + STRUCT_PACK 使用。
延伸思考
LIST_ZIP 体现了 DuckDB 对嵌套数据类型的一等公民支持。在数据分析中,平行数组是一个非常常见的数据模式——从 API 返回的 JSON、从传感器采集的时序数据、从推荐系统输出的标签和分数。
使用 LIST_ZIP 的几个典型场景:
- 标签-分数配对:推荐系统的物品 ID 列表和对应评分列表
- 时序对齐:时间戳数组和测量值数组
- 键值对构建:属性名和属性值分别存储时的配对
- 数据清洗:将平行数组转换为首尾一致的 JSON 结构
与其他工具对比
| 能力 | DuckDB LIST_ZIP | Python zip() | Spark | Pandas |
|---|---|---|---|---|
| SQL 原生 | ✅ | ❌ | ⚠️ UDF | ❌ |
| 不等长处理 | ✅ 可选截断/填充 | 截断到最短 | 需自定义 | 截断到最短 |
| 返回结构化 | ✅ 结构体数组 | 元组列表 | 需自定义 | 需自定义 |
| 引擎内执行 | ✅ | ❌ | ✅ | ❌ |
DuckDB 的优势:在 SQL 中直接完成配对,无需导出数据到 Python 处理。
📖 更多 DuckDB 实战技巧 → duckdblab.org
💡 觉得有用?Subscribe to DuckDB Lab,每周三获取一篇即学即用的 DuckDB 实战快讯!