TheGraph 一: 架构解析

查一个交易对过去 30 天的成交量,以太坊节点答不上来。跟着 graph-node 的源码走一遍:区块怎么进库、触发器在哪一步才算出来、mapping 为什么跑在 wasm 里、写入为什么是按区块的一次事务,以及重组时这些数据怎么退回去。

预计
9 分钟

想知道某个 Uniswap V2 交易对过去 30 天的成交量,直接问以太坊节点是问不出来的。

JSON-RPC 能给的只有这么几样:某个区块长什么样、某笔交易的收据、某个地址在某个高度的余额,以及 eth_getLogs —— 按区块区间和 topic 过滤出一批原始日志。没有 group by,没有 join,也没有「过去 30 天」。要那个数字,得自己把相关日志一条条拉下来、解码、累加,再存进自己的库。

The Graph 做的就是这件事,只不过把它变成了一份可以部署的声明:写清楚盯哪个合约的哪些事件、每个事件该变成什么数据、这些数据长什么样,索引器负责跑出来并给一个 GraphQL 接口。声明由四份东西组成 —— manifest、数据源、mapping 函数、schema,TheGraph 二:subgraph 四大关键定义数据拿 Uniswap V2 的 subgraph 逐份拆开。这一篇讲索引器那一侧:一条日志从链上到 GraphQL 响应,中间经过了什么。

下面的文件路径都在 graph-node 仓库里,可以拿着名字去搜。

区块先进库,触发器是后面才算出来的

最容易想歪的一步在开头。直觉上,索引器应该是「收到新区块 → 看看里面有没有我关心的事件 → 有就调 handler」,一条龙下来。实际上这是两个互不相干的组件,中间隔着一个数据库。

拉区块的叫 ingestor。以太坊的实现是 chain/ethereum/src/ingestor.rs 里的 PollingBlockIngestor,而它要实现的 trait 一共只有三个方法:

graph-node · graph/src/blockchain/mod.rs · trait BlockIngestor
#[async_trait]
pub trait BlockIngestor: 'static + Send + Sync {
async fn run(self: Box<Self>);
fn network_name(&self) -> ChainName;
fn kind(&self) -> BlockchainKind;
}

三个方法里有两个是在报自己的身份,真正干活的只有 run。它做的事就是不停地轮询链头,把区块写进 ChainStore不认识任何 subgraph,也不知道谁关心哪个事件。一个节点上跑着一百份 subgraph,ingestor 还是那一份,区块也只拉一遍。

把区块变成「该调哪个 handler」的是另一条路:BlockStreamTriggersAdapter::scan_triggers(from, to, filter) 按区间扫出 BlockWithTriggers,其中 filter 由所有数据源的声明合并而成,每个数据源自己的 match_and_decode 负责判断一条日志归不归它管、以及解成什么。

这个分工带来一个直接的后果:没在 manifest 里声明过的合约和事件,索引器根本不会去看。 它不是先解码再丢弃,而是压根不在过滤条件里。所以 subgraph 上线之后想多索引一个事件,只能改 manifest 重新部署,然后从 startBlock 重新同步一遍。

BlockStream 吐出来的是一个枚举,BlockStreamEvent::ProcessBlockBlockStreamEvent::Revert 两个分支 —— 第二个分支就是重组,后面单独说。消费它的是 core/src/subgraph/runner/mod.rs 里的 SubgraphRunnerprocess_blockmatch_triggersexecute_triggerstransact_block_statehandle_revert 这几个方法连起来就是整个索引循环。

mapping 跑在 wasm 沙箱里,能干什么由宿主函数决定

mapping 用 AssemblyScript 写,编译成 WebAssembly。选 wasm 不是为了快 —— 是为了让别人写的代码能在你的机器上跑而不出事。索引器是去中心化网络里的一个角色,它要执行的是任意开发者上传的字节码。

wasm 模块默认什么都干不了:没有文件系统,没有网络,没有系统调用。它唯一能做的是调用宿主显式注册进去的函数。这批函数在 runtime/wasm/src/host_exports.rsHostExports 上挂着 store_getstore_setstore_removestore_load_relatedipfs_catipfs_mapcrypto_keccak_256 这些。这张表就是 mapping 能力的全集,表上没有的事情它做不了。

从 AssemblyScript 那一侧看,这批能力集中在 @graphprotocol/graph-tsstore 命名空间:

@graphprotocol/graph-ts · index.ts · store 命名空间
export declare namespace store {
function get(entity: string, id: string): Entity | null;
function get_in_block(entity: string, id: string): Entity | null;
function loadRelated(entity: string, id: string, field: string): Array<Entity>;
function set(entity: string, id: string, data: Entity): void;
function remove(entity: string, id: string): void;
}

平时写 mapping 不直接调它们。graph codegen 会按 schema 生成一批实体类,都是内置 Entity 的子类,带上每个字段的 getter/setter —— 你写的 pair.save()Token.load(id),底下就是 store.setstore.get。生成的类是这套命名空间的类型化包装。

declare 是关键字,这些函数在 TypeScript 侧只有签名没有实现,实现在 Rust 那边。同一批注册进去的还有 "ethereum.call",对应 chain/ethereum/src/runtime/runtime_adapter.rs 里的 ethereum_call —— 它让 mapping 能对合约发一次 eth_call 读取 view 函数,用来补事件里没带的信息(比如 ERC-20 的 symboldecimals)。这是整个 mapping 里唯一会打网络的动作,也因此是最慢的一步,EthereumCallCache 存在的理由就是它。

真正把一个触发器送进 wasm 的是 core/src/subgraph/trigger_processor.rsSubgraphTriggerProcessor::process_trigger,它调 RuntimeHost::process_mapping_trigger,后者把请求包成 WasmRequest 丢进 channel,另一头 runtime/wasm/src/mapping.rs 收下来,调 module.handle_trigger(trigger),执行那个导出的 handler。

一个区块的写入是一次事务,重组时整块退回去

mapping 跑完不会立刻落库。handler 里那些 save() 先攒在内存的 BlockState 里,攒成一批 EntityModification,等这个区块的所有触发器都处理完,一次性提交。

提交的入口是 WritableStore trait 上的 transact_block_operations

graph-node · graph/src/components/store/traits.rs · WritableStore
async fn transact_block_operations(
&self,
block_ptr_to: BlockPtr,
block_time: BlockTime,
7 collapsed lines
firehose_cursor: FirehoseCursor,
mods: Vec<EntityModification>,
stopwatch: &StopwatchMetrics,
data_sources: Vec<StoredDynamicDataSource>,
deterministic_errors: Vec<SubgraphError>,
offchain_to_remove: Vec<StoredDynamicDataSource>,
is_non_fatal_errors_active: bool,
is_caught_up_with_chain_head: bool,
) -> Result<(), StoreError>;

参数表本身就说明了这次事务包含什么。block_ptr_to 是「同步到哪一块了」这个游标,它和 mods 在同一次事务里写下去 —— 所以进度和数据不可能对不上。索引器中途被 kill,重启之后从游标那一块继续,不会重复写也不会漏。

data_sources 那个参数是运行时新建的数据源。mapping 里调 PairTemplate.create(address) 动态注册一个新合约,这件事也算这个区块的状态变更,跟着一起提交。

deterministic_errors 值得停一下:确定性错误被记进这次事务,而不是让索引崩掉。区分点在于同样的输入会不会必然得到同样的失败 —— 一个 handler 里数组越界,换谁跑、跑几次都是这个结果,这种错误记下来、标记这个 subgraph 失败,所有索引器得到一致的结论。而 RPC 超时这种非确定性错误不能这么处理,只能重试,否则不同索引器会对同一份 subgraph 给出不同的最终状态。

重组走的是 BlockStreamEvent::Revert 分支,SubgraphRunner::handle_revert 负责把实体退回到分叉点之前的状态。Postgres 那边靠的是把实体存成带区块区间的多版本行(store/postgres/src/block_range.rs),回滚是把某个高度之后的版本抹掉,不是反向执行 handler。自己写脚本扫链的话,这段回滚逻辑得自己实现 —— Node.js,Ethers.js:解析以太坊区块链数据那篇里那个 number → hash 表只解决了「发现重组」,「退回去」是更麻烦的另一半。

查询打的是索引器的 Postgres,新鲜度等于同步进度

GraphQL 那一层(graphql/src/runner.rsgraphql/src/execution/)按 schema 生成查询接口,收到请求后翻译成 SQL,打的是索引器自己那个 Postgres。整个查询路径不碰以太坊节点。

所以两件事跟着确定下来。一是快 —— 开头那个「过去 30 天成交量」是一次带聚合的 SQL,不是几万次 eth_getLogs。二是数据只新鲜到同步进度那一刻:索引器落后链头 200 个区块,查出来的就是 200 个区块之前的世界。查询响应里的 _meta 字段会告诉你它同步到哪了,需要判断新鲜度就读它。

托管服务已经关了,部署路径全变了

网上大量教程还在教 graph deploy --product hosted-service,这条路已经没了。托管服务在 2024 年 6 月 12 日停止服务,全部查询迁到去中心化网络。graph-tooling 现在的 deploy 命令里已经没有 --product 这个 flag,只剩 nodedeploy-keyversion-labelipfs

现在的流程是 Subgraph Studio:

从零到发布
graph init
graph auth <DEPLOY_KEY>
graph codegen && graph build
graph deploy <SUBGRAPH_SLUG>

graph codegen 读 schema 和 ABI,生成上一节说的那批实体类和事件类型;graph build 把 mapping 编译成 wasm。部署到 Studio 之后还有一步 graph publish,把 subgraph 发布到链上,之后才由网络上的索引器来跑。Studio 的开发端点有每天 3,000 次查询的上限,发布到网络后每月有 10 万次免费查询额度,再往上按 GRT 计费。

计费这件事顺带改变了一个设计判断:查询要花钱之后,「先索引全量、查询时再筛」不再免费。该在 mapping 里预聚合的东西就得预聚合。

这套架构的代价

改 schema 或者加事件都要重新同步。 manifest 决定过滤条件,过滤条件决定索引器看过什么。加一个此前没声明的事件,历史数据不会凭空出现,只能从 startBlock 重跑一遍。一个从 2020 年开始索引的 subgraph,这一遍是以天计的。

mapping 里读合约是唯一的慢动作。 ethereum.call() 每次都是一个 eth_call 往返。在 handleTransfer 这种高频 handler 里读一次 decimals,同步速度会掉一个量级,所以那类值都得缓存进实体、只在第一次见到这个代币时读。

没有 join 到链下数据的办法。 subgraph 的世界只有链上事件和 IPFS。价格、法币汇率、KYC 状态这些,要么由另一个服务在查询之后拼,要么想办法先上链。

确定性是硬约束,不是风格。 去中心化网络要求所有索引器对同一份 subgraph 算出同一个结果,所以 mapping 里不能有随机数、不能读当前时间、不能发任意 HTTP 请求。习惯了写普通后端的人,第一次撞上的通常是这一条。

索引器的数据库是它自己的。 查询结果的可信度取决于你连的是哪个索引器同步到了哪一块。去中心化网络用质押和争议解决来兜这件事,但对一个只想拿数据的 DApp 来说,这是一个比「问自己的节点」更长的信任链条。

开头那笔成交量最终落成什么样的实体、Swap 事件怎么变成数据行,在 Uniswap v2 学习里能对上另一半 —— 那些事件是 UniswapV2Pair.sol 发出来的。