实验 15:分布式连接
> 两张表放在不同的机器上时,用什么办法把它们连起来,以及"要搬多少数据"该怎么算。
一、实验目标
1. 区分两种数据分布方式:**垂直分区**(每个节点存整张表)与**水平分区**(每个节点存每张表的一部分)。
2. 掌握三种数据移动策略:整表传输、广播连接(broadcast join)、半连接归约(semi-join reduction)。
3. 理解协同分区(co-partition):按连接键(join key)分区时,连接可以在各节点本地完成。
4. 会用"传输字节数"这个度量比较不同策略,并按选择率、节点数、主键宽度做敏感性分析。
5. 验证一件事:不同执行策略得到的**结果集必须完全相同**,差异只在代价。
二、预备知识
2.1 分布式数据库的基本设定
**分布式 DBMS**(distributed DBMS)把数据放在多个节点(node / server)上,节点之间通过网络通信。与单机 DBMS 相比,多了一项关键代价:**网络传输**。局域网带宽通常比磁盘顺序读慢一到两个数量级,因此分布式查询优化的第一原则是"少搬数据"。
2.2 垂直分区与水平分区
**垂直分区**(vertical partitioning):一张表整个放在一个节点上,不同表在不同节点。
```
节点 s1 节点 s2
┌──────────────┐ ┌──────────────┐
│ persons │ │ books │
│ (整张表) │ │ (整张表) │
└──────────────┘ └──────────────┘
└──────── 网络 ────────┘
```
**水平分区**(horizontal partitioning):每张表按某个键切成若干份,分散到各节点。
```
节点 s1 节点 s2
┌────────────────────┐ ┌────────────────────┐
│ persons (A–L) │ │ persons (M–Z) │
│ books (A–L) │ │ books (M–Z) │
└────────────────────┘ └────────────────────┘
按连接键 author/name 的姓氏首字母切分 → 协同分区
```
**协同分区**(co-partitioning):两张表按**同一个连接键**、用**同一个哈希/范围函数**分区。这样"能连上的记录一定在同一个节点",连接可以完全在本地完成。
2.3 半连接(semi-join)
**半连接**(semi-join):`R ⋉ S` 的结果只保留 R 中那些"在 S 里能找到匹配"的记录,且不重复、不带 S 的列。
半连接归约的思路:先把一边的**连接键投影**(很小)发过去,用它把另一边的无用记录过滤掉,再传输过滤后的数据。因为它只发"键",所以比发整张表省得多。
```
s1 (有 persons) s2 (有 books)
1. SELECT DISTINCT name
WHERE birthday < 2001-01-01
2. ────── 只发连接键投影(很小) ──────────▶
3. books ⋉ 这些键(半连接,本地过滤)
5. ◀────── 只发过滤后的 books ────────────── 4.
6. 本地做最终连接
```
2.4 三种数据移动策略
| 策略 | 做法 | 适合场景 |
|---|---|---|
| 整表传输 | 把一张表整个搬到另一张表所在节点 | 表小、或者对方节点算力冗余 |
| 广播连接 | 把小表复制到**每一个**有大事实的节点 | 小表足够小,且要保留并行度 |
| 半连接归约 | 先发连接键投影,再发过滤后的数据 | 过滤条件选择率低、连接键投影远小于整行 |
真实系统的相关默认值(可用于对照):Spark 的 `spark.sql.autoBroadcastJoinThreshold` 默认 10485760(10 MB),AQE(自适应查询执行)自 Spark 3.2.0 起默认开启;Trino 的 `join-distribution-type` 默认 AUTOMATIC(PARTITIONED 两侧重分布、BROADCAST 要求右表过滤后能放进每个节点内存、无统计信息时回退到哈希分布式)。
三、实验原理
3.1 实验用的查询
```sql
SELECT *
FROM persons JOIN books
ON name = author
WHERE birthday < 2001-01-01
```
• `persons`:5,000 条,每条约 60 字节(本模型取值)
• `books`:10,000 条,每条约 52 字节(本模型取值)
• 节点数:3
• 过滤条件 `birthday < 2001-01-01` 的选择率假设为 60%(即 60% 的 persons 满足)
注意:`persons` 与 `books` 的连接键是**姓名**(`name = author`),不是整数主键。这一点对传输量影响很大(见思考题 3)。
3.2 三种策略的传输量
**① 整表传输**(把 books 整表从 s2 发到 s1)
```
传输量 = |books| × 每条字节数 = 10,000 × 52 B = 520,000 B ≈ 508 KB
```
**② 广播连接**(把 books 复制到 3 个节点)
```
传输量 = 508 KB × 3 ≈ 1,523 KB
```
**③ 半连接归约**
```
第 1 步:发 persons 的连接键投影(16 B/条,未按 birthday 过滤的全量 5,000 条) = 5,000 × 16 B = 80,000 B
第 2 步:发过滤后的 books(10,000 × 60% × 52 B) = 312,000 B
合计 = 392,000 B ≈ 383 KB
```
三种策略的对比图:

按本模型计算,半连接归约比整表传输少约 25%,比广播连接少约 75%。
**与仓库 notebook 的对照**:`notebooks/Distributed-Joins.ipynb` 里用 `table_size()` 逐行按字符串实际长度统计,报告的数字是"整表约 522 KB,半连接约 71 + 316 = 387 KB"。与上面的模型值(508 KB / 383 KB)量级一致,差异来自"每条记录按固定字节估算"还是"按字符串实际长度逐行统计"。两种口径都可以用,但**比较时必须统一口径**。
3.3 水平分区下的本地连接
如果两表已按连接键协同分区,那么每个节点可以独立完成自己那份连接,最后把两个局部结果合并即可:
```
s1: persons(A–L) ⋈ books(A–L) ─┐
├─▶ 合并(UNION)→ 最终结果
s2: persons(M–Z) ⋈ books(M–Z) ─┘
```
此时网络传输的只有**最终(或部分)结果**,通常是三种做法里最省的一种。代价从"搬输入"变成了"搬输出"。
四、实验环境与准备
• Python ≥ 3.9;必做部分只用标准库。
• 选做部分(复现 notebook)需要 `duckdb`、`pandas`、`faker`:
```bash
python -m pip install duckdb pandas faker
```
• 对应 notebook:`notebooks/Distributed-Joins.ipynb`。
• 若无法安装 duckdb/faker,只做步骤 1、2、5 即可完成本实验的核心内容。
五、实验步骤
步骤 1:建立传输量计算模型(必做)
把三种策略写成函数,方便后面做敏感性分析。
```python
KB = 1024
def size_kb(n_rows, bytes_per_row):
"""按"条数 × 每条字节数"估算表的大小,返回 KB。"""
return n_rows * bytes_per_row / KB
def strategy_whole_table(n_books, book_bytes):
"""① 整表传输:把 books 整表发到 persons 所在节点。"""
return size_kb(n_books, book_bytes)
def strategy_broadcast(n_books, book_bytes, n_workers):
"""② 广播连接:把 books 复制到每个节点。"""
return size_kb(n_books, book_bytes) * n_workers
def strategy_semi_join(n_persons, n_books, book_bytes, key_bytes, selectivity):
"""③ 半连接归约:先发连接键投影,再发过滤后的 books。"""
keys = n_persons * key_bytes # 第 1 步
reduced_books = n_books * selectivity * book_bytes # 第 2 步
return (keys + reduced_books) / KB
本实验的规模
N_PERSONS, N_BOOKS = 5_000, 10_000
PERSON_BYTES, BOOK_BYTES = 60, 52
N_WORKERS = 3
KEY_BYTES = 16
SELECTIVITY = 0.6
a = strategy_whole_table(N_BOOKS, BOOK_BYTES)
b = strategy_broadcast(N_BOOKS, BOOK_BYTES, N_WORKERS)
c = strategy_semi_join(N_PERSONS, N_BOOKS, BOOK_BYTES, KEY_BYTES, SELECTIVITY)
print(f"① 整表传输 : {a:8.1f} KB")
print(f"② 广播连接 : {b:8.1f} KB")
print(f"③ 半连接归约 : {c:8.1f} KB")
print(f"半连接 / 整表 = {c / a:.2f} 半连接 / 广播 = {c / b:.2f}")
```
本机输出:
```
① 整表传输 : 507.8 KB
② 广播连接 : 1523.4 KB
③ 半连接归约 : 382.8 KB
半连接 / 整表 = 0.75 半连接 / 广播 = 0.25
```
步骤 2:敏感性分析(必做)
改变选择率、节点数、连接键宽度,看结论会不会翻转。
```python
print("选择率对半连接的影响(整表固定为 %.0f KB):" % a)
for sel in [0.1, 0.2, 0.4, 0.6, 0.8, 1.0]:
v = strategy_semi_join(N_PERSONS, N_BOOKS, BOOK_BYTES, KEY_BYTES, sel)
print(f" 选择率 {sel:4.1f} → {v:7.1f} KB {'(比整表省)' if v < a else '(不如整表)'}")
print("\n节点数对广播连接的影响:")
for w in [2, 3, 5, 8, 16]:
print(f" 节点 {w:2d} → {strategy_broadcast(N_BOOKS, BOOK_BYTES, w):7.1f} KB")
print("\n连接键宽度对半连接的影响(选择率 0.6):")
for k in [4, 8, 16, 32]:
print(f" 键宽 {k:2d} B → {strategy_semi_join(N_PERSONS, N_BOOKS, BOOK_BYTES, k, SELECTIVITY):7.1f} KB")
```
把结果抄进观测记录表,并回答一个问题:**选择率到多少时,半连接归约就不再比整表传输划算?**(用 `a == c` 解一下临界值。)
步骤 3:用 duckdb 复现 notebook 的两种做法(选做)
下面这段代码与 `notebooks/Distributed-Joins.ipynb` 等价,用内存 duckdb 模拟三个节点。
```python
import duckdb
import pandas as pd
from faker import Faker
Faker.seed(42)
fake = Faker()
number_of_tuples = 10000 # notebook 口径:两表同为 10,000 条
注意:3.1 节的传输量模型是 persons = 5,000 / books = 10,000,与这里的两表
10,000 条不是同一口径,notebook 实测不能与模型值逐位对齐
persons = [(fake.name(), str(fake.date())) for _ in range(number_of_tuples)]
books = [(fake.sentence(), persons[i % number_of_tuples][0],
fake.pyfloat(min_value=0, max_value=100, right_digits=2))
for i in range(number_of_tuples)]
persons_df = pd.DataFrame(persons, columns=["name", "birthday"])
books_df = pd.DataFrame(books, columns=["title", "author", "price"])
def table_size(table: pd.DataFrame) -> float:
"""估算 DataFrame 的大小(KB):字符串按长度计,其余按 4 字节计。"""
size = 0
for _, row in table.iterrows():
for e in row:
size += len(e) if isinstance(e, str) else 4
return size / 1024
class Server:
"""一个节点:内含一个 duckdb 连接和一块本地数据区。"""
def __init__(self):
self.con = duckdb.connect(database=":memory:")
self.local_data = {}
def import_data(self, tables):
for table_name, df in tables.items():
self.con.register(table_name, df)
self.con.execute(f"CREATE TABLE {table_name} AS SELECT * FROM {table_name}")
def execute_query(self, query, parameters, name):
df = self.con.execute(query, parameters).fetchdf()
self.local_data[name] = df
self.con.register(name, df)
def get_local_data(self, name):
return self.local_data.get(name)
def receive(self, name, df):
self.con.register(name, df)
def send(self, other, local_data_name):
other.receive(local_data_name, self.get_local_data(local_data_name))
```
**做法 1.1:整表传输**
```python
s1, s2 = Server(), Server()
s1.import_data({"persons": persons_df})
s2.import_data({"books": books_df})
s2.execute_query("SELECT * FROM books", [], "books_local")
s2.send(s1, "books_local")
print("发送 books 整表,大小 %.0f KB" % table_size(s2.get_local_data("books_local")))
s1.execute_query("""SELECT * FROM persons JOIN books_local ON name = author
WHERE birthday < ?""", ["2001-01-01"], "result")
print("结果行数:", s1.get_local_data("result").shape[0])
```
**做法 1.2:半连接归约**
```python
s1.execute_query("SELECT DISTINCT name FROM persons WHERE birthday < ?",
["2001-01-01"], "persons_filtered")
s1.send(s2, "persons_filtered")
print("发送连接键投影,大小 %.0f KB" % table_size(s1.get_local_data("persons_filtered")))
s2.execute_query("""SELECT books.* FROM books JOIN persons_filtered
ON author = name""", [], "books_filtered")
s2.send(s1, "books_filtered")
print("发送过滤后的 books,大小 %.0f KB" % table_size(s2.get_local_data("books_filtered")))
s1.execute_query("""SELECT * FROM persons JOIN books_filtered ON name = author
WHERE persons.birthday < ?""", ["2001-01-01"], "result")
print("结果行数:", s1.get_local_data("result").shape[0])
```
步骤 4:结果一致性校验
三种做法(整表传输、半连接归约、本地连接)必须得到**完全相同**的结果集,否则说明某个环节写错了。
```python
def normalized(df):
return df.sort_values(by=df.columns.tolist()).reset_index(drop=True)
r1 = normalized(result_entire_table)
r2 = normalized(result_semi_join)
pd.testing.assert_frame_equal(r1, r2)
print("整表传输 与 半连接归约 的结果一致:", r1.shape)
```
步骤 5:水平分区 + 本地连接(选做)
按姓氏首字母把两表协同分区(< 'M' 走 s1,≥ 'M' 走 s2),各自本地连接后合并。
```python
def partition(df, column):
mask = df[column].str.split(" ").str[1].str[0] < "M"
return df[mask].reset_index(drop=True), df[~mask].reset_index(drop=True)
p1_persons, p2_persons = partition(persons_df, "name")
p1_books, p2_books = partition(books_df, "author")
s1, s2 = Server(), Server()
s1.import_data({"persons": p1_persons, "books": p1_books})
s2.import_data({"persons": p2_persons, "books": p2_books})
q = "SELECT * FROM persons JOIN books ON name = author WHERE birthday < ?"
s1.execute_query(q, ["2001-01-01"], "result_s1")
s2.execute_query(q, ["2001-01-01"], "result_s2")
print("s1 局部结果 %.0f KB,s2 局部结果 %.0f KB" %
(table_size(s1.get_local_data("result_s1")),
table_size(s2.get_local_data("result_s2"))))
s1.send(s2, "result_s1") # 只搬局部结果
s2.execute_query("SELECT * FROM result_s1 UNION SELECT * FROM result_s2",
[], "result_final")
print("最终结果行数:", s2.get_local_data("result_final").shape[0])
```
步骤 6:把观测到的数字与模型对照
把步骤 3/5 打印出的 KB 数与步骤 1 的模型值并列,回答:
1. 差多少?差异来自哪里(字符串逐行统计 vs 固定字节估算)?
2. 水平分区方案的传输量属于什么量级?为什么?
六、观测记录表
模型计算值(必做,把本机输出填入并注明与参照值是否一致):
| 策略 | 计算式 | 参照值(KB) | 本机实测(KB) |
|---|---|---|---|
| ① 整表传输 | 10,000 × 52 B | 508 | |
| ② 广播连接 | 508 KB × 3 | 1,523 | |
| ③ 半连接归约 | 5,000 × 16 B + 10,000 × 0.6 × 52 B | 383 | |
选择率敏感性(半连接归约):
| 选择率 | 0.1 | 0.2 | 0.4 | 0.6 | 0.8 | 1.0 |
|---|---|---|---|---|---|---|
| 传输量(KB) | | | | | | |
| 是否优于整表传输 | | | | | | |
节点数敏感性(广播连接):
| 节点数 | 2 | 3 | 5 | 8 | 16 |
|---|---|---|---|---|---|
| 传输量(KB) | | | | | |
连接键宽度敏感性(半连接归约,选择率 0.6):
| 键宽(B) | 4 | 8 | 16 | 32 |
|---|---|---|---|---|
| 传输量(KB) | | | | |
duckdb 复现(选做):
| 项目 | 参照值(notebook) | 本机实测 |
|---|---|---|
| 整表传输量(KB) | 约 522 | |
| 连接键投影(KB) | 约 71 | |
| 过滤后的 books(KB) | 约 316 | |
| 半连接合计(KB) | 约 387 | |
| 水平分区:s1 局部结果(KB) | 需自行实测 | |
| 水平分区:s2 局部结果(KB) | 需自行实测 | |
| 三种做法的结果行数是否一致 | 必须一致 | |
七、思考题
1. 按本模型计算:整表传输、广播连接、半连接归约各传输多少 KB?请写出完整算式。
2. 半连接归约在什么情况下**不划算**?请用本模型求临界选择率(半连接代价 = 整表传输代价时的选择率)。
3. 把连接键从"姓名(16 B)"换成"整数 ID(4 B)",半连接归约的传输量会降多少?为什么真实系统更爱用整数代理键做连接?
4. 广播连接的总传输量是整表传输的 3 倍(本实验 3 节点),为什么真实系统还是经常用它?
5. 水平协同分区为什么最省?如果两表**没有**按连接键分区,要付出什么代价?请用本模型估算一次 shuffle 的传输量。
6. 半连接归约要求"先发连接键投影"。如果过滤条件在 books 一侧(而不是 persons 一侧),这个做法还成立吗?方向该怎么选?
7. Spark 的 `spark.sql.autoBroadcastJoinThreshold` 默认 10485760(10 MB)是什么意思?表的估计大小超过它会发生什么?
8. Trino 的 `join-distribution-type = AUTOMATIC` 会在什么情况下选 BROADCAST、什么情况下选 PARTITIONED?没有统计信息时怎么办?
八、思考题答案与解析
**第 1 题**
```
① 整表传输 = 10,000 × 52 B = 520,000 B ÷ 1024 = 507.8 KB ≈ 508 KB
② 广播连接 = 507.8 KB × 3 = 1523.4 KB ≈ 1523 KB
③ 半连接 = (5,000 × 16 + 10,000 × 0.6 × 52) B
= (80,000 + 312,000) B = 392,000 B ÷ 1024 = 382.8 KB ≈ 383 KB
```
**第 2 题**
半连接归约的代价随选择率 σ 线性上升:代价 = (80,000 + 520,000σ) B。令它等于整表传输 520,000 B:
```
80,000 + 520,000 σ = 520,000
σ = 440,000 / 520,000 ≈ 0.846
```
所以当**选择率高于约 85%**(即几乎所有人都要保留)时,半连接归约就不如直接整表传输——因为你发了一大把键,却几乎没过滤掉什么。
**第 3 题**
键宽从 16 B 降到 4 B:
```
投影 = 5,000 × 4 B = 20,000 B = 19.5 KB(原为 78 KB)
合计 = 20,000 + 312,000 = 332,000 B ÷ 1024 ≈ 324 KB(原为 383 KB)
```
降低约 59 KB(约 15%)。真实系统偏爱整数代理键(surrogate key)的原因:更窄(4–8 B vs 十几到几十字节)、定长(比较更快)、哈希更均匀、分区更均衡;而且用 ID 做连接键时,姓名变更不会波及外键。
**第 4 题**
广播连接虽然总传输量是 3 倍,但它换来的是**并行度**:每个节点都拿到了小表,可以各自对自己那份大表做本地连接,三个节点同时算。整表传输则把工作全压到一个节点上,其他节点空转。当小表足够小(Spark 默认阈值 10 MB 以内)时,"多传一点、换全部节点并行"通常是划算的。反过来说,节点越多,广播的总量越大,节点数很大时就该改用哈希重分布。
**第 5 题**
协同分区时,能匹配上的记录一定落在同一个节点,所以连接是纯本地操作,网络只需要搬运**最终结果**(往往远小于输入)。
若两表没有按连接键分区,就必须先做 **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)还贵一倍以上——这就是"分区方式决定连接代价"的直接体现。
**第 6 题**
成立,但要**选对方向**:半连接归约的方向应该从"过滤后更小的一侧"出发,把它的连接键投影发给另一侧做过滤。若过滤条件在 books 一侧(例如 `WHERE price < 20`),则应该:
1. s2 本地过滤 books,取出 `DISTINCT author`;
2. 把 author 投影发给 s1;
3. s1 用它半连接过滤 persons;
4. 把过滤后的 persons 发回 s2(或反过来);
5. 在某一侧完成最终连接。
一般原则:**先算选择率,谁小谁先投影**。如果两侧都有过滤条件,就都先本地过滤再投影。
**第 7 题**
`spark.sql.autoBroadcastJoinThreshold` 是允许广播的表的大小上限,默认 10485760 字节 = 10 MB。优化器会读表的统计信息估计大小:估计值 ≤ 10 MB 就走 broadcast join(把小表发到每个 executor);超过阈值就改用 Sort-Merge Join,两侧都按连接键 shuffle 重分布。把它设为 −1 可以彻底关闭广播。另外,Spark 3.2.0 起 AQE(Adaptive Query Execution)默认开启,它能在运行时根据实际统计把 Shuffle Join 动态转成 Broadcast Join。
**第 8 题**
Trino 的 `join-distribution-type = AUTOMATIC` 的逻辑:
• **BROADCAST**:右表(被广播的一侧)过滤后足够小,能放进每个 worker 的内存 → 广播右表,左表不动,各 worker 本地连接;
• **PARTITIONED**:两侧都大 → 按连接键哈希重分布两侧,再做分布式连接;
• **没有统计信息时**:优化器无法判断表大小,回退到哈希分布式(PARTITIONED),这是一种保守选择——它不会因误判而撑爆内存。
这也是为什么"收集统计信息(ANALYZE)"在分布式系统里格外重要:没有它,优化器只能选保守策略。
九、扩展任务(选做)
1. **双向过滤**:给 books 也加一个过滤条件(如 `price < 20`),实现"两侧都先过滤再投影"的半连接,测传输量。
2. **多级半连接**:三张表连接时,设计"先投影两次再传数据"的方案,与两表情形比较。
3. **代价模型扩展**:把"网络传输量"换成"传输量 / 带宽 + 各节点 CPU 时间",考虑并行度,重新比较三种策略。
4. **倾斜(skew)实验**:让某个作者的书籍特别多(例如某作者占 30% 的 books),观察协同分区后各节点的负载是否均衡,思考倾斜怎么处理。
5. **真实引擎对照**:如果有 Spark 或 Trino 环境,跑同一条查询,用 `EXPLAIN` 看优化器选了哪种分布方式,与本实验的结论对照。
十、常见错误与排查
| 现象 | 原因 | 处理 |
|---|---|---|
| 半连接归约算出来比整表传输还贵 | 选择率取了 1.0(没有过滤效果),或键宽设得过大 | 检查选择率;键宽按实际列类型填(姓名约 16 B,整数 ID 4 B) |
| 三种做法的结果行数不一致 | 过滤条件只在一侧生效,或 `DISTINCT` 投影丢了重复键导致多匹配 | 半连接只用于过滤,最终连接仍要用完整条件;结果集用 `sort_values` 后比对 |
| `assert_frame_equal` 报列顺序不同 | 不同做法的 SELECT 列顺序不同(如 `books.*` vs `*`) | 比对前统一列顺序:`df = df[df.columns.sort_values()]` |
| 水平分区后出现重复或缺失记录 | 分区谓词用了 `<` 与 `>`,漏掉了等于边界 | 用 `< "M"` 与 `>= "M"` 这种互补的划分 |
| `table_size()` 慢得离谱 | 用 `iterrows()` 逐行遍历大表 | 本实验数据量下可接受;数据量大时改成向量化的 `df.applymap` 或按列估算 |
| duckdb 报 `Table with name xxx does not exist` | 只在 `local_data` 里存了,没有 `con.register()` | `receive()` 里必须 `register`,duckdb 才能查到 |
| 广播连接随节点数线性增长,看起来很亏 | 这是模型本身的结果,不是 bug | 记住广播换来的是并行度,代价与收益要一起看 |
| `ModuleNotFoundError: No module named 'duckdb'` | 依赖没装 | 只做步骤 1、2、5,或 `pip install duckdb faker` |
| faker 生成的数据每次不同 | 没设种子 | 加 `Faker.seed(42)`,保证可复现 |