背景
上个月,我在给一个跨链DeFi协议做前端仪表盘。需求很简单:用户登录后,能看到他在以太坊、Polygon和Arbitrum三条链上的所有交易记录、当前质押的LP代币数量和历史收益。最开始我直接用ethers.js的provider.getLogs和getBalance去拉数据,结果发现三个问题:
- 每次切换链都要等5-10秒才能刷出数据,用户体验极差
- 用户交易一多(超过1000条),前端直接卡死,因为RPC节点一次只返回2000条日志,我得写递归去翻页
- 以太坊主网的RPC限流,免费Infura节点一分钟只能请求100次,用户多的时候直接报429
我当时想:不行,必须换个方案。正好团队后端同事提了一嘴The Graph,说可以自己搭子图来索引链上数据。我一开始以为就是把RPC换成GraphQL调用,结果一上手才发现水有多深——从子图定义、映射器写法到前端分页和实时更新,每个环节都有坑。
这篇文章就是我把整个流程走通后的完整记录,希望能帮到同样被链上数据查询折磨的你。
问题分析
最初的思路:直接用RPC + 前端缓存
我的第一版方案是:用ethers.js的getLogs拉取所有Transfer事件,然后在浏览器用localStorage缓存。但很快发现:
- 以太坊主网一个地址可能有几千笔交易,
getLogs一次最多返回2000条,我得写递归循环,每页等1-2秒 - 跨链时,每个链的RPC节点不同,缓存逻辑要分开写,代码变得极其臃肿
- 最致命的是:历史收益数据(比如用户某天质押了多少LP)需要聚合计算,RPC返回的是原始事件,我得在前端做大量运算,导致页面卡顿
为什么选择The Graph
The Graph本质上是把链上事件索引到PostgreSQL数据库里,然后通过GraphQL接口提供查询。好处是:
- 索引完的数据查询速度在毫秒级,比RPC快10倍以上
- 支持复杂过滤和聚合计算(比如按时间范围统计),这些运算在子图层面完成,前端只需拿结果
- 有托管服务(Hosted Service)和去中心化网络,不需要自己维护服务器
但坏处是:需要写子图定义(schema.graphql)和映射器(mapping.ts),对前端开发者来说是个新领域。
核心实现:从子图搭建到前端查询
第一步:搭建本地子图开发环境
我第一次踩的坑就是直接在Hosted Service上部署,结果每次改映射器都要等10分钟同步。后来发现应该先在本地跑Graph Node。
# 安装Graph CLI
npm install -g @graphprotocol/graph-cli
# 初始化子图项目(选择以太坊主网)
graph init --product subgraph-studio
# 输入子图名称、合约地址等
初始化后生成的项目结构:
my-subgraph/
├── schema.graphql # 定义数据模型
├── src/
│ └── mapping.ts # 事件处理逻辑
├── subgraph.yaml # 配置文件
└── package.json
这里有个坑:graph init会让你选网络,如果选mainnet,它会自动用以太坊主网的RPC。但本地开发时最好用hardhat或ganache本地节点,或者用测试网。我当时选了mainnet,结果第一次同步花了3小时,因为要扫描整个链的历史事件。
最终方案:用graph init --product subgraph-studio后,手动修改subgraph.yaml里的dataSources.source.address和network为测试网地址,比如Goerli。
第二步:定义数据模型(schema.graphql)
我的需求是记录用户的交易和质押信息。模型设计直接决定了查询效率。
# schema.graphql
type User @entity {
id: ID! # 用户地址
transactions: [Transaction!] @derivedFrom(field: "user")
totalStaked: BigInt!
totalRewards: BigInt!
lastUpdated: BigInt!
}
type Transaction @entity {
id: ID! # 交易哈希
user: User!
type: String! # "deposit", "withdraw", "swap"
amount: BigInt!
token: Bytes!
timestamp: BigInt!
blockNumber: Int!
}
type DailyStats @entity {
id: ID! # 格式: "userAddress-dayTimestamp"
user: User!
date: BigInt!
depositCount: Int!
withdrawCount: Int!
totalVolume: BigInt!
}
设计思路:
User实体是核心,关联transactions和DailyStatsDailyStats用userAddress-dayTimestamp作为ID,这样查询某用户某天的统计时直接get即可- 所有时间字段用
BigInt存储(solidity的uint256),前端再转成Date
第三步:编写映射器(mapping.ts)
映射器是子图的核心,把链上事件转换成数据模型。这里我踩了一个大坑:映射器里不能做异步操作,比如不能用fetch请求外部API。
// src/mapping.ts
import { BigInt, Bytes } from "@graphprotocol/graph-ts"
import {
Transfer,
Deposit,
Withdraw
} from "../generated/MyContract/MyContract"
import { User, Transaction, DailyStats } from "../generated/schema"
export function handleTransfer(event: Transfer): void {
// 更新发送方和接收方的余额
updateUserBalance(event.params.from, event.params.value.neg())
updateUserBalance(event.params.to, event.params.value)
// 记录交易
let transaction = new Transaction(event.transaction.hash.toHex())
transaction.user = event.params.from
transaction.type = "transfer"
transaction.amount = event.params.value
transaction.token = event.address
transaction.timestamp = event.block.timestamp
transaction.blockNumber = event.block.number.toI32()
transaction.save()
}
function updateUserBalance(address: Bytes, amount: BigInt): void {
let userId = address.toHex()
let user = User.load(userId)
if (user == null) {
user = new User(userId)
user.totalStaked = BigInt.fromI32(0)
user.totalRewards = BigInt.fromI32(0)
user.lastUpdated = BigInt.fromI32(0)
}
user.totalStaked = user.totalStaked.plus(amount)
user.lastUpdated = event.block.timestamp
user.save()
}
这里有个坑:User.load()和User.save()在映射器里是同步的,但每次调用都会产生数据库读写。如果一笔交易涉及多个用户(比如Transfer事件有from和to),要避免重复加载同一个用户。我一开始没注意,导致同一个用户被加载两次,数据覆盖了。
解决办法:在handleTransfer里先检查两个地址是否相同,如果相同(自己转给自己),只更新一次。
第四步:部署子图并获取API URL
本地测试通过后,部署到The Graph的托管服务:
# 1. 在The Graph Studio创建子图
# 2. 获取部署密钥
graph auth --product subgraph-studio <YOUR_KEY>
# 3. 部署
graph deploy --product subgraph-studio <SUBGRAPH_NAME>
部署成功后,会得到一个API URL,类似:
https://api.studio.thegraph.com/query/12345/my-subgraph/v0.0.1
注意:每次部署都会生成新版本,前端用的URL要更新版本号。我建议在环境变量里配置,方便切换。
第五步:前端接入Apollo Client
前端我用的是React + TypeScript + Apollo Client。这里有个关键点:Apollo默认的缓存策略会导致数据不更新,因为子图索引有延迟(通常几秒到几分钟)。
// graphql/queries.ts
import { gql } from "@apollo/client"
// 查询用户交易记录,支持分页
export const GET_USER_TRANSACTIONS = gql`
query GetUserTransactions(
$user: String!
$first: Int!
$skip: Int!
$orderDirection: String!
) {
transactions(
where: { user: $user }
first: $first
skip: $skip
orderBy: timestamp
orderDirection: $orderDirection
) {
id
type
amount
token
timestamp
blockNumber
}
}
`
// 查询用户每日统计
export const GET_USER_DAILY_STATS = gql`
query GetUserDailyStats(
$user: String!
$fromDate: BigInt!
$toDate: BigInt!
) {
dailyStats(
where: {
user: $user
date_gte: $fromDate
date_lte: $toDate
}
orderBy: date
orderDirection: asc
) {
id
date
depositCount
withdrawCount
totalVolume
}
}
`
// hooks/useTransactions.ts
import { useQuery } from "@apollo/client"
import { GET_USER_TRANSACTIONS } from "../graphql/queries"
export function useTransactions(userAddress: string, page: number, pageSize: number = 20) {
const { data, loading, error, refetch } = useQuery(GET_USER_TRANSACTIONS, {
variables: {
user: userAddress.toLowerCase(), // 注意:地址必须小写!
first: pageSize,
skip: (page - 1) * pageSize,
orderDirection: "desc"
},
// 关键:设置轮询,每30秒刷新一次,应对子图索引延迟
pollInterval: 30000,
// 关闭缓存,保证每次查询都从网络获取最新数据
fetchPolicy: "network-only"
})
return {
transactions: data?.transactions || [],
loading,
error,
refetch
}
}
这里有个坑:The Graph的查询中,地址字段必须是小写。如果用户输入的地址是大写或有校验和(EIP-55),查询会返回空结果。我一开始没做.toLowerCase(),debug了两小时才发现。
第六步:处理索引延迟和实时更新
子图索引不是实时的,通常有2-30秒的延迟。这意味着用户刚发起一笔交易,前端可能查不到。
我的解决方案是混合策略:
- 对于历史数据(超过1分钟的交易),直接走The Graph查询
- 对于刚发生的交易(用户通过钱包确认后),先用ethers.js监听事件,等确认后再触发子图刷新
// hooks/useRealtimeTransactions.ts
import { useContractEvent } from "wagmi"
import { useTransactions } from "./useTransactions"
export function useRealtimeTransactions(userAddress: string) {
const { transactions, loading, refetch } = useTransactions(userAddress, 1, 50)
// 监听合约的Transfer事件
useContractEvent({
address: contractAddress,
abi: contractABI,
eventName: "Transfer",
listener(from, to, value) {
// 如果事件涉及当前用户,触发子图刷新
if (from.toLowerCase() === userAddress.toLowerCase() ||
to.toLowerCase() === userAddress.toLowerCase()) {
// 延迟3秒,给子图索引留时间
setTimeout(() => refetch(), 3000)
}
},
})
return { transactions, loading }
}
完整代码(可直接复制运行)
以下是一个完整的React组件,展示用户交易列表,支持分页和实时更新:
// components/TransactionList.tsx
import React, { useState } from "react"
import { useAccount } from "wagmi"
import { useRealtimeTransactions } from "../hooks/useRealtimeTransactions"
import { formatEther } from "ethers/lib/utils"
const PAGE_SIZE = 20
export function TransactionList() {
const { address } = useAccount()
const [page, setPage] = useState(1)
const { transactions, loading } = useRealtimeTransactions(address || "")
if (!address) return <p>请连接钱包</p>
if (loading) return <p>加载中...</p>
const totalPages = Math.ceil(transactions.length / PAGE_SIZE)
const pageTransactions = transactions.slice(
(page - 1) * PAGE_SIZE,
page * PAGE_SIZE
)
return (
<div>
<h2>交易记录</h2>
<table>
<thead>
<tr>
<th>类型</th>
<th>金额</th>
<th>时间</th>
<th>区块</th>
</tr>
</thead>
<tbody>
{pageTransactions.map((tx) => (
<tr key={tx.id}>
<td>{tx.type}</td>
<td>{formatEther(tx.amount)}</td>
<td>{new Date(tx.timestamp * 1000).toLocaleString()}</td>
<td>{tx.blockNumber}</td>
</tr>
))}
</tbody>
</table>
<div>
<button
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
上一页
</button>
<span>第 {page} / {totalPages} 页</span>
<button
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
>
下一页
</button>
</div>
</div>
)
}
踩坑记录
坑1:子图部署后数据为0
现象:部署成功,GraphQL查询能返回实体结构,但所有数据都是空的。
原因:子图配置文件subgraph.yaml里的startBlock设置得太早,合约在那个区块还没部署。或者eventHandlers里的事件签名写错了。
解决:检查startBlock是否大于等于合约部署区块,用graph codegen重新生成类型,然后重新部署。
坑2:Apollo查询返回null但GraphQL Playground正常
现象:在The Graph Studio的Playground里查询正常,但前端Apollo返回null。
原因:Apollo的缓存策略。默认是cache-first,如果之前缓存过相同变量的查询,它不会重新请求网络。
解决:设置fetchPolicy: "network-only",或者每次查询时加一个随机变量(比如timestamp)来绕过缓存。
坑3:地址大小写导致查询失败
现象:用户输入0xAbC...,查询无结果。但Playground里用小写可以。
原因:The Graph的字符串比较是大小写敏感的,而以太坊地址的校验和格式(EIP-55)包含大小写。
解决:前端所有地址在传入查询前统一.toLowerCase()。子图映射器里存储地址时也要用小写。
坑4:映射器里循环调用save导致超时
现象:一笔交易涉及多个用户(比如批量转账),映射器执行超过50ms,子图索引报错。
原因:映射器有执行时间限制(AssemblyScript环境),循环里多次调用save()会累积时间。
解决:尽量合并写操作。比如批量转账事件,先收集所有用户更新,然后在事件处理函数最后一次性调用save()。或者用store.set()替代entity.save(),性能更好。
小结
用The Graph做链上数据查询,核心是把计算压力从前端转移到索引层。子图的schema设计要围绕查询场景来,不要试图把所有数据都塞进去。如果你需要更实时的数据(秒级),可以考虑结合ethers.js的事件监听做混合方案。下一步可以研究如何用The Graph的去中心化网络(Decentralized Network)替换托管服务,避免单点故障。