The Graph 协议是一个去中心化索引层,可将原始区块链事件转换为可查询的 GraphQL API,称为子图。开发者定义清单、模式和映射处理程序;Graph Node 通过 RPC 端点扫描区块,执行这些处理程序,并存储生成的实体以便快速查询。本文解释了索引机制,演示了一个可运行的子图部署和查询工作流,并展示了如何验证结果以及排查索引停滞或不一致的问题。它将文档化的协议行为与您应针对自己的 RPC 基础设施运行的测量分开说明。
The Graph 协议的功能及其定位
The Graph 协议是一个用于区块链数据的开放索引协议。您无需在每次页面加载时从 RPC 节点查询原始日志和收据,而是在子图中描述您关心的数据,协议的索引器会将区块处理到数据库中,以回答 GraphQL 查询。项目文档和生态系统列表中的公开描述是一致的:子图组织智能合约数据并通过 GraphQL 提供服务,而 Substreams 则覆盖更高吞吐量用例的流式管道。
实用的心智模型是一个三部分管道。清单声明要监视哪些合约、事件和起始区块。模式声明要存储的实体。映射处理程序是确定性函数,将每个匹配事件转换为实体写入。Graph Node 是针对链 RPC 端点运行此管道的开源 Rust 实现。
本文侧重于协议机制和可复现的本地工作流。关于 Graph Node 将使用的 RPC 端点的具体选择,请参阅 RPC 助手中的端点建议,而不是将本页视为提供商列表。
- 子图:将合约事件声明式索引到 GraphQL 实体中。
- Substreams:面向高容量或实时消费者的流式数据管道。
- Graph Node:读取区块并写入实体存储的索引器进程。
- GraphQL 端点:您的应用程序调用的查询接口。
假设与前提条件
以下工作流假设您已安装 Node.js 和 npm,Docker 可用于本地运行 Graph Node,并且您要索引的链有一个支持归档的 RPC 端点。归档访问很重要,因为具有历史起始区块的子图必须重放过去的状态;修剪过的端点在回填期间会失败或停滞。
您还需要合约地址和要索引事件的 ABI。保持 ABI 最小化:只需包含映射调用的函数和事件。如果您要在自托管和托管 RPC 之间为索引器的数据源做选择,专用节点选项和 RPC 端点指南解释了运营权衡。
Graph Node 内部的索引工作原理
Graph Node 按区块顺序处理链。对于每个区块,它检查清单的数据源,按合约地址和事件签名过滤日志,并调用匹配的处理程序。处理程序接收事件和存储句柄;它们读取或创建实体并保存。由于处理程序必须是确定性的,相同的区块范围总是产生相同的实体状态,这正是独立索引器能够达成一致的原因。
实际部署中主要有两种故障模式。首先,无法提供所需区块范围或对索引器进行速率限制的 RPC 端点会导致重试和明显的延迟。其次,依赖非确定性输入(如挂钟时间或外部 HTTP 调用)的处理程序会产生不同结果,并被协议的确定性规则拒绝。将 RPC 层视为索引正确性的一部分,而不仅仅是管道。
协议的去中心化网络在此机制之上增加了经济角色:索引器质押并提供查询服务,策展人信号哪些子图重要,委托人支持索引器。这些角色影响查询经济和可用性,但不会改变此处描述的子图编写工作流。
构建最小子图清单和模式
从标准的 Graph CLI 脚手架开始,然后用您的合约详细信息替换生成的文件。下面的清单是一个模板;请替换您的合约地址、起始区块和 ABI 路径。startBlock 应该是合约部署的区块,除非您有意需要更早的历史。
npm install -g @graphprotocol/graph-cli
graph init my-subgraph \
--from-contract 0xYourContractAddress \
--network mainnet \
--abi ./abis/MyContract.json \
--contract-name MyContract编写确定性映射处理程序
生成的 subgraph.yaml 指向一个映射文件。处理程序应做三件事:读取事件参数,加载或创建实体,并保存它。避免任何在不同机器上可能返回不同结果的调用。如果需要派生值,请从事件数据或存储中已有的实体计算它们。
下面的示例将通用的 Transfer 事件索引到 Transfer 实体中,并更新一个运行余额实体。它故意保持很小,以便您在增加复杂性之前端到端验证管道。
import { Transfer as TransferEvent } from '../generated/MyContract/MyContract';
import { Transfer, Account } from '../generated/schema';
import { BigInt } from '@graphprotocol/graph-ts';
export function handleTransfer(event: TransferEvent): void {
const id = event.transaction.hash.toHex() + '-' + event.logIndex.toString();
const transfer = new Transfer(id);
transfer.from = event.params.from;
transfer.to = event.params.to;
transfer.value = event.params.value;
transfer.blockNumber = event.block.number;
transfer.save();
const accountId = event.params.to.toHex();
let account = Account.load(accountId);
if (account == null) {
account = new Account(accountId);
account.balance = BigInt.fromI32(0);
}
account.balance = account.balance.plus(event.params.value);
account.save();
}针对 RPC 端点本地运行 Graph Node
本地 Graph Node 实例需要一个 Postgres 数据库、一个用于子图文件的 IPFS 节点,以及目标链的 RPC URL。下面的 compose 文件是一个起点;将 RPC_URL 设置为支持您链归档查询的端点。不要将公共端点硬编码到共享仓库中,因为速率限制和可用性因提供商和计划而异。
堆栈健康后,创建子图,在本地部署它,并等待索引器到达链头。graph deploy 命令会打印一个您可以轮询的状态 URL。
version: '3'
services:
postgres:
image: postgres:14
environment:
POSTGRES_USER: graph
POSTGRES_PASSWORD: graph
POSTGRES_DB: graph-node
ports: ['5432:5432']
ipfs:
image: ipfs/go-ipfs:v0.18.1
ports: ['5001:5001']
graph-node:
image: graphprotocol/graph-node
depends_on: [postgres, ipfs]
ports: ['8000:8000', '8020:8020', '8030:8030']
environment:
postgres_host: postgres
postgres_user: graph
postgres_pass: graph
postgres_db: graph-node
ipfs: 'ipfs:5001'
ethereum: 'mainnet:${RPC_URL}'
GRAPH_LOG: info通过 GraphQL 查询子图
一旦索引到达链头,查询本地 GraphQL 端点。下面的查询返回按价值排序的前十笔转账以及特定账户的当前余额。使用变量而不是字符串插值,以保持查询可缓存且安全。
预期的验证结果是一个 JSON 响应,其中 data 对象包含请求的实体。如果 data 为 null,请先阅读 errors 数组;它通常会指出缺失的字段或实体类型。
curl -s http://localhost:8000/subgraphs/name/my-subgraph \
-H 'content-type: application/json' \
--data '{"query":"query($account: String!) { transfers(first: 10, orderBy: value, orderDirection: desc) { id from to value blockNumber } account(id: $account) { id balance } }","variables":{"account":"0x0000000000000000000000000000000000000000"}}'验证索引健康状态并测量查询延迟
不要仅仅因为子图返回行就信任它。验证三件事:索引器已到达链头,实体计数与样本区块范围的独立来源匹配,查询延迟对您的应用程序可接受。下面的过程生成一个结果表,您用自己的测量值填写;此处不断言任何数字,因为它们取决于您的链、端点和硬件。
每个步骤至少运行三次并记录中位数。与直接 RPC eth_blockNumber 调用比较,以确认索引器没有滞后。
- 步骤 1:在您的 RPC URL 上调用
eth_blockNumber并记录链头区块。 - 步骤 2:查询子图的
_meta { block { number } }字段并记录已索引的区块。 - 步骤 3:计算滞后为链头减去已索引区块;60 秒后重复。
- 步骤 4:对十个相同的 GraphQL 查询计时并记录中位延迟。
- 步骤 5:在区块浏览器中对照原始事件抽查一个实体。
您自己运行的结果表模板
| 运行 | RPC 链头区块 | 已索引区块 | 滞后(区块) | 中位查询延迟(毫秒) | 实体抽查 |
|---|---|---|---|---|---|
| 1 | 从 eth_blockNumber 填写 | 从 _meta 填写 | 计算 | 测量 | 通过/失败 |
| 2 | 从 eth_blockNumber 填写 | 从 _meta 填写 | 计算 | 测量 | 通过/失败 |
| 3 | 从 eth_blockNumber 填写 | 从 _meta 填写 | 计算 | 测量 | 通过/失败 |
接近零的稳定滞后和通过的抽查表明管道健康。RPC 健康时滞后上升通常指向处理程序效率低下或数据库争用。RPC 错误时滞后上升指向端点,而不是子图。
排查索引停滞和实体不一致
如果索引停止推进,在更改子图之前检查 Graph Node 日志中的 RPC 错误。常见原因包括无法提供历史区块的修剪端点、回填期间的速率限制,或迫使索引器回退的链重组。先修复数据源,然后重新运行。
如果实体缺失或重复,检查处理程序的实体 ID 构造。仅由交易哈希构建的 ID 在交易发出多个匹配事件时会冲突;像上面的示例一样附加日志索引。如果处理程序读取同一区块中后续处理程序创建的实体,请重新排序处理程序或重构模式,使写入独立。
限制、权衡和后续步骤
子图非常适合实体形状的读取,但它们不是通用的流式总线。如果您需要将每个事件亚秒级传递到自定义接收器,请评估 Substreams 或直接日志订阅。索引还会在链头和可查询状态之间增加延迟,因此必须读取绝对最新区块的应用程序应针对该特定读取回退到 RPC 调用。
在运营上,Graph Node 背后的 RPC 端点是一等依赖项。为归档访问、适应回填的速率限制以及针对索引滞后的监控做好预算。有关成本和计划比较,请参阅 RPC 定价页面。
作为下一步,将相同的子图部署到测试网,再次运行验证表,并比较不同端点的滞后和延迟。这种比较,而不是供应商的声明,才是应该驱动您生产选择的测量。有关特定端点的建议,请使用 RPC 助手。