跳到主要内容

Web3 数据查询

链上数据查询的挑战

区块链是一个只追加的数据结构,所有状态变更都通过交易写入区块。前端要查询链上数据,面临三大核心挑战:

挑战说明举例
RPC 限制节点 RPC 只提供基础查询能力,无法做复杂聚合无法一次查出"某地址持有的所有 NFT"
历史数据索引区块链不是数据库,没有通用索引查某 Token 过去 30 天的转账记录需要逐区块扫描
复杂查询困难RPC 不支持 JOIN、GROUP BY 等操作统计 DEX 过去 24 小时的交易量需要自建索引
面试速答版

为什么链上查询难?怎么解? RPC 节点接口只能做基础查询,不会有 JOIN/聚合:

  • 「实时余额、当前价格」走 RPC + Multicall(一笔请求批量打多个 view 函数,省往返)。
  • 「历史交易、Token 持有者列表、过去 24h 交易量」必须靠索引服务,否则得逐区块扫日志。

The Graph / Subgraph 是什么? 最常用的去中心化索引方案:

  • 你写一个 Subgraph:声明监听哪些合约的哪些事件,怎么把事件映射成 GraphQL 实体。
  • 部署到 The Graph 网络后,前端就能用 GraphQL 查询「过去一周成交量 Top10 池子」之类的复杂数据。
  • 收费按查询次数走 GRT 代币;自托管也行(graph-node)。

还有哪些数据查询路径?

  • Etherscan/BscScan API:现成 REST,免费档够小项目用,但不通用、不去中心化。
  • Alchemy/Moralis/QuickNode 增强 API:提供「某地址持有的所有 NFT/Token」「交易历史」等开箱即用接口。
  • Dune Analytics:SQL 查链上数据做仪表盘,分析师友好。
  • Multicall3:一次 RPC 调 N 个 view 函数;Wagmi 的 useReadContracts 默认就用它。

面试速答版
  • Web3 数据查询:公共 RPC 随时可能限流或下线,生产环境务必使用付费 RPC 服务或自建节点。核心要点可以围绕 公共RPCvs付费服务、RPC方法分类、请求优化:批量请求与WebSocket、什么是Subgraph 展开,并补充边界情况、复杂度或适用场景。

RPC 节点服务

公共 RPC vs 付费服务

特性公共 RPCAlchemy / Infura / QuickNode
价格免费免费额度 + 付费套餐
速率限制严格(通常 <10 次/秒)宽松(数百次/秒)
可靠性不稳定,高峰期丢请求SLA 保障,99.9%+
归档节点通常不支持支持查询历史状态
增强 APINFT API、Token API、Webhook 等
公共 RPC 仅适合开发测试

公共 RPC 随时可能限流或下线,生产环境务必使用付费 RPC 服务或自建节点。

RPC 方法分类

常用 RPC 方法示例
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';

const client = createPublicClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
});

// 1. 读取链状态
const balance = await client.getBalance({
address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
});

// 2. 调用合约只读方法(eth_call)
const totalSupply = await client.readContract({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
abi: erc20Abi,
functionName: 'totalSupply',
});

// 3. 查询事件日志(eth_getLogs)
const logs = await client.getLogs({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
event: transferEvent,
fromBlock: 19000000n,
toBlock: 19000100n,
});

请求优化:批量请求与 WebSocket

批量 JSON-RPC 请求
const client = createPublicClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY', {
batch: true, // viem 自动合并同一 tick 内的请求
}),
});

// 以下三个请求会合并为一次 HTTP 请求
const [balance, blockNumber, gasPrice] = await Promise.all([
client.getBalance({ address }),
client.getBlockNumber(),
client.getGasPrice(),
]);
WebSocket 实时订阅
import { createPublicClient, webSocket } from 'viem';
import { mainnet } from 'viem/chains';

const wsClient = createPublicClient({
chain: mainnet,
transport: webSocket('wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
});

// 监听新区块
wsClient.watchBlockNumber({
onBlockNumber: (blockNumber) => console.log('新区块:', blockNumber),
});

// 监听合约事件
wsClient.watchContractEvent({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
abi: erc20Abi,
eventName: 'Transfer',
onLogs: (logs) => console.log('USDC 转账:', logs),
});
WebSocket vs 轮询

WebSocket 延迟 1-2 秒,轮询至少有 N 秒延迟。DEX 价格更新等实时场景优先用 WebSocket。


The Graph 协议

什么是 Subgraph

The Graph 是一个去中心化的链上数据索引协议,它将链上事件索引到 PostgreSQL 数据库中,并通过 GraphQL API 对外提供查询服务。

开发者编写 Subgraph(子图)来定义要监听的合约事件,以及如何将事件映射为结构化实体。

Subgraph 架构组成

  • Schemaschema.graphql):定义数据模型
  • Manifestsubgraph.yaml):声明监听的合约、事件、处理函数
  • Mappingsrc/mapping.ts):事件处理逻辑(AssemblyScript)
schema.graphql - 定义 Token 转账实体
type Transfer @entity {
id: Bytes!
from: Bytes!
to: Bytes!
value: BigInt!
blockNumber: BigInt!
blockTimestamp: BigInt!
transactionHash: Bytes!
}

前端查询 Subgraph

使用 urql 查询 Subgraph
import { Client, cacheExchange, fetchExchange, gql } from 'urql';

const subgraphClient = new Client({
url: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3',
exchanges: [cacheExchange, fetchExchange],
});

const POOLS_QUERY = gql`
query TopPools($first: Int!, $skip: Int!) {
pools(
first: $first, skip: $skip
orderBy: volumeUSD, orderDirection: desc
where: { volumeUSD_gt: "1000000" }
) {
id
token0 { symbol }
token1 { symbol }
volumeUSD
totalValueLockedUSD
}
}
`;

const { data } = await subgraphClient
.query(POOLS_QUERY, { first: 10, skip: 0 })
.toPromise();

Hosted Service vs 去中心化网络

The Graph 已从 Hosted Service(集中托管、免费)迁移到去中心化网络(Subgraph Studio),查询按量付费(GRT 代币),但获得了多节点冗余的高可用保障。


Multicall 批量查询

原理

Multicall 是一个部署在链上的聚合合约,它将多个合约调用打包到一次 RPC 请求中执行,大幅减少网络往返次数。

viem 内置 Multicall

viem 的 readContractgetBalance 等方法在 multicall: true(默认开启)时,会自动使用 Multicall 合约聚合请求:

viem multicall - 批量查余额
const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const addresses = ['0xAddr1...', '0xAddr2...', '0xAddr3...'];

// 以下多个 readContract 会自动聚合为一次 Multicall 请求
const balances = await Promise.all(
addresses.map((address) =>
client.readContract({
address: USDC,
abi: erc20Abi,
functionName: 'balanceOf',
args: [address],
})
)
);
显式 multicall - 查询多种数据
const results = await client.multicall({
contracts: [
{ address: USDC, abi: erc20Abi, functionName: 'totalSupply' },
{ address: USDC, abi: erc20Abi, functionName: 'balanceOf', args: ['0xAddr1...'] },
{ address: WETH, abi: erc20Abi, functionName: 'decimals' },
],
});
// results: [{ result: 1000000n, status: 'success' }, ...]
Multicall 使用场景
  • 批量查多个地址的 Token 余额
  • 批量查 NFT 集合中多个 tokenId 的 metadata
  • 仪表盘一次性加载多个合约的状态数据

事件日志查询

eth_getLogs 与 Filter

链上事件(Event / Log)是前端获取历史数据的重要途径。合约通过 emit 触发事件,这些事件记录在交易收据中,可通过 eth_getLogs 查询。

查询 ERC-20 Transfer 事件
import { parseAbiItem } from 'viem';

const transferEvent = parseAbiItem(
'event Transfer(address indexed from, address indexed to, uint256 value)'
);

const logs = await client.getLogs({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
event: transferEvent,
args: {
// indexed 参数可用于过滤
from: '0xSenderAddress...',
},
fromBlock: 19000000n,
toBlock: 19001000n,
});

logs.forEach((log) => {
console.log(`${log.args.from}${log.args.to}: ${log.args.value}`);
});

区块范围限制与分页策略

大多数 RPC 服务对 eth_getLogs 有区块范围限制(通常 2000-10000 个区块)。查询大范围历史数据时需要分页扫描

分页查询大范围事件日志
async function getAllLogs(
client: PublicClient,
params: { address: `0x${string}`; event: AbiEvent; fromBlock: bigint; toBlock: bigint },
chunkSize = 2000n
) {
const allLogs = [];
let current = params.fromBlock;

while (current <= params.toBlock) {
const chunkEnd = current + chunkSize - 1n > params.toBlock
? params.toBlock
: current + chunkSize - 1n;
const logs = await client.getLogs({ ...params, fromBlock: current, toBlock: chunkEnd });
allLogs.push(...logs);
current = chunkEnd + 1n;
}
return allLogs;
}
历史数据优先用索引服务

扫描数万区块耗时且消耗大量 RPC 配额,历史数据查询优先使用 The Graph 或 Etherscan API。


其他数据源

数据源适用场景特点
Etherscan API交易历史、合约 ABI、Token 转账记录免费额度 5 次/秒,数据全面
Alchemy Enhanced APINFT 元数据、Token 余额、交易历史单一 API 返回丰富的结构化数据
MoralisNFT API、DeFi 数据、跨链查询SDK 友好,支持多链
Dune Analytics链上数据分析、仪表盘用 SQL 查链上数据,适合分析场景
Covalent统一多链数据 API单一 API 覆盖 100+ 链
Alchemy NFT API 示例
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/nft/v3/${ALCHEMY_KEY}/getNFTsForOwner?owner=${address}`
);
const { ownedNfts } = await response.json();
// ownedNfts: [{ contract: { name }, tokenId, name, image }, ...]

前端数据缓存策略

链上数据有一个天然特性:同一区块号下,数据是确定性的。利用这一点可以构建高效的缓存策略。

TanStack Query + 区块号缓存
import { useQuery } from '@tanstack/react-query';
import { useBlockNumber } from 'wagmi';

function useTokenBalance(tokenAddress: string, userAddress: string) {
const { data: blockNumber } = useBlockNumber({ watch: true });

return useQuery({
queryKey: ['tokenBalance', tokenAddress, userAddress, blockNumber],
queryFn: () =>
client.readContract({
address: tokenAddress, abi: erc20Abi,
functionName: 'balanceOf', args: [userAddress],
}),
enabled: !!blockNumber,
staleTime: 12_000, // 12 秒(一个区块时间)
});
}
缓存策略要点
  • 区块号作为缓存键:区块号变化时自动使旧缓存失效
  • staleTime 设为区块时间:以太坊约 12 秒出块,在此期间数据不会变
  • WebSocket 监听新块:新块到来时触发相关查询刷新
  • Multicall 聚合:减少 RPC 请求数,降低配额消耗

更多关于链上交互的内容,参考 ethers.js 与 viem智能合约交互


常见面试问题

Q1: 前端查询链上数据有哪些方式?

答案

方式优点缺点适用场景
RPC实时性最高查询能力有限余额、allowance
The Graph复杂查询、分页排序有索引延迟历史数据、统计
Etherscan API开箱即用中心化、速率限制交易历史、合约 ABI
Alchemy / MoralisAPI 丰富付费、厂商锁定NFT、Token 数据

Q2: 什么是 Multicall?解决了什么问题?

答案

Multicall 是一个部署在链上的聚合合约,它允许在一次 RPC 请求中批量执行多个合约的只读调用。核心解决的问题是 N 次 RPC 请求合并为 1 次,减少网络往返和 RPC 配额消耗。典型场景包括批量查询多个地址的 Token 余额、加载仪表盘数据等。viem 默认内置了 Multicall 支持,调用多个 readContract 时会自动聚合。

Q3: The Graph 的 Subgraph 是如何工作的?

答案

开发者在 subgraph.yaml 中声明监听的合约和事件,在 schema.graphql 中定义实体模型,在 mapping.ts(AssemblyScript)中编写事件到实体的映射逻辑。部署后,Graph Node 持续监听链上新区块,执行 Mapping 处理器将数据写入 PostgreSQL,前端通过 GraphQL API 查询,支持分页、排序、过滤等复杂操作。

Q4: eth_getLogs 查询大范围区块时如何处理?

答案

RPC 服务限制单次查询的区块范围(通常 2000-10000 块),需分块查询:将大范围拆成多个小范围请求后合并结果。高频场景建议用 The Graph 索引历史事件而非直接扫链。

Q5: WebSocket 订阅和轮询各自适用于什么场景?

答案

WebSocket:实时性要求高的场景(DEX 价格更新、交易确认通知),延迟 1-2 秒,但需维护长连接。轮询:实时性要求不高的场景(余额刷新),实现简单但有轮询间隔的固有延迟。

Q6: 前端如何设计链上数据的缓存策略?

答案

核心是利用区块确定性(同一区块号下数据不变):用 TanStack Query/SWR 将区块号纳入缓存键,staleTime 设为一个出块时间(以太坊约 12 秒),通过 WebSocket 监听新区块触发刷新。对不常变化的数据(Token 符号、小数位数)可设更长缓存。

Q7: Etherscan API 和 The Graph 的主要区别?

答案

Etherscan 是中心化 REST API,提供预定义的通用数据端点(交易历史、合约 ABI),适合快速取用。The Graph 是去中心化 GraphQL 服务,开发者自定义 Schema 和索引逻辑,灵活性更高,适合构建 DApp 所需的定制化查询。

Q8: 如何在前端高效展示大量链上数据(如 Token 列表)?

答案

  1. Multicall 批量查询:一次请求获取多个 Token 的余额和元信息
  2. The Graph 或增强 API:获取结构化列表(带排序、过滤)
  3. TanStack Query 缓存:区块号作为失效依据
  4. 虚拟列表渲染react-window 只渲染可视区域
  5. 渐进式加载:先显示基础信息,再异步加载价格等辅助数据

相关链接