OceanBase源码解读:物化视图刷新统计清理任务

物化视图(Materialized View)每次刷新都会在系统表 __all_mview_refresh_stats 里记录一条刷新统计:刷新耗时、刷新 SCN、成功与否等。这些统计是运维观测的重要依据,但如果只写不删,长期运行下来这张表会无限膨胀。OceanBase 4.3 为此引入了一个专门的后台任务——ObMViewRefreshStatsMaintenanceTask,它按用户为每个物化视图设置的保留期(retention_period),周期性地清理过期的刷新统计记录。本文解读它的实现文件 rootserver/mview/ob_mview_refresh_stats_maintenance_task.cpp,重点看两个设计亮点:一是”单拍自续”的定时器调度模式,二是游标驱动的分批限流清理。

一、架构位置与整体设计

这个任务运行在 rootserver(RS)侧,继承自 ObTimerTask,入口是 runTimerTask。它和之前解读过的 timer、clean_snapshot、update_deps 等 rootserver/mview 后台任务家族是同一套工程范式:生命周期 init / start / stop / destroy 四件套,加上一个按状态分发的微型状态机。

任务的状态机只有三个工作阶段:

enum class StatusType {
  PREPARE,   // 版本兼容检查
  PURGE,     // 核心清理阶段
  SUCCESS,   // 本轮成功收尾
  FAIL       // 本轮失败收尾
};

每拍定时器触发时只执行当前状态对应的一小步工作,做完通过 switch_status 切换状态,并主动 schedule 下一拍。这就是”单拍自续”:定时器不设 repeat,跑一拍即止,下一拍由任务自己在状态切换时投递。

二、”单拍自续”调度模式

为什么不直接用一个 repeat 定时器一次跑完全量清理?答案在大事务风险:一个租户下可能有成百上千个物化视图,每个视图的统计记录数量也不可控,一口气删完可能产生巨大的删除事务、长时间持有锁。分拍执行把工作量切成了每拍一小份,拍与拍之间让出线程,天然实现了限流与重试退避。

状态切换的统一入口 switch_status 是整个状态机的咽喉,所有转换规则集中在这里:

void ObMViewRefreshStatsMaintenanceTask::switch_status(
    StatusType new_status, int ret_code)
{
  int ret = OB_SUCCESS;
  if (OB_LIKELY(OB_SUCCESS == ret_code)) {
    status_ = new_status;              // 成功:直接切新态
  } else if (is_retry_ret_code(ret_code)) {
    // OB_EAGAIN:保持原态,下一拍原地重试
  } else {
    status_ = StatusType::FAIL;        // 其他错误:进 FAIL 终态
    error_code_ = ret_code;
  }
  if (in_sched_ &&
      OB_FAIL(schedule_task(MVREF_STATS_MAINTENANCE_SCHED_INTERVAL,
                            false /*repeat*/))) {
    LOG_WARN("fail to schedule mvref stats maintenance task", KR(ret));
  }
}

三类结果三条路:成功切新态;可重试错误(目前只有 OB_EAGAIN)保持原态重试;其他错误进 FAIL 记录错误码。无论哪条路,最后一行都会排下一拍——这就是”自续”的实现点。可重试判定集中在 is_retry_ret_code,避免各阶段各自为政地重试导致死循环。

三、PREPARE:升级窗口期的自保护

PREPARE 阶段只做一件事:检查租户的最小数据版本。这个任务的清理逻辑依赖 4.3 才有的统计表结构,如果集群正在滚动升级、还有老版本副本,贸然执行会产生兼容性问题:

if (OB_FAIL(GET_MIN_DATA_VERSION(tenant_id_, compat_version))) {
  LOG_WARN("fail to get data version", KR(ret), K_(tenant_id));
} else if (OB_UNLIKELY(compat_version < DATA_VERSION_4_3_0_0)) {
  ret = OB_EAGAIN;   // 版本未就绪,返回可重试错误
  LOG_WARN("version lower than 4.3, try again", ...);
}
switch_status(StatusType::PURGE, ret);

版本不达标返回 OB_EAGAIN,switch_status 会让任务保持 PREPARE 原地重试,直到升级完成。这是 OceanBase 后台任务在升级窗口期的标准自保护姿势,值得在自研系统里借鉴。

四、PURGE:游标驱动的分批清理

PURGE 是核心阶段,本质是一个”拉一批 → 清一批”交替推进的双层循环,由两个游标控制进度:

  • mview_ids_ / mview_idx_:当前批次的物化视图 ID 列表和批内下标;
  • last_fetch_mview_id_:翻页游标,记录上一批最后一个 ID,实现增量拉取。

每拍进来先判断批内游标是否走完,走完就翻页拉取下一批:

if (mview_idx_ >= mview_ids_.count()) { // fetch next batch
  mview_ids_.reset();
  mview_idx_ = 0;
  if (OB_FAIL(ObMViewInfo::batch_fetch_mview_ids(*GCTX.sql_proxy_,
        tenant_id_, last_fetch_mview_id_, mview_ids_,
        MVIEW_NUM_FETCH_PER_SCHED))) {
    LOG_WARN("fail to batch fetch mview ids", ...);
  } else {
    fetch_mview_num_ += mview_ids_.count();
    // 拉到的数量小于配额,说明已是最后一页
    fetch_finish_ = mview_ids_.count() < MVIEW_NUM_FETCH_PER_SCHED;
    if (!mview_ids_.empty()) {
      last_fetch_mview_id_ = mview_ids_.at(mview_ids_.count() - 1);
    }
  }
}

批内有 ID 就进入清理循环,单拍删除行数受 MVREF_STATS_NUM_PURGE_PER_SCHED 限制:

while (OB_SUCC(ret) && mview_idx_ < mview_ids_.count() &&
       purge_stats_num < MVREF_STATS_NUM_PURGE_PER_SCHED) {
  const uint64_t mview_id = mview_ids_.at(mview_idx_);
  const int64_t limit = MVREF_STATS_NUM_PURGE_PER_SCHED - purge_stats_num;
  // 先取该物化视图的保留期配置
  if (OB_FAIL(ObMViewRefreshStatsParams::fetch_mview_refresh_stats_params(
        *GCTX.sql_proxy_, tenant_id_, mview_id, refresh_stats_params,
        true /*with_sys_defaults*/))) {
    ...
  } else if (refresh_stats_params.get_retention_period() == -1) {
    affected_rows = 0;               // -1 表示永不清理,跳过
  } else {
    filter_param.set_mview_id(mview_id);
    filter_param.set_retention_period(
        refresh_stats_params.get_retention_period());
    // 带 limit 的删除:affected_rows 返回实际删除行数
    if (OB_FAIL(ObMViewRefreshStatsPurgeUtil::purge_refresh_stats(
          *GCTX.sql_proxy_, tenant_id_, filter_param,
          affected_rows, limit))) { ... }
  }
  if (OB_SUCC(ret)) {
    purge_stats_num += affected_rows;
    if (affected_rows < limit) {
      ++purge_mview_num;
      ++mview_idx_;   // 该视图已删干净,才推进到下一个
    }
  }
}

这段代码有三个细节值得注意。其一,retention_period == -1 是”永不清理”的哨兵值,用户没设保留期时统计记录永久保留。其二,删除调用带了 limit 参数,由底层工具类保证单次 DELETE 不超过限流额度——限流是在 SQL 层面强制生效的,不只靠循环条件。其三,只有当 affected_rows < limit(说明这个视图的过期统计已删光)才推进 mview_idx_;如果删满了配额还没删完,下一拍会继续从当前视图删,保证单视图不被中途跳过。

当 fetch_finish_(翻页到头)且批内游标走完时,本轮 PURGE 结束,切换到 SUCCESS。

五、finish 与 cleanup:观测与复位

finish 是 SUCCESS 和 FAIL 共用的收尾,它输出一条信息量很大的日志——总耗时、各阶段耗时、扫描的物化视图数、清理的记录数、错误码全部在内。线上排查”统计表为什么没清干净”时,这条日志就是第一现场:

cost_us_ = ObTimeUtility::current_time() - start_time_;
LOG_INFO("mvref stats maintenace task finish", K(tenant_id_), K(round_),
         K(status_), K(error_code_), K(cost_us_), K(prepare_cost_us_),
         K(purge_cost_us_), K(fetch_mview_num_), K(purge_mview_num_),
         K(purge_stats_num_));
cleanup();
// 按 MVREF_STATS_MAINTENANCE_INTERVAL(完整轮次间隔)排下一轮
if (in_sched_ && OB_FAIL(schedule_task(
      MVREF_STATS_MAINTENANCE_INTERVAL, false /*repeat*/))) { ... }

注意两个间隔的区分:轮内各拍之间用 MVREF_STATS_MAINTENANCE_SCHED_INTERVAL(短间隔),轮与轮之间用 MVREF_STATS_MAINTENANCE_INTERVAL(长间隔)。cleanup 把所有轮内状态复位——游标、计数器、耗时统计全部归零,与构造函数初值一一对应,但不动 in_sched_、is_inited_ 等生命周期标志,保证下一轮能无缝开始。

小结

ob_mview_refresh_stats_maintenance_task.cpp 只有 291 行,但浓缩了 OceanBase 后台任务的两个经典模式:单拍自续的定时器调度(每拍一小步、状态切换时自排下一拍、可重试错误原地等待)和游标驱动的分批限流(翻页拉取 ID、单拍删除额度、删净才推进游标)。前者把不可控的大工作量切成可控的时间片,后者把不可控的删除事务切成可控的小事务。加上升级窗口期的版本自保护和收尾时的全量观测日志,这个文件可以作为”如何写一个生产级后台清理任务”的参考模板。至此 rootserver/mview 目录下的后台任务家族(timer、clean_snapshot、mds_op、update_deps、update_cache、push_snapshot、refresh_stats_maintenance)已全部解读完毕,后续将转入 storage/mview 分支,看刷新执行侧的实现。

发表回复

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