物化视图的刷新统计是一份「会自己长大」的数据:每一次刷新、每一次重试、每一条刷新语句都会往内部表里写记录。如果不做清理,这些诊断类数据会无限膨胀,最终拖慢整个租户的元数据访问。OceanBase 4.3 把清理逻辑收敛到一个只有 88 行的工具文件 storage/mview/ob_mview_refresh_stats_purge.cpp 里,类名 ObMViewRefreshStatsPurgeUtil,全静态方法,只有一个入口 purge_refresh_stats。代码虽短,但它同时要处理三件事:三张明细表的级联删除、聚合行的引用计数维护、以及为上层定时任务提供准确的「清理配额」语义。本文按「四表模型 → 核心数据结构 → 五阶段流程 → 两个设计细节」的顺序拆开看。
一、四表模型:明细与聚合的 1:N 关系
理解这个函数的前提是先理解它操作的四张内部表:
| 内部表 | 粒度 | 作用 |
|---|---|---|
__all_mview_refresh_run_stats |
一个 refresh_id 一行 | 运行级聚合,字段 num_mvs_current 表示本轮运行还有多少 MV 的统计仍然存活 |
__all_mview_refresh_stats |
(refresh_id, mview_id, retry_id) | 记录级明细,一次刷新尝试一行 |
__all_mview_refresh_change_stats |
同上三元主键 | 变更量明细(插入/更新/删除行数) |
__all_mview_refresh_stmt_stats |
同上三元主键 | 语句级明细(每条刷新 SQL 的耗时) |
聚合表与明细表是 1:N。这就带来一个不能回避的问题:如果只删过期明细,而某个 refresh_id 下的明细被删空了,聚合行仍然留在表里,用户查 DBMS_MVIEW_STATS 视图时会看到一条没有任何明细支撑的空统计。所以清理必须同步维护聚合行的引用计数——删一条明细,对应 refresh_id 的 num_mvs_current 减一,减到 0 就把整行删掉。
这个 util 被三条完全不同的路径复用,这也是它被抽成无状态类的直接原因:
rootserver/mview/ob_mview_refresh_stats_maintenance_task.cpp:定时维护任务,主要入口,按配额轮转清理。rootserver/mview/ob_mview_maintenance_task.cpp:在更宽泛的 MV 维护流程里顺带清理。- PL 侧
DBMS_MVIEW_STATS.PURGE_REFRESH_STATS经 executor 落到同一函数:用户手动清理。
二、核心数据结构
ObMViewRefreshStatsRecordId:三元主键 refresh_id_ / mview_id_ / retry_id_。注意 retry_id 的存在——同一次刷新如果因为锁冲突或失败重试,会产生多行明细,所以删除粒度必须精确到 retry_id,不能只按 (refresh_id, mview_id) 删。
ObMViewRefreshStats::FilterParam:只有两个可选条件,用位域 + union 表达「是否设置过」:
uint64_t mview_id_; // 按单个 MV 清理
int64_t retention_period_; // 按保留天数清理
union {
uint64_t flag_;
struct {
uint64_t has_mview_id_ : 1;
uint64_t has_retention_period_ : 1;
uint64_t reserved : 62;
};
};
这种 has_xxx 位标记的写法在 OceanBase 里非常常见:它让「没设」和「设成了默认值」可以区分开。is_valid() 相应要求——设了 mview_id 就不能是 OB_INVALID_ID,设了 retention_period 就必须大于 0。retention_period == -1 的语义是「永不清理」,由调用方短路,压根不会进这个函数。
dec_val_map:ObHashMap<int64_t, int64_t, NoPthreadDefendMode>,键是 refresh_id,值是本次要减掉的明细条数。两个细节值得注意:NoPthreadDefendMode 声明「确定单线程使用」,省掉哈希桶内的线程防御开销;label 统一用 "MVStatsPurge",让这部分内存能归因到本模块。
三、五阶段流程
整个函数是标准的「错误码瀑布」结构,五阶段严格顺序执行,任一阶段失败立即跳出并返回第一个错误码,不做补偿回滚——部分删除是被允许的,下一次调度会继续清理剩下的数据。
阶段 1:一次 SELECT 捞全待删主键。为什么不边查边删?两个原因:三张明细表都要按同一组主键删,必须共享同一份主键列表;MySQL 协议下流式结果集上再发 DELETE 不安全,会有游标失效问题。SELECT 的 WHERE 条件由 FilterParam 动态拼接,保留期翻译成 end_time < date_sub(now(), interval N day),limit 直接透传成 SQL 的 LIMIT:
select refresh_id, mview_id, retry_id from __all_mview_refresh_stats
where tenant_id = 0
[and mview_id = ?]
[and end_time < date_sub(now(), interval ? day)]
limit ?
这里的 tenant_id = 0 是 OceanBase 内部表访问的固定写法:系统租户视图里用 0 表示「本租户」占位,真实租户由连接层的 exec_tenant_id 决定。
阶段 2:逐条删三张明细表。顺序是 refresh_stats → change_stats → stmt_stats,即先主表后从表,并且用 else-if 瀑布串联,任一表删失败立刻停止,不在错误状态下继续写库:
if (OB_FAIL(ObMViewRefreshStats::drop_refresh_stats_record(...))) {
} else if (OB_FAIL(ObMViewRefreshChangeStats::drop_change_stats_record(...))) {
} else if (OB_FAIL(ObMViewRefreshStmtStats::drop_stmt_stats_record(...))) {
} else if (OB_ISNULL(dec_val = dec_val_map.get(record_id.refresh_id_))) {
if (OB_FAIL(dec_val_map.set_refactored(record_id.refresh_id_, 1))) { ... }
} else {
*dec_val += 1; // 原地累加,get 返回桶内值指针,无需二次写入
}
阶段 3:批量减计数。把内存里累计的「每个 refresh_id 该减多少」一次性落库,这是全文件性能设计最集中的地方:
FOREACH_X(iter, dec_val_map, OB_SUCC(ret)) {
const int64_t refresh_id = iter->first;
const int64_t dec_val = iter->second;
ObMViewRefreshRunStats::dec_num_mvs_current(sql_client, tenant_id, refresh_id, dec_val);
}
对应的 SQL 是 update ... set num_mvs_current = num_mvs_current - N where tenant_id = 0 and refresh_id = ?。阶段 4:清空聚合行。drop_empty_run_stats 执行 delete from __all_mview_refresh_run_stats where tenant_id = 0 and num_mvs_current <= 0,条件是全表扫描式的「计数已归零」,而不是按 refresh_id 精确删——它顺手把其他路径遗留的空聚合行一起清掉,相当于一次自我修复的兜底。
阶段 5:改写出参。函数末尾把 affected_rows 重写为 record_ids.count(),而不是 drop_empty_run_stats 返回的聚合行删除数。
四、两个容易读错的设计细节
1. 为什么要把 N 次单行更新合并成 1 次?同一个 refresh_id 往往一次要删掉几十上百条明细(多个 mview_id、多次 retry)。若每删一条明细就对其聚合行做一次 num_mvs_current - 1,会产生大量针对同一行的热点更新:单行锁竞争、undo 膨胀、以及不必要的日志写入。先在内存哈希表里把条数累计起来,最后每个 refresh_id 只发一条 UPDATE ... - N,是最小代价换取最大收益的批量化。
2. affected_rows 的语义为什么被改写成明细条数?因为上层定时任务是按「清理配额」推进度的:
purge_stats_num += affected_rows;
if (affected_rows < limit) { ++mview_idx_; } // 该 MV 已清干净,换下一个
也就是说,调用方用「affected_rows 是否达到 limit」来判断当前 mview 是否还有剩余数据,从而决定下一轮调度是继续清同一个 MV,还是推进到下一个 MV。语义必须与 limit 严格对齐,所以这里必须返回明细条数而不是聚合行数。如果把 drop_empty_run_stats 的返回值直接抛出去,配额判断就会失准,导致调度提前推进、留下永远清不掉的残余数据。
另一个隐含约束是执行顺序不可调换:必须先删明细、再减计数、最后删聚合行。如果反过来先减计数后删明细,就会出现一个窗口期——计数已归零但明细还在,此时若并发查询 DBMS_MVIEW_STATS 视图,会读到自相矛盾的结果(有明细、无聚合)。而且这个函数本身不开事务、不管提交,事务边界完全交给调用方,因此顺序正确性是唯一的一致性保障。
五、小结
这 88 行代码里没有复杂算法,全部价值都在「边界」二字上:
- 所有权边界:util 只做执行,不持有状态、不开事务,连接与提交由调用方负责,因此能同时服务定时任务、维护任务、PL 手动调用三条路径。
- 一致性边界:明细先删、计数后减、聚合行最后回收,用引用计数
num_mvs_current把 1:N 关系维持住。 - 性能边界:主键一次捞全、删除计数内存聚合、SQL 侧 LIMIT 分批,把大表清理切成可控的小块。
- 语义边界:
affected_rows的返回值定义直接决定上层调度的推进逻辑,是最容易被误改、也最需要小心的一处。
想看同类实现可以对比 ob_mlog_purge.cpp(物化视图日志清理器,靠水位线 purge_scn 决定删到哪),两者恰好代表清理类代码的两条基本路线:一条按「引用计数归零」,一条按「时间水位线」。刷新统计这种纯诊断数据适合前者——它没有下游消费者,只关心自己是否还有明细支撑。