T4 · RPC 是什么
你的 DApp 其实在跟谁说话?
- 练习的能力
- Builder
- 动手
- 对同一个查询分别用两家 RPC 服务,比较延迟、限速和历史数据的可用范围。
- AI Lab
- 让 AI 写一个带重试、超时与多节点回退的 Provider 封装,自己补上它漏掉的幂等处理。
一个现实问题
本地跑得好好的 DApp,上线第三天开始出怪事。
- 有用户说打开页面余额是 0,刷新几次又对了。
- 有用户说交易记录少了几条,第二天又全了。
- 你自己怎么试都正常。
你去翻日志,看到一堆 429。再往下翻,看到几条超时。再往下,看到一次返回的区块高度比五分钟前那次还要小。
这时候你才意识到一件事:**你的代码从头到尾没有碰过任何一条链。**它碰的是一个 HTTP 端点——一个你没写过、没部署过、没监控过、出了问题也进不去看日志的服务。
那个端点后面是谁?它凭什么给你数据?它什么时候会不给?它给错了你怎么知道?
思想实验
假设你在做一个报表系统,数据来自一个只读的数据库副本。有人告诉你这个副本有五个特点:
- **它会限速。**每秒超过若干次查询就拒绝你,而且不同查询的「重量」不一样——查一行和扫一千万行算的额度差几百倍。
- **它可能落后。**主库写入之后,副本什么时候同步完没有保证。你连续查两次,第二次可能比第一次更旧,因为负载均衡把你分到了另一台机器。
- 它可能没有历史。为了省磁盘,它只保留最近一段时间的数据。查更早的,它不是返回空,是报一个你看不懂的错。
- **它可能返回假数据。**没有任何机制阻止它这么做。你收到的是一个 JSON,你无法从这个 JSON 本身判断它对不对。
- **它不归你管。**它挂了,你只能等。
现在问自己一个问题:在这五个前提下,你还能做出一个可靠的报表系统吗?
能,但你的架构必须显式地处理这五件事:限速要有退避和排队;落后要有单调性校验;历史缺失要提前知道边界在哪;假数据要有交叉验证;不归你管要有备份来源。
这五件事,就是这一章的全部内容。
把「只读副本」换成「RPC 端点」,上面每一条都原样成立。差别只有一个:那个数据库至少是你们公司的,而这个端点不是。
你来决定
你要给一个即将上线的产品选 RPC 方案。
观察结果
四个选项的差异,可以收成五个维度:
| 维度 | 公开免费端点 | 托管服务 | 自建节点 | 多源加自有封装 |
|---|---|---|---|---|
| 成本 | 零 | 按用量,随规模线性增长 | 固定,前期高 | 最高,但可控 |
| 限速 | 严格且不透明 | 明确额度,超额计费 | 只受硬件限制 | 由你自己分配 |
| 历史深度 | 通常很浅 | 看套餐,归档要加钱 | 你自己决定 | 按方法路由 |
| 信任 | 完全信任对方 | 完全信任对方 | 只信任自己 | 可交叉验证 |
| 故障时 | 只能等 | 只能等 | 你自己修 | 自动回退 |
五个维度里,只有「信任」这一条是无法用钱解决的。
其余四条都是取舍:多花钱买额度、多花钱买归档、多花人力自建。只有「我怎么知道这个 JSON 是真的」这件事,不管你付多少钱,答案都一样——你不知道,除非你自己验证。
这是理解 RPC 最重要的一句话:**用第三方 RPC,意味着你信任它返回的数据。**你的产品的去中心化程度,在接入那一刻就被这个端点封了顶。
建立模型
一次调用真正走过的路:
- 你的代码
- Provider 封装
- HTTPS 到服务商入口
- 负载均衡挑一个实例
- 某个节点进程
- 它本地的数据库
决定你产品上限的三个维度:
一、限速。不要按「每秒多少次请求」来理解,要按「计算量额度」来理解。一次读余额和一次扫描一百万个区块的日志,消耗的额度可能差几百倍。所以你的用量曲线不取决于用户数,取决于你调用了哪些方法。
优化的第一步永远是:把日志范围缩小、把批量查询合并、把不变的数据缓存住。T15 会讲缓存层。
**二、数据深度。**节点分三种:只保留最近状态的(大多数),保留全部区块但状态被裁剪过的,以及保留每一个历史高度完整状态的归档节点。
查一个很老高度的余额或存储,前两种会报错而不是返回空。这个错误信息通常长得完全不像「没有历史数据」,第一次见到的人一般会以为是自己参数写错了。
**三、一致性。**同一时刻,两个实例的最新高度可能不同;链末端还会重组。这意味着两件事:
- 同一个逻辑请求连着发两次,第二次可能读到更旧的状态。
- 你按「最新」读到的数据,可能在几秒后就不存在了。
处理方法是用明确的区块标签,而不是每次都用「最新」:
| 标签 | 含义 | 什么时候用 |
|---|---|---|
| latest | 当前这个实例认为的最新区块 | 展示用,可以接受抖动 |
| safe / finalized | 已被协议标记为不易回滚 / 不可回滚 | 涉及资金的判定 |
| 具体高度 | 指定那一个区块 | 批量索引、对账、任何需要可重放的场景 |
| pending | 包含内存池里待处理的内容 | 只用于查 nonce,别用于读余额 |
**一条实践规则:任何需要「跑两次结果一样」的逻辑,都必须把区块高度固定下来,而不是用 latest。**索引器尤其如此——用 latest 写出来的索引器是不可重放的,出了问题你连复现都做不到。
自有封装层该有的能力:
| 能力 | 解决什么 |
|---|---|
| 超时 | 上游挂起时不拖垮你自己的连接池 |
| 重试加指数退避 | 瞬时抖动与 429 |
| 熔断 | 一家持续失败时不再浪费时间打它 |
| 按方法路由 | 归档查询走贵的源,普通查询走便宜的源 |
| 高度单调性保护 | 回退到落后的源时,不让业务看到高度倒退 |
| 交叉验证 | 对关键数据向两个源各问一次,不一致就告警 |
最后两条是把「多个后端」和「高可用」区分开的地方。只有前四条,你做的是负载均衡;加上后两条,你才是在处理不信任。
它叫什么
一台运行着链客户端、保存着链数据并执行验证的机器。
按保留的数据量分成几档:只保留近期状态的、保留全部区块但状态被裁剪的、以及保留每个高度完整状态的归档节点。档次决定了你能查多久以前的数据,也决定了成本。
链对外暴露的接口协议。请求体是一个 JSON,里面有方法名和参数数组,返回体里要么有 result 要么有 error。
它的方法名是跨客户端标准化的,这是它比任何 SDK 都稳定的原因:SDK 的 API 半年一变,eth_getBalance 十年没变。这也是为什么这门课的示例都直接用它。
你代码里那一层负责发起 RPC 调用的抽象。
它是你唯一能插入重试、超时、缓存、路由、校验的地方。如果你的业务代码到处直接发 HTTP 请求,这一章讲的所有能力你都没有地方放。
服务商对调用量的限制。通常不是按请求数计,而是按每个方法的计算量加权计。
它的失败表现有两种:明确的 429,以及降级成错误对象返回。后者最危险,因为不检查错误字段的代码会把它当成正常的空结果。
保存每一个历史高度完整状态的节点。查任意历史时刻的余额、存储槽、合约调用结果都需要它。
它的存储成本比普通节点高一个数量级以上,服务商也按更贵的档次收费。很多数据产品的成本结构就是在这里被决定的。
查询时指定「按哪个区块的状态来算」的参数。latest、finalized、具体高度各有各的用途。
**这是一个被严重低估的参数。**用错它,你的代码在 99% 的时间里都是对的,剩下 1% 的时间给出无法复现的错误结果。
动手
**全程只读查询,不发任何交易,不需要私钥,不涉及任何真实资产。**建议一个用公开免费端点,一个用托管服务的免费档,差异会更明显。
比延迟。
for i in $(seq 1 10); do
curl -s -o /dev/null -w '%{time_total}\n' -X POST <端点 A> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'
done两个端点各跑一次,对比中位数和最大值。看最大值,不要只看平均值——用户感知到的卡顿来自长尾。
比高度是否一致。
同一时刻分别问两家 eth_blockNumber,把十六进制转成十进制,看差几个块。
再连着问同一家十次,看有没有出现后一次比前一次小的情况。出现了,说明你被分到了不同的实例上,这就是你必须做高度单调性保护的原因。
比历史深度。
curl -s -X POST <端点 A> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance",
"params":["<任意活跃地址>","0x<一个很老的区块高度>"]}'把高度从最近往回推,二分查找出每家开始报错的那个高度。这就是它的历史边界。
仔细读那条错误信息。它多半不会写「没有归档数据」,而是一个关于状态查找失败的底层错误。记住它长什么样,以后在生产日志里再见到就能一眼认出来。
比日志查询的范围上限。
curl -s -X POST <端点 A> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getLogs","params":[{
"fromBlock":"0x...","toBlock":"0x...",
"address":"<一个活跃的代币合约地址>"}]}'从 100 个区块开始,每次翻倍,直到被拒绝。记下两家各自的上限,以及它们是按区块跨度限制还是按返回条数限制——这两种限制会让你的索引器写成完全不同的形状。
比限速。
连续发 100 次 eth_blockNumber,统计第几次开始返回 429 或错误对象。
然后换一个重方法(比如一个大范围的 eth_getLogs)再试,看多少次就被限住。对比两个数字,你就直观理解了「按计算量计费」是什么意思。
留意被限时返回的 HTTP 状态码和响应体。**有没有 Retry-After 头?错误是在 HTTP 层还是在 JSON 的 error 字段里?**你的重试逻辑必须同时覆盖这两种。
做一次交叉验证。
对同一个地址、同一个具体区块高度(不要用 latest),向两家各查一次 eth_getBalance,比对结果。
再换一个合约的 eth_call 做同样的事。
结果应该完全一致。不一致,说明至少有一家的数据有问题——而这正是你在生产环境里唯一能自己发现这件事的办法。
把结论写成一张表。
两家各自的:延迟中位数与 P99、历史边界高度、日志范围上限、被限速的阈值、以及价格。
这张表就是你选型的依据,也是你以后写 Provider 路由规则的依据。三个月后重新测一遍,这些数字会变。
AI Lab
分两步,第二步是重点。
第一步:
写一个 RPC Provider 封装,要求:
支持多个后端端点、超时、失败重试加指数退避、
按方法路由(归档类方法走指定端点)、以及熔断。
用我指定的语言,不要用任何我没提到的第三方库。
第二步:
现在假设这个封装被用在一个需要重放的索引器里。
逐条指出你上面的实现会在哪些情况下返回「不一致但看起来正常」的结果。
对每一条给出修复方案。第二步要逼出来的是这三件事,模型第一次写基本都会漏:
- 幂等性。
eth_sendRawTransaction超时后重试是相对安全的(重复广播同一份签名字节会被节点当作已知交易),但如果重试时重新构造并签名,钱就会出去两次(T3 讲过)。模型经常会把这两件事混为一谈。 - **高度回退。**从源 A 切到源 B,B 落后几个区块,你的索引器会读到一个比自己已处理位置更旧的状态,然后开始写入错误数据。
- **超时不等于没执行。**超时只说明你没等到响应,上游可能已经处理完了。对读方法无所谓,对写方法是致命的。
拿到它的修复方案后,自己挑一条在本地构造出来验证:把一个源换成一个故意返回旧高度的假服务,看它的封装会不会让业务读到倒退的数据。
**能让模型写出封装的人很多,知道该拿什么去质问这份封装的人很少。**这才是这个 Lab 训练的东西。
AI 说完之后,你必须自己验证
- 它用到的库名、类名和方法签名真实存在,你能在官方文档里查到同名的方法
- 它有没有把 HTTP 层的 429 和 JSON 响应体里的 error 字段都当作失败处理
- 它重试时有没有区分幂等方法与写方法,对广播交易的重试是否安全
- 它回退到另一个源时,有没有处理新源的区块高度更低这件事
- 它有没有把超时当成失败,而实际上超时的请求可能已经在上游执行了
- 它写的十六进制与十进制转换没有搞错,特别是区块高度和余额
- 它有没有把「调用成功返回」当成「数据一定正确」
真实案例
某次大型托管 RPC 服务出现区域性故障,大量前端在同一时间无法读取任何链上数据。
讽刺的地方在于:链本身一直在正常出块。链没停,产品停了。
这件事暴露的是一个结构性问题:一批号称去中心化的应用,实际上共享同一个中心化的单点。事后不少团队才开始做多源回退,而这件事本来在架构评审时就该被提出来。
一个交易界面用免费公开端点读取池子状态来计算报价。高峰期该端点落后了若干个区块。
用户看到的是几十秒前的价格,按这个价格提交的交易到链上必然滑点超限而失败,或者更糟——在一个明显不利的价格上成交。
根因不是「免费端点不好」,是用 latest 读取价格敏感数据,却没有校验数据的新鲜度。正确做法是把返回里的区块高度一起读出来,和你认为的最新高度比对,差得太多就拒绝报价。
一个数据产品要回扫某个合约从部署至今的全部事件。直接用一个大范围的日志查询,端点直接拒绝。
于是团队改成按固定跨度分片、并发拉取、每片记录检查点、失败重试。这套东西写完之后,他们才发现自己写了一个索引器的雏形。
**几乎所有链上数据基础设施,都是从「日志查询范围被限制」这一刻开始的。**T17 会把这个雏形补完,包括重组处理和断点续传。
一类诈骗手法:诱导用户在钱包里添加一个「更快的」自定义 RPC 端点。
这个端点可以对余额查询返回任意数字。受害者在钱包里看到一大笔「到账」的资产,以为交易完成,于是把自己的东西交了出去。链上从头到尾没有发生任何转账。
这是「用第三方 RPC 意味着你信任它返回的数据」这句话最直白的一次演示。你的界面上显示的每一个数字,都只是某个端点声称的数字。
改一个变量
你需要自己验证。路径有三条:自建节点(最彻底,也最贵);向多个独立来源交叉验证(便宜,能发现分歧但不能判断谁对);或者用轻客户端的思路——只信任区块头,其余数据要求对方给出可验证的证明。
第三条正是 T2 里 Merkle 结构的用武之地:给你一个状态值,同时给你一条到状态根的路径,你自己算一遍。
大多数产品选第二条,因为它的性价比最高。但你至少要知道自己选的是哪一条。
按用量计费的账单会变成一个真实的成本项,而且它的增长曲线和你的收入曲线未必匹配。
这时候真正的优化不是换更便宜的服务商,是减少调用:把读多写少的数据缓存住、把高频轮询改成事件订阅、把每用户一次的查询改成批量聚合、把能落库的东西落库。T15 和 T16 讲的就是这件事。
自建节点的临界点通常出现在这个阶段——不是因为想去中心化,是因为算下来更便宜。
延迟和调用量都会大幅下降,但你会换来一类新问题:断线期间的数据是丢的。
重连之后必须从上次处理到的高度回补,否则会静默漏掉一段区块。而且不同服务商对订阅的重组通知处理方式不同,有的会推送回滚事件,有的不会。
订阅是优化,不是替代。一个可靠的系统底下永远要有一条能按高度重放的补偿路径。
方法名完全不同(读账户用 getAccountInfo,读余额用 getBalance),但这一章的五个维度一条不少地成立:限速、历史深度、一致性、信任、单点故障。
有一处更严格:Solana 的历史数据默认只保留一段时间,更早的要走专门的历史服务。一个「查半年前的交易」的需求,在这里的成本结构和 EVM 上完全不同。T6 会讲它的账户模型,你会发现连查询的形状都变了。
带走的问题
如果你的产品所有数据都来自一个第三方端点,而用户也没有任何办法验证,那它和一个中心化 API 在信任结构上没有区别。链的价值在于「任何人都能独立验证」,而这个价值是否传递到你的用户手上,取决于你这一层怎么设计。
RPC 账单由你付,不由用户付。这是一个容易被忽略的单位经济学问题:每个活跃用户每天消耗多少计算量额度,这个数字决定了你的产品能不能规模化。在还只有一百个用户的时候就算一遍,比在有十万用户时再算要便宜得多。
端点返回了错误数据导致用户损失,谁承担?合同上多半是你。所以「我们用的是知名服务商」不是一个风险控制手段,交叉验证和数据新鲜度校验才是。涉及资金判定的地方尤其如此。
本章自测
因为方法名是跨客户端标准化的、十年没变,而 SDK 的 API 半年一变。
还有一个更实际的原因:**用 SDK 时你很容易不知道自己发了几个请求。**一行看起来简单的调用,底下可能是三次 RPC。直接写 JSON-RPC 时,你的调用量是显式的。
生产代码当然可以用 SDK,但你应该能在心里把它翻译回 JSON-RPC。
latest 是当前这个节点实例认为的最新区块,它随时可能因为重组而变。finalized 是协议层面标记为不可回滚的区块,代价是它比 latest 落后一段时间。
任何涉及资金判定的读取都应该用 finalized 或足够深的具体高度:给用户入账、解冻额度、触发清算。展示类的可以用 latest。
还有第三种情况:需要可重放的逻辑(索引、对账)必须用具体高度,两个标签都不行。
不是 bug,是正常现象。两次请求可能被负载均衡分到了不同的节点实例,它们的同步进度不一致;链末端的重组也会造成高度回退。
你的 Provider 层应该记住已见过的最大高度,发现返回值低于它时,要么重试、要么切源、要么把这个信息暴露给业务层,而不是直接把倒退的数据交给业务。
任何需要「某个历史时刻的状态」的场景:查一年前的余额、复算某笔历史交易、回放某个区块的合约调用结果、做深度的交易追踪。
注意区分:**读历史的区块和交易不需要归档节点,读历史的状态才需要。**这两件事经常被混为一谈,而它们的成本差一个数量级。
在选型之前先确认你真正要的是哪一种,能省掉很多钱。
没有标准答案,检查这几件事:
- 业务代码是否全部走一层自有的 Provider 抽象,而不是各自发 HTTP。
- 有没有至少两个独立来源,并且回退时处理了高度倒退。
- 重试策略是否区分了幂等读和写方法,超时后的行为是否明确。
- 429 是在 HTTP 层还是在 JSON 的 error 字段里,两种都处理了吗。
- 需要归档的方法有没有被单独路由,避免整体买贵的档位。
- 涉及资金判定的读取用的是哪个区块标签。
- 有没有监控:每家的延迟、错误率、高度差、额度消耗。
- 关键数据有没有交叉验证和不一致告警。
能写出第 2、3、8 条,说明你已经不是在做「接一个 API」,而是在做基础设施了。
一句话带走
RPC 是你和链之间唯一的门,它的限速与数据完整性决定了产品的上限。