每个 RPC 端点都是一个独立节点,拥有自己对链头的视图,因此当端点 A 和端点 B 的头部不同时,对 A 的 'latest' 读取和对 B 的 'latest' 读取回答的是两个不同的问题。头部滞后是正常的 gossip 和同步行为,不是停机,这就是为什么活性检查通过而数据却已过时。解决方案是一套读取纪律:只解析一次头部,将正确性关键的读取固定到精确的高度或哈希,在自己的管道中要求单调性,并将向后移动的头部视为滞后端点,除非同一高度的哈希发生了变化。本文涵盖区块标签语义、协调流程、可复现的头部滞后测量方法,以及两个端点不一致时的决策表。
为什么同一个查询在不同端点返回不同数据
JSON-RPC 端点不是共享数据库。它是一个节点(或一组负载均衡的节点),拥有自己的对等节点集、自己的同步状态以及自己对链头的本地视图。在任何时刻,端点 A 可能处于高度 N,而端点 B 处于 N-2,因为区块 gossip、同步模式和副本健康状况因节点而异。因此,对 A 的 latest 读取和对 B 的 latest 读取回答的是两个不同的问题,即使请求字节完全相同。
这是两个最常见的多端点错误报告的根本原因:'我的应用显示余额不一致' 和 '索引器看到了 API 没有看到的区块'。两者都不是提供商故障。它们是对移动目标请求值的预期后果。多区域 RPC 故障转移路由 文章指出了链头附近的这一风险;本文教授解决该问题的协调流程。
重要的思维转变是:一致性是你的读取模式的属性,而不是端点的属性。如果你从不固定高度,任何提供商都无法让你的读取一致,因为一致性从未被请求。
- 端点 A 在高度 N,端点 B 在 N-2,两者都健康;它们只是处于同一链的不同位置。
latest读取是请求 '该节点当前认为的链尖',这是节点本地且随时间变化的。- 按编号或按哈希读取是请求一个特定的、不可变的对象,一旦该区块在所有地方都存在,该对象就与端点无关。
区块标签改变的是问题,而不仅仅是新鲜度
以太坊 JSON-RPC 区块参数接受 latest、safe、finalized、earliest、pending 以及显式的区块编号或哈希。这些不是同一答案上的新鲜度旋钮;它们选择具有不同保证的不同对象。ethereum.org JSON-RPC API 文档 定义了参数集,而 执行 API 规范 是方法行为的权威参考。
latest 是节点的本地头部:快速,但在不同端点之间是非确定性的。safe 是合并后引入的超级多数投票头部,通常落后链尖约两个 epoch。finalized 是不可逆头部,落后更多。pending 是节点对尚未包含交易的本地视图,这是所有标签中最不可移植的,因为它取决于该节点的内存池。earliest 是创世区块。
按编号和按哈希读取是确定性的。一旦某个区块存在于你查询的每个端点上,该高度上的 eth_getBlockByNumber 返回相同的区块,该高度上的 eth_getBalance 返回相同的余额。这是唯一保证跨端点一致的读取形态,这就是为什么固定高度是核心纪律。
latest— 本地头部,快速,端点特定,在滞后节点上可能向后移动。safe— 超级多数投票头部,在以太坊合并后文档记载约落后两个 epoch;各网络行为不同。finalized— 不可逆头部,延迟更长,保证最强。pending— 本地内存池视图,跨端点不可复现。- 按编号 / 按哈希 — 一旦区块在所有地方都存在,就是确定性的且与端点无关。
头部滞后是正常行为,不是停机
落后两个区块的节点并没有未通过健康检查。它仍在接受连接,仍在响应 eth_blockNumber,并且对它拥有的每个高度仍返回有效数据。活性和新鲜度是不同的属性,而大多数监控将它们混为一谈。检测 RPC 节点落后于链尖 文章涵盖了单节点链尖滞后检测;多端点情况增加了第二个维度,因为你必须决定信任多个头部中的哪一个。
头部滞后的常见原因包括 gossip 传播延迟、节点在重启后仍在追赶、节点修剪了状态并正在重新获取,以及位于负载均衡器后面的滞后副本。在故障转移池中,请求可能落在其中任何一个上,因此同一客户端的连续两次调用可能命中两个不同的头部。
实际后果:检查 HTTP 200 和非空 eth_blockNumber 的活性探针会在一个实质落后的节点上通过。新鲜度必须单独测量,并且按端点测量。
- 活性:端点响应。新鲜度:端点的头部接近网络头部。
- 负载均衡器后面的滞后副本会静默地混合过时和新鲜的答案。
- 仅根据活性进行路由的故障转移可能会路由到过时节点。
协调纪律:固定、强制单调性、同类比较
三条规则使多端点读取可复现。第一,为正确性关键的读取固定高度:解析一次 latest,捕获区块编号和哈希,然后在该精确高度或哈希上查询每个来源。第二,在你自己的管道中要求单调性:绝不接受向后移动的头部,并将向后移动的头部视为滞后端点,而不是重组,除非同一高度的哈希发生了变化。第三,在比较来源时,从同一个固定的承诺读取同一个实体。
单调性规则值得强调,因为它是你拥有的最便宜的重组检测器。如果端点 A 报告高度 1000,随后报告 998,那几乎总是 A 滞后或 A 被重启,而不是链重组。真正的重组表现为同一高度但哈希不同。区分这两种情况可以防止一大类虚假重组警报。
固定高度还使缓存和幂等性变得可行。以 (method, params, blockHash) 为键的读取可以安全缓存和安全重试,因为底层对象无法改变。以 latest 为键的读取则两者都不行。
- 只解析一次头部,然后将每个正确性关键的读取固定到该高度或哈希。
- 拒绝向后移动的头部;只有同一高度的哈希变化才视为重组。
- 在同一个固定承诺上比较来源,绝不用
latest与latest比较。 - 缓存和重试以固定哈希为键,而不是以标签为键。
读己之写:交易在一个端点可见但在另一个端点不可见
当你将交易广播到端点 A 时,它进入 A 的内存池。端点 B 有不同的内存池,可能在一段时间内看不到该交易,甚至根本看不到。随后对 B 的 eth_getTransactionByHash 可能返回 null,即使该交易完全有效且 A 已经知道。这就是多端点池中的读己之写风险。
正确的模式是在接受该交易的端点上按哈希跟踪交易,或者轮询所有端点直到法定人数看到它,然后再宣布成功。仅凭单个端点的接受就宣布成功是 '交易消失了' 报告的常见来源,因为下一个请求可能被路由到不同的端点。
对于索引器和协调任务,同样的原则适用于区块粒度。逐块 EVM 索引器协调 方法遍历规范高度序列并比较哈希,是检测索引器和 API 之间分歧的持久方法。
- 广播和后续读取应针对同一端点,或使用法定人数。
- 一个端点上的
eth_getTransactionByHash返回 null 并不能证明交易失败。 - 在宣布成功前确保法定人数可见性,可消除读己之写竞态。
可复现地测量每个端点的头部滞后
为了在不猜测的情况下测量头部滞后,定时对多个端点采样 eth_blockNumber(或 Solana 风格端点上的 getSlot),记录时间戳和返回的高度,并计算每个端点相对于每个样本中观察到的最大高度的差值分布。还要记录固定高度上跨端点的区块哈希,以检测分歧,而不仅仅是延迟。
运行采样器足够长的时间以捕获正常变化,并记录环境:网络、端点 URL、采样间隔和总样本数。不要比较来自不同运行或不同网络的数字。下表是一个模板,用你自己的测量值填充;这些值由你产生,而不是已发布的基准。
对于 Solana 风格端点,等效的新鲜度信号是 slot 高度和 slot 哈希;同样的差值分布方法适用。有关端点上下文,请参阅 Solana 网络页面。
// head-lag sampler: run against several endpoints on a timer
// node sampler.js
const ENDPOINTS = [
'https://endpoint-a.example/rpc',
'https://endpoint-b.example/rpc',
'https://endpoint-c.example/rpc'
];
const INTERVAL_MS = 2000;
const SAMPLES = 60;
async function rpc(url, method, params = []) {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
});
const json = await res.json();
if (json.error) throw new Error(json.error.message);
return json.result;
}
async function sampleOnce() {
const t = Date.now();
const heights = await Promise.all(
ENDPOINTS.map(async (url) => {
try {
const hex = await rpc(url, 'eth_blockNumber');
return { url, height: parseInt(hex, 16), ok: true };
} catch (e) {
return { url, height: null, ok: false, error: String(e) };
}
})
);
const max = Math.max(...heights.filter(h => h.ok).map(h => h.height));
return { t, heights, max };
}
(async () => {
const rows = [];
for (let i = 0; i < SAMPLES; i++) {
rows.push(await sampleOnce());
await new Promise(r => setTimeout(r, INTERVAL_MS));
}
for (const ep of ENDPOINTS) {
const deltas = rows
.map(r => r.heights.find(h => h.url === ep))
.filter(h => h && h.ok)
.map(h => r => 0); // placeholder, replaced below
const d = rows
.map(r => {
const h = r.heights.find(x => x.url === ep);
return h && h.ok ? r.max - h.height : null;
})
.filter(x => x !== null);
d.sort((a, b) => a - b);
const p50 = d[Math.floor(d.length * 0.5)] ?? null;
const p95 = d[Math.floor(d.length * 0.95)] ?? null;
console.log(ep, 'samples=', d.length, 'p50=', p50, 'p95=', p95, 'max=', d[d.length - 1]);
}
})();结果表:用你自己的端点测量值填充
使用采样器输出填充如下表。每个端点记录一行,并保持运行参数固定,以便行之间可比。重点不是产生一个通用数字,而是描述你自己的池:哪些端点持续落后,落后多少。
除了高度差值,还要记录固定高度上的哈希一致性。两个端点只有在重组窗口期间才可能报告相同高度但不同哈希;在该窗口之外,同一高度的哈希不匹配表明值得调查的问题。
- 端点 URL — 采样的确切 URL。
- 样本数 — 成功样本的数量。
- p50 差值 — 落后于观察到的最大高度的中位数区块数。
- p95 差值 — 落后区块数的第 95 百分位。
- 最大差值 — 观察到的最差滞后。
- 固定高度哈希一致性 — 跨端点匹配 / 不匹配。
决策表:两个端点不一致,你信任哪一个?
当两个端点对同一逻辑查询返回不同数据时,在做出反应之前先对分歧进行分类。分类决定正确的行动,而大多数分歧是滞后,不是重组。
相同高度且相同哈希意味着端点一致;先前的差异几乎肯定是两次 latest 读取之间的时间伪影。相同高度但不同哈希意味着重组窗口:在采取行动前等待 finalized。不同高度意味着滞后:只有当较高头部是较低头部的有效后代时才信任它,你可以通过遍历父哈希来验证。
- 相同高度,相同哈希 — 一致;在固定高度重新读取以确认。
- 相同高度,不同哈希 — 重组;在提交前等待最终性。
- 不同高度,较高者是后代 — 滞后;较高头部是更新的视图。
- 不同高度,较高者不是后代 — 调查;这不是简单的滞后。
故障排查:症状及修复它们的读取模式
大多数多端点一致性投诉都映射到一小部分读取模式错误。修复通常是改变你请求的内容,而不是你使用的提供商。
如果余额在两个值之间闪烁,你正在跨端点读取 latest;固定一个高度。如果索引器看到了 API 没有看到的区块,API 落后了;在固定高度比较,并将较高头部视为更新。如果交易似乎消失了,你正在与接受它的端点不同的端点上读取它;在接受端点上按哈希跟踪或使用法定人数。如果头部向后移动,将其视为滞后端点,除非同一高度的哈希发生了变化。
- 值闪烁 — 固定高度并重新读取。
- 索引器领先于 API — 预期滞后;在固定高度协调。
- 交易 '消失' — 跨端点的读己之写;按哈希跟踪或使用法定人数。
- 头部向后移动 — 滞后端点,不是重组,除非同一高度哈希变化。
- 故障转移路由到过时节点 — 将新鲜度加入路由信号,而不仅仅是活性。
限制与权衡:新鲜度、延迟和法定人数的成本
固定高度以新鲜度换取确定性。固定读取是可复现和可缓存的,但按定义它不是链尖。对于正确性关键的读取,这是正确的权衡;对于 UI 新鲜度,可能不是。按读取选择,而不是按应用选择。
safe 和 finalized 按设计引入延迟。在以太坊合并后,safe 文档记载约落后头部两个 epoch,finalized 更靠后;各网络行为不同,因此将这些视为文档记载 / 因提供商和网络而异,而不是固定常量。法定人数读取比单次读取花费更多请求,但当你无法固定时,这是使多端点读取可信的唯一方法。
这些都不能消除监控的需要。新鲜度必须持续测量,路由应考虑它。RPC 节点监控与故障转移 文章涵盖了运维方面;多链 RPC 端点指南 涵盖了跨网络的端点选择。
- 固定读取:确定性、可缓存、不是链尖。
safe/finalized:更强的保证、增加的延迟、因网络而异。- 法定人数读取:更高的请求成本,当无法固定时唯一可信的多端点读取。
- 新鲜度必须被监控并输入路由,不能从活性假设。
下一步:将纪律应用到自己的池
首先对你实际的端点运行采样器并填写结果表。这为你提供了池表现出多少滞后的基线,以及哪些端点持续落后。然后改变你的读取模式:为正确性关键的读取固定高度,在管道中强制单调性,并在接受交易的端点上跟踪交易。
如果你正在评估提供商,请比较它们的新鲜度行为和一致性保证,而不仅仅是活性。API 服务 和 RPC 定价 页面描述了服务范围,OnFinality Learn 中心 收集了相关的可靠性文章。有关更广泛的端点选择视图,请参阅 多链 RPC 端点指南。
目标不是消除头部滞后,这是正常的,而是使你的读取在滞后情况下仍可复现。固定、强制单调性、同类比较。
- 在改变任何东西之前先测量你自己的池。
- 为正确性关键的读取固定高度。
- 强制单调性并在做出反应前对分歧进行分类。
- 在接受端点上跟踪交易或使用法定人数。