实验 15:分布式连接
为什么做这个实验
数据不在一台机器上时,连接的玩法会整个变掉。
单机里优化查询,第一目标是"算得快"——减少 CPU 与磁盘 I/O;分布式里,瓶颈往往从 CPU 变成了**网络**,
第一目标也随之变成"**少搬数据**"。局域网带宽通常比本地磁盘顺序读慢一到两个数量级,
搬错一次数据,省下的 CPU 时间连零头都抵不回来。
于是"怎么把两张分处不同节点的表连起来"就有了多种方案,它们的**结果完全相同**,
差别只在搬了多少字节:
| 策略 | 做法 | 什么时候划算 |
| --- | --- | --- |
| 整表传输 | 把一张表整个搬到另一张表所在的节点 | 表小,或者对方节点算力冗余 |
| 广播连接 | 把小表复制到**每一个**有大表的节点 | 小表足够小,且想保住并行度 |
| 半连接归约 | 先发连接键投影去过滤,再发过滤后的数据 | 过滤条件选择率低、连接键投影远小于整行 |
本实验用内存版 DuckDB 模拟三个节点,把这三种方案的传输量**量出来**并对比,
最后用单元测试证明一件事:**不同执行策略的结果集必须一模一样**——差的只是代价,不是答案。
运行前准备
• Python 3.9 及以上。
• 依赖:`duckdb`(每个节点一个内存实例)、`pandas`(搬的数据就是 DataFrame)、`faker`(造演示数据)。
• 缺包时安装:
```bash
python -m pip install duckdb pandas faker
```
(本机已配置环境可用:`python -m pip install duckdb pandas faker`)
• 打开方式:在仓库根目录执行 `jupyter notebook notebooks/Distributed-Joins.ipynb`。
第一个代码 cell 会把仓库根目录加进 `sys.path`,这样 `from system.data_classes import *` 才找得到 `Person` 与 `Book`。
• 若实在装不上 `duckdb`,本实验的核心(三种策略的传输量模型)仍可用纯 Python 算,见末尾思考题。
要解决的问题
```sql
SELECT *
FROM persons JOIN books
ON name = author
WHERE birthday < 2001-01-01
```
两个前提要盯住,因为它们直接决定搬多少数据:
1. **数据怎么分布**(见下面两种场景):
- 垂直分区:每个节点存**整张**表,不同表在不同节点;
- 水平分区:每张表按某个键切成若干份,分散到各节点。
2. **过滤条件在哪一侧**:`birthday < 2001-01-01` 作用在 `persons` 上。
它决定了半连接归约应该从哪一侧先发连接键投影。
下面先造数据、定义"节点",然后分别在两种分布场景下执行这条查询。
from __future__ import (
annotations,
) # postponed evaluation of annotations https://peps.python.org/pep-0563/
import sys, pathlib
# Put the repo root (the directory containing `system/`) on sys.path so
# `from system...` works whether this notebook is opened from its own
# folder or from the repo root.
for _p in (pathlib.Path.cwd(), *pathlib.Path.cwd().parents):
if (_p / "system").is_dir():
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
break
import duckdb
import pandas as pd
from typing import Any
from faker import Faker
from system.data_classes import *Faker.seed(42)
fake = Faker()
number_of_tuples = 10000
# create some fake data mimicking tuples in a relation
persons = [Person(fake.name(), fake.date()) for _ in range(number_of_tuples)]
books = [
Book(
fake.sentence(),
persons[i % number_of_tuples].name,
fake.pyfloat(min_value=0, max_value=100, right_digits=2),
)
for i in range(number_of_tuples)
]
# convert to Pandas DataFrame
persons_df = pd.DataFrame(persons)
books_df = pd.DataFrame(books)
display(persons_df[:5])
display(books_df[:5])定义"节点"(Server)
`Server` 类把一个节点抽象成两样东西:
• `con`:该节点上跑着的 DBMS 实例(这里是一个内存 DuckDB 连接);
• `local_data`:该节点的本地数据区,字典形式,名字 → DataFrame。
三个关键方法:
| 方法 | 作用 |
| --- | --- |
| `import_data` | 把表导入本节点的 DBMS |
| `execute_query` | 在本节点执行查询,结果存进本地数据区并**注册**回 DuckDB,供后续查询引用 |
| `send` / `receive` | 模拟网络传输:`send` 取出本地数据交给对方,`receive` 把它注册进对方的 DuckDB |
请注意这是**模拟**,不是真的网络:`send` 只是把同一个 DataFrame 的引用交给另一个对象,
没有序列化、没有带宽限制。所以本实验能观测的是"**搬了多少数据**",而不是"搬了多久"。
要测真实耗时,得用真正的分布式引擎(Spark / Trino)。
class Server:
"""A simple server that contains a duckdb connection.
Attributes:
con (duckdb.duckdb.DuckDBPyConnection): The duckdb connection.
local_data (dict[str, pd.DataFrame): The local data.
"""
def __init__(self, con: duckdb.duckdb.DuckDBPyConnection):
self.con: duckdb.duckdb.DuckDBPyConnection = con # DBMS running on this server
self.local_data: dict[str, pd.DataFrame] = {}
def import_data(self, tables: dict[str, pd.DataFrame]):
"""Import the data tables into the duckdb instance.
:param tables: A dictionary of table name and data to import.
"""
# duckdb resolves the literal `df_name` in the query below by the
# Python variable name (replacement scan), so df_name is used even
# though it never appears interpolated.
# pylint: disable-next=unused-variable
for table_name, df_name in tables.items():
self.con.execute(f"CREATE TABLE {table_name} AS SELECT * FROM df_name")
def execute_query(self, query: str, parameters: list[Any], name: str = "result"):
"""Execute the given `query` with its `parameters` on the duckdb instance. The result is stored in the local data area with the name `name` and registered in the duckdb instance.
:param query: The query to execute.
:param parameters: The parameters belonging to the query. Required to execute the query as a prepared statement.
:param name: The name of the result table.
"""
result_df = self.con.execute(query, parameters).fetchdf() # prepared statement
self.local_data[name] = result_df
self.con.register(name, result_df)
def get_local_data(self, name: str):
"""Retrieve the data table specified by the name `name` from the local data area.
:param name: The name of the data table to retrieve.
:return: The data table specified by the name `name`.
"""
try:
data_df = self.local_data[name]
return data_df
except KeyError:
print(f"No local data for {name}.")
return
def receive(self, name: str, data_df: pd.DataFrame):
"""Receive a table with the name `name` and the data given by `data_df`. Simulates receiving data by registering the data in the duckdb instance.
:param name: The name of the data table.
:param data_df: The data to be received.
"""
self.con.register(
name, data_df
) # simulate receiving data by registering the data in the duckdb instance
def send(self, other: Server, local_data_name: str):
"""Sends the data specified by `local_data_name` from the servers local data area to the `other` server.
:param other: Server to send the data to.
:param local_data_name: The name of the local data table to send.
"""
data_df = self.get_local_data(local_data_name)
other.receive(
local_data_name, data_df
) # simulate sending the data to another server怎么度量"搬了多少数据"
`table_size()` 的口径要记牢,后面所有数字都依赖它:
• 字符串按**实际字符长度**计字节;
• 数值等非字符串一律按 **4 字节**计;
• 最后除以 1024 换算成 KB。
这是一个**近似**估算,不是 DuckDB 的真实存储占用。另有一套口径是"条数 × 每条固定字节数"
(本实验的模型值:persons 60 B/条、books 52 B/条)。两套口径都可用,
但**比较时必须统一口径**,否则会得出错误的结论。
# Utility function to compute the size of a database table
def table_size(table: pd.DataFrame):
"""Returns the size of a pandas DataFrame in kilobytes. This function assumes 4 byte for numeric/non-string values and counts the length of the string otherwise.
:param table: pandas DataFrame representing a table.
:return: size of the table in kilobytes.
"""
size = 0 # byte
for _, row in table.iterrows():
for e in row:
if type(e) is str:
size += len(e) # length of string
else:
size += 4 # assume 4 byte for numeric/non-string values
return size / 1024场景 1:垂直分区——每个节点存一整张表
s1 s2
┌──────────────┐ ┌──────────────┐
│ persons │ │ books │
│ (整张表) │ │ (整张表) │
└──────────────┘ └──────────────┘
└────────── 网络 ────────┘
这是最"直白"的分布:一张表整个待在一个节点上。此时要做连接,就必须把某一侧搬过去
(或者把两侧都搬到第三个节点)。下面比较两种搬法。
# Setup server 1
s1 = Server(duckdb.connect(database=":memory:"))
s1.import_data({"persons": persons_df}) # s1 contains entire Persons table
# Setup server 2
s2 = Server(duckdb.connect(database=":memory:"))
s2.import_data({"books": books_df}) # s2 contains entire Books table做法 1.1:整表传输
最直接的做法:把 `books` 整表从 s2 搬到 s1,然后在 s1 上完成全部工作。
执行步骤:
1. s2 取出整张 `books`,放进自己的本地数据区(命名 `books_local`);
2. s2 把它 `send` 给 s1;
3. s1 在本地做 `persons ⋈ books_local`,再套上 `birthday < ?` 过滤,得到最终结果。
**代价**:整张 books 的字节数。
**问题**:① 数据里可能有一大半是无用的(作者不满足生日条件的那些书),全搬了;
② 工作全压在 s1 上,s2 搬完就闲着——**并行度丢了**。
预期看到:`Sending books table of size ... KB.` 后面跟着结果行数。
把这个 KB 数记下来,它是后面两种做法的对照基线。
# 1. Extract Books table on s2 and store it in the local data area of s2 with the name "books_local"
s2.execute_query("SELECT * FROM books", [], "books_local")
# 2. Send copy of the Books table to s1
s2.send(s1, "books_local")
print(f"Sending books table of size {table_size(s2.get_local_data("books_local"))} KB.")
# 3. s1 now contains the Books data in its local data area -> perform join between Persons and Books
s1.execute_query(
"""SELECT *
FROM persons JOIN books_local on name = author
WHERE birthday < ?
""",
["2001-01-01"],
"result", # name used to store the result in the local data area of s1
)
result_entire_table = s1.get_local_data(
"result"
) # retrieve the previously computed result
print("Number of results:", result_entire_table.shape[0])做法 1.2:半连接归约(semi-join reduction)
上一个做法的问题在于"搬了不该搬的"。改进思路:既然只关心 21 世纪前出生的人写的书,
那就**先把这个信息(连接键投影)发过去做过滤,再搬过滤后的数据**。
术语:**半连接**(semi-join)`R ⋉ S` 只保留 R 中"在 S 里能找到匹配"的记录,
不重复、也不带 S 的列。它在这里的用途是**过滤**,不是产出结果。
执行步骤:
1. s1 本地执行 `SELECT DISTINCT name FROM persons WHERE birthday < ?`,取出满足条件的连接键投影;
2. s1 把这份投影发给 s2——它只有一列姓名,**远小于整张 persons**;
3. s2 用它做半连接 `books ⋈ 这些键`,本地过滤出合格的 books;
4. s2 把过滤后的 books 发给 s1;
5. s1 用完整的过滤条件完成最终连接。
注意两点:
• 第 1 步用了 `DISTINCT`:投影里同一个键出现多次没有意义,去重能再省一点;
• 第 5 步**仍然要再写一遍** `persons.birthday < ?`。半连接只用于减少传输,
最终结果的正确性还得靠完整条件保证。
预期看到两段发送日志:一段是投影(很小),一段是过滤后的 books(明显小于整表)。
# 1. Extract the primary key of all persons born before the 21th century on s1 and store the result in the local data area of s1 with the name "persons_filtered"
s1.execute_query(
"SELECT DISTINCT name FROM persons WHERE birthday < ?",
["2001-01-01"],
"persons_filtered",
)
# 2. Send copy of filtered persons to S2
s1.send(s2, "persons_filtered")
persons_filtered_size = table_size(s1.get_local_data("persons_filtered"))
print(f"Sending filtered persons table of size {persons_filtered_size} KB.")
# 3. Extract books written by persons born before the 21th century
s2.execute_query(
"SELECT books.* FROM books JOIN persons_filtered ON author = name",
[],
"books_filtered",
) # semi-join
# 4. Send copy of filtered books to s1
s2.send(s1, "books_filtered")
books_filtered_size = table_size(s2.get_local_data("books_filtered"))
print(f"Sending filtered books table of size {books_filtered_size} KB.")
# 5. Execute final query, note that we could also reuse the pre-filtered persons table
s1.execute_query(
"""SELECT *
FROM persons JOIN books_filtered on name = author
WHERE persons.birthday < ?
""",
["2001-01-01"],
"result",
)
result_semi_join = s1.get_local_data(
"result"
) # retrieve the previously computed result
print("Number of results:", result_semi_join.shape[0])对比:搬了多少数据
仓库 notebook 的实测输出(`table_size()` 按字符串实际长度逐行统计):
• 做法 1.1 整表传输:约 **522 KB**;
• 做法 1.2 半连接归约:约 71 KB(连接键投影)+ 316 KB(过滤后的 books)= **387 KB**。
另有一套"固定字节估算"的模型口径(persons 5 000 条 × 60 B、books 10 000 条 × 52 B、
3 个节点、过滤选择率 60%、连接键 16 B):
| 策略 | 算式 | 传输量 |
| --- | --- | --- |
| ① 整表传输 | 10 000 × 52 B | **508 KB** |
| ② 广播连接 | 508 KB × 3 | **1 523 KB** |
| ③ 半连接归约 | 5 000 × 16 B + 10 000 × 0.6 × 52 B | **383 KB** |
两套数字量级一致,差异来自口径不同(逐行按字符串实际长度 vs 每条按固定字节估算)。
**比较时必须统一口径**,不要拿 522 去比 383。
按模型口径,半连接归约比整表传输少约 25%,比广播连接少约 75%。
还有一句代码注释里的提醒值得展开:这里的连接键是**姓名**(`name = author`),不是整数 ID。
姓名又长又不均匀,如果换成整数代理键(4 B),第 1 步的投影会从 5 000 × 16 B 降到 5 000 × 4 B,
半连接归约还能再省一截——这也是真实系统偏爱整数代理键做连接的原因之一。
场景 2:水平分区——每个节点存每张表的一部分
s1 s2
┌────────────────────┐ ┌────────────────────┐
│ persons (A–L) │ │ persons (M–Z) │
│ books (A–L) │ │ books (M–Z) │
└────────────────────┘ └────────────────────┘
按连接键(姓名)的姓氏首字母切分 → 协同分区
**协同分区**(co-partitioning):两张表按**同一个连接键**、用**同一个分区函数**切分。
这样一来"能连上的记录一定落在同一个节点",连接可以在各节点**本地**独立完成,
网络只需要搬运最终的(或部分)结果。
关键点:切分键必须**就是连接键**。如果两表没有按连接键分区,就必须先 **shuffle**(重分布)——
按连接键的哈希值把每张表的记录发到对应节点,那通常是最贵的一种做法。
分区函数:按连接键切分
`partition()` 取每条记录的姓名(persons 用 `name`,books 用 `author`),
按**空格拆分取第 2 段**(即姓氏)的首字母,以 'M' 为界切成两份:
• 姓氏首字母 < 'M' → 分区 1(放 s1);
• 否则 → 分区 2(放 s2)。
两个细节值得注意:
1. **两表必须用同一个函数、同一个键切分**。persons 按 `name`、books 按 `author`,
而连接条件正是 `name = author`,所以同一个人的记录必然落在同一个节点——这就是协同分区。
2. **切分谓词必须互补**。这里用 `< 'M'` 与其否定 `>= 'M'`(代码里是 `else` 分支),
边界上一个不漏、一个不重。若写成 `< 'M'` 与 `> 'M'`,恰好姓 'M' 开头的人会被两边同时丢掉。
这种按首字母切分的方式有个明显缺点:**姓氏分布不均匀**,S 开头的可能比 Q 开头的多好几倍,
两个节点负载失衡。真实系统用哈希分区来缓解这一点,但哈希也救不了"某个键本身特别密集"的倾斜。
def partition(data: list, attribute_name: str):
"""Partitions the input data based on the first character of the last name.
:param data: List of entities to partition.
:param attribute_name: Name of the attribute on which to partition the data.
:return: Two pandas.DataFrames containing both partitions.
"""
p1 = []
p2 = []
for e in data:
last_name = getattr(e, attribute_name).split(" ")[1]
if last_name[0] < "M":
p1.append(e)
else:
p2.append(e)
return pd.DataFrame(p1), pd.DataFrame(p2)# Partition the data based on the join attribute
s1_persons_df, s2_persons_df = partition(persons, "name")
s1_books_df, s2_books_df = partition(books, "author")
# Setup server 1
s1 = Server(duckdb.connect(database=":memory:"))
s1.import_data(
{"persons": s1_persons_df, "books": s1_books_df}
) # s1 contains a partition of Books and Persons
# Setup server 2
s2 = Server(duckdb.connect(database=":memory:"))
s2.import_data(
{"persons": s2_persons_df, "books": s2_books_df}
) # s2 contains a partition of Books and Persons做法 2.1:本地连接
因为两表已经按连接键协同分区,"能匹配的记录必在同一个节点",所以每个节点可以各自算自己的那份:
1. s1 算姓氏首字母 < 'M' 的那份局部连接结果,s2 算 ≥ 'M' 的那份;
2. s1 把自己的局部结果发给 s2(代码里挑了较小的那份来发);
3. s2 用 `UNION` 合并两份局部结果,得到最终结果。
**代价**:网络只搬了**局部结果**,而不是任何一张输入表。这通常是最省的一种做法——
代价从"搬输入"变成了"搬输出"。
代价转移到了别处:它要求数据**事先**按连接键分区好。分区本身是一次全量重排,
而且如果某个键特别密集(数据倾斜),对应节点会明显更慢。
# 1. Compute the partial join result locally on each node
s1.execute_query(
"SELECT * FROM persons JOIN books ON name = author WHERE birthday < ?",
["2001-01-01"],
"result_s1",
)
s2.execute_query(
"SELECT * FROM persons JOIN books ON name = author WHERE birthday < ?",
["2001-01-01"],
"result_s2",
)
# 2. Send `result_s1` to s2 since it is smaller than `result_s2`
s1.send(s2, "result_s1")
result_s1_size = table_size(s1.get_local_data("result_s1"))
print(f"Sending partial result computed on s1 of size {result_s1_size} KB to s2.")
# Compute final result by combining both partial results
s2.execute_query(
"SELECT * FROM result_s1 UNION SELECT * FROM result_s2", [], "result_final"
)
result_local_joins = s2.get_local_data("result_final")
print("Number of results:", result_local_joins.shape[0])单元测试:三种做法必须给出同一个答案
这是本实验最重要的一条断言:整表传输、半连接归约、本地连接三种执行策略,
**结果集必须完全相同**。它们只是代价不同,不是语义不同。
比对方式:三种结果的**行顺序**可能不同(不同执行计划产出顺序不同),
所以先把每个 DataFrame 按**全部列**排序并重置索引,再用 `assert_frame_equal` 逐格比对。
如果这条断言挂了,说明某个环节写错了——最常见的两种:
• 半连接那一步把 `DISTINCT` 投影当成了最终结果,丢掉了重复匹配;
• 水平分区的切分谓词不互补(例如一边用 `<` 另一边用 `>`),边界记录被漏掉。
跑完应看到 `Ran 1 test ... OK`。
import unittest
from pandas.testing import assert_frame_equal
class MyTest(unittest.TestCase):
def test_result_sets(self):
# Compare the three previously computed result sets.
# Sort each table on all columns to ensure that the order of rows match
result_11 = result_entire_table.sort_values(
by=result_entire_table.columns.tolist()
).reset_index(
drop=True
) # result computed by approach 1.1
result_12 = result_semi_join.sort_values(
by=result_semi_join.columns.tolist()
).reset_index(
drop=True
) # result computed by approach 1.2
result_21 = result_local_joins.sort_values(
by=result_local_joins.columns.tolist()
).reset_index(
drop=True
) # result computed by approach 2.1
assert_frame_equal(result_11, result_12)
assert_frame_equal(result_12, result_21)
# Run unit tests
unittest.main(argv=["ignored", "-v"], defaultTest="MyTest", verbosity=2, exit=False)真实引擎里的对应物
本 notebook 手工实现了三种策略;真实系统把这些选择做进了优化器:
• **Spark**:`spark.sql.autoBroadcastJoinThreshold` 默认 10485760 字节(10 MB)。
优化器估计某张表的大小 ≤ 10 MB 就走 broadcast join(把小表发到每个 executor,各自本地连接),
超过就改用 Sort-Merge Join,两侧都按连接键 shuffle 重分布。设为 −1 可彻底关闭广播。
另外 **AQE**(Adaptive Query Execution,自适应查询执行)自 **Spark 3.2.0** 起默认开启,
它能在运行时根据实际统计把 Shuffle Join 动态转成 Broadcast Join——这正是"估计不准就补救"的思路。
• **Trino**:`join-distribution-type` 默认 `AUTOMATIC`。右表过滤后足够小 → BROADCAST;
两侧都大 → PARTITIONED(哈希重分布两侧);**没有统计信息时回退到 PARTITIONED**
(保守选择,不会因误判而撑爆内存)。
• **GIS 场景**:如果连接涉及空间谓词(例如"哪些订单落在某个配送区域内"),
连接键不再是标量而是几何对象,分区与索引都要另做。Apache Sedona 在 Spark 之上提供了
空间分区、空间索引与空间连接(spatial join)算子,是这类负载的常用迁移路径。
一句话总结:**本实验手工做的"选哪种搬法",在真实引擎里是优化器根据统计信息自动做的。**
所以收集统计信息(ANALYZE)在分布式系统里格外重要。
思考题与详解
**1. 按"固定字节估算"的模型口径计算:整表传输、广播连接、半连接归约各传输多少 KB?写出完整算式。**
<details><summary>答案要点</summary>
已知:persons 5 000 条 × 60 B,books 10 000 条 × 52 B,3 个节点,
过滤选择率 σ = 0.6,连接键宽 16 B,1 KB = 1024 B。
• ① 整表传输 = 10 000 × 52 B = 520 000 B ÷ 1024 = **507.8 KB ≈ 508 KB**
• ② 广播连接 = 507.8 KB × 3 = **1 523.4 KB ≈ 1 523 KB**
• ③ 半连接归约 = (5 000 × 16 + 10 000 × 0.6 × 52) B = (80 000 + 312 000) B = 392 000 B ÷ 1024 = **382.8 KB ≈ 383 KB**
半连接 / 整表 ≈ 0.75,半连接 / 广播 ≈ 0.25。
</details>
**2. 半连接归约在什么情况下不划算?用本模型求临界选择率(半连接代价 = 整表传输代价时的 σ)。**
<details><summary>答案要点</summary>
半连接代价随选择率 σ **线性上升**:代价(σ) = (80 000 + 520 000 σ) B。
令它等于整表传输的 520 000 B:
80 000 + 520 000 σ = 520 000 ⇒ σ = 440 000 / 520 000 ≈ 0.846
所以当**选择率高于约 85%** 时,半连接归约不如直接整表传输——你发了一大把连接键,
却几乎没过滤掉什么,等于多付了一次投影的钱。
直觉:半连接赚的是"过滤掉的那一小部分"。σ 越接近 1,能过滤掉的越少,赚头越小,
而发键的固定开销(80 000 B)一分不少。
</details>
**3. 把连接键从"姓名(16 B)"换成"整数 ID(4 B)",半连接归约的传输量会降多少?为什么真实系统更爱用整数代理键做连接?**
<details><summary>答案要点</summary>
• 投影:5 000 × 4 B = 20 000 B(原为 80 000 B),省 60 000 B;
• 合计:20 000 + 312 000 = 332 000 B ÷ 1024 ≈ **324 KB**(原为 383 KB),降低约 59 KB,约 **15%**。
(注:本实验里降低幅度不算大,是因为过滤后的 books 那 312 000 B 占了大头。
真实场景里连接键投影占比更高时,收益会明显得多。)
真实系统偏爱整数代理键(surrogate key)的原因:
• **更窄**:4–8 B,而姓名通常十几到几十字节;
• **定长**:比较与哈希更快,分区更均衡;
• **稳定**:用 ID 做外键时,姓名变更不会波及引用它的表;
• **哈希均匀**:整数哈希分布好,shuffle 时不易倾斜。
</details>
**4. 广播连接的总传输量是整表传输的 3 倍(本实验 3 节点),为什么真实系统还是经常用它?**
<details><summary>答案要点</summary>
因为它换来的是**并行度**。
• 广播:每个节点都拿到小表,可以各自对自己那份大表做本地连接,**N 个节点同时算**;
• 整表传输:把工作全压到一个节点,其他节点搬完就空转。
当小表足够小(Spark 默认阈值 10 MB 以内)时,"多传一点、换全部节点并行"通常是划算的——
网络多传 2 份的代价,远小于让 2/3 的节点闲着。
反过来说:**节点越多,广播的总量越大**(线性增长),节点数很大时就该改用哈希重分布。
所以广播不是"永远好",而是"小表 + 中等规模集群"下的好选择。
</details>
**5. 水平协同分区为什么最省?如果两表没有按连接键分区,要付出什么代价?请用本模型估算一次 shuffle 的传输量。**
<details><summary>答案要点</summary>
协同分区时,能匹配上的记录**一定落在同一个节点**,所以连接是纯本地操作,
网络只需要搬运**最终结果**(往往远小于输入)。代价从"搬输入"变成"搬输出"。
若两表没有按连接键分区,就必须先 **shuffle**(重分布):按连接键的哈希值把每张表的记录
发到对应节点。按本模型估算,两张表都要搬:
persons: 5 000 × 60 B = 300 000 B ≈ 293 KB
books : 10 000 × 52 B = 520 000 B ≈ 508 KB
合计 ≈ 801 KB
这比半连接归约(383 KB)还贵一倍以上。**这就是"分区方式决定连接代价"的直接体现。**
代价还包括:分区本身是一次全量重排(离线成本),以及数据倾斜时某些节点会明显更慢。
</details>
**6. 半连接归约要求"先发连接键投影"。如果过滤条件在 books 一侧(比如 `WHERE price < 20`),这个做法还成立吗?方向该怎么选?**
<details><summary>答案要点</summary>
成立,但要**选对方向**。半连接归约的原则是:**先算选择率,谁过滤后更小,就从谁出发投影。**
过滤条件在 books 一侧时的做法:
1. s2 本地过滤 books(`WHERE price < 20`),取出 `DISTINCT author`;
2. 把 author 投影发给 s1;
3. s1 用它半连接过滤 persons,只保留"写过便宜书"的人;
4. 把过滤后的 persons 发回 s2(或留在 s1,看哪边后续更划算);
5. 在某一侧完成最终连接。
一般化:**两侧都有过滤条件时,两侧都先本地过滤再投影**,这样两端的投影都是最小的。
判断方向时不能只看行数,还要看**投影的字节数**:一侧行数少但连接键很宽,另一侧行数多但键很窄,
后者可能更划算。
</details>
**7. Spark 的 `spark.sql.autoBroadcastJoinThreshold` 默认 10485760(10 MB)是什么意思?表的估计大小超过它会发生什么?**
<details><summary>答案要点</summary>
它是**允许被广播的表的大小上限**,默认 10485760 字节 = 10 MB。
• 优化器读表的统计信息估计大小:**估计值 ≤ 10 MB** → 走 broadcast join(把小表发到每个 executor,各 executor 本地连接);
• **超过阈值** → 改用 Sort-Merge Join,两侧都按连接键 shuffle 重分布,再排序归并连接;
• 设为 **−1** 则彻底关闭广播。
补充:自 **Spark 3.2.0** 起 **AQE**(Adaptive Query Execution)默认开启,
它能在运行时拿到真实的 shuffle 统计后,把原本计划的 Shuffle Join **动态转成** Broadcast Join。
这是"估计不准就补救"的机制,也说明静态阈值只是第一道防线。
</details>
**8. 本实验用内存 DuckDB 模拟节点,`send()` 只是把 DataFrame 的引用交给另一个对象。这个模拟能测出什么、测不出什么?**
<details><summary>答案要点</summary>
**能测出**:每种策略**搬了多少数据**(字节数 / 行数),以及不同策略的结果集是否一致。
这两项正是本实验要建立的直觉——"优化目标从算得快变成少搬数据"。
**测不出**:
• 传输**耗时**与带宽竞争:没有序列化、没有网络栈、没有排队,`send` 是零成本的;
• 节点间的**并行加速**:这里是同一进程里顺序执行,广播换来的并行度体现不出来,
所以"广播虽然多传 2 份但整体更快"这个结论在本 notebook 里看不到;
• 内存压力:真实系统里广播的小表要放进每个 executor 的内存,装不下会 OOM
(这也是为什么要有 10 MB 阈值);
• **数据倾斜**:真实 shuffle 里某个热点键会让一个节点慢很多,这里没有。
要看这些,得上真正的分布式引擎(Spark / Trino),用 `EXPLAIN` 观察优化器实际选了哪种分布方式。
</details>