物化视图(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 分支,看刷新执行侧的实现。