上周线上有个报表库,物化视图刷新本来只要3分钟,突然变成40分钟。查了一圈,发现负责维护的同事图省事把基表上的物化视图日志(MATERIALIZED VIEW LOG)给drop了,刷新直接退化成了全量COMPLETE。这个案例我后面细说。今天想认真聊聊Oracle物化视图日志——它是什么、如何用CREATE MATERIALIZED VIEW LOG创建、为什么它能记录基表的DML变更,以及刷完日志去哪了。写这篇的起因是群里常有人问“为什么我的快速刷新不生效”“日志怎么一直涨”“ORA-23413怎么破”,所以我按自己的排查经验,把这一套逻辑完整梳理一遍,给做数据同步、报表中间层、数据仓库的同学一个可以直接抄作业的参考。
1. 物化视图日志解决了什么问题
1.1 从物化视图刷新机制看日志存在的意义
先抛个结论:物化视图日志(Materialized View Log,也叫snapshot log)是Oracle专门为物化视图快速刷新(FAST REFRESH)准备的一张“变更流水账”。它记录基表上发生的INSERT、UPDATE、DELETE等DML操作,物化视图做增量刷新时靠这张账本就知道“哪些行的数据变了、怎么变的”,不需要回源表扫全表比较。
物化视图刷新方式有三种:COMPLETE(全量重建)、FAST(增量刷新)、FORCE(优先FAST,不行就COMPLETE)。其中FAST刷新是生产环境最想要的,因为它只处理变更数据,耗时短、对源库压力小。但FAST刷新有个硬前置条件:基表上必须存在对应的物化视图日志。如果你试图对一个没有日志的表做快速刷新,Oracle会直接抛ORA-23413:materialized view does not have a materialized view log。这时候DBA如果没仔细看报错,直接把刷新改成FORCE甚至COMPLETE,短时间能跑通,但数据量上来后同步窗口越拉越长,最终酿成我开头说的那种线上事故。
用生活里的例子打比方:物化视图相当于一张汇总报表,你没日志的时候每次都要把原始单据重新翻一遍才能出报表,这叫全量刷新;有日志以后,每次只把“新增加的单据”“改过的单据”挑出来更新到报表上,这叫增量刷新。日志就是那本记录单据变更的流水账本。
正是因为日志决定了物化视图能不能走增量路径,选择正确的日志策略、维护好日志状态,就成了数据同步链路里绕不开的环节。本文所有演示基于Oracle 19c,但核心语法和原理在11g到21c都通用。
1.2 DML和DDL的区别,以及日志只关心DML这件事
群里经常有人把DDL和DML混着说,这两个概念虽然只差一个字母,但含义完全不同,对物化视图日志来说更是“一个管、一个不管”的边界。
DML(Data Manipulation Language)是数据操作语言,包括INSERT、UPDATE、DELETE、MERGE,它改变的是表里的数据内容。物化视图日志记录的就是这一类操作。每次对基表执行DML,相关行的主键值或ROWID、操作类型(I/U/D)、变更向量等信息都会写入日志,供后续刷新使用。
DDL(Data Definition Language)是数据定义语言,包括CREATE、ALTER、DROP、TRUNCATE,它改变的是表结构而不是数据内容。物化视图日志不记录DDL操作。这里有个特别阴间的坑:TRUNCATE在Oracle里属于DDL,虽然它把表数据清空了,但不会往物化视图日志里写任何记录。我见过不止一次,开发同学对基表执行了TRUNCATE,然后发现物化视图快速刷新出来的数据还是旧的,或者刷新直接报状态异常,就是因为TRUNCATE没有通过日志“留痕”。
| 对比项 | DML | DDL |
|---|---|---|
| 典型语句 | INSERT、UPDATE、DELETE、MERGE | CREATE、ALTER、DROP、TRUNCATE |
| 改变内容 | 表数据内容 | 表结构/对象定义 |
| 是否记录到物化视图日志 | 是 | 否 |
| 对物化视图的影响 | 可通过快速刷新增量同步 | 可能导致物化视图失效或需要完整刷新 |
这个区别在实际故障排查里特别有用。一旦发现物化视图数据和基表对不上,第一反应不要只盯着日志记录,先查一下有没有人对基表执行过DDL,尤其是TRUNCATE。如果确实执行过,日志和物化视图的一致性已经被破坏,别想着靠续传修补,直接重建物化视图或做一次完整刷新才是正路。
2. CREATE MATERIALIZED VIEW LOG 语法与参数逐项拆解
2.1 从最简到最全的创建语句
先看最基础的创建语句,在基表上建日志只需要一行SQL:
CREATE MATERIALIZED VIEW LOG ON sales;这个写法有什么特点?Oracle默认会采用WITH PRIMARY KEY方式记录日志,也就是说基表必须有主键,日志表里会记录主键列的值。如果基表没有主键,这条语句会直接报ORA-12052之类的错误,此时必须改为WITH ROWID方式:
CREATE MATERIALIZED VIEW LOG ON sales WITH ROWID;但生产环境通常不会用这么简单的写法,因为复杂的快速刷新场景对日志有额外要求。一个真正能应对大多数业务场景的完整写法如下:
CREATE MATERIALIZED VIEW LOG ON sales WITH PRIMARY KEY, ROWID SEQUENCE INCLUDING NEW VALUES PURGE AFTER 30 DAYS;拆开解释一下:
WITH PRIMARY KEY:记录主键列,适合有主键且物化视图通过主键关联的场景。WITH ROWID:记录行的物理地址,适合没有主键或者物化视图需要精确到物理行的场景。SEQUENCE:增加一个序列号,用来区分同一行在短时间内发生的多次DML操作。没有它,快速刷新在某些场景下会分不清先后顺序。INCLUDING NEW VALUES:把UPDATE之后的新值也写进日志,聚合类物化视图做快速刷新时基本必备。PURGE AFTER 30 DAYS:日志条目最多保留30天,超期自动清理,防止日志无限膨胀。
这些参数不是随便乱加的,后面2.2和2.3会单独讲选型逻辑。建日志时还会自动创建一系列内部对象,最核心的是MLOG$_基表名这个日志表,这个在第三章展开。
2.2 参数选择的判断标准:主键还是ROWID
主键和ROWID两种方式各有适用场景,选错了后面维护成本会明显上升。先说结论:基表有稳定主键且物化视图通过主键关联的,优先用WITH PRIMARY KEY;基表没有主键,或者物化视图在SELECT里明确需要访问ROWID的,用WITH ROWID。
为什么优先主键?主键语义稳定。业务表的主键在数据刷新过程中不会频繁变化,即使行的物理位置变了(比如表重建、分区移动),主键还是那个主键,物化视图可以通过主键关联正确找到对应的刷新目标。ROWID是物理地址,一旦行迁移、分区合并,ROWID就变了,物化视图里的历史ROWID就失效了。
但有些表天生没有主键,比如某些日志流水表、外部接入数据表,这时候只能退而求其次用WITH ROWID。需要特别留意的是,当物化视图本身需要快速刷新且基于连接查询时,SELECT列表里必须带上各基表的ROWID,否则Oracle拒绝走FAST路径。
实际开发里还有一种常见组合:WITH PRIMARY KEY, ROWID同时带上。这不是画蛇添足,而是为了让物化视图在不同刷新策略之间灵活切换。只带主键时,如果物化视图的查询里不小心漏了主键列,快速刷新可能会报错;同时带上ROWID能给优化器更多选择。代价是日志表会多存一列物理地址,占用一点空间,但通常是值得的。
在性能敏感的大表上建日志前,评估一下DML开销非常必要。日志的写入在DML发生时同步进行,等于每次INSERT/UPDATE/DELETE都增加了一次额外的写入成本。实测下来,字段越多、包含新值日志开销越大,尤其是UPDATE频繁的表,日志写入成本可能让整体DML性能下降10%到20%。如果业务不能接受,可以退而求其次只记录必要字段,或者调整刷新频率减少日志积压。
2.3 日志的清理策略:PURGE参数
物化视图日志如果不加清理策略,会像流水账一样一直积累。正常情况下,物化视图刷新完成后会消费掉这些日志,但刷新频率低、刷新失败、存在多个物化视图共用同一日志的时候,日志会积压,几十万上百万行都是可能的。日志表太大,不仅占用空间,还会让后续每次刷新扫描日志变慢,形成恶性循环。
Oracle提供PURGE子句来控制日志保留期限。最简单的写法是按天数清理:
CREATE MATERIALIZED VIEW LOG ON sales WITH PRIMARY KEY PURGE AFTER 15 DAYS;也可以写成基于刷新次数的形式:
CREATE MATERIALIZED VIEW LOG ON sales WITH PRIMARY KEY PURGE AFTER 5 REFRESHES;更灵活的是定时清理,比如每天凌晨清理一次:
CREATE MATERIALIZED VIEW LOG ON sales WITH PRIMARY KEY START WITH SYSDATE NEXT SYSDATE + 1;这里有一个很重要的实操细节:PURGE策略只是在日志条目“不再需要”的时候才允许清理,如果某个物化视图还没有完成刷新,日志就算到了保留期限也不能被清,Oracle会先保证刷新一致性。所以不要以为加了PURGE就可以高枕无忧,刷新失败时日志照样会涨。日常维护中,我习惯用下面这条SQL查看日志大概占了多少空间:
SELECT segment_name, bytes / 1024 / 1024 AS size_mb FROM user_segments WHERE segment_name IN ('MLOG$_SALES', 'TMP$_SALES', 'RUPD$_SALES');如果发现MLOG$_表已经涨到几GB,先查物化视图刷新状态,确认没有刷新锁,再手动清理:
BEGIN DBMS_MVIEW.PURGE_LOG( master => 'SALES', num => 100000, flag => 'DELETE' ); END;num参数表示每次删除的批大小,太大容易产生大量归档日志,太小又删得慢,实践中10000到100000之间比较合适。手动清理治标不治本,核心还是保证物化视图刷新按时成功,让日志正常消费。
3. 物化视图日志的内部结构与实践验证
3.1 MLOG$_表的真实结构
光说不练假把式。我建一张测试表,然后建日志,带大家看看日志表里到底存了什么。先执行:
CREATE TABLE sales ( id NUMBER PRIMARY KEY, product_id NUMBER, quantity NUMBER, amount NUMBER ); CREATE MATERIALIZED VIEW LOG ON sales WITH PRIMARY KEY, ROWID SEQUENCE INCLUDING NEW VALUES;此时Oracle自动创建了MLOG$_SALES、TMP$_SALES等对象。查看日志表结构:
DESC MLOG$_SALES;你会看到类似这样的列:SNAPTIME$$、DMLTYPE$$、OLD_NEW$$、CHANGE_VECTOR$$、ID、ROWID、SEQUENCE$$。简单解释几个关键列:
- SNAPTIME$$:记录这条日志被哪个刷新批次处理,刷新完成后Oracle用系统时间戳标记,表示这条日志已消费。
- DMLTYPE$$:操作类型,I代表INSERT,U代表UPDATE,D代表DELETE。
- OLD_NEW$$:标示新旧值,O代表旧值,N代表新值。
- CHANGE_VECTOR$$:变更向量,记录UPDATE具体改了哪些列,用于精准刷新物化视图。
- SEQUENCE$$:WITH SEQUENCE时才会真正写入,用来保证同一行的多次变更顺序正确。
- ID、ROWID:主键值或物理地址,是物化视图定位目标行的关键。
除了MLOG$_表,系统中还会出现TMP$_SALES、RUPD$_SALES之类的辅助表。TMP$_是刷新过程中的临时中转表,RUPD$_在新值日志场景下记录UPDATE的新旧值。这些表不用手动维护,但如果看到它们占用空间异常,多半是刷新卡死或有人手工动过日志,需要留意。
对照一下最容易混淆的概念:物化视图日志不是物化视图的数据副本,它只记录“变更信息”,不存全量数据。这也是它轻量、适合频繁刷新的原因。
3.2 一次DML操作在日志里到底发生了什么
我们可以通过一个完整操作链,看日志怎么记录、怎么被消费。接着上面的表和日志,对sales执行几条DML:
INSERT INTO sales VALUES (1, 100, 2, 200); INSERT INTO sales VALUES (2, 100, 1, 100); UPDATE sales SET quantity = 5 WHERE id = 1; DELETE FROM sales WHERE id = 2; COMMIT;查看日志表内容:
SELECT DMLTYPE$$, OLD_NEW$$, ID, ROWID, SEQUENCE$$ FROM MLOG$_SALES ORDER BY SEQUENCE$$;结果会出现多行记录:两条INSERT的I/N记录、一条UPDATE的U/N记录(因为INCLUDING NEW VALUES记录了新值)、一条DELETE的D/O记录。每条日志都包含了主键ID和ROWID,这样物化视图刷新时就能拿着这些信息去同步对应行。我在测试环境里模拟过,如果去掉INCLUDING NEW VALUES,UPDATE操作在日志里通常只有U/O旧值记录,基于聚合的物化视图刷新逻辑会非常受限,这也是很多聚合物化视图快速刷新建不出来的原因。
接下来创建物化视图并做快速刷新:
CREATE MATERIALIZED VIEW mv_sales_summary REFRESH FAST ON DEMAND AS SELECT product_id, SUM(quantity) AS total_qty, SUM(amount) AS total_amount FROM sales GROUP BY product_id; BEGIN DBMS_MVIEW.REFRESH('MV_SALES_SUMMARY', 'F'); END; /刷新完成后再次查询MLOG$_SALES,会发现日志已经被清空或标记。刷新动作本质上就是“读取日志、把变更合并到物化视图、把处理过的日志清理掉”三步。如果对基表继续做一批新DML,日志会重新累积,下一个刷新批次再继续消费,周而复始。
实操中建议用以下语句验证物化视图到底能不能FAST刷新:
BEGIN DBMS_MVIEW.EXPLAIN_MVIEW('MV_SALES_SUMMARY'); END; /EXPLAIN_MVIEW结果里专门有一列CAPABILITY_STATUS,如果显示REFRESH_FAST_AFTER_INSERT为POSSIBLE,说明快速刷新路径是通的;如果显示IMPOSSIBLE,后面会跟原因,比IRREFRESHABLE之类的提示友好得多。这一步在建好物化视图后马上做,别等到上线了才发现刷新路径有问题。
4. 常见错误与排查技巧实录
4.1 ORA-23413、ORA-12034、ORA-12031等经典报错
物化视图日志相关的报错,很多都是“日志缺失”“刷新滞后”“日志被清”这三类问题,我整理了一张速查表,都是实践里真实见过的场景:
| 报错信息 | 常见原因 | 处理方式 |
|---|---|---|
| ORA-23413: materialized view does not have a materialized view log | 基表上没建日志,或日志被drop | 重新创建物化视图日志 |
| ORA-12034: materialized view log younger than last refresh | 日志记录时间比物化视图最近刷新时间还新,刷新历史不一致 | 重新完整刷新或重建物化视图 |
| ORA-12031: materialized view log on table conflicts with index | 日志相关对象与索引冲突 | 检查同名索引/对象,重建日志对象 |
| ORA-12052: cannot fast refresh materialized view | 物化视图SQL结构不满足快速刷新条件 | 检查物化视图定义,补全ROWID/聚合条件 |
| ORA-3232: cannot use materialized view log because it references X | 日志里缺少物化视图所需列 | 重建日志并包含必要列 |
ORA-12034是高频问题,尤其常见于有人对基表执行了手动清理日志操作,或者物化视图刷新历史信息被重置。遇到它别硬撑,直接执行一次完整刷新让一致状态恢复:
BEGIN DBMS_MVIEW.REFRESH('MV_SALES_SUMMARY', 'C'); END; /如果物化视图很多,可以考虑重建相关日志并刷新所有归属它的物化视图,保证日志版本和物化视图版本对齐。一般情况下不到万不得已不建议直接动日志,动错一步比报错更麻烦。
4.2 日志膨胀和手动清理实战
日志膨胀是DBA咨询量很高的问题。表现是MLOG$_表占了几GB,查询和刷新都变慢。膨胀的原因往往不是单一因素,常见的组合是:物化视图刷新频率低、业务DML量大、刷新失败没有预警。
处理顺序很重要。先查刷新是否正常:
SELECT mowner, master, last_refresh_date, status FROM user_mview_analysis WHERE master = 'SALES';再看刷新锁:
SELECT * FROM v$lock WHERE type = 'JI' AND id1 = (SELECT obj# FROM obj$ WHERE name = 'MLOG$_SALES');确认没有刷新锁或刷新进程卡死后,再执行手动 purge,避免出现“删了日志导致进行中的刷新失败”的连锁问题。我见过有的人一上来就DELETE MLOG$_,不仅没解决膨胀,反而把正在刷新的物化视图搞坏了。
手动清理还有一种常用方法是直接用PURGE_LOG包,前面已经写过。值得一提的是,物化视图日志在刷新完成后会自动删除已处理的条目,正常情况下根本不需要手动清理。如果你发现自己天天在手动清日志,说明刷新链路本身一定有异常,重点排查刷新计划是否正常执行、物化视图是否处于失效状态。
4.3 分区表、连接物化视图对日志的额外要求
很多生产表的体量已经走上分区路线。基表是分区表时,物化视图日志最好也配合分区,否则日志表会成为新的瓶颈。Oracle支持在分区表上创建分区日志,例如:
CREATE MATERIALIZED VIEW LOG ON sales WITH PRIMARY KEY PURGE AFTER 30 DAYS ON PREBUILT TABLE;更稳妥的方式是在建日志时指定分区策略,让日志表和基表按照相同分区键对齐。分区日志的优势是清理历史日志可以走分区裁剪,删除过期日志效率高得多,不会产生海量单条DELETE。如果基表做了分区维护,比如SPLIT、MERGE分区,日志也需要同步维护,这些操作建议放在同一个变更窗口内评估。
再说连接物化视图(多表JOIN)的情况。快速刷新要求每一个参与连接的基表都有物化视图日志,不是只有主表有日志就行。假设物化视图是sales JOIN products,那么sales和products两张表都需要日志,而且物化视图SELECT里通常需要包含各表的ROWID或主键。一个常见坑是:建products日志时只用了WITH PRIMARY KEY,但物化视图里的连接条件引用的是products的ROWID,结果刷新报错,又回去改日志。
聚合物化视图对日志要求更严格。聚合函数、GROUP BY列都必须能被日志和刷新机制支持,COUNT、SUM这类常用聚合还好,AVG在快速刷新下的处理逻辑比较绕,因为需要同时维护COUNT和SUM,才能算回平均值。复杂的分析函数很多根本不支持快速刷新。所以在设计阶段就应当用EXPLAIN_MVIEW验证刷新能力,而不是上线后再补救。
5. 个人实操经验补充
最后分享几条这些年踩坑踩出来的经验。
第一,建日志前先列一个检查清单:基表有没有主键,有就带PRIMARY KEY;物化视图是否需要访问ROWID,需要就带ROWID;物化视图是不是聚合类,是就必须带SEQUENCE和INCLUDING NEW VALUES;日志清理策略有没有设置,没设置就补PURGE AFTER N DAYS。四条全过再执行CREATE语句。
第二,生产环境建日志前做DML性能基线对比。选一个业务低峰期,先跑一批代表业务特征的DML,统计耗时;建完日志再跑同一批,对比增量。日志列越少、不记录新值,开销越小。如果UPDATE量极大,可以考虑只记录主键加SEQUENCE,牺牲部分刷新效率换取DML性能。
第三,监控要跟上。我习惯每天检查一次大日志表的空间占用,查询语句简单直接:
SELECT segment_name, ROUND(bytes / 1024 / 1024, 2) size_mb FROM user_segments WHERE segment_name LIKE 'MLOG$%' ORDER BY size_mb DESC;配合物化视图刷新历史,基本能做到日志问题早发现早处理,不至于等到报表超时再救火。
第四,开发环境尽量模拟生产刷新策略。很多开发库建物化视图时图省事直接REFRESH FORCE,根本没有日志,到了生产环境复制过去就踩ORA-23413。开发阶段就把日志建好,用FAST刷新跑通,上线时能少很多折腾。
物化视图日志是Oracle增量同步体系里非常关键的一块拼图,创建语句不复杂,但参数选型、生命周期管理、异常诊断环环相扣。希望这份实操经验能帮大家少走一些弯路,遇到快速刷新相关问题时,至少有一个清晰的排查方向。