Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
基础设施与运维阅读约 14 分钟

Base OP-Stack 重组深度:索引器回滚与 L1 起源窗口

一份面向派生感知的实战手册,帮助你在不安全头替换、L1 起源窗口和回滚证明中保持 Base 索引器的正确性。

TL;DR

Base 及其他 OP-Stack 链暴露三个区块阶段:不安全(由排序器发布,可替换)、安全(从 L1 批次数据派生,若 L1 重组仍可重组)和已最终确认(L1 已最终确认,视为不可逆)。与以太坊不同,不安全头可能被新的 L1 批次派生出完全不同的 L2 区块整体替换,因此仅靠父哈希深度计数会低估风险敞口。正确的回滚边界是其 L1 起源在 L1 上仍属规范链的最高 L2 区块,这可能远低于 L2 高度。本文给出一种派生感知的索引器设计,包含不安全窗口、L1 起源水位线、撤销日志、幂等 upsert 以及回滚后断言检查。同时提供用于测量自有端点的结果表,以及针对提供商标签缺失和进行中回填的故障排查章节。

OP-Stack 三阶段模型及各阶段保证

OP Stack 规范描述了 L2 区块如何从 L1 批次数据派生,Optimism 文档描述了从不安全到安全再到已最终确认的演进过程。不安全头由排序器生成,尚未从 L1 派生,因此可被替换。安全区块从 L1 批次数据派生,但如果 L1 自身发生重组,它们仍可重组。已最终确认区块是 L1 已最终确认的,被视为不可逆。

此处仅引用机制来源。safefinalized 区块标签的确切行为因提供商而异,因此必须针对你自己的端点进行测量。以太坊 JSON-RPC 规范定义了带 safefinalized 标签的 eth_getBlockByNumber 以及 eth_blockNumber,这是索引器用于检测派生进度的读取路径。

对索引器而言,实际契约是:仅在有限窗口内写入不安全数据,将安全视为更强但非绝对的保证,并将已最终确认视为唯一适合不可逆副作用的阶段。如果你需要回顾这些标签的读取方式,请参阅 Base OP-Stack 最终性:安全与已最终确认区块标签

两个阶段的定义均来自一手来源而非本文:OP Stack 派生规范 定义了 L2 区块如何从 L1 批次数据派生,Optimism 交易最终性文档 定义了不安全、安全和已最终确认的演进过程。请结合阅读,因为规范解释了机制,而最终性页面解释了你实际查询的标签。

  • 不安全:由排序器发布,尚未从 L1 派生,可替换。
  • 安全:从 L1 批次数据派生,若 L1 重组仍可重组。
  • 已最终确认:L1 已最终确认,视为不可逆。
  • 标签行为因提供商而异;依赖前请先测量。

为什么 OP-Stack 重组与以太坊重组不同

在以太坊上,重组是分叉选择事件:一条竞争区块链在某个深度替换规范链。在 OP-Stack 链上,不安全头由排序器生成,因此排序器重启、无效区块或排序器停滞都可能导致派生管道替换一串不安全区块,而不是扩展它们。关于 Base 排序器停滞的公开报道说明了排序器故障如何产生重组而非缺口。

这意味着常规的父哈希深度计数会低估风险敞口。仅比较高度 N 处父哈希的检测器会看到不匹配,但无法判断替换是浅分叉还是派生驱动的更长串整体替换。OP Stack 规范是权威依据,解释了为什么不安全头尚未从 L1 派生,以及排序器故障如何产生重组而非缺口。

实际后果是,你的回滚边界不应仅由 L2 深度推导,而应由每个 L2 区块的 L1 起源推导,这是下一节的主题。

  • 以太坊重组是分叉选择事件;OP-Stack 重组可能是派生驱动的替换。
  • 排序器重启、无效区块或停滞可能替换一串不安全区块。
  • 仅靠父哈希深度计数会低估风险敞口。
  • OP Stack 规范是派生语义的权威依据。

L1 起源窗口与规范 L1 水位线

每个 L2 区块都关联一个 L1 起源区块。正确的回滚边界是其 L1 起源在 L1 上仍属规范链的最高 L2 区块。该边界可能远低于 L2 高度,尤其当不安全头的推进速度快于 L1 派生时。

要维护规范 L1 水位线,需读取每个 L2 区块的 L1 起源,并与规范 L1 链比较。如果 L1 起源不再规范,则从它派生的每个 L2 区块都必须视为可疑。这是分叉点的派生感知等价物。

读取路径使用 L2 端点上的 eth_getBlockByNumber 获取区块及其 L1 起源元数据,并在 L1 端点上确认规范性。以太坊 JSON-RPC 规范是这些方法的权威依据。关于可适配的通用检测器,请参阅 以太坊区块重组检测与确认深度

  • 回滚边界 = 其 L1 起源在 L1 上仍属规范链的最高 L2 区块。
  • 该边界可能远低于 L2 高度。
  • 通过将 L1 起源与规范 L1 比较来维护规范 L1 水位线。
  • 在 L2 和 L1 上均使用 eth_getBlockByNumber 进行确认。

选择写入策略:带撤销的不安全写入、仅安全写入或影子表

有三种实用写入策略。带撤销日志的不安全写入会立即写入不安全区块,但记录撤销日志,以便回滚时删除或取代它们。仅安全写入等待安全标签,以延迟换取更低风险。带影子表的不安全写入将不安全数据写入影子表,仅在变为安全后才提升到主表。

延迟与风险的权衡很直接:带撤销的不安全写入延迟最低、回滚复杂度最高;仅安全写入延迟最高、正确性最简单;影子表介于两者之间。深度受限的不安全窗口是推荐的折中方案:仅在不安全头之后固定数量的区块内写入不安全数据,更早的数据一律按仅安全处理。

窗口大小应通过测量确定,而非假设。使用本文后面的结果表记录针对你自己端点观测到的不安全深度和最深深回滚。

  • 带撤销的不安全写入:延迟最低,回滚复杂度最高。
  • 仅安全写入:延迟最高,正确性最简单。
  • 影子表:中间方案,带提升步骤。
  • 推荐:深度受限的不安全窗口,针对你的端点测量。

回滚流程:检测、确认、分叉点、删除、重新应用

检测始于你已写入的某个高度出现父哈希或哈希不匹配。通过读取该高度当前区块来确认新的规范头。通过向前回溯直到存储哈希与规范哈希匹配来找到分叉点。删除或取代分叉点之上的所有行,然后向前重新应用。

将重新应用实现为以规范区块身份(例如区块哈希加日志索引)为键的幂等 upsert。这确保重新应用不会重复计数。JSON-RPC 2.0 规范是请求区块不再规范时返回的错误对象封装的权威依据,这在检测期间是有用信号。

关于更广泛的对账模式,请参阅 逐区块 EVM 索引器对账

  • 在已写入高度通过父哈希或哈希不匹配进行检测。
  • 确认新的规范头。
  • 通过回溯到匹配哈希找到分叉点。
  • 删除或取代分叉点之上的行。
  • 以规范身份为键的幂等 upsert 向前重新应用。

证明回滚有效:对账与回滚计数器

只有能够证明,回滚才是可观测的。添加一个对账查询,断言每个已索引行的区块哈希仍属规范链。添加每个分叉事件的已回滚行计数器,使回滚可观测而非推断。

断言检查应在每次回滚后以及按计划运行。如果断言失败,说明存在静默损坏,而非干净回滚。这就是回滚与静默损坏的区别,也是规范页面未提供的运维契约。

按分叉事件保留计数器,以便将回滚规模与 L1 起源窗口及提供商标签行为关联起来。

  • 断言每个已索引行的区块哈希仍属规范链。
  • 统计每个分叉事件的已回滚行数。
  • 每次回滚后以及按计划运行断言。
  • 断言失败意味着静默损坏,而非干净回滚。

可运行的 Node.js 索引器:不安全窗口、L1 水位线与撤销日志

以下示例使用最小的内存存储来展示逻辑形态。请将存储替换为你的数据库,将 RPC 调用替换为你的提供商。它读取不安全头,维护 L1 起源水位线,通过哈希不匹配检测分叉,并以幂等 upsert 向前重新应用。

代码刻意保持精简以便适配。它不包含重试逻辑或速率限制;生产环境请添加这些。关于端点设置,请参阅 Base RPC URL、链 ID 与端点设置(RPC Assistant)

const { JsonRpcProvider } = require('ethers');

const L2_URL = process.env.L2_RPC_URL;
const L1_URL = process.env.L1_RPC_URL;
const UNSAFE_WINDOW = 64;

const l2 = new JsonRpcProvider(L2_URL);
const l1 = new JsonRpcProvider(L1_URL);

// In-memory store: height -> { hash, l1Origin, rows: [] }
const store = new Map();
const undoLog = [];
let rolledBackRows = 0;

async function getL2Block(n) {
  const b = await l2.send('eth_getBlockByNumber', ['0x' + n.toString(16), false]);
  if (!b) return null;
  // L1 origin is provider-specific; read from block metadata if available.
  const l1Origin = b.l1BlockNumber ? parseInt(b.l1BlockNumber, 16) : null;
  return { number: n, hash: b.hash, parentHash: b.parentHash, l1Origin };
}

async function isL1Canonical(l1Number, l1Hash) {
  if (l1Number == null) return true;
  const b = await l1.send('eth_getBlockByNumber', ['0x' + l1Number.toString(16), false]);
  return b && b.hash === l1Hash;
}

async function upsertRows(block) {
  // Idempotent upsert keyed on block hash + row index.
  const rows = store.get(block.number)?.rows || [];
  store.set(block.number, { hash: block.hash, l1Origin: block.l1Origin, rows });
}

async function detectAndRollback(headNumber) {
  for (let n = headNumber; n >= 0; n--) {
    const stored = store.get(n);
    if (!stored) continue;
    const canonical = await getL2Block(n);
    if (!canonical || canonical.hash !== stored.hash) {
      // Fork point found at n+1; roll back everything above n.
      for (let m = headNumber; m > n; m--) {
        const s = store.get(m);
        if (s) {
          rolledBackRows += s.rows.length;
          undoLog.push({ height: m, hash: s.hash, rows: s.rows });
          store.delete(m);
        }
      }
      return n + 1;
    }
  }
  return 0;
}

async function tick() {
  const head = await l2.getBlockNumber();
  const start = Math.max(0, head - UNSAFE_WINDOW);
  for (let n = start; n <= head; n++) {
    const b = await getL2Block(n);
    if (!b) continue;
    if (b.l1Origin != null) {
      const ok = await isL1Canonical(b.l1Origin, null);
      if (!ok) continue; // skip until L1 origin is canonical
    }
    await upsertRows(b);
  }
  const forkPoint = await detectAndRollback(head);
  if (forkPoint > 0) {
    console.log('Rolled back to', forkPoint, 'rows removed:', rolledBackRows);
  }
}

setInterval(tick, 5000);

回滚后断言检查与规范性校验

回滚后,运行断言检查,读取每个已存储高度并确认存储哈希仍与规范哈希匹配。这是回滚有效的证明。以下示例是可定期运行的紧凑断言检查。

如果断言失败,不要盲目重新应用。调查提供商是否返回了过期区块,或你的 L1 起源水位线是否有误。关于相关读取路径,请参阅 通过 Engine API 查看 Base 节点同步状态

async function assertCanonical() {
  let checked = 0;
  let mismatches = 0;
  for (const [height, stored] of store.entries()) {
    const canonical = await getL2Block(height);
    checked++;
    if (!canonical || canonical.hash !== stored.hash) {
      mismatches++;
      console.error('Mismatch at', height, 'stored', stored.hash, 'canonical', canonical && canonical.hash);
    }
  }
  console.log('Assertion pass: checked', checked, 'mismatches', mismatches);
  return mismatches === 0;
}

// Run after every rollback and on a schedule.
setInterval(assertCanonical, 60000);

用于测量自有端点的结果表

使用下表记录针对你自己端点的测量结果。不要依赖已发布的数字;要测量。在固定窗口内运行索引器并填写各列。

这些列记录观测到的不安全深度、每窗口分叉事件数、最深回滚和 L1 起源滞后。这四个数字决定了你的不安全窗口大小和回滚策略。

  • 观测到的不安全深度:不安全头减去安全头,在窗口内采样。
  • 每窗口分叉事件数:检测到的哈希不匹配计数。
  • 最深回滚:单次事件中回滚的最大区块数。
  • L1 起源滞后:L2 头减去其 L1 起源仍属规范链的最高 L2 区块。

故障模式与排查

提供商的 safe/finalized 标签未填充是常见故障模式;op-reth 的 issue“op-reth is not informing safe and finalized blocks”是一个公开示例。如果标签缺失,回退到 L1 起源追踪并自行测量差距。

回滚发生在回填进行中时,如果回填写入了分叉点之上的行,可能导致重复计数。在回滚期间暂停回填,或使回填幂等。遗漏派生驱动替换的通用父哈希检测器会低报;添加 L1 起源检查。深度超过保留窗口的重组需要从最后一个已最终确认区块进行完整重新同步。

对于提供商特定行为,将其视为已记录/因提供商而异,并针对你的端点验证。关于 Base 网络背景,请参阅 Base 网络

  • safe/finalized 标签缺失:回退到 L1 起源追踪。
  • 回滚期间进行中的回填:暂停或使其幂等。
  • 通用父哈希检测器:添加 L1 起源检查。
  • 深度超过保留窗口:从最后一个已最终确认区块重新同步。

局限性、权衡以及将最终性与摄取分离

L1 起源检查会增加每个区块的额外 L1 读取成本。仅安全写入会增加延迟惩罚。两者都是真实成本,没有一个是免费的。权衡是正确性与延迟和成本之间的取舍。

最终性应与摄取分离为独立的写入路径。摄取使用撤销日志写入不安全和安全数据;最终性将行提升为不可逆状态。将两者混合会使回滚逻辑更难推理。

关于 L1 读取的成本背景,请参阅 OP-Stack L1 费用计算与交易成本

  • L1 起源检查增加 L1 读取成本。
  • 仅安全写入增加延迟。
  • 将最终性作为与摄取分离的写入路径。
  • 混合摄取与最终性会使回滚推理复杂化。

后续步骤与相关阅读

首先使用结果表测量你的端点,然后选择写入策略并实现撤销日志和断言检查。如果你是 Base 端点新手,请从 Base RPC URL、链 ID 与端点设置(RPC Assistant) 开始。

关于相邻主题,请参阅 Base 存款、提款证明与存款事件OnFinality Learn 中心。关于基础设施选项,请参阅 API 服务RPC 定价

  • 使用结果表测量你的端点。
  • 选择写入策略并实现撤销日志加断言检查。
  • 查阅相邻的最终性、重组和对账页面。
  • 考虑生产规模的基础设施和定价。

永远不用担心基础设施

OnFinality 消除了 DevOps 的繁重工作,让您能够更聪明、更快地构建。

开始