跳转至

Vertica 宽表(多列表)的存储与查询优化

作者:JiangChong | 撰写时间:2026年06月

适用场景框: 你的表包含上百甚至数百列,查询越来越慢、Catalog 越来越大、备份恢复时间越来越长,同时你怀疑问题出在「列太多」但不知道如何定量分析和解决。

关联文章:

理解全文脉络: 文章从 Vertica 列存储的底层原理出发,解释宽表为什么天生与列存「不对付」;然后从系统级监控到逐列诊断,教会你量化宽表的影响;接着给出从快速见效到根本治理的分层解决方案;最后通过虚构案例、真实案例和完整演练串联所有知识点。如果你只是想快速定位问题,可以直接跳转到第 7 节:快速诊断 SQL 工具箱


第 1 节:原理理解 — 为什么宽表在 Vertica 中是问题

1.1 Vertica 列存储的物理真相

传统行式数据库(如 MySQL、PostgreSQL)将一行数据完整地存储在一起:一行 = 一个连续的磁盘块。读取一行时,所有列的数据都被一起拉进内存。

Vertica 完全不同。 在 Vertica 的列存架构中,每个列的数据独立存储在各自的文件中。一张 100 列的表,每次数据加载(COPY 语句)会为每个节点上的每个投影创建至少 100 个列数据文件——每列存储在一个独立文件中(v7.2 之前每列分为数据文件和索引文件两个文件,v7.2 起两者合并为一个文件)。这些文件都归属于一个 ROS(Read Optimized Storage)容器。

这意味着,一次 COPY 加载在磁盘上产生的是一组文件,而非一个文件。具体来说:

一次 COPY → 一个 ROS 容器 → 该容器内,表有多少列就有多少个文件(每列一个)。

所以,如果一张表有 200 列,每次 COPY 落盘就是 200 个文件。假设这张表在 3 节点集群上,K-safety=1(投影有 b0 和 b1 两个 buddy,各 buddy 在所有节点上都有 segment——即每个节点上同时存在 b0 和 b1 的数据片段),Tuple Mover 还没来得及把多次加载合并——当前每个投影 segment 在每个节点上累积了 50 个 ROS 容器。那么:

一个投影segment在一个节点上的文件数 = 50 个 ROS 容器 × 200 列/容器 = 10,000 个
整张表的总文件数                  = 10,000 × 3 节点 × 2 投影segment = 60,000 个

这只是一张表。 加上分区后,每个分区的数据隔离到独立的 ROS 容器中,文件数可能再翻几倍(详见 分区与 ROS 文件数量的关系)。

1.2 宽表对 Vertica 的三重打击

宽表(通常指超过 100 列,尤其是超过 200 列的表)从三个维度影响 Vertica 性能:

影响维度 机制 典型症状
Catalog 膨胀 每列每 ROS 容器都在 catalog 中有元数据记录;列数×ROS 容器数的乘积决定了 catalog 大小 v_catalog 查询变慢、数据库启动时间延长、备份/恢复耗时增加
文件句柄压力 查询需要打开它访问的每一列的文件句柄;SELECT * 在宽表上会打开所有列的文件 peak file handles 计数器飙升、操作系统 ulimit -n 可能触及上限
查询启动延迟 优化器需要为每列读取元数据(统计信息、编码类型、min/max 值);列越多,Plan 阶段的 catalog 锁持有时间越长 PreparePlan 阶段耗时异常长、高并发时 catalog 锁争抢

通俗比喻: 行式数据库像一本装订好的书——翻到某一页就能看到全部内容。Vertica 列存则像一个图书馆的卡片目录系统——每列是一个独立的卡片柜。读 10 列就像跑 10 个柜台取资料,一个人跑没问题;但 200 列的宽表就像要跑 200 个柜台,光「找到柜台在哪」就消耗大量时间。

1.3 触发条件

宽表问题并非列数一多就立即出现,而是在以下条件叠加时才会严重化:

  1. 列数 > 100 且每列都被查询引用SELECT * 或大宽表 JOIN)
  2. 频繁的小批量数据加载(导致大量 ROS 容器,列数 × ROS 容器数乘积爆炸)
  3. 默认投影的 ORDER BY 和 SEGMENTATION 包含过多列MaxAutoSegColumns 参数控制自动投影的 hash 分段列数:v9.x 之前默认 32 列,v10.x 起默认 8 列。宽表下即使 8 列的 hash 计算仍是可观 CPU 开销)
  4. 没有针对性的编码策略(全部使用默认 AUTO 编码,存储浪费叠加列数放大)

1.4 问题来源总结

来源 机制 影响
列数 × ROS 容器 元数据量 = 列数 × ROS 容器数;目录大小线性增长 Catalog 膨胀,所有元数据操作变慢
SELECT * 文件句柄 查询打开所有列的文件句柄,每 ROS 容器每列一个 文件句柄耗尽,查询失败
默认分段覆盖全列 hash(col1, col2, ..., col32) 的 CPU 开销随列数线性增长 加载和查询的 CPU 开销增加
编码策略缺失 AUTO 编码对低基数列可能浪费空间,且解压多耗 CPU 存储浪费、查询时 CPU 解码开销大

第 2 节:系统级监控 — 从宏观指标入手

在定位到具体表之前,先通过系统级指标判断是否存在宽表导致的系统性问题。

2.1 检查 Catalog 大小

Catalog 大小是宽表问题最直接的宏观指标。每列在每个 ROS 容器中都有元数据记录,宽表的 catalog 通常比窄表大一个数量级。

-- 查看各节点 catalog 内存峰值(最近 1 小时内每秒采样,取每节点峰值)
-- dc_ 前缀表属于 v_internal schema(数据采集器表)
SELECT nvl(n.subcluster_name, 'Enterprise Mode') AS subcluster_name,
       foo.node_name,
       NOW() AS check_time,
       MAX(catalog_size_in_MB) AS catalog_size_mb
FROM (
  SELECT node_name,
         SUM((total_memory_max_value - free_memory_min_value)) / (1024*1024) AS catalog_size_in_MB
  FROM v_internal.dc_allocation_pool_statistics_by_second
  WHERE "time" > CURRENT_TIMESTAMP - INTERVAL '1 hour'
    AND total_memory_max_value > 0
  GROUP BY node_name, TRUNC("time"::TIMESTAMP, 'SS'::VARCHAR(2))
) foo
LEFT JOIN v_catalog.nodes n ON foo.node_name = n.node_name
GROUP BY 1, 2
ORDER BY 1, 2;

如何解读结果:

  • catalog_size_mb > 15000 MB(15 GB):属于偏大的 catalog,需要关注。超过 20 GB 则属于严重,数据库启动和备份恢复都会明显变慢。
  • 各节点 catalog_size_mb 差异 > 20%:可能某个节点的 ROS 容器数多于其他节点,存在数据倾斜或 Tuple Mover 合并不均衡。
  • catalog_size_mb 持续增长而不回落:说明 Tuple Mover mergeout 速度跟不上新数据加载速度。

2.2 统计每个投影的列数与 ROS 容器数

这个查询帮你找出哪些投影既是宽表又有大量 ROS 容器——这是问题的「热点」组合。

-- 按投影统计列数和 ROS 容器数,定位宽表+多 ROS 的热点投影
-- 先按投影聚合节点级指标,再 JOIN projection_columns 获取列数
-- 注意:① 不能直接 JOIN 后 SUM(row_count),会被列数放大
--      ② 通过 is_segmented 区分分段/复制投影:分段投影行数 SUM(各节点分片求和),
--         复制投影行数 MAX(每节点存全量,不能加);存储总量统一 SUM(含副本冗余)
WITH proj_stats AS (
  SELECT ps.projection_id,
         ps.projection_schema,
         ps.projection_name,
         ps.anchor_table_name,
         pi.is_segmented,
         MAX(ps.ros_count) AS max_ros_per_node,
         CASE WHEN pi.is_segmented
              THEN SUM(ps.row_count)
              ELSE MAX(ps.row_count)
         END AS total_rows,
         SUM(ps.used_bytes) AS total_bytes
  FROM v_monitor.projection_storage ps
  JOIN (SELECT DISTINCT projection_id, is_segmented FROM v_catalog.projections) pi
    ON ps.projection_id = pi.projection_id
  GROUP BY 1, 2, 3, 4, pi.is_segmented
)
SELECT ps.projection_schema,
       ps.projection_name,
       ps.anchor_table_name,
       COUNT(DISTINCT pc.column_id) AS column_count,
       ps.max_ros_per_node,
       ps.total_rows,
       ROUND(ps.total_bytes / 1024^3::NUMERIC(10,2), 2) AS total_gb
FROM proj_stats ps
JOIN v_catalog.projection_columns pc ON ps.projection_id = pc.projection_id
GROUP BY 1, 2, 3, 5, 6, 7
HAVING COUNT(DISTINCT pc.column_id) > 50  -- 只看超过 50 列的宽表
ORDER BY COUNT(DISTINCT pc.column_id) * ps.max_ros_per_node DESC  -- 列数×ROS容器数
LIMIT 20;

如何解读结果:

  • column_count * max_ros_per_node 超过 5000:这个投影的元数据规模开始令人担忧。例如 100 列 × 50 ROS 容器 = 5000。
  • total_rows:分段投影为集群逻辑行数(各节点分片求和);复制投影取单节点行数(避免重复计算)。
  • total_gb:集群总存储占用(含副本冗余),不分段/复制投影均按各节点存储求和。
  • total_gb 很大但 max_ros_per_node 不高:存储优化尚可,但列数多本身可能影响查询。
  • max_ros_per_node > 500:接近 1024 的 ROS pushback 阈值(参见 理解 Vertica 的分区 §3.2),需要立即关注。

2.3 检查文件句柄使用峰值

文件句柄是宽表查询最容易触及的操作系统级瓶颈。

-- 查看最近执行中查询的文件句柄峰值
SELECT transaction_id,
       statement_id,
       operator_name,
       path_id,
       MAX(CASE WHEN counter_name = 'peak file handles' THEN counter_value END) AS peak_file_handles,
       MAX(CASE WHEN counter_name = 'execution time (us)' THEN counter_value/1000000 END) AS exec_seconds
FROM v_monitor.execution_engine_profiles
WHERE counter_name IN ('peak file handles', 'execution time (us)')
  AND (transaction_id, statement_id) IN (
    SELECT transaction_id, statement_id FROM v_monitor.query_profiles
    WHERE query_start::TIMESTAMPTZ > CURRENT_TIMESTAMP - INTERVAL '1 day'
    ORDER BY query_duration_us DESC LIMIT 100
  )
GROUP BY 1, 2, 3, 4
HAVING MAX(CASE WHEN counter_name = 'peak file handles' THEN counter_value END) > 100
ORDER BY peak_file_handles DESC
LIMIT 20;

如何解读结果:

  • peak_file_handles > 1000:查询打开的文件句柄数偏高,宽表 SELECT * 的典型特征。
  • peak_file_handles > 5000:非常危险,接近 Linux 默认的 ulimit -n 上限(通常是 65536),多查询并发时可能触及限制。
  • operator_name = 'Scan' 且文件句柄很高:最典型的宽表扫描场景。

通俗解释: 文件句柄就像一个人能同时打开的书本数量。列存数据库中每列都是一个独立的文件,宽表查询就像同时翻 200 本书——操作系统只允许同时打开有限数量的文件,超出就会报错。

2.4 Linux 层面:检查文件系统文件数

SSH 到集群节点,统计 Vertica 数据目录下的文件总数,这是最底层的验证手段。

# 统计单个节点上的 Vertica ROS 文件总数
# 注意:/data/vertica/data 需替换为实际的 Vertica 数据目录
find /data/vertica/data -type f | wc -l

# 按投影目录查看文件数分布
for dir in /data/vertica/data/*/; do
  count=$(find "$dir" -type f | wc -l)
  echo "$count  $(basename $dir)"
done | sort -rn | head -20

如何解读结果:

  • 文件数持续增长:Tuple Mover mergeout 可能跟不上加载速度,需要检查 Tuple Mover 配置。

第 3 节:逐步定位根因 — 从宏观到微观

系统级监控确认存在宽表问题后,需要定位到具体的表和列,找出根因。

3.1 第一步:找到列数最多的表

-- 找到列数最多的表(v_catalog.projections 本身不含系统 schema 行,无需额外过滤)
SELECT p.projection_schema,
       p.anchor_table_name,
       COUNT(DISTINCT pc.table_column_name) AS column_count,
       COUNT(DISTINCT p.projection_basename) AS projection_count
FROM v_catalog.projections p
JOIN v_catalog.projection_columns pc
  ON p.projection_id = pc.projection_id
WHERE p.is_super_projection
GROUP BY 1, 2
ORDER BY column_count DESC
LIMIT 20;

如何解读:

  • column_count > 100:属于宽表范畴,需要进一步分析。
  • column_count > 200:严重宽表,查询和存储都可能存在问题。
  • projection_count > 2:除了 super projection 和 buddy projection 外,还有额外的自定义投影,这本身也会增加 catalog 开销,如非必要应考虑清理(参见 表约束产生的projection问题)。

如果这张表不是你的目标表,进入下一步。

3.2 第二步:分析宽表的每列存储占用与编码

找到目标宽表后,逐列分析存储占用和编码策略,找出浪费空间最大的列。

-- 注意:将 'your_table_name' 替换为实际的表名
-- 分析某张宽表每列的存储占用和编码
-- total_rows:分段投影为集群逻辑行数,复制投影取单节点值
-- total_mb:集群总存储(含所有投影副本),用于评估优化收益
SELECT cs.anchor_table_column_name AS column_name,
       cs.encodings,
       cs.compressions,
       CASE WHEN pi.is_segmented
            THEN SUM(cs.row_count)
            ELSE MAX(cs.row_count)
       END AS total_rows,
       SUM(cs.used_bytes) AS total_bytes,
       ROUND(SUM(cs.used_bytes) / 1024^2::NUMERIC(10,2), 2) AS total_mb,
       MAX(cs.ros_count) AS max_ros_per_node,
       pc.data_type,
       pc.statistics_type
FROM v_monitor.column_storage cs
JOIN v_catalog.projection_columns pc
  ON cs.projection_id = pc.projection_id
 AND cs.column_id = pc.column_id
JOIN (SELECT DISTINCT projection_id, is_segmented FROM v_catalog.projections) pi
  ON cs.projection_id = pi.projection_id
WHERE cs.anchor_table_schema = 'public'
  AND cs.anchor_table_name = 'your_table_name'
GROUP BY 1, 2, 3, pi.is_segmented, pc.data_type, pc.statistics_type
ORDER BY total_bytes DESC;

如何解读:

  • total_rows:分段投影为集群逻辑行数(各节点分片求和),复制投影取单节点值(避免重复计算)。
  • total_mb > 1 GB 的单列:该列可能是 VARCHAR(65000) 且存储大量数据,或者编码策略不当。total_mb 为集群总存储(含所有投影副本),编码优化可同时压缩所有副本的存储。
  • encodings = 'AUTO' 但基数列是低基数:例如 status 字段只有 5 个取值但使用 AUTO(即 Delta Int Pack),换成 RLE 后压缩比可以从 5:1 提升到 100:1 甚至更高。
  • compressions != 'none'(如 lzo)但 encodings 仍为 LZO:说明列只是被压缩了,但没有选择最适合的编码类型。编码优先于压缩——好的编码让 Vertica 可以直接在编码数据上运算而不必解压。
  • statistics_type 为空或 ROWCOUNT:该列缺乏直方图统计,优化器无法做出好的代价估算,可能导致查询计划不优。

编码 vs 压缩的区别: 编码(encoding)是对数据值的数学转换,Vertica 可以直接在编码后的数据上执行运算而无需解码。压缩(compression)如 LZO 是对二进制数据的压缩,查询前必须先解压缩。优先选对编码,压缩是最后的手段。

3.3 第三步:分析默认投影的 ORDER BY 和分段设计

宽表的默认投影往往把过多列放入 ORDER BY 或 SEGMENTATION 中,这是隐藏的 CPU 开销来源。

-- 注意:将 'your_table_name' 替换为实际的表名
-- 查看 super projection 的排序和分段列
SELECT p.projection_name,
       p.is_super_projection,
       p.segment_expression,
       p.is_segmented,
       COUNT(pc.sort_position) AS sort_column_count,
       -- 列出排序位置靠前的列
       MAX(CASE WHEN pc.sort_position BETWEEN 0 AND 4 THEN pc.projection_column_name END) AS sort_col_0_4
FROM v_catalog.projections p
JOIN v_catalog.projection_columns pc
  ON p.projection_id = pc.projection_id
WHERE p.anchor_table_name = 'your_table_name'
  AND p.anchor_table_schema = 'public'
  AND pc.sort_position >= 0
GROUP BY 1, 2, 3, 4
ORDER BY p.is_super_projection DESC;

如何解读:

  • segment_expression 中包含大量列(如 hash(col1, col2, ..., col8):这是默认行为(MaxAutoSegColumns 参数控制自动投影的 hash 分段列数,v9.x 之前为 32 列,v10.x 起默认 8 列)。宽表下对 8 列做 hash 分段仍有 CPU 开销。建议将分段列精简到 2-4 个高基数列
  • sort_column_count 等于列总数:这说明投影 ORDER BY 包含了所有列(默认行为),大部分列在查询中其实不会被用作 GROUP BY 或 JOIN 键,不需要出现在排序里。

3.4 第四步:量化查询中实际使用的列数

很多时候 SELECT * 是默认行为,但实际上应用程序只用到其中 10-20 列。通过分析真实查询中引用的列,可以确定哪些列可以拆出去。

-- 注意:将 'your_table_name' 替换为实际的表名
-- 查看最近查询对该宽表的列引用情况
SELECT LEFT(qp.query, 200) AS query_snippet,
       qp.query_start,
       ROUND(qp.query_duration_us / 1000000::NUMERIC(10,2), 2) AS duration_sec
FROM v_monitor.query_profiles qp
WHERE qp.query ILIKE '%your_table_name%'
  AND qp.query_start > CURRENT_TIMESTAMP - INTERVAL '7 days'
ORDER BY qp.query_duration_us DESC
LIMIT 20;

如何解读:

  • 手动抽查 SQL 文本,看实际引用了哪些列。通常你会发现大部分查询只用到 20-30% 的列。
  • 如果大量查询都是 SELECT *,可以推动应用程序改为只选择需要的列。
  • duration_sec 很高的查询如果只引用少量列但扫描了整个宽表,说明投影设计有改进空间(可以创建只包含常查询列的窄投影)。

3.5 第五步:检查 DELETE_VECTOR 累积

宽表如果频繁进行 DELETE 或 UPDATE 操作,delete vector 的累积会进一步放大存储和查询开销。

-- 注意:将 'your_table_name' 替换为实际的表名
-- 检查宽表的 delete vector 累积情况
SELECT sc.node_name,
       sc.projection_name,
       SUM(sc.deleted_row_count) AS total_deleted_rows,
       SUM(sc.total_row_count) AS total_rows,
       ROUND(SUM(sc.deleted_row_count) / NULLIF(SUM(sc.total_row_count), 0)::NUMERIC(10,2) * 100, 2) AS deleted_pct,
       SUM(sc.disk_size) AS total_disk_bytes,
       ROUND(SUM(sc.disk_size) / 1024^3::NUMERIC(10,2), 2) AS total_disk_gb
FROM v_monitor.storage_containers sc
JOIN v_catalog.projections p
  ON sc.projection_id = p.projection_id
WHERE p.anchor_table_name = 'your_table_name'
  AND p.anchor_table_schema = 'public'
GROUP BY 1, 2
ORDER BY deleted_pct DESC;

如何解读:

  • deleted_pct > 20%:超过 20% 的已删除行仍在磁盘上占用空间,应考虑执行 PURGE() 或使用 DROP_PARTITIONS 代替 DELETE。
  • 宽表的 DELETE 尤其昂贵:因为每列的 delete vector 都需要被读取和计算。这也是为什么宽表应该优先用分区做数据生命周期管理(分区管理数据的好处)。

第 4 节:解决方案 — 从快速见效到根本治理

4.1 立即措施(当天可执行)

4.1.1 清洗 ROS 容器:执行 PURGE 和 mergeout

如果发现大量 delete vector 或 ROS 容器碎片化,先清理再优化。

-- 注意:将 'your_table_name' 替换为实际的表名
-- 清理 delete vector
SELECT PURGE_TABLE('public.your_table_name');

-- 强制触发 Tuple Mover mergeout(将小 ROS 容器合并)
SELECT DO_TM_TASK('mergeout', 'public.your_table_name');

为什么这样做: 清理 delete vector 后,ROS 容器中的数据更纯粹,减少查询时的行过滤开销。mergeout 将碎片化的小 ROS 合并为大 ROS,减少文件总数。

4.1.2 对低基数列启用 RLE 编码

这是投入产出比最高的单步优化。找到低基数(distinct 值 < 1000)且出现在 ORDER BY 子句中的列,将编码改为 RLE。

-- 注意:将 'your_table_name' 和 'low_card_column' 替换为实际值
-- 第一步:查看当前编码类型
SELECT projection_column_name, encoding_type, sort_position
FROM v_catalog.projection_columns
WHERE table_name = 'your_table_name'
  AND table_schema = 'public'
  AND sort_position >= 0
ORDER BY sort_position;

-- 第二步:通过 Database Designer 重新分析编码,而非逐个手动修改
-- 对单个投影运行 encoding 分析(不实际修改)
SELECT DESIGNER_DESIGN_PROJECTION_ENCODINGS('public.your_table_name', '/tmp/encoding_design', false, true);

为什么这样做: RLE(Run-Length Encoding)对排序的低基数列压缩比极高。在极端场景中,一个有 5 个 distinct 值的 status 列在运行 RLE 后可以从 500 MB 压缩到 5 MB。而且 Vertica 执行引擎可以在 RLE 编码数据上直接做聚合而不需要物化解压(参考 查询引擎中的 RLE 优化)。

为什么用 DBD 而不是手动改: 逐个修改列的编码类型既繁琐又容易犯错。Database Designer 会基于实际数据采样(默认 1%)分析列的数据特征,给出最优编码组合。手动指定编码只能基于你对数据分布的猜测。

4.1.3 精简分段列

如果 SEGMENTATION 包含 10+ 列,立即创建新的投影,将分段列精简到 2-4 个高基数列。

-- 注意:将 'your_table_name'、'high_card_key1' 等替换为实际值
-- 为宽表创建一个精简分段的新 super projection
CREATE PROJECTION public.your_table_optimized_super
/*+basename(your_table),createtype(L)*/
(
 -- 列出所有列(因为 super projection 必须包含所有列)
 col1, col2, ..., colN
)
AS
 SELECT col1, col2, ..., colN
 FROM public.your_table_name
 ORDER BY high_card_key1, high_card_key2  -- 按常用 JOIN/GROUP BY 键排序
SEGMENTED BY hash(high_card_key1, high_card_key2) ALL NODES;

-- 刷新新投影数据
SELECT REFRESH('public.your_table_optimized_super');

-- 确认新投影数据追平后,删除旧的自动投影
DROP PROJECTION public.your_table_name_b0;
DROP PROJECTION public.your_table_name_b1;

为什么这样做: 分段 hash 是对分段列中所有列做 hash 运算。32 列的 hash 计算成本是 4 列的 8 倍以上。而且分段列太多并不提升分布均匀性,2-4 个高基数(> 100K distinct)列已经足够让数据均匀分布到所有节点。

⚠️ 注意: 删除 super projection 后 your_table_name_b0/b1 需要确保已有替代的 buddy projection pair。如果使用 OFFSET 0,至少需要创建一对 buddy 投影以维持 K-safety。

4.2 短期优化(当周执行)

4.2.1 创建列子集投影

宽表的最大矛盾是:表有 200 列,但 80% 的查询只用到其中 20 列。 创建一个只包含这些「热列」的自定义投影,让多数查询走窄投影。

-- 注意:将 'your_table_name'、'hot_col1' 等替换为实际值
-- 创建一个只包含常查询列的「热列投影」
CREATE PROJECTION public.your_table_hot
/*+basename(your_table_hot)*/
(
 hot_col1, hot_col2, hot_col3,  -- 高频查询列
 join_key,                      -- JOIN 键
 date_key,                      -- 分区/过滤键
 hot_col4, hot_col5
)
AS
 SELECT hot_col1, hot_col2, hot_col3,
        join_key, date_key, hot_col4, hot_col5
 FROM public.your_table_name
 ORDER BY date_key, join_key
SEGMENTED BY hash(join_key) ALL NODES;

SELECT REFRESH('public.your_table_hot');

为什么这样做: 这个投影只有 7 列而非 200 列。Scan 操作符只需要打开 7 个文件句柄而非 200 个,catalog 锁定周期也相应缩短。Vertica 优化器会自动选择投影——如果查询引用的列全部在热列投影中,优化器会选择它而非 super projection。

如何验证优化器选择了正确的投影: 使用 PROFILE 执行查询后,检查 v_monitor.projection_usage

SELECT projection_name FROM v_monitor.projection_usage
WHERE transaction_id = :t_id AND statement_id = :s_id;

4.2.2 为宽表中的特殊列选择合适的编码

并非所有列都适合 AUTO 编码。根据数据特征选择编码,可以在不牺牲查询性能的前提下大幅压缩存储。

编码速查表(基于 vault 已有资源 Vertica 性能调优 - 2 使用系统表排除 Vertica 查询性能故障 §3.10.2 的编码选择指南):

数据特征 推荐编码 典型压缩比 适用场景
低基数(< 1000 distinct)且排序 RLE 50:1 ~ 100:1 状态码、标志位、类别
低基数(< 1000 distinct)未排序 BLOCK_DICTBLOCKDICT_COMP 20:1 ~ 50:1 未排序的枚举列
自增 ID / 连续时间戳 COMMONDELTA_COMP 100:1 ~ 1000:1 序列主键、等间隔时间
窄范围整数 DELTAVAL 5:1 ~ 20:1 年龄、分数、序号
公因数倍数 GCDDELTA 5:1 ~ 20:1 时间戳毫秒(1000 的倍数)
大文本/VARCHAR(>1000) ZSTD_COMPGZIP_COMP 5:1 ~ 10:1 日志、描述、JSON
高基数数值 AUTO(默认) 2:1 ~ 5:1 金额、度量值

关键原则:

  • 排序决定编码效果。 RLE 和 COMMONDELTA_COMP 只有在列按该列排序时才能发挥最大威力。如果列不在 ORDER BY 中,即使改了编码也收益有限。
  • 让 DBD 做决策,而不是凭直觉。 运行 DESIGNER_DESIGN_PROJECTION_ENCODINGS() 让 Vertica 分析实际数据后给出建议。

4.2.3 用分区代替宽表的部分列

宽表中常见的模式是:前 50 列是业务属性、中间 100 列是不同日期的指标值。这种「横向扩展」的宽表在设计上有天然的列式存储劣势。

推荐方案: 将「日期-指标」这一块转置为行存储(即长表),利用 Vertica 的分区和列存优势。

-- 将宽表(每个日期一个列)改为长表(日期+指标值一行)
-- 宽表结构: id, attr1, attr2, metric_20240101, metric_20240102, ...
-- 长表结构: id, attr1, attr2, metric_date, metric_value

-- 长表的优势:
-- 1. 列数固定(5 列),不再是「每加一天就加一列」
-- 2. 按 metric_date 分区,利用存储裁剪
-- 3. 编码只在 1 个 metric_value 列上生效,而非 100 个列上重复配置
CREATE TABLE public.your_table_long (
  id INTEGER NOT NULL,
  attr1 VARCHAR(100),
  attr2 VARCHAR(100),
  metric_date DATE NOT NULL,
  metric_value NUMERIC(18,2)
)
PARTITION BY EXTRACT(year FROM metric_date);

-- 从宽表转置数据到长表
INSERT INTO public.your_table_long
SELECT id, attr1, attr2,
       '2024-01-01'::DATE, metric_20240101 FROM public.your_wide_table WHERE metric_20240101 IS NOT NULL
UNION ALL
SELECT id, attr1, attr2,
       '2024-01-02'::DATE, metric_20240102 FROM public.your_wide_table WHERE metric_20240102 IS NOT NULL
-- ... 继续其他日期列
;

为什么这样做: 宽表到长表的转换看似增加了行数,但列数从 100+ 降到 5-10,ROS 文件数大幅减少,且 Vertica 的列存架构天然擅长对少量列做大规模聚合扫描。

4.3 根本治理论 — 宽表拆分

如果宽表确实需要保留所有列(所有列在业务上都被频繁查询),那么问题不在投影设计而在数据模型设计。此时应该考虑对表进行拆分。

拆分决策矩阵:

场景 拆分策略 预期效果
表有 200+ 列,其中 50 列是「主实体属性」,150 列是「关联指标」 拆为 1 个主表 + 1 个指标表,通过主键 JOIN 列数减半,每个表可以使用适合的编码和投影
按业务域可以自然分组(如用户画像 80 列 + 交易行为 120 列合并为 200 列) 拆为 2 个域表,通过主键 JOIN 列数大幅下降,且各域的查询互不干扰
表中大量列为 NULL(稀疏列) 拆为 1 个稠密表 + 多个「宽表稀疏属性」表 减少 NULL 值存储,提升编码效率
不同列的查询频率差异大(热列 20 个 + 冷列 180 个) 用列子集投影代替拆分(4.2.1),无需改应用 0 应用改造,即刻见效

拆分前务必验证 JOIN 性能: 使用 PROFILE 测试拆分后的 JOIN 查询,确保 Vertica 优化器能利用相同的分段键(identical segmentation)避免数据 Resegment。如果两个表的分段键一致,JOIN 可以在本地节点完成,无需网络重分布(见 Identical Segmentation 原理)。


第 5 节:深入案例

> 📝 虚构案例 · 运营商经分 300 列表的 Catalog 爆炸

场景: 某运营商经分系统,一张「用户日汇总表」包含 300 列(基础属性 50 列 + 日指标 250 列),3 节点 × K-safety=1(2 个 buddy 投影),按天分区,保留 180 天。每天凌晨批量加载约 500 万行。

问题发现: DBA 发现数据库启动时间从 3 分钟逐步增长到 45 分钟,SELECT * FROM v_catalog.projections 耗时从 0.1 秒变成 8 秒。

诊断过程:

-- 检查 catalog 大小
SELECT node_name,
       MAX(catalog_size_in_MB) AS catalog_mb
FROM (
  SELECT node_name,
         SUM((total_memory_max_value - free_memory_min_value)) / (1024*1024) AS catalog_size_in_MB
  FROM v_internal.dc_allocation_pool_statistics_by_second
  WHERE total_memory_max_value > 0
  GROUP BY node_name, TRUNC("time"::TIMESTAMP, 'SS'::VARCHAR(2))
) foo
GROUP BY node_name
ORDER BY node_name;

输出:

    node_name     | catalog_mb
------------------+------------
 v_db_node0001    |      12453
 v_db_node0002    |      11982
 v_db_node0003    |      12117

catalog 超过 12 GB。

-- 定位热点表
WITH proj_stats AS (
  SELECT ps.projection_id, ps.anchor_table_name,
         pi.is_segmented,
         MAX(ps.ros_count) AS max_ros_count,
         CASE WHEN pi.is_segmented
              THEN SUM(ps.row_count)
              ELSE MAX(ps.row_count)
         END AS total_rows,
         SUM(ps.used_bytes) AS total_bytes
  FROM v_monitor.projection_storage ps
  JOIN (SELECT DISTINCT projection_id, is_segmented FROM v_catalog.projections) pi
    ON ps.projection_id = pi.projection_id
  WHERE ps.anchor_table_schema = 'public'
  GROUP BY 1, 2, pi.is_segmented
)
SELECT ps.anchor_table_name,
       COUNT(DISTINCT pc.column_id) AS column_count,
       ps.max_ros_count,
       ps.total_bytes / 1024^3 AS total_gb
FROM proj_stats ps
JOIN v_catalog.projection_columns pc ON ps.projection_id = pc.projection_id
GROUP BY 1, 3, 4
ORDER BY column_count DESC
LIMIT 5;

输出:

    anchor_table_name     | column_count | max_ros_count | total_gb
--------------------------+--------------+---------------+----------
 user_daily_summary       |          300 |           180 |      856
 call_detail              |           85 |            30 |      234
 sms_center               |           42 |            15 |       87

根因分析:

每个节点上 b0 和 b1 两个投影 segment 各含 180 个分区,单节点列数据文件数 = 300列 × 2投影segment × 180分区 ≈ 108,000 个(3 节点共计 324,000 个)。仅这一张表的 catalog 元数据就达到约 3.5 GB(3 × 108,000 × ~3KB/条)。每天新增一批分区,catalog 持续膨胀。

修复方案:

  1. 立即SELECT DO_TM_TASK('mergeout', 'public.user_daily_summary') 减少活动分区中的小 ROS 容器
  2. 短期:创建「热列投影」(50 列),让 80% 的日常查询走窄投影
  3. 根本治理:将 250 个日指标列转置为长表 (user_id, attr_1..50, metric_date, metric_name, metric_value),分区按 metric_date;列数从 300 降到 55

效果对比:

指标 优化前 优化后
Catalog 大小 12 GB 2.8 GB
数据库启动时间 45 分钟 6 分钟
典型日常查询延迟 18 秒 2.3 秒
单节点列数据文件数 108,000 38,000

> 📝 虚构案例 · 金融风控 500 列表的查询超时

场景: 某金融机构风控系统,一张「借款人特征宽表」包含 500 列。每个借款人有 500 个维度的特征,用于机器学习模型评分。每天跑批量评分,需要 SELECT * 扫描全表约 2000 万行。

问题: 批量评分 SQL 频繁超时(30 分钟超时阈值),执行计划显示 Scan 操作符耗时占 95%。

诊断过程:

-- 用 PROFILE 执行一条代表性查询
PROFILE SELECT * FROM public.borrower_features WHERE batch_date = '2026-06-05';

-- 查看执行引擎耗时分布
SELECT operator_name, path_id,
       SUM(CASE WHEN counter_name = 'execution time (us)' THEN counter_value END) / 1000000 AS exec_sec,
       MAX(CASE WHEN counter_name = 'peak file handles' THEN counter_value END) AS peak_fh,
       SUM(CASE WHEN counter_name = 'bytes read from disk' THEN counter_value END) / 1024^3 AS read_gb
FROM v_monitor.execution_engine_profiles
WHERE transaction_id = :t_id AND statement_id = :s_id
GROUP BY 1, 2
ORDER BY exec_sec DESC;

输出:

 operator_name | path_id | exec_sec | peak_fh | read_gb
---------------+---------+----------+---------+---------
 Scan          |       3 |    1420  |    9500 |     156

Scan 操作符打开 9500 个文件句柄,从磁盘读取 156 GB 数据(远超实际需要的 2000 万行 × 500 列 × 8 字节 ≈ 80 GB 估算,说明存在数据碎片问题)。

根因分析:

  • 500 列 × 每节点 19 个 ROS 容器 = 单节点 Scan 管理约 9500 个文件句柄
  • 大量 delete vector(每日更新风控特征导致)叠加,实际扫描数据是有效数据的 2 倍
  • 所有列使用 AUTO 编码,500 列中有 150 个低基数列(< 100 distinct),AUTO 对它们浪费了大量存储

修复方案:

  1. SELECT PURGE_TABLE('public.borrower_features') 清理 delete vector
  2. 运行 DESIGNER_DESIGN_PROJECTION_ENCODINGS() 让 DBD 重新分析编码

    • 150 个低基数列从 AUTO 改为 RLE,存储从 180 GB 降到 12 GB
    • 实际磁盘读取从 156 GB 降到 42 GB
    • 创建「评分用热列投影」:只包含模型实际使用的 80 列而非全部 500 列
    • 将每日增量更新改为分区交换(SWAP_PARTITIONS_BETWEEN_TABLES):先向临时表加载新数据,再与原表做分区交换,避免产生 delete vector

效果对比:

指标 优化前 优化后
批量评分耗时 > 30 分钟(超时) 4.2 分钟
peak file handles 9500 520
磁盘读取 156 GB 42 GB
表存储大小 320 GB 68 GB

> 📋 真实案例 · 表约束导致额外 projection 增加宽表开销

来源:表约束产生的projection问题

场景: 某金融机构某业务表添加 UNIQUE 约束后,Vertica 自动创建了额外的约束相关 projection,导致该表的 projection 数量增加,在宽表场景下 catalog 开销进一步扩大。

关键发现:

  • 启用约束才会产生约束相关 projection
  • 如果约束字段已经位于 projection 排序分段字段的前面,则不会另外产生 projection
  • 添加约束前,可手动创建一个排序分段仅包含约束字段、或将约束字段放在排序分段前几位的 projection,避免自动产生额外 projection

应对策略: 在宽表上添加约束之前,先确认现有的 super projection 的 ORDER BY 首列是否覆盖了约束字段。如果已覆盖,则不会产生额外 projection。这可以避免宽表因约束而产生更多 projection,进一步放大 catalog 开销。


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

📝 虚构场景 · 完整演练

背景: 某企业数据仓库的 ETL 开发团队反映「最近所有的表查询都变慢了」。你是 Vertica DBA,需要从零开始排查。

时间线

时间 动作 发现
09:00 用户反映查询变慢 对比上周同时间点,平均查询延迟从 3 秒升到 35 秒
09:15 检查系统级指标 CPU 40%,内存 60%,I/O 等待 8%——看不出问题
09:30 检查 catalog 大小 8.5 GB,比上周的 3.2 GB 增长了 2.6 倍

排查步骤

Step 1 — 确认 catalog 膨胀:

SELECT node_name,
       MAX(catalog_size_in_MB) AS catalog_mb
FROM (
  SELECT node_name,
         SUM((total_memory_max_value - free_memory_min_value)) / (1024*1024) AS catalog_size_in_MB
  FROM v_internal.dc_allocation_pool_statistics_by_second
  WHERE total_memory_max_value > 0
  GROUP BY node_name, TRUNC("time"::TIMESTAMP, 'SS'::VARCHAR(2))
) foo
GROUP BY node_name
ORDER BY node_name;
    node_name     | catalog_mb
------------------+------------
 v_etl_node0001   |       8720
 v_etl_node0002   |       8450
 v_etl_node0003   |       8510

判断: catalog 8.5 GB。

Step 2 — 找到最近的变更:

SELECT table_schema, table_name, create_time
FROM v_catalog.tables
WHERE create_time > CURRENT_TIMESTAMP - INTERVAL '7 days'
ORDER BY create_time DESC
LIMIT 10;

发现 3 天前创建了 etl_wide_export 表。

Step 3 — 分析该表的列数和存储:

WITH proj_stats AS (
  SELECT ps.projection_id, ps.anchor_table_name,
         pi.is_segmented,
         MAX(ps.ros_count) AS max_ros,
         CASE WHEN pi.is_segmented
              THEN SUM(ps.row_count)
              ELSE MAX(ps.row_count)
         END AS total_rows,
         SUM(ps.used_bytes) AS total_bytes
  FROM v_monitor.projection_storage ps
  JOIN (SELECT DISTINCT projection_id, is_segmented FROM v_catalog.projections) pi
    ON ps.projection_id = pi.projection_id
  WHERE ps.anchor_table_name = 'etl_wide_export'
  GROUP BY 1, 2, pi.is_segmented
)
SELECT ps.anchor_table_name,
       COUNT(DISTINCT pc.column_id) AS column_count,
       ps.max_ros,
       ROUND(ps.total_bytes/1024^3::NUMERIC(10,2), 2) AS total_gb
FROM proj_stats ps
JOIN v_catalog.projection_columns pc ON ps.projection_id = pc.projection_id
GROUP BY 1, 3, 4;
  anchor_table_name | column_count | max_ros | total_gb
--------------------+--------------+---------+----------
 etl_wide_export    |          380 |     320 |     1250

判断: 380列 × 每投影segment 320 个 ROS 容器 × 2 投影segment = 每节点约 243,200 个列数据文件。仅这一张表就贡献了约 3.5 GB 的 catalog 增长。

Step 4 — 根因确认:

ETL 开发人员创建了 CREATE TABLE etl_wide_export AS SELECT * FROM ... 将 30 多个源表的所有列合并到一张宽表中,用于下游数据导出。每天有 48 批小批量加载(每 30 分钟一次),3 天内积累了 320 个 ROS 容器。

Step 5 — 修复:

  1. 将导出任务改为只 SELECT 下游实际需要的 50 列(而非 380 列)
  2. 删除 etl_wide_export 表,用 VIEW 代替(VIEW 不实际存储数据)
  3. 如果导出确实需要物化中间结果,创建只含 50 列的物化表
  4. 对物化表使用 DIRECT 加载(COPY ... DIRECT)减少 ROS 容器碎片

效果: Catalog 从 8.5 GB 回落到 3.6 GB,查询延迟恢复到 3 秒以内。


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

诊断目标 SQL 关键阈值
查看 catalog 峰值 SELECT node_name, MAX(catalog_mb) FROM (SELECT node_name, SUM((total_memory_max_value-free_memory_min_value))/1048576 AS catalog_mb FROM v_internal.dc_allocation_pool_statistics_by_second WHERE total_memory_max_value>0 GROUP BY 1, TRUNC("time"::TIMESTAMP,'SS')) foo GROUP BY 1 ORDER BY 2 DESC > 15 GB 偏大,> 20 GB 严重
找出列数最多的表 SELECT p.anchor_table_schema, p.anchor_table_name, COUNT(DISTINCT pc.table_column_name) AS col_cnt FROM v_catalog.projections p JOIN v_catalog.projection_columns pc ON p.projection_id = pc.projection_id WHERE p.is_super_projection GROUP BY 1,2 ORDER BY col_cnt DESC LIMIT 20 > 100 列关注,> 200 列严重
列数×ROS容器数热点 SELECT ps.projection_name, COUNT(DISTINCT pc.column_id) col_cnt, MAX(ps.ros_count) max_ros FROM v_monitor.projection_storage ps JOIN (SELECT DISTINCT projection_id, is_segmented FROM v_catalog.projections) pi ON ps.projection_id=pi.projection_id JOIN v_catalog.projection_columns pc ON ps.projection_id=pc.projection_id GROUP BY 1 HAVING COUNT(DISTINCT pc.column_id) > 50 ORDER BY COUNT(DISTINCT pc.column_id)*MAX(ps.ros_count) DESC LIMIT 20 乘积 > 5000 关注
每列存储占用和编码 SELECT cs.anchor_table_column_name, cs.encodings, SUM(cs.used_bytes)/1024^2 AS total_mb, MAX(cs.ros_count) max_ros FROM v_monitor.column_storage cs WHERE cs.anchor_table_name = ':t_name' GROUP BY 1,2 ORDER BY total_mb DESC 单列 > 1 GB 且编码为 AUTO 应检查
检查分段列数 SELECT p.projection_name, p.segment_expression, COUNT(pc.sort_position) sort_cols FROM v_catalog.projections p JOIN v_catalog.projection_columns pc ON p.projection_id=pc.projection_id WHERE p.anchor_table_name=':t_name' AND pc.sort_position>=0 GROUP BY 1,2 分段列 > 8 列应精简
查询文件句柄峰值 SELECT transaction_id, MAX(CASE WHEN counter_name='peak file handles' THEN counter_value END) peak_fh FROM v_monitor.execution_engine_profiles WHERE counter_name IN ('peak file handles', 'execution time (us)') GROUP BY 1 HAVING MAX(CASE WHEN counter_name='peak file handles' THEN counter_value END) > 100 ORDER BY peak_fh DESC LIMIT 20 > 1000 偏高,> 5000 危险
检查 delete vector 占比 SELECT sc.projection_name, SUM(sc.deleted_row_count)/NULLIF(SUM(sc.total_row_count),0)*100 AS del_pct, SUM(sc.disk_size)/1024^3 disk_gb FROM v_monitor.storage_containers sc JOIN v_catalog.projections p ON sc.projection_id=p.projection_id WHERE p.anchor_table_name=':t_name' GROUP BY 1 ORDER BY del_pct DESC > 20% 需要 PURGE
查看查询使用哪些投影 SELECT projection_name FROM v_monitor.projection_usage WHERE transaction_id = :t_id AND statement_id = :s_id 确认走的是热列投影还是 super projection
单节点文件数(Linux) find /data/vertica/data -type f\|wc -l > 100 万需要关注 catalog 大小

第 8 节:最佳实践清单

按投入产出比从高到低排序:

  1. 永远不要用 SELECT * 查询宽表 — 这是投入最小、产出最高的优化。在宽表上 SELECT * 相当于打开所有列的文件句柄并扫描所有列的磁盘数据。应用层明确列出需要的列名,就能避免 90% 的宽表性能问题。
  2. 让 Database Designer 选择编码,不要手动猜 — 运行 DESIGNER_DESIGN_PROJECTION_ENCODINGS() 基于 1% 实际数据采样自动确定每列最优编码。手动选择编码容易在大量列面前出错,且 DBD 会考虑列之间的编码互作用。
  3. 精简分段列到 2-4 个高基数列 — 默认的 32 列 hash 分段在宽表上既浪费 CPU 又不提升分布均匀性。选择 DISTINCT 值 > 100K 的列做分段键,2-4 个足够。
  4. 低基数列 + 在 ORDER BY 中 = RLE 编码 — 这是 Vertica 列存压缩的「杀手锏」。一个只有 10 个 distinct 值的列在 RLE 下可以从 1 GB 压缩到 10 MB,且 Vertica 可以在编码数据上直接做聚合。
  5. 用列子集投影覆盖高频查询,而不是让所有查询走 super projection — 如果 80% 的查询只用到 20% 的列,创建一个只含这 20% 列的投影。文件句柄从 200 降到 20,Scan 时间可减少 50% 以上。
  6. 用分区代替宽表的「横向日期列」 — 如果宽表中有 metric_20240101, metric_20240102, ..., metric_20241231 这 365 列,立刻转置为长表并按日期分区。列数从 400+ 降到 50+,且分区裁剪自动跳过不需要的日期。
  7. 定期 PURGE() 清理 delete vector — delete vector 随时间累积会显著增加宽表的扫描开销。对于按分区做数据生命周期管理的表,用 DROP_PARTITIONS 取代 DELETE 可以完全避免 delete vector。
  8. 监控 ROS 容器数,不要让它超过 500 — 每个节点每投影的 ROS 容器数不应超过 500。超过这个阈值意味着 Tuple Mover 没有及时合并,或者分区粒度过细。使用 DO_TM_TASK('mergeout') 主动触发合并。
  9. 添加约束前确认投影 ORDER BY 已覆盖约束字段 — 宽表上每多一个额外的投影,ROS 文件就翻倍。参照 表约束产生的projection问题 的方式,先确认 ORDER BY 首列是否覆盖约束字段,避免自动产生额外投影。
  10. Catalog 超过 15 GB 时主动诊断 — 不要等到 Catalog 20 GB+ 才处理。15 GB 是预警线,此时应运行本文第 2 节和第 3 节的诊断 SQL,定位是哪些表在驱动 catalog 膨胀。

扩展阅读