关于 mz-optimize-memory
用于测量并降低 Materialize 计算集群内存占用的技能,强调先征求许可,再依据证据诊断和验证变更,最后调整副本规格;不用于排查数据新鲜度问题。
主要功能
- 01基于测量的内存诊断
- 02考虑水合状态的内部数据读取
- 03在专用集群中进行实验
- 04读取数据或变更前征求许可
- 05验证优化并调整副本规格
适用场景
- 01排查 Materialize 集群内存配置过大的情况
- 02集群水合后评估内存使用
- 03在生产环境之外测试拟议变更
MCP 相关
可用时通过 Materialize MCP 开发端点执行集群查询,并在 `query` 工具中指定目标集群及所需副本。变更在 MCP 之外执行。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ materializeinc/ agent-skills --skill mz-optimize-memory安装后在 Claude Code 中输入/mz-optimize-memory确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
DEVELOPMENT.mdreferences/hint-sizing.mdindexes.mdouter-joins.mdsubqueries.mdwindow-functions.md详细介绍
优化集群内存
降低 Materialize 计算集群的内存开销,每项结论都必须有测量结果支撑。一次优化工作的交付内容包括:逐项给出判定并附上测量证据、使用用户自己的部署工具验证变更,以及最终调整到合适大小的副本。释放内存并不等于节省了费用,直到副本缩容才算。
对于新鲜度问题(对象落后于当前时间),请改用 mz-debug-freshness 技能。内存优化和新鲜度优化使用相同的工具,但工作流程不同。
基本规则
- 在进行任何更改之前,以及读取用户数据之前,都必须先征求同意。读取目录、内省信息和 EXPLAIN 结果无需许可;对用户的关系执行 SELECT(基数探测、匹配率查询、精确性证明)会读取其数据,必须事先征求同意。任何会在用户环境或其机器上创建、修改、删除对象或写入行的操作(包括本地 Docker 容器中的操作),都必须先提出方案,并且只有在用户同意后才能执行。批准针对的是一类更改,而非每条语句:获批的实验集群可用于构建和删除候选对象;任何超出此范围的操作都需要重新征求同意。
- 即使获准进行实验,也绝不在生产集群上更改对象。对象定义通常由 Materialize 之外的来源管理(mz-deploy、dbt 或部署脚本);仅修改目录中的对象会在下次部署时被悄悄还原,或只还原一部分。接受的更改应通过编辑该来源来交付;如果目录本身就是事实来源,则交付 DDL 供用户运行。
- 实验必须在用户创建的专用实验集群上运行(由你提供命令)。绝不在生产集群上构建或重建候选对象:在那里构建的对象会在生产副本上进行水合,使生产资源在实验中消耗内存和 CPU。对生产环境执行只读的
EXPLAIN ANALYZE和内省查询属于诊断,可以进行;任何会触发水合的操作都必须在实验集群上进行。 - 在任何情况下,都不要建议将物化视图的
REFRESH EVERY用作优化手段。 - 每项判定都必须有测量结果支撑:测量两侧的大小、从计划中读取连接键、统计使用者数量。仅凭图形结构或计划文本作出的论据不能作为判定;估算可以决定是否进行实验,但不能作为最终结论:共享安排会以静态的每行字节数计算(第 5 步的宽度计算)无法预见的方式重构连接计划,因此估算可能偏高也可能偏低。对于任何非平凡的索引或边界判定(边界:位于某个集合及其键位置、供多个使用者共享的索引视图),上线前都必须先进行构建测量。
- 检查的默认结论是保持不变,并且要记录在笔记中。检查多种情况(测量结果正确的提示、精确的 min/max 或 TopK,其安排正是为处理撤回而必须保留的输入副本,以及不适用任何特征的单副本安排);只更改测量结果所指向的内容,并仅汇报优化机会。没有此类特征支撑的更改就是无谓折腾:重建却没有测得节省。只有在用户询问了某个条目(提案条目、命名对象)或需要解释剩余占用时(例如解释副本为何无法进一步缩容),才说明某项内容保持不变。
- 按置信度分批处理。至少有约 97% 把握的更改(经
mz_expected_group_size_advice测量后确定的提示调优、没有计划读取的索引删除、计划证明未读取的列删除)可以合并成一批;把握较小的更改则应单独应用,并在下一项更改前重新测量,以便将每项节省归因到具体更改,并让交互影响(例如新索引重构了相邻对象的计划)单独呈现为一个数值。无论如何,都要记录每项更改的估算值,以及每批更改的测量结果。
连接与工具
如果可用,优先使用 Materialize MCP 开发者端点。它的工具会自我描述;对于此技能,重要的是:每个绑定集群的读取(EXPLAIN ANALYZE、mz_introspection、用户对象的 EXPLAIN)都要通过 query 工具执行,并传入集群参数;在多副本集群上,还要传入 cluster_replica 参数(包括任何额外副本,例如 Cloud 上不计费的支持副本)。该端点的另一个工具 query_system_catalog 在这里是个陷阱:它不会拒绝这些语句(mz_introspection 关系会通过它的名称检查),并会针对会话的默认集群回答这些语句:如果那里恰好只有一个副本,答案看起来有效(通常是空结果,或是另一个集群的数字),且不会报错;如果有多个副本,则会报错 log source reads must target a replica;如果没有副本,则会报错 has no replicas available to service request。切勿通过该工具发送绑定集群的读取。工具无法执行任何 SET,因此集群始终是参数,而不是会话设置。对于 v26.40 之前的服务器,端点自身的说明要求绝不查询 mz_introspection.mz_dataflow_arrangement_sizes;该规则早于 query 工具,并且仅针对目录工具(较新的服务器会自行说明这一点),因此不适用于传入集群参数的 query(该关系的 id 与什么关联,见“读取 introspection”)。工具响应是没有列名的裸行数组,数字以字符串形式返回,默认每个响应上限为 1 MB、每个请求上限为 60 秒(这两项都是环境级设置,操作员可以调整);因此应保持 SELECT 列表简短且顺序固定,并为清点查询设置 LIMIT 或使用聚合,而不是列出大型集群中的每个 arrangement(LIMIT 限制的是结果,而不是 join 的工作量)。请求超时会取消查看,但有时不会取消工作:其 dataflow 可能会在集群上运行至完成,因此不要原样重发。时间戳以自纪元以来的毫秒数返回,除非转换为 text;会话数据库之外的对象名称需要加上数据库前缀,否则如果会话数据库中存在同名 schema,语句会报错 unknown catalog item;如果不存在,则会报错 unknown schema。
如果未配置 MCP 服务,或其缺少 query 工具(较旧的 Materialize,或被操作员禁用;query 需要 v26.30,cluster_replica 参数需要 v26.33),建议用户进行配置(mz-health-check 技能介绍了客户端配置),或改为请用户提供 SQL 连接字符串。每当请求连接字符串时,都要提醒用户:该字符串应对应于权限范围仅限当前阶段所需操作的角色(诊断阶段为只读;实验角色见下文“进行更改”),绝不能是管理员或超级用户。依赖该权限范围前,先确认它确实生效:SHOW enable_rbac_checks。Cloud 默认强制执行 RBAC,而自托管实例默认设置为 enableRbac: false,模拟器镜像也会关闭这些检查;关闭检查后,用户对象上的 GRANT 和所有权门槛将不再强制执行,因此受限角色在此处无法提供任何保护(系统对象上的门槛以及下文的 restrict_to_user_objects 是独立的,仍然适用)。还要请用户确认该角色未设置 restrict_to_user_objects,因为这会隐藏系统目录,并导致 EXPLAIN ANALYZE 无法处理该角色不拥有的对象,即使 RBAC 检查已关闭也如此。
所有写入操作都在 MCP 之外执行。“进行更改”部分定义了由谁执行以及在哪里执行。
读取内部状态
适用于所有内部状态读取的规则,无论通过 MCP 还是 SQL:
- 只测量已完成 hydration 的数据流。 内部状态(包括
EXPLAIN ANALYZE和mz_introspection读取的所有内容)会随着 hydration 完成而填充:仍在进行 hydration 的数据流会返回空值或部分数值,而返回的数值反映的是追赶过程,不是稳态。每次测量前都要检查mz_internal.mz_hydration_statuses(由控制平面维护,因此即使副本正忙也能查询)。在一次排查过程中执行的每次重建后,同样适用此规则:重建,等待 hydration 完成,然后重新测量。副本崩溃后,该关系可能会短暂返回上一代副本的行,因此副本恢复后要重新读取一次,再相信true。对于完全无法完成 hydration 的集群,请参见“Intake: a cluster that cannot hydrate”。 - 内部状态由目标集群提供,并且作用域限于会话:先执行
SET cluster = ...(或使用 MCPquery工具的集群参数)。如果集群有多个副本,还要指定一个副本(执行SET cluster_replica = ...,或使用工具的cluster_replica参数):否则,多副本集群上的内部状态读取会直接失败(“log source reads must target a replica”);不同副本上的读数也可能不同,而且你测量的副本本身必须已完成 hydration。 - 查询结果可能会比 DDL 延迟几秒;如果更改后查询立即返回空行,请稍等并重新运行,再得出结论。
mz_introspection.mz_dataflow_arrangement_sizes.id是数据流 ID(uint8),绝不能与mz_objects.id联接:通过mz_introspection.mz_compute_exports(从dataflow_id到export_id)访问目录,或者匹配name(Dataflow: <db>.<schema>.<object>,即创建对象时使用的名称,而不是当前名称:蓝绿部署或 dbt 运行会以暂存名称创建对象,然后再交换,因此数据流会保留暂存名称,只有导出路径才能找到它)。- 文档中的数据流故障排查页面
(https://materialize.com/docs/transform-data/dataflow-troubleshooting/)
包含本技能所依据的总体统计和逐运算符查询,以及这里不会重复介绍的 CPU 侧检测手段(
mz_scheduling_elapsed、mz_compute_operator_durations_histogram、mz_dataflow_addresses区域遍历);数据新鲜度页面 (https://materialize.com/docs/transform-data/freshness-troubleshooting/) 介绍了延迟问题。
排查:无法完成数据加载的集群
用户通常遇到的是一个不断发生 OOM 的集群,而不是一个稳定但规模过大的集群。不要对陷入 OOM 循环的副本启动测量工作流:在数据加载过程中崩溃的副本无法提供可用的测量结果(“读取内省数据”)。先通过两个控制面关系确认状态,即使副本不断重启,它们也能给出答案:
mz_internal.mz_hydration_statuses 在副本重启期间会持续报告 hydrated = false(单次读取到 false 只能说明仍在加载;如果集群在 mz_cluster_replicas 中没有副本行,状态也会保持为 false,此时 replica_id 为 NULL),而 mz_internal.mz_cluster_replica_status_history 会确认原因是 MEMORY(状态为 offline 且原因是 oom-killed 的行涵盖所有内存类限制,包括 RAM、堆和溢出磁盘,但不会说明具体是哪一种;任何其他故障的原因都为 NULL;当前的 mz_cluster_replica_statuses 往往会在两次终止之间显示 online,因此无法用于确认;模拟器的进程编排器不会记录原因,因此应统计其中的 offline 行)。第二项检查很重要,因为计算负载过重(例如跨连接或键倾斜导致某个工作进程持续运算数小时且内存保持平稳:下文的二次连接形态;只要副本仍能响应,按工作进程汇总的 mz_introspection.mz_scheduling_elapsed_per_worker 就能显示那个繁忙的工作进程)或输入资源不足,也会导致集群无法完成数据加载,而扩大副本规模无法解决这些问题。只有当集群上的所有对象都已完成数据加载,且每个进程最新的历史记录行为 online 时,循环才算结束,测量才有意义。数据加载完成后仍反复遭到终止,就是本技能要处理的稳定运行时情形;只有在集群持有的状态更少(见下文的调节手段)或副本规模增大后,这种情形才会结束。
提供两种由用户执行、以达到可测量状态的方式:
- 使用较小的输入数据集进行开发,直到优化好数据流水线,然后再扩大数据规模。
- 暂时将集群(或其克隆)的规模调大到足以完成数据加载而不会发生 OOM,在那里测量并优化,然后再调整到合适的规模。过大的副本是测量平台,而不是解决方案。
成本模型
高层模型:
- 索引和物化视图由持久化数据流进行增量维护,在输入发生变化时更新其结果。这就是它们需要持续占用内存的原因:运算符必须保留足够的状态,以便响应任何输入变化。
- 大部分保留状态都位于安排(ARRANGEMENTS)中:按键组织的内存行集合,会随输入变化而维护。安排的类型决定了哪些方式可以触达它。索引(INDEX)安排可以共享:
CREATE INDEX会维护被索引集合的安排,供其他数据流读取,但仅限于该索引所在的集群。其他安排都属于数据流内部:它们是私有工作状态,仅对创建它们的数据流可见。 - 数据流内部安排位于运算符的输入和输出上。运算符的输入安排(INPUT)要么是显式计划节点,要么在运算符内部构建且没有自己的节点;两者都会显示在
EXPLAIN ANALYZE MEMORY中。显式节点在 EXPLAIN 中有三种写法:EXPLAIN OPTIMIZED PLAN中的ArrangeBy;EXPLAIN PHYSICAL PLAN中每个键对应一行arrangements[i](详细文本,该语句的默认输出);以及裸EXPLAIN <object>或EXPLAIN PHYSICAL PLAN AS TEXT打印的箭头文本中的Arrange (key)。某些运算符可以重用其输入上已有的安排(索引,或上游运算符已安排的输出),而不是自行构建;某些运算符则会让其输出保持已安排状态,以便同一数据流中的下一个运算符重用。重用仅限于数据流内部;要跨数据流共享,始终需要索引。这两项属性——重用输入安排和输出保持已安排状态——决定了索引可以替代什么;下方的运算符表会逐个列出这些属性。 - 只有保留状态的运算符才会占用内存。联接的输出是普通的、未安排的集合,因此“此联接输出了 500M 行”本身并不代表内存开销。应查看输出的后续用途:分层 MIN/MAX、TopK 和显式安排都会保留状态。可累积聚合(sum/count/avg)会折叠为每组累加器,开销很低。
- 并非所有保留的内存都是安排状态:每个物化视图末尾的持久化 sink 都有自己的缓冲区;该缓冲区在水合时最大,之后会逐渐排空,因此只有在水合期间才能观测到这部分峰值。重启副本可以再次触发水合。请参阅下方的“安排之外的内存”。
- 普通 VIEW 是已保存的查询,而不是已计算的对象:每个使用它的数据流都会内联该视图的定义,并由优化器在该使用者的上下文中重新规划,只保留该使用者需要的列。五个数据流使用同一个视图,意味着其运算符和安排会有五份独立副本。这就是为什么在数据流清单中找不到 SQL 中的大视图:它的开销会以内联形式出现在每个使用者中,也正因如此,分析已测量的安排比阅读视图 SQL 更有价值。两种对象可以停止内联,让视图只计算一次,但产物不同:
- 视图上的 INDEX 会在每个集群中计算一次视图,其产物就是一个安排,同一集群中的使用者可直接读取,包括联接;
- MATERIALIZED VIEW 会在全局计算一次视图并持久化结果,任何集群都可读取,但持久化结果不会以安排形式结束:每个使用它的数据流都会按所需的键安排读取的内容。 因此,MV 会去重计算,但不会去重安排状态;在每个读取集群上为 MV 建立索引,可以为按该索引键读取它的使用者补上缺少的部分:用一个共享安排取代每个使用者各自的一份私有副本。
- 索引自身的数据流可能持有比它导出的安排更多的安排:为计算出的视图建立索引时,也会计算该视图,并产生计算所需的数据流内部安排。导出的安排只是可共享的产物,并非整个数据流的内存占用。
运算符级详情
按物理计划运算符列出;无论输入布局是作为节点打印出来还是在内部构建,都会计入:
| 运算符 | 输入布局 | 是否复用现有输入布局 | 输出是否已排序且可复用 | 内存 |
|---|---|---|---|---|
| Get、Constant、Map/Filter/Project、FlatMap、Negate | 无 | 读取时会经由一个布局读取,但不会持有它 | 否 | 自身不占用内存 |
显式布局节点(ArrangeBy / Arrange,拼写形式见上文) | 按它打印的每个键构建一个布局(默认文本:Arrange (key),每个键各有一个 (key);详细模式:每个键各有一行 arrangements[i]);打印为 Unarranged Raw Stream 的节点(详细模式下为 raw=true,且没有 arrangements 行)不构建任何布局,只将现有布局作为普通集合传递 | 是,且不会明确标出:它只列出自己构建的布局;输入已有的键会直接传递,不会列出;如果节点的所有键都已存在,该节点就会从计划中省略 | 是,每个构建的键对应一个 | 每个构建的键所需内存为行数乘以宽度;原始流形式不占用内存 |
Join,type=differential(线性) | 每个阶段的两个输入都已布局:输入关系会复用现有布局,或通过打印出的 Arrange 构建;运行中的中间结果是第一个阶段之后每个阶段的一侧,并在内部布局(JoinStage) | 是 | 否 | 输入关系加上每个中间结果 |
Join,type=delta | 每个输入关系都按其探测所用的每个键布局:会复用现有布局,或通过打印出的 Arrange 构建;中间结果不会布局 | 是 | 否 | 仅输入关系,不含中间结果 |
| Reduce | 内部按分组键布局(层级变体按(哈希值、分组键)布局) | 否 | 是,按分组键布局 | 见下文 Reduce |
Distinct(SELECT DISTINCT 或仅含键的 GROUP BY;在优化后的计划中为 Distinct,在详细物理计划中为 Reduce::Distinct,在箭头文本和 EXPLAIN ANALYZE 中为 Distinct GroupAggregate) | 内部按所有列布局(Arranged DistinctBy) | 否 | 是,按整行布局 | 每个分组约两个记录,即输入加输出;仅多重性 |
| TopK | 内部在每个层级按(哈希值、分组键)布局 | 否 | 仅 Monotonic Top1 | 非单调模式:每个层级的输入,加上每个层级的简化布局;MonotonicTopK:一个阶段,更新流动期间每个分组约占用(工作线程数 x limit)条记录,静止后接近 limit 条记录,另加一个简化布局;Monotonic Top1:每个分组一条记录 |
| Threshold | 按整行(按顺序排列的所有列)布局,仅由物理计划打印 | 理论上可以,实际上从不复用(其输入是 Union) | 是,按整行布局 | 一个整行布局,加上输出 |
| Consolidating Union、LetRec 边界、单调输入合并 | 不布局;改用合并批处理器 | 否 | 否 | 临时占用,见下文 Consolidation |
| Persist sink(MV 输出,EXPLAIN 中不显示) | 不布局;改用校正缓冲区,记录为 mv_sink(<id>)::write | 否 | 否 | 见“布局之外的内存” |
Reduce。 Reduce 会按自身布局(分组键加聚合参数)来整理输入,因此它从不使用现有布局,也没有任何索引可以替代该输入布局:即使按分组键建立了索引,也只会作为全表扫描读取;而 Reduce(包括简单的 count(*))仍会构建自己的布局(已测量)。该输入布局持有的内容取决于聚合类别:
- 可累加聚合(sum、count、avg)会在差异中携带累加器,且累加器的值为空,因此输入会合并为每个分组一条记录,而编排后的输出会再增加一条:每个分组约两条记录,无论输入多少行。每行成本低,但成本取决于分组数量:一个累加器(
count、sum)对应的这对记录每个分组约占 150 字节,avg(两个累加器)约占 300 字节,因此分组数量非常多且每组很小时,可累加聚合就不再便宜。不要用generate_series测试来校准这个数值:当各分组完全相同时,累加器存储得很紧凑,测得的数值会低十倍。 - 普通的
SELECT DISTINCT或仅按键GROUP BY就是上面的 Distinct 行。聚合级 DISTINCT(count(DISTINCT v))则会增加一个大小按分组数乘以不同值数计算的编排对。DISTINCT ON根本不是 Reduce,而是limit=1的 TopK。 - 基本聚合(
string_agg、jsonb_agg以及其他不可累加、非层次化的聚合)会保留分组中每条符合条件的输入行,并将其精简为聚合的参数列。精简后的相同行会合并为一条带有重数的记录,因此记录数是每个分组中不同的(分组键、参数)元组数,而不是行数:低基数的参数列会让大分组变小。 - 非单调 min/max(分桶的层次化实现)会以同样方式保留并精简完整输入;精确的增量极值聚合无法保留更少内容,因为当前极值被撤回时,必须能根据保留的内容给出答案。随后,层次结构会为每个桶级增加两个编排(
Arranged MinsMaxesHierarchical input和Reduced Fallibly MinsMaxesHierarchical);每个桶数超过分组所保留记录数的级别,都会持有一份几乎完整的输入副本,而这正是 GROUP SIZE 提示所要缩减的内容。单调 min/max(仅追加输入)则不会有此问题,并会将获胜值放在差异中,每个分组一条记录;无论定义如何,MV 都永远不会单调(其 sink 可能会撤回数据),因此只有直接读取仅追加源的数据流才能使用这种实现。
混合了不同类别的 GROUP BY 会被规划为多个独立的 Reduce(一个处理可累加聚合,一个处理层次化聚合,每个基本聚合各一个,例如 string_agg 或 jsonb_agg),再按分组键连接;该连接会直接读取各 Reduce 的输出编排,不会增加自己的编排。
融合进 Reduce 的 Map/filter/project 工作会让输出保持编排状态(Fused with Child Map/Filter/Project 这一行也会打印在 Get 和 FlatMap 节点下,因此仅凭这一行不能判断存在编排);如果投影会输出映射列,则不会发生融合,例如 avg 就是如此,而 Reduce 上方保留的 Mfp 会让下一个使用方需要新建编排。
TopK。 非单调实现会按每个层次级别编排输入(在 mz_arrangement_sizes 中显示为 Arranged TopK input),并为每个级别保留一个缩减后的编排(Reduced TopK input),按(哈希、分组键)作为键;缩减后的编排会保留层次结构不会输出的行的撤回记录,这些记录总共只保留一份,而不是每个级别各保留一份,并集中在最后几个级别;当分组大小超过 limit 时,这些级别中的记录几乎是完整的输入,而当分组大小未超过 limit 时则为空。与 Reduce 一样,任何 TopK 实现都不会使用已有编排,因此索引无法替代其输入编排;也只有 Monotonic Top1 会让结果保持编排状态,因此在其他两种实现上执行连接时会打印 Arrange。MV 的输出永远不会单调(参见 Reduce)。
Join 实现。 对两个以上输入执行的 DIFFERENTIAL join 会运行一系列二元 join,其中间结果是常驻的 arrangement,其大小与中间结果成正比,可能远超输入和输出。DELTA join 不维护中间结果:它会将每个输入的更新与其他输入现有的 arrangement 流式匹配,因此其内存成本恰好等于输入 arrangement 的内存成本,而共享索引可以提供这些 arrangement。在 EXPLAIN ANALYZE MEMORY 中查看时,Differential Join 运算符行显示的内存正是可消除的中间状态;输入 arrangement 则是其下方单独的 Arrange 行,两种实现都会保留这些 arrangement。计划选用哪种实现(type=differential 与 type=delta)往往比 join 的规模更重要,而且可以改变:将输入 arrangement 作为索引提供,就能让规划器选择 delta join(“将 differential join 切换为 delta”,references/indexes.md)。
合并。 合并型 Union(显示为 Consolidating Union;当 Union 的任一输入是 Negate 时,规划器会将其标记为合并型,因此 EXCEPT 和外连接的下推都包含这种 Union)、LetRec 循环边界处的合并,以及单调 reduce 和 TopK 的输入合并,都会将更新保留在合并批处理器中,直到每条更新的时间戳完成,然后发出这些更新且不保留任何内容:没有维护的 trace。其内存仍会显示在 mz_arrangement_sizes 中(批处理器的记录数和字节数会汇总到合并运算符 ID 对应的 records 和 size 列中:Union 对应 UnionConsolidation,LetRec 边界对应 LetRecConsolidation,单调运算符对应 Consolidated ReduceMonotonic input、Consolidated MonotonicTopK input 和 Consolidated MonotonicTop1 input),并且该行会在批处理器排空后消失。通常这只是水合期间的瞬态状态。如果时间过滤器向合并运算符提供数据,它就会成为常驻状态:每个带有未来时间戳的撤回都会被暂存,直到其时间到达;只要行仍然存在,就会为其保留一条记录。近期更新(位于 compute_temporal_bucketing_summary 时间范围内,默认值为 2 秒)存放在消费运算符的批处理器中,其余更新则存放在紧邻该运算符之前的独立 Temporal delay 运算符中(时间分桶);该运算符也会使用自己的运算符 ID,以相同方式记录这些更新。Materialize Cloud 默认启用时间分桶;从 v26.47 开始,自托管版本和模拟器也默认启用。更早的自托管版本和模拟器默认不启用,除非设置了 enable_compute_temporal_bucketing:此时没有 Temporal delay 运算符,每条暂存的撤回都存放在消费运算符的批处理器中。
超出 arrangement 的内存
这在水合期间尤为重要。每个物化视图末尾的 persist sink 都会将更新暂存在更正缓冲区中,直到写入为止。水合期间,该缓冲区会容纳整个 MV 的快照(直到快照完成才会写入任何内容);已写入的副本也会一直保留,直到 persist 将其读回,因此 sink 的峰值内存为 MV 输出的两份副本:其大小取决于 MV 发出的数据量,而不是读取的数据量或数据流自身的 arrangement;后者会显示在同一统计表的其他行中。对现有 MV 进行重新水合时,峰值也会通过另一种方式达到这两份副本:sink 会写入重新计算出的输出与分片中现有数据之间的差异,因此它会将两者都缓冲起来,直到它们以相反的符号相互抵消。
汇总信息中看不到 sink,但它的缓冲区包含在编排数据中:mz_arrangement_sizes 中的运算符 mv_sink(<id>)::write,已计入数据流和对象总量。当某个 MV 自身的计划应用 mz_now() 谓词,且它与 sink 之间没有任何编排或合并操作时(即仅包含过滤和投影的 MV),缓冲区也会为每条存活行停放一条未来时间的撤回记录,直到其时间到达。未来时间的撤回记录只会由过滤器之后第一个按时间分批的运算符停放一次,因此任何中间编排都会将其吸收;通过索引或另一个 MV 读取经过过滤的视图的使用者则完全不会停放它(其计划显示的是 ReadIndex 或 MV 读取,而不是 mz_now() Filter)。因此,水合后仍然很大的 sink 行,要么表示有待处理的未来时间撤回记录(记录触发后,该行会立即缩减),要么表示写入积压,即 sink 跟不上 persist。合并批处理器的行也是如此(见上文“合并”段落)。
sink 周边未计入编排总和的内容:正在追加到 persist 的在途批次(日志只记录 ::write 缓冲区,从不记录追加操作),以及超出所记录 size 的分配器开销,后者在重建后会立即达到最大值。
如需直接监控 sink 缓冲区,请通过 mz_introspection.mz_cluster_prometheus_metrics 读取 mz_persist_sink_correction_* 指标(metric_name 列;集群范围的计数器和每个工作器的高水位线仪表,没有按 sink 区分的标签)。
因此,副本的总堆内存(mz_internal.mz_cluster_replica_metrics 中的 heap_bytes,包括 RAM 和交换空间)会超过其编排总和;而水合后的编排总量会两次低估峰值:既没有计入这部分差额,编排本身在水合时也会达到峰值,此时每个合并批处理器都会持有整个快照,直到首次封存;轨迹合并(编排内部的批次合并)会暂时同时持有源批次和合并后的输出(某个索引导出的编排在构建十二秒后,测得的大小是稳定后大小的 4.4 倍)。因此,副本容量应依据测得的整个集群水合峰值来确定(见下文“进行更改”),绝不能依据编排总量。若峰值主要由某个 MV 的 sink 导致,可采用的措施见杠杆表(“MV 水合峰值”)。
进行上述比较时,请读取 heap_bytes,而不是 memory_bytes。在 Cloud 和采用可自托管默认配置的环境中,副本启用了交换空间,且没有临时磁盘,因此编排是普通的、可换出的堆内存:memory_bytes 只统计驻留 RAM;冷页换出后,它可能低于编排总和;heap_limit 是 RAM 加交换空间的强制上限,而此处的 disk_bytes 报告的是交换空间,不是磁盘。除非交换空间占用过多,导致水合时间或数据新鲜度受影响,否则可将交换空间视为透明。在进程编排器(模拟器和本地 environmentd)下,编排器对 heap_bytes、heap_limit 和 disk_bytes 返回 NULL,因此只能使用 memory_bytes;它统计驻留 RAM,而启用了交换空间的主机上的容器仍可能发生换页,所以此处 memory_bytes 下降本身并不意味着内存已释放。mz_internal.mz_cluster_replica_utilization 会以副本分配量的百分比报告相同的读数(cpu_percent、memory_percent 和 heap_percent,其中 heap_percent 是 heap_bytes 占 heap_limit 的比例),是最快速的可用空间读数。
工作流
从上到下进行。编号顺序是一次处理的默认形式,而非固定脚本。证据指向其他方向时,可跳过步骤或调整顺序,但有两个承重顺序不能改变:先提供提示再归因(步骤 2),以及先估算再构建(步骤 5)。
- 盘点。确认集群已完成数据加载(“Reading introspection”),然后在目标集群上按大小排列数据流:
mz_introspection.mz_dataflow_arrangement_sizes。这能告诉你内存用在哪里;在此之前,工作流中的其他步骤都无从谈起。按大小排序以确定工作顺序,但盘点必须完整:第 3 步的重复探测会对每个 arrangement 聚类,因为同一个小型 arrangement 在二十个数据流中构建起来,总量也会很大;提案中点名的每个对象,无论大小,都要测量。 - 尽早处理提示。先在目标集群上查询
mz_introspection.mz_expected_group_size_advice:一次读取就能列出集群上的每个运算符、其提示建议以及可回收的空间。然后对节省空间显著的数据流运行EXPLAIN ANALYZE HINTS FOR MATERIALIZED VIEW <mv>(也可使用FOR INDEX),以确定每条提示应放在哪里。这项操作成本低,不需要更改逻辑,并且应首先执行,因为过大的层级会放大后续每个步骤归因的规模。通常收益最大的一条提示是尚未加提示的 min/max,它会静默地默认使用 8 层层级(尚未加提示的DISTINCT ON或按组LIMITTopK 也默认使用相同的八层)。详情见:[references/hint-sizing.md]。 - 先归因,再选择优化手段。同一个显眼的数值,可能代表保留了完整输入的层级聚合、原始重新键控的 arrangement、先扩展再收缩的联接结果,或随 arrangement 携带的负载字节;这些情况需要不同的优化手段。成本模型中的运算符表说明了每种运算符保留的内容;参考文件会进一步细化(min/max 或 TopK 的每层层级成本、宽度和键摊销的影响、窗口函数的打包行状态、时态过滤器的撤回副本)。按运算符归因:
EXPLAIN ANALYZE MEMORY FOR MATERIALIZED VIEW <mv>(或FOR INDEX <idx>;这是仅有的两种可解释对象),或者将mz_introspection.mz_arrangement_sizes与mz_dataflow_operator_dataflows联接。针对大规模内存占用的标准探测:- 每行字节数扫描:大小 ÷ 每个运算符的记录数。键和标量列的行,每行 arrangement 后约占 15 到 50 B(三个整数占 17,一个 uuid 加一个整数占 25,一个整数加时间戳和 32 字符字符串占 50);若某项远高于此范围,说明 arrangement 可能携带了负载列(一个 200 字节的
jsonb文档读数约为 230)。跳过小型 arrangement:每个 arrangement 在每个工作器上都有几百字节的固定基础开销(16 个工作器的副本约为 4.5 kB),记录数低于几百时,这会主导下方的比率。 - 跨数据流重复:按记录数对所有大小已测的 arrangement 聚类,容差为百分之几。记录数近似相同,就表明同一个集合在多个数据流中建立了 arrangement,即使键、名称和投影各不相同;在计划中解析各个簇,再将其视为同一个对象。设置容差是因为同一集合的读数会按运算符异步记录,而且
records包含尚未合并的批次,其插入/撤回对只有在合并时才会抵消;具体百分比由经验确定。 - 数据流内同类 arrangement 盘点:在每个大数据流中,列出键和记录数相同但宽度不同的 arrangement(键从
mz_dataflow_operator_dataflows中运算符的名称ArrangeBy[[Column(0, "k")]]读取,通过operator_id联接;运算符内部的 arrangement 名称中不包含键,需查看计划)。这些通常是每个使用者的投影不一致,导致公共子表达式共享失效。应将此项作为单独步骤执行;跨数据流视图不会显示这类情况。 - 基数探测:比较行数、不同联接键对数和不同分组键数;这些数据可用于确定索引候选项的大小,并在重建前估算联接对数量(见下文的二次联接情形)。
- 投影下推停滞:对于大型 arrangement 的宽度超过其使用者读取宽度的数据流,运行
EXPLAIN OPTIMIZED PLAN WITH (arity);阻碍因素和修复方案属于同一种优化手段(见下文)。 - 工作器负载倾斜:当某个工作器似乎承担了某个数据流的大部分工作时,运行
EXPLAIN ANALYZE CPU WITH SKEW FOR MATERIALIZED VIEW <mv>(或FOR INDEX)。它会为每个运算符、每个工作器打印一行(worker_id、cpu_ratio、worker_elapsed、avg_elapsed、total_elapsed);运算符的最大cpu_ratio就是其倾斜度。计数器从运算符创建时开始累计,因此数据加载期间的读数衡量的是数据加载,之后的读数仍包含数据加载;稳态倾斜度是两次读数的差值。将测量行或计划运算符映射回 SQL 子句,将在下文单独说明(“将测量结果映射回 SQL”)。
- 每行字节数扫描:大小 ÷ 每个运算符的记录数。键和标量列的行,每行 arrangement 后约占 15 到 50 B(三个整数占 17,一个 uuid 加一个整数占 25,一个整数加时间戳和 32 字符字符串占 50);若某项远高于此范围,说明 arrangement 可能携带了负载列(一个 200 字节的
- 根据下方的优化手段表对每个大规模内存占用分类。报告优化手段能够改善的内存占用;其余部分保留在笔记中(基本规则)。
- 实施前先估算。根据归因结果,估算每个候选方案可节省的空间(移除的内容减去新增的内容),并注明置信度(各优化手段对应的公式,以及区间较宽时的处理方法,见“评估提案并进行估算”)。每个索引候选项都必须通过宽度计算(两侧的每行字节数;模型见 references/indexes.md)和计划分析,才能进入实验;这也适用于你自己的提案,不只是别人要求你评估的提案。实测构建结果将最终定论(基本规则)。可先在无数据环境中免费验证是否采用及计划形态相关问题([references/indexes.md]);大小相关问题则需要实验集群。根据估算价值与投入和风险的比较决定是否实施;未达到门槛的复杂重写应作为已设计并估算的提案留存。
- 在实验集群上测量(见“进行更改”):构建候选方案,与基线比较,验证结果完全一致,并记录数值及每个数据流的数据加载时间(注意事项:启用某个索引可能会让联接改为基于热点键执行)。
- 交接:提供估算值与实测值对照表(见“评估提案并进行估算”)、以用户的事实来源表述的已接受更改,以及落实步骤:调整副本大小,使其能够满足实测的完整数据加载峰值,而非稳态需求(见“进行更改”中的“最终评估与容量规划”),并提供清理检查清单。
将测量结果映射回 SQL
将测量结果映射回 SQL 需要两步。第一步,从测量行映射到计划算子,这是精确的。EXPLAIN ANALYZE 的每一行都对应默认 EXPLAIN(物理/LIR 计划)中的一个算子,但顺序不同:这些行按 LIR id 降序排列,因此多输入算子的子节点顺序与计划文本中的顺序相反;计划文本还包含没有对应行的内容(Fused with Child Map/Filter/Project),With/Return 框架会折叠到标签中(With l0 = <op>、Returning <op>),关系会以 id(Read u20)而非名称显示,有些标签也不相同(计划中的 Differential Cross Join 在 ANALYZE 中显示为 Differential Join)。因此,切勿按位置将行映射到算子:EXPLAIN PHYSICAL PLAN WITH (node identifiers) 会为每个算子标注其 LirId,mz_introspection.mz_lir_mapping 也以相同的 id 为键(其中包含按节点划分的原始算子 id 范围;其 global_id 是一个临时 id,通过 mz_introspection.mz_mappable_objects 从对象名称桥接而来),测量行则按该 id 与计划算子关联。第二步,从计划算子映射到源子句,没有可追踪的 id,而且算子标签较为通用并会重复,因此要通过特征标记来映射:计划中显示的列名(分组键、排序列表、连接等价项和过滤器中的 #2{col} 注释;派生列显示为不带注释的 #n,名称也可能在投影后丢失)、输入连接的完整键集合(三个关系的链与两个关系在同一列上的链属于不同的 CTE)、同级聚合(与 count(*) 并列的 min 子句不同于单独的 min 子句)、算子读取的关系、显著常量,以及当前提示值(EXPLAIN OPTIMIZED PLAN 中算子上的 exp_group_size=N;若某个值在视图中独一无二,就能锁定其子句)。单个特征标记只能作为假设;要用完整键集合和同级算子确认。如果这样仍无法将映射确定到约 97% 以上,就用标记来确认:将视图文本放入 EXPLAIN OPTIMIZED PLAN FOR SELECT ...,给怀疑的子句设置一个显著的提示值(OPTIONS (... INPUT GROUP SIZE = 1234)),exp_group_size 发生变化的那个算子就是该子句。只需执行一次 EXPLAIN,无需 hydration,任何接受提示的子句都可以(min/max、DISTINCT ON、每组 LIMIT);对于没有提示子句的算子,可在同一 CTE 中标记一个接受提示的邻近子句,然后查看其周围的子树。关于标记,有两点需要了解:两个子句若逐字节完全相同(包括提示),在计划中就是同一个算子(优化器会共享子树),因此 HINTS 会为二者报告同一行,两个子句也会得到相同的修改;在其中一个子句上加标记会将它们拆开,这是诊断结果,不是问题;而且,提示在物理计划中的踪迹更粗略(min/max 只显示其桶列表,即 16^n 括号,TopK 则不显示任何内容),所以应在优化后计划中读取标记和当前值。每次修改都要依据内容定位,绝不要按行号定位:从目录读回的 SQL 是引擎重新渲染的结果,因此行号不会与用户的源代码匹配。需要相同值的结构相同子句,应使用一条统一指令。references/hint-sizing.md 使用这种方法处理提示子句;这种方法适用于任何算子。
杠杆表
根据测量特征分派对应的调整手段。每个参考文件都包含完整的方法、验证步骤和演示示例。
| 特征 | 调优手段 | 详情 |
|---|---|---|
| HINTS 报告了节省量;未提示时为最小值/最大值(levels=8,未设置 exp_group_size) | 重新调优或添加 GROUP SIZE 提示 | [references/hint-sizing.md] |
| 同一 (collection, key) 出现在 2 个或更多数据流中 | 共享索引;当基础视图较宽时,使用精简投影视图和索引 | [references/indexes.md] |
| 反复出现的复合重新键控,没有现有索引可用 | 恰好针对该 (collection, key, column-union) 创建新的边界视图和索引,并调整消费者的指向 | [references/indexes.md] |
| 一个数据流中有两个大型 arrangement,键和计数相同,但宽度不同 | 使用并集宽度视图和索引(数据流内的孪生视图) | [references/indexes.md] |
| 被 2 个或更多数据流使用的高开销计算视图 | 为计算视图创建索引;它会对整个计算去重,即使消费者不按键读取也能获得收益 | [references/indexes.md] |
三个或更多输入的 type=differential join,其 Differential Join 行占用大量内存(中间结果) | 为额外探测键创建索引,弥补增量路径缺少的键,以便规划器将其改为增量 join | [references/indexes.md] |
| 经时间筛选的视图(mz_now 窗口)被多个消费者读取 | 为共享的时间筛选视图创建索引(N 个私有副本合并为 1 个;对于自行携带 mz_now() 谓词的消费者,其 sink 也不再滞留撤回记录) | [references/indexes.md] |
| 每行字节数异常偏高(负载随 arrangement 携带) | 原地提取并精简负载(见下文) | 行内 |
| Reduce 或 arrangement 携带了没有消费者读取的列 | 缩窄无用列(见下文) | 行内 |
DISTINCT 涉及的列多于消费者读取的列(共享视图中常见 DISTINCT *) | 投影下推受阻(见下文):移除冗余 DISTINCT,或缩窄其列清单 | 行内 |
| DISTINCT ON 选取结果,同时在相同关系和键上对同级项执行 max/min | Argmax 冗余(见下文):删除冗余的 max/min 聚合,从选取结果中读取该值 | 行内 |
| LEFT JOIN 的右侧可证明始终匹配,且不携带优化器已知的键 | 通过两类可证明的保证之一转换为 INNER(键已知时,菱形结构已经消失,因此转换不会带来收益);没有普查特征,因此每次重建时都要查找 | [references/outer-joins.md] |
| 对 join 某一侧的所有列执行宽 Distinct,再进行全宽自连接(通用外连接降级) | 投影下推受阻(见下文):预先投影保留侧,或将 ON 改写为普通等值连接 | [references/outer-joins.md],“The pushdown gaps” |
一组 2 个或更多 LEFT JOIN 被逐个降级(n 个 differential join,每个都带一个匹配键 Distinct,没有 Threshold),而不是一个增量 join | 恢复 VOJ(variadic outer join)降级方式(将内连接移出这组连接,使用 ON 而非 USING,将局部谓词和表达式移入派生表,并进行去相关化),前提是匹配率和右侧的键满足参考文档中的交叉阈值 | [references/outer-joins.md] |
| 窗口函数(OVER/PARTITION BY)处理宽行或热点分区 | 投影下推受阻(见下文):缩窄窗口输入关系(减少内存);增量改写(DISTINCT ON、min/max、LAG/LEAD 自等值连接)可提高数据新鲜度,但可能增加内存 | [references/window-functions.md],“The projection-pushdown blocker” |
CrossJoin,或以外层关系自身的列为键的 Distinct 和 ArrangeBy,将结果送回该关系进行连接(去相关化子查询),或 SQL 排查清单“Query shapes to recognize”中的任何形式 | 手动去相关化:九种改写模式,每种都有精确性规则 | [references/subqueries.md] |
| 没有任何读取者的索引或 MV | 删除它,并执行固定流程(见下文) | 行内 |
副本的 hydration 峰值主要来自一个或两个大型 MV 的 mv_sink(<id>)::write 缓冲区 | 将 MV 拆分成更小的 MV(下文的“MV hydration spikes”;最后手段) | 行内 |
行内调优手段:
就地提取以缩减负载。 Materialize 会激进地下推列投影,穿过 TopK 和 Reduce,进入 Join 的输入以及源读取,但绝不会移动标量 EXPRESSION:Map 会保留在查询指定的位置(只有当它本来就紧挨着源读取时,才会融合到源读取中),无论其上方是 TopK、Reduce 还是 Join。因此,先 Join、后拆解的 jsonb/text 大块数据会被源与提取点之间的每个算子完整读取,包括 Join 的输入排列和 TopK。应在负载首次进入流水线时修复:在读取源关系的第一个视图中(无论流水线第一层称为扫描还是包装视图),只提取实际使用的字段,让 picks、层级聚合和 Join 只携带精简列。优先采用这种方式,而不是回查 Join(先精简流水线,再在末尾重新 Join 臃肿关系):回查会按回查键重新排列臃肿关系;如果精简后的流水线只保留了一两份副本,这种全宽排列会抵消大部分收益。提取表达式对每一行都是确定性的,因此只要不会出错,提前计算就不会改变输出;但如果某行上的转换、除法或下标访问会报错,而该行本会被后续过滤器丢弃,那么它会使整个集合及其所有使用方都失败。应使用下游谓词保护此类表达式(CASE WHEN <pred> THEN <expr> END),并在双向 EXCEPT ALL 证明(见下文“验证规范”)中加入一条会触发错误的行。
缩减无用列并采用紧凑类型。 如果 Reduce 的分组键比其使用方读取的内容更宽,或 GROUP BY 携带了使用方都不需要的列,那么每个分组都会保留无用字节。应将 GROUP BY 或携带的列缩减到实际使用的内容。只用于 IS NOT NULL 判断的列,可在扫描时缩减为布尔值。类似地,也应缩减数据表示:实际是 UUID 的文本转换为 uuid,文本布尔值转换为 boolean,小型固定字符串词表转换为小整数代码。(整数宽度之间的转换很少能节省内存:整数存储本来就按值的大小占用空间。)这些优化会改变列级别的输出,因此应在流水线内部应用,同时保持流水线输出的使用方契约;并通过双向 EXCEPT ALL 证明缩减位置的结果完全一致。
投影下推受阻(手动投影)。 优化器会持续下推投影,直到遇到需要完整输入宽度的结构;从该处往下,每个环节都会携带所有列,无论使用方实际读取多少列。四种结构会导致这种情况:窗口函数(其三算子结构见 references/window-functions.md,会打包窗口的整个输入范围)、一般外连接的降级实现(会对齐所有保留列)、DISTINCT(包含列出的所有列,因此共享视图中的 DISTINCT * 会在每个使用方内部整理完整宽度),以及仍然存在的 Threshold(EXCEPT、EXCEPT ALL 和 INTERSECT 的实现,以整行作为键)。索引视图是第五种有意如此的情况:无论读取方投影了哪些列,索引都会保存视图的完整宽度(解决方法见 indexes.md 中的精简投影视图)。它们都可以用同一种方法诊断:EXPLAIN OPTIMIZED PLAN WITH (arity) 会标注每个算子的列数;如果某个区域的元数远高于输出读取的列数,也高于该区域本身所消耗的列数(分区列和排序列、连接键、去重列),那么投影就受阻了。前四种情况有两类相同的修复方式:在阻塞点正下方手动投影(将窗口的输入包装在显式投影中;预先投影外连接的保留侧;在使用方仅对所需列应用 DISTINCT;缩窄集合操作的两个操作数),或者移除阻塞结构(将窗口函数改写为增量形式,见 references/window-functions.md;将 ON 改写为普通等值连接,见 references/outer-joins.md;删除冗余的 DISTINCT;移动集合操作)。结果是否完全一致,取决于具体情况:如果被删除的列不会进入任何输出,那么窗口和外连接的手动投影与原结果完全一致;而缩窄 DISTINCT 会改变结果,除非被删除的列由保留列函数决定,因此应说明这一差异,由用户决定。若优化器知道 DISTINCT 的列列表包含一个键(EXPLAIN OPTIMIZED PLAN WITH (keys)),该 DISTINCT 就是冗余的,并且已经被消除;如果唯一性仅在语义上成立,则应让优化器知道该键(使用带键边界,见 references/outer-joins.md),或者先用 count 与 count-distinct 探针证明,再将其删除。
冗余的 argmax。 如果 v 是键之后的第一个排序项且不为 null,或者排序明确写为 v DESC NULLS LAST,那么 SELECT DISTINCT ON (k) ... ORDER BY k, v DESC 选出的行会使同一关系上的 max(v) ... GROUP BY k 并列聚合可证明为冗余:选出的首行已经包含最大值(如果另一个列排在 v 之前,那么选出的行就不是 argmax)。条件取决于 NULL 的排列位置,因为 DESC 的默认值是 NULLS FIRST,这会返回 max 会跳过的 NULL。删除整个 max 聚合并从选出的行读取该值,结果完全一致;可用标准的双向 EXCEPT ALL 证明。
死对象与固定流程。 无人读取的 MV 是整个数据流的丢弃项;在处理它之前,应通过多种独立方式确认它没有被使用(没有下游对象、没有服务读取,并询问所有者)。对于死索引,应先确认是否有对象采用它,再执行任何 DDL;绝不能通过删除来试探结果:mz_internal.mz_compute_dependencies 会列出导入该索引的每个数据流(dependency_id 为该索引的行;其他索引也算采用者,而目录级别的 mz_object_dependencies 永远不会显示索引采用关系),存储计划中的 Used Indexes 页脚可按消费者确认这一点,mz_introspection.mz_arrangement_sharing 则是该索引 trace 的实时引用计数,它统计将其作为 arrangement 读取的消费者,不统计对其执行全表扫描的消费者,所以计数为 1 并不能证明无人读取。删除索引时会打印一条 NOTICE,列出依赖对象。这条 NOTICE 意味着索引数据流会继续运行并维护,直到每个列出的依赖对象都被重建(或更改,或者 environmentd 重启;重启会重新规划依赖关系发生变化的对象,从而解除固定)。这不代表索引仍有必要,也不代表删除失败。如果没有 NOTICE,则说明没有对象采用该索引,删除后会立即释放数据流。计划会在 CREATE 时采用索引,因此完整流程是:删除索引,然后重建 NOTICE 列出的每个消费者,最后确认没有残留的僵尸数据流(以下查询会列出导出对象不在目录中的每个受维护数据流;固定消费者的 EXPLAIN 页脚会将已删除的索引显示为 [DELETED INDEX],这是针对每个消费者成本最低的检查)。一旦 NOTICE 列出依赖对象,仅删除索引不会释放任何资源。
-- zombie dataflows: still maintained, exporting no catalog object
SELECT d.name FROM mz_introspection.mz_dataflows d
WHERE d.name LIKE 'Dataflow: %.%.%' AND NOT EXISTS (
SELECT 1 FROM mz_introspection.mz_compute_exports e
JOIN mz_catalog.mz_objects o ON o.id = e.export_id
WHERE e.dataflow_id = d.id);
MV 水合峰值(持久化 sink)。 当一两个巨大的 MV 主导集群的水合峰值时(根据成本模型,每个 sink 在峰值时会缓冲约两份 MV 输出),可将 MV 拆分为几个较小的 MV(按键范围拆分,或拆开很少一起联接的列组),这样就不会完整缓冲单个快照,而且各部分的峰值不会全部重合。这种做法很繁琐(必须重新指向消费者,或通过 UNION ALL 将它们合并,并且对象图也会改变),只有在各部分于不同时间水合时才能降低峰值。Materialize 默认最多同时水合四个数据流,因此拆分成少于约四个部分效果不大,同时发生的 sink 缓冲量仍会限制在约四个部分的总量。这个四是 compute_hydration_concurrency 设置;降低该值是拆分之外的直接替代方案。只有 Cloud 标志服务才能用它指定单个副本,而 Cloud 用户不能自行设置系统参数,因此需要联系 Materialize;自托管运营者可使用 mz_system 执行 ALTER SYSTEM SET,或通过 system-parameters ConfigMap 设置该值。无论哪种方式,新值都会应用于环境中的每个副本。只有在 sink 峰值是最严重的集群内存问题时才建议采用上述任一方案,并通过两种方式确认这一点:在水合期间(重启副本可按需触发水合),mz_arrangement_sizes 中的 mv_sink(<id>)::write 行(以及 mz_persist_sink_correction_* 高水位 gauge)会显示缓冲区;此外,不依赖水合过程,直接测量每个 MV 的输出大小:
SELECT sum(mz_row_size(mv.*)) FROM mv; -- bytes of the MV's rows,
-- per-row overhead included
mz_row_size 接受行记录(mv.* 或仅 mv;不要用 row(...) 包裹整个行引用,否则会嵌套一条记录并为每行增加一个头部)。打包后的字节数可放入 23 字节内联缓冲区的行会显示为固定的 24 字节;更长的行则显示其打包长度加上这 24 字节开销。大致而言,sink 预期的水合开销是该总量的两倍(arrangement 以不同表示形式存储行,因此这并非精确字节数);选择此方案前,应将其与 arrangement 总量和测得的 heap_bytes 峰值进行比较。
需要识别的查询形态
三种 SQL 形态的规划成本,远高于它们读起来给人的印象。 在归因时,先检查执行计划和 SQL 中是否存在这些形态,再选择优化手段。
连接条件中的 IS NOT DISTINCT FROM。 对于可为 NULL 的列,如果这是连接中唯一可用的条件,它会被规划为带后置过滤的交叉连接:两个输入都按空键排列,所有计算都在同一个 worker 上进行(对于 NOT NULL 列,它会简化为 =,并保持为等值连接;如果还有真正的等值条件,它则会变成普通等值连接上的后置过滤)。默认的 EXPLAIN 会将其显示为作用于 Arrange (empty key) 输入的 Differential Cross Join,优化后的执行计划则显示为 CrossJoin,因此可在执行计划文本中搜索这两个标签;请注意,mz_lir_mapping 和 EXPLAIN ANALYZE 会将同一个算子标记为普通的 Differential Join,此时各 worker 的耗时偏斜是判断线索。可改写为普通等值条件;或者,如果确实需要匹配 NULL,则改写为 a.k = b.k OR (a.k IS NULL AND b.k IS NULL)。这种写法完全等价,规划器会将其还原为等值连接(随后,如果连接键是可为 NULL 的列,请在“陷阱”一节运行闭包审计)。
二次方连接(交叉连接和键倾斜)。 如果连接没有可用的等值条件,或者等值条件涉及基数低或严重倾斜的非唯一列,那么产生的配对数量会远超任一输入的行数,而且同一键的所有配对都会在该键对应的同一个 worker 上生成。典型症状是耗时而非内存:数据填充始终无法完成,或者重建的 MV 在一个 worker 上持续数小时,而内存用量保持不变(下游过滤或折叠会在配对生成后将其丢弃)。应对方法是在构建前探测,因为事后很难检查正在持续消耗资源的副本:在构建或重建任何连接之前,根据两侧不同键的数量和每个键的最高频次估算配对规模(对热点键,将左侧计数乘以右侧计数后求和);如果结果比任一输入高出几个数量级,就应立即停止,而不是尝试调优。在仍可正常响应的运行中集群上,EXPLAIN ANALYZE CPU WITH SKEW 会显示每个算子的各 worker 耗时;真正的交叉连接会在默认 EXPLAIN 中显示为 Arrange (empty key) 输入。空键会将数据哈希到同一个 worker,因此副本上的所有交叉连接都会在同一个 worker 上运行,多个交叉连接还会在那里叠加。高负载持续运行期间,副本提供的内省功能可能完全无法响应;这种无响应现象加上一个繁忙的 worker,本身就是典型特征。
子查询会解相关为按外层列键控的连接。 读取关系、位于 CASE 中,或不止包含 Map、Filter、Project 和表函数调用的子查询,会被降级为:在相关键上执行 Distinct,将子查询作为数据流计算一次,然后按该键连接回去;如果没有相关列,则使用 CrossJoin。代价是该连接的布局以及子查询自身的算子;当外层键在聚合或 LIMIT 之前向下推入连接时,这些算子会为每个不同的外层值保留一组数据,而不会针对每个外层行重新执行。内存开销来自所有按相关键排列的数据(连接回去的键对、按键执行的 Distinct、子查询自身的排列),其大小取决于该键的宽度。因此,开销较大的形态包括:按负载列(jsonb、list、array、长 text)关联的子查询、位于外连接 ON 子句中的子查询(键包含所有保留列),以及可空情形(NOT IN、筛选条件中的非合取项之外的 IN),这些情形会让外层键为 CrossJoin 提供种子。在计划中,留意 CrossJoin,或以外层关系自身的列(所有列或某个负载列)为键的 Distinct 加 ArrangeBy,并且其结果会传入一个连接,再连接回该关系。在 SQL 中,留意可空一侧的 NOT IN、筛选合取条件(WHERE、内连接的 ON、HAVING)之外对可空比较使用 IN/ANY、对会聚合或使用 LIMIT 的子查询使用 IN(即使在 WHERE 中也一样)、外连接 ON 子句中的子查询、= ANY(<collection column>),以及仅按当前行关联的标量子查询或 LATERAL ... LIMIT 1 子查询。完整的特征列表、九种重写及其精确性要求、优化器目前已处理的情况,以及无法通过重写解决的问题,详见 [references/subqueries.md]。
陷阱
此技能所做更改带来的风险。发布任何索引包或重建之前,都要运行这些检查,而不只是针对已经出过问题的站点。
可空键闭包审计。 对可空键列建立的索引会将所有 NULL 记录归到同一个键下,并放在同一个工作器上。私有布局是安全的:规划器会在其下方放置 IS NOT NULL 过滤器,因此 NULL 行永远不会进入连接。共享索引无法在其下方放置该过滤器(下推过滤器会导致索引无法被采用),因此规划器会将保护条件提升到连接上方。随后,任何基于该键的连接都会让一侧的所有 NULL 记录与另一侧的记录两两配对(自连接会使数量平方),全部在同一个工作器上执行,之后再丢弃这些结果。症状:一个工作器持续运转数分钟到数小时,而其他工作器闲置;内存平稳(生成后立即丢弃,不会保留任何内容);副本内省无响应。审计方法:针对包中的每个索引和 MV,读取 EXPLAIN PHYSICAL PLAN AS VERBOSE TEXT;在每个连接段中,标记位于闭包区域(连接行与其输入运算符之间)、列属于该连接布局键的 IS NOT NULL 过滤器。位于 arrange 下方的过滤器位置安全,不算命中。无需查看计划的确认方法:mz_arrangement_sizes_per_worker 中恰好有一个工作器的记录数超出部分对应 NULL 记录数。按固定顺序修复(参见上文“失效对象”和下文“进行更改”):删除原始可空键索引(这样后续使用者就无法重新触发陷阱,新索引也不会采用该索引),创建一个满足 WHERE key IS NOT NULL 的直通视图并为其建立索引,然后重建 NOTICE 中列出的每个使用者。
启用索引可能会使连接转而使用热点键。 新的共享索引会改变连接实现,而不只是共享状态:当所有输入都已预先布局时,规划器会将线性链切换为增量计划;如果增量路径流经高扇出查找,该键的流量就会汇聚到一个工作器,在数据水合阶段和稳定运行期间都是如此,从而可能让原本只需数分钟的数据水合变成持续数小时的苦苦运行。添加索引后,检查大型使用者的 EXPLAIN OPTIMIZED PLAN WITH (join implementations),并检查阶段键可能产生扇出的增量路径;同时比较每个重建数据流的数据水合时间与更改前的时间(mz_internal.mz_compute_hydration_times)。缓解措施见 references/indexes.md 中的验证规则。
删除或替换索引可能会留下僵尸数据流。 计划在 CREATE 时采用索引,因此,新索引会采用旧索引;执行 DROP INDEX 后,旧数据流仍会继续维护,直到重建 NOTICE 中列出的每个使用者为止(同版本重启会保留所有缓存计划,只有依赖项发生变化的对象的计划会被重新规划,例如被删除索引的依赖对象;这些计划会根据当前目录重新规划,并采用当时存在的任何索引;升级或更改优化器标志则会重新规划所有内容)。“进行更改”中的按顺序删除、创建、重建和验证流程就是防护措施。
索引顾问只是一个输入,绝非事实依据
mz_internal.mz_index_advice 只根据对象依赖图进行分析。它不了解大小、列宽、键是否正确或外部使用者:
- 它给出的“添加索引”建议不考虑宽度。在宽视图上,它可能建议创建一个全宽布局的索引,而该索引的大小可能抵消甚至超过它要替换的窄型私有副本。请先计算两边的大小。
- 它看不到表上的索引。无论索引有多大、多么无用,它都不会建议删除。
- 它只会沿着接入 sink 或已建立索引的对象的依赖链传递建议。普通的无索引物化视图下方的所有内容都会从输出中悄然消失。
- 它给出的 MV 降级建议(“转换为视图”,可带索引,也可不带)只会查看 catalog 依赖,其中包括其他集群维护的对象;它看不到非 catalog 对象形式的读取者(临时
SELECT、SUBSCRIBE、仪表板、应用程序)。依据这类建议转换提供服务的 MV,会破坏顾问一无所知的契约。
因此:每条顾问建议都必须通过测量来判定,绝不能把它的沉默视为证据。
判定建议并进行估算
需要对修复清单作出判定时,无论清单来自同事、顾问还是你先前的分析:
- 每项给出一个判定:采纳、修改或拒绝,并附上测量结果(基本规则)。“修改”是正式判定;最常见的情况是键正确但对象选错或对象过宽,以及将字面值筛选列并入连接键,导致索引两者都无法服务(references/indexes.md,索引键正确性)。
- 认可共享关系上的索引之前,先进行闭包审计并计算宽度。认可删除之前,检查采用者(
mz_compute_dependencies、存储计划的页脚)和外部使用者。将所有未经测量的项目视为未经证实;典型的未审核清单中,既有正确的键,也有错误的键和冗余项目,且至少有一项会带来直接危险。 - 估算基于逐运算符归因:去重节省的字节数等于移除的私有副本总量减去新增量;调整提示词的估算节省量取自建议视图对该运算符给出的估值,并通过调整后测量予以确认;精简的节省量按每行字节差 × 记录数 × 副本数计算。为每个数字注明置信度。若区间仍然很宽,说明方法有问题:应进一步归因以缩小区间,而不是重复断言。如果某个数字无法进一步收窄,请指出未知量及能够确定它的实验。
- 依赖数据属性的重写(窗口边界、优化器无法识别的唯一性、匹配率、永不为 NULL 的列)必须先推导该属性,绝不能假定:先通过探测确认,再陈述并验证该属性,同时记录确保其持续成立的防护规则。
- 对所有已实施的更改,在同一张表中报告估算值与实测值,并明确撤回任何被测量结果证伪的数字。
进行更改
谁来执行什么,以及在哪里执行:
-
诊断:只读,可在任何地方进行;读取 catalog、进行内省和读取 EXPLAIN 都不需要权限,但读取用户数据需要权限(基本规则)。
-
实验:在用户创建的专用实验集群上进行。提出实验集群的预期副本规格和使用期限,并向用户提供命令,例如:
CREATE CLUSTER optimize_experiments (SIZE = '<size>');仅在用户明确要求时才自行运行这些操作。集群持续运行会消耗 Cloud 积分,在其他环境中则会占用节点容量,而且用户创建的集群不会被自动遗忘。如果环境已达到集群数量上限,
CREATE CLUSTER会因creating cluster would violate max_clusters limit而失败;在 Materialize Cloud 上由 Materialize 提高上限,在自托管环境中则由操作员提高上限,因此应将此情况告知用户,而不是绕过限制。 -
在实验集群内执行对象 DDL,有两种方案(MCP 无法写入;请提供两种方案并让用户选择):
- 为受限角色建立直接 SQL 连接,其 DDL 操作范围严格限定为实验集群和实验模式(
GRANT CREATE, USAGE ON CLUSTER optimize_experiments TO <agent_role>,实验模式也授予相同权限;此外还需对生产模式和集群授予 USAGE(默认情况下,PUBLIC 拥有集群的 USAGE 权限,但安全加固的环境会撤销该权限),并对要比较的对象授予 SELECT)。CREATE INDEX要求拥有被索引对象的 OWNERSHIP,因此该角色不能直接为生产 MV 创建索引:应在实验模式中为其创建视图,为该视图创建索引,并让候选消费者读取该视图。该角色无法在其他位置构建对象(其他位置均未授予 CREATE 权限),也无法修改或删除任何集群(这些操作受所有权限制,且实验集群由用户拥有)。上述限制仅在enable_rbac_checks启用时成立;它在 Cloud 上默认启用,但在自托管环境和模拟器上默认未启用。此外,该角色既不能是超级用户,也不能是拥有生产对象的角色的成员。如果检查未启用,则只有下方的方案 2 能真正实现隔离。 - 由你编写独立的 SQL 脚本供用户执行,并通过 MCP 进行只读测量。这样无需新凭据,但每次构建迭代都需要用户往返操作一次。
无论采用哪种方案,实验角色对生产对象只需拥有 SELECT 权限,并对其模式和集群拥有 USAGE 权限。应用已接受的更改属于单独阶段,需要单独授予权限,并且必须由用户通过事实来源来完成。
- 为受限角色建立直接 SQL 连接,其 DDL 操作范围严格限定为实验集群和实验模式(
-
部署已接受的更改:询问对象定义的事实来源在哪里(mz-deploy、dbt、自定义脚本或目录本身),并在相应位置表达更改。mz-dbt 和 mz-deploy 技能涵盖这些工具。全量重建部署工具会一并重建所有对象,从而消除下方大部分固定依赖风险;无论采用哪种方式都仍需确保部署时先创建索引,再创建 MV。增量部署(仅重建已更改的对象)后,应让用户在可以接受重新水合的时间,于生产集群上运行下方最终评估中的重启检查,以确认扩容后剩余的余量;若全量重建部署在专属集群上水合新一代对象,则该过程本身就是检查(从该副本的指标和历史记录中读取峰值)。
-
最后调整副本大小,同样应建议由用户执行。 最终评估与容量规划。 集群的内存峰值通常出现在数据恢复期间,此时所有内容都会同时重建。生产环境中的重启、重新部署或升级都会触发这一过程;Cloud 上的升级会自动滚动进行,平台也会重新调度副本,因此峰值是常规事件,无法靠永不重启来避免。峰值由两部分构成。编排状态:即使测得的数据恢复峰值更低,也至少要按稳态占用的 2 倍规划,因为 Materialize 工程团队认为 2 倍的数据恢复峰值可以接受,而且任何版本都可能在不事先通知的情况下让集群峰值升至这一水平。物化视图 sink 不适用这条规则:每个 sink 在数据恢复时会缓冲大约两份其物化视图输出(成本模型),这部分占用叠加在编排状态峰值之上,与稳态占用无关,之后会逐渐释放。因此,这部分占用只会在数据恢复期间出现,只有重启副本才会再次产生。
因此,对已接受的更改集进行最终评估时,应一次性恢复整个最终状态:重启保存该状态的实验集群副本(对于托管集群,将 REPLICATION FACTOR 设为 0 后再恢复原值;对于非托管集群,则删除并重新创建副本),或者在新的实验集群上从头构建最终更改集,并记录该次数据恢复的峰值。只重建已更改的数据流会低估生产环境数据恢复时的峰值,因此,针对峰值的更改,或可能提高峰值的更改(新增索引、新边界),也要通过恢复整个实验集群来测量。
从 mz_internal.mz_cluster_replica_metrics_history 中读取 heap_bytes,即 RAM 加交换空间,因为交换空间吸收峰值后,memory_bytes 会呈现平稳值(在进程编排器、模拟器和本地 environmentd 下,没有 heap_bytes,memory_bytes 是唯一可用的峰值);mz_cluster_replica_metrics 是某一时刻的读数,会漏掉已经过去的峰值,而且历史数据每个副本每分钟采样一次,因此单个高采样值只能视为下限。对于 sink 占用,还要读取 mz_cluster_prometheus_metrics 中的 mz_persist_sink_correction_max_per_sink_worker_* 峰值仪表,它们会在缓冲区释放后保留峰值;而繁忙副本的 introspection 报告中,mv_sink 行通常早已消失。
容量由两个数值决定:稳态 heap_bytes,副本容量至少要达到实验测得值的 2 倍;以及整个集群的数据恢复峰值 heap_bytes(RAM 加交换空间),必须保持在 heap_limit(强制执行的 RAM 加交换空间上限,见 mz_cluster_replica_metrics.heap_limit)的大约 90% 以内;mz_cluster_replica_utilization 中的 heap_percent 就是该比率。建议选择这两种计算结果中较大的容量。应读取该列,而不要依赖记忆中的比率:不同副本系列和部署的 heap_limit 与 RAM 容量之差各不相同,也可能在不事先通知的情况下变化;在启用了交换空间的自托管环境中,上限由 Pod 的 cgroup 内存和交换空间限制决定;在进程编排器下,该列为 NULL,无法进行此项检查。交换空间大多无须特别处理;只有大量交换导致数据恢复或数据新鲜度变慢时才需要关注,这会反映在数据恢复时间和延迟上,而不是内存故障。
将对象复制到实验集群:使用 SHOW CREATE ... 捕获定义,其输出为已解析名称的纯 SQL;将其中的 IN CLUSTER 改为实验集群后重放。物化视图的 create_sql 目录列以 AS OF 时间戳结尾,CREATE 会拒绝该时间戳;删除它之后,其余内容就能重放,但其中的 ID 注解([u123 AS "db"."sch"."name"]、IN CLUSTER [u1])按 ID 绑定并忽略名称,因此只能在同一环境中重放。
对象由手动方式构建或替换时始终适用的机制(实验集群,以及通过原始 SQL 脚本部署的环境):
- 计划在创建时固定,并在相同版本的重启间缓存:物化视图或索引不会采用创建后新增的索引或提示,除非触发重新规划(升级或更改优化器标志会重新规划所有对象;相同版本的重启只会重新规划依赖项发生变化的对象)。因此,若要按需更改计划,需先删除并重新创建使用方:先处理索引,再处理物化视图。
- 每次重建都会改变采用关系。执行任何删除或创建操作后,都要重新读取采用关系(
mz_compute_dependencies、已存储计划的页脚)和副本编号,然后再在报告中引用;重建前引用的数值都已过时,包括索引的使用方数量和重新水合峰值。 - 同一依赖链上已有旧索引时,新建的索引会采用旧索引作为其输入。执行
DROP INDEX后,旧数据流仍会存活并继续维护(NOTICE 会列出所有被固定依赖的对象),直到所有采用者都重建或 environmentd 重启。因此,应先删除废弃索引,再创建替代索引;然后重建 NOTICE 列出的所有使用方;最后确认没有残留的僵尸数据流。 - 对视图或物化视图执行
DROP ... CASCADE会连带删除其所有依赖对象,包括依赖物化视图和视图上的索引;NOTICE 的 DETAIL 会列出这些对象,重建脚本必须按依赖顺序恢复所有列出的对象(对象应采用的输入索引要先于该对象创建,该对象自己的索引则要在该对象之后创建)。 - 重写物化视图后,如果重写导致推断出的 NOT NULL 属性丢失,请使用
WITH (ASSERT NOT NULL ...)恢复 NOT NULL 一致性。这是强制执行的断言,而非元数据:如果断言列中出现 NULL,在该行被移除之前,每次读取物化视图都会失败,因此只能断言重写确实能保证非空的列。确认列名、顺序和类型完全一致(参见“验证规范”中的mz_columns差异)。 EXPLAIN ... FOR MATERIALIZED VIEW显示的是已存储计划,不会反映新添加的索引;要预览新建物化视图时会采用的计划,请在该物化视图所在的集群上运行其 SELECT 的 EXPLAIN(使用SET cluster,或query工具的集群参数)。页脚中的Target cluster:会显示预览所用的集群。在其他集群上,预览会静默地显示未使用索引的计划,因为索引布局仅限于各自的集群。
每次工作结束时,都要向用户提供清理检查清单:列出已删除的候选对象、列出实验集群及其 DROP CLUSTER 命令(该集群应由用户删除),并提供变更部署后用于调整生产副本大小的命令。
验证规范
- 使用双向
EXCEPT ALL证明完全一致性:对每个关系都执行一次,最好每个关系使用一个查询,这样两侧会在同一时间戳读取。 - 声明类型也是完全一致性的一部分:将每个发生变更的物化视图的
mz_catalog.mz_columns签名(名称、位置、类型)与变更前的快照进行差异比较。重写后可能每行都保持不变,但列类型仍会改变(下文的部分和类型扩宽就是常见情况),而EXCEPT ALL无法检测到这一点,类型化读取器却可以。 - DDL 前创建快照:只要物化视图的定义链中包含
mz_now(),INSERT INTO snap SELECT * FROM mv就会被拒绝(“写入语句中调用 mz_now”)。使用时间过滤器时,随着行过期,文件快照也会变得陈旧。此时有效的证明方式是实时比较:在临时名称下构建替代视图,然后在同一时间戳下,将其与仍在运行的原视图进行双向EXCEPT ALL比较。 - 语义差异仅限于中间类型扩宽的重写(例如,将
int4列的部分和以int8承载,而最终总和则变为numeric)仍可在用户要求输出逐字节相同时提供,但必须始终作为选项公开,并明确说明差异,绝不能悄然交付。用户可能知道自己的数据不会溢出。 - 在共享数据上执行变更探测:只插入新键,或者保留精确的撤销操作;只删除所插入的内容;之后重新验证基线。
- 不要留下任何临时对象;遇到阻碍时,应如实报告未完成部分,而不是绕过阻碍。
开发此技能(测试方法、评估框架):请参阅 DEVELOPMENT.md。