在 OceanBase 4.3 的物化视图(Materialized View)体系里,有一类 MV 的刷新节奏被绑定在基表的合并/冻结节拍上,也就是 REFRESH_MODE = MAJOR_COMPACTION。合并线程推进 major freeze 时,必须反复回答一个问题:这些 MV 里的数据是否已经刷到当前版本?这个判定处在极热路径上,如果每次都去扫一遍元数据表 __all_mview,代价完全不可接受。于是就有了本文要解读的 ob_mview_update_cache_task.cpp——一个专职”抄表”的后台任务:它每 5 秒把 __all_mview 里每个 MV 的 LAST_REFRESH_SCN 抄进内存哈希表,让热路径只查内存。整个文件不到 200 行,却是理解 OceanBase「持久化元数据 + 内存旁路缓存」这套读写分离设计的好样本。
一、任务在系统中的位置
该任务类 ObMviewUpdateCacheTask 继承自 ObMViewTimerTask,后者是 ObSharedTimer 的外壳封装:把”任务对象”注册到租户级共享定时器上,定时回调 runTimerTask()。数据流非常单向:
__all_mview(租户本地系统表)
│ 每 5s 一轮 SELECT(仅 MAJOR_COMPACTION 模式)
▼
ObMviewUpdateCacheTask::runTimerTask()
│ extract_sql_result() 摊平成三个平行数组
▼
ObMViewMaintenanceService::update_mview_refresh_info_cache()
│ 增量比对:新增/变更才写,消失的删除
▼
mview_refresh_info_cache_(hash: mview_id -> refresh_scn)
值得注意的是,这个任务对象本身几乎是 POD:只有三个 bool 成员(is_inited_ / is_stop_ / in_sched_),不持有任何句柄。它干活时要用的 sql_proxy 和 ObMViewMaintenanceService,都是在回调里通过 GCTX.sql_proxy_ 和 MTL(ObMViewMaintenanceService*) 现取的。这样任务随租户创建销毁的代价接近零,也天然是租户粒度的——每个租户各扫各的表,互不干扰。
二、核心数据结构与 SQL
缓存里只存 SCN,但查询会把 REFRESH_MODE 一起取回来,供下游做二次校验(防御式编程,即使 SQL 将来被改成拉全量,缓存语义也不会被破坏):
SELECT CAST(MVIEW_ID AS UNSIGNED) AS MVIEW_ID,
LAST_REFRESH_SCN,
CAST(REFRESH_MODE AS UNSIGNED) AS REFRESH_MODE
FROM `oceanbase`.`__all_mview`
WHERE TENANT_ID = 0 and REFRESH_MODE = %d
三个细节值得单独说:CAST(… AS UNSIGNED) 是防御性转换,保证 get_uint() 取值路径稳定;TENANT_ID = 0 并不是”只查 sys 租户”,而是因为租户本地系统表本身已按租户物理隔离,tenant_id 列固定写 0,这是 OB 系统表的一贯约定;反引号包库表名则避免保留字冲突。
结果集用三个 ObSEArray<uint64_t, 2> 承接。栈上预留 2 个槽位,绝大多数租户的 MAJOR_COMPACTION MV 数量都很少,能完全避免堆分配,超出容量才自动退化到堆——这是 OB 容器的经典渐进策略。执行结果对象则用 SMART_VAR 放在栈上(大页内联),处理短命大对象时不产生堆碎片。
三、关键流程
3.1 生命周期:init / start / stop / destroy
int ObMviewUpdateCacheTask::start()
{
int ret = OB_SUCCESS;
if (IS_NOT_INIT) {
ret = OB_NOT_INIT;
} else {
is_stop_ = false;
if (!in_sched_ && OB_FAIL(schedule_task(TaskDelay, true /*repeat*/))) {
LOG_WARN("fail to schedule update mview cache task", KR(ret));
} else {
in_sched_ = true;
}
}
return ret;
}
void ObMviewUpdateCacheTask::stop()
{
is_stop_ = true;
in_sched_ = false;
cancel_task();
}
in_sched_ 是”已挂载到定时器”的幂等标记,防止重复 start 导致同一任务被注册多次、5 秒内被触发多轮。这里 schedule_task(TaskDelay, repeat=true) 由定时器框架周期回调,和 ob_mlog_maintenance_task 那种”任务体内自己 switch_status 续期”的一次性任务风格不同。停机顺序也是敏感的:先置停止位、再 cancel_task,否则取消瞬间落地的新一轮任务看不到停止位,会白跑一次全表扫描。destroy() 则额外 wait_task(),等正在执行的回调真正结束,避免租户销毁过程中定时器线程还在用已释放的 sql_proxy。
3.2 回调主体:runTimerTask 四步走
void ObMviewUpdateCacheTask::runTimerTask()
{
int ret = OB_SUCCESS;
const uint64_t tenant_id = MTL_ID();
ObMySQLProxy *sql_proxy = GCTX.sql_proxy_;
ObMViewMaintenanceService *service = MTL(ObMViewMaintenanceService*);
if (OB_ISNULL(sql_proxy) || OB_ISNULL(service)) {
ret = OB_ERR_UNEXPECTED;
} else if (!is_valid_tenant_id(tenant_id)) {
ret = OB_ERR_UNEXPECTED;
} else {
const int64_t refresh_mode = (int64_t)ObMVRefreshMode::MAJOR_COMPACTION;
ObSEArray<uint64_t, 2> mview_ids, mview_refresh_scns, mview_refresh_modes;
SMART_VAR(ObMySQLProxy::MySQLResult, res) {
// 拼 SQL -> sql_proxy->read() -> extract_sql_result()
}
if (OB_FAIL(ret)) {
// do nothing,等下一轮
} else if (OB_FAIL(service->update_mview_refresh_info_cache(
mview_ids, mview_refresh_scns, mview_refresh_modes))) {
LOG_WARN("fail to update mview refresh info cache", K(ret));
}
}
}
两个设计取向很鲜明。第一,函数返回 void:定时器回调的失败只打日志不外抛,本轮失败无所谓,5 秒后自然重试——这就是”抄表”式任务的容错优势。第二,失败就什么都不做,只有三步(拼 SQL、执行、摊平)全成功才更新缓存。这是”宁可不更新,也不能写坏”的保守策略:缓存是旁路加速层,少更新一次只会让下游多一次 SQL 回退,写坏了才会导致判定错乱。
3.3 结果摊平:两段式保证数组对齐
while (OB_SUCC(ret) && OB_SUCC(mysql_result->next())) {
if (OB_FAIL(mysql_result->get_uint(0, mview_id))
|| OB_FAIL(mysql_result->get_uint(1, last_refresh_scn))
|| OB_FAIL(mysql_result->get_uint(2, refresh_mode))) {
LOG_WARN("fail to get int/uint value", K(ret));
} else if (OB_FAIL(res_ids.push_back(mview_id))
|| OB_FAIL(res_scns.push_back(last_refresh_scn))
|| OB_FAIL(refresh_modes.push_back(refresh_mode))) {
LOG_WARN("fail to push back array", K(ret));
}
}
if (OB_LIKELY(OB_SUCCESS == ret || OB_ITER_END == ret)) {
if (OB_FAIL(mview_ids.assign(res_ids))
|| OB_FAIL(last_refresh_scns.assign(res_scns))
|| OB_FAIL(mview_refresh_modes.assign(refresh_modes))) {
LOG_WARN("fail to assign array", K(ret));
}
}
第一段在局部 res_* 数组里逐行攒数据,第二段用 assign() 整体覆盖出参。assign 是”清空 + 拷贝”的原子替换语义,且只在第一段完全成功(ret 为 SUCCESS 或仅 ITER_END)时才做,因此调用方拿到的三个数组必然等长且互相一致,不会出现 id 有 3 个、scn 只有 2 个的错位。每行取值前也预置了无效值(OB_INVALID_ID / OB_INVALID_SCN_VAL / MAX),一旦取值失败,从日志里的数值就能看出是哪一列出问题。
3.4 下游的增量入库
数组交给 update_mview_refresh_info_cache() 后并不是无脑覆盖。它会用一个临时 ObHashSet 记录本轮出现的全部 mview_id,逐条判断:缓存里不存在就插入,存在但 SCN 相同就跳过(need_update = false),SCN 变了才 set_refactored(..., overwrite=1)。最后再扫一遍缓存,把不在本轮集合里的 mview_id 删掉——这样 MV 被 DROP 之后,缓存里的僵尸条目也会在 5 秒内自然清理。整个更新只统计真正变化的条数(update_cache_cnt),而不是每轮全量重建,成本和变更量成正比。
四、小结
这个文件小,但把 OceanBase 处理”元数据 → 热路径”问题的套路展示得很完整:用后台低频任务把持久化元数据投影到内存,用增量比对把更新成本压到与变更量成正比,用”失败即放弃本轮”的保守策略保护旁路缓存不写脏,用栈上小数组 + SMART_VAR 把每 5 秒一次的固定开销压到最低。理解了这一层,再看合并/冻结侧”MView 是否刷完”的判定逻辑,就很容易看清它的数据来源了。