阅读视图

发现新文章,点击刷新页面。

被The Graph的GraphQL查询坑了三天,我用一个真实DeFi项目把链上数据索引彻底搞懂了

背景

上个月,我在给一个跨链DeFi协议做前端仪表盘。需求很简单:用户登录后,能看到他在以太坊、Polygon和Arbitrum三条链上的所有交易记录、当前质押的LP代币数量和历史收益。最开始我直接用ethers.js的provider.getLogsgetBalance去拉数据,结果发现三个问题:

  1. 每次切换链都要等5-10秒才能刷出数据,用户体验极差
  2. 用户交易一多(超过1000条),前端直接卡死,因为RPC节点一次只返回2000条日志,我得写递归去翻页
  3. 以太坊主网的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。但本地开发时最好用hardhatganache本地节点,或者用测试网。我当时选了mainnet,结果第一次同步花了3小时,因为要扫描整个链的历史事件。

最终方案:用graph init --product subgraph-studio后,手动修改subgraph.yaml里的dataSources.source.addressnetwork为测试网地址,比如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实体是核心,关联transactionsDailyStats
  • DailyStatsuserAddress-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. 对于历史数据(超过1分钟的交易),直接走The Graph查询
  2. 对于刚发生的交易(用户通过钱包确认后),先用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)替换托管服务,避免单点故障。

❌