OceanBase源码解读:物化视图刷新信息缓存任务 ob_mview_update_cache_task

在 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 是否刷完”的判定逻辑,就很容易看清它的数据来源了。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注