Solana logsSubscribe 通知是一种 JSON-RPC 2.0 通知:它携带 jsonrpc、method 以及包含订阅 id 和 LogsResponse 结果的 params 对象,并且没有 id,因为节点主动推送而非请求响应。LogsResponse 包含 context.slot 以及 value.signature、value.err 和 value.logs;在重组流时,context.slot 是排序键,value.err 在成功时为 null,失败时为解码后的错误对象。由于 value.logs 是扁平字符串数组,要将指令与其日志关联,需要跟踪 Program invoke 和 Program success/failed 行的深度计数器,而非读取固定索引。承诺级别可能导致同一 slot 被多次投递,因此需要精确一次处理的消费者必须按签名去重,并优先采用所见的最强承诺。本指南将协议文档行为、建议以及可复现的测量方法区分开来,供您在自己的端点上运行。
PubSub 通知信封及其为何不是请求/响应
Solana 的 WebSocket PubSub API 遵循 JSON-RPC 2.0 规范,其中通知是一个没有 id 的请求对象,服务器不会对其回复。JSON-RPC 2.0 规范 将此信封定义为 jsonrpc、method 和 params,没有 id 字段。正是这一缺失导致了大多数解析错误:客户端无法将通知关联到 JSON-RPC id,因为没有每个通知的 id 可供匹配。
相反,关联通过订阅 id 进行。当您发送 logsSubscribe 请求时,节点返回的结果就是订阅 id。该订阅的每个后续通知都在 params.subscription 中携带相同的 id。您的客户端必须维护一个从订阅 id 到本地处理程序的映射,并通过该映射路由每个传入帧。这与其他流式方法使用的关联模型相同,在 JSON-RPC 通知 ID 关联与批量排序 中有更深入的介绍。
一个实际后果是,单个 WebSocket 连接可以承载多个订阅,来自不同订阅的帧会任意交错。如果您假设下一帧属于您发送的最后一个请求,那么一旦打开第二个订阅,就会错误路由数据。信封仅通过 method 和 params.subscription 自我描述,因此请将这两个字段视为路由键。
- jsonrpc:始终为字符串 "2.0"。
- method:订阅方法名,例如 "logsNotification"。
- params.subscription:订阅调用返回的数字订阅 id。
- params.result:此事件的 LogsResponse 负载。
- 无 id 字段:节点不期望也不发送通知的响应。
LogsResponse 结构:context、slot、signature、err 和 logs
Solana 关于 logsSubscribe 的文档将通知结果定义为包含两个顶级字段的 LogsResponse:context 和 value。context 包含 slot 编号。value 包含 signature、err 和 logs。这就是整个契约,每个字段对正确解析都至关重要。
context.slot 是重组流的排序键。签名标识交易,但签名不是单调的,也不能告诉您节点观察到事件的顺序。如果您要构建按时间排序的活动视图,请先按 context.slot 排序或分桶,然后在每个 slot 内按签名排序。将签名视为排序键会产生在负载下看起来乱序的流。
value.logs 是扁平的字符串数组。它不是结构化树,不是对象数组,也不是按指令分组。节点按照运行时产生的顺序发出与交易日志消息中相同的文本行。您想要的任何结构,例如哪些日志属于哪条指令,都必须由解析器重建。关于 解码 getTransaction meta:日志与内部指令 的配套指南从 RPC 侧介绍了相同的日志格式,解码逻辑可复用。
- context.slot:节点观察到交易的 slot;用于排序。
- value.signature:交易签名;用于去重。
- value.err:成功时为 null,失败时为解码后的错误对象。
- value.logs:按运行时发出顺序排列的扁平字符串数组。
err 字段:成功时为 null,否则为解码后的失败对象
交易成功时 value.err 为 null。交易失败时,value.err 是一个镜像交易 meta err 的对象。常见形状是 {InstructionError: [index, reason]},其中 index 是从零开始的指令索引,reason 是描述失败的字符串或嵌套对象。由于形状与 getTransaction meta 匹配,您用于 RPC 响应的同一解码器可以在此处无需修改地复用。
这里隐藏着一个微妙且代价高昂的 bug。如果客户端将任何非 null 的 err 视为暂时性网络故障并重试同一交易,它将重新提交一个确定性失败。如果错误是由程序逻辑引起的 InstructionError,重试将以相同方式失败,并可能再次消耗费用。正确的解释是交易已被包含并失败;err 字段是结果,不是传输错误。
区分传输层问题与链上失败。WebSocket 连接断开、超时或 JSON 解析错误是传输问题。格式良好的通知中非 null 的 value.err 是链上结果。您的重试策略应仅适用于前者,而告警应将后者视为业务事件。
- null:交易成功。
- {InstructionError: [index, reason]}:特定指令失败。
- 根据失败类别可能出现其他对象形状;请防御性解码。
- 切勿仅因 value.err 非 null 就重试交易。
使用深度计数器重组扁平日志数组
由于 value.logs 是扁平数组,将日志与指令关联的唯一可靠方法是跟踪嵌套深度。运行时会发出诸如 "Program <ID> invoke [1]"、"Program log: ..."、"Program <ID> success" 和 "Program <ID> failed" 的行。invoke 行上的方括号数字是深度。每次 invoke 递增深度;每次 success 或 failed 递减深度。深度为 N 时发出的日志属于在深度 N 打开的调用。
深度计数器让您可以构建结构化视图:帧栈,每个帧包含程序 id、深度和日志行列表。当您看到 invoke 行时,压入一个帧。当您看到 Program log 行时,将其追加到顶部帧。当您看到 success 或 failed 时,弹出帧并将其附加到父帧。这与内部指令解码使用的算法相同,在 解码 getTransaction meta:日志与内部指令 中有描述。
不要假设固定索引。每条指令的日志行数随计算预算、程序行为以及程序是否发出日志而变化。将 logs[3] 读取为“第一条指令的日志”的解析器会在第一条发出不同行数的交易上出错。深度跟踪是唯一稳定的方法。
- invoke 行打开一个帧,并在方括号中携带深度。
- Program log 行追加到当前顶部帧。
- success 和 failed 行关闭当前帧。
- 计算单元行是信息性的,不改变深度。
承诺级别、重复签名与精确一次处理
logsSubscribe 接受 commitment 参数,Solana 文档指出 confirmed 和 finalized 可能在不同时间投递同一 slot 的通知。finalized 通知可以取代 confirmed 通知。如果您在 confirmed 订阅,稍后在 finalized 订阅,或者在重连后重新订阅,您可能会多次看到同一签名。
需要精确一次处理的消费者必须按签名去重,并优先采用所见的最强承诺。维护一个从签名到观察到的最高承诺级别的映射,仅当承诺提升或签名为新时才发出下游事件。这是建议,不是协议保证:节点不承诺每个签名只投递一次。
承诺注意事项与排序相互作用。slot N 的 confirmed 通知可能在 slot N-1 的 finalized 通知之后到达。如果您的下游消费者假设 slot 单调递增,就会看到明显的回退。先按承诺级别分桶,然后在每个桶内按 context.slot 排序,仅当签名的承诺增强时才提升它。
- confirmed 和 finalized 都可能投递同一 slot。
- 按签名去重;优先采用最强承诺。
- 不要假设跨承诺级别的 context.slot 单调递增。
- 重连后重新订阅可能重放近期事件。
可运行的 Node.js 示例:订阅、解码并跟踪深度
以下示例打开 WebSocket,使用 mentions 过滤器订阅,解码每个通知,打印签名、slot 和 err,并重组日志深度。它使用 ws 包,并假设有一个 Solana JSON-RPC WebSocket 端点。将端点替换为您自己的提供商 URL。无论连接到公共端点还是托管端点(如 OnFinality 的 Solana 网络),该模式都适用。
解析器有意保持最小化。它不按签名去重,也不处理承诺提升;这些留作上一节描述的练习。目标是展示信封契约的工作代码,以便您将其适配到自己的管道。
const WebSocket = require('ws');
const ENDPOINT = process.env.SOLANA_WS_ENDPOINT;
const ws = new WebSocket(ENDPOINT);
let subscriptionId = null;
function parseLogs(logs) {
const stack = [];
const frames = [];
for (const line of logs) {
const invoke = line.match(/^Program (\S+) invoke \[(\d+)\]$/);
const done = line.match(/^Program (\S+) (success|failed)$/);
const log = line.match(/^Program log: (.*)$/);
if (invoke) {
stack.push({ programId: invoke[1], depth: Number(invoke[2]), logs: [] });
} else if (log && stack.length) {
stack[stack.length - 1].logs.push(log[1]);
} else if (done && stack.length) {
const frame = stack.pop();
frame.status = done[2];
frames.push(frame);
}
}
return frames;
}
ws.on('open', () => {
ws.send(JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'logsSubscribe',
params: [{ mentions: ['11111111111111111111111111111111'] }, { commitment: 'confirmed' }]
}));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.id === 1 && msg.result) {
subscriptionId = msg.result;
console.log('subscribed with id', subscriptionId);
return;
}
if (msg.method !== 'logsNotification') return;
if (msg.params.subscription !== subscriptionId) return;
const { context, value } = msg.params.result;
console.log('slot', context.slot, 'sig', value.signature, 'err', value.err);
const frames = parseLogs(value.logs);
for (const f of frames) {
console.log(' program', f.programId, 'depth', f.depth, 'status', f.status);
}
});
ws.on('close', () => console.log('closed'));
ws.on('error', (e) => console.error('error', e.message));结果表:针对您自己的端点测量通知行为
提供商行为各不相同。协议契约有文档记录,但投递时机、重复率和重连重放因提供商而异,应进行测量而非假设。下表是您针对自己的端点运行的方法。用观察到的值填写;不要将任何行视为通用常量。
在固定窗口内运行订阅,例如一小时,并记录计数。在 confirmed 和 finalized 下重复以进行比较。如果您运营多个端点,请对每个端点运行相同脚本并比较。这是描述您自己投递行为的唯一可靠方法。
- 端点 URL:您测试的 WebSocket 端点。
- 承诺级别:confirmed 或 finalized。
- 窗口:运行的开始和结束时间戳。
- 收到的通知总数:logsNotification 帧的计数。
- 唯一签名数:不同 value.signature 值的计数。
- 重复签名数:总数减去唯一数。
- 非 null err 计数:value.err 不为 null 的通知数。
- 最大 slot 间隔:连续 context.slot 值之间的最大差值。
- 重连重放:强制重连后再次看到的签名。
故障排查:空通知、重复和 id 冲突
空通知通常意味着过滤器太窄或承诺级别太严格。mentions 过滤器仅匹配提及给定公钥的交易。如果您按程序 id 过滤,您只会看到提及该程序的交易,而不是该程序处理的每笔交易。扩大过滤器或使用 all 订阅并在客户端过滤。同时确认在开始路由帧之前已捕获订阅 id。
跨承诺级别的重复签名是预期行为,不是 bug。如果您在 confirmed 和 finalized 订阅,或者在重连后重新订阅,同一签名可能出现多次。按签名去重并优先采用最强承诺。如果您在单个承诺级别内看到重复,请检查客户端是否打开了两个订阅并将两者路由到同一处理程序。
非网络故障的非 null err 是链上结果。不要重试交易。解码错误对象,记录指令索引和原因,并将其路由到您的业务逻辑。如果您看到高比例的非 null err 值,请检查程序逻辑而非传输。
重连后的订阅 id 冲突发生在客户端重用过期 id 或未能清除其映射时。重连时,丢弃所有先前的订阅 id,重新订阅,并根据新响应重建映射。连接生命周期细节在 Solana RPC WebSocket 方法与连接生命周期 中介绍。
- 空流:扩大过滤器或降低承诺级别。
- 重复:按签名去重,优先采用最强承诺。
- 非 null err:链上失败,不是传输错误。
- id 冲突:重连时清除订阅映射。
logsSubscribe 契约的局限性与权衡
logsSubscribe 提供日志,而非结构化指令。您得到的是扁平字符串数组,必须自行重建结构。这很灵活,但将解析负担放在客户端,且日志格式的任何变化都可能破坏解析器。该格式在实践中稳定,但不是版本化模式。
通知信封没有 id,因此无法使用 JSON-RPC id 关联。您必须维护自己的订阅映射。这很简单,但在重连或多订阅场景下容易出错。JSON-RPC 通知 ID 关联与批量排序 指南介绍了通用模式。
承诺级别不保证精确一次投递。您必须去重和提升。这为消费者增加了状态,意味着您不能将流视为简单队列。如果需要更强的保证,可能需要与 getTransaction 或单独的索引器进行对账。
最后,WebSocket 投递是尽力而为的。节点可能在负载下丢弃帧,客户端可能在重连期间错过事件。对于关键管道,将 logsSubscribe 视为低延迟信号,并与持久源对账。Solana WebSocket API(RPC Assistant) 页面和 Solana 订阅过滤器机制 指南介绍了相关权衡。
- 扁平日志需要客户端重建结构。
- 没有 id 意味着您必须维护自己的订阅映射。
- 承诺级别需要去重和提升逻辑。
- WebSocket 投递是尽力而为的;关键管道需对账。
后续步骤:从解析到生产管道
一旦能够解码通知,下一步是让消费者具备弹性。添加签名去重、承诺提升以及针对乱序 slot 的有界缓冲区。持久化已处理的最高 slot,以便在重启后恢复而无需重新处理所有内容。
如果大规模运行,考虑使用托管端点以减少运维开销。OnFinality 的 Solana 网络 提供 WebSocket 访问,RPC 定价 页面描述了套餐。对于希望更高层接口的团队,API 服务 和 OnFinality Learn 中心 有相关指南。
最后,针对真实流量测试解析器。使用上面的结果表来描述您的端点,并重新查看 Solana RPC WebSocket 方法与连接生命周期 指南以了解重连和保活模式。正确的信封解析与弹性连接处理的结合,才能使 logsSubscribe 管道达到生产就绪。
- 添加去重、承诺提升和有界缓冲区。
- 持久化已处理的最高 slot 以确保重启安全。
- 考虑使用托管端点以应对规模。
- 针对真实流量测试并填写结果表。