日志服务与 Palf:把 Multi-Paxos 摊开成一条可复制的日志
打开 OceanBase 的日志服务目录 src/logservice/palf/,你会看到一堆似曾相识的名字:log_storage.h、log_block_mgr.h、log_cache.h、log_io_worker.h、log_meta.h,还有 lsn.h 里定义了 LSN 结构、lsn_allocator.h 里有个 LSNAllocator。把目录名遮住,你会以为这是一个单机文件系统的实现。这不是错觉。OceanBase 官方把它明确命名为 Palf,全称 "Paxos Backed Append Only Log File System"------Paxos 支撑的、只追加的日志文件系统。官方原文(V4.4.2《参考指南·系统原理》写得很直白:"OceanBase 数据库 V4.0.0 版本参考文件系统,将日志服务抽象为 'Paxos Backed Append Only Log File System',简称 Palf。"
先纠正一个流传很广的误传。有些介绍文章把 Palf 写成 "Paxos Appended Log Format"(Paxos 追加日志格式),这是错的。官方全称里两个关键词是 Backed(被支撑)和 File System(文件系统),不是 Format。这个差别不是文字游戏,它决定了你怎么读这套代码:Palf 不是"一种日志格式",而是"一个用 Paxos 做后端的文件系统"。格式只是它的一部分,它还有存储层、网络层、IO 调度层、缓存层、元数据层。理解了这一点,log_block_mgr 和 log_cache 为什么和 lsn.h 并列就不再奇怪。
那这一篇要回答什么?不是重讲一遍"Paxos 是什么"。读者已经知道多数派、知道 WAL、知道 Multi-Paxos 的大致轮廓。真正难的是把官方那句轻描淡写的"日志会基于选出的 Leader 推进 Multi-Paxos 的状态机,做未确认日志的恢复,并在恢复阶段完成后开始提供服务"翻译成源码级的时序。一句话背后藏着四件必须搞清的事:一次复制的完整生命周期是怎么走的,多数派到底怎么算出来,新 Leader 上任后那段"不能对外服务"的时间里它在干什么,以及成员变更为什么绝不能一步切过去。这一篇把 Multi-Paxos 的复制过程、Leader 恢复、成员变更、LSN/SCN 双坐标、滑动窗口背压、异步复制与 R 副本级联全部摊开讲。选举状态机的内部细节------lease 怎么生效、epoch 怎么递增、RoleChangeReason 五种切换原因留给后续,这里只讲与日志复制直接相关的部分。

1. Palf 取代了什么
3.x 时代,OceanBase 的复制层由两个相对独立的模块组成:src/clog/ 负责提交日志的存储与 Paxos 复制,src/election/ 负责选主与租约。这两块代码在 3.x 里耦合不算紧密,各有各的入口,各有各的状态机。到 4.0,这两个目录都不见了,取而代之的是 src/logservice/palf/,而选举被收编成它的一个子目录 src/logservice/palf/election/。也就是说,选举从"平级的独立服务"降格成了"文件系统的一个内部机制"。这个变化在目录结构上就写死了,你不需要看任何文档,光看路径就能读出设计意图。
官方对 Palf 的功能给了两组并列的定位,分别对事务系统和分布式系统。对事务系统,它要满足 Write-Ahead Logging 的功能需求、实现事务的原子性和持久性;要支持"返回特定语义的时间戳",满足读写事务、备机弱读等生成事务版本号的需求;要实现事务的高性能,同时做到多核下的可扩展。对分布式,它要基于 Paxos 保证数据在多数派副本持久化成功,并通过成员变更提供容灾;要提供异步复制能力;要提供完善的诊断监控能力。这六条不是口号,每一条都能在代码里找到对应物。第一条对应 LogEngine::append_log,第二条对应 LSNAllocator::alloc_lsn_scn------LSN 和 SCN 是在同一次原子操作里一起分配的,这一点后面有源码级证据,第三条对应那 128 位 CAS 无锁分配,第四条对应 LogNetService 与 LogQuorumPolicy,第五条对应 LogReplicaType::LOGONLY_REPLICA 和 learner 机制,第六条对应 PalfMonitorCb 那一大族 record_*_event 回调。
把 Palf 按文件系统的职责分层,src/logservice/palf/ 下上百个文件就不再是一盘散沙。PalfEnv(palf_env.h)是"一台 OBServer 上一个租户的全部 Palf 实例"的容器,相当于挂载点集合与全局状态,管理磁盘配额、IO 线程、块池,它的 create/load/open/close/remove 就是文件系统层面的挂载与卸载。PalfHandle(palf_handle.h)是上层拿到的句柄,等价于一个打开的文件描述符,注释写得很直白"打开一个 id 对应的 Paxos Replica,返回文件句柄"。LogEngine(log_engine.h)是"一个日志流"的完整逻辑投影,持有 log_storage_、log_meta_storage_ 两个存储对象外加 log_net_service_,对外提供 append_log、read_log、truncate、begin_flashback、end_flashback 这套读写截断接口。再往下是 LogStorage + LogBlockMgr(块设备)、LogIOWorker(写回)、LogNetService(网络文件系统)、LogCache(页缓存)、LogMeta(元数据)。
这份清单本身就是答案的一部分。Palf 之所以要"伪装成文件系统",是为了把"复制"这件事从上层业务的视野里彻底抹掉。事务系统、存储引擎、RootService 都不需要知道 Paxos、不需要知道多数派、不需要知道成员列表,它们只需要拿一个 PalfHandle,像写本地文件一样 append。复制的复杂度被压在 LogNetService 和 LogSlidingWindow 下面,暴露给上层的是一套干净的文件 API。这层抽象的成本在下一节就会暴露:当你把"分布式共识"包装成"写文件"时,你就必须回答"这次写到底什么时候算成功",而这个问题的答案不在文件系统语义里,在多数派语义里。
2. 一次复制的一生
要理解 Palf 的复制,最好的切入点是 PalfHandleImpl 往上暴露的那一个回调接口。palf_callback.h 里 PalfFSCb 只有一个方法:
cpp
class PalfFSCb
{
public:
// end_lsn返回的是最后一条已确认日志的下一位置
virtual int update_end_lsn(int64_t id, const LSN &end_lsn,
const share::SCN &end_scn, const int64_t proposal_id) = 0;
};
注释那句"end_lsn 返回的是最后一条已确认日志的下一位置"就是整个复制协议的对外承诺。上层拿到这个回调,就意味着"从 begin_lsn 到 end_lsn - 1 这段日志,已经被多数派持久化了,你可以放心把它们当作已提交事实去回放、去回复客户端"。所以整条复制链路的终点不是"写进了磁盘",而是"这个回调被触发"。下面把从写到回调的完整时序拆开,配套的泳道图在下文。
第一步是 Leader 侧的 append。LogEngine::append_log(const LSN &lsn, const LogWriteBuf &write_buf, const share::SCN &scn)(log_engine.h:163)是落盘入口,但它拿到的 lsn 和 scn 并不是自己算的,而是上游 LogSlidingWindow::submit_log 通过 LSNAllocator::alloc_lsn_scn 一次性分配好的。这个分配发生在"日志内容还没落盘"之前,也就是说 LSN 的分配是预分配的------先占位置,再填内容,最后把这批写请求打包成 LogIOFlushLogTask 丢给 LogIOWorker。这一步为什么要把分配和落盘拆开?因为分配要走 LSNAllocator 的无锁 CAS,越快越好;落盘要走 DIO,可能很慢。把两者解耦,多线程可以各自拿到自己的 LSN 后并发提交,而不是在落盘这一慢环节上串行排队。
第二步是广播。Leader 把这条聚合日志(LogGroupEntry)通过 LogEngine::submit_push_log_req(log_engine.h:210,一个模板函数)下发。它有两个分支:成员只有自己时直接走本地路径,成员多于一个且不是批量 RPC 时,会复用一个 ObRpcPreparedBody 把"打包一次、群发多份"做掉。真正发给远端的报文类型由 log_req.h 的 PushLogType 定义,只有三个值:PUSH_LOG = 0(Leader 主动推给成员)、FETCH_LOG_RESP = 1(响应 Follower 的拉取)、PUSH_LOG_WO_ACK = 2(推了但不要 ACK)。注意最后一个值,它就是给 learner 和 R 副本准备的------不投票的副本,Leader 推日志给它时不需要等它应答,走"无确认推送"路径。一个整型枚举,把"同步成员"和"异步观察者"这两条完全不同的路径在协议层面前置区分开了。

第三步是 Follower 落盘并应答。Follower 收到 PUSH_LOG 后,先做前向校验(forward check):校验这条日志的 prev_lsn 和它本地已落盘的位置接不接得上,校验 committed_end_lsn 对不对,再校验头部累加校验和。这些校验的依据,全部来自 log_group_entry_header.h 的 LogGroupEntryHeader 字段------magic_ 是 0x4752(注释写明 "GR(group header)"),committed_end_lsn_ 记录"本条日志之前最大已提交的 LSN"(log_group_entry_header.h:138),accumulated_checksum_ 是"包含本条、不含头部"的累加校验和(同文件 143 行)。前向校验通过后,Follower 把日志写入本地 WAL,然后通过 LogEngine::submit_push_log_resp(log_engine.h:265)回 ACK。这个 ACK 里带的是它已经持久化的 end_lsn。
LogGroupEntryHeader 里还有两处设计细节,不单独说会漏掉。第一处是 flag_ 的版本兼容。头文件注释画得很清楚:LOG_GROUP_ENTRY_HEADER_VERSION 用最低位做奇偶校验(parity)、倒数第二位标 padding、倒数第三位标 RAW_WRITE;VERSION2 改成用低 16 位放 crc16。同一份代码要能读新旧两种头部,靠的是 version_ 字段------读老日志时按老布局解析,读新日志时按新布局解析。持久化系统里这条规则几乎是铁律:你永远不能假设线上没有旧格式的数据,所以"格式升级靠 version 兜底"必须从第一版就设计好。第二处是 accumulated_checksum_ 的增量校验语义。它是"包含本条、不含头部"的累加校验和,用途是增量校验整条日志流:LogGroupEntry::truncate 按 SCN 截断日志时会传一个 pre_accum_checksum,用累加值判断截断点前后是否连续。有了累加校验和,校验不需要从头读整条流,只要比对相邻两条的累加值就能发现中间是否缺数据------这在复制场景里既能检测"日志被损坏",也能检测"日志被篡改"。LogSlidingWindow::sliding_cb 在滑出一条日志前会先调 checksum_.verify_accum_checksum(...),校验失败就直接抛 LOG_DBA_ERROR_V2(OB_LOG_CHECKSUM_MISMATCH, ...),把损坏当作 DBA 级别的严重错误上报,而不是静默跳过。
第四步是多数派确认,这是整条链路最核心、也最容易被讲糊的一步。Leader 维护一张 match_lsn_map_,每个成员记录一个 LsnTsInfo(log_ack_info.h),里面有该成员已确认的 lsn_ 和最近一次 ACK 的时间戳。LogSlidingWindow::get_majority_lsn_(log_sliding_window.cpp:2884)负责把这张表折算成"多数派 LSN":
cpp
} else if (FALSE_IT(accept_quorum = quorum_policy_->get_accept_quorum(replica_num))) {
} else if (valid_member_cnt < accept_quorum) {
// match_lsn_map does not reach accept quorum
} else {
lib::ob_sort(lsn_array, lsn_array + valid_member_cnt, LSNCompare());
// LSNCompare sorts in descending order, so the entry at accept_quorum - 1
// is the maximum LSN that has been matched by at least accept_quorum members.
result_lsn = lsn_array[accept_quorum - 1];
}
把全体成员最新的 match_lsn 收集起来、降序排序,取第 accept_quorum - 1 个(下标从 0 起),得到的就是"至少有 accept_quorum 个成员都已经达到的那个 LSN"。这是"取第 k 大"这个朴素定义在多数派语义下的直接实现------下标恰好落在多数派票数上的那个位置,就是安全的确认点。再往上,try_advance_committed_lsn_(log_sliding_window.cpp:1768)用 ATOMIC_BCAS 把这个结果原子地推进到 committed_end_lsn_:
cpp
LSN old_committed_end_lsn;
get_committed_end_lsn_(old_committed_end_lsn);
while (end_lsn > old_committed_end_lsn) {
if (ATOMIC_BCAS(&committed_end_lsn_.val_, old_committed_end_lsn.val_, end_lsn.val_)) {
break;
} else {
get_committed_end_lsn_(old_committed_end_lsn);
}
}
这里用无锁 CAS 而不是加锁,理由和 LSN 分配一样:committed_end_lsn_ 是热点变量,多个 ACK 处理线程可能同时想推进它,用 CAS 循环能避免串行化。
第五步是回调上游。当一条日志的 end_lsn 已经被 committed_end_lsn_ 覆盖,滑动窗口就会把它滑出,滑出动作触发 LogSlidingWindow::sliding_cb(log_sliding_window.cpp:2231)。这个函数里有一段是全篇的题眼:
cpp
} else {
// Call fs_cb.
int tmp_ret = OB_SUCCESS;
const int64_t fs_cb_begin_ts = ObTimeUtility::current_time();
if (OB_SUCCESS != (tmp_ret = palf_fs_cb_->update_end_lsn(palf_id_, log_end_lsn, log_max_scn, log_proposal_id))) {
...
}
const int64_t fs_cb_cost = ObTimeUtility::current_time() - fs_cb_begin_ts;
fs_cb_cost_stat_.stat(fs_cb_cost);
if (fs_cb_cost > 1 * 1000) {
PALF_LOG_RET(WARN, OB_ERR_TOO_MUCH_TIME, "fs_cb->update_end_lsn() cost too much time", ...);
}
到这一步,"一次复制"才算真正完成。committed_end_lsn_ 的推进本身不触发任何外部可见的动作,真正通知上游的是滑动窗口滑出时对 palf_fs_cb_->update_end_lsn 的调用。这解释了一个反直觉的设计选择:确认和通知是两件事。committed_end_lsn_ 可能在 ACK 到达的瞬间就被推进,但只有当这条日志被滑窗淘汰、且它的回调真的执行完,上层才知道"这段日志已提交"。Palf 把两者分层,是为了让"确认点"这个高频率、低成本的原子操作,和"回调执行"这个可能很慢(回调里往往要唤醒等待事务、推进回放位点、回复客户端)的操作互不阻塞。代码里甚至统计了 fs_cb_cost,超过 1ms 就告警,可见这条回调在作者眼里是"可能成为瓶颈的环节",而不是"顺手调一下"。
3. 多数派、滑窗与背压
上一步里"多数派 LSN"是一个瞬时快照,但 Leader 的提交是持续不断的,它需要一套整进整出的机制来管理"哪些日志还在途、哪些已经被确认、哪些可以回收"。这就是 LogSlidingWindow(log_sliding_window.h:197)。它不是普通的队列,而是一个带固定槽位、以 log_id 为序号的滑动窗口,每个槽位挂一个 LogTask,保存这条聚合日志的 begen_lsn、end_lsn、max_scn、proposal_id、各阶段时间戳。窗口的大小常量在 log_define.h:
cpp
const int64_t PALF_SLIDING_WINDOW_SIZE = 1 << 11; // must be 2^n(n>0), default 2^11 = 2048
const int64_t PALF_MAX_LEADER_SUBMIT_LOG_COUNT = PALF_SLIDING_WINDOW_SIZE / 2; // 1024
窗口 2048 个槽位,Leader 最多同时在途 1024 条。1024 是 2048 的一半,这个"半窗口"不是随手取的,log_sliding_window.cpp:317 的 leader_can_submit_larger_log_ 里注释把理由讲得很完整:
cpp
// should guarantee:
// 1. sliding window in follower - sliding window size in leader > 2, otherwise
// logs in follower may not be slided, because the log which committed_end_lsn
// can commit the first log may be out of sliding window.
// 2. max number of concurrent submitting group log in leader better be half of
// sliding window size in follower. If not, some logs may be rejected by follower
// because sliding window is full.
两条理由指向同一个物理事实:Leader 和 Follower 的窗口并不是同一个东西,Leader 的窗口是"我发出去还没被确认的",Follower 的窗口是"我收到但还没被应用/滑出的"。两者之间存在网络延迟造成的相位差。如果 Leader 允许在途量等于窗口大小,那么当它把窗口塞满时,这些日志涌到 Follower 那边,Follower 的窗口可能已经被更早的、还没滑出的日志占满了,于是新日志被拒收。拒收本身不是错误,但它带来的是无谓的重传和延迟抖动。留一半余量,等于给"相位差"预留了缓冲:Leader 在途最多 1024,Follower 窗口 2048,两者相差一倍,足以吸收乱序 ACK 和网络抖动。
背压是怎么产生的,代码里很具体。submit_log(log_sliding_window.cpp:452)在分配 LSN 之前先做一次 leader_can_submit_larger_log_ 检查,如果超过半窗口就返回 OB_EAGAIN;即使在分配之后,还要调 leader_wait_sw_slot_ready_(log_sliding_window.cpp:424)做二次确认,因为多线程并发提交时,可能有两个线程用同一个 max_log_id 通过了前置检查。这个等待函数非常朴素:
cpp
do {
if (false == leader_can_submit_larger_log_(log_id)) {
// double check log_id是否超出leader sw的range限制,
// 这一步是必要的,因为多线程并发submit时可能使用同一个max_log_id通过前置检查
ret = OB_EAGAIN;
} else if (OB_FAIL(guard.get_log_task(log_id, log_task))) {
...
}
if (OB_EAGAIN == ret) {
ob_usleep(100); // sleep 100us
}
} while(OB_EAGAIN == ret);
背压不是靠信号量或条件变量实现的,而是靠一个 100 微秒的轮询自旋。这看起来有点"土",但它换来的是极简的锁模型------不需要维护等待队列,也不会出现"唤醒丢失"。代价是等待期间 CPU 空转,所以这个 usleep(100) 的粒度是个折中:太短则忙等烧 CPU,太长则吞吐掉下来。100 微秒大致是"一次 DIO 落盘时间的十分之一到百分之一",让出 CPU 的同时又不会让提交线程睡太久。
窗口的大小其实是一个三方的平衡。窗口越大,Leader 能并发的在途日志越多,越能填满磁盘带宽、掩盖网络往返延迟,吞吐上限越高;但每条在途日志都要占内存承载回调上下文和写缓冲,窗口越大内存越大;更麻烦的是,窗口越大,Leader 切换后需要重传、重确认的日志量也越大,恢复越慢。2048 这个值配合 Leader 32MB、Follower 40MB 的 group buffer,在内存可控的前提下给了 1024 的并发度。如果把它开到 65536,palf_env_impl.cpp 里 io_cb_num = PALF_SLIDING_WINDOW_SIZE * 128 这样的分配会直接把回调线程池撑爆------这就是"窗口大小的上限其实是被下游缓冲区倒推出来的"。
4. 新 Leader 的第一步:RECONFIRM
Paxos 最容易被跳过、也最容易写错的部分,是"新 Leader 上任后能不能立刻服务"。选举只保证"选出唯一 Leader",不保证"这个 Leader 的日志最全"。一个刚当选的 Leader,本地日志可能落后于某个已经投票给它的 Follower------因为选举时 Follower 投票的依据是"你的提案号够新",而不是"你的日志够全"。如果新 Leader 直接开始对外服务,它就可能在自己落后的位置上追加新日志,把已经提交的日志覆盖掉,这是 Paxos 里著名的"新 Leader 必须先恢复未确认日志"问题。
OceanBase 在副本状态机里为这段过程专门留了一个状态。log_define.h 的 ObReplicaState 是 INVALID_STATE = 0, INIT = 1, ACTIVE = 2, RECONFIRM = 3, PENDING = 4。正常情况下副本在 INIT 和 ACTIVE 之间流转,ACTIVE 是"正常服务";PENDING 是"降级中的中间态";而 RECONFIRM 独自表示"我刚当上 Leader,正在做日志重确认,还不能服务"。状态切换的入口在 log_state_mgr.cpp:582 的 to_reconfirm_:
cpp
int LogStateMgr::to_reconfirm_(const int64_t new_leader_epoch)
{
int ret = OB_SUCCESS;
if (OB_FAIL(sw_->to_leader_reconfirm())) {
PALF_LOG(ERROR, "sw to_leader_reconfirm failed", K(ret), K_(palf_id));
} else {
reconfirm_start_time_us_ = ObTimeUtility::current_time();
reset_status_();
update_role_and_state_(LEADER, RECONFIRM);
reconfirm_->reset_state();
leader_ = self_;
leader_epoch_ = new_leader_epoch;
// New leader need clear logs after sw' first empty slot(log hole).
// If not, later local flush waiting will fail.
(void) sw_->clean_log();
}
return ret;
}
注意最后那句 sw_->clean_log() 和它的注释:新 Leader 上任后要清掉滑动窗口第一个空洞之后的日志。因为新 Leader 本地可能存在"旧 Leader 留下的、未被确认的尾部日志",这些日志与它即将通过重确认得到的"真实最新日志"可能冲突,先清掉它们,后续拉取和重放才能从干净的地基开始。
重确认的执行体是 LogReconfirm(log_reconfirm.h:33),它自己是一个完整的状态机。log_reconfirm.h:75 定义了八个状态:INITED、WAITING_LOG_FLUSHED、FETCH_MAX_LOG_LSN、RECONFIRM_MODE_META、RECONFIRM_FETCH_LOG、RECONFIRMING、START_WORKING、FINISHED。主循环在 log_reconfirm.cpp:465 的 reconfirm() 里,switch 逐级下推。把它的语义读懂,就理解了新 Leader 上任的全过程。
WAITING_LOG_FLUSHED 等待本地所有已提交的日志落盘完成,然后发出第一轮 prepare(submit_prepare_log_)。这一步对应 Paxos 的 prepare 阶段:新 Leader 用一个更大的 new_proposal_id_ 向多数派询问"你们各自接受过的最大日志在哪"。FETCH_MAX_LOG_LSN 收集这些应答。核心逻辑在 handle_prepare_response 里,它维护三个变量:prepare_quorum_max_accepted_log_server_(日志最新的那个成员)、prepare_quorum_max_accepted_lsn_(它的 LSN)、prepare_quorum_max_accepted_log_pid_(它的 proposal_id)。log_reconfirm.h:132 的注释把这段的语义点得很清楚:"After reconfirm, election leader will have learned the max accepted log from prepare quorum. 'max_lsn_' is max flushed id"。换言之,prepare 的返回不只是一个"承诺不接收旧提案"的票,还捎带回了"你接受过的最大日志在哪"这个信息,新 Leader 据此知道自己该向谁拉齐。
接下来的 RECONFIRM_MODE_META 会先对访问模式做一次重确认(mode_mgr_->reconfirm_mode_meta()),因为模式里带着 ref_scn 这个 SCN 下界,切主后必须保证所有新提交日志的 SCN 都大于它。随后进入 RECONFIRM_FETCH_LOG,这才是"拉日志"本身。这里有个很容易被忽略的细节:can_receive_log()(log_reconfirm.cpp:410)写的是 return (RECONFIRM_FETCH_LOG == state_),注释解释"Self can receive log only in RECONFIRM_FETCH_LOG stage. Or it may receive logs with old proposal_id whose lsn is larger than prepare_quorum_max_accepted_lsn"。也就是说,处于 RECONFIRM 的 Leader 只在这一个状态下接收别人推来的日志,其余时间一律拒收------因为过早接收可能收到旧 Leader 的、proposal_id 更老的日志,把已经确认的进度搅乱。这个"只在一个窗口接收"的约束,是防覆盖的关键闸门。
拉齐之后进入 RECONFIRMING,这里有一个反直觉的额外条件。is_accept_quorum_catch_up_(log_reconfirm.cpp:427)要求"accept 多数派"也追上 prepare_quorum_max_accepted_lsn_ 才能继续,注释解释了为什么:
cpp
// This step is necessary, because if we directly go to start_working stage:
// 1) For 2F1A case, the A member can skip prev log check and reply ack faster than F.
// This will lead to an unexpected state: the other F replica lacks logs when
// reconfirm finishes. If leader crashes later, the data of these logs will lost.
// 2) For all F replica case, start_working log need prev log check, so all followers
// also need wait log sync before process. It's similar with this step.
这段注释是有真金白银的。在 2F1A(两个全功能副本加一个仲裁副本)部署下,仲裁副本不存日志、不需要做前向校验,所以它能比真正的 Follower 更快地答应 START_WORKING。如果只等"prepare 多数派"而不等"accept 多数派",Leader 会在另一个 Follower 还没拉齐日志时就认为重确认完成、开始对外服务;此时若 Leader 再宕机,那个落后的 Follower 被选为新 Leader,它缺的那段已提交日志就永久丢了。is_accept_quorum_catch_up_ 用"accept 多数派也要追上"堵住了这个洞。这是"多数派"这个词在不同阶段含义不同的一个具体例子:prepare 阶段的多数派用来"学最新的日志在哪",accept 阶段的多数派用来"确认自己真的拿到了那段日志",两者的成员集合可能一样,但要求的状态不同。
最后一个状态 START_WORKING 是收尾。Leader 写一条 START_WORKING 日志并等它提交,然后调用 try_advance_committed_end_lsn(saved_end_lsn_)(log_reconfirm.cpp:614)把 committed_end_lsn_ 一次性推进到 saved_end_lsn_------也就是"进入 START_WORKING 那一刻的 max_lsn"。为什么可以一次性跳跃推进?因为 START_WORKING 日志一旦被多数派确认,就逻辑上意味着"它之前的所有日志都已经被确认了",否则这条日志自己也提交不了。这里能看出 padding 之外另一种"位置推进"手法:不需要为中间每一段补写日志,只靠一条哨兵日志的提交把整体确认点抬过去。整个过程配了 10 秒超时(log_define.h:95 的 PALF_LEADER_RECONFIRM_SYNC_TIMEOUT_US),超过就触发 OB_LOG_LEADER_RECONFIRM_TIMEOUT 告警(log_state_mgr.cpp:853),说明作者把"重确认卡住"当作必须被运维看见的异常,而不是可以无限等待的正常状态。
5. 成员变更为什么要两阶段
成员变更是 Paxos 家族里最容易写错的部分。改成员列表的瞬间,如果处理不当,会出现"旧配置的多数派"和"新配置的多数派"同时成立且互不相交的窗口,直接脑裂。举个最小的例子:配置是 {A,B,C},多数派是 2;现在想把 C 换成 D,得到 {A,B,D}。如果"改配置"这个动作本身只需一次多数派(比如只要 {A,B} 同意),那么在 C 被移除、D 还没同步上日志的窗口里,如果 A、B 觉得配置已经是 {A,B,D},而 C 还以为配置是 {A,B,C} 且自己是多数派的一员,就可能出现两组人各认为自己是多数派。Raft 用"联合配置"解决这个问题,Palf 用一条同时携带新旧两份配置的配置日志解决------思路同源,细节不同。
证据在 log_engine.h,它暴露了三个成对的接口,名字已经把流程说清楚了:submit_prepare_meta_req(prepare)、submit_change_config_meta_req(change config)、submit_change_mode_meta_req(change mode)。submit_prepare_meta_req_(log_engine.h:271)携带的是 LogPrepareMeta,它只有两个字段(log_meta_info.h:66):voted_for_(我投给谁)和 log_proposal_id_。这是 Paxos 的 prepare 阶段:先取得多数派"不会接受比你更旧提案"的承诺,同时(如上一节所述)顺带学习"你们各自接受的最大日志在哪"。这一步在成员变更里的作用是"锁住当前的多数派",让接下来的配置切换有一个稳定的地基。
submit_change_config_meta_req_(log_engine.h:291)携带 LogConfigMeta,它是两阶段里真正"切配置"的那一步。看它的字段(log_meta_info.h:255):
cpp
int64_t version_;
int64_t proposal_id_;
LogConfigInfoV2 prev_;//modified in version_42
LogConfigInfoV2 curr_;//modified in version_42
int64_t prev_log_proposal_id_;
LSN prev_lsn_;
int64_t prev_mode_pid_;
prev_ 和 curr_ 同时出现,这是关键。新配置生效的前提是这条日志被多数派确认,而多数派用的是变更前 的成员集合来算------因为你不能用一个还没生效的配置去投票。等这条配置日志提交后,新配置才正式生效。这样就不存在"新旧两个不相交多数派并存"的窗口:变更期间,判定谁来投票始终用的是旧配置,直到配置日志本身被旧配置的多数派确认。再配 prev_log_proposal_id_、prev_lsn_、prev_mode_pid_ 这三个字段,是为了做前向校验和交叉检查------配置日志必须接在"变更前那条日志"的后面,且不能跨过一个模式切换,避免配置和模式的 proposal 打架。
和 Raft 的 joint consensus 相比,Palf 的做法更"扁"。Raft 需要显式定义一个中间的联合配置 (C_{old,new}),先让联合配置过多数派,再让新配置过多数派,两次共识、两个阶段,中间态本身是一条正式配置。Palf 把"旧配置 + 新配置"打包在一条 LogConfigMeta 里做一次共识,用 LogConfigVersion(log_meta_info.h:72)来排序和去重。LogConfigVersion 由 proposal_id_ 和 config_seq_ 两个整数组成,它的注释说明了两个字段的分工:
cpp
// Ensure that:
// 1. For leader don't has any writes, this field used to ensure leader completeness.
// (NB: can not use the proposal_id of prepare request, because a lagged replica
// may receive the prepare reqeust of new leader).
// 2. For changing config, this field used to ensure inc config_version monotoniclly.
proposal_id 保证 Leader 完整性(新 Leader 不能把旧 Leader 的配置覆盖掉),config_seq 保证配置版本单调递增。这里"不能直接用 prepare 请求的 proposal_id"这个括号注释,正是上一条"旧配置多数派投票"思想的延伸:一个落后的副本可能收到新 Leader 的 prepare 请求,如果用它当版本号,落后副本会把一个更旧的配置误判成更新的。
submit_change_mode_meta_req(log_engine.h:311)管的是"模式"而不是"成员",携带 LogModeMeta,包含 mode_version_、access_mode_ 和 ref_scn_。ref_scn_ 的注释是"after switching over, scn of all submitted log should be bigger than ref_scn_"------切主后备库所有新提交日志的 SCN 必须大于这个下界,这是把"逻辑时间"也纳入共识,防止切主后 SCN 回退。模式有四个值(palf_options.h):APPEND、RAW_WRITE、FLASHBACK、PREPARE_FLASHBACK,而它们的迁移路径受 can_switch_access_mode_ 严格限制:APPEND 不能直接切到 PREPARE_FLASHBACK 或 FLASHBACK,PREPARE_FLASHBACK 不能切到 RAW_WRITE,FLASHBACK 不能切回 PREPARE_FLASHBACK。为什么限制这么死?因为每种模式对"日志的写法和读法"有不同的假设:APPEND 是顺序追加,RAW_WRITE 允许上层按 LSN 直接改内容(用于备库),FLASHBACK 要按 SCN 截断日志。如果允许任意迁移,就可能出现"一边在闪回截断、一边在追加新日志"这种自相矛盾的组合。用状态机禁止非法迁移,是把"不可能同时发生"这件事写进类型系统。
最后还有一把显式的锁。log_meta_info.h:176 定义了 ConfigChangeLockType,用位掩码表示"当前正在做哪种变更":LOCK_PAXOS_MEMBER_CHANGE = 0x1、LOCK_PAXOS_REPLICA_NUMBER_CHANGE = 0x2、LOCK_LEARNER_CHANGE = 0x4、LOCK_ACCESS_MODE_CHANGE = 0x8、LOCK_ARBITRATION_MEMBER_CHANGE = 0x10。配套的 LogLockMeta(log_meta_info.h:188)记录了 lock_owner_、lock_type_、lock_time_。为什么要锁?因为"成员变更"和"模式切换"如果并发执行,可能产生互相矛盾的中间状态------一边在加成员、一边在切读写模式,配置日志的 proposal 和模式日志的 proposal 会互相穿插,谁都说不清最终生效的是哪个组合。这把锁用最朴素的办法把多种变更串行化,消灭了并发组合的爆炸。它的代价很直接:并发度下降,你不能一边加成员一边切模式;换来的是"稳态只有一种变更在进行"这个不变量,让所有下游判断都不需要处理组合情形。
这里还有一个"平滑退出"的细节值得单独说。LogConfigInfo(log_meta_info.h:106)里除了 log_sync_memberlist_(参与日志同步的 Paxos 成员,不含仲裁)、log_sync_replica_num_、arbitration_member_、learnerlist_,还有一个 degraded_learnerlist_------"从 Paxos 成员里移出去、但降级为只读 learner 的副本"。保留这个列表,是为了让被移出的成员仍能继续同步日志(作为 learner),避免它立刻变成孤岛、下次想重新加回时要从头拉全量日志。被移除的成员不是被一脚踢开,而是被降格保留,这是"平滑退出"在数据结构里的体现。
6. LSN 与 SCN
讲完复制和成员变更,得回到那个最基础的问题:Palf 用什么样的坐标来定位一条日志?答案是有两套坐标,而且它们是在同一次原子操作里一起分配的,这个设计同时服务了两个完全不同的需求。
第一套是 LSN。lsn.h 里 LSN 的全部有效数据就是一个 uint64_t 的偏移量。给定块大小,lsn_2_block 用除法算出它在哪个块,lsn_2_offset 用取模算出块内偏移。所以 LSN 是纯物理坐标------它不携带时间信息,只告诉你在日志流这条"文件"里,这条日志从第几个字节开始。官方对 LSN 的表述是:"一条日志使用日志流内唯一的 LSN 表示,使用 LSN 可以在内存及磁盘上唯一定位一条日志。对于已经明确提交的日志,在多个副本之间完全一致。"最后那句是 Paxos 的承诺:一条日志一旦被多数派确认,它在所有副本上的 LSN 和内容都一致,所以 LSN 可以当"跨副本的物理坐标"用。
第二套是 SCN。它是全局时间戳,定义在 src/share/scn.h,用于 MVCC 可见性判断:一条日志的 SCN 大于某个读操作的 SCN,这个读操作就看不到它。LSN 只在单个日志流内有意义(比较两个 LSN 的大小,只有在这两个日志属于同一日志流时才正确),SCN 在租户内全局可比。两者的对照可以写成一张表。
| 维度 | LSN | SCN |
|---|---|---|
| 语义 | 单流内的物理字节偏移 | 全局逻辑时间戳(事务版本) |
| 可比范围 | 仅同一日志流内 | 租户内全局可比 |
| 用途 | 定位日志、跨副本对齐、GC 水位 | MVCC 可见性、事务快照、seek(scn) |
| 单调性 | 流内单调递增 | 全局单调递增 |
| 跨副本一致性 | 已提交日志完全一致 | 由 GTS 分配,参与共识 |
真正有意思的是这两个坐标一起被分配。lsn_allocator.h 的 LSNAllocator::alloc_lsn_scn 一个函数同时吐出 LSN、log_id、SCN,而内部的 LSNTsMeta 是一个 128 位联合体:
cpp
union LSNTsMeta {
struct types::uint128_t v128_;
struct {
uint8_t is_need_cut_ : 1;
uint64_t log_id_delta_ : LOG_ID_DELTA_BIT_CNT; // 28 bits,可生成25万个 log_id
uint64_t scn_delta_ : LOG_TS_DELTA_BIT_CNT; // 35 bits,ns 级别约32秒
uint64_t lsn_val_ : 64;
};
};
(log_id_delta, scn_delta, lsn_val) 这三个量被压在同一个 128 位整数里,用 CAS 原子更新。这意味着"分配下一个位置"是一个无锁的原子操作------多个线程并发写日志时,各自 CAS 拿到自己的位置,不会互相阻塞。这就是官方说的"实现事务的高性能,同时做到多核下的可扩展"在代码里的样子。把三个量压进一个 128 位整数的做法,本质上是用位打包换一次原子比较交换:与其对三个独立的变量分别加锁,不如把它们合并成一个可原子操作的单元。代价是位数有限------log_id_delta 只有 28 位(约 25 万个 log_id),scn_delta 只有 35 位(纳秒级约 32 秒),所以这个联合体表达的是"相对于 base 的增量",一旦增量逼近上界就要通过 try_freeze 冻结、重置 base。位宽限制在这里不是缺陷,而是"让无锁成为可能"的必然约束。
这里有个隐含的正确性约束:因为 LSN 和 SCN 一起分配、各自单调,所以"LSN 更大"必然"SCN 更大"。这个单调关系是日志回放的基础------按 LSN 顺序回放,SCN 也是有序的,存储引擎不需要额外排序。lsn_allocator.h 里还有个 LOG_CUT_TRIGGER = 1 << 21(2MB):聚合日志跨越 2MB 边界时要切分,为了让单条聚合日志不超过一个宏块大小,保证存储层按宏块对齐读取时不会跨块。这个常量和存储层的宏块大小是同一个数字,是两个子系统之间的一条隐式契约。
回到官方"返回特定语义的时间戳"那句话------现在能翻译了。Palf 的接口不止是"写一条日志、返回它的偏移量",它还在同一次分配里给出一个与这条日志绑定的 SCN。这个 SCN 的"特定语义"是:它既可以当作 MVCC 的版本号,又可以当作全局可比的时间戳,还可以当作按时间定位日志的索引(LogGroupEntryHeader::max_scn_ 就是为了 seek(scn))。一个函数返回一个复合坐标,是 Palf 把"日志服务"和"事务版本生成"这两个职责合一的直接后果。
7. 日志落盘的物理组织
上一节讲的是逻辑坐标,这一节讲物理承载。Palf 向磁盘申请空间的最小单位是 64MB,常量在 log_define.h:59:PALF_PHY_BLOCK_SIZE = 1 << 26。实际可写的数据块是 64MB 减去 4KB 的块头信息,所以 PALF_BLOCK_SIZE = 64MB - 4KB。块以独立文件的形式落在磁盘上,文件名就是 block_id------convert_to_normal_block 干的就是拼出 <dir>/<block_id> 这个路径。LogStorage(log_storage.h)持有 log_tail_(当前写入尾部,见 206 行)和 curr_block_writable_size_(当前块剩余可写空间,见 135 行),块写满时调 inner_switch_block_(164 行)切换到下一个块。
为什么是 64MB 一块?大块顺序写能把随机写变成顺序写,DIO 对齐也简单。但代价有两个,都很具体。一个是空间放大:如果某个日志流写入量很小,它的当前块里可能只用了 1MB,但磁盘上已经占着 64MB,直到这个块被填满或被 GC。一个租户有 N 个日志流,就有 N 个这样"半空"的块。另一个是文件数量:每个块是一个文件,日志盘配得大、写入又猛的话,块文件会很多,GC 不及时时 ls 一个日志目录能看到成百上千个文件。
空间放大直接推导出了那个 512MB 的常量。log_define.h:50 定义 MIN_DISK_SIZE_PER_PALF_INSTANCE = 512 * 1024 * 1024ul,而这个常量真的被用在容量校验里。src/storage/tx_storage/ob_ls_service.cpp 的 get_resource_constraint_value_ 里:
cpp
const int64_t per_palf_size = MIN_DISK_SIZE_PER_PALF_INSTANCE;
...
clog_disk_value = disk_opts.log_disk_usage_limit_size_ / per_palf_size;
也就是说,"一个租户能创建多少日志流"这个数,等于"日志盘总配额除以 512MB"。512MB 除以 64MB 正好是 8,也就是说给每个 Palf 实例留够 8 个块的空间,GC 才能正常滚动------当前块在写、若干块在等确认、若干块在等回放、若干块刚被回收,一个健康的 GC 需要这么多块同时在环。如果日志盘太小,块很快写满、GC 跟不上,就会触发 LogWritingThrottle 停止写入。这里"日志流数量"和"日志盘配额"被 512MB 这个常量刚性绑在一起,是 Palf 设计里一个很重要的隐含约束。
这条约束的现实后果是:扩容日志流(比如增加 UNIT_NUM 导致 LS 变多)不只是"多几个 LS",还要求日志盘同步扩容。如果日志盘不够,新建的 LS 会因为 MIN_DISK_SIZE_PER_PALF_INSTANCE 校验不通过而创建失败,或者勉强创建后在限流下跑得很慢。这跟"按需分配的共享存储"不同------Palf 的磁盘是预留给每个实例的定长块,扩容是刚性的。好处是行为可预测、不受邻居影响;代价是资源利用率低、扩缩容不灵活。Palf 在这里选择的是"预留"而不是"挤用"。
还有一个"系统日志流优先"的细节。log_define.h:169 定义 SYS_PALF_ID = 1,每个租户(含系统租户)都有一个 ID 固定为 1 的日志流,承载这个租户的"全局单点服务",最典型的是 GTS(全局时间戳服务)的 Leader。把系统级日志流单独拎出来,是因为它的写入模式和用户日志流完全不同:系统流写的是元数据、时间戳区间,量小但绝不能停;用户流写的是业务数据,量大但可以限流。LogIOWorker 里有个 need_ignoring_throttling_ 字段(log_io_worker.h:144),注释写着系统日志流不参与磁盘限流。这是"系统流优先"在代码里的体现------限流是为了保护日志盘,但系统流的元数据写入是保护对象的保护者,不能被自己限住。
写入限流不是一个开关,而是一个多级水位系统。PalfDiskOptionsWrapper 持有的 PalfDiskOptions 里有四个关键参数:log_disk_usage_limit_size_(日志盘总量)、log_disk_utilization_threshold_(开始复用旧日志文件的利用率阈值)、log_disk_utilization_limit_threshold_(停止提交与接收日志的上限)、log_disk_throttling_percentage_(触发写入限流的阈值)。这四个数字画出了从"正常运行"到"降速"再到"停止"的一条曲线。限流的触发条件在 PalfThrottleOptions::need_throttling 里,判据是 unrecyclable_disk_space_ > total_disk_space_ * trigger_percentage_ / 100------所谓 unrecyclable 就是"已经写了但还不能回收"的那部分,它由 GC 水位决定,而不是由写入量直接决定。触发限流后并不是立刻停写,PalfThrottleOptions::get_available_size_after_limit() 算的是"从触发限流到彻底停止写入之间还剩多少空间可以慢慢写",用这段空间撑到 GC 追上。这套"先降速、后停写、靠 GC 兜底"的三段式,比"一到水位就硬停"平滑得多:它给 GC 争取了时间,也避免了写入量在阈值附近反复抖动(进了线就停、掉出线就放)导致的吞吐剧烈波动。代价是磁盘利用率被刻意压低------你配的日志盘总有相当一部分是"不可回收但还没到回收点"的日志,这正是 log_disk_utilization_threshold_ 存在的原因:低于这个利用率时正常写,高于时才开始复用旧文件,把"空间换平滑"这件事做在参数里。
8. 异步复制与树状级联
Palf 不只有"三副本强一致"这一种复制形态。LogReplicaType(log_define.h:198)定义了三种副本:NORMAL_REPLICA(全能副本,Paxos 成员)、ARBITRATION_REPLICA(仲裁副本)、LOGONLY_REPLICA(日志型/只读副本)。这三种值和上层 Locality 里的 F/R 是两套坐标------Palf 层只关心"这个副本在 Paxos 里扮演什么角色"。
| 副本类型 | 存日志 | 投票 | 参与选举 | 典型用途 |
|---|---|---|---|---|
| NORMAL_REPLICA | 是 | 是 | 是 | 全能副本,构成 Paxos 多数派 |
| ARBITRATION_REPLICA | 否 | 是 | 是 | 2F1A 降本,只投票不存数据 |
| LOGONLY_REPLICA | 是 | 否 | 否 | R 副本,只同步与回放,不拉长提交延迟 |
仲裁副本的价值在"降本":三副本部署要 3 份数据,如果只想要容灾、不想要 3 份存储,可以配"2 个全功能副本 + 1 个仲裁副本",多数派还是 2,但数据只存 2 份。它不存数据、只投票,这就带来一个时间上的压力:仲裁副本拿不到配置日志就投不了票,多数派就凑不齐。前面提到的两个重发配置日志的间隔常量------普通成员 500ms(PALF_RESEND_CONFIG_LOG_INTERVAL_US),仲裁成员 10ms(PALF_RESEND_CONFIG_LOG_FOR_ARB_INTERVAL_US)------50 倍的差距正说明仲裁成员在成员变更里的关键性。仲裁的相关代码被 #ifdef OB_BUILD_ARBITRATION 包起来,是编译期可选特性。
日志型/只读副本就是官方文档里的 R 副本,官方定义是"只读型副本为非 Paxos 副本,对应副本不可构成 Paxos 成员组,不参与选举投票"。它同步完整的日志、在本地回放,但不投票、不能当选 Leader。它最大的价值是"不增加投票成员":你加多少 R 副本,都不会拉长事务提交延迟,因为它们不参与多数派计算。在 Palf 里 R 副本是用 learner 机制实现的,palf_handle.h 的 add_learner 注释直接写着"add a learner(read only replica) in this cluster"。learner 的核心数据结构在 log_learner.h:23:
cpp
class LogLearner {
common::ObAddr server_; // net addr
common::ObRegion region_; // Region info
int64_t register_time_us_;
int64_t keepalive_ts_; // last recv keepalive timestamp
};
region_ 字段是关键。官方文档说明:"对于 V4.3.x 版本,OceanBase 数据库从 V4.3.1 版本开始支持 R 副本按照 Region 级联,跨 Region 部署的 R 副本可以自动组成树状级联结构来同步日志,减少跨 Region 网络流量的消耗。"翻译成 Palf 的语言就是:learner 之间可以组成父子关系,一个 Region 里的 R 副本不必都从 Leader 直接拉日志,而是可以指定一个父副本,由父副本再去喂子副本。这族接口在 log_engine.h 里有六个:submit_register_parent_req/submit_register_parent_resp(注册父子关系)、submit_learner_keepalive_req/submit_learner_keepalive_resp(心跳)、submit_retire_parent_req/submit_retire_child_req(解除关系)。心跳节奏由 PALF_PARENT_KEEPALIVE_INTERVAL_US = 1s 和 PALF_PARENT_CHILD_TIMEOUT_US = 4s 控制:子副本每秒发一次 keepalive,4 秒收不到就认为父子关系失效、需要重新注册。
树状级联省流量的算术很直观。如果 Leader 在一个 Region,另外三个 Region 各有 5 个 R 副本,直连模式下每个 R 副本都要跨 Region 从 Leader 拉全量日志,跨 Region 流量是 15 份;树状模式下每个 Region 选 1 个节点直连 Leader,跨 Region 流量是 3 份,其余 12 个副本在 Region 内从这 1 个节点拉(Region 内流量便宜)。15 份降到 3 份,这是"以拓扑换带宽"。代价是级联深度增加带来的延迟:Region 内的 R 副本要等父副本先收到再转发,回放延迟比直连多一跳。但对只读副本而言,延迟不是第一诉求,带宽成本才是。FetchLogEngine 里的 FETCH_LOG_TASK_MAX_COUNT_PER_LS = 64(fetch_log_engine.h:75)限定了每个日志流最多 64 个并发拉取任务,防止大量 R 副本同时拉日志把父副本压垮------这是对"一个父副本带很多子副本"这个拓扑的隐性保护。
异步复制和同步复制最终在协议层被统一成同一个 PushLogType 枚举的两条分支:PUSH_LOG(需要 ACK,走同步多数派)和 PUSH_LOG_WO_ACK(不需要 ACK,走异步观察者)。learner 走后者------不投票,Leader 推日志给它时不需要等它 ACK。一个数据类型区分了"同步成员"和"异步观察者",这个区分在协议的最底层,所有上层的成员管理、成员变更都建立在这个二分之上。
9. 横向对比
Raft 和 Palf 都做"Leader 追加日志、多数派确认、Follower 追日志",但有几处关键差异值得逐条拆开。Raft 用 term 标记领导轮次,每个 term 只做一次选举共识,选举和复制绑得很紧;Palf 用来自 LogPrepareMeta 的 proposal_id,Leader 一旦当选就持续服务、不主动换届,这是 Multi-Paxos 的特征------把"选主"这个高成本动作从"每次写"里剥离出去,只做一次。成员变更上,Raft 的 joint consensus 显式定义联合配置 (C_{old,new}) 作为中间态,先提交联合配置再提交新配置;Palf 把新旧配置打包在一条 LogConfigMeta 里做一次共识,用 LogConfigVersion 排序。成员角色上,Raft 只有 voter 和 non-voting learner 两种;Palf 有 NORMAL_REPLICA/ARBITRATION_REPLICA/LOGONLY_REPLICA 三种,把"投票"和"存数据"这两个职责彻底解耦,于是有了"2F1A 只存两份数据"这种 Raft 原生不具备的部署形态。读一致性上,Palf 的租约机制让 Leader 在自己的 lease 内可以本地读、不需要每个读都过多数派------这是它比"读也要过 Raft 日志"的方案省一大截往返的原因,代价是 lease 依赖本地单调时钟、并要容忍时钟偏移。
为什么 OceanBase 不直接用 Raft,而选 Multi-Paxos 加独立选举?根子在职责分离上。Raft 每个 term 一次选举、term 单调,所有写(配置变更、日志追加)都在同一条请求流里排队;Palf 把"选主"抽成独立的 election 模块,官方原话是"两者有一定的相关性,但在实现上又尽量做到减少耦合"。Leader 一旦当选就长期服务,不需要在每次写里携带 term 判断------日志里带的是 proposal_id,只在换主时才变。这带来两个直接好处。其一是读路径不必过日志:Raft 的线性一致读要么走日志(读索引),要么走 ReadIndex 加一轮心跳确认;Palf 靠 lease,在租约内 Leader 可以认定自己是唯一合法 Leader,本地读直接返回,省掉一次心跳往返。其二是日志复制能开更高的并行度:Multi-Paxos 允许多个 proposal 同时在途(Palf 滑动窗口的 1024 就是并发度),而朴素的 Raft 实现往往把 AppendEntries 做成近似串行。代价也很清楚:Palf 需要额外处理"选主与复制的耦合点"------RECONFIRM 里既要读取 state_mgr_->get_leader_epoch() 又要跟 new_proposal_id_ 对齐,这条耦合线比 Raft 里"term 一把梭"更难读,也是为什么选举机制值得单独拿出来讲。把这五种系统和 Palf 放在一起看,会发现它们的分歧几乎都源于同一个问题的不同答案:冲突消解应该发生在协议层,还是交给上层。Raft 和 ZAB 把顺序和任期揉进协议核心,Kafka 把顺序交给分区、把冲突交给上层应用,InnoDB 干脆假设单机无冲突,Palf 则把顺序(LSN)和版本(SCN)都收进协议、再向上暴露成文件语义。选哪条路,取决于这个系统最怕的是什么------怕脑裂就在协议层多下功夫,怕复杂就把责任下沉到应用层。
Kafka 的分区日志也是"只追加 + 分段(segment)+ 按 offset 定位",和 Palf 的"块文件 + LSN 定位"高度相似。Kafka 用 ISR(in-sync replicas)表示"跟得上的副本集合",Leader 从 ISR 里选;Palf 用滑动窗口的 ACK 推进 confirmed LSN,用 LogReplicaType 区分成员和观察者。差异首先在一致性:Kafka 默认 acks=1 是"Leader 写成功即返回",即使配 acks=all 也是等 ISR 全确认,是可用性优先、且 ISR 本身可以缩容;Palf 是多数派强一致,Leader 必须等多数派持久化,且多数派是按配置算的固定集合,不会因为慢而缩。差异其次在目的:Kafka 的日志就是数据本身,读日志就是消费消息;Palf 的日志是数据的"变更记录",上层还要把它回放(replay)到 LSM-Tree 里变成数据。所以 Palf 需要在日志上做 GC(回放完的日志可以删),Kafka 则是按保留策略或消费进度删。这个目的差异还决定了"高水位"的含义不同:Kafka 的高水位是"所有 ISR 都复制到的位置",Palf 的 committed_end_lsn_ 是"多数派复制到的位置",前者更保守、延迟更高,后者更激进、依赖多数派不回退。
InnoDB 把 redo log 组织成固定数量的文件(innodb_log_files_in_group,默认 2 个),循环使用,用 LSN 作为字节偏移。Palf 是"64MB 块 + 按需追加 + GC 回收",不是固定环形。这个差异来自"单机 vs 分布式":InnoDB 的 redo log 只需要本地持久化,环形复用最省空间;Palf 的日志要向多个副本复制、要支持新副本从某个位置开始追赶,所以不能环形覆盖,必须保留一段历史直到所有副本都确认,于是用"追加 + 按 confirmed LSN 回收"。Palf 的 LSN 和 InnoDB 的 LSN 语义相近(都是字节偏移),但 Palf 额外绑定了 SCN 用于 MVCC,而 InnoDB 的 redo 里不带全局时间戳。更本质的差异在于"谁决定什么时候可以覆盖":InnoDB 由 checkpoint 决定、只与本地脏页有关;Palf 由 committed_end_lsn_ 和所有副本的确认进度共同决定、是跨节点的全局判断。
ZAB(ZooKeeper Atomic Broadcast)也是"Leader 广播 + 多数派确认 + 新 Leader 恢复",和 Paxos 系同源,它的新 Leader 恢复阶段叫"发现(discovery)与同步(synchronization)",和 Palf 的 LogReconfirm 八个状态是同一件事的两种写法。ZAB 用 zxid(epoch + counter)作为日志坐标,Palf 用 LSN + SCN 双坐标。ZAB 的设计目标是"为主备复制定制的原子广播",保证原主提案的顺序,Palf 面向的是"事务日志 + MVCC",需要把时间戳(SCN)也纳入共识------这就是为什么 Palf 的 LogModeMeta 里有一个 ref_scn,而 ZAB 的配置里没有时间戳这个维度。成员变更上,ZAB 通过"配置在日志里、成员变更即一条特殊事务"的机制处理,和 Palf 把配置变更做成专门的 LogConfigMeta 思路类似,但 Palf 的 LogConfigVersion(proposal_id + config_seq)在版本排序上更显式,配合 ConfigChangeLockType 位掩码锁把多类变更串行化。
10. 代价与权衡
把上面这些机制放在一起看,能提炼出几处明确的"放弃了什么、换来了什么"。第一处是 64MB 大块预分配。它把随机写变成顺序写、让 DIO 对齐变简单,代价是空间放大(半空的块)和文件数量膨胀,并且直接导致了"每个实例至少 512MB、日志流数量被日志盘配额刚性约束"的后果。扩容不灵活、资源利用率低,是这套设计为"行为可预测、不受邻居干扰"付出的价格。
第二处是单 IO 线程加批量合并。LogIOWorker::MAX_THREAD_NUM = 1(log_io_worker.h:81)意味着所有日志落盘都由一个线程消费队列。收益是写入天然有序、无锁竞争、批量合并简单------一个线程可以从队列里一次捞出一批任务合并写入,减少 DIO 次数;代价是如果一个 IO 特别慢(磁盘抖动),会阻塞后面所有日志流。这是"用有序性换并行度"的选择,配合 BatchLogIOFlushLogTaskMgr 的合并,把一个线程的效率压榨到接近满血,但单线程的吞吐上限客观存在。
第三处是成员变更的多阶段与显式锁。变更要走 prepare 加 change config 至少两轮共识,每轮都要等多数派;代码量大(log_config_mgr.cpp 是 Palf 里最大的文件之一)、状态多。它换来的是"不会脑裂"这个不可妥协的正确性,以及"变更过程中旧配置仍然提供多数派语义"的平滑性。再叠上 LogLockMeta 显式锁,多种变更被串行化,可靠性进一步提升,代价是并发度下降------你不能一边加成员一边切模式。这类"用复杂度换正确性"的取舍,在分布式协议里几乎总是划算的,因为脑裂的代价是数据永久损坏,而变更是低频操作。
第四处是重确认的"等待 accept 多数派追上"这一步。is_accept_quorum_catch_up_ 让新 Leader 在开始服务前多等一轮,显著拉长了主备切换期间的不可用时间(这也是为什么它配了 10 秒超时告警)。如果为了"快速恢复服务"而省掉这一步,在 2F1A 或网络分区场景下就可能丢掉已提交数据。这里 Palf 选择了"宁可多等一会儿,也不能冒丢数据的风险",把可用性让位给了一致性------在故障恢复这个最容易出错的窗口里,这个选择是对的。换个角度看,那 10 秒超时也划出了 Palf 对"故障恢复时长"的预期上界:正常情况几秒内就该完成重确认,逼近 10 秒说明网络或磁盘出了问题,需要人来介入,而不是让系统无限期地自我修复。
11. 读路径与 IO 调度
前面十节几乎都在讲写,但日志系统的另一条腿是读。读日志的场景比你想的多:Follower 追日志要从 Leader 读、回放引擎要把日志读出来应用到 MemTable、闪回要按 SCN 定位并截断、新副本重建要从头读一遍。读路径的核心是"定位加缓存",定位靠 LSN,缓存靠 LogCache。log_cache.h 定义了缓存的两个关键常量:
cpp
static const int64_t CACHE_LINE_SIZE = LOG_CACHE_ALIGN_SIZE; // 64KB
static const int64_t LAST_CACHE_LINE_SIZE = CACHE_LINE_SIZE - MAX_INFO_BLOCK_SIZE; // 64KB-4KB
static const char *const OB_LOG_KV_CACHE_NAME = "log_kv_cache";
缓存的粒度是 64KB,复用 OceanBase 的通用 KV Cache(share/cache/ob_kv_storecache.h 的 ObIKVCache),注册名 log_kv_cache。为什么不是 4KB(DIO 的对齐尺寸)?因为读日志的典型场景是"一次读一段连续日志做回放",64KB 的预取粒度更划算------它能把多次小读合并成一次大读,同时减少 KV Cache 里对象数量、降低元数据开销。LAST_CACHE_LINE_SIZE 比常规缓存行少 4KB,是因为块的最后一个缓存行要扣掉块头,凑不满整行,这个 4KB 就是 MAX_INFO_BLOCK_SIZE 那个块头尺寸。缓存的容量上限由 LOG_CACHE_MEMORY_LIMIT = 20 控制,含义是"日志缓存最多占用租户内存的 20%"。用比例而不是固定值,是因为租户内存可配置,固定值在大租户上太小、小租户上又太大;用比例能保证日志缓存不会把存储引擎、SQL 的内存挤爆。
缓存不是谁读都填。log_cache.h:54 的 EnableFillCacheFunctor 决定了填充条件:只有在"这个副本是同步成员、且正在服务读请求"时才填缓存。R 副本、仲裁副本这种纯观察者不填------它们读日志只是为了本地回放,读了就丢,不值得占内存。这个条件的存在,解释了为什么 LOG_CACHE_MEMORY_LIMIT = 20 这个百分比不会失真:如果所有副本都无脑填缓存,一个集群里 F、R、A、C 都在吃缓存,20% 的账就算不平;只让同步成员填,账才对得上。这是一条"缓存策略跟随副本角色"的设计,本质上是把"谁有资格缓存"这件事和"谁在 Paxos 里说话算数"对齐了。
写路径和读路径的落盘请求,最终都被统一到 log_io_task.h 的 LogIOTask 抽象上,类型由 LogIOTaskType 枚举给出七类:FLUSH_LOG_TYPE(写日志)、FLUSH_META_TYPE(写元数据)、TRUNCATE_PREFIX_TYPE(截断前缀,用于 GC)、TRUNCATE_LOG_TYPE(截断日志,用于闪回)、FLASHBACK_LOG_TYPE(闪回)、PURGE_THROTTLING_TYPE(限流标记)、ASYNC_MARK_TYPE(异步标记)。这七类任务在实现上被拆成两个执行阶段:do_task 在 IO 线程(LogIOWorker)里执行,负责真正的磁盘读写;after_consume 在回调线程池(log_io_task_cb_thread_pool.h 的 LogIOTaskCbThreadPool)里执行,负责推进状态机、触发回调、唤醒等待者。把"做 IO"和"处理 IO 回调"拆到两个线程池,是因为回调逻辑可能很重------它往往要触发事务提交、唤醒等日志的线程------如果放在唯一的 IO 线程里执行,一个慢回调就把后面所有日志的落盘堵死了。这和前面"确认与通知分离"是同一种思路:让快的路径保持快,让慢的动作挪到别处。
Palf 里还有一族专门用来"发现异常"的超时常量,log_define.h 里一口气定义了十几个,每一个都对应一种失败模式。PALF_FETCH_LOG_INTERVAL_US = 2s 是 Follower 主动拉日志的兜底节奏------正常情况 Leader 会主动推,但推丢了、连接断了,就靠这个定时拉;PALF_FETCH_LOG_OUTER_TRIGGER_INTERVAL_US = 500ms 是"有人催"的拉取间隔,比如成员变更前的预检查;PALF_BROADCAST_LEADER_INFO_INTERVAL_US = 5s 是 Leader 广播自己身份的心跳,是 get_current_leader_likely 这类接口的数据来源;PALF_LOG_SYNC_DELAY_THRESHOLD_US = 3s 是"同步延迟超过 3 秒就告警"的监控线,它不改变协议行为,只用来打告警;MATCH_LSN_ADVANCE_DELAY_THRESHOLD_US = 1s 盯的是"某个成员的 match_lsn 超过 1 秒没推进",这往往意味着那个成员掉队了;PALF_LEADER_ACTIVE_SYNC_TIMEOUT_US 和 PALF_LEADER_RECONFIRM_SYNC_TIMEOUT_US 都是 10 秒,对应 Leader 上任后两段必须走完的同步流程;DEFAULT_LOG_LOOP_INTERVAL_US = 100ms 是后台 loop 线程的轮询周期。把这些常量连起来看,会发现 Palf 的"失败可感知"不是靠一套复杂的健康检查,而是靠"给每一个可能卡住的阶段配一个超时,超了就报警",这是运维友好性在常量层面的体现。
12. 反直觉点
第一个反直觉点是 padding 日志并不"免费"。log_define.h 里 PADDING_LOG_CONTENT_CHAR = '\0',看起来它什么都没干,只是往块尾填了几个零。但实际上它要走完整的落盘和多数派复制流程:占一个 log_id、占一个滑动窗口位置、占一段磁盘、要向所有副本推、要收集 ACK、要计入 accumulated_checksum、要跨副本对齐 LSN。也就是说,为推进一个位置而写 padding,成本约等于写一条真实日志。padding 的用途不止"块尾 4KB 对齐"------它更重要的用途是在"没有真实日志的情况下推进位置",比如新 Leader 上任要把 committed_end_lsn_ 从旧位置推进到新位置,中间可能没有业务日志;成员变更需要"一条配置日志落在一个确定的位置";切主要标记新起点。这些场景都需要"写一条什么都不做、但占用一个位置的日志"。用统一的 padding 机制而不是给每个场景写特例逻辑,换来的是"日志流位置严格连续、所有位置都有日志、推进就是追加"这个简洁模型,付出的是"无效位置也要花真实带宽和磁盘"。如果你看到某段时间日志盘写入量明显高于业务量,很可能就是 padding 或配置日志在刷。
第二个反直觉点是元数据也被当作日志存。LogEngine 里 log_meta_storage_ 和 log_storage_ 是同一种 LogStorage,只是初始化的 logical_block_size 参数不同。成员列表、访问模式、快照点这些"元数据"不单独搞一套存储格式,而是复用日志存储------PALF_META_BLOCK_SIZE 和 PALF_BLOCK_SIZE 是同一个值(64MB 减 4KB),元数据走的是和日志一样的块格式。这么做的好处是:元数据的持久化和日志的持久化走同一套代码路径,重启加载、截断、校验、恢复全部共享,代码量直接砍半;更深一层的好处是,元数据的变更天然带上了 proposal_id 和 LSN,于是"改成员列表"这件事自动被纳入 Paxos 的排序,不需要为元数据单独设计一套共识机制。这是一个"用同一套抽象覆盖两种数据"的典型,它让 Palf 的状态机保持单一,代价是元数据的读写也要承受日志存储的全部开销(包括块级对齐、GC 水位判断)。
第三个反直觉点是"无效值用最大值而不是零"。log_define.h:137 定义 LOG_INVALID_LSN_VAL = UINT64_MAX,同级还有 LOG_MAX_LSN_VAL = LOG_INVALID_LSN_VAL - 1 和 PALF_INITIAL_LSN_VAL = 0。为什么无效 LSN 是最大值而不是 0?因为 0 是一个合法的初始 LSN------日志流刚创建时 begin_lsn 就是 0。用最大值当无效哨兵,可以让所有合法 LSN 都小于它,于是"是否有效"只需一次"小于最大值"的比较,不需要额外的标志位或特判分支。INVALID_PROPOSAL_ID = INT64_MAX 是同一个手法。同类还有 FIRST_VALID_LOG_ID = 1,log_id 从 1 开始,0 是无效值。这套"哨兵取极端值"的写法,在持久化系统里是标配,因为它把"边界判断"变成了一次无需解释的数值比较。
第四个反直觉点在副本状态机的读法上。ObReplicaState 里的 RECONFIRM 看起来是一个"副本状态",但单独看它没有意义------你必须和 role 一起读。log_state_mgr.cpp:591 写的是 update_role_and_state_(LEADER, RECONFIRM),log_state_mgr.cpp:608 记录的是 record_role_change_event(palf_id_, FOLLOWER, ObReplicaState::ACTIVE, LEADER, ObReplicaState::RECONFIRM, ...)。也就是说,RECONFIRM 永远和 LEADER 这个角色绑定,它描述的是"这个副本现在是一个正在重确认的新 Leader",而不是一个可以脱离角色独立理解的副本态。Palf 把 role(LEADER/FOLLOWER)和 state(INIT/ACTIVE/RECONFIRM/PENDING)做成两个维度,是因为"我是 Leader"和"我处于哪个阶段"是两个正交的命题:我可以是 Leader 但还在 RECONFIRM(不能服务),也可以是 Leader 且 ACTIVE(正常服务)。读代码时如果把这两层混成一层,就会误以为 RECONFIRM 排除了 Leader 身份,进而看不懂为什么 to_reconfirm_ 里既把 role 设成 LEADER 又要求 state 是 RECONFIRM。
小结
把 Multi-Paxos 的一次完整复制铺开看,它的骨架其实很朴素:Leader 通过 LSNAllocator::alloc_lsn_scn 预分配 (LSN, SCN),把聚合日志落本地 WAL 后经 submit_push_log_req 广播;Follower 做前向校验、落盘、用 submit_push_log_resp 回 ACK;Leader 用 get_majority_lsn_ 把全体成员的 ACK 排序取第 k 大,得到多数派确认点,用 ATOMIC_BCAS 推进 committed_end_lsn_;最后滑动窗口滑出触发 palf_fs_cb_->update_end_lsn,把"这段日志已提交"这个事实通知上游。确认和通知被刻意分成两层,确认是高频低成本的原子操作,通知是可能拖慢的回调,两者互不阻塞。这条链路上每一个数字都有来处:2048 的窗口是因为要给 Leader 和 Follower 之间的相位差留缓冲,1024 的半窗口是因为窗口塞满会导致 Follower 拒收,64MB 的块是因为要把随机写变成顺序写,512MB 的配额是因为 GC 需要至少 8 个块同时在环。
新 Leader 上任的那段"不能服务"的时间,是整套协议里最容易被跳过、却最不能省的一环。选举只保证选出唯一 Leader,不保证日志最全,所以 ObReplicaState::RECONFIRM 这个状态必须存在:先 prepare 学习"多数派里谁的日志最新",只在这一个状态下接收日志以免收到旧 Leader 的老提案,等 accept 多数派也追上后才写 START_WORKING 哨兵日志、一次性把确认点抬过去、转入 ACTIVE。那个"等 accept 多数派追上"的额外条件看似冗余,实则是 2F1A 场景下防止已提交数据丢失的关键闸门。成员变更则用一条同时携带新旧配置的 LogConfigMeta 加 LogConfigVersion 排序,替代了 Raft 显式的联合配置,再用 ConfigChangeLockType 位掩码锁把多类变更串行化------思路和 Raft joint consensus 同源,实现更扁、更工程化。
最后回到 LSN 和 SCN 这对坐标。它们由同一个 128 位 CAS 一起分配,"LSN 更大则 SCN 更大"这个单调关系是日志回放的秩序来源。LSN 是单流内的物理字节偏移,用于跨副本对齐和 GC 水位;SCN 是全局逻辑时间戳,用于 MVCC 可见性和按时间定位。把这两者绑在一次原子操作里,是 Palf 同时服务"事务系统要持久化顺序"和"事务系统要版本号"这两个需求的直接后果,也是它区别于 InnoDB redo log(只有 LSN)、区别于 ZAB(只有 zxid)的地方。读懂了这对坐标和那条从 append 到 update_end_lsn 的时序,再看 palf_handle.h 里那些 add_member/remove_member/force_set_member_list 接口,就只是这套机制之上的常规操作了。