跳至内容
·5 分钟阅读

生产环境中的链上数据:那些没人告诉你的事

区块链数据既不干净,也不可靠,更不容易处理。RPC 限流、链重组、BigInt 精度丢失、索引方案的取舍——来自实际交付 DeFi 产品的惨痛教训。

分享:X / TwitterLinkedIn

有这么一种幻想:链上数据天生就是可信的。不可变的账本。透明的状态。读一下就完事了。

我以前也这么信。然后我把一个 DeFi 看板上了生产环境,花了三周时间搞清楚为什么我们的代币余额是错的、事件历史有缺口、数据库里还存着来自已经不存在的区块的交易。

链上数据是原始的、充满敌意的,充斥着各种边缘情况,会以你察觉不到的方式破坏你的应用——直到用户提交了一个 bug 报告。这篇文章涵盖了我用血泪换来的所有经验。

数据可靠性的幻觉#

第一件没人告诉你的事是:区块链不会给你数据。它给你的是状态转换。没有 SELECT * FROM transfers WHERE user = '0x...'。有的只是日志、收据、存储槽位和调用追踪——全部以需要上下文才能解码的格式编码。

一个 Transfer 事件日志给你 fromtovalue。它不会告诉你代币符号。不会告诉你精度位数。不会告诉你这是一笔正常的转账,还是一个收手续费的代币在暗中扣走了 3%。也不会告诉你这个区块 30 秒后是否还存在。

"不可变"的部分是真的——一旦最终确认。但最终确认不是即时的。而且你从 RPC 节点拿到的数据不一定来自已最终确认的区块。大多数开发者查询 latest 然后当作真相。这是 bug,不是功能。

然后是编码问题。一切都是十六进制。地址有混合大小写校验和(或者没有)。代币金额是乘以 10^decimals 的整数。一笔 100 美元的 USDC 转账在链上看起来是 100000000,因为 USDC 有 6 位精度,不是 18 位。我见过生产代码假设每个 ERC-20 代币都是 18 位精度。结果余额差了 10^12 倍。

RPC 限流会毁掉你的周末#

每个生产级的 Web3 应用都要跟 RPC 端点通信。而每个 RPC 端点的限流都比你预期的激进得多。

以下是关键数字:

  • Alchemy 免费版:约 3000 万计算单元/月,40 请求/分钟。听起来很慷慨,直到你意识到一个跨大范围区块的 eth_getLogs 调用就能吃掉数百个计算单元。一天的索引就能烧光你的月度配额。
  • Infura 免费版:10 万请求/天,大约 1.15 请求/秒。试试以这个速度翻页查询 50 万个区块的事件日志。
  • QuickNode 免费版:与 Infura 类似——10 万请求/天。

付费套餐有帮助,但并不能消除问题。即使你在 Alchemy 的 Growth 套餐上每月花 200 美元,重度索引任务仍然会触发吞吐量限制。而当你触发限流时,不会得到优雅的降级。你得到的是 429 错误,有时带着无用的消息,有时连 retry-after 头部都没有。

解决方案是组合使用备用提供商、重试逻辑,以及非常审慎地选择发出哪些调用。以下是用 viem 构建的健壮 RPC 配置:

typescript
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
 
const client = createPublicClient({
  chain: mainnet,
  transport: fallback(
    [
      http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
        retryCount: 3,
        retryDelay: 1500,
        timeout: 15_000,
      }),
      http("https://mainnet.infura.io/v3/YOUR_KEY", {
        retryCount: 3,
        retryDelay: 1500,
        timeout: 15_000,
      }),
      http("https://rpc.ankr.com/eth", {
        retryCount: 2,
        retryDelay: 2000,
        timeout: 20_000,
      }),
    ],
    { rank: true }
  ),
});

rank: true 选项至关重要。它告诉 viem 测量每个传输层的延迟和成功率,自动优先使用最快、最可靠的那个。如果 Alchemy 开始限流,viem 会将流量切换到 Infura。如果 Infura 宕机,它就回退到 Ankr。

但有一个细节:viem 的默认重试逻辑使用指数退避,这通常是你想要的。然而,截至 2025 年初,有一个已知问题——在启用批量模式时,retryCount 无法正确重试 RPC 级别的错误(如 429)。如果你在批量请求,请显式测试你的重试行为。不要假设它能正常工作。

链重组:你不会预见到的 Bug#

链重组发生在网络暂时对哪个区块是规范的产生分歧时。节点 A 看到包含交易 [A, B, C] 的区块 1000。节点 B 看到包含交易 [A, D] 的不同的区块 1000。最终网络达成一致,一个版本获胜。

在工作量证明链上,这很常见——每天会发生多次 1-3 个区块的重组。合并后的 Ethereum 好多了。一次成功的重组攻击现在需要协调接近 50% 的验证者。但"好多了"不等于"不可能"。2022 年 5 月信标链上发生过一次显著的 7 区块重组,原因是客户端对提议者提升分叉的实现不一致。

而且 Ethereum 主网上重组有多罕见并不重要。如果你在 L2 或侧链上构建——Polygon、Arbitrum、Optimism——重组更加频繁。Polygon 历史上曾有过 10 个区块以上的重组。

实际问题是这样的:你索引了区块 18,000,000。你把事件写入了数据库。然后区块 18,000,000 被重组了。现在你的数据库里有来自一个在规范链上不存在的区块的事件。这些事件可能引用了从未发生过的交易。你的用户看到了幽灵转账。

修复方案取决于你的架构:

方案一:确认延迟。 在 N 个区块确认通过之前不索引数据。对于 Ethereum 主网,64 个区块(两个纪元)能给你最终性保证。对于 L2,检查具体链的最终性模型。这很简单但会增加延迟——在 Ethereum 上大约 13 分钟。

方案二:重组检测与回滚。 积极索引但跟踪区块哈希。每收到一个新区块,验证父区块哈希是否与你索引的上一个区块匹配。如果不匹配,你就检测到了重组:删除来自孤块的所有数据,重新索引规范链。

typescript
interface IndexedBlock {
  number: bigint;
  hash: `0x${string}`;
  parentHash: `0x${string}`;
}
 
async function detectReorg(
  client: PublicClient,
  lastIndexed: IndexedBlock
): Promise<{ reorged: boolean; depth: number }> {
  const currentBlock = await client.getBlock({
    blockNumber: lastIndexed.number,
  });
 
  if (currentBlock.hash === lastIndexed.hash) {
    return { reorged: false, depth: 0 };
  }
 
  // 向后回溯,找到链分叉的位置
  let depth = 1;
  let checkNumber = lastIndexed.number - 1n;
 
  while (checkNumber > 0n && depth < 128) {
    const onChain = await client.getBlock({ blockNumber: checkNumber });
    const inDb = await getIndexedBlock(checkNumber); // 你的数据库查询
 
    if (onChain.hash === inDb?.hash) {
      return { reorged: true, depth };
    }
 
    depth++;
    checkNumber--;
  }
 
  return { reorged: true, depth };
}

这不是假设。我有过一个生产系统,在链顶端索引事件但没有重组检测。三周运行正常。然后 Polygon 上一次 2 区块的重组导致我们数据库中出现了一个重复的 NFT 铸造事件。前端显示某个用户拥有一个他们实际上并不拥有的代币。这个问题调试了两天,因为没人把重组当作根本原因来排查。

索引问题:选择你的痛苦#

要把结构化的链上数据接入你的应用,你有三个真正的选择。

直接 RPC 调用#

直接调用 getLogsgetBlockgetTransaction。这对小规模读取有效——查看用户余额、获取单个合约的最近事件。但对历史索引或跨合约的复杂查询行不通。

问题在于组合爆炸。想要过去 30 天所有 Uniswap V3 的交换记录?那是大约 20 万个区块。以 Alchemy 每次 getLogs 调用 2K 区块范围限制,至少需要 100 次分页请求。每次都计入你的限流配额。而且如果任何一次调用失败,你需要重试逻辑、游标跟踪和从中断处恢复的方法。

The Graph(子图)#

The Graph 是最早的解决方案。定义 schema,用 AssemblyScript 写映射,部署,然后用 GraphQL 查询。托管服务已经弃用——现在全部在去中心化的 Graph 网络上,这意味着你需要用 GRT 代币支付查询费用。

优点:标准化,文档齐全,大量现有子图生态系统可以 fork。

缺点:AssemblyScript 写起来痛苦。调试有限。部署需要几分钟到几小时。如果你的子图有 bug,你重新部署后要等它从头重新同步。去中心化网络增加了延迟,有时候索引者会落后于链顶端。

我在数据新鲜度要求 30-60 秒就可接受的读取密集型看板中使用过 The Graph。它在那种场景下工作良好。我不会在任何需要实时数据或复杂业务逻辑映射的场景中使用它。

自定义索引器(Ponder、Envio)#

这是生态系统显著成熟的领域。Ponder 和 Envio 让你用 TypeScript(不是 AssemblyScript)编写索引逻辑,在开发期间本地运行,并作为独立服务部署。

Ponder 给你最大的控制权。你用 TypeScript 定义事件处理器,它管理索引管道,输出是一个 SQL 数据库。代价是:你需要自己管理基础设施。扩容、监控、重组处理——都是你的事。

Envio 优化的是同步速度。他们的基准测试显示初始同步速度比 The Graph 显著更快。他们原生处理重组,并支持 HyperSync,一种用于更快数据获取的专用协议。代价是:你要绑定他们的基础设施和 API。

我的建议:如果你在构建生产级 DeFi 应用并且有工程能力,用 Ponder。如果你需要最快的同步速度而且不想管理基础设施,评估 Envio。如果你需要快速原型或者想用社区维护的子图,The Graph 仍然可以。

getLogs 比看起来危险得多#

eth_getLogs RPC 方法看起来简单得有欺骗性。给它一个区块范围和一些过滤条件,拿回匹配的事件日志。以下是在生产环境中实际发生的情况:

区块范围限制因提供商而异。 Alchemy 限制 2K 区块(不限日志数量)或不限区块(最多 1 万条日志)。Infura 有不同的限制。QuickNode 有不同的限制。公共 RPC 可能限制在 1K 区块。你的代码必须处理所有这些情况。

响应大小限制是存在的。 即使在区块范围内,如果一个热门合约每个区块产生数千个事件,你的响应可能会超过提供商的负载限制(Alchemy 上是 150MB)。调用不会返回部分结果。它直接失败。

空范围也不是免费的。 即使匹配的日志为零,提供商仍然会扫描区块范围。这计入你的计算单元。

以下是一个处理这些约束的分页工具:

typescript
import type { PublicClient, Log, AbiEvent } from "viem";
 
async function fetchLogsInChunks<T extends AbiEvent>(
  client: PublicClient,
  params: {
    address: `0x${string}`;
    event: T;
    fromBlock: bigint;
    toBlock: bigint;
    maxBlockRange?: bigint;
  }
): Promise<Log<bigint, number, false, T, true>[]> {
  const { address, event, fromBlock, toBlock, maxBlockRange = 2000n } = params;
  const allLogs: Log<bigint, number, false, T, true>[] = [];
 
  let currentFrom = fromBlock;
 
  while (currentFrom <= toBlock) {
    const currentTo =
      currentFrom + maxBlockRange - 1n > toBlock
        ? toBlock
        : currentFrom + maxBlockRange - 1n;
 
    try {
      const logs = await client.getLogs({
        address,
        event,
        fromBlock: currentFrom,
        toBlock: currentTo,
      });
 
      allLogs.push(...logs);
      currentFrom = currentTo + 1n;
    } catch (error) {
      // 如果范围太大(结果太多),将其一分为二
      if (isRangeTooLargeError(error) && currentTo > currentFrom) {
        const mid = currentFrom + (currentTo - currentFrom) / 2n;
        const firstHalf = await fetchLogsInChunks(client, {
          address,
          event,
          fromBlock: currentFrom,
          toBlock: mid,
          maxBlockRange,
        });
        const secondHalf = await fetchLogsInChunks(client, {
          address,
          event,
          fromBlock: mid + 1n,
          toBlock: currentTo,
          maxBlockRange,
        });
        allLogs.push(...firstHalf, ...secondHalf);
        currentFrom = currentTo + 1n;
      } else {
        throw error;
      }
    }
  }
 
  return allLogs;
}
 
function isRangeTooLargeError(error: unknown): boolean {
  const message = error instanceof Error ? error.message : String(error);
  return (
    message.includes("Log response size exceeded") ||
    message.includes("query returned more than") ||
    message.includes("exceed maximum block range")
  );
}

关键洞察是失败时的二分切割。如果 2K 区块范围返回的日志太多,就切成两个 1K 范围。如果 1K 仍然太多,继续切。这会自动适应高活跃度的合约,而不需要你提前知道事件密度。

BigInt 会让你谦卑#

JavaScript 的 Number 类型是 64 位浮点数。它能表示最大到 2^53 - 1 的整数——大约 9 千万亿。听起来很多,直到你意识到 1 ETH 的 wei 值是 1000000000000000000——一个有 18 个零的数字。那是 10^18,远超 Number.MAX_SAFE_INTEGER

如果你在管道中的任何地方不小心把 BigInt 转成了 Number——JSON.parse、数据库驱动、日志库——你会得到静默的精度丢失。数字看起来大致正确,但最后几位是错的。你在测试中不会发现这个问题,因为测试金额很小。

以下是我上过生产环境的 bug:

typescript
// 这个 BUG:看起来无害,实则不然
function formatTokenAmount(amount: bigint, decimals: number): string {
  return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
 
// 小金额没问题:
formatTokenAmount(1000000n, 6); // "1.0000" -- 正确
 
// 大金额就静默出错:
formatTokenAmount(123456789012345678n, 18);
// 返回 "0.1235" -- 错误,实际精度已丢失
// Number(123456789012345678n) === 123456789012345680
// 最后两位被 IEEE 754 四舍五入了

修复方法:在除法之前永远不要转换为 Number。使用 viem 内置的工具函数,它们操作的是字符串和 BigInt:

typescript
import { formatUnits, parseUnits } from "viem";
 
// 正确做法:操作 BigInt,返回字符串
function formatTokenAmount(
  amount: bigint,
  decimals: number,
  displayDecimals: number = 4
): string {
  const formatted = formatUnits(amount, decimals);
 
  // formatUnits 返回完整精度的字符串,如 "0.123456789012345678"
  // 截断(不要四舍五入)到期望的显示精度
  const [whole, fraction = ""] = formatted.split(".");
  const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
 
  return `${whole}.${truncated}`;
}
 
// 同样关键:对用户输入使用 parseUnits,永远不要用 parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
  // parseUnits 正确处理字符串到 BigInt 的转换
  return parseUnits(input, decimals);
}

注意我用的是截断而不是四舍五入。这是刻意的。在金融场景下,当真实值是 "1.00009999..." 时显示 "1.0001 ETH" 比真实值是 "1.00005001..." 却因为四舍五入而显示 "1.0001" 要好。用户根据显示的金额做决策。截断是保守的选择。

另一个陷阱:JSON.stringify 不知道如何序列化 BigInt。它会抛异常。你 API 的每一个包含代币金额的响应都需要一个序列化策略。我在 API 边界使用字符串转换:

typescript
// API 响应序列化器
function serializeForApi(data: Record<string, unknown>): string {
  return JSON.stringify(data, (_, value) =>
    typeof value === "bigint" ? value.toString() : value
  );
}

缓存策略:缓存什么、缓存多久、何时失效#

并非所有链上数据都有相同的新鲜度要求。以下是我使用的层级:

永久缓存(不可变):

  • 交易收据(一旦被打包,就不会再变)
  • 已最终确认的区块数据(区块哈希、时间戳、交易列表)
  • 合约字节码
  • 来自已最终确认区块的历史事件日志

缓存几分钟到几小时:

  • 代币元数据(名称、符号、精度)——技术上对大多数代币来说是不可变的,但代理合约升级可以改变实现
  • ENS 解析——5 分钟 TTL 效果不错
  • 代币价格——取决于你的精度要求,30 秒到 5 分钟

缓存几秒或完全不缓存:

  • 当前区块号
  • 账户余额和 nonce
  • 待确认交易状态
  • 未最终确认的事件日志(又是重组问题)

实现不需要很复杂。一个带内存 LRU 和 Redis 的两级缓存就能覆盖大多数场景:

typescript
import { LRUCache } from "lru-cache";
 
const memoryCache = new LRUCache<string, unknown>({
  max: 10_000,
  ttl: 1000 * 60, // 默认 1 分钟
});
 
type CacheTier = "immutable" | "short" | "volatile";
 
const TTL_MAP: Record<CacheTier, number> = {
  immutable: 1000 * 60 * 60 * 24, // 内存中 24 小时,Redis 中永久
  short: 1000 * 60 * 5,            // 5 分钟
  volatile: 1000 * 15,             // 15 秒
};
 
async function cachedRpcCall<T>(
  key: string,
  tier: CacheTier,
  fetcher: () => Promise<T>
): Promise<T> {
  // 先查内存
  const cached = memoryCache.get(key) as T | undefined;
  if (cached !== undefined) return cached;
 
  // 再查 Redis(如果有的话)
  // const redisCached = await redis.get(key);
  // if (redisCached) { ... }
 
  const result = await fetcher();
  memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
 
  return result;
}
 
// 用法:
const receipt = await cachedRpcCall(
  `receipt:${txHash}`,
  "immutable",
  () => client.getTransactionReceipt({ hash: txHash })
);

反直觉的经验:最大的性能提升不是缓存 RPC 响应,而是完全避免 RPC 调用。每次你准备调用 getBlock 时,问问自己:我现在真的需要链上的数据吗,还是可以从已有的数据中推导出来?我能通过 WebSocket 监听事件而不是轮询吗?我能把多次读取批量合并成一次 multicall 吗?

TypeScript 与合约 ABI:正确的做法#

viem 的类型系统由 ABIType 驱动,提供从合约 ABI 到 TypeScript 代码的完整端到端类型推导。但前提是你要正确配置。

错误的做法:

typescript
// 没有类型推导 — args 是 unknown[],返回值是 unknown
const result = await client.readContract({
  address: "0x...",
  abi: JSON.parse(abiString), // 运行时解析 = 没有类型信息
  functionName: "balanceOf",
  args: ["0x..."],
});

正确的做法:

typescript
// 用 as const 定义 ABI 以获得完整的类型推导
const erc20Abi = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "balance", type: "uint256" }],
  },
  {
    name: "transfer",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [{ name: "success", type: "bool" }],
  },
] as const;
 
// 现在 TypeScript 知道:
// - functionName 自动补全为 "balanceOf" | "transfer"
// - balanceOf 的 args 是 [address: `0x${string}`]
// - balanceOf 的返回类型是 bigint
const balance = await client.readContract({
  address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  abi: erc20Abi,
  functionName: "balanceOf",
  args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- 完全类型化

as const 断言是让一切生效的关键。没有它,TypeScript 会把 ABI 类型拓宽为 { name: string, type: string, ... }[],所有的推导机制都会崩溃。这是我在 Web3 TypeScript 代码库中见到的最常见的错误。

对于更大的项目,使用 @wagmi/cli 直接从你的 Foundry 或 Hardhat 项目生成类型化的合约绑定。它读取你编译的 ABI 并生成已经应用了 as const 断言的 TypeScript 文件。不需要手动复制 ABI,不会出现类型漂移。

残酷的现实#

区块链数据是一个伪装成数据库问题的分布式系统问题。当你把它当作"又一个 API"来对待时,你就开始积累那些在开发环境中不可见、在生产环境中间歇性出现的 bug。

工具已经大幅改善。viem 在类型安全和开发者体验方面相比 ethers.js 是巨大的进步。Ponder 和 Envio 让自定义索引变得触手可及。但根本性的挑战——重组、限流、编码、最终性——是协议层面的。没有任何库能把它们抽象掉。

构建时要假设你的 RPC 会对你撒谎,你的区块会重组,你的数字会溢出,你的缓存会返回过期数据。然后显式地处理每种情况。

这就是生产级链上数据的真正样子。

相关文章