Solana 的 slotSubscribe 和 blockSubscribe WebSocket 方法以不同的节奏发出不同的通知:slotSubscribe 每产生一个插槽触发一次,携带 slot、parent 和 root 的 SlotNotification;而 blockSubscribe 每个区块触发一次,包含完整区块和 err 字段的 BlockNotification。这两个流并不同步——插槽通知通常先于其区块通知,root 滞后于两者,且某些插槽不产生区块。消费者必须以插槽号为关联键,使用 root 作为最终性水位,并在重连后使用 getBlocks 或 getBlock 对缺口进行对账。本文解释了其机制,提供了一个可运行的 Node.js 示例,并提供了构建可靠 Solana 通知消费者的故障排除手册。
通知信封与订阅生命周期
Solana 的 WebSocket 订阅遵循 JSON-RPC 2.0 规范,其中通知是一个 JSON 对象,包含 jsonrpc 字段、method 字段以及包含 result 和 subscription 标识符的 params 对象。订阅标识符在客户端首次调用订阅方法时返回,必须用于将传入的通知匹配到正确的流。此信封定义在 JSON-RPC 2.0 规范 中,并且在所有 Solana WebSocket 方法中保持一致。
当客户端订阅 slotSubscribe 时,服务器会响应一个订阅 ID。此后,服务器会为集群产生的每个新插槽推送一条通知。类似地,blockSubscribe 返回一个订阅 ID,并为每个匹配订阅参数的区块推送一条通知。生命周期在客户端取消订阅或连接断开时结束。由于通知是服务器发起的,客户端必须异步处理它们,并维护状态以关联插槽和区块。
订阅 ID 在每个连接和每个方法中都是唯一的。如果客户端打开多个订阅,它必须按 ID 路由通知。一个常见的错误是假设通知按订阅顺序到达;JSON-RPC 2.0 规范不保证不同订阅之间的顺序,并且 Solana 的流是独立的。
- 通知信封:
{ jsonrpc: '2.0', method: 'slotNotification', params: { result: {...}, subscription: <id> } } - 订阅 ID 由初始的
slotSubscribe或blockSubscribe调用返回。 - 通知是异步推送的;客户端不得阻塞等待它们。
slotSubscribe 发出什么:SlotNotification 负载与节奏
slotSubscribe 方法为集群产生的每个插槽发出一个 SlotNotification。根据 Solana slotSubscribe 文档,通知结果包含三个字段:slot(新产生的插槽)、parent(父插槽)和 root(当前根插槽)。没有交易数据,没有区块内容,也没有关于该插槽是否包含区块的指示。它纯粹是一个活跃性和排序信号。
节奏大约是每个插槽一条通知,但确切时间取决于集群的插槽产生速率。Solana 的目标插槽时间约为 400 毫秒,但这可能会变化。由于 slotSubscribe 不包含区块数据,将插槽通知视为“区块现在可读”的消费者通常会尝试获取一个尚未可用或永远不会产生的区块(如果该插槽被跳过)。
root 字段是集群认为已扎根的最高插槽,意味着它已被绝大多数质押确认。这是安全修剪或最终化状态的正确水位。消费者不得将通知自身的 slot 视为已扎根;它仅仅是观察到的最新插槽。
- 负载:
{ slot: number, parent: number, root: number } - 节奏:每个产生的插槽一条通知(大约每 400 毫秒)。
- 不包含交易或区块数据。
root是最终性水位,而不是通知的slot。
blockSubscribe 发出什么:BlockNotification 与 err 字段
blockSubscribe 方法为每个匹配订阅条件的区块发出一个 BlockNotification。Solana blockSubscribe 文档 将通知结果描述为包含 slot、block 和 err。block 字段携带完整的区块内容,包括交易,而 err 要么是 null(表示成功区块),要么是错误对象(如果区块被跳过或失败)。这允许消费者在需要交易数据时避免单独的 getBlock 往返。
由于 blockSubscribe 仅对实际产生的区块触发,它可以合法地跳过未产生区块的插槽。一个插槽通知可能到达,但该插槽永远不会产生区块通知。这是一个关键区别:slotSubscribe 报告每个插槽,而 blockSubscribe 仅报告区块。err 字段是区块未成功产生的信号,但其确切语义取决于订阅参数和集群行为。
blockSubscribe 方法需要诸如 filter 和 commitment 之类的参数。filter 可以是像 all 这样的字符串,也可以是带有 mentionsAccountOrProgram 的对象。commitment 级别决定区块何时被视为可用。更高的承诺级别(例如 finalized)可能会延迟通知,但提供更强的保证。通知的 err 字段不能替代检查区块状态;它指示区块是否被跳过或失败。
- 负载:
{ slot: number, block: object | null, err: object | null } - 节奏:每个产生的区块一条通知(不是每个插槽)。
- 跳过未产生区块的插槽。
err表示被跳过或失败的区块;null表示成功。
为什么两个流不同步
slotSubscribe 和 blockSubscribe 流是独立的,并且不同步。插槽通知通常先于其区块通知,因为插槽在区块完全组装和传播之前就已产生。然而,插槽与其区块通知之间的顺序不保证相邻;其他插槽通知可能在其间到达。因此,两个流之间的任何关联都必须以插槽号为键,而不是以到达顺序为键。
Root 滞后于两个流。插槽通知中的 root 字段是最高已扎根插槽,它落后于当前插槽。这意味着给定插槽的区块通知可能在该插槽扎根之前到达。需要最终性的消费者必须等待 root 推进超过感兴趣的插槽,或使用反映其风险承受能力的承诺级别。
由于流不同步,消费者不能假设收到插槽通知就意味着会跟随一个区块通知。有些插槽完全被跳过,有些区块可能因网络条件而延迟或从未送达。唯一可靠的关联方式是跟踪插槽号,并在需要时使用 getBlocks 或 getBlock 进行对账。
- 插槽通知通常先于区块通知,但不是相邻的。
- Root 滞后于两个流;区块可能在其插槽扎根之前就被通知。
- 按插槽号关联,而不是按到达顺序。
- 并非每个插槽都会产生区块通知。
使用流中的 root 作为最终性水位
SlotNotification 中的 root 字段是集群认为已扎根的最高插槽。这是安全修剪或最终化状态的正确水位。消费者应跟踪观察到的最大 root,并使用它来确定哪些插槽已最终化。例如,如果当前 root 是 1000,那么直到 1000 的插槽都被视为最终,可以安全处理或修剪。
一个常见的错误是将通知自身的 slot 视为已扎根。slot 字段是新产生的插槽,尚未扎根。将其用作最终性水位会导致处理未最终化的数据。相反,始终使用 root 字段。如果 root 停滞(长时间不推进),可能表明网络问题或 RPC 提供商的问题。
当与 blockSubscribe 结合时,消费者可以使用插槽流中的 root 来决定区块何时最终化。例如,在收到插槽 N 的区块通知后,等待 root 推进到至少 N,然后再认为该区块最终。这避免了对可能回滚的区块采取行动。
root是最高已扎根插槽;将其用作最终性水位。- 不要将通知的
slot视为已扎根。 - Root 停滞可能表明网络或提供商问题。
- 将 root 与区块通知结合以确定最终性。
缺口问题:重连与跳过的插槽
当 WebSocket 连接断开时,slotSubscribe 和 blockSubscribe 流都会丢失断开期间观察到的插槽和区块。由于某些插槽完全被跳过,消费者不能仅从插槽号跳跃推断出遗漏的区块。例如,如果最后观察到的插槽是 100,下一个是 105,那么插槽 101-104 可能已产生但未被观察到,或者其中一些可能被跳过。没有额外数据,消费者无法知道是哪种情况。
恢复必须与流一起对照 getBlocks 或 getBlock 进行对账。重连后,消费者应查询 getBlocks 以获取最后观察到的插槽与当前插槽之间的范围,以确定哪些插槽实际产生了区块。这在 使用 getBlocks 检测跳过的插槽和索引器缺口 一文中有详细说明。同样的原则适用于 blockSubscribe:重连后,使用 getBlock 获取由 getBlocks 标识的插槽的缺失区块。
一个健壮的消费者还应处理重连期间的重复插槽观察。如果客户端重连并重新订阅,它可能会收到已处理插槽的通知。消费者必须按插槽号去重并保持一致状态。这是基于 WebSocket 的系统中的常见挑战,RPC WebSocket 重连而不丢失数据 一文提供了处理模式。
- 重连会丢失断开期间的通知。
- 插槽号跳跃并不意味着遗漏区块;有些插槽被跳过。
- 重连后使用
getBlocks对账缺口。 - 对插槽观察去重以避免重复处理。
可运行的 Node.js 消费者:插槽跟踪、根推进与缺口检测
以下 Node.js 示例连接到 Solana WebSocket 端点,订阅 slotSubscribe,记录根推进,统计观察到的插槽与获取的区块,并在强制重连后报告第一个缺口。它使用 ws 包进行 WebSocket 通信,并使用 @solana/web3.js 库进行 HTTP 回退。将端点替换为您自己提供商的 WebSocket URL。
该示例维护一个观察到的插槽集合和一个最后 root 变量。在每次插槽通知时,它更新 root 并通过将新插槽与前一个插槽进行比较来检查缺口。如果检测到缺口,它会查询 getBlocks 以获取缺失范围。在强制重连后,它重复缺口检测。这演示了弹性消费者的核心机制。
const WebSocket = require('ws');
const { Connection, clusterApiUrl } = require('@solana/web3.js');
const WS_ENDPOINT = 'wss://api.mainnet-beta.solana.com'; // Replace with your provider
const HTTP_ENDPOINT = clusterApiUrl('mainnet-beta');
const connection = new Connection(HTTP_ENDPOINT, 'confirmed');
let ws;
let subscriptionId = null;
let lastSlot = null;
let lastRoot = null;
let observedSlots = new Set();
let reconnectCount = 0;
function connect() {
ws = new WebSocket(WS_ENDPOINT);
ws.on('open', () => {
console.log('WebSocket connected');
ws.send(JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'slotSubscribe',
params: []
}));
});
ws.on('message', async (data) => {
const msg = JSON.parse(data);
if (msg.method === 'slotNotification') {
const { slot, parent, root } = msg.params.result;
const subId = msg.params.subscription;
if (subscriptionId === null) subscriptionId = subId;
console.log(`Slot: ${slot}, Parent: ${parent}, Root: ${root}`);
observedSlots.add(slot);
if (lastRoot === null || root > lastRoot) {
lastRoot = root;
console.log(`Root advanced to ${root}`);
}
if (lastSlot !== null && slot > lastSlot + 1) {
const gapStart = lastSlot + 1;
const gapEnd = slot - 1;
console.log(`Gap detected: slots ${gapStart} to ${gapEnd}`);
try {
const blocks = await connection.getBlocks(gapStart, gapEnd);
console.log(`Blocks in gap: ${blocks.length}`);
} catch (err) {
console.error('Error fetching blocks:', err);
}
}
lastSlot = slot;
}
});
ws.on('close', () => {
console.log('WebSocket closed. Reconnecting...');
reconnectCount++;
setTimeout(connect, 1000);
});
ws.on('error', (err) => {
console.error('WebSocket error:', err);
});
}
connect();
// Force a reconnect after 30 seconds for testing
setTimeout(() => {
if (ws) ws.close();
}, 30000);结果表:流 vs 负载 vs 节奏 vs 它不告诉你什么
下表总结了 slotSubscribe 和 blockSubscribe 之间的关键区别。读者应根据自己的端点验证这些特征,因为提供商特定的行为可能有所不同。例如,一些提供商可能会缓冲或批量处理通知,从而影响节奏。该表是构建消费者的指南,而不是测量的替代品。
要验证,请运行上面的 Node.js 示例并记录通知。将观察到的节奏与预期的插槽时间进行比较。检查区块通知是每个插槽都到达还是仅对产生的区块到达。测量插槽通知与其对应区块通知之间的延迟。记录根推进速率。这些测量将帮助您调整消费者的超时和对账逻辑。
- slotSubscribe:负载
{slot, parent, root},每个插槽的节奏,不告诉你区块是否存在。 - blockSubscribe:负载
{slot, block, err},每个区块的节奏,不告诉你跳过的插槽。 - Root 滞后于两个流;将其用作最终性水位。
- 提供商特定行为:一些提供商可能会延迟或批量处理通知;请用自己的端点验证。
故障排除:区块通知但无预期区块、Root 停滞、重复插槽、承诺选择
如果您收到区块通知但区块无法通过 getBlock 获取,可能是因为区块被跳过或失败。检查通知中的 err 字段。如果 err 非空,则区块未成功产生。如果 err 为 null 但 getBlock 返回 null,则区块可能在请求的承诺级别下尚不可用。尝试较低的承诺级别或等待 root 推进。
Root 停滞——即 root 字段长时间不推进——可能表明网络分区或 RPC 提供商的问题。监控根推进速率,并在超过阈值时设置警报。如果 root 停滞,考虑切换到其他提供商或端点。OnFinality 的 Solana 网络页面 提供了有关支持的端点和承诺级别的信息。
重连期间的重复插槽观察很常见。当客户端重连并重新订阅时,它可能会收到已处理插槽的通知。通过维护已处理插槽的集合并忽略重复项来进行去重。如果您需要每个插槽恰好处理一次,请使用持久存储来跟踪跨重启的已处理插槽。
承诺选择影响通知的传递时间。对于 blockSubscribe,commitment 参数决定区块被通知前所需的最终性级别。更高的承诺级别(例如 finalized)提供更强的保证,但可能增加延迟。选择与您的应用程序风险承受能力相匹配的承诺级别。Solana 承诺级别与交易确认 一文详细解释了这些权衡。
- 区块通知但无区块:检查
err字段和承诺级别。 - Root 停滞:监控并考虑切换提供商。
- 重复插槽:按插槽号去重。
- 承诺选择:平衡最终性和延迟。
WebSocket 订阅的局限性与权衡
WebSocket 订阅并非在所有情况下都能替代 HTTP 轮询。它们提供更低的延迟和基于推送的更新,但它们是有状态的,需要仔细处理重连和缺口。如果连接断开,通知会丢失,消费者必须进行对账。与轮询相比,这增加了复杂性,因为轮询中客户端控制请求节奏,并且可以轻松地从最后处理的插槽恢复。
另一个限制是 slotSubscribe 不包含区块数据,而 blockSubscribe 不包含跳过的插槽。为了获得完整的画面,消费者通常需要两个流加上 HTTP 回退。这增加了资源使用和复杂性。此外,提供商特定的行为可能有所不同:一些提供商可能会限制订阅数量、限制通知速率或具有不同的超时策略。始终检查您的提供商的文档。
最后,root 字段是集群级别的水位,但它不保证特定区块已最终化。在极少数情况下,区块可能已扎根但后来回滚。对于大多数应用程序,已扎根就足够了,但对于高价值交易,可能需要额外的确认。Solana 文档 提供了关于通知语义的权威细节。
- WebSocket 订阅是有状态的;重连需要对账。
- 没有单个流提供完整数据;结合插槽、区块和 HTTP。
- 提供商特定的限制和节流可能适用。
- 在所有边缘情况下,已扎根并不保证绝对最终性。
后续步骤:构建生产就绪的消费者
要构建生产就绪的消费者,首先实现 Node.js 示例并测量所选端点上的通知节奏和根推进。使用结果设置超时和对账间隔。然后,为已处理插槽添加持久存储以处理重启和去重。集成 getBlocks 和 getBlock 进行缺口恢复,如 getBlocks 缺口检测文章 中所述。
考虑使用提供可靠 WebSocket 端点和清晰文档的提供商。OnFinality 的 Solana WebSocket API 为理解可用方法提供了一个起点。有关定价和服务详情,请参阅 RPC 定价 和 API 服务。OnFinality Learn 中心 包含更多关于 Solana 可靠性和一致性的文章。
最后,在不利条件下测试您的消费者:强制重连、模拟网络延迟,并验证缺口恢复是否有效。监控根推进并在停滞时发出警报。通过这些实践,您可以构建一个弹性的 Solana 通知消费者,处理 slotSubscribe 和 blockSubscribe 的不同步特性。
- 在您的端点上测量节奏和根推进。
- 实现持久去重和缺口恢复。
- 选择可靠的提供商并了解其限制。
- 在重连和网络延迟下进行测试。