Web3 中级实战:基于智能合约与 The Graph 构建链上数据索引查询服务
很多人刚接触链上应用时,会有一个非常直观的疑问:
合约数据明明都在链上,为什么还要额外做“索引服务”?
我自己第一次做 DApp 后台时,也踩进过这个坑:前端直接用 ethers.js 去扫事件,测试网数据少时一切正常;一旦合约跑了几天、用户多了、事件量大了,查询立刻变慢,分页困难,聚合统计也很痛苦。你会发现,“链上可读”不等于“适合业务查询”。
这篇文章我们就从一个中级实战角度,做一个完整的小项目:
- 写一个简单的 Solidity 智能合约
- 通过事件把关键业务状态暴露出来
- 用 The Graph 建立索引
- 用 GraphQL 查询链上数据
- 讨论常见坑、排查思路,以及安全与性能最佳实践
这不是“概念介绍”,而是带你真正跑通一遍。
背景与问题
链上状态查询有几个天然难点:
-
RPC 查询更偏底层
- 适合按合约方法读状态
- 不适合复杂筛选、排序、分页、聚合
-
事件日志天然适合做“历史记录”
- 比如谁何时创建了订单、谁买了多少、累计交易额多少
- 但链本身不提供高层次检索接口
-
业务前端通常需要“类数据库”体验
- 按用户筛选
- 按时间倒序分页
- 查询某资产最近 20 条交易
- 汇总统计某个地址的交易次数和成交额
如果只靠 RPC + 前端本地处理,通常会出现:
- 首屏慢
- 数据不完整
- 难分页
- 历史回放复杂
- 重组(reorg)时数据容易错乱
所以,The Graph 的核心价值就是:
把链上事件和状态,转换成一个可被 GraphQL 查询的索引数据库。
前置知识
在开始之前,建议你至少熟悉:
- Solidity 基础语法
- EVM 事件(event)机制
- Node.js / npm
- GraphQL 基础查询语法
- 使用
ethers.js或viem进行合约交互
如果你已经写过简单合约、也能部署到本地链或测试网,那这篇内容会比较顺。
环境准备
本文示例使用以下技术栈:
- Solidity
- Hardhat
- The Graph CLI
- Graph Node(本地)
- GraphQL
建议环境:
- Node.js 18+
- Docker / Docker Compose
- npm 或 pnpm
- 本地链:Hardhat Network
先准备一个工作目录结构:
web3-graph-demo/
├─ contracts/
├─ scripts/
├─ subgraph/
└─ package.json
安装 Hardhat:
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
安装 The Graph 相关依赖:
npm install -g @graphprotocol/graph-cli
场景设计:做一个链上捐赠榜单
为了让例子足够实用,又不至于太复杂,我们实现一个简单的 DonationBoard 合约:
- 用户可以向合约捐赠 ETH
- 每次捐赠都记录事件
- 合约维护每个地址累计捐赠额
- The Graph 负责索引:
- 每一笔捐赠记录
- 每个捐赠者的累计金额
- 总捐赠统计
这个模式很常见,换成:
- NFT 交易记录
- DAO 投票明细
- 链游道具转移
- DeFi 存取款历史
思路都一样。
核心原理
先看整体数据流:
flowchart LR
A[用户发起交易] --> B[智能合约执行]
B --> C[事件 Event 写入区块日志]
C --> D[Graph Node 监听新区块]
D --> E[Mapping 解析事件]
E --> F[Subgraph Store]
F --> G[GraphQL 查询接口]
G --> H[前端/后端服务]
核心链路分成 3 层:
1. 智能合约层:定义“可索引信号”
The Graph 最擅长处理的是事件。
所以合约设计时,要明确哪些业务动作要通过 event 暴露出来。
例如:
DonationReceived(donor, amount, totalDonated, timestamp)
这样做的好处是:
- 事件结构稳定
- 查询成本低
- 历史回放方便
2. Subgraph 层:把事件映射成实体
Graph Node 会监听链上事件,并执行映射函数(mapping):
- 收到一条 Donation 事件
- 创建一条
Donation实体 - 更新
Donor实体的累计金额 - 更新
GlobalStat实体的总捐赠次数/总金额
可以理解成:
事件是输入流,实体是查询模型。
3. 查询层:使用 GraphQL 提供业务接口
最终前端不需要自己扫描日志,而是直接查询:
- 最近 10 笔捐赠
- 某地址所有捐赠记录
- 捐赠排行榜
这一步体验会很像查数据库。
一张图看懂实体关系
classDiagram
class Donation {
+id: Bytes
+donor: Donor
+amount: BigInt
+timestamp: BigInt
+txHash: Bytes
+blockNumber: BigInt
}
class Donor {
+id: Bytes
+totalAmount: BigInt
+donationCount: BigInt
+createdAt: BigInt
}
class GlobalStat {
+id: String
+totalAmount: BigInt
+totalDonations: BigInt
}
Donor "1" --> "*" Donation : has
GlobalStat --> Donation : aggregates
这里有个很重要的建模原则:
查询怎么用,实体就怎么设计。
不要把实体完全照着合约状态机械搬运。
你应该围绕“前端要怎么查”来建模。
实战代码(可运行)
下面我们从零搭起来。
第一步:编写智能合约
创建 contracts/DonationBoard.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract DonationBoard {
mapping(address => uint256) public donatedAmount;
uint256 public totalDonated;
event DonationReceived(
address indexed donor,
uint256 amount,
uint256 donorTotal,
uint256 totalDonated,
uint256 timestamp
);
function donate() external payable {
require(msg.value > 0, "amount must be > 0");
donatedAmount[msg.sender] += msg.value;
totalDonated += msg.value;
emit DonationReceived(
msg.sender,
msg.value,
donatedAmount[msg.sender],
totalDonated,
block.timestamp
);
}
function getDonatedAmount(address donor) external view returns (uint256) {
return donatedAmount[donor];
}
}
这个合约很简单,但足够体现索引思路:
mapping适合查某个地址累计值event适合索引历史记录和排行榜
第二步:部署脚本
创建 scripts/deploy.js:
const hre = require("hardhat");
async function main() {
const DonationBoard = await hre.ethers.getContractFactory("DonationBoard");
const donationBoard = await DonationBoard.deploy();
await donationBoard.waitForDeployment();
console.log("DonationBoard deployed to:", await donationBoard.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
启动本地链:
npx hardhat node
部署合约:
npx hardhat run scripts/deploy.js --network localhost
记下输出的合约地址,后面 subgraph 要用。
第三步:构造测试数据
创建 scripts/donate.js:
const hre = require("hardhat");
async function main() {
const contractAddress = "替换成你的合约地址";
const donationBoard = await hre.ethers.getContractAt("DonationBoard", contractAddress);
const [owner, user1, user2] = await hre.ethers.getSigners();
let tx;
tx = await donationBoard.connect(user1).donate({ value: hre.ethers.parseEther("1") });
await tx.wait();
tx = await donationBoard.connect(user2).donate({ value: hre.ethers.parseEther("2") });
await tx.wait();
tx = await donationBoard.connect(user1).donate({ value: hre.ethers.parseEther("0.5") });
await tx.wait();
console.log("donations sent");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行:
npx hardhat run scripts/donate.js --network localhost
到这里,链上已经有事件了。
第四步:启动本地 Graph Node
如果你想本地完整跑通,最方便的是用官方常见的 Docker 方式。
创建 docker-compose.yml:
version: '3'
services:
postgres:
image: postgres:14
ports:
- '5432:5432'
environment:
POSTGRES_PASSWORD: let-me-in
POSTGRES_USER: graph-node
POSTGRES_DB: graph-node
ipfs:
image: ipfs/kubo:latest
ports:
- '5001:5001'
graph-node:
image: graphprotocol/graph-node:latest
ports:
- '8000:8000'
- '8001:8001'
- '8020:8020'
- '8030:8030'
- '8040:8040'
depends_on:
- postgres
- ipfs
environment:
postgres_host: postgres
postgres_user: graph-node
postgres_pass: let-me-in
postgres_db: graph-node
ipfs: 'ipfs:5001'
ethereum: 'mainnet:http://host.docker.internal:8545'
GRAPH_LOG: info
启动:
docker compose up -d
这里有一个实际经验点:
如果你在 Linux 上,host.docker.internal 可能不可用,需要替换成宿主机实际 IP。
第五步:初始化 Subgraph
进入 subgraph 目录并初始化:
mkdir subgraph
cd subgraph
graph init --product hosted-service demo/donation-board
如果你只是本地跑,也可以手动创建必要文件。这里我直接给出一套最小可用版本。
1)定义 GraphQL Schema
创建 subgraph/schema.graphql:
type Donation @entity {
id: Bytes!
donor: Donor!
amount: BigInt!
timestamp: BigInt!
txHash: Bytes!
blockNumber: BigInt!
}
type Donor @entity {
id: Bytes!
totalAmount: BigInt!
donationCount: BigInt!
createdAt: BigInt!
donations: [Donation!]! @derivedFrom(field: "donor")
}
type GlobalStat @entity {
id: String!
totalAmount: BigInt!
totalDonations: BigInt!
}
2)定义 Subgraph Manifest
创建 subgraph/subgraph.yaml:
specVersion: 1.0.0
indexerHints:
prune: auto
schema:
file: ./schema.graphql
dataSources:
- kind: ethereum
name: DonationBoard
network: mainnet
source:
address: "替换成你的合约地址"
abi: DonationBoard
startBlock: 0
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
entities:
- Donation
- Donor
- GlobalStat
abis:
- name: DonationBoard
file: ./abis/DonationBoard.json
eventHandlers:
- event: DonationReceived(indexed address,uint256,uint256,uint256,uint256)
handler: handleDonationReceived
file: ./src/donation-board.ts
注意:这里的
network: mainnet只是 Graph Node 中给以太坊 endpoint 起的别名。
在我们的docker-compose.yml里,mainnet实际指向本地 Hardhat 节点。
3)复制 ABI
把 Hardhat 编译生成的 ABI 复制到:
mkdir -p abis
cp ../artifacts/contracts/DonationBoard.sol/DonationBoard.json ./abis/
如果你只想保留 ABI 内容,也可以手动裁剪出 abi 字段对应 JSON。
4)编写 Mapping
创建 subgraph/src/donation-board.ts:
import { BigInt } from "@graphprotocol/graph-ts";
import { DonationReceived } from "../generated/DonationBoard/DonationBoard";
import { Donation, Donor, GlobalStat } from "../generated/schema";
export function handleDonationReceived(event: DonationReceived): void {
let donation = new Donation(event.transaction.hash.concatI32(event.logIndex.toI32()));
donation.donor = event.params.donor;
donation.amount = event.params.amount;
donation.timestamp = event.params.timestamp;
donation.txHash = event.transaction.hash;
donation.blockNumber = event.block.number;
donation.save();
let donor = Donor.load(event.params.donor);
if (donor == null) {
donor = new Donor(event.params.donor);
donor.totalAmount = BigInt.zero();
donor.donationCount = BigInt.zero();
donor.createdAt = event.block.timestamp;
}
donor.totalAmount = donor.totalAmount.plus(event.params.amount);
donor.donationCount = donor.donationCount.plus(BigInt.fromI32(1));
donor.save();
let stat = GlobalStat.load("global");
if (stat == null) {
stat = new GlobalStat("global");
stat.totalAmount = BigInt.zero();
stat.totalDonations = BigInt.zero();
}
stat.totalAmount = stat.totalAmount.plus(event.params.amount);
stat.totalDonations = stat.totalDonations.plus(BigInt.fromI32(1));
stat.save();
}
这里我故意没有直接信任事件里的 donorTotal 和 totalDonated 去更新实体,而是使用索引侧自行累加。
原因很简单:索引逻辑要尽量保持可验证和可重放。如果业务复杂,字段该信事件还是自己算,要根据一致性要求权衡。
第六步:生成代码并构建
在 subgraph 目录中安装依赖:
npm init -y
npm install --save-dev @graphprotocol/graph-cli
npm install @graphprotocol/graph-ts
生成代码并构建:
graph codegen
graph build
如果成功,会生成 generated/ 和 build/ 目录。
第七步:创建并部署到本地 Graph Node
创建本地 subgraph:
graph create --node http://localhost:8020 demo/donation-board
部署:
graph deploy --node http://localhost:8020 --ipfs http://localhost:5001 demo/donation-board
如果一切正常,你会得到 GraphQL 查询地址,通常是:
http://localhost:8000/subgraphs/name/demo/donation-board
第八步:查询索引结果
查询最近捐赠记录
{
donations(first: 10, orderBy: timestamp, orderDirection: desc) {
id
amount
timestamp
txHash
donor {
id
totalAmount
donationCount
}
}
}
查询捐赠排行榜
{
donors(first: 10, orderBy: totalAmount, orderDirection: desc) {
id
totalAmount
donationCount
}
}
查询全局统计
{
globalStat(id: "global") {
totalAmount
totalDonations
}
}
一次事件处理的时序图
sequenceDiagram
participant U as 用户
participant C as DonationBoard
participant E as Event Log
participant G as Graph Node
participant M as Mapping
participant Q as GraphQL Client
U->>C: donate()
C->>E: emit DonationReceived
G->>E: 监听新区块与日志
G->>M: 调用 handleDonationReceived
M->>G: 保存 Donation/Donor/GlobalStat
Q->>G: GraphQL 查询
G-->>Q: 返回结构化索引数据
逐步验证清单
这部分非常实用,尤其适合你排查“为什么查不到数据”。
建议按这个顺序验证:
1. 合约事件是否真的发出了
用 Hardhat 控制台或区块浏览器看交易 receipt:
const tx = await donationBoard.connect(user1).donate({ value: ethers.parseEther("1") });
const receipt = await tx.wait();
console.log(receipt.logs);
2. ABI 里的事件签名是否匹配
subgraph.yaml 中的事件定义,必须和 Solidity 事件签名严格一致:
- event: DonationReceived(indexed address,uint256,uint256,uint256,uint256)
多一个空格、少一个 indexed,都可能导致 handler 不触发。
3. startBlock 是否太晚
如果你把 startBlock 设置成部署后很久的区块,而测试事件发生在它之前,就永远索引不到。
4. Graph Node 是否连上链
看日志:
docker logs -f <graph-node-container-id>
如果看到无法连接以太坊节点、区块高度不增长,先别看 mapping,先把基础设施连通性修好。
5. Mapping 是否抛错
AssemblyScript 报错时,实体不会正常入库。
Graph Node 日志中常见报错包括:
- 空值访问
- 类型不匹配
- 实体 ID 非法
- BigInt 处理不当
常见坑与排查
这是我觉得最值钱的一部分。很多教程都能“讲通”,但一跑就报错。
坑 1:事件签名不匹配
现象:
- 部署成功
- Graph Node 正常同步区块
- 但 handler 完全没被调用
排查:
重点检查:
- 参数顺序是否一致
indexed是否一致- 类型是否一致(
uint和uint256在 ABI 层面通常会展开,但最好保持一致)
建议:
直接从 ABI 自动生成事件声明,不要手敲。
坑 2:实体 ID 设计不合理
如果你写成:
let donation = new Donation(event.transaction.hash);
那同一笔交易里如果有多个同类日志,就会发生 ID 冲突。
更稳妥的写法是:
let donation = new Donation(event.transaction.hash.concatI32(event.logIndex.toI32()));
这样“交易哈希 + 日志索引”基本能保证唯一。
坑 3:把合约状态和索引状态混为一谈
很多人会问:
既然合约里已经有
totalDonated,为什么GlobalStat还要自己再维护一份?
答案是:索引模型服务于查询,不一定等于链上存储模型。
合约状态关注的是链上可验证和执行成本;
索引状态关注的是查询便利和聚合效率。
如果你把两者强行绑定,后续改查询需求会很难受。
坑 4:重组(Reorg)导致数据看起来“回退”
链不是绝对静态的,尤其在测试网更明显。
Graph Node 会根据链状态做回滚和重放。
现象:
- 你看到某条数据先出现,后来又消失
- 统计值短时间内变化异常
建议:
- 前端对“最新几个区块”的数据做弱最终性提示
- 关键业务使用确认块数(confirmations)
- 不要把“刚上链 1 个块”的结果直接当成最终账本
坑 5:BigInt / BigDecimal 精度处理错误
金额类字段不要随便转 number。
在链上和索引层都应尽量使用大整数。
如果你要展示 ETH:
- 链上和索引中存 wei
- 前端再格式化为
1.5 ETH
不要在 mapping 里为了“好看”提前转浮点数。
坑 6:本地 Docker 无法访问宿主机 RPC
现象:
Graph Node 启动了,但日志里一直连不上 http://host.docker.internal:8545
排查:
- macOS / Windows 通常没问题
- Linux 需要改成实际宿主机 IP
- 或者把 Hardhat 节点也放进 Docker 网络
这是本地调试里非常常见的环境问题,我当时第一次配本地 Graph Node,就卡在这里半天。
安全/性能最佳实践
索引服务虽然不直接持币,但一旦出错,前端看到的数据就会误导用户。所以它同样需要“工程化”。
一、智能合约层最佳实践
1. 关键业务动作必须有事件
如果你希望某个动作能被可靠索引,就不要只改状态不发事件。
尤其是:
- 创建订单
- 成交
- 转账
- 清算
- 投票
2. 事件字段要为查询服务
比如下面这些字段就很有价值:
indexed userindexed marketamounttimestampstatus
但也别滥加字段。事件过大也会增加 gas 成本。
3. 用事件表达“事实”,少表达“推导结果”
例如发出“用户存入了多少”、“谁和谁发生了交易”这类事实更稳。
一些容易变化的派生统计,不一定适合全塞进事件里。
二、Subgraph 层最佳实践
1. 实体设计面向查询,而不是照抄合约
比如:
- 历史记录单独一张表
- 用户聚合单独一张表
- 全局统计单独一张表
这样前端查询效率高很多。
2. Mapping 保持幂等和简单
Mapping 最好只做:
- 解析事件
- 读写实体
- 少量派生计算
不要塞入太复杂的业务逻辑。
越复杂,越难排查回放问题。
3. 尽量避免不必要的链上 call
The Graph 支持在 mapping 中调用合约 view 方法,但不建议滥用:
- 会拖慢索引速度
- 遇到历史状态差异更难排查
- 某些合约升级/代理模式下还会有兼容问题
优先使用事件数据完成索引。
三、查询层最佳实践
1. 永远做分页
不要写:
{
donations {
id
}
}
数据一大就会出问题。应使用:
{
donations(first: 20, orderBy: timestamp, orderDirection: desc) {
id
amount
}
}
2. 避免超深嵌套查询
GraphQL 很灵活,但也容易一把查太多层。
建议把列表查询和详情查询拆开。
3. 前端缓存“准静态数据”
比如排行榜、近 24 小时统计,不一定每秒都打 GraphQL。
合理加缓存能显著减轻查询压力。
什么时候适合 The Graph,什么时候不适合?
这点很重要,别一股脑全上。
适合的场景
- 事件驱动型业务
- 历史记录查询
- 排行榜、明细列表
- 按地址/资产/时间过滤
- 需要 GraphQL 统一对外接口
不太适合的场景
- 超高实时性、要求毫秒级强一致
- 大量跨链、多源复杂聚合且变更频繁
- 必须依赖大量链上 view call 才能得出结果
- 对查询逻辑有很重的定制化 OLAP 需求
这时你可能要考虑:
- 自建监听器 + PostgreSQL
- Kafka + ETL
- ClickHouse / Elasticsearch
- 专门的数据平台
The Graph 很强,但它不是万能数据库。
一个可落地的项目组织建议
如果你准备把它用到真实项目里,我建议目录分层清楚一些:
project/
├─ contracts/ # 合约
├─ deploy/ # 部署脚本
├─ subgraph/ # 索引定义
├─ apps/web/ # 前端
├─ packages/sdk/ # 查询封装、类型定义
└─ docs/ # ABI、地址、版本记录
特别是下面两点很关键:
- ABI 版本和合约地址要留档
- Subgraph 版本要和合约版本对应
否则后面合约升级、事件变更时,排查会非常痛苦。
总结
这篇文章我们做了一条完整链路:
- 用 Solidity 写了一个带事件的捐赠合约
- 在本地链部署并制造测试数据
- 用 The Graph 建立
Donation / Donor / GlobalStat三类实体 - 通过 mapping 把事件转换成可查询数据
- 用 GraphQL 实现历史记录、排行榜、全局统计查询
- 讨论了事件签名、实体 ID、reorg、BigInt、环境连通性等常见坑
如果你想把它真正用到业务里,我给你几个可执行建议:
- 先设计事件,再写 subgraph,不要反过来。
- 实体建模围绕查询需求,而不是围绕合约存储布局。
- 历史记录和聚合统计分开建模。
- 本地先跑通最小闭环:1 个合约、1 个事件、1 个实体、1 条查询。
- 对最新区块数据保留“非最终确认”的认知,不要把索引结果当成绝对即时真相。
边界条件也要记住:
- 如果你的需求主要是“查某个单一状态”,直接 RPC 可能更简单
- 如果你的需求是“历史+筛选+分页+聚合”,The Graph 会非常顺手
- 如果你要做超复杂分析型查询,可能需要 The Graph 之外的数据系统配合
一句话总结:
智能合约负责可信执行,The Graph 负责高效可查;把两者配好,链上应用才真的“能用”。
如果你已经能跑通这篇的例子,下一步很自然就是把场景替换成你自己的业务事件:订单、NFT、投票、质押、清算,本质都是同一套方法。