多表操作是关系型数据分析的核心能力。在真实业务中,数据几乎从来不会只存在于一张表里——用户信息在用户表、订单记录在订单表、商品详情在商品表……如何把这些分散的数据高效地拼接到一起,是每个数据工程师的必修课。
如果你用过 SQL,那么 JOIN 一定不陌生。Polars 同样提供了强大且灵活的 Join 能力,而且在 Rust 的 Lazy 框架下,还能享受到查询优化器带来的性能红利。本节课,我们将系统学习 Polars Rust 中的数据合并操作,包括纵向/横向拼接、五种 Join 类型、多条件 Join、asof Join,以及 Lazy 框架下的查询优化技巧。
在实际数据处理中,我们经常需要把多个 DataFrame 拼接到一起。拼接分为两种方向:
UNION ALLSELECT a.*, b.*vstack() —— 纵向拼接vstack() 是最常用的纵向拼接函数,它要求所有 DataFrame 的 schema(列名和类型)完全一致”
use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 创建两个结构相同的 DataFrame
let df1 = df![
"城市" => ["北京", "上海"],
"人口_万" => [2189, 2487]
]?;
let df2 = df![
"城市" => ["广州", "深圳"],
"人口_万" => [1868, 1756]
]?;
let df_concat = df1.clone().vstack(&df2)?;
println!("{}", df_concat);
Ok(())
}输出:
shape: (4, 2)
+------+----------+
| 城市 | 人口_万 |
| --- | --- |
| str | i32 |
+======+==========+
| "北京" | 2189 |
| "上海" | 2487 |
| "广州" | 1868 |
| "深圳" | 1756 |
+------+----------+concat_df_diagonal() —— Schema 不一致时的拼接有时候,两个 DataFrame 的列不完全相同,但你想把它们纵向拼接。这时就需要 concat_df_diagonal(),它会自动处理缺失列,用 null 填充:
use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// df_a 有"城市"和"人口"两列
let df_a = df![
"城市" => ["北京", "上海"],
"人口_万" => [2189, 2487]
]?;
// df_b 有"城市"和"GDP_亿"两列,列名不同!
let df_b = df![
"城市" => ["广州", "深圳"],
"GDP_亿" => [28232, 32388]
]?;
// concat_df_diagonal 会自动用 null 填充缺失的列
let df_diag = polars::functions::concat_df_diagonal(&[df_a, df_b])?;
println!("{}", df_diag);
Ok(())
}输出:
shape: (4, 3)
+------+----------+----------+
| 城市 | 人口_万 | GDP_亿 |
| --- | --- | --- |
| str | i32 | i32 |
+======+==========+==========+
| "北京" | 2189 | null |
| "上海" | 2487 | null |
| "广州" | null | 28232 |
| "深圳" | null | 32388 |
+------+----------+----------+hstack() —— 横向拼接hstack() 用于将多个 DataFrame 按列横向拼接。它要求所有 DataFrame 的 行数相同:
use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let df_names = df![
"姓名" => ["张三", "李四", "王五"]
]?;
let df_scores = df![
"语文" => [90, 85, 78],
"数学" => [95, 88, 92]
]?;
// 横向拼接:将 df_scores 的列追加到 df_names 右侧
let df_result = df_names.hstack(&df_scores.columns())?;
println!("{}", df_result);
Ok(())
}输出:
shape: (3, 3)
+------+------+------+
| 姓名 | 语文 | 数学 |
| --- | --- | --- |
| str | i32 | i32 |
+======+======+======+
| "张三" | 90 | 95 |
| "李四" | 85 | 88 |
| "王五" | 78 | 92 |
+------+------+------+在 Lazy 框架下,进行横向合并(即把两个 DataFrame 的列并排拼接)时,不再推荐使用 with_columns() + Cross Join 的 hack 方式。Polars v0.53 提供了专门的函数 concat_lf_horizontal.
use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let df_base = df![
"姓名" => ["张三", "李四", "王五"]
]?;
let df_extra = df![
"班级" => ["A班", "B班", "A班"]
]?;
// ✅ LazyFrame 横向合并 —— Polars v0.53 推荐写法
let lf_result = concat_lf_horizontal(
[df_base.lazy(), df_extra.lazy()],
HConcatOptions::default(),
)?;
println!("{}", lf_result.collect()?);
Ok(())
}在 Polars v0.53 中,polars::prelude::concat() 是 Lazy API 下拼接多个 LazyFrame 的推荐统一入口。它通过 UnionArgs 参数灵活控制拼接方式。
use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. 纵向拼接:适用于列结构相同的 DataFrame
let df1 = df!["城市" => ["北京", "上海"], "人口_万" => [2189, 2487]]?;
let df2 = df!["城市" => ["广州", "深圳"], "人口_万" => [1868, 1756]]?;
let vertical = concat([df1.lazy(), df2.lazy()], UnionArgs::default())?
.collect()?;
println!("1. 纵向拼接:\n{}\n", vertical);
// 2. 对角线拼接:适用于列结构不同的 DataFrame
let df_a = df!["城市" => ["北京", "上海"], "人口_万" => [2189, 2487]]?;
let df_b = df!["城市" => ["广州", "深圳"], "GDP_亿" => [28232, 32388]]?;
let diagonal = concat(
[df_a.lazy(), df_b.lazy()],
UnionArgs {
diagonal: true,
..Default::default()
},
)?
.collect()?;
println!("2. 对角线拼接:\n{}\n", diagonal);
// 2. 对角线拼接:适用于列结构不同的 DataFrame
let df_a = df!["城市" => ["北京", "上海"], "人口_万" => [2189, 2487]]?;
let df_b = df!["城市" => ["广州", "深圳"], "GDP_亿" => [28232, 32388]]?;
let diagonal = concat(
[df_a.lazy(), df_b.lazy()],
UnionArgs {
diagonal: true,
..Default::default()
},
)?
.collect()?;
println!("2. 对角线拼接:\n{}\n", diagonal);
Ok(())
}
小结 📝:
Polars 中 DataFrame / LazyFrame 的拼接主要分为纵向和横向两种方式,不同场景推荐使用以下函数:
拼接类型 | 操作场景 | Eager API 推荐函数 | Lazy API 推荐函数 | 关键要求 |
|---|---|---|---|---|
纵向拼接 | 结构完全相同 | vstack() 或 concat_df | concat(..., UnionArgs::default()) | Schema(列名与类型)必须一致 |
纵向拼接 | 结构不完全相同 | concat_df_diagonal() | concat(..., UnionArgs { diagonal: true, ..Default::default() }) | 自动用 null 填充缺失列 |
横向拼接 | 按列并排合并 | hstack() 或 concat_df_horizontal() | concat_lf_horizontal() | 行数必须相同 |
使用建议:
vstack()(Eager)或 concat() 默认方式(Lazy),性能最佳、最清晰。concat_df_diagonal()(Eager)或 concat() + diagonal: true(Lazy)。hstack() 或 concat_df_horizontal()。concat_lf_horizontal(),避免旧的 with_columns() + Cross Join hack。concat() + UnionArgs 处理纵向拼接(包括 vertical 和 diagonal)。记忆口诀: 同结构纵向用
vstack/concat默认; 列不同纵向用diagonal; 横向拼接看行数,Lazy 首选concat_lf_horizontal。
Join 是多表操作的核心。Polars 的 LazyFrame 提供了 .join() 方法,支持 5 种 Join 类型,和 SQL 的 JOIN 语义一一对应。
lazy_df.join(
other.lazy(), // 右表(LazyFrame)
left_keys, // 左表的连接键
right_keys, // 右表的连接键
JoinArgs::new(JoinType::Inner), // Join 类型
)让我们用两个示例表来演示各种 Join 类型:
use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 用户表
let users = df![
"user_id" => [1, 2, 3, 4],
"name" => ["张三", "李四", "王五", "赵六"]
]?;
// 订单表(注意:user_id=4 没有订单,user_id=5 不在用户表中)
let orders = df![
"order_id" => [101, 102, 103, 104],
"user_id" => [1, 2, 2, 5],
"amount" => [100, 200, 150, 300]
]?;
// 以下演示各种 Join 类型...
Ok(())
}只保留两表中都能匹配到的行。这是最严格的 Join 类型。
let result = users
.clone()
.lazy()
.join(
orders.clone().lazy(),
[col("user_id")],
[col("user_id")],
JoinArgs::new(JoinType::Inner),
)
.collect()?;
println!("Inner Join:\n{}", result);Inner Join:
shape: (3, 4)
┌─────────┬──────┬──────────┬────────┐
│ user_id ┆ name ┆ order_id ┆ amount │
│ --- ┆ --- ┆ --- ┆ --- │
│ i32 ┆ str ┆ i32 ┆ i32 │
╞═════════╪══════╪══════════╪════════╡
│ 1 ┆ 张三 ┆ 101 ┆ 100 │
│ 2 ┆ 李四 ┆ 102 ┆ 200 │
│ 2 ┆ 李四 ┆ 103 ┆ 150 │
└─────────┴──────┴──────────┴────────┘保留左表的所有行,右表匹配不上的用 null 填充。这是最常用的 Join 类型。
let result = users
.clone()
.lazy()
.join(
orders.clone().lazy(),
[col("user_id")],
[col("user_id")],
JoinArgs::new(JoinType::Left),
)
.collect()?;
println!("Left Join:\n{}", result);Left Join:
shape: (5, 4)
┌─────────┬──────┬──────────┬────────┐
│ user_id ┆ name ┆ order_id ┆ amount │
│ --- ┆ --- ┆ --- ┆ --- │
│ i32 ┆ str ┆ i32 ┆ i32 │
╞═════════╪══════╪══════════╪════════╡
│ 1 ┆ 张三 ┆ 101 ┆ 100 │
│ 2 ┆ 李四 ┆ 102 ┆ 200 │
│ 2 ┆ 李四 ┆ 103 ┆ 150 │
│ 3 ┆ 王五 ┆ null ┆ null │ ← 右表无匹配,null 填充
│ 4 ┆ 赵六 ┆ null ┆ null │ ← 右表无匹配,null 填充
└─────────┴──────┴──────────┴────────┘
保留右表的所有行,左表匹配不上的用 null 填充。和 Left Join 正好相反。
let result = users
.clone()
.lazy()
.join(
orders.clone().lazy(),
[col("user_id")],
[col("user_id")],
JoinArgs::new(JoinType::Right),
)
.collect()?;
println!("Right Join:\n{}", result);Right Join:
shape: (4, 4)
┌──────┬──────────┬─────────┬────────┐
│ name ┆ order_id ┆ user_id ┆ amount │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ i32 ┆ i32 ┆ i32 │
╞══════╪══════════╪═════════╪════════╡
│ 张三 ┆ 101 ┆ 1 ┆ 100 │
│ 李四 ┆ 102 ┆ 2 ┆ 200 │
│ 李四 ┆ 103 ┆ 2 ┆ 150 │
│ null ┆ 104 ┆ 5 ┆ 300 │ ← 左表无匹配,null 填充
└──────┴──────────┴─────────┴────────┘
保留两表的所有行,匹配不上的都用 null 填充。相当于 Left Join + Right Join 的并集。
let result = users
.clone()
.lazy()
.join(
orders.clone().lazy(),
[col("user_id")],
[col("user_id")],
JoinArgs::new(JoinType::Full),
)
.collect()?;
println!("Full Join:\n{}", result);Full Join:
shape: (6, 5)
┌─────────┬──────┬──────────┬───────────────┬────────┐
│ user_id ┆ name ┆ order_id ┆ user_id_right ┆ amount │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ i32 ┆ str ┆ i32 ┆ i32 ┆ i32 │
╞═════════╪══════╪══════════╪═══════════════╪════════╡
│ 1 ┆ 张三 ┆ 101 ┆ 1 ┆ 100 │
│ 2 ┆ 李四 ┆ 102 ┆ 2 ┆ 200 │
│ 2 ┆ 李四 ┆ 103 ┆ 2 ┆ 150 │
│ null ┆ null ┆ 104 ┆ 5 ┆ 300 │
│ 3 ┆ 王五 ┆ null ┆ null ┆ null │
│ 4 ┆ 赵六 ┆ null ┆ null ┆ null │
└─────────┴──────┴──────────┴───────────────┴────────┘笛卡尔积:左表的每一行和右表的每一行组合。不指定连接键,结果行数 = 左表行数 x 右表行数。
polars features需要开启cross_join
let result = users
.clone()
.lazy()
.join(
orders.clone().lazy(),
// Cross Join 不需要指定连接键,传空数组
vec![],
vec![],
JoinArgs::new(JoinType::Cross),
)
.collect()?;
println!("Cross Join:\n{}", result);
Cross Join:
shape: (16, 5)
┌─────────┬──────┬──────────┬───────────────┬────────┐
│ user_id ┆ name ┆ order_id ┆ user_id_right ┆ amount │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ i32 ┆ str ┆ i32 ┆ i32 ┆ i32 │
╞═════════╪══════╪══════════╪═══════════════╪════════╡
│ 1 ┆ 张三 ┆ 101 ┆ 1 ┆ 100 │
│ 1 ┆ 张三 ┆ 102 ┆ 2 ┆ 200 │
│ 1 ┆ 张三 ┆ 103 ┆ 2 ┆ 150 │
│ 1 ┆ 张三 ┆ 104 ┆ 5 ┆ 300 │
│ 2 ┆ 李四 ┆ 101 ┆ 1 ┆ 100 │
│ … ┆ … ┆ … ┆ … ┆ … │
│ 3 ┆ 王五 ┆ 104 ┆ 5 ┆ 300 │
│ 4 ┆ 赵六 ┆ 101 ┆ 1 ┆ 100 │
│ 4 ┆ 赵六 ┆ 102 ┆ 2 ┆ 200 │
│ 4 ┆ 赵六 ┆ 103 ┆ 2 ┆ 150 │
│ 4 ┆ 赵六 ┆ 104 ┆ 5 ┆ 300 │
└─────────┴──────┴──────────┴───────────────┴────────┘重要提醒 ⚠️:Cross Join 会产生笛卡尔积,行数爆炸式增长!请确保右表行数较小,否则可能导致内存溢出。使用 Cross Join 需要在
Cargo.toml中启用"cross_join"feature。
Join 类型 | 保留左表 | 保留右表 | 匹配不上 | 典型场景 |
|---|---|---|---|---|
Inner | 仅匹配行 | 仅匹配行 | 丢弃 | 精确匹配 |
Left | 全部 | 仅匹配行 | null 填充 | 保留主表 |
Right | 仅匹配行 | 全部 | null 填充 | 保留从表 |
Full | 全部 | 全部 | null 填充 | 数据合并 |
Cross | 全部 | 全部 | 全组合 | 笛卡尔积 |
在实际业务中,我们经常需要用多个列作为连接键。比如,一个公司有多个部门,每个部门都有编号为 1、2、3 的员工——这时候光靠 员工编号 就无法唯一标识一个人了,需要 部门 + 编号 的组合键。
在 Polars 中,多条件 Join 非常简单,只需要在 left_keys 和 right_keys 中传入多个列名即可:
use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 员工薪资表
let salaries = df![
"dept" => ["工程部", "工程部", "市场部", "市场部"],
"emp_id" => [1, 2, 1, 2],
"name" => ["张三", "李四", "王五", "赵六"],
"salary" => [25000, 22000, 18000, 20000]
]?;
// 绩效表(注意:每个部门都有 emp_id=1,2)
let performance = df![
"dept" => ["工程部", "工程部", "市场部", "市场部", "工程部"],
"emp_id" => [1, 2, 1, 2, 3],
"score" => [95, 88, 78, 92, 85],
"quarter" => ["Q1", "Q1", "Q1", "Q1", "Q1"]
]?;
// 多条件 Join:同时用 dept + emp_id 作为连接键
let result = salaries
.lazy()
.join(
performance.lazy(),
// 左表连接键:dept 和 emp_id 两列
vec![col("dept"), col("emp_id")],
// 右表连接键:dept 和 emp_id 两列
vec![col("dept"), col("emp_id")],
JoinArgs::new(JoinType::Left),
)
.collect()?;
println!("{}", result);
Ok(())
}输出:
shape: (5, 6)
+--------+--------+------+--------+-------+---------+
| dept | emp_id | name | salary | score | quarter |
| --- | --- | --- | --- | --- | --- |
| str | i32 | str | i32 | i32 | str |
+========+========+======+========+=======+=========+
| "工程部" | 1 | "张三" | 25000 | 95 | "Q1" |
| "工程部" | 2 | "李四" | 22000 | 88 | "Q1" |
| "市场部" | 1 | "王五" | 18000 | 78 | "Q1" |
| "市场部" | 2 | "赵六" | 20000 | 92 | "Q1" |
| "工程部" | 3 | null | null | 85 | "Q1" |
+--------+--------+------+--------+-------+---------+解读 🔍: 本例使用
dept和emp_id两列作为复合连接键进行 Left Join。
name 和 salary 为 null。
使用多列 Join 可以精确匹配“部门 + 员工编号”的组合,避免仅用单个 emp_id 时可能出现的跨部门错误关联。关键要点:
left_keys 和 right_keys 的列数必须相同,且一一对应emp_id,右表叫 employee_id),Polars 会按位置对应在时间序列分析和金融数据处理中,我们经常遇到这样的场景:两条数据的时间戳不完全对齐,但你需要找到最接近的那个时间点的数据。这就是 asof Join 的用武之地。
asof Join("as of" = "截至")是一种基于最近时间戳的模糊匹配 Join。对于左表的每一行,它会在右表中找到时间戳最接近且不超过左表时间戳的那一行。
想象一下:
注意:使用 asof Join 需要在
Cargo.toml中启用"asof_join"feature。
use polars::prelude::*;
/// AsOf Join 示例
///
/// AsOf Join 是一种"最近匹配"连接:对左表每一行,
/// 在右表中找到键值 <= 左表键值(Backward策略)的最近一行,
/// 常用于时间序列场景(如将高频传感器数据对齐到低频参考值)。
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ── 左表:传感器数据,每秒一条记录 ────────────────────────────────
let sensor = df![
"timestamp" => [
"2024-01-01 10:00:01",
"2024-01-01 10:00:02",
"2024-01-01 10:00:30",
"2024-01-01 10:01:15",
"2024-01-01 10:01:45",
],
"temperature" => [23.1, 23.3, 24.0, 24.5, 25.0]
]?;
// ── 右表:参考基准值,每分钟更新一次 ──────────────────────────────
let reference = df![
"timestamp" => [
"2024-01-01 10:00:00",
"2024-01-01 10:01:00",
"2024-01-01 10:02:00",
],
"baseline" => [22.0, 22.5, 23.0]
]?;
println!("原始传感器数据:\n{}\n", sensor);
println!("原始参考数据:\n{}\n", reference);
// ── 预处理:将字符串时间戳解析为 datetime[μs],并添加分组列 ────────
//
// to_datetime 参数说明:
// arg1 Some(TimeUnit::Microseconds) — 目标时间精度(微秒)
// arg2 None — 时区(None 表示本地 / naive)
// arg3 StrptimeOptions — 解析格式与严格模式
// arg4 lit("raise") — 夏令时歧义处理策略
// 可选值:"raise"(报错)/ "earliest" / "latest" / "null"
//
// 注意:Polars 0.53 的 AsOf join 在 left_by / right_by 为空时
// 存在内部 panic(row_encode 越界)。
// 通过添加值全为 1 的虚拟 group 列进行分组,可绕过此问题。
let sensor_with_group = sensor
.lazy()
.with_column(
col("timestamp")
.str()
.to_datetime(
Some(TimeUnit::Microseconds),
None,
StrptimeOptions {
format: Some("%Y-%m-%d %H:%M:%S".into()),
strict: true,
exact: true,
..Default::default()
},
lit("raise"),
)
.alias("ts"),
)
// 虚拟分组列:所有行同组,保证 left_by / right_by 非空
.with_column(lit(1i32).alias("group"))
.sort(["timestamp"], Default::default())
.collect()?;
let reference_with_group = reference
.lazy()
.with_column(
col("timestamp")
.str()
.to_datetime(
Some(TimeUnit::Microseconds),
None,
StrptimeOptions {
format: Some("%Y-%m-%d %H:%M:%S".into()),
strict: true,
exact: true,
..Default::default()
},
lit("raise"),
)
.alias("ts"),
)
.with_column(lit(1i32).alias("group"))
.sort(["timestamp"], Default::default())
.collect()?;
println!("添加分组列后的传感器数据:\n{}\n", sensor_with_group);
println!("添加分组列后的参考数据:\n{}\n", reference_with_group);
// ── AsOf Join ─────────────────────────────────────────────────────
//
// 连接键:ts(datetime[μs]),左右表类型必须一致
// 策略:Backward —— 对每条左表记录,找右表中 ts <= 左表 ts 的最近一行
// 即"向后找最近的参考时间点"
// tolerance:None —— 不限制最大时间差;可设为具体值限制匹配范围
// allow_eq:true —— 允许时间戳完全相等时匹配
// check_sortedness:true —— 检查输入是否已排序(提升错误提示)
let result = sensor_with_group
.lazy()
.join(
reference_with_group.lazy(),
[col("ts")], // 左表连接键
[col("ts")], // 右表连接键
JoinArgs::new(JoinType::AsOf(Box::new(AsOfOptions {
left_by: vec!["group".into()].into(), // 左表分组列
right_by: vec!["group".into()].into(), // 右表分组列
tolerance: None,
strategy: AsofStrategy::Backward,
allow_eq: true,
check_sortedness: true,
tolerance_str: None,
}))),
)
// 只保留需要的列,丢掉辅助的 timestamp / group 列
.select([col("ts"), col("temperature"), col("baseline")])
.collect()?;
println!("AsOf Join 结果(向后查找最接近的时间戳):\n{}", result);
Ok(())
}原始传感器数据:
shape: (5, 2)
┌─────────────────────┬─────────────┐
│ timestamp ┆ temperature │
│ --- ┆ --- │
│ str ┆ f64 │
╞═════════════════════╪═════════════╡
│ 2024-01-01 10:00:01 ┆ 23.1 │
│ 2024-01-01 10:00:02 ┆ 23.3 │
│ 2024-01-01 10:00:30 ┆ 24.0 │
│ 2024-01-01 10:01:15 ┆ 24.5 │
│ 2024-01-01 10:01:45 ┆ 25.0 │
└─────────────────────┴─────────────┘
原始参考数据:
shape: (3, 2)
┌─────────────────────┬──────────┐
│ timestamp ┆ baseline │
│ --- ┆ --- │
│ str ┆ f64 │
╞═════════════════════╪══════════╡
│ 2024-01-01 10:00:00 ┆ 22.0 │
│ 2024-01-01 10:01:00 ┆ 22.5 │
│ 2024-01-01 10:02:00 ┆ 23.0 │
└─────────────────────┴──────────┘
AsOf Join 结果(向后查找最接近的时间戳):
shape: (5, 3)
┌─────────────────────┬─────────────┬──────────┐
│ ts ┆ temperature ┆ baseline │
│ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ f64 ┆ f64 │
╞═════════════════════╪═════════════╪══════════╡
│ 2024-01-01 10:00:01 ┆ 23.1 ┆ 22.0 │← 匹配 10:00:00
│ 2024-01-01 10:00:02 ┆ 23.3 ┆ 22.0 │← 匹配 10:00:00
│ 2024-01-01 10:00:30 ┆ 24.0 ┆ 22.0 │← 匹配 10:00:00
│ 2024-01-01 10:01:15 ┆ 24.5 ┆ 22.5 │← 匹配 10:01:00
│ 2024-01-01 10:01:45 ┆ 25.0 ┆ 22.5 │← 匹配 10:01:00
└─────────────────────┴─────────────┴──────────┘
asof Join 的核心参数:
strategy:Backward(向后找最近的时间点,默认)或 Forward(向前找)tolerance:设置最大允许时间差,超过则不匹配left_by / right_by:可选的精确匹配列,先按这些列精确分组,再在组内做 asof 匹配Polars Lazy 框架最大的优势之一就是查询优化器。当你使用 Lazy API 进行 Join 操作时,优化器会自动进行多种优化,让你的查询跑得更快、用更少的内存。
1. 谓词下推(Predicate Pushdown) 📤
如果你在 Join 之后使用了 filter() 条件,优化器会尝试把过滤条件下推到 Join 之前执行。这样 Join 时需要处理的数据量就大大减少了。
优化前: 优化后:
users ─┐ users ── filter(age > 25) ─┐
├─ Join ── filter() orders ── filter(...) ─────├─ Join
orders ┘ ┘2. 投影裁剪(Projection Pushdown) ✂️
如果你只需要 Join 结果中的几列,优化器会在 Join 之前就把不需要的列裁剪掉,减少内存占用和计算量。
优化前: 优化后:
users[id, name, age, ...] ─┐ users[id] ──────────────┐
├─ orders[user_id, amount] ├─
orders[user_id, amount, ...]┘ ┘3. Join 顺序优化
当你有多个 Join 操作时,优化器会根据数据量大小自动选择最优的 Join 顺序。
explain() 查看优化计划explain() 是你理解和调试查询优化的利器。它会打印出优化前和优化后的查询计划:
use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let users = df![
"user_id" => [1, 2, 3, 4, 5],
"name" => ["张三", "李四", "王五", "赵六", "钱七"],
"age" => [25, 30, 35, 28, 22],
"city" => ["北京", "上海", "广州", "深圳", "杭州"]
]?;
let orders = df![
"order_id" => [101, 102, 103, 104, 105, 106],
"user_id" => [1, 2, 3, 1, 2, 4],
"amount" => [100, 200, 150, 300, 250, 180],
"category" => ["电子", "服装", "食品", "电子", "服装", "食品"]
]?;
// 构建一个包含 Join + Filter + Select 的 Lazy 查询
let query = users
.lazy()
.join(
orders.lazy(),
[col("user_id")],
[col("user_id")],
JoinArgs::new(JoinType::Left),
)
// Join 后过滤
.filter(col("amount").gt(lit(150)))
// 只选择需要的列
.select([col("name"), col("amount"), col("category")]);
// 打印优化后的查询计划
println!("=== 优化后的查询计划 ===");
let plan = query.explain(true)?;
println!("{}", plan);
// 实际执行
println!("\n=== 查询结果 ===");
let result = query.collect()?;
println!("{}", result);
Ok(())
}输出(优化计划):
=== 优化后的查询计划 ===
simple π 3/3 ["name", "amount", "category"]
INNER JOIN:
LEFT PLAN ON: [col("user_id")]
DF ["user_id", "name", "age", "city"]; PROJECT["user_id", "name"] 2/4 COLUMNS
RIGHT PLAN ON: [col("user_id")]
FILTER [(col("amount")) > (150)]
FROM
DF ["order_id", "user_id", "amount", "category"]; PROJECT["user_id", "amount", "category"] 3/4 COLUMNS
END INNER JOIN
=== 查询结果 ===
shape: (4, 3)
┌──────┬────────┬──────────┐
│ name ┆ amount ┆ category │
│ --- ┆ --- ┆ --- │
│ str ┆ i32 ┆ str │
╞══════╪════════╪══════════╡
│ 张三 ┆ 300 ┆ 电子 │
│ 李四 ┆ 200 ┆ 服装 │
│ 李四 ┆ 250 ┆ 服装 │
│ 赵六 ┆ 180 ┆ 食品 │
└──────┴────────┴──────────┘解读 🔍:
simple π 3/3 ["name", "amount", "category"]:最终投影(Projection)优化生效,最终只输出 3 列。DF ... PROJECT["user_id", "name"] 2/4 COLUMNS:投影下推(Projection Pushdown) 生效!左表(users)在 Join 前只读取了必要的 2 列(user_id 用于 Join,name 用于最终输出),避免读取 age 和 city 这两列无用数据。FILTER [(col("amount")) > (150)] 出现在右表扫描阶段:谓词下推(Predicate Pushdown) 生效!过滤条件 amount > 150 被尽可能早地推送到右表(orders)的扫描阶段,在 Join 之前就完成过滤,大幅减少参与 Join 的数据量。额外说明:
query.explain(true) 可以清晰看到这些优化如何让查询更高效,尤其在处理大表时效果显著。这些优化在数据量大时效果尤为显著,可能带来数倍甚至数十倍的性能提升!
优化技巧 | 说明 |
|---|---|
始终用 Lazy API | 让优化器自动工作 |
尽早 select() | 减少中间数据的列数 |
尽早 filter() | 减少中间数据的行数 |
用 explain() 检查 | 确认优化是否生效 |
避免 Cross Join | 除非确实需要笛卡尔积 |
Join 键选择高基数字段 | 避免数据倾斜导致性能问题 |
让我们把前面学到的知识综合运用起来,完成一个真实的业务场景:合并用户表和订单表,计算每个用户的总消费金额和订单数。
users 表和 orders 表(Left Join)use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ====== 准备数据 ======
// 用户表
let users = df![
"user_id" => [1, 2, 3, 4, 5],
"name" => ["张三", "李四", "王五", "赵六", "钱七"],
"city" => ["北京", "上海", "广州", "深圳", "杭州"]
]?;
// 订单表(注意:user_id=5 没有订单)
let orders = df![
"order_id" => [101, 102, 103, 104, 105, 106, 107],
"user_id" => [1, 2, 1, 3, 2, 4, 1],
"amount" => [100, 250, 150, 80, 200, 300, 120],
"category" => ["电子", "服装", "食品", "服装", "电子", "食品", "服装"]
]?;
// ====== 构建查询 ======
let result = users
.lazy()
// 第一步:Left Join 保留所有用户
.join(
orders.lazy(),
[col("user_id")],
[col("user_id")],
JoinArgs::new(JoinType::Left),
)
// 第二步:按用户分组聚合
.group_by([col("user_id"), col("name")])
.agg([
// 计算订单数(排除 null)
col("order_id").count().alias("order_count"),
// 计算总消费金额
col("amount").sum().alias("total_amount"),
])
// 第三步:筛选总消费 > 200 的用户
.filter(col("total_amount").gt(lit(200)))
// 第四步:按总消费降序排列
.sort(
["total_amount"],
SortMultipleOptions::new()
.with_order_descending(true)
.with_nulls_last(true),
)
// 执行查询
.collect()?;
println!("=== 用户消费分析结果 ===");
println!("{}", result);
Ok(())
}输出:
=== 用户消费分析结果 ===
shape: (3, 4)
┌─────────┬──────┬─────────────┬──────────────┐
│ user_id ┆ name ┆ order_count ┆ total_amount │
│ --- ┆ --- ┆ --- ┆ --- │
│ i32 ┆ str ┆ u32 ┆ i32 │
╞═════════╪══════╪═════════════╪══════════════╡
│ 2 ┆ 李四 ┆ 2 ┆ 450 │
│ 1 ┆ 张三 ┆ 3 ┆ 370 │
│ 4 ┆ 赵六 ┆ 1 ┆ 300 │
└─────────┴──────┴─────────────┴──────────────┘解析 💡:
这就是 Lazy API 的魅力——链式调用让整个数据处理流程清晰易读,而且查询优化器会在后台自动优化执行计划!
给定以下两张表,请完成以下任务:
用户表 users:
user_id | name | level |
|---|---|---|
1 | 张三 | VIP |
2 | 李四 | 普通 |
3 | 王五 | VIP |
4 | 赵六 | 普通 |
订单表 orders:
order_id | user_id | amount | order_date |
|---|---|---|---|
1001 | 1 | 299 | 2024-03-01 |
1002 | 1 | 159 | 2024-03-05 |
1003 | 2 | 88 | 2024-03-02 |
1004 | 3 | 520 | 2024-03-03 |
1005 | 1 | 66 | 2024-03-08 |
1006 | 5 | 120 | 2024-03-04 |
要求:
avg_amount(平均消费金额 = 总消费 / 订单数)use polars::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// TODO: 创建 users 和 orders 两张表
// let users = df![ ... ]?;
// let orders = df![ ... ]?;
// TODO: 用 Lazy API 完成 Join + 聚合 + 过滤 + 排序
// 提示:
// 1. users.lazy().join(orders.lazy(), ...)
// 2. .group_by([col("user_id"), col("name"), col("level")])
// 3. .agg([col("amount").sum(), col("amount").count(), ...])
// 4. .filter(col("total_amount").gt(lit(200)))
// 5. .sort(["total_amount"], ...)
// TODO: 打印结果
// println!("{}", result);
Ok(())
}挑战加分项 🌟:尝试用
explain()查看你的查询计划,看看优化器做了哪些优化。再试试把filter放在join之前,对比一下查询计划的变化!
本节课我们系统学习了 Polars Rust 中的数据合并操作,从基础拼接到高级 Join,再到 Lazy 框架下的查询优化。
操作类型 | 推荐函数 / 方法 | 适用场景 | 关键要点 |
|---|---|---|---|
纵向拼接(同结构) | vstack()(Eager)concat(..., UnionArgs::default())(Lazy) | Schema 完全一致 | 最快、最推荐 |
纵向拼接(异构) | concat_df_diagonal()(Eager)concat(..., UnionArgs { diagonal: true, .. })(Lazy) | 列不完全相同,自动填充 null | 灵活处理 schema 差异 |
横向拼接 | hstack() / concat_df_horizontal()(Eager)concat_lf_horizontal()(Lazy) | 按列并排,行数需匹配 | Lazy 中强烈推荐专用函数 |
等值 Join | .join() + JoinType(Inner/Left/Right/Full/Cross) | 多表关联 | 支持单列或多列复合键 |
模糊 Join | .join() + JoinType::AsOf | 时间序列对齐 | 需要启用 asof_join feature |
查询优化 | explain(true) | 查看优化计划 | 谓词下推、投影下推、Join 重写等 |
本节核心要点:
Left Join + vstack() / concat() 即可满足需求。filter() 和 select(),减少中间数据量。explain():调试时一定要查看优化计划,确认优化器是否生效。掌握了这些多表操作,你已经具备了用 Polars 处理复杂关系型数据的能力!
下一节课,我们将学习 Polars Rust 第 8 课:窗口函数与高级聚合,探索 over()、滚动窗口、排名函数等强大功能。窗口函数是数据分析中的"杀手锏",能让你在不改变数据行数的前提下完成复杂的聚合计算。敬请期待!🎉