跳转至

Vertica 大表统计信息维护最佳实践

作者:JiangChong | 发布时间:2026-04-14

适用场景:你管理着数十亿行到千亿行级别的大表(运营商场景下常见),需要决定统计信息的收集时机、频率和策略——全量统计还是分区级统计?每天收集还是每小时收集?如何避免统计收集本身拖慢业务?

关联文章

理解全文脉络

本文假设你已经读过 Vertica 统计信息管理与查询性能,了解统计信息的基本概念(FULL/ROWCOUNT/NONE 三层结构、EXPLAIN 诊断方法)。本文聚焦于大表特有的挑战

  • 如果你在纠结「这个 200 亿行的分区表应该每天收集还是每周收集」,从第 1 节开始理解决策框架
  • 如果你想知道「分区级统计和全量统计到底该怎么选」,直接看第 3 节
  • 如果你担心「ANALYZE_STATISTICS 太慢会拖慢业务」,请重点阅读第 4 节资源配置和第 8 节最佳实践清单

第 1 节:大表统计信息的核心挑战

1.1 大表与普通表的本质区别

对于大表(数十亿行到千亿行),统计信息维护面临三个普通表不会遇到的问题。

行数 ↔ 存储换算:根据 Vertica 表行数与存储大小估算关系(497 张生产表实测),中位数 48 bytes/行(10 亿行 ≈ 45 GB),CDR 话单类宽表 ~120 bytes/行(10 亿行 ≈ 112 GB)。以下阈值同时标注行数和参考存储量,存储量以中位数 ~50 bytes/行估算。

挑战 普通表(百万-千万行 / <500MB) 大表(十亿行以上 / >50GB)
收集耗时 秒级 分钟级到小时级
过期风险 低——全量收集快,随时可补 高——收集耗时长,不能频繁执行
资源争抢 可忽略 可能挤占业务查询的 CPU 和内存
优化器影响 估算偏差影响有限 估算偏差可能导致 TB 级数据被错误广播
分区复杂性 通常不分区或简单分区 多层分区,需考虑分区级统计

核心认知:大表的统计信息维护不是一个「要不要做」的问题,而是一个「怎么做才能兼顾准确性和成本」的问题。错误的策略要么让统计信息长期过期(查询变慢),要么让统计收集本身成为性能瓶颈。

1.2 统计信息缺失对大表的放大效应

统计信息缺失对大表的破坏性远大于小表。考虑一个典型的 JOIN 场景:

事实表 A(500 亿行 / 约 2.5TB)JOIN 维度表 B(50 万行 / 100MB)
  • 有准确统计:优化器知道 A 很大、B 很小 → 选 B 做 Inner 表(构建哈希表)→ 广播 100MB → 正常执行
  • 无统计或过期:优化器不知道 A 有多大 → 可能选 A 做 Inner 表 → 尝试将 TB 级数据加载到各节点内存中建立哈希表 → 内存爆炸、Spill、甚至 OOM

这就是为什么 某运营商真实案例中,14.5 亿行的 user_dtal 表因统计信息过期被错误选为 Inner 表,单条查询消耗 28GB 内存、执行 1 小时,而修复后只需几十秒。

1.3 大表统计信息的决策因素

决定大表统计信息策略时,你需要考虑以下因素:

决策因素 问题 影响
数据变化率 每天新增/修改多少数据? 决定收集频率
表大小 表的总数据量多大? 决定收集成本(耗时)
查询模式 哪些列参与 JOIN/WHERE/GROUP BY? 决定需要哪些列的 FULL 统计
分区策略 表是否分区?按什么键分区? 决定是否可以用分区级统计替代全量统计
业务窗口期 什么时间可以执行统计收集? 决定调度策略
加载模式 全量替换还是增量追加? 决定收集时机

第 2 节:大表统计信息监控(量化评估)

在决定策略之前,需要先量化评估当前的大表统计信息状态。

2.1 识别需要重点关注的大表

做什么:找出数据量大但统计信息不完整的表。在运营商等场景下,重点关注 10 亿行以上的大表——这些表对查询性能的影响最大,需要优先处理。

-- 先从 >100 万行的表开始筛查,按行数降序排列
SELECT
  ps.anchor_table_schema AS table_schema,
  ps.anchor_table_name AS table_name,
  SUM(ps.row_count) AS total_rows,
  ROUND(SUM(ps.disk_size) / (1024*1024*1024), 1) AS total_size_gb,
  MAX(CASE WHEN pc.statistics_type = 'FULL' THEN 1 ELSE 0 END) AS has_full_stats,
  MAX(pc.statistics_updated_timestamp) AS last_full_stats
FROM v_monitor.projection_storage ps
LEFT JOIN v_catalog.projection_columns pc
  ON ps.anchor_table_id = pc.table_id
 AND pc.statistics_type = 'FULL'
WHERE ps.anchor_table_schema NOT IN ('v_catalog', 'v_monitor', 'v_internal')
GROUP BY ps.anchor_table_schema, ps.anchor_table_name
HAVING SUM(ps.row_count) > 1000000  -- >100 万行
   AND MAX(CASE WHEN pc.statistics_type = 'FULL' THEN 1 ELSE 0 END) = 0
ORDER BY total_rows DESC;

如何解读结果

  • has_full_stats = 0:该大表完全没有 FULL 统计——紧急处理
  • total_rows 越大越靠前:行数多的表优先处理——一条错误 JOIN 可能消耗几百 GB 内存
  • 如果结果为空:所有 >100 万行的表都有 FULL 统计——但你仍需检查统计是否过期(2.2 节)

2.2 评估统计信息新鲜度

做什么:有 FULL 统计 ≠ 统计信息准确。如果统计收集之后数据发生了显著变化,统计信息就过期了。

-- 两个注意点:
--   1. projection_columns(每列一行)不能直接 JOIN projection_storage → 用 CTE 分离聚合
--   2. 每个表的 K-safety buddy 投影会导致 SUM(row_count) 重复 → 只取一个 super projection
WITH anchor_proj AS (
  SELECT
    anchor_table_name,
    projection_schema,
    MIN(projection_id) AS projection_id
  FROM v_catalog.projections
  WHERE is_super_projection = true
  GROUP BY anchor_table_name, projection_schema
),
actual_rows AS (
  SELECT
    ps.anchor_table_schema AS table_schema,
    ps.anchor_table_name AS table_name,
    ps.anchor_table_id AS table_id,
    SUM(ps.row_count) AS current_rows
  FROM v_monitor.projection_storage ps
  JOIN anchor_proj ap ON ps.projection_id = ap.projection_id
  WHERE ps.anchor_table_schema NOT IN ('v_catalog', 'v_monitor', 'v_internal')
  GROUP BY ps.anchor_table_schema, ps.anchor_table_name, ps.anchor_table_id
),
stats_info AS (
  SELECT
    table_schema,
    table_name,
    table_id,
    MAX(statistics_updated_timestamp) AS last_stats_update
  FROM v_catalog.projection_columns
  WHERE statistics_type = 'FULL'
  GROUP BY table_schema, table_name, table_id
)
SELECT
  ar.table_schema,
  ar.table_name,
  ar.current_rows,
  ts.row_count AS stats_row_count,
  si.last_stats_update,
  ts.stat_collection_time AS last_rowcount_update,
  CASE
    WHEN ar.current_rows > 0
    THEN ROUND(ABS(ar.current_rows - COALESCE(ts.row_count, 0)) * 100.0 / ar.current_rows, 1)
    ELSE 0
  END AS row_count_deviation_pct
FROM actual_rows ar
JOIN stats_info si ON ar.table_id = si.table_id
LEFT JOIN v_catalog.tables t ON ar.table_id = t.table_id
LEFT JOIN v_monitor.TABLE_STATISTICS ts ON t.table_id = ts.table_oid
WHERE ar.current_rows > 1000000  -- >100 万行
ORDER BY row_count_deviation_pct DESC
LIMIT 30;

如何解读结果

  • row_count_deviation_pct > 10%:实际行数与统计信息记录的行数偏差超过 10%——统计信息已过期,优化器的 JOIN 估算可能已经出错
  • row_count_deviation_pct > 30%:严重过期——优化器严重低估或高估表大小,执行计划很可能已经变差
  • stats_row_count IS NULL:TABLE_STATISTICS 中没有记录——该表从未被执行过 ANALYZE_STATISTICS 或 Tuple Mover 尚未覆盖。ANALYZE_STATISTICS 会同时更新 projection_columns 的列级统计和 TABLE_STATISTICS 的行数记录
  • last_stats_update 早于最近数据加载时间:即使行数偏差不大,MIN/MAX 和直方图也可能过期(参考 2.4 节的谓词范围检查)

2.3 按分区粒度检查统计覆盖

做什么:对于分区表,检查哪些分区缺少统计信息,哪些分区统计信息过期。

-- 同样:选一个 super projection + CTE 分离,避免 K-safety 和 column JOIN 双重膨胀
WITH anchor_proj AS (
  SELECT
    anchor_table_name,
    projection_schema,
    MIN(projection_id) AS projection_id
  FROM v_catalog.projections
  WHERE is_super_projection = true
  GROUP BY anchor_table_name, projection_schema
),
actual_rows AS (
  SELECT
    ps.anchor_table_schema AS table_schema,
    ps.anchor_table_name AS table_name,
    ps.anchor_table_id AS table_id,
    SUM(ps.row_count) AS current_rows
  FROM v_monitor.projection_storage ps
  JOIN anchor_proj ap ON ps.projection_id = ap.projection_id
  WHERE ps.anchor_table_schema NOT IN ('v_catalog', 'v_monitor', 'v_internal')
  GROUP BY ps.anchor_table_schema, ps.anchor_table_name, ps.anchor_table_id
),
stats_info AS (
  SELECT
    table_id,
    MAX(statistics_updated_timestamp) AS newest_stats,
    MIN(statistics_updated_timestamp) AS oldest_stats
  FROM v_catalog.projection_columns
  WHERE statistics_type = 'FULL'
  GROUP BY table_id
)
SELECT
  ar.table_schema,
  ar.table_name,
  ar.current_rows AS total_rows,
  si.newest_stats,
  si.oldest_stats
FROM actual_rows ar
JOIN stats_info si ON ar.table_id = si.table_id
WHERE ar.current_rows > 1000000
ORDER BY ar.current_rows DESC
LIMIT 20;

如何解读结果

  • 比较 newest_statsoldest_stats 的时间差:如果差距很大(如几天),说明不同分区的统计信息收集时间不统一——某些分区的统计可能已经过期
  • newest_stats 与最近一次数据加载时间对比:如果早于加载时间,需要重新收集
  • 与 2.2 节的结果交叉验证:如果行数偏差大且统计时间跨度大,确认是增量加载的表——需要更频繁的分区级统计收集

2.4 检查优化器事件中的统计信息告警

做什么dc_optimizer_events 记录优化器在执行计划生成过程中遇到的统计信息相关问题。这是判断统计信息是否影响实际查询的最直接证据。

SELECT
  event_type,
  COUNT(*) AS event_count,
  MIN("time") AS first_seen,
  MAX("time") AS last_seen
FROM dc_optimizer_events
WHERE event_type IN ('NO HISTOGRAM', 'PREDICATE OUTSIDE HISTOGRAM')
  AND "time" > CURRENT_TIMESTAMP - INTERVAL '7 days'
GROUP BY event_type
ORDER BY event_count DESC;

如何解读结果

事件类型 含义 行动
NO HISTOGRAM 优化器尝试使用直方图估算谓词选择性,但该列没有直方图 对该列的 JOIN/WHERE 条件列执行 ANALYZE_HISTOGRAM
PREDICATE OUTSIDE HISTOGRAM 查询的过滤条件值落在直方图记录的范围之外 需重新收集统计——直方图的 MIN/MAX 已过期
两种事件同时高频出现 统计信息全面过期 紧急:立即对相关表执行全量 ANALYZE_STATISTICS

第 3 节:全量统计 vs 分区级统计(策略选择框架)

这是大表统计信息维护最核心的决策。本节提供一个可操作的决策框架。

3.1 Vertica 统计信息的四级体系

v_catalog.projection_columns.statistics_type 的取值来看,Vertica 实际上支持四级统计:

statistics_type 优先级(从高到低):
┌─────────────────────────────────────────────┐
│  FULL        — 全表级列统计(最完整)           │
│  直方图 + NDV + MIN/MAX + 磁盘占用信息         │
│  来源:ANALYZE_STATISTICS()                  │
├─────────────────────────────────────────────┤
│  PARTITION   — 分区级列统计(局部准确)         │
│  某个分区的直方图 + NDV + MIN/MAX              │
│  来源:ANALYZE_STATISTICS_PARTITION()        │
├─────────────────────────────────────────────┤
│  ROWCOUNT    — 表级行数统计(基础)            │
│  来源:Tuple Mover 自动维护                   │
├─────────────────────────────────────────────┤
│  NONE        — 无统计信息(盲操作)            │
└─────────────────────────────────────────────┘

关于 PARTITION 类型statistics_type = 'PARTITION' 表示该列使用的是分区级统计而非全表统计。优化器在查询时优先使用 FULL,如果 FULL 不存在则回退到 PARTITION。详见 v_catalog.projection_columns.statistics_type 的字段说明和 TABLE_STATISTICS 参考

3.2 全量统计(FULL)的适用场景

全量统计通过 ANALYZE_STATISTICS('schema.table') 收集,扫描所有分区的所有数据,生成直方图、NDV、MIN/MAX。

适用条件:

  • 表行数在可控范围内(一般 < 10 亿行,收集耗时 < 10 分钟)
  • 查询涉及跨分区范围扫描(如 WHERE date BETWEEN '2024-01-01' AND '2024-12-31'
  • 数据是全量替换式加载(整个表 DROP + 重新 COPY)
  • 有足够的维护窗口期(如每日凌晨 2:00-6:00)

优点:统计信息最完整,优化器估算最准确。

缺点:大表收集耗时长(数百亿行的表可能需要数十分钟到数小时),在收集期间消耗大量 CPU 和内存。

3.3 分区级统计(PARTITION)的适用场景

分区级统计通过 ANALYZE_STATISTICS_PARTITION() 收集,只扫描指定的一个或多个分区,生成该分区的列级统计。

适用条件:

  • 表非常大(>100 亿行),全量收集耗时远超业务窗口
  • 数据是增量追加式加载(每天只加载一个新分区,历史分区不变)
  • 查询模式以「最近 N 天」为主(只查最新分区)
  • 分区键与数据加载的时间维度对齐

优点:收集速度快(只扫描新增分区),对业务影响最小。

缺点:跨分区查询时优化器需要合并多个分区的统计信息(PARTITION 统计的优先级低于 FULL);如果查询频繁跨大范围分区扫描,分区级统计的估算精度不如全量统计。

历史教训:Vertica 在某些版本中存在 bug(VER-95196/VER-95198/VER-95200),分区统计信息可能代替全表统计信息被不当使用导致次优计划。这些问题已在 12.0.x、23.4.x、24.2.x 等版本中修复。如果你的版本较老,建议升级或优先使用 FULL 统计。

3.4 决策流程图

你可以按以下逻辑选择策略:

大表统计信息策略决策:

表行数 < 10 亿?
├── 是 → 全量统计(FULL),每日收集
│       条件:收集耗时在维护窗口内
└── 否 → 表是分区表吗?
    ├── 是 → 数据加载模式?
    │   ├── 增量追加(每天加载新分区)
    │   │   └── 分区级统计(PARTITION),每个新分区加载后立即收集
    │   │       + 每周/每月做一次全量统计作为兜底
    │   │
    │   └── 全量替换(整个表重新加载)
    │       └── 全量统计(FULL),加载完成后立即收集
    └── 否 → 评估是否可以添加分区
         ├── 可以 → 按时间键分区 → 采用分区级统计策略
         └── 不行 → 全量统计 + 列选择优化(只收集 JOIN/WHERE 列)

3.5 混合策略:增量收集 + 定期全量

对于超大型表(>100 亿行),最佳实践是混合策略

  1. 增量收集(日常):每次数据加载后,对新增分区执行 ANALYZE_STATISTICS_PARTITION()
  2. 全量兜底(周末/月末):选择一个业务完全空闲的时段,执行全量 ANALYZE_STATISTICS() 作为质量兜底
  3. 列级精准收集(按需):对 JOIN 列和高选择性 WHERE 列,执行 ANALYZE_HISTOGRAM() 确保直方图精度

这种策略在某运营商 138 节点集群的实际运维中验证有效(详见第 5 节真实案例)。


第 4 节:如何避免统计信息收集影响业务

这是本文的第二个核心主题。ANALYZE_STATISTICS 本身也是一个查询——它会扫描数据、消耗 CPU 和内存、持有锁。如果管理不当,它可能从「性能优化工具」变成「性能杀手」。

4.1 专用资源池隔离

核心思路:为 ANALYZE_STATISTICS 操作建立专用资源池,限制它能使用的内存和并发度,确保即使统计收集变慢,也不会挤占业务查询的资源。

创建专门的统计收集资源池

-- 创建低优先级的统计收集专用资源池
CREATE RESOURCE POOL analyzestatistics
  MEMORYSIZE '0%'          -- 不使用固定内存配额
  MAXMEMORYSIZE '20%'      -- 最多使用 20% 总内存——防止统计收集吞噬全部内存
  PLANNEDCONCURRENCY 2     -- 最多同时执行 2 条统计收集——控制并发度
  MAXCONCURRENCY 4
  RUNTIMEPRIORITY LOW      -- 低优先级——业务查询优先执行
  QUEUETIMEOUT 0           -- 不限制排队时间
  EXECUTIONPARALLELISM 4;  -- 限制并行度——大表收集不要用完所有 CPU 核心

-- 将统计操作路由到此资源池
-- 在运行 ANALYZE_STATISTICS 之前执行:
SET SESSION RESOURCE_POOL = analyzestatistics;

为什么每个参数取这个值

参数 取值 原因
MAXMEMORYSIZE '20%' 最多占用总内存的 20% 防止大表统计收集吞噬全部内存。参考某运营商 OOM 案例——高频收集导致内存耗尽,触发 OOM Killer
PLANNEDCONCURRENCY 2 最多 2 条并发 限制同时收集的表数,避免多表并发收集导致 IO 风暴
RUNTIMEPRIORITY LOW 低优先级 业务查询优先——统计收集慢一点没关系,但不能拖慢用户查询
EXECUTIONPARALLELISM 4 限制并行度 大表收集的并行度太高会导致短时间内 CPU 飙升,影响其他查询

关于 analyzestatistics 资源池:这一实践来自 数据库状态监控脚本,其中监控查询开头就执行 set resource_pool=analyzestatistics; 来确保自己的查询在专用资源池中运行。同理,所有 ANALYZE_STATISTICS 操作也应该使用专用资源池。

4.2 调度策略:在正确的时间做正确的事

原则:统计信息收集必须安排在数据加载之后、业务查询高峰之前。

# ETL 脚本中的统计收集示例
# 每个 INSERT/COPY 步骤之后立即收集,而非等到批处理末尾

vsql -c "
  INSERT INTO fact_table
  SELECT * FROM staging_table;
  COMMIT;

  -- 立即收集新分区的统计
  SET SESSION RESOURCE_POOL = analyzestatistics;
  SELECT ANALYZE_STATISTICS_PARTITION('fact_table', 'p_$(date +%Y%m%d)', '');
"

调度时间线示例(典型的日批处理场景):

时间 操作 说明
02:00 数据加载开始 COPY/INSERT 批量写入
03:30 数据加载完成
03:31 统计信息收集 对变动的表立即收集,在 analyze statistics 资源池中执行
04:00 统计收集完成
06:00 业务查询开始 优化器使用最新统计信息

关键原则

  1. 数据加载和统计收集必须紧耦合——中间不要隔数小时。某运营商 2021 年 4 月案例中,统计在 21:55 收集,数据在 04:08 加载,中间 6 小时的间隔导致 9% 的新数据未被统计覆盖,查询内存从 16GB 飙升到 83GB。
  2. 大表的统计收集应分批串行执行——不要同时收集多张 TB 级大表。
  3. 利用 MultiplanAnalyzeStats=1 并行加速单表收集——将一个大表的统计收集拆成多个子任务并行执行,减少单表收集的墙钟时间。

4.3 锁定风险管控

ANALYZE_STATISTICS 在完成统计计算后,需要将结果写入 Catalog。这一步需要获取 Global Catalog (GCL) X 锁——这是一个数据库级别的排他锁,会阻塞其他所有 DDL 和 Tuple Mover 操作。

监控 GCL X 锁的持有时间

-- 检查 Analyze Statistics 操作持有的 GCL X 锁时长
SELECT
  node_name,
  TRUNC(grant_time, 'HH24') AS hour,
  COUNT(*) AS lock_count,
  ROUND(AVG(EXTRACT(EPOCH FROM ("time" - grant_time))), 2) AS avg_hold_seconds
FROM v_internal.dc_lock_releases
WHERE object_name = 'Global Catalog'
  AND transaction_id IN (
    SELECT transaction_id FROM v_monitor.tuple_mover_operations
    WHERE operation_name = 'Analyze Statistics'
      AND operation_start_timestamp > CURRENT_TIMESTAMP - INTERVAL '24 hours'
  )
GROUP BY node_name, TRUNC(grant_time, 'HH24')
ORDER BY hour, node_name;

注意dc_lock_releasesv_internal schema 下,其中 time 是锁释放时间,grant_time 是锁获取时间。锁持有时间 = time - grant_time

此 SQL 来源于 集群性能问题处理检查要点 中的 GCL X 锁分析脚本。

如何解读结果

  • avg_hold_seconds < 1:正常——统计写入 Catalog 很快
  • avg_hold_seconds > 5:需要关注——GCL X 锁持有时间过长可能阻塞 Tuple Mover 的 mergeout 操作
  • avg_hold_seconds > 30:严重——可能存在性能问题。检查:
  • 是否在 mergeout 高峰期执行了 ANALYZE_STATISTICS?
  • Catalog 是否过大?(检查 v_catalog.tables 总表数)
  • 是否同时收集了过多表导致 Catalog 写入积压?

缓解措施

  • 避开 mergeout 高峰:不要在执行期间执行大量 ANALYZE_STATISTICS,尤其是大表
  • 分批执行:将统计收集分散到不同时段,避免短时间内持有大量 GCL X 锁
  • 使用 MultiplanAnalyzeStats=1:加速单表统计写入

4.4 收集频率的最优区间

基于真实运维经验总结的频率建议:

表类别 数据变化频率 推荐收集策略 最小间隔
超大型事实表(>100 亿行) 每天增量加载 分区级统计:加载后立即收集 + 周末全量兜底 分区统计 <5 分钟,全量统计 ≥1 周
大型事实表(10 亿-100 亿行) 每天全量替换 全量统计:加载后立即收集 ≥24 小时
中型表(千万-10 亿行) 不定期更新 全量统计:更新后收集 按需
小型表(<千万行) 几乎不变 全量统计:一次性收集,除非结构变更 一次性即可

绝对底线

📋 真实案例教训 — 来自 某运营商 OOM 案例:ANALYZE_STATISTICS每 5 秒的频率执行,持续消耗内存,最终触发 OOM Killer 导致节点宕机。

  • 最小间隔绝对不低于 5 分钟
  • 大表(>10 亿行)建议间隔 ≥ 1 小时
  • 超大型表(>100 亿行)建议间隔 ≥ 24 小时(全量统计)

第 5 节:深入案例

5.1 案例一:大表混合策略——增量分区统计 + 周末全量兜底

📝 虚构案例

场景:某金融机构,16 节点集群。核心事实表 fact_trade 按日期分区,每天凌晨 2:00 加载前一天的分区数据,日均新增 800 万行(约 1GB 压缩后,按宽表 ~120 bytes/行估算)。表总数据量 18 个月积累:约 45 亿行 / 500 GB

问题:原有的「每天凌晨全量 ANALYZE_STATISTICS」策略,随着数据量增长,统计收集时间从最初的 3 分钟延长到 35 分钟——已经侵占到 6:00 开始的业务查询时间。运维团队在考虑是否降低收集频率。

诊断过程

1. 确认收集耗时趋势

-- 检查最近 30 天 ANALYZE_STATISTICS 的耗时变化
-- 注意:fact_trade 需替换为实际表名
SELECT
  DATE(start_timestamp) AS run_date,
  ROUND(AVG(request_duration_ms) / 1000, 1) AS avg_duration_sec
FROM v_monitor.query_requests
WHERE request ILIKE '%analyze_statistics%fact_trade%'
  AND start_timestamp > CURRENT_TIMESTAMP - INTERVAL '30 days'
GROUP BY DATE(start_timestamp)
ORDER BY run_date;

输出:

run_date   | avg_duration_sec
-----------+-----------------
2026-05-01 |            180.0
2026-05-06 |            380.5
2026-05-11 |            605.2
2026-05-16 |            850.8
2026-05-21 |           1105.3
2026-05-26 |           1600.7
2026-05-30 |           2100.0

收集时间从 30 天前的 180 秒线性增长到 2100 秒(35 分钟)——平均每 5 天增加约 6 分钟。按此趋势,再过 30 天收集耗时将超过 1 小时。

2. 分析数据变化模式

-- 检查 fact_trade 各分区的最近数据写入时间,区分活跃分区和静态历史分区
SELECT
  pu.projection_name,
  MAX(pu.query_start_timestamp) AS last_write_time,
  CURRENT_TIMESTAMP - MAX(pu.query_start_timestamp) AS time_since_last_write
FROM v_monitor.projection_usage pu
WHERE pu.anchor_table_name = 'fact_trade'
  AND pu.io_type = 'output'
GROUP BY pu.projection_name
ORDER BY last_write_time DESC;

如何解读projection_usageio_type = 'output' 记录每次数据写入。对于按日期分区的表,每个投影对应一个或多个分区——最近有写入的投影就是仍在变化的「活跃分区」;很久没有写入的投影对应的是「静态历史分区」。

输出:

 projection_name                   | last_write_time         | time_since_last_write
-----------------------------------+-------------------------+----------------------
 fact_trade_DBD_18_seg_p20260530_b0| 2026-05-30 03:15:22     | 02:45:00
 fact_trade_DBD_18_seg_p20260529_b0| 2026-05-29 03:18:05     | 1 day 02:42:17
 fact_trade_DBD_18_seg_p20260528_b0| 2026-05-28 03:12:44     | 2 days 02:47:38
 fact_trade_DBD_18_seg_p20260527_b0| 2026-05-27 03:15:30     | 3 days 02:44:52
 fact_trade_DBD_18_seg_p20260526_b0| 2026-05-26 03:20:11     | 4 days 02:40:11
 fact_trade_DBD_18_seg_p20260525_b0| 2026-05-25 03:14:55     | 5 days 02:45:27
 fact_trade_DBD_18_seg_p20260524_b0| 2026-05-24 03:18:33     | 6 days 02:41:49
 fact_trade_DBD_18_seg_p20260523_b0| 2026-05-23 03:16:40     | 7 days 02:43:42
 fact_trade_DBD_18_seg_p20260522_b0| 2026-05-22 03:17:18     | 8 days 02:43:04
 fact_trade_DBD_18_seg_p20260515_b0| 2026-05-15 03:19:52     | 15 days 02:40:30
...

可见只有最近 7 天(p20260524 ~ p20260530)的投影有写入记录——这些分区每天凌晨接收增量数据;7 天前的分区(p20260522 及更早)last_write_time 均已超过 7 天,说明数据已稳定。

关键发现:fact_trade 只有最近 7 天的分区数据会有少量 UPDATE(修正数据),7 天前的分区已经是静态历史数据——不再变化。

3. 评估分区级统计的精度

对比实验:对 fact_trade 分别执行分区统计和全量统计后,运行 5 条典型业务查询,对比 EXPLAIN 的 Cost 估算。

结果:

查询类型 全量统计 Cost 估算 分区统计 Cost 估算 偏差
当日交易查询(WHERE trade_date = today 45K 45K 0%
近 7 天汇总 280K 275K -1.8%
近 30 天趋势 950K 930K -2.1%
跨季度对比(2025 Q1 vs 2025 Q4) 2.1M 1.5M -28.6%

关键发现:分区级统计对「最近 N 天」的查询估算精度几乎与全量统计一致,但对跨季度大范围查询的估算偏差较大(28.6%)。而实际业务中,92% 的查询只涉及最近 30 天

根因分析:全量统计每天重复扫描 18 个月的历史数据——其中 17 个月的数据是完全静态的。这不是策略问题,而是资源浪费——每天花 35 分钟做 95% 无意义的工作。

修复方案

  1. 日常策略(每日):只对最新分区执行 ANALYZE_STATISTICS_PARTITION()
  2. 兜底策略(每周日凌晨):执行全量 ANALYZE_STATISTICS() 确保跨年度查询的估算精度
  3. ANALYZE_STATISTICS 使用专用资源池 analyzestatistics,限制 EXECUTIONPARALLELISM=4
-- 日常:分区级统计(加载后立即执行)
-- 注意:p_20260530 需替换为实际分区名
SET SESSION RESOURCE_POOL = analyzestatistics;
SELECT ANALYZE_STATISTICS_PARTITION('fact_trade', 'p_20260530', '');

-- 周末:全量统计(周日凌晨 3:00 执行)
SET SESSION RESOURCE_POOL = analyzestatistics;
SELECT ANALYZE_STATISTICS('fact_trade');

效果对比

指标 优化前(每日全量) 优化后(增量+兜底)
每日统计收集耗时 35 分钟 2 分钟
每日查询性能 正常 正常(无退化)
跨年度查询精度 完美 周末全量恢复后完美
统计收集期间的 CPU 峰值 85% 25%
业务查询受影响时间段 5:30-6:00 无影响

5.2 案例二:大表无限增长导致统计收集耗时失控

📝 虚构案例

场景:某电商平台,8 节点集群。订单表 orders 不做分区,以每月约 5 亿行的速度增长,当前总量 80 亿行(按中等表 ~50 bytes/行估算,约 400 GB)。统计收集策略是「每次数据加载后全量 ANALYZE_STATISTICS」。

问题:统计收集时间从 3 个月前的 3 分钟增长到现在的 12 分钟。虽然单次 12 分钟不算长,但问题在于:(1) 它每月增长约 3 分钟,按此趋势 6 个月后将超过 30 分钟,(2) 凌晨 4:00 启动时恰好与 mergeout 高峰期重叠,GCL X 锁竞争导致实际墙钟时间拖到 25 分钟以上,(3) 统计收集期间的 CPU 使用率高达 90%+,导致同一时段运行的备份任务和 Tuple Mover 操作明显变慢。ETL 总窗口只有 2 小时,统计收集吃掉的比例从 2.5% 增长到现在的 10%,趋势堪忧。

诊断过程

  1. 时间线分析:统计收集在 4:00 启动——恰好与 mergeout 高峰期重叠。ANALYZE_STATISTICS 结束时需要获取 GCL X 锁写入 Catalog,而 mergeout 也在竞争同一把锁,互相阻塞导致实际完成时间延长到 4:25。
  2. 趋势分析:查询 projection_storage 确认该表以每月 5 亿行的速度增长。当前 80 亿行,统计 12 分钟;按此趋势 6 个月后 110 亿行,统计将超过 18 分钟——必然超出 ETL 窗口。
  3. CPU 分析EXECUTIONPARALLELISM 使用默认值,8 节点 × 默认并行度 = 统计收集在 12 分钟内占用 90%+ CPU。虽短但密集,恰好卡在 mergeout/备份的同一时段。

根因分析:这是一个典型的「用全量统计处理无限增长的不分区表」问题。表不做分区,每次统计收集都必须扫描全部数据(当前 80 亿行,且以每月 5 亿行增长)。随着表增长,收集时间线性增加,加上与 mergeout 的锁竞争——在可预见的未来(3-6 个月)将必然超出 ETL 维护窗口。

修复方案

  1. 立即(本周):为 orders 表添加按月份的分区(PARTITION BY order_month),将统计策略改为「当月分区全量 + 历史分区不收集」
  2. 过渡期(当天)

  3. 设置 SET SESSION RESOURCE_POOL = analyzestatistics; 将统计收集隔离

  4. 限制 EXECUTIONPARALLELISM=4 降低 CPU 占用
  5. 将收集频率从每天改为每 3 天一次(临时降低频率,等待分区改造完成)

效果对比(分区改造后):

指标 优化前 优化后
统计收集耗时 25 分钟(纯收集 12 分钟 + 锁排队 13 分钟) <1 分钟(仅当月分区)
CPU 峰值 90% 15%
mergeout 延迟 频繁排队 正常
ETL 超时次数 3-4 次/周 0

关键教训对于持续增长的大表,分区是解决统计信息维护成本的根本方案。不分区的大表,全量统计收集的时间只会越来越长,最终必然超过维护窗口。


5.3 案例三:统计收集与业务查询的资源争抢

📋 真实案例 · 来源:20210430某运营商 Vertica 数据库性能问题处理报告

场景:某运营商,138 节点集群。每天上午 9:00-12:00 app_pool 出现大量查询排队,而统计信息在凌晨 3:00 收集。

诊断过程

  1. 定位到问题 SQL——一个多表关联 INSERT 语句
  2. EXPLAIN 显示:TB_DW_CONTRACT_PROD_ITEMS → PREDICATE VALUES OUT-OF-RANGE
  3. 时间线分析:
  4. 全量统计收集:28 日 21:55
  5. 数据装载完成:29 日 04:08
  6. 数据在统计之后 6 小时加载——统计信息从加载完成那一刻就已过期
  7. 数据变化率:1,157 / 12,711 = 9%——新数据不在直方图的 MIN/MAX 范围内

根因分析:统计收集时间(21:55)和数据加载时间(04:08)之间存在6 小时的窗口期。数据加载后,统计信息立即过期。优化器基于过期统计选择了错误的大表做驱动表,导致查询消耗 83GB 内存。

修复方案

  1. 立即:对问题表重新收集统计 + 调整 force_outer
  2. 长期:部署四层自动收集策略,将统计收集与数据加载紧密耦合:
层级 策略 触发时机 适用对象
第 1 层 批处理脚本内嵌 INSERT/COPY 后立即执行 所有有数据变更的表
第 2 层 query_profiles 增量识别 每小时检查哪些表被写入过 未被第 1 层覆盖的表
第 3 层 HDFS 同步后收集 外部数据同步完成后 通过 HDFS 加载的表
第 4 层 TOP SQL 汇总收集 每天一次 所有被高频查询的表(兜底)

效果对比

指标 优化前 优化后
问题查询内存消耗 83 GB 16 GB
问题查询执行时间 非常慢 11 秒

核心教训:统计收集的时间窗口比统计收集的频率更重要。统计信息必须在数据加载之后立即收集,而不是在一天中的固定时间——哪怕你每天都收集,如果收集在加载之前,等于白收集。


5.4 案例四:临时表——统计信息维护的最大盲区

📋 真实案例 · 来源:20210624某运营商 Vertica 数据库性能问题处理报告

场景:同一运营商集群(138 节点),ETL 批处理脚本中使用大量中间临时表(如 mid_xxxtmp_xxx)。这些表在单个批处理脚本中创建 → INSERT 数据 → 参与后续 JOIN → 脚本结束后 DROP。

问题user_dtal 表因统计过期被选为错误的 Inner 表,导致单条查询消耗 28GB 内存、执行 1 小时。同时,批处理脚本中的中间临时表完全没有统计信息,因为它们在定时统计收集运行时已经不存在了。

根因分析

  1. user_dtal 表有 14.5 亿行,统计信息过期导致优化器完全错估其大小
  2. 中间临时表在脚本执行期间存在,但定时统计收集脚本运行时它们已被 DROP——形成了统计信息的永久盲区
  3. 临时表参与后续 JOIN 时,优化器对其数据量一无所知——只能用默认值(10K)估算

修复方案

在 ETL 脚本的每个 INSERT 步骤之后,立即对目标表(包括临时表)收集统计信息:

-- ETL 批处理脚本中的模式(Perl/shell 封装)
INSERT INTO mid_order_summary
SELECT ... FROM raw_orders WHERE ...;
COMMIT;

-- 临时表也必须收集统计——否则后续 JOIN 会盲操作
SET SESSION RESOURCE_POOL = analyzestatistics;
SELECT ANALYZE_STATISTICS('mid_order_summary');

-- 后续 JOIN 现在有准确统计了
INSERT INTO final_report
SELECT ... FROM mid_order_summary JOIN dim_product ...;

效果

  • user_dtal 的 JOIN 计划恢复正常
  • 中间临时表的 JOIN 估算从 (NO STATISTICS) → Row 准确估算
  • 批处理整体执行时间显著缩短

核心教训临时表的统计信息维护是最容易被忽视的问题。解决方案不是定时收集,而是在每个 INSERT 后立即内联收集——即便这会让脚本多一两秒的执行时间,但比起后续 JOIN 因无统计而跑数小时,这个代价微乎其微。


第 6 节:完整诊断流程实战

📝 虚构场景 · 完整演练

场景设定:周二上午 10:00,业务团队反馈「今天的日报跑了一个小时还没出来,平时 5 分钟就好了」。你登录数据库,开始系统化排查。

时间线

10:05 — 确认问题 SQL

SELECT
  transaction_id, statement_id,
  user_name,
  start_timestamp,
  request_duration_ms / 1000 AS duration_sec,
  memory_acquired_mb,
  substr(request, 1, 150) AS request_preview
FROM v_monitor.query_requests
WHERE start_timestamp > CURRENT_TIMESTAMP - INTERVAL '2 hours'
  AND is_executing = true
  AND request_duration_ms > 600000  -- 超过 10 分钟
ORDER BY request_duration_ms DESC;

输出:

transaction_id | statement_id | user_name | start_timestamp      | duration_sec | memory_acquired_mb | request_preview
---------------+--------------+-----------+----------------------+-------------+--------------------+-------------------------------------
        800234 |            1 | report_etl| 2026-05-26 09:02:15  |         3360 |              18432 | INSERT INTO report.daily_summary SELECT ...

transaction_id = 800234,日报汇总 SQL,已执行 56 分钟,消耗 18GB 内存——远超正常范围(该报表平时 5 分钟、<2GB 内存即可完成)。

10:07 — 查看执行计划

EXPLAIN SELECT ... -- 问题 SQL

关键发现:

+-JOIN HASH [Cost: 35K, Rows: 50K (ROW COUNT ONLY)]
|  Outer (LOCAL ROUND ROBIN)
|  Inner (BROADCAST)

  • (ROW COUNT ONLY) 出现——关键表只有行数统计,没有 FULL 列级统计
  • Cost 仅 35K、Rows 仅 50K——明显低估。日报表应该有数百万行
  • Inner 表做 BROADCAST——优化器认为它很小(但实际上不是)

10:10 — 定位缺少 FULL 统计的大表

-- 只取一个 super projection 避免 K-safety 重复;projection_columns 单独查避免 JOIN 膨胀
WITH anchor_proj AS (
  SELECT anchor_table_name, MIN(projection_id) AS projection_id
  FROM v_catalog.projections WHERE is_super_projection = true
  GROUP BY anchor_table_name
),
actual_rows AS (
  SELECT
    ps.anchor_table_name AS table_name,
    SUM(ps.row_count) AS total_rows,
    ROUND(SUM(ps.disk_size) / (1024*1024*1024), 1) AS total_size_gb
  FROM v_monitor.projection_storage ps
  JOIN anchor_proj ap ON ps.projection_id = ap.projection_id
  WHERE ps.anchor_table_name IN ('daily_summary', 'transaction_fact')
  GROUP BY ps.anchor_table_name
)
SELECT
  ar.table_name,
  ar.total_rows,
  ar.total_size_gb,
  pc.best_stat_type,
  pc.last_update
FROM actual_rows ar
JOIN (
  SELECT table_name,
    CASE WHEN COUNT(CASE WHEN statistics_type = 'FULL' THEN 1 END) > 0 THEN 'FULL'
         WHEN COUNT(CASE WHEN statistics_type = 'PARTITION' THEN 1 END) > 0 THEN 'PARTITION'
         ELSE 'ROWCOUNT' END AS best_stat_type,
    MAX(statistics_updated_timestamp) AS last_update
  FROM v_catalog.projection_columns
  GROUP BY table_name
) pc ON ar.table_name = pc.table_name;

输出:

table_name       | total_rows  | total_size_gb | best_stat_type | last_update
-----------------+-------------+---------------+----------------+---------------------------
daily_summary    |   5,200,000 |          0.3  | FULL           | 2026-05-26 03:15:22
transaction_fact | 300,000,000 |         18.0  | ROWCOUNT       | 2026-05-23 03:12:05

关键发现:transaction_fact——3 亿行 / 约 18 GB(按 ~60 bytes/行估算)——只有 ROWCOUNT 统计。最近一次更新是 5 月 23 日,距今已 3 天。而该表每天凌晨有增量加载,意味着已经经历了 3 次数据加载而统计信息一直未更新。

10:12 — 检查统计偏差

WITH anchor_proj AS (
  SELECT anchor_table_name, MIN(projection_id) AS projection_id
  FROM v_catalog.projections WHERE is_super_projection = true
  GROUP BY anchor_table_name
)
SELECT
  ts.table_name,
  ts.row_count AS stats_row_count,
  SUM(ps.row_count) AS actual_row_count,
  ROUND((SUM(ps.row_count) - ts.row_count) * 100.0 / SUM(ps.row_count), 1) AS deviation_pct
FROM v_monitor.projection_storage ps
JOIN anchor_proj ap ON ps.projection_id = ap.projection_id
JOIN v_monitor.TABLE_STATISTICS ts ON ps.anchor_table_id = ts.table_oid
WHERE ps.anchor_table_name = 'transaction_fact'
GROUP BY ts.table_name, ts.row_count;

输出:

table_name       | stats_row_count | actual_row_count | deviation_pct
-----------------+-----------------+------------------+--------------
transaction_fact |     240,000,000 |      300,000,000 | 20.0%

统计信息记录的行数比实际少 20%——优化器基于 2.4 亿行做 JOIN 决策,而实际有 3 亿行。对于一个大表 JOIN,20% 的偏差足以让优化器选择错误的驱动表和 JOIN 策略。

10:15 — 修复:收集统计

SET SESSION RESOURCE_POOL = analyzestatistics;
SELECT ANALYZE_STATISTICS('transaction_fact');
-- 返回: 0(成功)

3 亿行 / 18GB 的表,ANALYZE_STATISTICS 耗时约 45 秒

10:18 — 验证修复

EXPLAIN SELECT ... -- 同样的 SQL

修复后的 EXPLAIN 输出:

+-JOIN HASH [Cost: 450K, Rows: 4.5M]
|  Outer (LOCAL ROUND ROBIN)
|  Inner (RESEGMENT)  ← 不再做 BROADCAST!

对比:

指标 修复前 修复后 说明
统计标记 (ROW COUNT ONLY) (无标记) FULL 统计已收集
Cost 35K 450K 优化器正确评估了真实代价
Rows 估算 50K 4.5M 从严重低估恢复到接近真实值
Inner 表策略 BROADCAST RESEGMENT 大表不再被广播到所有节点

10:20 — 确认查询恢复

重新运行日报 SQL:4 分 30 秒完成。对比之前的 56 分钟+,性能恢复。

事后根因:3 天前(5 月 23 日)是周五,统计收集脚本在周五正常运行。但周六和周日的数据加载没有触发统计收集(因为周末脚本只加载数据,不收集统计)。周一凌晨的收集脚本因一个配置错误跳过了 transaction_fact 表。连续 3 次数据加载后统计信息严重过期,优化器在周二上午的日报查询中基于 3 天前的数据做了错误的 JOIN 决策。

改进措施

  1. 在 ETL 脚本的每个数据加载步骤后添加 ANALYZE_STATISTICS(而非依赖单独的收集脚本)
  2. 添加监控告警:对 >10 亿行的表,如果统计更新时间超过 24 小时,触发告警
  3. 周末的加载脚本增加统计收集步骤——周末数据也在增长,统计不能停

第 7 节:快速诊断 SQL 工具箱

诊断目标 SQL
找出缺少 FULL 统计的大表 见第 2.1 节完整 SQL(含 anchor_proj 去重 + LEFT JOIN 检测无 FULL 的表)
统计信息行数偏差检查 见第 6 节行数偏差 SQL——比较 TABLE_STATISTICS.row_countprojection_storage.row_count 的实际汇总
统计最新更新时间 SELECT table_schema, table_name, statistics_type, MAX(statistics_updated_timestamp) FROM v_catalog.projection_columns WHERE statistics_type IN ('FULL','PARTITION') GROUP BY 1,2,3 ORDER BY 4;
优化器事件统计告警 SELECT event_type, COUNT(*) FROM dc_optimizer_events WHERE event_type IN ('NO HISTOGRAM','PREDICATE OUTSIDE HISTOGRAM') AND "time" > CURRENT_TIMESTAMP - INTERVAL '7 days' GROUP BY 1;
EXPLAIN 检查统计状态 EXPLAIN SELECT ...; — 查找 (NO STATISTICS) / (ROW COUNT ONLY) / PREDICATE VALUES OUT-OF-RANGE
收集全量统计 SELECT ANALYZE_STATISTICS('schema.table');
收集分区级统计 SELECT ANALYZE_STATISTICS_PARTITION('schema.table', 'partition_name', '');
收集列直方图 SELECT ANALYZE_HISTOGRAM('schema.table', 'column_name');
统计收集耗时趋势 SELECT DATE(start_timestamp), AVG(request_duration_ms)/1000 FROM v_monitor.query_requests WHERE request ILIKE '%analyze_statistics%' AND start_timestamp > CURRENT_TIMESTAMP - INTERVAL '30 days' GROUP BY 1 ORDER BY 1;
ANALYZE STATISTICS 的 GCL X 锁监控 见第 4.3 节完整 SQL——检查 dc_lock_releasesobject_name='Global Catalog'transaction_idtuple_mover_operationsoperation_name='Analyze Statistics' 的记录
统计相关配置参数 SELECT parameter_name, current_value, default_value FROM configuration_parameters WHERE parameter_name ILIKE '%analy%' OR parameter_name ILIKE '%stat%' ORDER BY 1;
生成大表批量收集命令 见第 2.1 节「识别需要重点关注的大表」SQL,将其中 has_full_stats=0 的表名代入 SELECT ANALYZE_STATISTICS('schema.table');。大表建议先 SET SESSION RESOURCE_POOL = analyzestatistics; 再执行收集

第 8 节:最佳实践清单

# 实践 为什么 投入产出比
1 数据加载后立即收集统计 统计信息的最佳时机是数据刚加载完成。迟到的统计 = 过期的统计 = 错误的执行计划。详见案例 5.3——6 小时窗口导致 83GB 内存消耗 ⭐⭐⭐⭐⭐
2 为 ANALYZE_STATISTICS 建立专用资源池 限制内存上限(≤20%)、低优先级、限制并发度——确保统计收集即使变慢也不会挤占业务查询。RUNTIMEPRIORITY LOW 是核心 ⭐⭐⭐⭐⭐
3 超大表(>100 亿行)使用分区级统计 + 定期全量兜底 每天重复扫描历史静态数据是最大的资源浪费。分区级统计在「最近 N 天」查询上的精度几乎等同全量——用几分钟的代价替代数十分钟 ⭐⭐⭐⭐⭐
4 持续增长的大表必须分区 不分区的大表,全量统计收集时间随数据量线性增长,最终必然超过维护窗口。分区是解决这个问题的根本方案 ⭐⭐⭐⭐⭐
5 临时表也需要统计——在每个 INSERT 后立即收集 临时表是统计信息的永久盲区——它们在定时收集脚本运行时已经不存在。内联收集让脚本多花 2 秒,但可能让后续 JOIN 少跑 2 小时 ⭐⭐⭐⭐
6 监控统计信息的行数偏差(阈值 >10%) 行数偏差是统计过期的最直接信号。10% 的偏差足以让优化器选错驱动表。按 2.2 节的 SQL 建立巡检 ⭐⭐⭐⭐
7 收集大表统计时要限制 EXECUTIONPARALLELISM 默认并行度可能让统计收集占用全部 CPU 核心。设为 4-8 可以显著降低 CPU 峰值,同时收集时间只增加 20-30% ⭐⭐⭐⭐
8 避开 mergeout 高峰期执行大表 ANALYZE_STATISTICS 统计收集结束时需要获取 GCL X 锁写入 Catalog——如果与 mergeout 高峰期重叠,会互相阻塞。按时段错开安排 ⭐⭐⭐
9 高频统计收集的绝对底线:≥5 分钟间隔 某运营商真实案例——每 5 秒收集一次导致 OOM Killer 触发节点宕机。这是生产事故级别的教训 ⭐⭐⭐
10 对 JOIN 列和高选择性 WHERE 列单独执行 ANALYZE_HISTOGRAM 如果大表无法频繁做全量统计,至少保证 JOIN 列的直方图是最新的——这些列对查询计划的影响最大 ⭐⭐⭐
11 建立统计收集的监控告警 对 >10 亿行的表,如果统计更新时间超过 24 小时,自动告警。不要等问题出现了才发现统计已过期数天 ⭐⭐⭐
12 统计收集脚本中加入去重保护 如果一张表在 30 分钟内已被收集过,跳过本次收集。避免多个 ETL 脚本重复收集同一张表(某运营商 5 秒间隔的教训) ⭐⭐

扩展阅读