首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Polars Rust 第 7 课:数据合并与 Join

Polars Rust 第 7 课:数据合并与 Join

作者头像
不吃草的牛德
发布2026-04-23 13:07:03
发布2026-04-23 13:07:03
940
举报
文章被收录于专栏:RustRust

多表操作是关系型数据分析的核心能力。在真实业务中,数据几乎从来不会只存在于一张表里——用户信息在用户表、订单记录在订单表、商品详情在商品表……如何把这些分散的数据高效地拼接到一起,是每个数据工程师的必修课。

如果你用过 SQL,那么 JOIN 一定不陌生。Polars 同样提供了强大且灵活的 Join 能力,而且在 Rust 的 Lazy 框架下,还能享受到查询优化器带来的性能红利。本节课,我们将系统学习 Polars Rust 中的数据合并操作,包括纵向/横向拼接、五种 Join 类型、多条件 Join、asof Join,以及 Lazy 框架下的查询优化技巧。


一、纵向与横向拼接 🧩

在实际数据处理中,我们经常需要把多个 DataFrame 拼接到一起。拼接分为两种方向:

  • 纵向拼接(Vertical Concat):把行堆叠起来,类似 SQL 的 UNION ALL
  • 横向拼接(Horizontal Concat):把列并排拼起来,类似 SQL 的 SELECT a.*, b.*

1.1 vstack() —— 纵向拼接

vstack() 是最常用的纵向拼接函数,它要求所有 DataFrame 的 schema(列名和类型)完全一致

代码语言:javascript
复制
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(())
}

输出:

代码语言:javascript
复制
shape: (4, 2)
+------+----------+
| 城市 | 人口_万  |
| ---  | ---      |
| str  | i32      |
+======+==========+
| "北京" | 2189    |
| "上海" | 2487    |
| "广州" | 1868    |
| "深圳" | 1756    |
+------+----------+

1.2 concat_df_diagonal() —— Schema 不一致时的拼接

有时候,两个 DataFrame 的列不完全相同,但你想把它们纵向拼接。这时就需要 concat_df_diagonal(),它会自动处理缺失列,用 null 填充:

代码语言:javascript
复制
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(())
}

输出:

代码语言:javascript
复制
shape: (4, 3)
+------+----------+----------+
| 城市 | 人口_万  | GDP_亿   |
| ---  | ---      | ---      |
| str  | i32      | i32      |
+======+==========+==========+
| "北京" | 2189    | null     |
| "上海" | 2487    | null     |
| "广州" | null    | 28232    |
| "深圳" | null    | 32388    |
+------+----------+----------+

1.3 hstack() —— 横向拼接

hstack() 用于将多个 DataFrame 按列横向拼接。它要求所有 DataFrame 的 行数相同

代码语言:javascript
复制
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(())
}

输出:

代码语言:javascript
复制
shape: (3, 3)
+------+------+------+
| 姓名 | 语文 | 数学 |
| ---  | ---  | ---  |
| str  | i32  | i32  |
+======+======+======+
| "张三" | 90   | 95   |
| "李四" | 85   | 88   |
| "王五" | 78   | 92   |
+------+------+------+

1.4 Lazy API 中的横向合并

在 Lazy 框架下,进行横向合并(即把两个 DataFrame 的列并排拼接)时,不再推荐使用 with_columns() + Cross Joinhack 方式。Polars v0.53 提供了专门的函数 concat_lf_horizontal.

代码语言:javascript
复制
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(())
}

1.5 concat() -- LazyFrame 拼接函数

在 Polars v0.53 中,polars::prelude::concat() 是 Lazy API 下拼接多个 LazyFrame 的推荐统一入口。它通过 UnionArgs 参数灵活控制拼接方式。

代码语言:javascript
复制

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)。
  • 横向拼接
    • • Eager API 使用 hstack()concat_df_horizontal()
    • • Lazy API 强烈推荐concat_lf_horizontal(),避免旧的 with_columns() + Cross Join hack。
  • • Polars v0.53 起,Lazy API 下统一推荐通过 concat() + UnionArgs 处理纵向拼接(包括 vertical 和 diagonal)。

记忆口诀 同结构纵向用 vstack / concat 默认; 列不同纵向用 diagonal 横向拼接看行数,Lazy 首选 concat_lf_horizontal


二、Join 基础:五种连接类型 🔀

Join 是多表操作的核心。Polars 的 LazyFrame 提供了 .join() 方法,支持 5 种 Join 类型,和 SQL 的 JOIN 语义一一对应。

2.1 基本语法

代码语言:javascript
复制
lazy_df.join(
    other.lazy(),           // 右表(LazyFrame)
    left_keys,              // 左表的连接键
    right_keys,             // 右表的连接键
    JoinArgs::new(JoinType::Inner),  // Join 类型
)

让我们用两个示例表来演示各种 Join 类型:

代码语言:javascript
复制
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(())
}

2.2 Inner Join(内连接)⭕

只保留两表中都能匹配到的行。这是最严格的 Join 类型。

代码语言:javascript
复制
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);
代码语言:javascript
复制
Inner Join:
shape: (3, 4)
┌─────────┬──────┬──────────┬────────┐
│ user_id ┆ name ┆ order_id ┆ amount │
│ ---     ┆ ---  ┆ ---      ┆ ---    │
│ i32     ┆ str  ┆ i32      ┆ i32    │
╞═════════╪══════╪══════════╪════════╡
│ 1       ┆ 张三 ┆ 101      ┆ 100    │
│ 2       ┆ 李四 ┆ 102      ┆ 200    │
│ 2       ┆ 李四 ┆ 103      ┆ 150    │
└─────────┴──────┴──────────┴────────┘

2.3 Left Join(左连接)⬅️

保留左表的所有行,右表匹配不上的用 null 填充。这是最常用的 Join 类型。

代码语言:javascript
复制
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);
代码语言:javascript
复制
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 填充
└─────────┴──────┴──────────┴────────┘

2.4 Right Join(右连接)➡️

保留右表的所有行,左表匹配不上的用 null 填充。和 Left Join 正好相反。

代码语言:javascript
复制
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);
代码语言:javascript
复制
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 填充
└──────┴──────────┴─────────┴────────┘

2.5 Full Join(全连接)🔄

保留两表的所有行,匹配不上的都用 null 填充。相当于 Left Join + Right Join 的并集。

代码语言:javascript
复制
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);
代码语言:javascript
复制
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   │
└─────────┴──────┴──────────┴───────────────┴────────┘

2.6 Cross Join(交叉连接)✖️

笛卡尔积:左表的每一行和右表的每一行组合。不指定连接键,结果行数 = 左表行数 x 右表行数。

polars features需要开启cross_join

代码语言:javascript
复制
let result = users
    .clone()
    .lazy()
    .join(
        orders.clone().lazy(),
        // Cross Join 不需要指定连接键,传空数组
        vec![],
        vec![],
        JoinArgs::new(JoinType::Cross),
    )
    .collect()?;

println!("Cross Join:\n{}", result);

代码语言:javascript
复制
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。

2.7 五种 Join 类型速查表

Join 类型

保留左表

保留右表

匹配不上

典型场景

Inner

仅匹配行

仅匹配行

丢弃

精确匹配

Left

全部

仅匹配行

null 填充

保留主表

Right

仅匹配行

全部

null 填充

保留从表

Full

全部

全部

null 填充

数据合并

Cross

全部

全部

全组合

笛卡尔积


三、多条件 Join 🔑

在实际业务中,我们经常需要用多个列作为连接键。比如,一个公司有多个部门,每个部门都有编号为 1、2、3 的员工——这时候光靠 员工编号 就无法唯一标识一个人了,需要 部门 + 编号 的组合键。

在 Polars 中,多条件 Join 非常简单,只需要在 left_keysright_keys 中传入多个列名即可:

代码语言:javascript
复制
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(())
}

输出:

代码语言:javascript
复制
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"    |
+--------+--------+------+--------+-------+---------+

解读 🔍: 本例使用 deptemp_id 两列作为复合连接键进行 Left Join

  • • 左表(salaries)的 4 条记录全部保留,并成功匹配到右表对应的绩效数据。
  • • 右表中存在一条左表没有匹配的记录(工程部 emp_id=3),因此结果中多出一行,该行的 namesalarynull 使用多列 Join 可以精确匹配“部门 + 员工编号”的组合,避免仅用单个 emp_id 时可能出现的跨部门错误关联。

关键要点

  • left_keysright_keys列数必须相同,且一一对应
  • • 列名可以不同(比如左表叫 emp_id,右表叫 employee_id),Polars 会按位置对应
  • • 多条件 Join 的底层会自动优化为复合键哈希查找,性能很好

四、asof Join:时间序列的模糊匹配 ⏱️

在时间序列分析和金融数据处理中,我们经常遇到这样的场景:两条数据的时间戳不完全对齐,但你需要找到最接近的那个时间点的数据。这就是 asof Join 的用武之地。

什么是 asof Join?

asof Join("as of" = "截至")是一种基于最近时间戳的模糊匹配 Join。对于左表的每一行,它会在右表中找到时间戳最接近且不超过左表时间戳的那一行。

典型场景

想象一下:

  • • 你有一份每秒采样一次的传感器数据
  • • 另一份是每分钟更新一次的参考值
  • • 你想把参考值"对齐"到每一秒的传感器数据上

代码示例

注意:使用 asof Join 需要在 Cargo.toml 中启用 "asof_join" feature。

代码语言:javascript
复制
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(())
}
代码语言:javascript
复制
原始传感器数据:
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 的核心参数

  • strategyBackward(向后找最近的时间点,默认)或 Forward(向前找)
  • tolerance:设置最大允许时间差,超过则不匹配
  • left_by / right_by:可选的精确匹配列,先按这些列精确分组,再在组内做 asof 匹配

五、LazyFrame 下 Join 的查询优化 ⚡

Polars Lazy 框架最大的优势之一就是查询优化器。当你使用 Lazy API 进行 Join 操作时,优化器会自动进行多种优化,让你的查询跑得更快、用更少的内存。

5.1 三大核心优化

1. 谓词下推(Predicate Pushdown) 📤

如果你在 Join 之后使用了 filter() 条件,优化器会尝试把过滤条件下推到 Join 之前执行。这样 Join 时需要处理的数据量就大大减少了。

代码语言:javascript
复制
优化前:                        优化后:
users ─┐                       users ── filter(age > 25) ─┐
       ├─ Join ── filter()     orders ── filter(...) ─────├─ Join
orders ┘                                                ┘

2. 投影裁剪(Projection Pushdown) ✂️

如果你只需要 Join 结果中的几列,优化器会在 Join 之前就把不需要的列裁剪掉,减少内存占用和计算量。

代码语言:javascript
复制
优化前:                        优化后:
users[id, name, age, ...] ─┐   users[id] ──────────────┐
                            ├─   orders[user_id, amount] ├─
orders[user_id, amount, ...]┘                           ┘

3. Join 顺序优化

当你有多个 Join 操作时,优化器会根据数据量大小自动选择最优的 Join 顺序。

5.2 用 explain() 查看优化计划

explain() 是你理解和调试查询优化的利器。它会打印出优化前和优化后的查询计划:

代码语言:javascript
复制
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(())
}

输出(优化计划):

代码语言:javascript
复制
=== 优化后的查询计划 ===
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 用于最终输出),避免读取 agecity 这两列无用数据。
  • FILTER [(col("amount")) > (150)] 出现在右表扫描阶段谓词下推(Predicate Pushdown) 生效!过滤条件 amount > 150 被尽可能早地推送到右表(orders)的扫描阶段,在 Join 之前就完成过滤,大幅减少参与 Join 的数据量。
  • • Join 被优化为 INNER JOIN(尽管代码中写的是 Left Join):这是 Polars 查询优化器结合后续 Filter 和 Select 操作后的智能重写(因为 Left Join + Filter 右侧列后,某些情况下会等价或被优化)。

额外说明

  • • Polars 的查询优化器非常激进,会自动进行投影下推谓词下推、Join 类型调整等优化。
  • • 使用 query.explain(true) 可以清晰看到这些优化如何让查询更高效,尤其在处理大表时效果显著。

这些优化在数据量大时效果尤为显著,可能带来数倍甚至数十倍的性能提升!

5.3 优化建议清单

优化技巧

说明

始终用 Lazy API

让优化器自动工作

尽早 select()

减少中间数据的列数

尽早 filter()

减少中间数据的行数

用 explain() 检查

确认优化是否生效

避免 Cross Join

除非确实需要笛卡尔积

Join 键选择高基数字段

避免数据倾斜导致性能问题


六、实战练习:用户消费分析 📊

让我们把前面学到的知识综合运用起来,完成一个真实的业务场景:合并用户表和订单表,计算每个用户的总消费金额和订单数

需求描述

  1. 1. 合并 users 表和 orders 表(Left Join)
  2. 2. 筛选出总消费超过 200 元的用户
  3. 3. 按总消费降序排列
  4. 4. 输出:用户名、订单数、总消费金额

完整代码

代码语言:javascript
复制
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(())
}

输出:

代码语言:javascript
复制
=== 用户消费分析结果 ===
shape: (3, 4)
┌─────────┬──────┬─────────────┬──────────────┐
│ user_id ┆ name ┆ order_count ┆ total_amount │
│ ---     ┆ ---  ┆ ---         ┆ ---          │
│ i32     ┆ str  ┆ u32         ┆ i32          │
╞═════════╪══════╪═════════════╪══════════════╡
│ 2       ┆ 李四 ┆ 2           ┆ 450          │
│ 1       ┆ 张三 ┆ 3           ┆ 370          │
│ 4       ┆ 赵六 ┆ 1           ┆ 300          │
└─────────┴──────┴─────────────┴──────────────┘

解析 💡:

  • 张三有 3 笔订单(100+150+120=370),满足条件
  • 李四有 2 笔订单(250+200=450),消费最高!
  • 赵六有 1 笔订单(300),也满足条件
  • 王五(80 元)和钱七(无订单)被过滤掉了

这就是 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

要求

  1. 1. 用 Left Join 合并两张表
  2. 2. 计算每个用户的总消费金额订单数量
  3. 3. 添加一列 avg_amount(平均消费金额 = 总消费 / 订单数)
  4. 4. 只保留总消费 >= 200 的用户
  5. 5. 按总消费降序排列

参考答案框架

代码语言:javascript
复制
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 重写等

本节核心要点

  • 优先使用 Lazy API:让 Polars 查询优化器自动进行谓词下推、投影裁剪和 Join 优化,显著提升性能。
  • 选择正确的拼接/Join 类型:大多数业务场景下 Left Join + vstack() / concat() 即可满足需求。
  • 尽早过滤与投影:在 Join 之前尽量使用 filter()select(),减少中间数据量。
  • 善用 explain():调试时一定要查看优化计划,确认优化器是否生效。
  • 复合键与 asof Join:解决实际业务中“部门+员工”、“时间序列对齐”等常见场景。

掌握了这些多表操作,你已经具备了用 Polars 处理复杂关系型数据的能力!

下一节课,我们将学习 Polars Rust 第 8 课:窗口函数与高级聚合,探索 over()、滚动窗口、排名函数等强大功能。窗口函数是数据分析中的"杀手锏",能让你在不改变数据行数的前提下完成复杂的聚合计算。敬请期待!🎉


本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-04-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Rust火箭工坊 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、纵向与横向拼接 🧩
    • 1.1 vstack() —— 纵向拼接
    • 1.2 concat_df_diagonal() —— Schema 不一致时的拼接
    • 1.3 hstack() —— 横向拼接
    • 1.4 Lazy API 中的横向合并
    • 1.5 concat() -- LazyFrame 拼接函数
  • 二、Join 基础:五种连接类型 🔀
    • 2.1 基本语法
    • 2.2 Inner Join(内连接)⭕
    • 2.3 Left Join(左连接)⬅️
    • 2.4 Right Join(右连接)➡️
    • 2.5 Full Join(全连接)🔄
    • 2.6 Cross Join(交叉连接)✖️
    • 2.7 五种 Join 类型速查表
  • 三、多条件 Join 🔑
  • 四、asof Join:时间序列的模糊匹配 ⏱️
    • 什么是 asof Join?
    • 典型场景
    • 代码示例
  • 五、LazyFrame 下 Join 的查询优化 ⚡
    • 5.1 三大核心优化
    • 5.2 用 explain() 查看优化计划
    • 5.3 优化建议清单
  • 六、实战练习:用户消费分析 📊
    • 需求描述
    • 完整代码
  • 七、课后作业 📝
    • 题目
    • 参考答案框架
  • 八、总结与下节预告 🎯
    • 核心操作速查表
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档