OBServer 的进程模型与启动流程
在 PostgreSQL 上做运维,你对这样一个现象习以为常:ps -ef | grep postgres 会列出几十上百行,主进程是 postmaster,每一个客户端连接对应一个 postgres: xxx user db 的 backend 后端进程。客户端连接会映射为 backend 进程,但 ps 看到的全部 postgres 进程,还包含 checkpointer、wal writer、autovacuum 等后台系统进程,并不全是业务连接。连接数高的时候不要盲目调大 max_connections 和共享内存,优先考虑连接池;还要留意 C 语言编写的扩展,这类扩展一旦内存异常会造成单个 backend 进程崩溃。
而换到 OceanBase,ps -ef | grep observer 通常只有一行。几十个租户、几千个客户端连接、上百个后台定时任务,全都塞在这一个进程里。你把这个进程 kill -9,整台机器上的所有租户一起消失,不会像 PG 那样“只断这一条连接”。这不是部署方式的差异,是两种数据库对“隔离”这件事给出的不同答案:OceanBase 把隔离的边界从进程上移到了租户,用一套用户态的调度器 OMT(OceanBase Multi-Tenant)在同一个地址空间里给租户切 CPU、切内存、切线程。换句话说,PG 用操作系统的进程边界做隔离,OceanBase 把这道边界搬进了自己的代码里。
这个差异值得拆开看,因为它决定了你在 OceanBase 上遇到性能问题时的排查路径。PG 里一个慢查询把 CPU 打满是“那个进程”的事,你 strace 那个 backend 就行;OceanBase 里一个租户把 CPU 打满,你要问的是“OMT 为什么没有给它限流”“它的工作线程数为什么是这么多”“它的 CPU 使用率是怎么算出来的”——这些问题都指向进程内部的一套自研调度机制,而不是操作系统的 nice 和 cgroup。换句话讲,在 OceanBase 上做资源问题定位,你得先理解它的调度器,再看业务。
本篇要回答三个具体问题:一个 observer 进程从 main() 到对外提供服务,中间经历了哪些阶段、每个阶段装了什么;这个进程里到底有多少线程、这些线程怎么“属于”某个租户;以及 OMT 那个 TIME_SLICE_PERIOD = 10000 到底是什么——是被很多人描述成“每 10ms 给租户分一次时间片”的抢占式调度,还是别的东西。
第三个问题是本篇的核心,也是最容易被讲错的地方。不少资料把 OMT 描述成“每 10ms 给每个租户分一次 CPU 时间片”,听起来像 Linux 的 CFS 或者 Go 的 GMP。读完 ob_multi_tenant.cpp 你会发现,这个描述只对了一半,而被漏掉的那一半恰恰是理解 OceanBase 进程模型的关键:这个 10ms 周期里没有任何“切换正在运行的线程”的动作,它做的是记账和决策。资源隔离的完整细节(META 租户拆分、token 模型、cgroup 分层)留给其他章节再叙,本篇只把进程、线程、启动和调度的骨架搭清楚,让后面所有篇章有一个共同的坐标系。
1. OBServer 是进程不是机器
官方文档在《参考指南--系统原理》里给的层级是:集群由一个或多个 Region 组成,Region 由一个或多个 Zone 组成,Zone 由一个或多个 OBServer 组成,每个 OBServer 可有若干个 Unit,每个 Unit 可有若干个日志流(Logstream)的副本,每个 Logstream 可使用若干个分片(Tablet)。这是一条从“城市”到“一条数据”的完整链路,也是这个系列后面每一篇都要落回去的地图。你之后读存储篇、复制篇、多租户篇,看到的每一个对象都能在这条链路上找到自己的位置。
关键在 OBServer 这一层的定义。官方文档的原话是“运行 observer 进程的物理机。一台物理机上可以部署一个或多个 OBServer(通常情况下一台物理机只部署一个 OBServer)。在 OceanBase 数据库内部,Server 由其 IP 地址及服务端口唯一标识”。这句话里有两个很容易读快而漏掉的信息:OBServer 在文档里既指机器又指进程——“运行 observer 进程的物理机”是硬件的说法,“由 IP 地址及服务端口唯一标识”是软件实体的说法。日常说“这个集群有 3 台 OBServer”,其实指 3 个 observer 进程;说“这台 OBServer 挂了”,指进程挂了。这两件事在单机单进程部署下是重合的,但概念上必须分开,否则你理解不了“一台物理机部署多个 OBServer”这种部署形态——那是同一台机器上跑多个 observer 进程,每个进程有自己的 IP:port 标识、自己的 ObServer 单例、自己的一套线程池,彼此不共享内存。
Unit 的定义紧跟其后,官方文档写的是“租户在 OBServer 节点上的容器,描述租户在 OBServer 节点上的可用资源(CPU、MEMORY 等)。一个租户在一个 OBServer 只能同时存在一个 Unit”。这句话的措辞很讲究:Unit 是“容器”,不是“运行实体”。真正在跑的是工作线程,Unit 里的 CPU/MEMORY 数字,是 OMT 用来决定“这个租户能开多少线程、能用多少内存”的参数。换句话说,Unit 是给调度器看的账面数字,不是内核里能 cgroup 一个单位扣出来的实体。后面讲 min_worker_cnt() 和 max_worker_cnt() 时,你会反复回到这句话——那两个函数读的正是 Unit 的 unit_min_cpu() 和 memory_size(),Unit 的配置一变,允许的线程数就跟着变。
把这些拼起来,OBServer 的真实身份是:一个用户态进程 + 一批挂在进程内、按租户组织的工作线程与资源队列。它同 PG 那种“进程管理器 + 进程池”的结构不是一回事,它是一个自带调度器的单体服务。这个定语“自带调度器”是理解后面所有权衡的钥匙:既然调度器在进程里,那隔离就得自己做,CPU 记账也得自己做,可观测性还是得自己做——内核不会替你操心“哪个租户用了多少 CPU”,因为它只看到一堆平等的线程。这也解释了为什么 OceanBase 要写 OMT、要写 libc hook、要写一堆租户级监控,它们都是“把调度器搬进进程”这个决定的连带后果。
2. main() 与 16MB 栈
入口在 src/observer/main.cpp 的 main()。第一眼看到的不是 ObServer,而是一段栈操作:它先读进程的 RLIMIT_STACK,有限值就用它,否则默认 size_t stack_size = 16<<20(16MB),然后 ::mmap(nullptr, stack_size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) 申请出一块栈空间,用 CALL_WITH_NEW_STACK(inner_main(argc, argv), stack_addr, stack_size) 把真正的启动逻辑切到这块新栈上跑。这段代码在 main() 里,比 ObServer 的任何东西都靠前,连日志系统都还没起来,出错只能靠 MPRINT 直接写标准输出。判断它有没有生效,最直接的办法是在 main() 里打一行日志看调用栈地址,或者对照 ulimit -s 的值——如果部署时把 ulimit -s 设成了 unlimited,那 16MB 的默认值就不会被用上,实际栈大小取决于环境。
为什么要绕这一下?因为 OceanBase 的调用栈很深——SQL 解析、计划生成、执行递归、存储层的合并迭代都是层层嵌套,而进程在启动时继承的栈限制(ulimit -s)在某些部署环境里可能偏小。在入口处把整个数据库逻辑塞进一块显式申请的、足够大的栈上,是一种数据库工程式的兜底:不依赖部署环境的 ulimit 配置,谁来启动都一样。代价也很直接——这块栈是常驻的,main() 之后的所有代码都跑在它上面,而且它是通过 mmap 独立申请的,不进 glibc 的线程栈管理,退出时还得自己 munmap。
int main(int argc, char *argv[])
{
int ret = OB_SUCCESS;
size_t stack_size = 16<<20;
struct rlimit limit;
if (0 == getrlimit(RLIMIT_STACK, &limit)) {
if (RLIM_INFINITY != limit.rlim_cur) {
stack_size = limit.rlim_cur;
}
}
void *stack_addr = ::mmap(nullptr, stack_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (MAP_FAILED == stack_addr) {
ret = OB_ERR_UNEXPECTED;
} else {
ret = CALL_WITH_NEW_STACK(inner_main(argc, argv), stack_addr, stack_size);
if (-1 == ::munmap(stack_addr, stack_size)) {
ret = OB_ERR_UNEXPECTED;
}
}
return ret;
}
inner_main() 才是真正的启动函数,它做的事按顺序是:先 set_memory_limit(INT_MAX64) 在配置加载前临时解除内存上限,因为后面要解析配置、建目录、开日志;用 pthread_getname_np 把主线程命名为 observer;用 ObStackHeaderGuard 加 sigaltstack 给信号处理注册一块独立栈,并设 ::oceanbase::common::g_redirect_handler = true,这样当某个线程自己的栈爆了、默认信号栈不可用时,信号处理函数还能跑到这块备用栈上把崩溃现场打出来。
int inner_main(int argc, char *argv[])
{
// temporarily unlimited memory before init config
set_memory_limit(INT_MAX64);
#ifdef ENABLE_SANITY
backtrace_symbolize_func = oceanbase::common::backtrace_symbolize;
#endif
if (0 != pthread_getname_np(pthread_self(), ob_get_tname(), OB_THREAD_NAME_BUF_LEN)) {
snprintf(ob_get_tname(), OB_THREAD_NAME_BUF_LEN, "observer");
}
ObStackHeaderGuard stack_header_guard;
// just take effect in observer
int64_t memory_used = get_virtual_memory_used();
紧接着是 ObSignalHandle::change_signal_mask()——第一件“正经事”是改信号掩码,因为后面所有涉及子进程、超时、信号中断的操作都依赖它已经设好,掩码没设对,进程可能被一个不该处理的信号打断。
再往后是一串“环境准备”:parse_opts(argc, argv, opts) 用 getopt_long 解析命令行;依次 create_full_path 建 run/、log/、etc/、audit/、log/alert 五个目录;调用 check_uid_before_start(CONF_DIR) 检查启动用户的 uid 必须和 etc/ 目录的属主一致,防止换用户启动导致权限错乱——这是运维事故里很常见的一类,比如用 root 建了 etc/ 再用 admin 启动,observer 读不到配置又不报明显错误;初始化 curl。
一行 OB_LOGGER.set_file_name(LOG_FILE_NAME, false, true, RS_LOG_FILE_NAME, ELECT_ASYNC_LOG_FILE_NAME, TRACE_LOG_FILE_NAME, audit_file, ALERT_LOG_FILE_NAME) 一次性把 observer.log、rootservice.log、election.log、trace.log、审计日志、alert.log 六个文件挂上;再 print_version、print_all_limits、dl_iterate_phdr 打印加载的动态库和内核限额。
这一串动作的共同点是“必须在日志真正可用之前完成”,因为后面一旦出错,你唯一的排查依据就是这些前置动作留下的输出。
其中有两条 glibc 调参值得单独说:mallopt(M_MMAP_MAX, 1024*1024*1024) 和 mallopt(M_ARENA_MAX, 1)。后者的注释写得很明白——// disable malloc multiple arena pool。glibc 默认会“每个线程一个 malloc arena”,在多线程数据库里,默认的多 arena 会在内存紧张时放大 RSS:每个 arena 独立向内核要内存、独立产生碎片,而且线程在 arena 之间迁移时会带来锁竞争。OceanBase 用自己的内存管理器和 ObMallocAllocator 统一分配,所以关掉 glibc 的多 arena,避免两套分配策略打架。这是“自研内存管理”这条路线必然要付的集成成本:你既然不让 glibc 管内存,就得把它的默认行为关掉,否则它的启发式策略会在你看不见的地方影响 RSS 曲线。
主线程要先给自己占一个 Worker
inner_main() 里有一段看起来很怪的代码,是理解 OceanBase 线程模型的第一把钥匙,它在 ObServer 实例构造之前就执行了:
// Create worker to make this thread having a binding
// worker. When ObThWorker is creating, it'd be aware of this
// thread has already had a worker, which can prevent binding
// new worker with it.
lib::Worker worker;
lib::Worker::set_worker_to_thread_local(&worker);
ObServer &observer = ObServer::get_instance();
// to speed up bootstrap phase, need set election INIT TS ...
ATOMIC_STORE(&palf::election::INIT_TS, palf::election::get_monotonic_ts());
主线程在启动前,先 new 了一个 lib::Worker 对象并把它设进线程局部存储(set_worker_to_thread_local)。注释解释了原因:OceanBase 的工作线程模型里,一个线程要能被调度,必须先绑定一个 Worker 上下文;ObThWorker 在创建时会检查目标线程是否已经绑过 worker,如果没绑,就可能把这个主线程误当作空闲工作线程绑走。一旦主线程被绑走,它就会去跑 SQL,启动流程没人推进,整个进程卡死在这里,表现为日志停在某一行不动、端口又不通,非常难查。
这是本篇第一个小小的反直觉点:在一个用线程池跑 SQL 的系统里,“main 线程”是一个需要被特意保护起来的资源,因为调度器看所有线程是平等的。在 PG 里不存在这个问题——postmaster 主进程和 backend 是两类截然不同的东西,主进程从来不执行查询,它只是 fork 和 wait,内核也不会把主进程派去处理某个连接。OceanBase 把两者放进了同一个线程空间,就必须在启动入口处显式地给主线程“上锁”,防止它被自己的调度器抓去干活。这个细节也解释了后面 wait() 为什么敢用 SLEEP(3) 这种粗粒度轮询——因为主线程根本不参与服务,慢一点无所谓。
紧接着的 ATOMIC_STORE(&palf::election::INIT_TS, palf::election::get_monotonic_ts()) 也很有意思,注释说“to speed up bootstrap phase, need set election INIT TS to count election keep silence time as soon as possible”。选举模块需要一个“进程启动以来的静默计时起点”,越早打越好,所以它被放在进程启动的最初段,而不是等选举模块初始化时才打。这是把“时间起点”和“模块初始化”解耦的一个小动作:计时可以早于模块存在,等选举模块起来时,它读到的静默时长已经是一段真实的、从进程启动算起的时间,而不是从模块初始化算起的时间。这对 bootstrap 阶段的选主速度有直接影响。
ObServer 的生命周期是四段式,ob_server.h 的类注释把总纲写得非常清楚:“This the class definition of ObAddr which responds the server itself. It's designed as a singleton in program. This class is structure aggregated but not logical processing. Please don't put cumbersome logical processes into it.”直译过来是:这是一个聚合结构的类,不做逻辑处理,不要把冗长的业务逻辑塞进来。ObServer 的职责只有两个:持有全局单例对象、按正确顺序调它们的 init/start/stop/wait;真正的业务逻辑在 ObRootService、ObSql、ObService、ObMultiTenant 这些成员自己身上。单例本身是 ob_server.h 里内联的 static ObServer THE_ONE; return THE_ONE;,配套的 OBSERVER 宏(#define OBSERVER (::oceanbase::observer::ObServer::get_instance()))在源码里被大量使用,MYADDR 宏则等价于 OBSERVER.get_self()。整个进程只会构造这一个 ObServer,它的构造时机就是第一次 get_instance() 被调用时,也就是 inner_main() 里 ObServer &observer = ObServer::get_instance(); 那一行。
不过这条注释只是原则,源码里能看到它对原则的妥协。ObServer 的成员列表里除了一堆子系统对象,还直接挂了一堆继承 common::ObTimerTask 的内部类——ObRefreshNetworkSpeedTask、ObRefreshCpuFreqTimeTask、ObRefreshIOCalibrationTimeTask,以及 ObTenantDutyTask duty_task_、ObDiskUsageReportTask disk_usage_report_task_ 等。这些不是“业务”而是“运维探针”:
刷新网卡速率、CPU 主频、IO 校准值,上报磁盘用量。把它们直接挂在 ObServer 上,让这个类从“结构聚合”微微滑向了“任务注册中心”,和“don't put cumbersome logical processes into it”有点张力;但反过来看,如果散落到各子系统,启动期“谁在什么时候被拉起”就不可见了,集中挂着换来的是启动顺序和依赖关系显式可读。这是可维护性对纯粹性的一次让步。
3. init():手工编织的拓扑序
ObServer::init()(src/observer/ob_server.cpp:230)是一条长长的 if-else if 链,每一环失败就立刻 raise(SIGKILL) 自杀——注意不是优雅退出,而是直接 SIGKILL,因为初始化到一半的进程状态是不确定的,继续活着比死掉更危险,可能带着半初始化的数据对外提供服务。读这段代码的正确姿势不是“记它初始化了什么”,而是“看它为什么必须按这个顺序”,因为顺序本身就是依赖声明。开头几行就把顺序逻辑交代了:先 init_config() 加载配置,再 check_os_params(GCONF.strict_check_os_params) 校验内核参数,然后有一段带注释的启动——// start ObTimerService first, because some timers depend on it,对应代码是 ObSimpleThreadPoolDynamicMgr::get_instance().init() 后紧跟 ObTimerService::get_instance().start()。
ObTimerService(deps/oblib/src/lib/task/ob_timer_service.h)必须第一个启动,因为后面所有 ObTimer::schedule(...) 的定时任务都依赖它,schedule 的签名在 deps/oblib/src/lib/task/ob_timer.h:49,是
int schedule(ObTimerTask &task, const int64_t delay, bool repeate = false, bool immediate = false)。
ObServer 自己的跳变探针、duty_task_、sql_mem_task_ 都要往这个全局 timer 上挂,ObServer 里还挂了一堆继承 common::ObTimerTask 的内部类,比如 ObRefreshNetworkSpeedTask(间隔 1 小时,REFRESH_INTERVAL = 1L * 1000L * 1000L)、ObRefreshCpuFreqTimeTask(间隔 10 秒)、ObRefreshIOCalibrationTimeTask(间隔 10 秒)。定时器不先起来,后面每挂一个任务都会失败。这是“基础设施先于消费者”的第一条实例:调度器先于被调度的东西,同时也提前把“时间”这个能力准备好。更准确地说,ObTimerService 本身就是 ObServer 一系列探针任务的宿主,它不起来,整个进程连“每隔 10 秒知道一下自己的状态”都做不到。
再往后是一长串“引擎无关”的静态初始化:sql::init_sql_factories()、sql::init_sql_executor_singletons()、sql::init_sql_expr_static_var()、ObPreProcessSysVars::init_sys_var()、ObBasicSessionInfo::init_sys_vars_cache_base_values()。这些是 SQL 引擎的全局工厂和静态变量,必须在任何 SQL 执行之前完成,所以放在很靠前的位置。它们的共同特征是“无外部依赖的纯静态构造”,因此可以最早做,也必须最早做——一旦某个线程开始解析 SQL,这些工厂就被读到了,届时再去构造就会和并发读产生竞争,而初始化阶段的任何竞争都会变成极难复现的偶发崩溃。所以这段的顺序虽然看起来只是“先做无依赖的事”,实际上是在给后续所有并发行为划一条“这些全局状态在此之前必须已经冻结”的红线。
真正的“重头戏”从这里排开,源码顺序是这样的(全部出自 ob_server.cpp 的 init(),每一环都是前几环的消费者):init_global_context() → init_version() → init_sql_proxy() → ObDeviceManager::init_devices_env() → init_io() → init_restore_ctx() → init_global_kvcache() → init_schema() → init_network() → init_interrupt() → init_plugin() → rs_mgr_.init() → init_ob_service() → init_root_service() → root_service_monitor_.init() → init_sql() → init_sql_runner() → init_sequence() → init_pl() → lst_operator_.init() → tablet_operator_.init() → location_service_.init() → init_autoincrement_service() → init_bandwidth_throttle() → init_storage() → init_ts_mgr() → weak_read_service_.init() → bl_service_.init() → palf::election::GLOBAL_INIT_ELECTION_MODULE() → init_multi_tenant() → 一串 init_xxx_task。
这条链里有几个次序是“反直觉但必有原因”的。init_global_context() 排在 init_schema() 和 init_network() 之前,因为 gctx_ 是整个进程的“服务定位器”——注释说它存的是“should share with all classes using in observer”的指针,而且“whole pointers stored in gctx wouldn't be changed once they're assigned”,所以对外只暴露 const 引用。这些指针必须先可用,后面 init_location_service、init_network 才能把它们挂进去。init_global_kvcache() 又排在 init_schema() 之前,因为 schema 服务自己要用 KV 缓存来存表结构的版本,缓存没起来,schema 的版本管理就没有落脚点。init_schema() 排在 init_network() 之前,因为 Schema 服务要先能加载用户表结构,网络层起来后接收到的 SQL 才有办法做解析和权限判断——网络先起来而 schema 后起来,中间那段窗口里的请求会全部报错。
init_storage() 排在 init_sql() 和 location_service_.init() 之后,这个顺序是“存储是底层、应该先起”这个直觉的反例。OceanBase 的存储初始化并不只是“打开数据文件”:它要注册内部表、建立元数据访问通道、把日志流元数据和 Tablet 元数据的管理器挂到 schema 与 location 上,这些动作依赖 SQL 侧和位置服务侧的能力。更准确地说,OceanBase 的“存储”不是文件系统意义上的底层,而是建立在元数据服务之上的一层——所以它必须在元数据服务之后初始化。如果你带着传统数据库“存储引擎是地基”的印象去读这段,会误以为源码写错了顺序。
init_multi_tenant() 排在几乎最后,它的实现只有十几行(ob_server.cpp:2737):调 multi_tenant_.init(self_addr_, &sql_proxy_),然后 duty_task_.schedule(lib::TGDefIDs::ServerGTimer) 和 sql_mem_task_.schedule(lib::TGDefIDs::SqlMemTimer)。它需要两个东西才能起——self_addr_(自己的 IP:port,由前面的 init_self_addr 建好)和 sql_proxy_(访问内部表的代理,由 init_sql_proxy() 建出来,而 init_sql_proxy() 又依赖 gctx_ 和 schema)。multi_tenant_.init 内部还会用 bucket_lock_.init(OB_TENANT_LOCK_BUCKET_NUM, ...) 初始化租户创建/删除用的分桶锁,并把 ObTenantNodeBalancer 与 sql_proxy 绑定起来做跨节点的配额均衡。这两个依赖决定了它不可能更早——self_addr_ 依赖网络配置解析,sql_proxy_ 依赖内部表访问链路,任何一环没就绪,multi_tenant_.init 都会失败。从这条依赖链能反推出一件事:在 init() 阶段 OMT 是“各子系统就位后的收口”,它是消费者不是生产者;而到了 start() 阶段 OMT 又提前起来,因为那时它要反过来给别的东西提供租户容器。
所以 init_multi_tenant() 被推到最后是依赖链的必然结果,不是随手排的。这里可以下一个判断:ObServer::init() 里那条 if-else if 链,本质上是一个手工编织的拓扑排序。源码没有用任何依赖注入框架或显式的依赖声明,就是靠“谁先谁后”的代码顺序表达依赖。代价是新人必须通读整条链才能改顺序,任何一处插队都可能踩到隐藏依赖;好处是没有隐藏的控制流,出问题时顺着日志一步步往回看就能定位是哪一环挂了,因为每一环都打了 LOG_ERROR。
4. start():从 STARTING 到 SERVING
init() 结束后所有子系统都初始化完毕,但还没有对外服务,start() 负责把它们逐个“点火”。源码里 start() 一开始就打状态标记 gctx_.status_ = SS_STARTING;(ob_server.cpp:927),在成功路径的末尾改成 GCTX.status_ = SS_SERVING; 并记录 GCTX.start_service_time_ = ObTimeUtility::get_current_time();(ob_server.cpp:1264)。SS_STARTING → SS_SERVING 这两个状态,就是“实例启动中”和“开始服务”的分界线。运维时你判断一个 observer 是不是真的可用了,看的应该是 start_service_time_ 有没有被填上,而不是进程在不在——进程起来了但状态还停在 SS_STARTING,说明它还在等日志回放或 schema 同步,这时候连上来的请求会被拒绝或超时。
start() 的实际顺序是这样的:start_sig_worker_and_handle() 起信号线程 → startup_accel_handler_.start() 起启动加速任务处理器 → OB_TS_MGR.start() 起时间戳服务 → net_frame_.start() 起网络框架 → ObIOManager::get_instance().start() → OB_STORAGE_OBJECT_MGR.start() → multi_tenant_.start() → wr_service_.start() → SERVER_STORAGE_META_SERVICE.start() → log_block_mgr_.start(storage_env_.log_disk_size_) 起日志盘管理 → try_update_hidden_sys() → location_service_.start() → weak_read_service_.start() → bl_service_.start() → root_service_monitor_.start() → ob_service_.start() → locality_manager_.start()。这条顺序同样不是随意的,它大致遵循“从下往上、从共享设施到分租户服务”的方向:先起被所有租户共享的基础设施(信号、时间戳、网络、IO),再起租户容器,最后起对外服务和监控。把它和 init() 的顺序对比还会发现,init 里靠后的模块(比如 location、root service)在 start 里反而相对靠前——因为“初始化”讲的是构造依赖,而“启动”讲的是服务依赖,两者画的是两张不同的图。
这里有一个必须纠正的细节:有些资料把 multi_tenant_.start() 描述成排在 ob_service_.start() 之后、靠后启动,但 5.0.2.0 的源码里它在 OB_STORAGE_OBJECT_MGR.start() 之后、log_block_mgr_.start() 之前,位置相当靠前。这个位置不是随便挪的:multi_tenant_ 一起,OMT 的 10ms tick 线程就开始跑,timeup() 会遍历租户列表做决策;而租户列表里此刻只有一个隐藏 sys 租户(下面会讲),所以即使 tick 跑起来也不会做错事。把 OMT 提前,是为了让后面 location_service_、root_service_monitor_ 这些需要“按租户”工作的组件,在起来时就已经有一个可用的租户容器。反过来,如果把 OMT 放到最后起,那么 bootstrap 期间创建 SYS 租户的日志流时,OMT 还没接管,租户容器的创建和线程分配就得走另一条临时路径,代码会更绕。
try_update_hidden_sys() 是一段很容易被忽略但很关键的逻辑(ob_server.cpp:1283):observer 刚启动、还没被 RootService 纳入集群时,本地并没有真正的 SYS 租户,但很多初始化动作又需要一个“sys 上下文”。于是先造一个隐藏 sys 租户占位——multi_tenant_.get_tenant(OB_SYS_TENANT_ID, tenant) 返回 OB_TENANT_NOT_IN_SERVER 时调 multi_tenant_.create_hidden_sys_tenant(),否则调 update_hidden_sys_tenant()。等元数据同步过来,再走 convert_hidden_to_real_sys_tenant() 转正。这段也解释了后面 check_if_multi_tenant_synced() 在等什么:ObMultiTenant 里有个 has_synced_ 标志和 set_synced()/has_synced() 接口,隐藏 sys 租户转正、租户列表和元数据对齐之后,这个标志才置位。start() 卡在这里等,就是在等“本机的租户视图和集群的元数据对齐”。另外还有 create_virtual_tenants() 造的虚拟租户,用于承载一些系统级隔离场景(比如审计)。
location_service_.start() 的位置源码给了注释,这是整段 start() 里最值钱的一条线索:
“Before bootstrap create sslog ls, start location service related thread such as rpc refresh timer here. During the creation of the SYS tenant (before the SYS tenant full schema), we can rely on using rpc to refresh the location, and other modules can obtain the location of SSLOG LS and SYS LS normally.”
意思是 bootstrap 期间要创建 SYS 租户的日志流,这个过程需要知道“某个 LS 在哪台机器”,也就是需要 Location Service;而此时 schema 可能还没完整,只能靠 RPC 刷新位置。所以 Location Service 必须早于 bootstrap 起来。这类“顺序注释”在 start() 里不多,但每一条都值得读——它们是官方给自己留的路标,也是读代码时最值钱的线索,因为顺序背后的原因通常不会写在别处。读 start() 时,凡是带注释的顺序点,都值得停下来想清楚“如果不这样会怎样”。
OceanBase 的“启动”和“变成可用集群”是两件事,中间夹着 bootstrap 或 restore。bootstrap 指全新集群第一次启动、没有任何数据,root_service_monitor_ 检测到本机被指定为 RootService 后走 bootstrap 流程,创建 SYS 租户、建系统表、初始化元数据;restore/replay 指集群已有数据,重启后需要从本地日志回放或从备库恢复。start() 在返回前要给这两条路径设置就绪门槛,它做了三项显式检查:check_if_multi_tenant_synced()(租户列表是否和元数据同步)、check_if_schema_ready()(schema 是否就绪)、check_if_timezone_usable()(时区信息是否可用)。三项都过了,才把状态置为 SS_SERVING。
这三项凑在一起,恰好是“我开始接待 SQL 请求”的最低门槛:要知道有哪些租户(多租户视图就绪),要知道表结构(schema 就绪),还要知道时区(时间函数能算对)。少任何一项,进来的 SQL 都可能算错或报错——不知道租户就分不清请求该给谁,不知道 schema 就没法解析和鉴权,不知道时区则 NOW()、日期运算可能给出错误结果。紧跟着还有一段等待回放的逻辑:const int64_t MAX_CHECK_TIME = 15 * 60 * 1000 * 1000L; // 15min,然后 check_user_tenant_schema_refreshed(tenant_ids, expire_time) 和 check_log_replay_over(tenant_ids, expire_time)(ob_server.cpp:1220)。回放没完成之前,start() 不返回,也就不会进入 SS_SERVING。这就是“实例起来了但还没对外服务”的那段窗口——你在运维时看到的 observer 进程已在、端口未通的状态,多半就卡在这里,而 15 分钟上限意味着卡死超过 15 分钟会走失败路径 raise(SIGKILL)。
5. stop() 与 wait()
start() 返回后,inner_main() 调 observer.wait()。ObServer::wait()(ob_server.cpp:1756)的主干就是一个轮询循环:
while (!stop_) { common::ObBKGDSessInActiveGuard inactive_guard; SLEEP(3); },每 3 秒检查一次 stop_ 标志位,这个标志位由信号处理路径设置(收到 SIGTERM 之类)。循环退出后调 stop(),然后依次 config_mgr_.wait()、OB_LOGGER.wait()、OB_LOG_COMPRESSOR.wait()、ObTaskController::get().wait()、sig_worker_->wait()、signal_handle_->wait(),把各个后台线程收干净。每一步都配了一行 FLOG_INFO,所以退出过程本身也是可观测的——如果你怀疑退出卡住,看最后打出的是哪一行,就知道卡在哪个模块的 wait() 上,比如卡在 wait OB_LOGGER 上就说明日志线程还在刷盘,卡在 wait signal worker 上就说明信号线程没响应。
SLEEP(3) 这种粗粒度轮询在“服务线程”上是不合格的,但 wait() 跑在主线程上、不服务任何请求,3 秒的延迟只影响退出速度,不影响吞吐,也不影响任何在线请求的响应时间。这是“主线程不做业务”的又一处体现:主线程唯一的工作就是等信号,然后优雅收尾。inner_main() 在 wait() 返回后还会做两件事:print_all_thread("BEFORE_DESTROY", OB_SERVER_TENANT_ID) 和 observer.destroy(),退出前再 print_all_thread("AFTER_DESTROY", ...)。print_all_thread(main.cpp:454)读 /proc/self/task 目录,逐个读每个线程的 comm 名字,比对是否带 T%lu_ 前缀,带前缀的就打印出来,用于排查“退出时还有哪些租户线程没停干净”。BEFORE_DESTROY 和 AFTER_DESTROY 两次打印的差集,就是 destroy() 实际收掉的线程。如果两次打印完全一样,说明有线程没被收干净,这是排查“进程退不掉”的第一手线索。
这从侧面证明了一件事:OceanBase 的工作线程名字里带租户 ID,所以 top -H 或 ps -T 能直接看出哪个线程属于哪个租户。名字的拼装逻辑在 deps/oblib/src/lib/thread/ob_thread_name.h 的 set_thread_name:snprintf(name, ..., "T%ld_%s%ld", tenant_id, type, idx),租户 ID 为 0 时不加前缀。工作线程自己还会再改一次名字,ObThWorker::set_th_worker_thread_name()(ob_th_worker.cpp:333)把名字设成 L%d_G%ld(worker level 和 group id),最终你在 top -H 里看到的形如 T1001_L0_G0。这个命名约定是生产排查最实用的抓手之一:某个租户 CPU 打高时,一条 top -H -p <pid> 就能列出它名下所有线程,再对照 dump tenant info 里的线程统计,两个视角互相印证。如果某个租户的线程名里出现了你没预期的 group id,那多半是资源组或大查询组的线程被激活了。
ObServer::stop() 的顺序值得单独看一眼,因为它没有写成 start() 的严格倒放。开头是 OB_LOGGER.stop()、OB_LOG_COMPRESSOR.stop()、ObTaskController::get().stop()、config_mgr_.stop(),然后才是 sig_worker_->stop()、signal_handle_->stop()、TG_STOP(lib::TGDefIDs::Blacklist)、TG_STOP(lib::TGDefIDs::DetectManager)、ObNetKeepAlive::get_instance().stop(),最后 net_frame_.mysql_shutdown()。日志、日志压缩器、任务控制器、配置管理器排在最前,信号线程、黑名单、检测线程、网络反而在后面;而 start() 里网络却是很早就起来的,两者起点和终点并不对称。这种“先关输出、再关输入、最后关执行”的次序,本质上是让退出过程的信息流从“还在产生”逐步收敛到“只剩收尾”。
这种非对称不是笔误:被所有模块依赖的“全局设施”要先进入停止态,这样后续各模块停的时候,不会再有新的日志写入、新的配置刷新、新的任务被调度进来,退出过程才会快速收敛。如果反过来先停网络再停日志,中间那段窗口里各模块还在打日志、还在刷配置,退出可能被反复打断,甚至出现“日志里缺最后几行”的排查噩梦。源码没给这段顺序写注释,读的人得自己推断出来——这也说明 ObServer 那套“只聚合、按顺序调生命周期”的设计,代价就是顺序的知识只存在于代码里,没有文档化,任何一次重构都可能悄悄打破这个隐含约定。
6. 进程里的四类线程
讲到这里必须纠正一个印象:一个 observer 进程不是“OMT 加上几个线程”,而是至少四类线程各跑各的池,它们的归属、规模和生命周期都不一样。官方文档自己也把线程分成两类来描述:“一类是处理 SQL 和事务提交的线程,统称为租户工作线程。其余的是处理网络 IO、磁盘 IO、Compaction 以及定时任务的线程,统称为后台线程。在当前版本,租户工作线程和大部分后台线程是分租户的,网络线程是共享线程的。”
| 线程类别 | 是否分租户 | 载体 |
|---|---|---|
| 租户工作线程 | 是 | ObThWorker,见 ob_tenant.cpp 的 workers_ 链表 |
| 网络 IO 线程 | 否(共享) | ObSrvNetworkFrame 的三档优先级线程组 |
| 后台线程 | 大部分分租户 | 全局 ObTimerService + ObSimpleThreadPool |
| PX 并行执行线程 | 是(按租户、按资源组) | ObPxPool / ObPxPools |
网络 IO 线程由 ObSrvNetworkFrame(src/observer/ob_srv_network_frame.h)承载,它同时负责 RPC、MySQL 协议、Unix domain socket 三类入口,成员包括请求翻译器 ObSrvXlator xlator_(把网络字节流翻译成内部的 ObRequest)、MySQL 协议处理 ObSMHandler mysql_handler_、请求派发器 ObSrvDeliver deliver_(把翻译好的请求投进目标队列)、以及基于 libeasy 的网络 IO 层 ObNetEasy *net_easy_。它内部用一行枚举钉死了三档优先级线程组:enum { NET_IO_NORMAL_GID = 0, NET_IO_HP_GID = 64, NET_IO_BATCH_GID = 72 };,get_req_transport()、get_high_prio_req_transport()、get_batch_rpc_req_transport() 分别拿到普通、高优、批量三套 transport。请求进来先被 xlator_ 翻译,再由 deliver_ 按优先级投进目标租户的队列——注意“投递”这一步就把租户 ID 带上了,这是请求从“共享线程”进入“租户线程”的交接点。
分三档的理由很直接:批量 RPC(DDL 期间的大对象传输、数据迁移、备份恢复)会占满网络 IO 线程,如果不隔离,一个批量任务就能把普通业务 RPC 的 IO 处理拖住。给高优 RPC 单独一组线程(NET_IO_HP_GID = 64),保证关键路径(事务提交、心跳)的响应不被批量流量淹没。这是“网络层也要做优先级隔离”的体现,也是共享网络线程“不隔离租户但要隔离优先级”这个折中的落地——官方口径说网络线程是共享的,但共享不等于无差别。ObSrvNetworkFrame 还管 MySQL 登录线程的数量,能运行时通过 reload_mysql_login_thread_config() 读 GCONF.sql_login_thread_count 再调 deliver_.set_mysql_login_thread_count(cnt) 动态改线程数。
后台线程里,定时器由全局的 ObTimerService(deps/oblib/src/lib/task/ob_timer_service.h)统一承载,ObServer::init() 一开头就把它拉起来;通用后台任务用 ObSimpleThreadPool(deps/oblib/src/lib/thread/ob_simple_thread_pool.h),它的定义是 using ObSimpleThreadPool = ObSimpleThreadPoolBase<ObLightyQueue>;,配了 static const int64_t QUEUE_WAIT_TIME = 100 * 1000;(100us)和一个“线程数可自适应”的 ObAdaptiveStrategy(least_thread_num, estimate_ts, expand_rate, shrink_rate)——四个参数是“最少线程数、估算周期、扩容比率、缩容比率”,池子会根据队列积压情况在最少线程数之上动态扩缩。这跟 OMT 工作线程的扩缩逻辑思路一致,只是 OMT 那套更复杂、绑了租户和 token 语义,而这里只是个通用的“忙了就加、闲了就减”的池子。换句话说,OceanBase 在进程里同时存在两种扩缩哲学:一种是可计量的租户配额扩缩,一种是纯按积压的通用扩缩,前者精细、后者粗糙,各自服务不同的任务类型。
PX 并行执行线程是第四类。并行查询(Parallel eXecution)用的是另一套线程池 ObPxPool(src/observer/omt/ob_tenant.h),它继承 share::ObThreadPool,内部是一个 common::ObPriorityQueue2<0, 1> queue_,带有 int64_t concurrency_、int64_t active_threads_ 和 void try_recycle(int64_t idle_time)。ObPxPool 是按租户、按资源组创建的,由 ObPxPools 用一张 pool_map_ 管理(get_or_create(group_id, pool)),每个池有自己的 tenant_id_ 和 group_id_。它实现了 try_recycle()——空闲一段时间后回收线程,避免并行查询的线程常驻占内存,这是它和 OMT 工作线程最大的差别:OMT 的线程即使空闲也保留着待命,PX 的线程空闲就会被收走。这个差别的根源在于两类线程的“重启成本”不同——OMT 工作线程要参与 CPU 记账和租户配额,频繁创建销毁会打乱记账;PX 线程只是个执行载体,回收了下次再建也不影响配额。
为什么 PX 要单独一套池、不直接用 OMT 工作线程?因为并行查询的线程数需求是脉冲式的:一条大分析查询可能要几十个并行度,跑完就没了。如果让 OMT 工作线程去扛,就得长期维持高线程数(浪费内存),或者频繁扩缩(抖动,且每次扩缩都要 create/destroy 线程)。单独一个可回收的池,把这个脉冲隔离开,峰值内存只在查询期间存在,查询结束线程被 try_recycle 收走。这是“OLTP 线程”和“AP 线程”在进程内分家的具体落地,也解释了为什么 OceanBase 在 HTAP 场景下能同时扛点查和报表——它们用的根本不是同一批线程,也就不会互相把对方的队列堵住。
7. ObThWorker 的调度循环
ObThWorker(src/observer/omt/ob_th_worker.h)的定义是 class ObThWorker : public lib::Worker, public lib::Threads,它同时是“可被调度的上下文”(lib::Worker)和“一个真实线程”(lib::Threads)。这种多重身份是 OceanBase 线程模型的一个缩影:一个线程既要能被 lib 层统一管理(起停、命名、join),又要携带租户和资源组的调度语义,还要在需要时被“借给”另一个租户用。类里有一行 static thread_local uint64_t serving_tenant_id_;,记录当前线程正在服务哪个租户,线程改名、诊断信息、ASH 采样都靠它;这也是为什么同一个物理线程在不同时刻可能服务于不同租户——它属于某个租户的池,但绑定关系是逻辑上的,不是内核意义上的。
这里的模型是 pull 而不是 push:工作线程主动去租户的请求队列里 pop 一个请求来跑,跑完再 pop 下一个,不是“来了请求就派一个线程”。这个方向看似细节,实际上决定了整个系统的背压行为:队列是缓冲,线程是消费者,消费者按自己的节奏取活,队列满了请求就排队等待,排队时间进监控,而不是让请求去创建线程。push 模型下压力会直接传导到线程创建上,pull 模型下压力停留在队列里,这是一个关键的架构分野。核心循环在 ObThWorker::worker()(ob_th_worker.cpp:345),骨架是这样的:
Worker::set_worker_to_thread_local(static_cast<lib::Worker*>(this));
blocking_ts_ = &Thread::blocking_ts_;
is_doing_ddl_ = &Thread::is_doing_ddl_;
ATOMIC_STORE(&is_running_, true);
while (!has_set_stop()) {
set_th_worker_thread_name();
wait_start_time = ObTimeUtility::current_time();
ret = tenant_->get_new_request(*this,
is_level_worker() ? NESTING_REQUEST_WAIT_TIME : REQUEST_WAIT_TIME, req);
wait_end_time = ObTimeUtility::current_time();
if (OB_SUCC(ret) && OB_NOT_NULL(req)) { process_request(*req); }
else if (OB_ENTRY_NOT_EXIST == ret) { ret = OB_SUCCESS; } // 队列空,超时
IGNORE_RETURN ATOMIC_FAA(&idle_us_, (wait_end_time - wait_start_time));
tenant_->check_worker_count(*this);
}
这个循环里每个动作都有信息量。get_new_request 的等待超时是 REQUEST_WAIT_TIME,头文件里定义成 static const int64_t REQUEST_WAIT_TIME = 10 * 1000L;(10ms),而嵌套请求(is_level_worker())用的是 NESTING_REQUEST_WAIT_TIME = 1 * 1000 * 1000L(1 秒)——嵌套请求通常是内部递归调用,不能等太久,也不该频繁轮询。wait_end_time - wait_start_time 被累加进 idle_us_:线程在队列上等待的时间被单独记成“空闲”,而不是“忙”,这是后面 CPU 记账的原始数据。请求处理完,线程立刻调 tenant_->check_worker_count(*this) 或 group->check_worker_count(*this),检查这个租户/资源组当前该有多少线程。注意 process_request 里还有一层细节:请求处理前会 set_req_flag(&req) 标记“我在跑请求”,处理完这个标记会被清掉,而 blocking_ts 非零加上 has_req_flag() 为真,正是判断“这个线程是在跑请求时阻塞了”的完整条件。
check_worker_count 的两半分工很清楚。租户级那半在 ObTenant::check_worker_count(ObThWorker &w)(ob_tenant.cpp:2491)很短,它只做一件事:如果租户被标记为要缩容(ATOMIC_LOAD(&shrink_) 为真)且这是自己的机会,就用 ATOMIC_BCAS(&shrink_, true, false) 抢到这个“允许退出”的名额,然后 w.stop() 让这个线程退出——缩容由工作线程自己执行,OMT tick 只负责把 shrink_ 置位。资源组那半在 ObResourceGroup::check_worker_count()(ob_tenant.cpp:500)复杂得多,它要统计 running_cnt、blocking_cnt、idle_cnt,算出 token,再决定扩还是缩。两半都遵循同一个原则:workers_lock_ 用的是 trylock() 而不是阻塞锁,拿不到就下一轮再说,绝不让调度逻辑卡住正在跑的请求。这个“宁可晚一轮也不阻塞”的取舍,是调度器和业务线程之间的基本契约——调度器可以慢,但不能拖住干活的人。
为什么让线程自己退出而不是让 tick 线程去 kill?因为杀线程在任何语言里都是危险操作(可能死在持锁状态、死在不可中断的临界区)。让线程在“一个请求刚处理完、状态干净”的时刻自己检查并退出,是唯一安全的自杀时机。代价是缩容有延迟——如果那个线程一直没被唤醒(队列长期空),它就不会去检查 shrink_,得靠 10ms 的 timeup() 里的 check_worker_count() 把它标记,或者靠超时唤醒让它回到循环顶部。这个细节解释了为什么缩容在观测上总是比扩容慢半拍。
8. 为什么不用 OS 调度器
看到这里,一个自然的疑问是:既然都在一个进程里,为什么不直接把这 N 个工作线程交给 Linux 的 CFS,让内核按 nice 或 cgroup 分配 CPU?为什么要在用户态再写一套 ObMultiTenant + ObTenant + ObThWorker 的调度逻辑?多写这一套不只是工作量问题,它还得自己实现时间片、优先级、记账这些内核本来免费提供的东西,看起来很不划算,而且一旦自己实现有 bug,内核那套久经考验的机制也救不了你。这个问题不是“OceanBase 喜欢造轮子”能打发的,它有三个具体的、站得住的理由,每一个都指向内核原语在这类场景下的能力边界。
第一是租户级配额无法用内核原语表达。Linux 的调度单位是线程(task),它的 nice/SCHED_*/cgroup cpu.weight 都是“按线程”或“按 cgroup 分组”的。要给“租户 A 最多用 2 个 CPU、租户 B 最多用 10 个 CPU”这种语义建模,只能把每个租户的线程塞进一个 cgroup 并设权重。而权重是相对的——它不保证绝对上限,只保证按比例分配。OceanBase 要的是“租户的 CPU 上限”这种绝对语义,这在 cgroup v1/v2 里要么做不到,要么得靠 cpu.max 配额(带宽限制),而带宽限制会导致租户在配额没用满时也被 throttle,损失吞吐。于是 OceanBase 换了个思路:用线程数当配额的载体。给租户开 unit_min_cpu × cpu_quota_concurrency 个线程,线程数就是它并行度的硬上限,这个上限是绝对的、可数的、可预测的。
第二是可观测性。如果调度交给内核,你从数据库内部看到的是“N 个线程在跑”,但你看不到“这个租户的哪个线程在等锁、等了多久、为什么没被补线程”。OceanBase 在用户态自己调度,就能在 timeup() 里做 update_token_usage()、update_queue_size()、handle_retry_req()、update_lq_cpu_limit() 这些统计和决策,把“租户级 CPU 使用率”“队列长度”“重试堆积”暴露成可查的指标。这些指标的颗粒度是内核给不了的——cgroup 最多给你“这个 cgroup 用了多少 CPU 时间”,给不了“这个租户有多少线程正卡在等锁上”,也给不了“这条 SQL 是大查询所以被让出”这种业务语义。
第三是避免线程爆炸。如果每条请求都新建线程(thread-per-request)或者每个连接一个线程(thread-per-connection),高并发下线程数会失控:每个线程自带一个栈,OceanBase 一个租户工作线程的栈加附加结构约 3.5MB 起(见 max_worker_cnt() 分母里的 get_tenant_stack_size(id_) + (3 << 20) + (512 << 10)),几千个线程就是几十 GB 的虚拟内存和大量上下文切换。用户态固定线程池 + 队列,把“并发请求数”和“线程数”彻底解耦:队列再长,线程数也是有界的。这也是 ObThWorker 用 pull 模型的原因——线程池自己控制节奏,而不是被请求推着跑。
这三个理由合起来,就是“为什么不用 OS 调度器”的完整论证。但反过来看,这套自研调度不是免费的:它把内核本来帮你做的事(时间片、优先级、抢占、CPU 记账)全揽到自己身上,于是每一样都得自己实现一遍——包括本篇后面要讲的两个“手工活”:用控制线程数来近似 CPU 配额,用 libc hook 来自建 CPU 记账。理解了这一点,再看 ObMultiTenant 那几百行代码,你看到的就不是“重复造轮子”,而是“把内核的活接过来自己干”的必然代价。
9. OMT 的 tick 与 10ms
多租户调度器 ObMultiTenant(src/observer/omt/ob_multi_tenant.h)继承 share::ObThreadPool,类里第一行常量就是 const static int64_t TIME_SLICE_PERIOD = 10000;,成员里维护一个 TenantList tenants_(实际是 common::ObSortedVector<ObTenant*>)和一把 mutable common::SpinRWLock lock_——用可排序向量而不是哈希表,是因为它要频繁顺序遍历所有租户,顺序遍历下连续内存的缓存友好度比哈希表高得多。它的 run1()(ob_multi_tenant.cpp:2814)是被引用最多的那段代码:起一个 lib::set_thread_name("MultiTenant") 的线程,然后 while (!has_set_stop()) 循环,整个 tick 逻辑就是这个循环体,简单到几乎一眼能看完,但每一行都对应一个资源决策。
每轮循环里,它先开一个 ObTimeGuard timeguard("multi_tenant_timeup", 100 * 1000)(超过 100ms 就留慢日志),拿一把 SpinRLockGuard guard(lock_) 读锁,每 1 秒用 REACH_TIME_INTERVAL(1 * 1000 * 1000L) 检查一次 cgroup 状态(GCTX.cgroup_ctrl_->check_cgroup_status()),然后 for (TenantList::iterator it = tenants_.begin(); it != tenants_.end(); it++) 遍历所有租户,对没停的调 (*it)->timeup(),需要的话先 regist_threads_to_cgroup()。遍历完释放锁,ob_usleep(TIME_SLICE_PERIOD, true/*is_idle_sleep*/) 睡 10ms。注意 timeguard.click() 在几个关键点打了戳(tenant_list_lock、check_cgroup_status、tenant_timeup),一旦超时,日志能告诉你是“锁竞争慢”还是“遍历租户慢”。每 10 秒(REACH_TIME_INTERVAL(10000000L))还会 dump 一次每个租户的详细信息,日志关键字是 dump tenant info。整个循环没有任何 sleep 在持有锁期间发生——ob_usleep 在锁作用域之外,这是“绝不拿锁睡觉”这条铁律的直接体现。
现在回答本篇开头那个问题:这个 10ms 不是抢占式时间片。 真正的抢占式时间片调度,语义是“线程 A 跑满一个时间片,调度器强制把它换下、让 B 上”,它需要修改被抢占线程的执行状态(改指令指针、切栈、动寄存器)。而 ObMultiTenant::run1() 里没有任何抢占动作:它不 pthread_kill、不 sched_yield、不动别人的寄存器、不碰任何工作线程的栈。它做的事,是每 10ms 醒来一次,把每个租户的“账本”更新一遍。
timeup() 的实现印证了这一点——ObTenant::timeup()(ob_tenant.cpp:2005)在 try_rdlock 成功后依次调 check_group_worker_count()、check_worker_count()、update_token_usage()、handle_retry_req()、update_queue_size()、update_lq_cpu_limit()。这六件事全是决策和统计:资源组该开多少线程、租户该开多少线程、CPU 用了百分之多少、有没有请求在排队重试、队列长度多少、大查询该分多少 CPU。没有一件是“切换正在跑的线程”,也没有一件会阻塞超过毫秒级(每件都有 trylock 或时间窗保护)。所以 TIME_SLICE_PERIOD 更准确的名字应该是“OMT 心跳周期”或“调度决策周期”,而不是“时间片”。它决定的是“多久重新算一次该给谁多少线程”,而不是“每个线程能连续跑多久”。把这个概念摆正,你再看 OceanBase 的调优文档,那些关于 cpu_quota_concurrency、Unit 规格、线程数的建议就都通了。
这是本篇第二个、也是最容易踩的反直觉点:一个叫“time slice”的常量,干的却是“调度决策”的活。如果你面试时被问“OceanBase 怎么用 10ms 时间片抢占租户”,正确的回答是“它不抢占,它靠控制线程数”。真正造成“公平感”的是线程数分配和 cgroup,而这个 10ms 只是让“该给谁多少线程”这个判断每 10ms 刷新一次,让线程扩缩能跟上负载变化。换句话说,它是一个反馈环路的采样周期,不是执行权的分配周期;把它当成后者,你会去找一个根本不存在的“强制切换”动作。
那 CPU 隔离到底靠什么?靠三样东西。最本质的是控制线程数——给每个租户开固定数量的工作线程,租户能并行跑几个请求,取决于它有多少线程,这是“软隔离”的硬上限。真正带强制力的只有 cgroup,开启后每个租户的工作线程放进独立的 cgroup 目录,由 Linux 内核按权重分配物理 CPU,这一层才是“真抢占”,但它是内核做的,不是 OMT 做的,而且权重是相对的、不保证绝对上限(官方文档也这么描述:“如果一个 OBServer 上只有一个租户负载很高,其余租户比较空闲,那么这个负载高的租户的 CPU 也会受到 MAX_CPU 的限制”)。第三是协作式让出,工作线程在代码里埋了很多“检查点”,跑到检查点时主动检查要不要让出,这是下一节 check_wait() 的内容。官方文档把第一点说得很白:“Unit 的 CPU 隔离是通过一个 Unit 的活跃租户工作线程数实现的……在缺省配置下,OBServer 节点会给每个 CPU 启动 4 个线程,4 这个倍数可以通过配置 cpu_quota_concurrency 来控制。“OceanBase 的软隔离单位是”线程“,不是”时间片”,这个认知是理解所有数字的前提。
为什么要用集中式 tick,而不让每个租户自己管自己的线程?集中式 tick 换来的是全局视角:ObMultiTenant 能看到所有租户的状态,因此可以做全局决策——节点级内存吃紧时对比各租户的 token 使用率优先收紧用得少的,cgroup 注册状态变了能一次性处理完所有租户。如果每个租户自管,跨租户的资源协商就得引入额外的消息传递或共享状态,复杂度更高,而且容易出现“两个租户各自以为对方会让步”的僵局。代价同样明确:run1() 是单线程串行遍历所有租户,每 10ms 一次,ObTimeGuard("multi_tenant_timeup", 100 * 1000) 给它设了 100ms 的超时告警——一次完整遍历超过 100ms 就会在日志里留痕。这意味着租户数越多,单次 tick 越慢,单机租户数是有实际上限的。再叠一层,tick 期间持有 lock_,虽然用的是读锁(多个读锁可并发),但遍历时其他需要拿写锁的操作(创建/删除租户)会等待。这是“集中式”换“全局视角”必须接受的限制。
10. 协作式让出的检查点
如果说 TIME_SLICE_PERIOD = 10000 是“调度器的思考周期”,那 WORKER_CHECK_PERIOD = 500L(500us,定义在 ob_th_worker.h)就是“工作线程的自检周期”。两者的量级差了二十倍,这不是巧合:调度决策不需要那么频繁(10ms 足够感知负载变化),而中断和让出需要足够灵敏(500us 才能在用户可接受的延迟内响应 kill 或超时),所以一个慢一个快。ObThWorker::check_wait()(ob_th_worker.cpp:210)是这套检查点的入口:它先看 tenant_->has_stopped()、tenant_->user_sched_enabled()、get_disable_wait_flag()、get_curr_request_level() 等条件,全部通过后进入关键分支 else if (curr_time > last_check_time_ + WORKER_CHECK_PERIOD)——只有距上次检查超过 500us 才真正做检查,避免每条指令都检查拖慢执行。
检查里调 check_throttle(),如果没被限流,再看 if (0 != threshold && curr_time > get_query_start_time() + threshold),其中 threshold = GCONF.large_query_threshold:查询执行时间超过 large_query_threshold 就判定为大查询,调 tenant_->lq_yield(*this) 让出。check_status()(ob_th_worker.cpp:515)是另一个入口,它在 SQL 执行路径里被频繁调用,检查会话是否被终止(session_->is_terminate)、内存是否超限(CHECK_MEM_STATUS())、是否超时(is_timeout())、是否被中断(IS_INTERRUPTED()),最后才落到 check_wait()。两个入口分工明确:check_status 管“该不该中止这个查询”,check_wait 管“该不该让出 CPU”,而它们共享同一个 500us 的时间戳 last_check_time_,所以一次检查能同时回答两个问题。这种“共用一个时间戳”的写法还有个副作用:任何两个检查点之间最坏会隔约 1ms(一个检查周期),这也是 OceanBase 中断响应延迟的理论下界。
lq_yield()(ob_tenant.cpp:2582)的让出策略很具体:它先 ATOMIC_INC(&tt_large_quries_) 记账;如果当前队列里还有请求(req_queue_.size() > 0)且线程还在默认组(group_id == 0),就 switch_worker_to_large_query_group(&w) 把线程切到大查询组 OBCG_LQ;如果已经在 LQ 组且没有开 cgroup,就调 lq_wait(w) 做限速。也就是说,大查询不是被“暂停”,而是切换到一个专用的低优先级线程组,把默认组的线程位置让给短查询。这里有个精妙的条件:只有当队列里还有请求时才切换,如果队列空了,大查询可以继续跑——因为此时没有短查询在等它让位。
lq_wait()(ob_tenant.cpp:2564)里有一个很有意思的公式,它把“大查询该占多少 CPU”翻译成了一个睡眠时长:
double large_query_percentage = std::max(1.0, 1.0 * GCONF.large_query_worker_percentage) / 100.0;
int64_t wait_us = last_query_us * lq_group_worker_cnt /
(default_group_worker_cnt * large_query_percentage) - last_query_us;
wait_us = std::min(wait_us, min(100 * 1000, w.get_timeout_remain()));
if (wait_us > 10 * 1000) {
ObUsleepBlockingGuard guard(false/*count as blocking*/);
ob_usleep(wait_us);
}
这个公式用“上一次大查询的执行时间 × 大查询组线程数 /(默认组线程数 × 大查询比例)− 上一次执行时间”来算该睡多久,本质是让大查询的 CPU 占用比例回落到 large_query_worker_percentage(默认 30%)附近。官方文档描述的口径是“大查询最多占用 30% 的租户工作线程,30% 这个百分比可以通过配置项 large_query_worker_percentage 来设置”,源码就是把这句话翻译成了一个 sleep 时长。这里还有一处细节值得点出:ObUsleepBlockingGuard guard(false) 把 count_usleep_as_blocking 临时改成 false——因为这次 sleep 是“调度器主动限速”,不应该被算成“线程阻塞、需要补线程”,否则就形成了“限速大查询 → 被判定为阻塞 → 补线程 → 更多大查询”的正反馈。这个开关的存在,说明 CPU 记账模型已经精细到要区分“哪种 sleep 算阻塞”。
协作式让出这套机制,代价是埋点成本。WORKER_CHECK_PERIOD = 500us 的检查点要在 SQL 执行路径里密集埋点(THIS_WORKER.check_status()),才能在 500us 粒度上响应中断和让出;这些埋点散落在解析、优化、执行、存储各层,是长期的维护负担——你加一条新的执行算子,就得记得在里面埋检查点,否则一个长循环会卡住整个中断机制。但反过来,正是这些埋点让“大查询让出”“超时中断”“限流 kill 会话”能在深递归中及时生效。这是一个典型的“侵入式设计”:把调度逻辑切碎撒进执行路径,侵入换响应性。
11. 阻塞不计 CPU
ObThWorker 里有两个字段很关键:int64_t* blocking_ts_;(阻塞起始时间戳的指针)和 int64_t idle_us_;(累计空闲微秒数,ob_th_worker.cpp:105-106)。它们共同支撑了 OceanBase 的 CPU 计量模型。模型的出发点是这样的:判断一个线程“用了多少 CPU”,不能只看它在不在跑,要看它是在算还是在等。一个卡在磁盘 IO 上或者等锁的线程,占着工作线程的坑,但对 CPU 的消耗接近零。如果把它算成满负载,租户的 CPU 使用率会被高估,调度器就会错误地不给它补线程,导致 CPU 空转。
OceanBase 识别“等”的方式相当硬核:在 libc 层面拦截阻塞类系统调用。deps/oblib/src/lib/thread/ob_tenant_hook.cpp 里重写了一批函数,而且比 v1 描述的“usleep/poll/futex”要多——它拦截了 pthread_mutex_lock、pthread_mutex_timedlock、pthread_rwlock_rdlock、pthread_rwlock_wrlock、pthread_rwlock_timedrdlock、pthread_rwlock_timedwrlock、pthread_join、ob_epoll_wait、ob_poll、ob_pthread_cond_wait、ob_pthread_cond_timedwait、usleep,以及通过 futex_hook 拦截的 FUTEX_WAIT_PRIVATE。这些函数用 dlsym(RTLD_NEXT, ...) 找到真实的实现,再包一层计时,等于把“哪里可能阻塞”这件事在链接层重写了一遍,覆盖了锁、条件变量、线程 join、IO 多路复用和休眠五类最常见的阻塞点。这套覆盖面本身就是一张“线程可能会在哪里卡住”的地图:凡是 hook 列表里出现的调用,都是 OceanBase 认为会影响 CPU 记账的等待场景。
拦截的核心是下面这个 SYS_HOOK 宏,它用 in_sys_hook++ 做重入判断,把真实调用包在 Thread::WaitGuard 里,让整段阻塞时间都被记到 blocking_ts_ 上:
#define SYS_HOOK(func_name, ...) \
({ int ret = 0; \
if (!in_sys_hook++) { \
oceanbase::lib::Thread::WaitGuard guard(oceanbase::lib::Thread::WAIT);\
ret = real_##func_name(__VA_ARGS__); \
} else { ret = real_##func_name(__VA_ARGS__); } \
in_sys_hook--; ret; })
Thread::WaitGuard(deps/oblib/src/lib/thread/thread.h:114)是整件事的核心,它继承 BaseWaitGuard,构造时如果 blocking_ts_ 还是 0,就把它设成当前时间;析构时再清零。也就是说,一次被 hook 的阻塞调用期间,Thread::blocking_ts_ 非零,代表“这个线程从此刻起在等”。thread.h 里还定义了一组等待类型:WAIT = (1 << 0)、WAIT_IN_TENANT_QUEUE = (1 << 1)、WAIT_FOR_IO_EVENT = (1 << 2)、WAIT_FOR_LOCAL_RETRY = (1 << 3)、WAIT_FOR_PX_MSG = (1 << 4),不同 hook 用不同类型,比如 ob_poll 用 WAIT_FOR_IO_EVENT,get_new_request 用 WAIT_IN_TENANT_QUEUE。in_sys_hook 这个 thread_local int 是个重入计数器,避免嵌套 hook 重复计时;usleep 还多了一层门槛,只有 v >= 50 * 1000(50ms)且 count_usleep_as_blocking 为真时才走 hook,短 sleep 不算阻塞——这条门槛挡住了大量“短 sleep 却并非真阻塞”的误判,否则线程池里每个 100us 的轮询 sleep 都会被计成阻塞,记账立刻失真。
拿到了“谁在阻塞、阻塞了多久”的信息,check_worker_count() 就能做补偿决策。ObTenant::check_worker_count()(ob_tenant.cpp:2241)的做法是:token 从 min_active_worker_cnt() 起算,然后遍历 workers_ 链表,对每个正在跑请求(w->has_req_flag())、阻塞时间超过 threshold 的默认工作线程记一个 token;其中 threshold 读自租户配置 _stall_threshold_for_dynamic_worker(默认 3LL * 1000,即 3ms),并且在 _ob_enable_dynamic_worker 为真时才生效。逻辑是:如果一个正在跑请求的线程阻塞超过 3ms,就认为它在等待而非计算,给它记一个“额外 token”,token 增加意味着这个租户该有的线程数上限提高,随后的 acquire_more_worker(diff, succ_num) 就会开新线程补上。做 DDL 的线程单独走 ddl_token++,因为 DDL 通常是长事务、阻塞是常态。另外这个判断只对 w->is_default_worker() 成立——高优先级 worker、组 worker、level worker 都不参与,避免把控制线程也补一遍。
另一头是 update_token_usage()(ob_tenant.cpp:2602):它每秒钟(duration >= 1000 * 1000)统计一次,把每个工作线程的 idle_us_ 用 ATOMIC_SET(&w->idle_us_, 0) 读走并清零,累加成 idle_us,然后算 token_usage_ = std::max(.0, 1.0 * (total_us - idle_us) / total_us),其中 total_us = duration * total_worker_cnt_。也就是说,租户的 CPU 使用率 =(总线程时间 − 空闲时间)/ 总线程时间,而这个“空闲时间”里有很大一块正是由 libc hook 记下来的阻塞时间。源码里还有一行防御性检查:如果汇总到的线程数 idle_check_count != total_worker_cnt_,直接 LOG_ERROR_RET(OB_ERR_UNEXPECTED, "some threads are not counted in idle_us, which will cause cpu_usage is inaccurate", ...)——漏统计一个线程,CPU 使用率就是错的,这个不变量被显式守护,因为它直接决定调度器给不给补线程。
这是本篇第三个反直觉点,也是最深的那个:OceanBase 判断“租户用了多少 CPU”,不是问操作系统,而是自己数——数每个线程在算还是在等,用“总时间减等待时间”倒推。为此它不惜在 libc 层拦截 usleep/poll/epoll_wait/futex/各种锁。代价是可移植性:这套 hook 依赖 ELF 的符号拦截(dlsym(RTLD_NEXT, ...))和 thread_local,换到 musl、静态链接、Windows 场景就不成立,这也解释了为什么 OceanBase 的服务器端只跑 Linux。收益是它能在不依赖内核统计的前提下,拿到租户粒度、秒级、线程级的 CPU 画像,还能区分“等租户队列”“等 IO 事件”“等本地重试”这些细类,这是 cgroup 给不了的颗粒度。
12. worker 数怎么算
既然“CPU 隔离靠线程数”,那线程数怎么定?答案在 ObTenant(src/observer/omt/ob_tenant.h)。它不是线程,而是一个租户的资源容器 + 请求队列 + 工作线程集合:类里挂着 WList workers_、WList nesting_workers_、ReqQueue req_queue_、ObMultiLevelQueue *multi_level_queue_、ObRetryQueue retry_queue_ 以及一堆请求计数,还有一个 GroupMap group_map_ 用来管理租户内的资源组。一个 ObTenant 对象就是“这个租户在这台机器上的一切运行时状态”的聚合——它的线程、队列、内存上下文、诊断容器都在这里,OMT 的每个 tick 就是对每个 ObTenant 做一轮核算。换算的核心是一个比值 cpu_quota_concurrency(),默认 4:
int64_t ObTenant::cpu_quota_concurrency() const
{ return static_cast<int64_t>((tenant_config.is_valid() ? tenant_config->cpu_quota_concurrency : 4)); }
int64_t ObTenant::min_worker_cnt() const
{ return priority_worker_cnt()
+ std::max(1L, static_cast<int64_t>(unit_min_cpu()
* (tenant_config.is_valid() ? tenant_config->cpu_quota_concurrency : 4))); }
int64_t ObTenant::priority_worker_cnt() const
{ return std::max(2L, static_cast<int64_t>(unit_min_cpu() / 10)); }
min_worker_cnt() 就是“这个租户至少该有多少工作线程”:priority_worker_cnt() + max(1, unit_min_cpu × 4)。以 unit_min_cpu = 2 为例,priority_worker_cnt = max(2, 0) = 2,主项是 2 × 4 = 8,所以至少 10 个线程。priority_worker_cnt 保底 2 个,是给高优先级请求留的通道,它的公式 max(2, unit_min_cpu / 10) 意味着 CPU 少的租户也至少有 2 个“紧急通道”线程。官方文档解释了 4 这个数字的含义:一个线程执行 SQL 时会遇到 IO 等待、锁等待,用不满一个物理 CPU,所以给每个 CPU 配 4 个线程,让等待期间 CPU 不空转。这个 4 不是随便拍的数字,它隐含了一个关于“阻塞比例”的假设——假设线程有约 3/4 的时间在等,这个假设成立时 4 倍刚好;假设不成立时(纯 CPU 密集、几乎不阻塞),4 倍就是浪费。
上限是另一个函数 max_worker_cnt()(ob_tenant.cpp:1469):divisor 在普通模式是 20、数据库隔离模式是 5,cnt = std::max(unit_memory / divisor / (get_tenant_stack_size(id_) + (3 << 20) + (512 << 10)), 150L)。注意这个上限是按内存算的,不是按 CPU 算的——分母是一个线程的总内存占用(线程栈 + 3MB + 512KB 的附加开销),分子是租户可用内存,商就是能开的线程数上限,再保底 150。分母里的 3 << 20(3MB)和 512 << 10(512KB)不是随便加的常数,它们代表了线程除了栈之外还要占的附加结构(上下文、诊断信息、执行状态等),源码把它们显式写进公式,就是为了让“线程成本”这个估算尽量贴近真实。
这里出现了一个源码和文档不一致的地方,值得单独标出来。官方文档在讲线程上限时说:“一个租户工作线程因为执行大查询被挂起时,作为补偿,系统可能会新创建一个租户工作线程,但是总的租户工作线程数不能超过 MAX_CPU 的 10 倍,10 这个倍数可以通过配置项 workers_per_cpu_quota 来设置。“文档的口径是”上限 = MAX_CPU × workers_per_cpu_quota(默认 10)”,CPU 维度的;而 5.0.2.0 源码里 max_worker_cnt() 的实际实现是内存维度的。两者在最坏情况下会给出不同结果:一台 CPU 少、内存大的机器,按文档线程上限低,按源码上限高。
13. 横向对比
把 OceanBase 和 PostgreSQL、MySQL 放一起,三种进程/线程模型的分歧就清楚了。PG 的 postmaster 是一个进程管理器:每来一个客户端连接,它 fork() 一个后端进程,连接的所有计算在这个独立进程的地址空间里完成,进程间靠共享内存(shared_buffers)交换数据。好处是隔离彻底——一个 backend 段错误崩溃,postmaster 回收它,其他连接不受影响;坏处是共享昂贵,每多一个进程就多一份页表、多一次上下文切换,而且共享内存里的数据指针不能跨进程直接解引用,缓冲区管理要额外设计(PG 的 buffer pool 必须用偏移量而非指针索引)。OceanBase 反其道而行:所有租户在一个进程里,共享同一个全局 KV 缓存、同一份 schema 缓存、同一套存储引擎对象,跨租户的共享是零拷贝的指针共享。代价是故障域不隔离:一个租户的代码路径如果踩坏了全局结构的某个字段,或者触发了 abort(),整个进程一起挂,所有租户陪葬。
OceanBase 用什么补偿这个代价?把“隔离”从进程层挪到内存层和行为层。内存上,每个租户有自己的内存配额(Unit 的 memory_size),越界会被 ObMallocAllocator 拦住而不是写坏别人;行为上,工作线程被限制在租户自己的队列和线程里,一个租户的慢查询不会直接抢走别人的线程(check_worker_count 是在租户自己的 workers_ 链表上算 token)。但这些隔离都是“软”的——它防的是“资源被抢”,防不了“内存被写坏”。所以 OceanBase 对内存分配的安全要求,实际上比 PG 这种进程隔离模型更高,因为它没有进程边界兜底。你在 OceanBase 源码里看到的大量 CHECK_MEM_STATUS()、ObMemVersionNodeGuard、租户内存追踪,本质都是在替代 PG 里“进程崩溃不扩散”这个天然屏障。
对比 MySQL 又是另一种取向。MySQL(默认引擎,非 thread pool 插件)是“一个连接一个线程”:连接建立时创建一个线程,连接关闭时销毁,max_connections 就是线程数上限。连接数上千时,线程数和上下文切换开销会压垮系统,所以要靠限流。OceanBase 从设计上就不是 thread-per-connection:连接的接入由网络框架的 IO 线程处理(ObSrvNetworkFrame 的 deliver_ 管 MySQL 登录/sql 线程),SQL 执行由租户的固定工作线程从队列里拉取。客户端连接数和工作线程数是解耦的——一万个连接可能共享 40 个工作线程。这个设计让 OceanBase 能扛高连接数,但也意味着连接的会话状态必须独立存储(ObSQLSessionMgr),不能像 thread-per-connection 那样把会话状态放在线程栈上。解耦的代价就是“会话状态要显式管理、显式清理”,这也是 OceanBase 会话管理代码量不小的原因。
对比 TiDB 又是一层。TiDB 用 Go,调度交给 Go runtime 的 GMP——goroutine 的挂起、恢复、迁移由 runtime 自动完成,开发者不用管,写起来像同步代码。OceanBase 是 C++,没有 goroutine,调度必须自己写:请求入队、工作线程拉取、检查点让步、线程扩缩容,全是显式代码。这是 C++ 的代价,也是 C++ 的自由——OceanBase 可以精确控制“哪个线程属于哪个租户”“线程什么时候增减”“CPU 怎么记账”,而 goroutine 调度器对租户是无感知的,一个 goroutine 属于哪个租户、用了多少 CPU,runtime 不关心;TiDB 要做资源隔离得在更上层想办法(资源组、限流、tidb_server_memory_limit)。OceanBase 的 ObThWorker + ObTenant + ObMultiTenant 三层结构,本质是把 Go 的 GMP 拆开、按“租户”这个维度重新实现了一遍,而且额外做了 GMP 不做的事——CPU 记账和阻塞补偿。三种答案没有绝对优劣,PG 最在乎隔离的鲁棒性,MySQL 最在乎实现简单,TiDB 最在乎开发效率,OceanBase 最在乎在单进程里实现大规模租户下的可控与可观测。
14. 代价与权衡
把前面散落的取舍集中过一遍,这是本篇最该带走的东西。单进程换来零拷贝共享,代价是故障域不隔离——所有租户共享 gctx_、共享缓存、共享存储引擎句柄,跨租户访问是普通指针,没有序列化成本,一次 RPC 内部的多个算子可以共享同一份内存对象。但进程崩溃是全局性的,一个租户的代码路径踩坏全局结构整个进程一起挂,OceanBase 必须靠更严格的内存安全约束来避免“一个租户拖垮全部”,这是用“实现难度”换“运行效率”。所以在这套架构下,“写好内存相关的代码”不是编码风格问题,而是可用性的一部分。
线程数按 CPU 的 4 倍超配,代价是内存。cpu_quota_concurrency = 4 让每个 CPU 对应 4 个线程,隐含假设是“线程大部分时间在等 IO/锁,不会同时占满 CPU”。这提高了 IO 密集场景的吞吐,让 CPU 在等待期间不空转,代价是每个线程的栈和附加结构(约 3.5MB 起)都占内存,且线程数受 max_worker_cnt 的内存上限约束。如果业务是纯 CPU 密集型、几乎不阻塞,4 倍就是浪费;如果阻塞比例很高(大量锁竞争),4 倍可能还不够,得靠 _stall_threshold_for_dynamic_worker 的动态补线程兜。这两个方向的偏差都要靠 token_usage 和 blocking_ts 的反馈来修正,这说明“4 倍”只是个起点,不是定值,调优时要看实测的token_usage而不是死守默认。
集中式 10ms tick 换来全局视角,代价是单点串行。ObMultiTenant::run1() 单线程遍历所有租户,能看到全局状态、能做全局决策,实现简单、状态一致,不需要跨线程协商。但遍历耗时随租户数线性增长,ObTimeGuard("multi_tenant_timeup", 100ms) 给它的容忍上限是 100ms;且遍历期间持读锁,创建/删除租户要被阻塞。租户数极多时,这个 tick 会变成瓶颈,而且它是单点——这个线程挂了,所有租户的线程扩缩都停摆,只能靠进程重启恢复。
libc hook 换来精确 CPU 记账,代价是可移植性和维护成本。拦截 usleep/poll/epoll_wait/futex/锁让 OceanBase 能自己数“线程在等还是算”,不依赖内核统计,还能区分等待类型。但这套机制绑定 Linux/glibc,要在 musl、静态链接、Windows 上工作就得另想办法;再有一层风险是 count_usleep_as_blocking 这种 thread_local 全局开关,一旦某段代码忘了用 ObUsleepBlockingGuard 把它临时关掉(源码里 lq_wait 就用了 ObUsleepBlockingGuard guard(false)),CPU 统计就会出现偏差——把调度性 sleep 错算成阻塞,会触发错误的补线程,越补越乱。
检查点让步换来公平和响应性,代价是埋点成本。WORKER_CHECK_PERIOD = 500us 的检查点要求 SQL 执行路径里密集调用 check_status(),才能在 500us 粒度上响应中断和让出。这些埋点散落在解析、优化、执行、存储各层,是长期的维护负担——新增算子必须记得埋点,代码评审时也要盯住。但正是它们让“大查询让出”“超时中断”“限流 kill 会话”能在深递归中及时生效,没有它们,一个长循环就能让整个租户的中断机制失效。
还有一条容易被忽略的:关掉 glibc 多 arena(mallopt(M_ARENA_MAX, 1))换来内存可控,代价是与 glibc 分配器的通用优化割裂。OceanBase 用自己的 ObMallocAllocator 统一分配、按租户计量,就必须放弃 glibc 多 arena 带来的多核分配扩展性——多线程同时 malloc 时会更早撞上 arena 锁。这是“要按租户记账”这个目标逼出来的选择:想让内存按租户可计量,就不能让 glibc 在背后偷偷开一堆它自己管理的池子,否则你算出来的租户内存永远和 RSS 对不上。这六条权衡串起来,其实指向同一件事:OceanBase 把“隔离”和“记账”从操作系统手里接了过来,换来的是细粒度和可控性,付出的是实现复杂度、可移植性和一套需要长期维护的软件基础设施。
15. 可观测性
读懂代码是一回事,能在真实环境里验证是另一回事。线程级最直接:print_all_thread 读 /proc/self/task/*/comm,线程名带 T<tenant_id>_ 前缀,生产上 ps -T -p <observer_pid> 或 top -H 按名字就能数出每个租户有多少工作线程,再用第七节、第十二节的公式对比,验证 min_worker_cnt/max_worker_cnt 的推算是否和实际吻合。如果实际线程数明显低于推算的 min_worker_cnt,那多半是内存不够,acquire_more_worker 被 max_worker_cnt 或内存水位(ObMallocAllocator 的 5% 剩余判断)拦住了,这时该先看租户内存而不是先怀疑调度器。把每个租户的线程数画成随时间变化的曲线,还能看出负载模式——是稳定的还是脉冲的,这决定了你该调 Unit 规格还是调动态扩线程的门槛。
租户级靠日志。官方文档说“在日志中搜索 dump tenant info 关键字,可看到租户的规格、线程、队列及请求统计等信息,且这条日志每个租户每 30s 打印一次”。这条日志的来源正是 ObMultiTenant::run1() 里那段 REACH_TIME_INTERVAL(10000000L) 的逻辑:每 10 秒调 LOG_INFO("dump tenant info", "tenant", **it)。日志里能看到 unit_config 的 min_cpu/max_cpu/memory_size/log_disk_size、super_block 的 replay_start_point、以及 worker 数量。对比 unit_min_cpu × cpu_quota_concurrency 和你实际看到的线程数,就能验证前面 min_worker_cnt 的公式。
调度器级看 ObTimeGuard("multi_tenant_timeup", 100 * 1000)。单次 tick 超过 100ms 会留下慢日志,单机租户多的时候盯着这条,就能知道 tick 有没有成为瓶颈。另外 check_worker_count 里那些 LOG_INFO("resource group worker expanded", ...) / "worker thread created" / "stall worker detected for dynamic worker" 的日志,能让你看到“扩线程是因为队列积压(expand_to_token)还是因为阻塞补偿(blocking_cnt)“,这是判断”该调 cpu_quota_concurrency 还是该看业务阻塞”的依据——如果扩线程总是因为 blocking_cnt,说明线程在等锁或等 IO,调线程数只能缓解,根因在业务或锁设计上。反过来,如果扩线程总由队列积压触发,那才是真的并发不够,这时候加 Unit 规格或调 cpu_quota_concurrency 才有意义。
16. 常见误区
误区一:以为 OBServer 是“机器”,所以一台机器多个 OBServer 是“多进程”。 官方定义里 OBServer 由 IP:port 唯一标识,一台物理机跑两个 OBServer,就是两个独立的 observer 进程,各自有完整的 ObServer 单例、各自的 OMT、各自的服务端口,不共享内存。别把“一台机器”和“一个 OBServer”划等号,尤其在排查“为什么两个 OBServer 的负载不一样”时,先确认你说的是进程还是机器,否则会把“两个独立实例的资源竞争”误判成“一个实例内部调度不公”。判断的办法很简单:看端口和 run/observer.pid,两个进程各有自己的配置目录和日志目录,互不影响。
误区二:以为 TIME_SLICE_PERIOD = 10000 是抢占式时间片。 它是 OMT 线程的 tick 周期,做的是记账和决策,不是切换运行中的线程。真正造成“公平感”的是线程数分配和 cgroup。有人让你解释“OceanBase 怎么用 10ms 时间片抢占租户”,正确的回答是“它不抢占,它靠控制线程数”,这个区别决定了你调优时的抓手是 cpu_quota_concurrency 和 Unit 规格,而不是去找某个“时间片参数”。反过来,如果你真的在代码里找到了“强制切换”的动作,那多半是 cgroup 或信号机制在起作用,不是 OMT 在调度。
误区三:以为工作线程和连接一一对应。 工作线程是租户级的固定池,连接是网络框架管理的会话,一个租户的 40 个工作线程服务可能上千个连接。连接多不等于线程多,max_connections 之类的参数在 OceanBase 里的含义和 MySQL 完全不同,想靠“加连接数”或“减连接数”来调节 CPU 占用是走错了方向;连接数真正影响的是会话状态内存和网络 IO 线程的负载,而不是租户的并发执行能力。
误区四:以为 max_worker_cnt 是按 CPU 算的。 5.0.2.0 源码里它是按 unit_memory / 每线程内存 算的(ob_tenant.cpp:1469 的 max_worker_cnt()),文档里“MAX_CPU 的 10 倍”是旧口径。调 workers_per_cpu_quota 在新版本上不直接决定上限,扩容租户线程能力要同时看 Unit 的内存规格,只加 CPU 不加内存,线程数可能一点不变;反过来一台内存很大但 CPU 很小的机器,可能算出比预期高得多的线程上限。真正决定“能不能再开一个线程”的,是租户的剩余内存水位,而不是它还剩几个 CPU。
误区五:以为主线程也参与服务。 inner_main() 里主线程第一件事是给自己绑定一个占位 Worker,防的是被调度器当成空闲线程绑走。主线程只跑启动和 wait() 轮询,不碰业务,所以你在 top -H 里看到的名为 observer 的那个线程几乎永远是休眠的,那不是异常,也不代表 worker 不够用。真正干活的是那些名字形如 T1001_L0_G0 的线程,排查性能问题时要盯的是它们,而不是主线程。判断一个线程是不是在工作,最直接的办法是看它在 dump tenant info 里对应的计数有没有增长。
小结
OceanBase 的进程模型可以一句话概括:一个 observer 进程就是一个 ObServer 单例(static ObServer THE_ONE),多租户的隔离边界从“进程”上移到了“租户”,用 OMT 在单地址空间内切 CPU、切内存、切线程。围绕这条主线,启动流程是一条手工编织的拓扑序——init() 里 ObTimerService 最先起(定时器是被依赖的基础设施)、init_global_context 先于 init_schema 和 init_network(服务定位器先于消费者)、init_global_kvcache 先于 init_schema(schema 要用 KV 缓存)、init_storage 反直觉地排在 init_sql 和 location_service 之后(存储依赖元数据与位置服务)、init_multi_tenant 最后(依赖 sql_proxy_ 和 self_addr_)。start() 则分三段推进:先起信号线程和网络框架,multi_tenant_.start() 在 5.0.2.0 里早于 log_block_mgr 和 location_service,接着经 try_update_hidden_sys 造出隐藏 sys 租户占位,再经 check_if_multi_tenant_synced、check_if_schema_ready、check_if_timezone_usable 三道门槛和最长 15 分钟的回放等待,才把状态从 SS_STARTING 翻成 SS_SERVING;退出时 stop() 不是 start() 的倒放,而是让被所有模块依赖的日志、配置、任务控制器先进入停止态。主线程在启动前先自绑一个 lib::Worker 占位,因为在这个把调度器装进进程的架构里,main 线程是一个需要保护的资源;ObServer 本身只做聚合和按序调生命周期,业务逻辑全在成员自己身上,启动顺序的知识则以代码顺序这种唯一形式存在。
调度这条线的核心结论,是把“10ms 时间片”从想象里拆掉:ObMultiTenant::TIME_SLICE_PERIOD = 10000 是 OMT 线程 run1() 的心跳周期,每个 tick 对每个租户调 timeup() 做 check_group_worker_count/check_worker_count/update_token_usage/handle_retry_req/update_queue_size/update_lq_cpu_limit 这六件“决策与统计”的事,它是调度决策周期,不是抢占时间片。真正的 CPU 隔离靠三样:控制线程数(min_worker_cnt = priority_worker_cnt + max(1, unit_min_cpu × cpu_quota_concurrency),默认 4 倍;上限 max_worker_cnt 在 5.0 是按内存倒推的)、cgroup 权重(内核做的真抢占,但只保证相对比例)、以及协作式检查点让步(WORKER_CHECK_PERIOD = 500us,超 large_query_threshold 就 lq_yield 切到大查询组并限速)。三者里只有 cgroup 是内核强制,另外两个都是用户态协作,所以 OceanBase 的隔离本质上是“软”的,它的可靠性依赖每个线程都乖乖在检查点上看一眼状态。
最值得带走的一个认知,是 OceanBase 的 CPU 记账是自建的:它通过 ob_tenant_hook.cpp 在 libc 层拦截 usleep/poll/epoll_wait/futex/各类锁,配合 Thread::WaitGuard 和 thread_local 的 blocking_ts_/idle_us_,自己数“每个线程在等还是在算”,再用 token_usage_ = (total_us − idle_us)/total_us 倒推租户 CPU 使用率;阻塞超过 _stall_threshold_for_dynamic_worker(默认 3ms)就补 token、开新线程,保证 CPU 不空转。代价是这套东西绑定 Linux/glibc,且要靠 count_usleep_as_blocking 这种开关人工区分“阻塞 sleep”和“调度 sleep”。对比之下,PG 用进程隔离换鲁棒,MySQL 用 thread-per-connection 换简单,TiDB 用 goroutine 换开发效率,OceanBase 用“单进程 + 用户态租户调度”换单进程内的大规模租户可控与可观测——代价是故障域不隔离、实现复杂、绑定 Linux,以及一套必须自己维护的记账与埋点体系。