Solana 订阅过滤器在服务端针对原始账户字节或日志文本进行评估,一个语法有效的过滤器仍可能匹配不到任何内容,且不会产生任何错误。只有 programSubscribe 接受由 memcmp 和 datasize 条目组成的复合过滤器数组;accountSubscribe 接受单个账户公钥,logsSubscribe 接受字符串 all 或 mentions 数组,因此把 getProgramAccounts 的过滤器数组复制到订阅上会被拒绝或错误应用。memcmp 在指定偏移量处将原始字节与 base58 或字节值进行比较,这意味着偏移量必须已经考虑到程序在该字段之前写入的任何判别符或头部,而 Anchor 判别符差 8 字节是典型的静默漏匹配。datasize 固定账户数据长度,当程序更改布局时就会失效,而且由于过滤器数组是逻辑与关系,一个过时的条目就能让整个订阅归零。唯一可靠的验证方法是将过滤订阅与未过滤订阅或使用相同过滤器的 HTTP getProgramAccounts 读取进行对账。
Solana WebSocket 各方法的订阅过滤器术语
Solana 提供三种面向账户和日志的 WebSocket 订阅,每种接受不同的过滤器形态。Solana programSubscribe 文档定义了一个包含 memcmp 和 datasize 条目的过滤器对象,而 logsSubscribe 文档定义了字面字符串 all 或包含 mentions 数组的对象。accountSubscribe 是个例外:它接受一个裸账户公钥,不接受任何过滤器,因此没有什么可配置错误的,也没有什么可缩窄的。
这种不对称是静默失败的第一个来源。已经使用 HTTP getProgramAccounts 过滤器 API 的开发者自然会假设同一个数组在任何地方都适用,但订阅方法并不共享这套术语。如果你还在梳理连接层,Solana RPC WebSocket 方法与连接生命周期指南介绍了订阅如何打开、确认和关闭,而 Solana WebSocket API(RPC Assistant)页面是在你确定过滤器形态之前检查实时方法签名的最快方式。
实用规则是选择与你实际需要的粒度相匹配的方法。如果你想要某个程序拥有的每个账户,programSubscribe 是唯一带复合过滤器的方法。如果你想要单个账户,accountSubscribe 是正确的,过滤是不必要的。如果你想观察程序活动而非账户状态,带 mentions 的 logsSubscribe 是合适的工具,解析 logsSubscribe 通知负载指南介绍了通知正文中会到达的内容。
- programSubscribe:包含 memcmp 和 datasize 条目的过滤器对象,针对账户数据字节进行评估。
- logsSubscribe:字符串 all,或 { mentions: [pubkey] },针对日志文本进行评估。
- accountSubscribe:单个账户公钥,没有过滤器字段,无法缩窄。
- 将 getProgramAccounts 过滤器数组复制到 accountSubscribe 或 logsSubscribe 上,会因提供商不同而被拒绝或忽略。
memcmp 语义:字节偏移量、编码与 Anchor 判别符陷阱
memcmp 过滤器指定账户数据中的字节偏移量和一个以 base58 或字节数组编码的值,只有当该偏移量处的原始字节等于所提供的值时才会匹配。比较是字节精确且区分大小写的;没有规范化,没有对齐,也不感知你程序的字段布局。偏移量是账户数据缓冲区中的绝对位置,而不是字段索引。
这条绝对偏移量规则正是大多数静默漏匹配的根源。基于 Anchor 的程序会在账户主体之前写入一个 8 字节判别符,因此 Rust 结构体中排在第一位的字段在序列化数据中实际上从偏移量 8 开始。针对该字段在偏移量 0 处编写的过滤器将改为比较判别符字节,从而永远匹配不到任何内容,且不会报错。同类错误也会出现在程序在你关心的字段之前写入的任何手写头部、版本字节或长度前缀上。
编码是第二个陷阱。以 32 个原始字节存储的公钥字段,如果你在期望字节的地方传入字符串,则不会匹配解码后为同一公钥的 base58 字符串;以小端序存储的数值字段也不会匹配大端序字节数组。始终从你程序使用的同一序列化路径推导过滤器值,并在订阅之前通过 getAccountInfo 读取一个已知账户并检查原始 base64 数据来确认偏移量。
- 偏移量是账户数据缓冲区中的绝对位置,而不是字段索引。
- Anchor 程序在主体之前放置一个 8 字节判别符;字段偏移量从 8 开始。
- memcmp 是字节精确且区分大小写的;base58 和字节数组编码不可互换。
- 在打开订阅之前,对照真实账户的原始数据验证偏移量。
datasize 语义与布局脆弱性
datasize 过滤器是对账户数据长度的匹配。它有助于排除已关闭的账户(这些账户可能仍会被短暂观察到),以及区分共享同一所有者但形态不同的账户。它也是这套术语中最脆弱的过滤器,因为它编码了关于你程序序列化布局的假设,而一旦你添加字段、重排结构体或提升版本,这个假设就会改变。
失败模式是悄无声息的。当程序发布账户更大的版本 3 布局时,固定为版本 2 长度的 datasize 过滤器会停止匹配新账户,同时继续匹配任何仍然存在的旧版账户。你看到的是部分流,而不是错误,而缺失的账户恰恰很可能是你最想要的。将 datasize 视为必须与程序部署同步更新的版本标记,并优先将其作为辅助过滤器而非主要选择器。
由于过滤器数组是逻辑与关系,当布局出现分歧时,datasize 与 memcmp 的交互会很糟糕。对所有者索引字段的 memcmp 与对版本 2 布局的 datasize 组合,一旦程序发布版本 3 就会一无所获,即使每个过滤器单独来看都是格式正确的。如果你需要在迁移期间跟踪多种布局,请为每种布局运行单独的订阅,而不是试图在一个过滤器数组中表达并集。
- datasize 匹配账户数据总长度,而不是字段长度。
- 当程序更改账户布局时,它会静默停止匹配。
- 在布局迁移期间将 datasize 与 memcmp 组合,可能使整个订阅归零。
- 迁移期间为每种布局运行一个订阅,而不是编码并集。
逻辑与语义与空闲订阅的歧义
programSubscribe 过滤器数组中的每个条目都必须匹配,通知才会被投递。没有或运算,没有取反,也没有部分匹配。当你刻意编写数组时这很直接,但当过滤器来自不同来源时就会变成隐患,例如从索引器复制的 memcmp 和从迁移脚本复制的 datasize。一个过时的条目就足以抑制所有通知。
更深层的问题是,匹配不到任何内容的订阅与正确但空闲的订阅无法区分。节点不会发送错误、警告,也不会发送与过滤器评估相关的周期性心跳。一个静默损坏的订阅看起来与一个安静程序上的健康订阅完全一样。这就是为什么过滤器正确性不能仅通过观察订阅来验证;必须通过独立读取来验证。
可靠的检查是对账。在过滤订阅旁边打开一个未过滤的 programSubscribe,或者使用相同过滤器发起 HTTP getProgramAccounts 读取,并在同一时间窗口内比较计数。如果过滤订阅返回零,而未过滤订阅返回满足你预期谓词的账户,那么过滤器就是错的。getProgramAccounts 过滤器与分页指南介绍了该比较的 HTTP 一侧,构建可恢复的账户集索引器指南介绍了如何随时间保持两个视图一致。
- 过滤器数组是逻辑与;每个条目都必须匹配。
- 当过滤器匹配不到任何内容时不会发出错误。
- 损坏的订阅和空闲的订阅在观察上完全相同。
- 对照未过滤订阅或 HTTP getProgramAccounts 读取进行对账。
可运行示例:统计过滤与未过滤通知数量
以下 Node.js 脚本针对同一程序打开两个 programSubscribe 订阅,一个未过滤,一个带 memcmp 加 datasize 过滤器,在固定窗口内统计通知数量,并报告不匹配情况。它使用标准 ws 包和公共 JSON-RPC 2.0 订阅信封。请将程序 ID、memcmp 偏移量和过滤器值替换为从你自己程序序列化布局推导出的值。
针对一个你确定活跃的程序运行它。如果未过滤订阅收到通知而过滤订阅没有收到,那么你的过滤器是错的;如果两者都没有收到,那么该程序在该窗口内只是空闲,你应该延长窗口或选择更繁忙的程序。这是测量方法,不是基准测试:你记录的数字特定于你的端点、你的程序和你的观察窗口。
const WebSocket = require('ws');
const WS_URL = process.env.SOLANA_WS_URL || 'wss://api.mainnet-beta.solana.com';
const PROGRAM_ID = process.env.PROGRAM_ID;
const WINDOW_MS = 60000;
// Derive these from your program's serialized layout.
// Anchor programs write an 8-byte discriminator before the body.
const MEMCMP_OFFSET = 8;
const MEMCMP_BYTES = Buffer.from(process.env.MEMCMP_BASE58 || '', 'base64');
const DATASIZE = Number(process.env.DATASIZE || 165);
function subscribe(label, filter, counter) {
const ws = new WebSocket(WS_URL);
ws.on('open', () => {
ws.send(JSON.stringify({
jsonrpc: '2.0',
id: label,
method: 'programSubscribe',
params: [PROGRAM_ID, filter ? { encoding: 'base64', filters: filter } : { encoding: 'base64' }]
}));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.method === 'programNotification') counter.count += 1;
if (msg.error) console.error(label, 'error', msg.error);
});
ws.on('error', (err) => console.error(label, 'socket error', err.message));
return ws;
}
const unfiltered = { count: 0 };
const filtered = { count: 0 };
const wsA = subscribe('unfiltered', null, unfiltered);
const wsB = subscribe('filtered', [
{ memcmp: { offset: MEMCMP_OFFSET, bytes: MEMCMP_BYTES.toString('base64') } },
{ dataSize: DATASIZE }
], filtered);
setTimeout(() => {
console.log('window_ms', WINDOW_MS);
console.log('unfiltered_notifications', unfiltered.count);
console.log('filtered_notifications', filtered.count);
console.log('mismatch', unfiltered.count > 0 && filtered.count === 0);
wsA.close();
wsB.close();
process.exit(0);
}, WINDOW_MS);结果表:过滤器类型、匹配内容与静默失败
将下表用作工作表。在右侧列中填入你在自己的端点和程序上实际观察到的内容,然后与文档化行为进行比较。重点不是相信某个已发布的数字,而是自己复现比较,因为过滤器正确性完全取决于你程序的序列化布局。
记录观察窗口、程序 ID、你发送的确切过滤器数组以及两个订阅的计数。如果过滤计数为零而未过滤计数非零,则失败在于过滤器,而非网络。如果两者都为零,在得出任何结论之前先延长窗口。
- 过滤器类型:memcmp | 匹配内容:指定偏移量处的原始字节等于所提供的值 | 常见静默失败:偏移量未考虑 8 字节判别符或头部 | 你的观察:____
- 过滤器类型:datasize | 匹配内容:账户数据总长度等于所提供的整数 | 常见静默失败:程序更改了布局,固定的长度已过时 | 你的观察:____
- 过滤器类型:memcmp + datasize | 匹配内容:两个条件同时为真 | 常见静默失败:一个过时条目抑制整个订阅 | 你的观察:____
- 过滤器类型:logsSubscribe mentions | 匹配内容:提及该公钥的日志行 | 常见静默失败:期望日志过滤器具有账户状态语义 | 你的观察:____
- 过滤器类型:accountSubscribe | 匹配内容:单个账户公钥,无过滤器 | 常见静默失败:传入该方法不接受的过滤器数组 | 你的观察:____
排查:偏移量漂移、编码、大小写与承诺级别
程序升级后的偏移量漂移是订阅曾经有效而现在返回空的最常见原因。当程序在你过滤的字段之前添加字段时,后续每个偏移量都会移动。从当前序列化布局重新推导偏移量,并重新运行对账脚本。如果你维护索引器,构建可恢复的账户集索引器指南介绍了如何检测和恢复这类漂移而不丢失事件。
编码不匹配是第二常见原因。当方法期望字节时以 base58 提供 memcmp 值,或反之,将比较错误的字节序列并匹配不到任何内容。确认你的提供商文档所说明的编码,并从你程序使用的同一序列化路径推导值。区分大小写遵循同样的原则:base58 区分大小写,过滤器术语中没有任何不区分大小写的匹配。
承诺级别影响你观察到什么,而不影响过滤器匹配什么。processed 承诺级别的订阅可能会为后来被回滚的账户投递通知,而 confirmed 或 finalized 承诺级别投递更少、更晚的通知。如果你的过滤和未过滤订阅使用不同的承诺级别,你的对账计数会因与过滤器无关的原因而不一致。在比较的两侧保持承诺级别一致。围绕承诺默认值和通知时序的提供商特定行为因提供商而异,因此请查阅你的端点文档,而不是假设一致。
- 在每次更改布局的程序升级后重新推导 memcmp 偏移量。
- 对照你的提供商文档化的方法签名确认 base58 与字节编码。
- memcmp 中没有不区分大小写的匹配;base58 区分大小写。
- 在过滤和未过滤订阅之间保持承诺级别一致。
- 承诺和通知时序的提供商默认值各不相同;请查阅端点文档。
用 HTTP 交叉检查验证订阅
第二条验证路径使用 HTTP 而非第二个 WebSocket。使用你传给 programSubscribe 的同一过滤器数组发起 getProgramAccounts 调用,并将返回的账户集与你观察到的通知进行比较。HTTP 调用是时间点读取,因此不会与流式计数完全匹配,但它会立即告诉你过滤器是否匹配到任何账户。
这种交叉检查成本低廉,并能捕获最昂贵的一类 bug:语法有效但语义为空的过滤器。getProgramAccounts 过滤器与分页指南详细介绍了 HTTP 过滤器 API,包括分页如何与大型结果集交互。如果 HTTP 调用返回账户而订阅返回空,那么问题在于订阅参数,而非过滤器谓词。
curl -s https://api.mainnet-beta.solana.com -X POST -H 'Content-Type: application/json' -d '{
"jsonrpc": "2.0",
"id": 1,
"method": "getProgramAccounts",
"params": [
"YOUR_PROGRAM_ID",
{
"encoding": "base64",
"filters": [
{ "memcmp": { "offset": 8, "bytes": "YOUR_BASE58_OR_BASE64_VALUE" } },
{ "dataSize": 165 }
]
}
]
}'服务端订阅过滤的局限与权衡
服务端过滤减少了带宽和客户端工作,但它把正确性转移到了你无法观察的地方。节点针对原始字节评估你的过滤器,不匹配时不返回任何内容,因此每个过滤器 bug 都会变成静默的数据缺口,而不是可见的错误。这种权衡对于探索性工作可以接受,对于任何必须完整的工作则很危险。
过滤器也无法表达并集、取反或对解码后字段的谓词。如果你需要匹配多种布局之一的账户,或者解码后所有者字段属于某个集合的账户,你必须要么运行多个订阅,要么订阅未过滤并在客户端过滤。客户端过滤消耗带宽,但给你可见性:你可以记录被拒绝的内容并立即检测漂移。
最后,过滤器语义与序列化布局绑定,而序列化布局是你程序的实现细节,而非稳定接口。你编写的任何过滤器都与特定程序版本耦合。将过滤器定义视为与程序部署一同发布的版本化产物,并在布局更改时用对账方法重新验证它们。关于高流量订阅的端点选择和容量规划,请参阅 RPC 定价和 API 服务概览,并浏览 Solana 网络页面了解端点选项。
- 服务端过滤隐藏了正确性;过滤器 bug 是静默的数据缺口。
- 过滤器数组中无法表达并集、取反或解码字段谓词。
- 客户端过滤消耗带宽,但使拒绝可观察。
- 过滤器定义与特定程序版本耦合,必须进行版本化。
后续步骤:为持续验证对订阅进行埋点
持久的修复方法是让验证持续进行,而不是一次性检查。在过滤订阅旁边运行低频未过滤订阅或周期性 getProgramAccounts 读取,并在未过滤参考活跃而过滤流安静时发出警报。这将静默失败转变为可检测的失败。
再配合少量客户端验证:解码你确实收到的账户,并断言它们满足你预期的谓词,而不仅仅是你编码的谓词。如果收到的账户未通过预期谓词,你的过滤器太宽松;如果参考流显示你的过滤器本应匹配但未到达的账户,你的过滤器太严格。两个方向都有信息量。
对于在托管基础设施上构建的团队,Solana WebSocket API(RPC Assistant)页面记录了方法范围,OnFinality Learn 中心汇集了关于连接生命周期、日志解析和索引器构建的相关指南。从 Solana 网络页面开始选择端点,然后将对账脚本接入你的部署流水线,以便在发布时而非生产环境中捕获过滤器漂移。
- 运行参考流,并在过滤流安静时发出警报。
- 解码收到的账户并断言预期谓词,而不仅仅是编码的谓词。
- 将过滤器定义视为在发布时检查的版本化产物。
- 在扩大订阅数量之前,回顾连接生命周期和日志解析指南。