跳转到内容
123xiao | 无名键客

《Web3 中级实战:基于智能合约与 The Graph 构建链上数据索引查询服务》

字数: 0 阅读时长: 1 分钟

Web3 中级实战:基于智能合约与 The Graph 构建链上数据索引查询服务

很多人刚接触链上应用时,会有一个非常直观的疑问:

合约数据明明都在链上,为什么还要额外做“索引服务”?

我自己第一次做 DApp 后台时,也踩进过这个坑:前端直接用 ethers.js 去扫事件,测试网数据少时一切正常;一旦合约跑了几天、用户多了、事件量大了,查询立刻变慢,分页困难,聚合统计也很痛苦。你会发现,“链上可读”不等于“适合业务查询”。

这篇文章我们就从一个中级实战角度,做一个完整的小项目:

  • 写一个简单的 Solidity 智能合约
  • 通过事件把关键业务状态暴露出来
  • 用 The Graph 建立索引
  • 用 GraphQL 查询链上数据
  • 讨论常见坑、排查思路,以及安全与性能最佳实践

这不是“概念介绍”,而是带你真正跑通一遍。


背景与问题

链上状态查询有几个天然难点:

  1. RPC 查询更偏底层

    • 适合按合约方法读状态
    • 不适合复杂筛选、排序、分页、聚合
  2. 事件日志天然适合做“历史记录”

    • 比如谁何时创建了订单、谁买了多少、累计交易额多少
    • 但链本身不提供高层次检索接口
  3. 业务前端通常需要“类数据库”体验

    • 按用户筛选
    • 按时间倒序分页
    • 查询某资产最近 20 条交易
    • 汇总统计某个地址的交易次数和成交额

如果只靠 RPC + 前端本地处理,通常会出现:

  • 首屏慢
  • 数据不完整
  • 难分页
  • 历史回放复杂
  • 重组(reorg)时数据容易错乱

所以,The Graph 的核心价值就是:
把链上事件和状态,转换成一个可被 GraphQL 查询的索引数据库。


前置知识

在开始之前,建议你至少熟悉:

  • Solidity 基础语法
  • EVM 事件(event)机制
  • Node.js / npm
  • GraphQL 基础查询语法
  • 使用 ethers.jsviem 进行合约交互

如果你已经写过简单合约、也能部署到本地链或测试网,那这篇内容会比较顺。


环境准备

本文示例使用以下技术栈:

  • 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();
}

这里我故意没有直接信任事件里的 donorTotaltotalDonated 去更新实体,而是使用索引侧自行累加。
原因很简单:索引逻辑要尽量保持可验证和可重放。如果业务复杂,字段该信事件还是自己算,要根据一致性要求权衡。


第六步:生成代码并构建

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 是否一致
  • 类型是否一致(uintuint256 在 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 user
  • indexed market
  • amount
  • timestamp
  • status

但也别滥加字段。事件过大也会增加 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、地址、版本记录

特别是下面两点很关键:

  1. ABI 版本和合约地址要留档
  2. Subgraph 版本要和合约版本对应

否则后面合约升级、事件变更时,排查会非常痛苦。


总结

这篇文章我们做了一条完整链路:

  1. 用 Solidity 写了一个带事件的捐赠合约
  2. 在本地链部署并制造测试数据
  3. 用 The Graph 建立 Donation / Donor / GlobalStat 三类实体
  4. 通过 mapping 把事件转换成可查询数据
  5. 用 GraphQL 实现历史记录、排行榜、全局统计查询
  6. 讨论了事件签名、实体 ID、reorg、BigInt、环境连通性等常见坑

如果你想把它真正用到业务里,我给你几个可执行建议:

  • 先设计事件,再写 subgraph,不要反过来。
  • 实体建模围绕查询需求,而不是围绕合约存储布局。
  • 历史记录和聚合统计分开建模。
  • 本地先跑通最小闭环:1 个合约、1 个事件、1 个实体、1 条查询。
  • 对最新区块数据保留“非最终确认”的认知,不要把索引结果当成绝对即时真相。

边界条件也要记住:

  • 如果你的需求主要是“查某个单一状态”,直接 RPC 可能更简单
  • 如果你的需求是“历史+筛选+分页+聚合”,The Graph 会非常顺手
  • 如果你要做超复杂分析型查询,可能需要 The Graph 之外的数据系统配合

一句话总结:

智能合约负责可信执行,The Graph 负责高效可查;把两者配好,链上应用才真的“能用”。

如果你已经能跑通这篇的例子,下一步很自然就是把场景替换成你自己的业务事件:订单、NFT、投票、质押、清算,本质都是同一套方法。


分享到:

上一篇
《微服务架构中基于服务网格的灰度发布与流量治理实战指南》
下一篇
《自动化测试中的测试数据管理实战:从数据构造、隔离到回收的可复用方案》