database_tutorial

第3章:查询处理与优化

查询处理与优化是数据库系统的核心组件,直接决定了系统的性能表现。本章将深入探讨从SQL语句到物理执行计划的完整转换过程,包括查询解析、逻辑优化、物理优化以及执行策略的选择。我们将特别关注成本模型的设计、连接算法的选择、以及统计信息在优化决策中的关键作用。通过本章学习,您将能够理解和分析复杂查询的执行计划,识别性能瓶颈,并做出合理的优化决策。

学习目标

3.1 查询解析与重写

3.1.1 词法和语法分析

查询处理的第一步是将SQL文本转换为内部表示。这个过程包括:

词法分析(Lexical Analysis):将SQL语句分解为标记(tokens),如关键字、标识符、操作符等。词法分析器通常使用有限状态自动机(DFA)实现,能够识别SQL的保留字和各种字面量。

词法分析的关键任务:

语法分析(Syntax Analysis):根据SQL语法规则构建抽象语法树(AST)。现代数据库通常使用LALR(Look-Ahead Left-to-Right)或递归下降解析器。语法分析不仅要检查语法正确性,还要处理SQL方言的差异。

语法分析的核心步骤:

  1. 语法规则匹配:根据BNF(Backus-Naur Form)或EBNF语法规则进行匹配
  2. 优先级处理:正确解析操作符优先级(如AND优先于OR)
  3. 歧义消解:处理语法歧义(如连续的JOIN操作)
  4. 错误恢复:提供有意义的错误信息和位置定位
  5. 方言兼容:支持不同SQL标准(SQL-92、SQL:2011等)和厂商扩展
SQL: SELECT name, age FROM users WHERE age > 21 ORDER BY name

AST表示:
        SELECT_STMT
       /     |     \
   PROJECT   |    ORDER_BY
    /  \     |       |
  name age  FROM    name
            /  \
         SCAN  FILTER
          |      |
        users  age > 21
        
详细的AST节点信息:
- SELECT_STMT: 根节点,表示完整的SELECT语句
- PROJECT: 投影节点,包含选择的列[name, age]
- FROM: 数据源节点,引用表users
- SCAN: 表扫描操作符
- FILTER: 过滤条件节点,包含谓词age > 21
- ORDER_BY: 排序节点,指定排序键name和方向ASC

每个节点还包含:
- 位置信息(行号、列号)用于错误报告
- 类型信息(初步的类型推断)
- 别名信息(AS子句)
- 限定符(DISTINCT、ALL等)

3.1.2 语义分析

语义分析阶段验证查询的逻辑正确性,确保语法正确的SQL在语义上也是有效的:

名称解析(Name Resolution) 将表名、列名映射到系统目录中的对象,处理作用域和命名空间:

类型检查(Type Checking) 验证操作符和函数的参数类型匹配,必要时插入类型转换:

权限验证(Authorization Check) 检查用户是否有权限访问相关对象:

视图展开(View Expansion) 将视图引用替换为其定义,处理嵌套视图:

完整性约束验证

3.1.3 查询重写

查询重写是逻辑优化的重要组成部分,通过等价变换改进查询结构,为后续物理优化创造更多机会。重写规则基于关系代数的等价定律,保证语义不变的前提下提高执行效率。

谓词下推(Predicate Pushdown)

谓词下推是最基本也是最有效的优化技术之一。其核心思想是将过滤条件尽早应用,减少后续操作处理的数据量。根据关系代数定律: \(\sigma_p(R \bowtie S) = \sigma_p(R) \bowtie S\)(当p只涉及R的属性)

原始查询:
SELECT * FROM (SELECT * FROM orders) o WHERE o.status = 'SHIPPED'

重写后:
SELECT * FROM orders WHERE status = 'SHIPPED'

-- 更复杂的例子:跨越连接的谓词下推
原始:
SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id
WHERE o.amount > 1000 AND c.country = 'US'

重写后(下推到扫描层):
SELECT * FROM 
  (SELECT * FROM orders WHERE amount > 1000) o 
  JOIN 
  (SELECT * FROM customers WHERE country = 'US') c 
  ON o.customer_id = c.id

谓词下推的收益分析:

常量折叠(Constant Folding)与传播(Constant Propagation)

常量折叠在编译时计算常量表达式,常量传播则将已知常量值代入其他表达式:

-- 常量折叠
原始:WHERE price * 1.1 > 100 * 1.1
折叠:WHERE price * 1.1 > 110

-- 常量传播
原始:WHERE x = 5 AND y = x + 3
传播:WHERE x = 5 AND y = 8

-- 复杂表达式简化
原始:WHERE date >= CURRENT_DATE - INTERVAL '30' DAY 
      AND date <= CURRENT_DATE - INTERVAL '1' DAY
简化:WHERE date BETWEEN '2024-12-10' AND '2025-01-08'  -- 假设当前日期为2025-01-09

子查询去关联(Subquery Decorrelation)

关联子查询需要对外查询的每一行执行一次,性能开销巨大。去关联化将其转换为连接,实现批量处理:

-- Type 1: 简单关联子查询
原始:
SELECT * FROM orders o 
WHERE o.total > (SELECT AVG(total) FROM orders WHERE region = o.region)

重写为:
SELECT o.* FROM orders o 
JOIN (SELECT region, AVG(total) as avg_total FROM orders GROUP BY region) r
ON o.region = r.region AND o.total > r.avg_total

-- Type 2: EXISTS子查询
原始:
SELECT * FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o 
    WHERE o.customer_id = c.id 
    AND o.date > '2024-01-01'
)

重写为:
SELECT DISTINCT c.* FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.date > '2024-01-01'

-- Type 3: NOT EXISTS子查询(使用反连接)
原始:
SELECT * FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
)

重写为:
SELECT c.* FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.customer_id IS NULL

去关联的收益:

视图合并(View Merging)与内联(Inlining)

简单视图可以直接展开,复杂视图需要谨慎处理:

-- 简单视图合并
CREATE VIEW active_customers AS
SELECT * FROM customers WHERE status = 'ACTIVE';

原始查询:
SELECT name FROM active_customers WHERE country = 'US'

合并后:
SELECT name FROM customers 
WHERE status = 'ACTIVE' AND country = 'US'

-- 聚合视图的处理(不能简单合并)
CREATE VIEW customer_stats AS
SELECT customer_id, COUNT(*) as order_count, SUM(amount) as total_amount
FROM orders GROUP BY customer_id;

-- 这种视图通常保持为派生表,除非满足特定条件

表达式规范化(Expression Normalization)

将逻辑等价的表达式转换为统一形式,便于识别优化机会:

-- 布尔表达式规范化(CNF:合取范式)
原始:(A AND B) OR (A AND C)
规范化:A AND (B OR C)

-- 算术表达式规范化
原始:x * 2 + x * 3
规范化:x * 5

-- 比较表达式标准化
原始:5 < x
规范化:x > 5

-- NULL处理规范化
原始:x IS NOT NULL AND x > 5
规范化:x > 5  -- x > 5隐含x IS NOT NULL

连接消除(Join Elimination)

当连接不影响结果时可以消除:

-- 外键约束保证的连接消除
原始:
SELECT o.* FROM orders o 
JOIN customers c ON o.customer_id = c.id
-- 如果不使用customers的列,且有外键约束,可以消除连接

优化后:
SELECT o.* FROM orders o
-- 前提:o.customer_id有外键约束引用c.id

-- 唯一性约束保证的连接消除
原始:
SELECT DISTINCT c.name 
FROM customers c JOIN orders o ON c.id = o.customer_id

优化后(如果c.id是主键):
SELECT name FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)

公共子表达式消除(Common Subexpression Elimination)

识别并复用重复计算:

原始:
SELECT 
    price * quantity * tax_rate as tax_amount,
    price * quantity * (1 + tax_rate) as total_amount
FROM orders

优化后:
WITH computed AS (
    SELECT *, price * quantity as base_amount 
    FROM orders
)
SELECT 
    base_amount * tax_rate as tax_amount,
    base_amount * (1 + tax_rate) as total_amount
FROM computed

聚合优化(Aggregation Optimization)

-- 分组推送(Group By Pushdown)
原始:
SELECT region, SUM(amount) 
FROM orders o JOIN customers c ON o.customer_id = c.id
GROUP BY region

优化(如果region来自customers):
SELECT region, SUM(order_sum)
FROM (
    SELECT customer_id, SUM(amount) as order_sum 
    FROM orders GROUP BY customer_id
) o
JOIN customers c ON o.customer_id = c.id
GROUP BY region

-- 聚合合并(Aggregate Merge)
原始:
SELECT COUNT(*) FROM t WHERE a > 5
UNION ALL
SELECT COUNT(*) FROM t WHERE b < 10

可考虑合并为:
SELECT 
    SUM(CASE WHEN a > 5 THEN 1 ELSE 0 END),
    SUM(CASE WHEN b < 10 THEN 1 ELSE 0 END)
FROM t

3.1.4 逻辑计划生成

经过重写后的查询被转换为逻辑查询计划,通常表示为关系代数操作树:

逻辑操作符:
- σ (Selection/Filter)
- π (Projection)
- ⋈ (Join)
- γ (Group By/Aggregation)
- τ (Sort)
- ∪, ∩, - (Set Operations)

3.2 代价模型

3.2.1 代价模型基础

代价模型是优化器选择执行计划的依据。一个完整的代价模型需要考虑:

CPU代价

I/O代价

内存代价

网络代价(分布式环境)

3.2.2 代价估算公式

全表扫描代价 \(C_{scan} = N_{pages} \times C_{seq\_io} + N_{tuples} \times C_{cpu\_tuple}\)

其中:

实际例子:10GB表,页大小8KB,噣1,310,720页,1亿行 \(C_{scan} = 1,310,720 \times 1.0 + 100,000,000 \times 0.01 = 2,310,720\)

索引扫描代价 \(C_{index} = H \times C_{random\_io} + N_{leaf} \times C_{seq\_io} + N_{tuples} \times C_{cpu\_tuple}\)

其中:

例子:查找1000行,索引高度3,叶子节点10个 \(C_{index} = 3 \times 4.0 + 10 \times 1.0 + 1000 \times 0.01 = 32\)

索引回表代价 当索引不覆盖所有需要的列时: \(C_{index\_fetch} = C_{index} + N_{rows} \times C_{random\_io} \times clustering\_factor\)

其中clustering_factor∈[0,1]表示数据的聚集程度

嵌套循环连接代价 \(C_{NLJ} = C_{outer} + N_{outer} \times C_{inner}\)

对于块嵌套循环: \(C_{BNLJ} = C_{outer} + \lceil\frac{|R|}{M-2}\rceil \times C_{inner}\)

其中M为可用内存页数

哈希连接代价 内存哈希连接: \(C_{HJ\_mem} = C_{outer} + C_{inner} + (N_{outer} + N_{inner}) \times C_{hash}\)

Grace哈希连接(磁盘): \(C_{HJ\_disk} = 3 \times (C_{outer} + C_{inner})\)

排序归并连接代价 \(C_{SMJ} = C_{sort}(R) + C_{sort}(S) + C_{merge}\)

其中排序代价: \(C_{sort}(R) = 2 \times |R| \times (1 + \lceil\log_{M-1}(\frac{|R|}{M})\rceil)\)

并行执行代价模型 \(C_{parallel} = \frac{C_{serial}}{N_{workers} \times efficiency} + C_{coordination}\)

其中efficiency通常为0.7-0.9,协调代价随工作线程数增加

3.2.3 代价校准

代价模型的准确性依赖于参数校准,这些参数必须反映实际硬件环境和工作负载特征。准确的代价模型是优化器做出正确决策的基础。

硬件参数校准

不同硬件配置需要不同的代价参数:

-- SSD vs HDD 参数对比
HDD系统:
  random_page_cost = 4.0    # 随机I/O是顺序I/O的4倍
  seq_page_cost = 1.0
  effective_io_concurrency = 1

SSD系统:
  random_page_cost = 1.5    # SSD随机访问更快
  seq_page_cost = 1.0  
  effective_io_concurrency = 200

NVMe系统:
  random_page_cost = 1.1    # 几乎无随机访问惩罚
  seq_page_cost = 1.0
  effective_io_concurrency = 1000

微基准测试方法

-- 测量顺序扫描速度
CREATE TABLE bench_seq (id int, data text);
INSERT INTO bench_seq SELECT i, repeat('x', 100) 
FROM generate_series(1, 1000000) i;

-- 测量随机访问速度
CREATE TABLE bench_random AS 
SELECT * FROM bench_seq ORDER BY random();
CREATE INDEX ON bench_random(id);

-- 测量CPU操作代价
SELECT count(*) FROM bench_seq;  -- 纯CPU密集
SELECT count(*) FROM bench_seq WHERE data LIKE '%pattern%';  -- CPU密集型过滤

缓冲池效应建模

实际系统中,缓冲池显著影响I/O代价:

\[C_{effective} = C_{io} \times (1 - hit\_ratio) + C_{memory} \times hit\_ratio\]

其中命中率估算: \(hit\_ratio = min(1, \frac{buffer\_size}{working\_set\_size})^{skew\_factor}\)

-- 不同缓冲池大小的影响
小缓冲池(1GB):
  - 热数据竞争激烈
  - 频繁的页面置换
  - 随机访问代价高

大缓冲池(64GB):
  - 工作集可能完全在内存
  - 预热后I/O极少
  - CPU成为瓶颈

自适应参数调整

现代系统通过机器学习动态调整参数:

# 伪代码:自适应代价校准
class AdaptiveCostCalibration:
    def __init__(self):
        self.samples = []
        self.model = LinearRegression()
    
    def record_execution(self, plan, actual_time):
        features = extract_features(plan)  # I/O数、CPU操作数等
        self.samples.append((features, actual_time))
        
        if len(self.samples) >= 100:
            self.retrain()
    
    def retrain(self):
        X = [s[0] for s in self.samples]
        y = [s[1] for s in self.samples]
        self.model.fit(X, y)
        
        # 更新代价参数
        self.update_cost_parameters(self.model.coef_)

工作负载特定校准

不同工作负载需要不同的参数设置:

OLTP工作负载:
  - 短查询为主
  - 索引访问频繁
  - 优化启动时间
  参数:
    random_page_cost = 2.0  # 索引访问友好
    cpu_index_tuple_cost = 0.005
    cpu_tuple_cost = 0.01

OLAP工作负载:
  - 大表扫描
  - 复杂聚合
  - 批处理
  参数:
    random_page_cost = 4.0  # 偏好顺序扫描
    parallel_setup_cost = 1000
    parallel_tuple_cost = 0.1
    
混合工作负载:
  - 需要动态调整
  - 基于查询分类使用不同参数集
  - 时间窗口感知(白天OLTP,夜间OLAP)

3.2.4 高级代价模型技术

非线性代价模型

实际系统中,代价往往呈非线性关系:

-- 排序的非线性代价
小数据集(< work_mem):
  C_sort = N × log(N) × C_compare  # 内存快速排序

大数据集(> work_mem):
  C_sort = N × log(N) × C_compare + 2 × N × C_io  # 外部归并排序
  
-- 哈希表的非线性代价
负载因子 < 0.75:
  C_probe = O(1)
  
负载因子 > 0.75:
  C_probe = O(1 + α)  # α是负载因子,性能退化

并行执行代价模型

并行查询的代价不是简单的线性缩放:

\[C_{parallel} = C_{startup} + \frac{C_{work}}{N_{workers} × efficiency} + C_{merge}\]

其中效率因子考虑:

-- 并行度选择
理想并行度 = min(
    可用CPU核数,
    sqrt(数据量/块大小),  # Amdahl定律
    内存带宽/查询带宽需求
)

分布式查询代价模型

分布式环境增加网络传输代价:

\[C_{distributed} = C_{local} + C_{shuffle} + C_{coordinate}\]

网络传输代价建模: \(C_{network} = latency + \frac{data\_size}{bandwidth} + serialization\_cost\)

-- 数据本地性优化
本地处理率 = 本地数据量 / 总数据量

if 本地处理率 > 0.8:
    使用本地优先策略
elif 数据量 < 10MB:
    使用广播策略
else:
    使用重分区策略

3.2.5 Rule of Thumb:代价模型设计原则

  1. 相对代价比绝对代价重要
    • 优化器只需要比较不同计划的相对优劣
    • 绝对时间预测困难且不必要
    • 保持代价单位一致即可
  2. I/O通常是瓶颈,但不总是
    • HDD系统:I/O主导(80%+时间)
    • SSD系统:I/O和CPU平衡(50/50)
    • 内存系统:CPU和内存带宽主导
  3. 考虑缓存层次结构
    L1 Cache: 1 cycle
    L2 Cache: 4 cycles  
    L3 Cache: 40 cycles
    Memory: 200 cycles
    SSD: 20,000 cycles
    HDD: 2,000,000 cycles
    
  4. 简化假设的权衡
    • 过于简单:估计不准,选择差的计划
    • 过于复杂:优化时间长,维护困难
    • 建议:80/20原则,捕获主要因素
  5. 最坏情况 vs 平均情况
    • OLTP:优化平均情况(大量短查询)
    • OLAP:考虑最坏情况(避免失控查询)
    • 关键业务:P99性能比平均值重要
  6. 数据倾斜的影响
    • 均匀分布假设经常失效
    • 热点数据需要特殊处理
    • 考虑使用分位数而非平均值
  7. 动态因素
    • 并发查询的资源竞争
    • 系统负载的时间变化
    • 数据温度(冷热数据)
  8. 经验公式
    哈希表构建:选择较小的表
    嵌套循环:外表行数 × 内表访问代价
    归并连接:适合已排序或需要排序的场景
    索引选择:选择性 < 5% 时使用索引
    并行度:sqrt(数据量) 但不超过CPU核数
    

3.3 连接算法

连接操作是关系数据库的核心,也是查询处理中最昂贵的操作。选择合适的连接算法对查询性能至关重要。本节深入分析三大经典连接算法及其变体。

连接操作的复杂性来源于:

  1. 笛卡尔积爆炸:两个大表连接可能产生巨大的结果集
  2. 选择性不确定:连接选择性从0(无匹配)到N×M(完全笛卡尔积)
  3. 多种物理实现:每种算法有不同的性能特征
  4. 内存限制:大多数连接无法完全在内存中完成

3.3.1 嵌套循环连接(Nested Loop Join)

嵌套循环连接是最直观的连接算法,通过双重循环遍历两个表的所有组合。虽然简单,但在特定场景下仍是最优选择。

基本算法(Simple Nested Loop)

for each tuple r in R (outer table):
    for each tuple s in S (inner table):
        if join_condition(r, s):
            output (r ⋈ s)

代价分析:
- I/O代价: |R| + |R| × |S|
- CPU代价: |R| × |S| 次谓词评估
- 内存需求: O(1) - 只需要存储当前处理的元组
这种简单实现的问题是内表被扫描 R 次,I/O代价极高。

块嵌套循环(Block Nested Loop)

利用内存缓冲区批量处理,显著减少I/O:

算法:Block Nested Loop Join
Input: 外表R,内表S,可用内存M页
Output: R ⋈ S

for each chunk of (M-2) pages from R:
    load chunk into memory
    for each page of S:
        for each tuple r in memory chunk:
            for each tuple s in current S page:
                if join_condition(r, s):
                    output (r ⋈ s)

代价分析:
- 最好情况(R完全装入内存): |R| + |S| I/O
- 一般情况: |R| + ⌈|R|/(M-2)⌉ × |S| I/O
- 最坏情况(M=3): |R| + |R| × |S| I/O

优化技巧:

索引嵌套循环(Index Nested Loop)

当内表有索引时,避免全表扫描:

算法:Index Nested Loop Join
前提:S表在连接列上有索引

for each tuple r in R:
    use index to probe S for matches with r.join_key
    for each matching tuple s:
        output (r ⋈ s)

代价分析:
设索引高度为h,平均匹配记录数为m
- I/O代价: |R| + |R| × (h + m)
- 对于B+树索引,h通常为3-4
- 如果索引聚簇,匹配记录的获取更高效

索引选择策略

选择索引的考虑因素:
1. 索引选择性(唯一值数量/总行数)
2. 索引聚簇因子(数据物理顺序与索引顺序的相关性)
3. 索引覆盖能力(是否包含所需的所有列)

if 内表索引选择性 > 0.95:  # 高选择性
    使用索引嵌套循环
elif 外表很小 AND 内表索引存在:
    使用索引嵌套循环
else:
    考虑其他连接算法

适用场景深度分析

  1. OLTP点查询:外表通过索引过滤后只有少量行
  2. 小表驱动大表:小表作为外表,大表有索引
  3. 流式处理需求:需要尽快返回第一批结果
  4. 复杂连接条件:非等值连接(如范围连接)

性能优化技巧

-- 优化1:批量索引探测(Batched Index Probe)
收集一批外表键值
批量探测索引,利用局部性
减少随机I/O

-- 优化2:缓存内表页(Page Caching)
LRU缓存最近访问的内表页
对于倾斜的连接键分布特别有效

-- 优化3:预取优化(Prefetching)
异步预取下一批需要的页
隐藏I/O延迟

3.3.2 排序归并连接(Sort-Merge Join)

排序归并连接通过先排序后归并的策略,将随机访问转换为顺序访问,特别适合大数据集的连接。

核心算法

算法:Sort-Merge Join
阶段1 - 排序:
    sort R by join_key
    sort S by join_key

阶段2 - 归并:
    r_cursor = first tuple in R
    s_cursor = first tuple in S
    r_group = []
    s_group = []
    
    while r_cursor != null and s_cursor != null:
        # 收集相同键值的组
        r_group = all tuples in R with key = r_cursor.key
        s_group = all tuples in S with key = s_cursor.key
        
        if r_cursor.key == s_cursor.key:
            # 输出笛卡尔积
            for each r in r_group:
                for each s in s_group:
                    output (r ⋈ s)
            advance both cursors past current groups
        elif r_cursor.key < s_cursor.key:
            advance r_cursor past r_group
        else:
            advance s_cursor past s_group

代价分析:
- 排序代价: 2×(|R| + |S|)×log₂(max(|R|,|S|)/M) I/O
- 归并代价: |R| + |S| I/O
- 总代价: O(|R|log|R| + |S|log|S|) + O(|R| + |S|)

处理重复键值

当连接键有大量重复时,需要特殊处理:

算法:处理重复键的归并连接
当 r.key == s.key 时:
    # 标记S组的起始位置
    s_group_start = s_cursor
    
    # 对R组的每个元素
    while r_cursor.key == current_key:
        # 重置S组游标
        s_cursor = s_group_start
        
        # 扫描整个S组
        while s_cursor.key == current_key:
            output (r_cursor, s_cursor)
            s_cursor++
        r_cursor++

注意:如果某个键值的重复很多,可能需要将一组暂存到磁盘

外部排序优化

大数据集需要外部排序,关键优化包括:

多路归并排序:
1. 初始运行生成
   - 使用快速排序生成初始有序运行
   - 运行长度 = 可用内存大小
   - 使用置换选择可生成2×内存大小的运行

2. 归并阶段
   - K路归并(K = 可用内存页数 - 1)
   - 使用最小堆维护K个运行的当前最小值
   - 双缓冲技术隐藏I/O延迟

优化技巧:
- 预读取:异步读取下一个块
- 写缓冲:批量写入排序结果
- 运行合并:动态合并短运行减少归并趟数

利用已有序性

检测和利用有序性:
1. 统计信息指示数据已排序
2. 索引扫描自然产生有序输出
3. 前序操作已排序(如ORDER BY)

if R已按join_key排序:
    跳过R的排序阶段
if S已按join_key排序:
    跳过S的排序阶段
if 两表都已排序:
    直接进入归并阶段,代价降至O(|R| + |S|)

范围连接支持

排序归并是少数支持非等值连接的算法:

范围连接(R.a ≤ S.b):
while r != null and s != null:
    # 找到所有满足条件的S元组
    s_mark = s_cursor
    while s != null and r.a ≤ s.b:
        output (r, s)
        s++
    
    r++
    s = s_mark  # 回溯S游标
    
    # 跳过不再需要的S元组
    while s != null and s.b < r.a:
        s++

性能特征与优化

内存需求分析:
- 最小内存:3页(输入2页,输出1页)
- 理想内存:sqrt(|R| + |S|) 用于排序
- 充足内存:|R| + |S| 可完全内存排序

I/O模式:
- 顺序读写为主(对SSD和HDD都友好)
- 可预测的访问模式便于预取
- 支持并行排序和并行归并

CPU优化:
- SIMD加速比较操作
- 分支预测友好的代码结构
- 缓存友好的数据布局

3.3.3 哈希连接(Hash Join)

哈希连接是现代数据库中最常用的连接算法,通过哈希表实现近似O(1)的查找复杂度。

经典哈希连接(Classic Hash Join)

算法:In-Memory Hash Join
前提:构建表R能完全装入内存

阶段1 - 构建(Build Phase):
    hash_table = new HashTable()
    for each tuple r in R:
        hash_key = hash(r.join_key)
        hash_table[hash_key].append(r)
    
阶段2 - 探测(Probe Phase):
    for each tuple s in S:
        hash_key = hash(s.join_key)
        for each r in hash_table[hash_key]:
            if r.join_key == s.join_key:  # 处理哈希冲突
                output (r ⋈ s)

代价分析:
- I/O代价: |R| + |S| (各读一次)
- CPU代价: |R|×C_hash + |S|×C_probe
- 内存需求: |R| + 哈希表开销(通常1.2-1.5倍)

哈希函数选择

理想哈希函数特性:
1. 均匀分布:最小化哈希冲突
2. 计算快速:避免成为瓶颈
3. 确定性:相同输入产生相同输出

常用哈希函数:
- MurmurHash:快速、分布好
- CRC32:硬件支持
- 模运算:简单但可能不均匀

多级哈希策略:
h1(key) = key % num_partitions  # 分区哈希
h2(key) = murmur(key) % bucket_size  # 桶内哈希

Grace Hash Join(磁盘哈希连接)

当内存不足时,使用分区-连接两阶段策略:

算法:Grace Hash Join
设可用内存为M页,哈希函数h将数据分为k个分区

阶段1 - 分区(Partitioning):
    # 创建k个分区,k = ⌈|R|/M⌉
    for i in 0..k-1:
        create partition files R_i and S_i
    
    # 分区R
    for each page in R:
        for each tuple r in page:
            i = h(r.join_key) % k
            write r to R_i
    
    # 分区S
    for each page in S:
        for each tuple s in page:
            i = h(s.join_key) % k
            write s to S_i

阶段2 - 连接(Joining):
    for i in 0..k-1:
        # 构建阶段
        load R_i into memory hash table
        
        # 探测阶段
        for each tuple s in S_i:
            probe hash table with s
            output matches

代价分析:
- 分区I/O: 2×(|R| + |S|) (读一次,写一次)
- 连接I/O: |R| + |S| (再读一次)
- 总I/O: 3×(|R| + |S|)
- 递归分区:如果分区仍太大,需要递归

混合哈希连接(Hybrid Hash Join)

优化Grace Hash Join,保留第一个分区在内存:

算法:Hybrid Hash Join
内存分配:
- k-1个输出缓冲区用于分区
- 剩余内存用于第0分区的哈希表

阶段1 - 混合构建:
    # 构建第0分区的内存哈希表
    # 其他分区写入磁盘
    for each tuple r in R:
        i = h(r.join_key) % k
        if i == 0:
            insert r into memory hash_table
        else:
            write r to partition R_i

阶段2 - 混合探测:
    # 处理S,第0分区直接探测
    for each tuple s in S:
        i = h(s.join_key) % k
        if i == 0:
            probe memory hash_table with s
        else:
            write s to partition S_i
    
    # 处理剩余分区
    for i in 1..k-1:
        load R_i and join with S_i

优势:
- 节省第0分区的I/O(约1/k的数据)
- 更好的内存利用率
- 支持动态调整分区数

自适应哈希连接

根据运行时统计动态调整策略:

运行时优化:
1. 动态角色互换
   if |S| < |R| after filtering:
       swap build and probe tables
   
2. 位图过滤(Bloom Filter)
   # 构建阶段创建Bloom filter
   bloom = BloomFilter(expected_size=|R|)
   for each r in R:
       bloom.add(r.join_key)
   
   # 探测阶段先过滤
   for each s in S:
       if bloom.contains(s.join_key):
           probe hash table  # 避免无效探测

3. 分区倾斜处理
   if partition i is skewed:
       use finer-grained hash function
       or use nested loop for this partition

4. 早期聚合
   if detecting many duplicates:
       pre-aggregate in hash table
       reduce memory pressure

并行哈希连接

并行化策略:

1. 分区并行(Partitioned Parallelism)
   - 每个线程处理不同分区
   - 无需同步
   - 负载均衡依赖分区质量

2. 共享哈希表(Shared Hash Table)
   - 所有线程共享一个哈希表
   - 需要并发控制(锁或无锁)
   - 更好的负载均衡

3. 复制哈希表(Replicated Hash Table)
   - 每个线程有R的完整副本
   - 无需同步探测
   - 内存开销大

选择策略:
if |R| 很小:
    使用复制策略
elif 分区均匀:
    使用分区并行
else:
    使用共享哈希表

3.3.4 连接算法选择策略

选择正确的连接算法对查询性能至关重要。优化器需要根据多个因素做出决策。

决策矩阵

连接算法选择决策树:

1. 连接条件类型
   ├─ 非等值连接 → 嵌套循环或排序归并
   └─ 等值连接 → 继续评估

2. 数据规模
   ├─ 小表连接(<1000行)→ 嵌套循环
   ├─ 一大一小 → 评估索引
   └─ 两个大表 → 哈希或排序归并

3. 索引可用性
   ├─ 内表有选择性索引 → 索引嵌套循环
   └─ 无合适索引 → 哈希连接

4. 内存约束
   ├─ 充足(>小表×1.5)→ 内存哈希连接
   ├─ 中等 → Grace/Hybrid哈希
   └─ 严重不足 → 排序归并或块嵌套

5. 输出要求
   ├─ 需要排序 → 排序归并
   ├─ 需要去重 → 哈希连接(自然去重)
   └─ 流式输出 → 嵌套循环

定量分析模型

代价比较公式:

设 R 为小表,S 为大表,M 为可用内存页数

嵌套循环(无索引):
C_NL = |R| + |R| × |S| / (M-2)

索引嵌套循环:
C_INL = |R| + |R| × (h + m × clustering_factor)
其中 h=索引高度,m=平均匹配数

排序归并:
C_SM = 3×(|R| + |S|) × (1 + log_(M-1)(|R|/M))

哈希连接:
C_HJ = 3×(|R| + |S|)  # Grace Hash
C_HJ = (|R| + |S|)     # In-memory Hash

选择原则:
选择 min(C_NL, C_INL, C_SM, C_HJ)

实际场景决策

场景1:OLTP点查询
- 特征:外表过滤后1-10行
- 选择:索引嵌套循环
- 原因:最少的总体I/O

场景2:数据仓库星型连接
- 特征:事实表连接多个维度表
- 选择:对小维度表用哈希,大维度表用索引
- 原因:混合策略最优

场景3:自连接
- 特征:表与自身连接
- 选择:排序归并(数据只需排序一次)
- 原因:减少I/O

场景4:多对多连接
- 特征:两表都有大量重复
- 选择:哈希连接
- 原因:处理重复最高效

场景5:窗口函数后连接
- 特征:数据已排序
- 选择:归并连接
- 原因:利用已有序性

自适应执行策略

现代优化器支持运行时调整:

自适应哈希连接(Adaptive Hash Join):
1. 开始构建哈希表
2. 监控内存使用
3. 如果内存不足:
   - 切换到Grace Hash
   - 或切换到排序归并
4. 如果发现数据已排序:
   - 切换到归并连接

自适应并行度:
1. 开始with估计的并行度
2. 监控各线程进度
3. 动态调整:
   - 数据倾斜→重新分区
   - 负载不均→工作窃取
   - 内存压力→降低并行度

特殊连接类型

半连接(Semi-Join)- EXISTS/IN:
- 首选:哈希半连接(找到匹配即停止)
- 备选:索引查找(如果有唯一索引)

反连接(Anti-Join)- NOT EXISTS/NOT IN:
- 首选:哈希反连接(NULL处理简单)
- 注意:NULL值的特殊处理

外连接(Outer Join):
- 左外:保留哈希/归并都可
- 全外:排序归并最自然
- 注意:索引嵌套循环需要特殊处理

笛卡尔积(Cartesian Product):
- 尽量避免(通过谓词下推)
- 不得已时:块嵌套循环
- 优化:将小表广播

Rule of Thumb 总结

  1. 默认选择:哈希连接(大多数等值连接)
  2. 有索引时:evaluate C_index vs C_hash
  3. 内存受限:排序归并(更优雅的降级)
  4. 已排序数据:归并连接(零额外代价)
  5. 极小数据集:嵌套循环(简单高效)
  6. 分布式环境:考虑数据传输成本
  7. 并行执行:哈希连接并行性最好
  8. 不确定时:准备多个计划,运行时选择

3.4 优化器实现策略

查询优化器是数据库的“大脑”,负责将逻辑查询计划转换为高效的物理执行计划。优化器的质量直接决定了数据库系统的性能上限。

3.4.1 优化器架构

基于规则的优化器(Rule-Based Optimizer, RBO) 使用预定义的规则和优先级来选择执行计划:

规则优先级示例:

  1. ROWID直接访问
  2. 唯一索引等值查找
  3. 主键索引等值查找
  4. 唯一索引范围扫描
  5. 普通索引等值查找
  6. 普通索引范围扫描
  7. 全表扫描

基于代价的优化器(Cost-Based Optimizer, CBO) 通过量化评估选择最优计划:

混合优化器(Hybrid Optimizer) 结合RBO和CBO的优点:

自适应查询优化(Adaptive Query Optimization) 运行时动态调整执行计划:

3.4.2 搜索空间

查询优化的搜索空间巨大,找到最优计划是一个NP-hard问题:

连接顺序空间 n个表的连接顺序复杂度分析:

例子:

连接算法选择空间 每个连接节点可选择:

总空间大小:$O(m^n \times n!)$,其中m为算法数

访问路径选择空间 每个基表访问可选:

物理属性选择

组合爆炸问题 10个表的查询,每表有2个索引,3种连接算法:

3.4.3 搜索策略

穷举搜索(Exhaustive Search) 枚举所有可能的计划:

动态规划(System R Algorithm) 经典的自底向上优化算法:

def optimize_query(tables):
    # 初始化:单表的最优访问路径
    for table in tables:
        best_plans[{table}] = find_access_paths(table)
    
    # 逐步构建更大的子集
    for i in range(2, len(tables) + 1):
        for subset in combinations(tables, i):
            best_cost = infinity
            best_plan = None
            
            # 考虑所有可能的最后一个表
            for table in subset:
                remaining = subset - {table}
                
                # 考虑所有连接算法
                for join_method in [NLJ, HJ, SMJ]:
                    cost = best_plans[remaining].cost + 
                           join_cost(best_plans[remaining], table, join_method)
                    
                    if cost < best_cost:
                        best_cost = cost
                        best_plan = create_plan(best_plans[remaining], table, join_method)
            
            best_plans[subset] = best_plan
    
    return best_plans[all_tables]

特点:

贪心算法(Greedy Algorithm) 局部最优决策:

def greedy_join_order(tables, predicates):
    result = select_seed_table(tables)  # 选择起始表
    remaining = tables - {result}
    
    while remaining:
        best_table = None
        best_cost = infinity
        
        for table in remaining:
            # 评估连接代价
            cost = estimate_join_cost(result, table, predicates)
            if cost < best_cost:
                best_cost = cost
                best_table = table
        
        result = join(result, best_table)
        remaining.remove(best_table)
    
    return result

启发式规则:

遗传算法(Genetic Algorithm) 基于进化的随机优化:

def genetic_optimizer(tables, generations=100, population_size=50):
    # 初始化种群
    population = [random_plan(tables) for _ in range(population_size)]
    
    for generation in range(generations):
        # 评估适应度
        fitness = [1/cost(plan) for plan in population]
        
        # 选择
        parents = selection(population, fitness)
        
        # 交叉
        offspring = []
        for p1, p2 in pairs(parents):
            child = crossover(p1, p2)
            offspring.append(child)
        
        # 变异
        for child in offspring:
            if random() < mutation_rate:
                mutate(child)
        
        # 更新种群
        population = elite + offspring
    
    return best(population)

适用场景:

级联优化框架(Cascades/Volcano) 自顶向下的优化框架:

class CascadesOptimizer:
    def optimize(self, logical_expr, required_props):
        # 检查记忆化缓存
        if (logical_expr, required_props) in memo:
            return memo[(logical_expr, required_props)]
        
        best_plan = None
        best_cost = infinity
        
        # 应用转换规则
        for rule in transformation_rules:
            if rule.matches(logical_expr):
                new_expr = rule.apply(logical_expr)
                plan = optimize(new_expr, required_props)
                if plan.cost < best_cost:
                    best_plan = plan
                    best_cost = plan.cost
        
        # 应用实现规则
        for rule in implementation_rules:
            if rule.matches(logical_expr):
                physical_op = rule.implement(logical_expr)
                
                # 递归优化子表达式
                child_props = derive_required_props(physical_op, required_props)
                child_plans = [optimize(child, props) 
                              for child, props in zip(logical_expr.children, child_props)]
                
                plan = create_plan(physical_op, child_plans)
                if plan.cost < best_cost:
                    best_plan = plan
                    best_cost = plan.cost
        
        memo[(logical_expr, required_props)] = best_plan
        return best_plan

优点:

3.4.4 剪枝技术

剪枝是使查询优化在实践中可行的关键技术:

基于代价的剪枝(Cost-based Pruning)

def cost_based_pruning(partial_plan, upper_bound):
    # 下界估计
    lower_bound = partial_plan.cost + estimate_remaining_cost()
    
    if lower_bound >= upper_bound:
        return PRUNE  # 剪枝
    
    # 增量代价检查
    if partial_plan.cost > upper_bound * 0.8:  # 80%规则
        if not has_promising_properties(partial_plan):
            return PRUNE
    
    return CONTINUE

剪枝效果:

基于属性的剪枝(Property-based Pruning)

有趣属性(Interesting Properties):

def property_pruning(plan1, plan2, required_props):
    # 如果两个计划产生相同的逻辑结果
    if equivalent(plan1.output, plan2.output):
        # 且plan1代价更低
        if plan1.cost < plan2.cost:
            # 且plan1提供的属性不比plan2差
            if dominates(plan1.properties, plan2.properties, required_props):
                return PRUNE_PLAN2
    
    return KEEP_BOTH

基于规则的剪枝(Rule-based Pruning)

启发式规则:

  1. 笛卡尔积延迟:总是先执行有连接条件的连接
  2. 左深树限制:只考虑左深树,减少搜索空间
  3. 访问路径限制:选择性>50%时不考虑索引
  4. 连接顺序约束:优先连接有FK-PK关系的表
def apply_heuristic_rules(search_space):
    # 笛卡尔积延迟
    if has_cartesian_product(plan) and has_alternative_with_join(plan):
        return PRUNE
    
    # 小表驱动
    if is_large_table_driving_small_table(plan):
        return SWAP_AND_RETRY
    
    # 选择性阈值
    if index_selectivity > 0.5 and not is_covering_index:
        return USE_TABLE_SCAN
    
    return CONTINUE

分支限界剪枝(Branch and Bound)

class BranchAndBound:
    def __init__(self):
        self.upper_bound = float('inf')
        self.best_plan = None
    
    def search(self, partial_plan, remaining_tables):
        # 下界估计
        lower_bound = self.estimate_lower_bound(partial_plan, remaining_tables)
        
        if lower_bound >= self.upper_bound:
            return  # 剪枝
        
        if not remaining_tables:
            # 更新最优解
            if partial_plan.cost < self.upper_bound:
                self.upper_bound = partial_plan.cost
                self.best_plan = partial_plan
            return
        
        # 分支
        for table in remaining_tables:
            for join_method in [NLJ, HJ, SMJ]:
                new_plan = extend_plan(partial_plan, table, join_method)
                self.search(new_plan, remaining_tables - {table})

动态剪枝(Dynamic Pruning)

运行时根据实际执行情况调整:

def adaptive_pruning(optimizer_state):
    # 监控优化时间
    if optimization_time > timeout * 0.5:
        increase_pruning_aggressiveness()
    
    # 监控搜索进度
    if plans_explored < expected_plans * 0.1:
        # 搜索太慢,加强剪枝
        tighten_cost_bounds()
    elif plans_explored > expected_plans * 0.9:
        # 搜索太快,可能错过好计划
        relax_cost_bounds()
    
    # 基于历史经验
    if similar_query_in_history():
        use_historical_bounds()

3.4.5 计划缓存与重用

计划缓存策略(Plan Cache Strategy)

缓存键设计:

class PlanCacheKey:
    def __init__(self, query):
        self.sql_template = parameterize(query)  # 参数化SQL
        self.schema_version = get_schema_version()  # 模式版本
        self.stats_version = get_stats_version()  # 统计信息版本
        self.session_settings = get_relevant_settings()  # 相关设置
    
    def __hash__(self):
        return hash((self.sql_template, self.schema_version, 
                    self.stats_version, self.session_settings))

缓存策略:

  1. 精确匹配:SQL文本完全相同
  2. 参数化匹配:忽略字面量值的差异
  3. 模板匹配:结构相同的查询
  4. 语义匹配:逻辑等价的查询

参数敏感性处理(Parameter Sensitivity)

class AdaptivePlanCache:
    def __init__(self):
        self.cache = {}
        self.parameter_stats = {}
    
    def get_plan(self, query, parameters):
        key = create_cache_key(query)
        
        if key not in self.cache:
            # 首次编译
            plan = compile_query(query, parameters)
            self.cache[key] = [plan]
            self.parameter_stats[key] = create_histogram(parameters)
        else:
            # 检查参数分布
            if is_outlier(parameters, self.parameter_stats[key]):
                # 参数异常,可能需要不同计划
                plan = compile_query(query, parameters)
                if significantly_different(plan, self.cache[key]):
                    self.cache[key].append(plan)
            else:
                # 选择合适的缓存计划
                plan = select_best_cached_plan(self.cache[key], parameters)
        
        return plan

参数分类:

计划稳定性(Plan Stability)

计划基线管理:

class PlanBaseline:
    def __init__(self):
        self.accepted_plans = {}  # 已接受的计划
        self.candidate_plans = {}  # 候选计划
        self.blacklist_plans = {}  # 黑名单计划
    
    def evolve_plan(self, query):
        current_plan = optimize(query)
        
        if query in self.accepted_plans:
            baseline_plan = self.accepted_plans[query]
            
            if current_plan != baseline_plan:
                # 新计划需要验证
                self.candidate_plans[query] = current_plan
                
                # 试运行验证
                if self.verify_plan(current_plan, baseline_plan):
                    # 新计划更好,逐步切换
                    self.gradual_switch(baseline_plan, current_plan)
                else:
                    # 保持原计划
                    return baseline_plan
        else:
            # 首次见到的查询
            self.accepted_plans[query] = current_plan
        
        return current_plan
    
    def verify_plan(self, new_plan, old_plan):
        # 在小样本上测试
        new_cost = execute_sample(new_plan)
        old_cost = execute_sample(old_plan)
        
        # 要求显著改进(如20%)
        return new_cost < old_cost * 0.8

缓存失效策略

触发条件:

  1. 统计信息更新:表大小变化>10%
  2. 模式变更:索引创建/删除
  3. 配置变更:内存、并行度等参数调整
  4. 时间过期:LRU或TTL策略
  5. 性能退化:实际执行时间 > 预期时间 × 2
def should_invalidate_cache(cache_entry):
    # 统计信息检查
    if stats_significantly_changed(cache_entry.tables):
        return True
    
    # 模式版本检查  
    if schema_version() != cache_entry.schema_version:
        return True
    
    # 性能监控
    if cache_entry.actual_cost > cache_entry.estimated_cost * 2:
        cache_entry.recompile_count += 1
        if cache_entry.recompile_count > 3:
            return True
    
    # 时间检查
    if time.now() - cache_entry.create_time > MAX_AGE:
        return True
    
    return False

3.5 统计信息与基数估计

3.5.1 统计信息收集

基本统计信息

直方图 用于描述数据分布,主要类型:

等宽直方图(Equi-Width)

等深直方图(Equi-Depth)

压缩直方图

采样策略

3.5.2 基数估计方法

选择率估计

等值谓词: \(sel(col = val) = \frac{1}{NDV(col)}\)

范围谓词(假设均匀分布): \(sel(col > val) = \frac{max(col) - val}{max(col) - min(col)}\)

多谓词选择率

独立性假设: \(sel(P_1 \land P_2) = sel(P_1) \times sel(P_2)\)

包含性假设: \(sel(P_1 \lor P_2) = sel(P_1) + sel(P_2) - sel(P_1) \times sel(P_2)\)

连接基数估计

基于外键关系: \(|R \bowtie S| = |R|\)(如果R.fk引用S.pk)

基于包含性假设: \(|R \bowtie S| = \frac{|R| \times |S|}{max(NDV(R.key), NDV(S.key))}\)

3.5.3 高级基数估计技术

多维直方图

草图算法(Sketches)

机器学习方法

采样执行

3.5.4 统计信息维护

更新策略

统计信息反馈

3.5.5 Rule of Thumb:基数估计

  1. 均匀分布假设通常不准确:实际数据常有倾斜
  2. 相关性被低估:列间往往存在相关性
  3. 连接后选择率难估计:连接可能改变数据分布
  4. 保守估计:宁可高估也不要严重低估
  5. 关注数量级:估计误差在一个数量级内通常可接受

3.6 查询执行引擎

3.6.1 执行模型

火山模型(Iterator Model)

class Operator:
    def open(): initialize
    def next(): return next tuple
    def close(): cleanup

优点:

缺点:

物化模型(Materialization Model)

向量化模型(Vectorized Model)

编译执行(Code Generation)

3.6.2 并行执行

分区并行(Horizontal Parallelism)

流水线并行(Pipeline Parallelism)

混合并行

3.6.3 内存管理

内存分配策略

缓冲池管理

本章小结

查询处理与优化是数据库系统性能的决定性因素。本章我们学习了:

核心概念

关键公式

实用技巧

常见陷阱与错误

陷阱1:过度依赖优化器

问题:认为优化器总能选择最优计划 原因:统计信息过期、基数估计错误、参数嗅探问题 解决:定期更新统计信息、使用hint引导、监控执行计划变化

陷阱2:忽视参数相关性

问题:查询计划对参数值敏感,导致性能不稳定 案例WHERE status = ? 当status=’RARE’和status=’COMMON’时需要不同计划 解决:使用自适应游标共享、准备多个计划版本

陷阱3:基数估计的级联错误

问题:早期估计错误在多表连接中被放大 原因:独立性假设不成立、数据倾斜 解决:使用多维统计、运行时自适应调整

陷阱4:嵌套循环的意外全表扫描

问题:内表缺少索引导致性能灾难 症状:CPU使用率100%,查询运行时间异常长 预防:检查执行计划、确保连接列有索引

陷阱5:哈希连接的内存溢出

问题:构建表太大导致频繁磁盘交换 表现:I/O激增、临时表空间爆满 优化:增加工作内存、使用分区哈希连接

陷阱6:排序的隐藏开销

问题:ORDER BY、GROUP BY、DISTINCT产生意外排序 影响:内存消耗、临时文件I/O 技巧:利用索引避免排序、考虑哈希聚合

陷阱7:分布式查询的数据倾斜

问题:某些节点处理数据量远超其他节点 后果:整体性能受最慢节点限制 对策:重新分区、使用倾斜处理技术

陷阱8:过早物化

问题:不必要地物化中间结果 案例:WITH子句被物化而非内联 优化:理解CTE物化策略、使用MATERIALIZED/NOT MATERIALIZED hint

练习题

基础题

练习3.1:查询重写 给定查询:

SELECT * FROM orders o
WHERE EXISTS (
    SELECT 1 FROM customers c 
    WHERE c.id = o.customer_id AND c.country = 'CN'
)

请将其重写为等价的JOIN形式。

提示 考虑EXISTS子查询的语义:只要存在匹配就返回true
答案 ```sql SELECT o.* FROM orders o INNER JOIN customers c ON c.id = o.customer_id WHERE c.country = 'CN' ``` 这种重写的优势: 1. JOIN通常比关联子查询效率高 2. 优化器有更多优化机会 3. 可以利用连接算法的并行性

练习3.2:代价计算 假设:

计算A和B进行嵌套循环连接的I/O代价(A为外表)。

提示 块嵌套循环:每次读取内存能容纳的外表块数
答案 块嵌套循环连接: - 外表A占1000页,内存容纳50页 - 需要扫描外表:1000/50 = 20次 - 每次扫描内表B:100页 总I/O代价 = 读取A + (扫描次数 × 读取B) = 1000 + (20 × 100) = 1000 + 2000 = 3000(顺序I/O) 如果考虑首次读取为顺序I/O,后续为随机I/O: = 1000 + 100 + (19 × 100 × 4) = 1100 + 7600 = 8700(混合I/O代价)

练习3.3:选择率估计 表users有100,000行:

估计查询的结果行数:

SELECT * FROM users 
WHERE age BETWEEN 25 AND 35 
  AND city = 'Beijing' 
  AND gender = 'F'
提示 假设各列独立,使用选择率相乘
答案 各谓词选择率: 1. age BETWEEN 25 AND 35: - 范围:(35-25+1)/(80-18+1) = 11/63 ≈ 0.175 2. city = 'Beijing': - 1/1000 = 0.001 3. gender = 'F': - 1/2 = 0.5 总选择率(独立性假设): 0.175 × 0.001 × 0.5 = 0.0000875 估计结果行数: 100,000 × 0.0000875 = 8.75 ≈ 9行

挑战题

练习3.4:连接顺序优化 有四个表:

查询:A ⋈ B ⋈ C ⋈ D(所有连接选择率为0.1)

请确定最优连接顺序。

提示 考虑中间结果大小,先连接能最大程度减少数据量的表
答案 分析各种顺序的中间结果大小: 策略1:从最小表开始 - C(100) ⋈ B(1000) = 100×1000×0.1 = 10,000 - 结果 ⋈ A(10,000) = 10,000×10,000×0.1 = 10,000,000 - 结果 ⋈ D(50,000) = 10,000,000×50,000×0.1 = 50,000,000,000 策略2:考虑过滤后大小 - A过滤后:10,000×0.1 = 1,000 - B过滤后:1,000×0.5 = 500 - C过滤后:100×1.0 = 100 - D过滤后:50,000×0.01 = 500 最优顺序:C ⋈ A ⋈ B ⋈ D 或 C ⋈ A ⋈ D ⋈ B - C(100) ⋈ A(1,000) = 100×1,000×0.1 = 10,000 - 结果 ⋈ B(500) = 10,000×500×0.1 = 500,000 - 结果 ⋈ D(500) = 500,000×500×0.1 = 25,000,000 关键:先处理过滤后较小的表,减少中间结果

练习3.5:索引选择问题 表orders有1,000,000行:

有三个索引:

  1. idx_date(date)
  2. idx_status(status)
  3. idx_composite(date, status, amount)

分析以下查询应该使用哪个索引:

SELECT * FROM orders 
WHERE date = '2024-01-15' 
  AND status = 'PENDING'
  AND amount > 1000
提示 考虑索引选择性和覆盖能力
答案 分析各索引的效果: **idx_date**: - 选择性:1/30 ≈ 3.3% - 需要回表过滤status和amount - 预计扫描:33,333行 **idx_status**: - PENDING假设是5%的那个值 - 选择性:5% - 需要回表过滤date和amount - 预计扫描:50,000行 **idx_composite**: - 可以使用前两个列的等值条件 - date选择性:1/30 - status进一步过滤:5% - 组合选择性:0.033 × 0.05 = 0.00165 - 预计扫描:1,650行 - amount > 1000可以在索引中过滤(索引过滤) **结论**:使用idx_composite - 最低的选择性 - 避免大量回表 - 支持索引条件下推(ICP) 额外考虑:如果amount > 1000过滤掉大部分数据(如90%),则最终只需要访问约165行。

练习3.6:查询计划分析 解释为什么下面的查询计划可能有问题:

Hash Join (cost=10000)
  Hash Cond: (large_table.id = small_table.id)
  -> Seq Scan on large_table (10M rows)
  -> Hash
      -> Seq Scan on small_table (100 rows)
         Filter: status = 'ACTIVE'
提示 考虑哈希连接的构建表选择
答案 问题分析: 1. **构建表选择错误** - 当前:小表(100行)作为构建表 ✓ - 这部分是正确的 2. **真正的问题**: - large_table可能有索引未被使用 - 如果只需要少量列,应该使用索引覆盖扫描 - 全表扫描10M行代价极高 3. **优化建议**: - 在large_table.id上创建索引 - 使用索引嵌套循环可能更好(100次索引查找) - 或者在连接前对large_table进行过滤 4. **内存考虑**: - 虽然构建表小,但探测阶段仍需处理10M行 - 可能造成大量CPU缓存失效 5. **统计信息问题**: - 优化器选择此计划可能因为统计信息过期 - 实际的过滤选择率可能与估计不符 改进方案: ``` Nested Loop (cost=1000) -> Seq Scan on small_table (100 rows) Filter: status = 'ACTIVE' -> Index Scan on large_table (cost=10) Index Cond: id = small_table.id ```

练习3.7:优化器提示 某查询在生产环境突然变慢,发现执行计划从索引扫描变为全表扫描。请分析可能的原因和解决方案。

提示 考虑什么因素会导致优化器改变决策
答案 **可能原因**: 1. **统计信息变化** - 表增长导致数据分布改变 - 自动统计更新检测到新的数据特征 - 直方图显示数据严重倾斜 2. **数据量达到阈值** - 表大小超过优化器的索引使用阈值 - 查询返回行数比例增加 3. **参数嗅探问题** - 缓存的计划基于非典型参数值 - 绑定变量的值分布变化 4. **系统参数调整** - random_page_cost被调高 - effective_cache_size被调低 - 并行度设置改变 5. **索引问题** - 索引碎片严重 - 索引统计信息不准确 - 索引被意外禁用 **解决方案**: 1. **紧急措施** - 使用查询提示强制使用索引 - 清除计划缓存 - 手动固定好的执行计划 2. **诊断步骤** ```sql -- 检查统计信息更新时间 -- 分析实际vs估计行数 -- 检查索引状态和碎片率 ``` 3. **长期方案** - 实施计划稳定性机制 - 定期维护统计信息 - 监控执行计划变化 - 使用自适应查询优化 4. **预防措施** - 建立计划基线 - 实施渐进式统计更新 - 使用查询存储跟踪历史

练习3.8:分布式查询优化 在分布式环境中,表orders按customer_id分区存储在3个节点上,表customers按id分区存储在3个节点上。如何优化以下查询?

SELECT c.name, SUM(o.amount)
FROM orders o JOIN customers c ON o.customer_id = c.id
WHERE c.country = 'US'
GROUP BY c.name
提示 考虑数据移动最小化和并行执行
答案 **分析**: - orders和customers都按customer_id/id分区(co-located) - 需要先过滤再聚合 **策略1:Colocated Join** ``` 各节点并行执行: 1. 本地过滤customers (country='US') 2. 本地连接orders和customers(数据已co-located) 3. 本地预聚合 GROUP BY c.name 4. 发送预聚合结果到协调节点 5. 协调节点最终聚合 ``` 优点:最小化网络传输 缺点:要求分区键匹配 **策略2:Broadcast Join** ``` 1. 各节点过滤本地customers 2. 将过滤后的小结果集广播到所有节点 3. 各节点用本地orders连接广播的customers 4. 本地预聚合 5. 最终聚合 ``` 优点:customers过滤后数据量小时高效 缺点:广播开销 **策略3:Redistribute Join** ``` 1. 按c.name重新分区两表 2. 新分区上执行连接 3. 本地聚合(已按GROUP BY键分区) 4. 直接输出结果 ``` 优点:聚合不需要最终合并 缺点:重分区开销大 **最优方案**: 如果US客户比例小(如<10%),选择策略2(Broadcast) 如果分区已经对齐,选择策略1(Colocated) 如果需要完全并行聚合,选择策略3(Redistribute) **优化技巧**: - 谓词下推:在读取时就过滤country='US' - 投影下推:只传输需要的列 - 预聚合:减少网络传输量 - 使用列式存储加速扫描