物化视图会膨胀吗?
序言
今天在②群有位筒子提了这样一个问题:
“他这个已经超过了 10 行,也超过 50%,但是没有触发自动 vacuum?然后呢,我基于这个 t1 做了个物化视图,然后我发现每次刷新,物化视图查询都不会有死元组
但是生产环境中,物化视图就会有死元组,并且死元组有 200 多万,count(*) 的时候就 2 万多行,这样就需要频繁去 vacuum。那么物化视图他刷新的时候,会重新查询一次,不就拿最新的结果集了么?那怎么生产环境的物化视图会有死元组呢?
下来我也简单看了一下这块的逻辑,涨知识!什么情况下物化视图会导致膨胀?
分析
这个问题其实可以拆解为两个问题,第一个即为什么表没有触发 vacuum。这个问题老生常谈,默认的触发阈值是 autovacuum_vacuum_scale_factor * reltuples + autovacuum_vacuum_threshold,autovacuum_vacuum_threshold 默认值是 50,主要是为了预防小表频繁触发而设置的最低水位线,所以假如表增删改的数量超过了 20% ,就会触发 vacuum,当然实际情况还要取决于是否有空闲 worker,是否能获取到 ShareUpdateExclusiveLock 等等。
至于鸡生蛋还是蛋生鸡,各位读者可以翻阅之前的文章,这里就不再赘述原理。
让我们看下第二个问题:手动刷新物化视图,物化视图不包含死元祖,但是生产中却包含死元组。首先,截止最新版本,原生 PostgreSQL 还不支持物化视图的增量刷新,当然可以通过 pg_ivm 插件来实现,但是限制颇多,比如分区表
“The base tables must be simple tables. Views, materialized views, inheritance parent tables, partitioned tables, partitions, and foreign tables can not be used.
有篇 PPT 也有介绍 IVM 的实现,这里表过不提。
因此,刷新物化视图就只存在两种语法 refresh materialized view concurrently 与否,顾名思义,是否允许并发查询
“Refresh the materialized view without locking out concurrent selects on the materialized view. Without this option a refresh which affects a lot of rows will tend to use fewer resources and complete more quickly, but could block other connections which are trying to read from the materialized view. This option may be faster in cases where a small number of rows are affected.
刷新物化视图时不锁定物化视图上的并发查询。如果不使用该选项,影响大量行的刷新往往会使用更少的资源,完成得更快,但可能会阻塞试图从物化视图读取数据的其它连接。在只影响少量行的情况下,该选项可能会更快。
让我们复现一下,先用 pgbench 灌点数据,由于 concurrently 需要唯一索引,所以也顺带建一个索引
postgres=# create materialized view myview as select * from pgbench_accounts ;
SELECT 10000000
postgres=# create unique index on myview(aid);
CREATE INDEX
postgres=# analyze myview,pgbench_accounts;
ANALYZE
postgres=# select n_tup_ins,n_tup_upd,n_tup_del,n_tup_hot_upd,n_live_tup,n_dead_tup from pg_stat_all_tables where relname = 'pgbench_accounts';
n_tup_ins | n_tup_upd | n_tup_del | n_tup_hot_upd | n_live_tup | n_dead_tup
-----------+-----------+-----------+---------------+------------+------------
10000000 | 0 | 0 | 0 | 10000035 | 0
(1 row)
postgres=# select n_tup_ins,n_tup_upd,n_tup_del,n_tup_hot_upd,n_live_tup,n_dead_tup from pg_stat_all_tables where relname = 'myview';
n_tup_ins | n_tup_upd | n_tup_del | n_tup_hot_upd | n_live_tup | n_dead_tup
-----------+-----------+-----------+---------------+------------+------------
10000000 | 0 | 0 | 0 | 10000035 | 0
(1 row)
当前死元组、活元组数量一致,然后压测一下 pgbench -T 300 -c 2,同时不带 concurrently 刷新一下物化视图 (这个筒子在本地的复现时没有加 concurrently)
postgres=# select n_tup_ins,n_tup_upd,n_tup_del,n_tup_hot_upd,n_live_tup,n_dead_tup from pg_stat_all_tables where relname = 'pgbench_accounts';
n_tup_ins | n_tup_upd | n_tup_del | n_tup_hot_upd | n_live_tup | n_dead_tup
-----------+-----------+-----------+---------------+------------+------------
10000000 | 878 | 0 | 4 | 10000035 | 878
(1 row)
postgres=# refresh materialized view myview ;
REFRESH MATERIALIZED VIEW
postgres=# select n_tup_ins,n_tup_upd,n_tup_del,n_tup_hot_upd,n_live_tup,n_dead_tup from pg_stat_all_tables where relname = 'myview';
n_tup_ins | n_tup_upd | n_tup_del | n_tup_hot_upd | n_live_tup | n_dead_tup
-----------+-----------+-----------+---------------+------------+------------
20000000 | 0 | 0 | 0 | 10000000 | 0
(1 row)
可以看到的确没有死元组。那让我们压测的过程中,用 concurrently 再次刷新一下
postgres=# refresh materialized view concurrently myview ;
REFRESH MATERIALIZED VIEW
postgres=# select n_tup_ins,n_tup_upd,n_tup_del,n_tup_hot_upd,n_live_tup,n_dead_tup from pg_stat_all_tables where relname = 'myview';
n_tup_ins | n_tup_upd | n_tup_del | n_tup_hot_upd | n_live_tup | n_dead_tup
-----------+-----------+-----------+---------------+------------+------------
20022288 | 0 | 22288 | 0 | 10000035 | 22288
(1 row)
可以看到,这次就存在死元组了!因此现象明了了
不带有 concurrently 每次取全量快照,取可见的数据,因此不存在死元组 带有 concurrently 会有死元组,原理待分析
那差异在哪里呢?用脚趾都能想到,为了实现不阻塞查询,concurrently 的实现肯定会复杂一些 (类似 create index concurrently 三次快照扫描),在 matview.c 里有这块的介绍
/*
* refresh_by_match_merge
*
* Refresh a materialized view with transactional semantics, while allowing
* concurrent reads.
*
* This is called after a new version of the data has been created in a
* temporary table. It performs a full outer join against the old version of
* the data, producing "diff" results. This join cannot work if there are any
* duplicated rows in either the old or new versions, in the sense that every
* column would compare as equal between the two rows. It does work correctly
* in the face of rows which have at least one NULL value, with all non-NULL
* columns equal. The behavior of NULLs on equality tests and on UNIQUE
* indexes turns out to be quite convenient here; the tests we need to make
* are consistent with default behavior. If there is at least one UNIQUE
* index on the materialized view, we have exactly the guarantee we need.
*
* The temporary table used to hold the diff results contains just the TID of
* the old record (if matched) and the ROW from the new table as a single
* column of complex record type (if matched).
*
* Once we have the diff table, we perform set-based DELETE and INSERT
* operations against the materialized view, and discard both temporary
* tables.
*
* Everything from the generation of the new data to applying the differences
* takes place under cover of an ExclusiveLock, since it seems as though we
* would want to prohibit not only concurrent REFRESH operations, but also
* incremental maintenance. It also doesn't seem reasonable or safe to allow
* SELECT FOR UPDATE or SELECT FOR SHARE on rows being updated or deleted by
* this command.
*/
static void
refresh_by_match_merge(Oid matviewOid, Oid tempOid, Oid relowner,
int save_sec_context)
可以看到,借用了临时表,与老版本进行全外连接,生成"差值",并且新老版本之间不能有重复值,因此这也说明了为什么需要唯一索引。
“This join cannot work if there are any duplicated rows in either the old or new versions,in the sense that every column would compare as equal between the two rows.
所以当带有 concurrently 选项时,物化视图的刷新实际上是借助临时表的插入和删除来完成的,来与老版本一行一行进行比较,然后更新老版本,因此表会膨胀也就不足为奇了,速度也会更慢。
“With the CONCURRENTLY parameter applied, Postgres prepares a temporary updated materialized view version. The system will compare the two versions and identify the differences between them. Then, it will only apply the changes to the original materialized view table using the standard INSERT and UPDATE operations. This Postgres REFRESH MATERIALIZED VIEW method leaves the original table unlocked and available for the tasks to perform on it.
PostgreSQL has implemented the CONCURRENTLY option in the 9.4 version. Therefore, you need this version or higher to use the command without losing access to the table while refreshing it.
小结
有舍有得,concurrently 的引入使得物化视图可以不阻塞查询,但是代价就是会引入表膨胀。物化视图有其自己特定场景的应用,我们知道假如是普通视图的话,其本质是一个"壳",在生成执行计划的时候会有"视图推入",优化器会自动展开视图,进行一些谓词的推入,但是有些场景你可能会发现优化器选择错误,这个时候你就得自己进行改造。
而物化视图就不存在这些问题,它是预先生成的数据,像 join/filter 等都已经提前做好,并且你也可以收集其统计信息,但是坏处就是 refresh all-or-nothing,另外你要做查询的时候你得知道它的数据不是最新的,毕竟是 instantly stale。
OK,此期就聊到这里。关于物化视图更多原理和细节,让我们下期继续。
参考
https://blog.devart.com/postgresql-materialized-views.html
https://github.com/sraoss/pg_ivm
Materialised views now and the future