---
title: "Solana Account Archive：在任意 slot 查询账户状态"
description: "在任意历史 slot 对任意 Solana 账户调用 getAccountInfo，该归档永不裁剪，通过重放一年的 Solana 数据构建。"
---

# Solana Account Archive：在任意 slot 查询账户状态

<ImageBlock
  src="https://media.alchemy.com/solana-account-archive.png"
  alt="Solana 账户存档:查询任意 slot 的账户状态"
  width={1920}
  height={900}
  priority
/>

Account Archive 可以回答 `getAccountInfo` 这个问题——针对任意 Solana 账户在任意历史 slot 的状态（投票账户和逐 slot 更新的 sysvar 除外），覆盖范围不断扩展，且从不清理。使用 `slot` 参数可读取某一时间点的状态，使用 `lastUpdateBeforeSlot` / `firstUpdateAfterSlot` 可遍历某个账户的更新历史。读取的中位延迟为微秒级。

## Solana 只记得当下

关于 Solana 有一个即便是经验丰富的开发者也会感到意外的细节：验证节点为每个账户只存储一份副本，即最新的那份。每次写入都会原地覆盖之前的状态，因此账户一旦被更新，它之前的样子就不复存在了。即使某个 slot 的完整区块节点仍然保存着，该 slot 上的账户状态也已经不存在于任何机器上了。

这是设计使然，而非缺陷，也是 Solana 速度快的原因之一。但这意味着一整类很自然的问题都无法回答，比如：

- **调试**："我们的清算在 slot 285,401,337 触发。当时那个 oracle price 账户的状态是什么？"
- **回测**："重建这个池子在过去六个月内每个时间点的储备情况。"
- **审计**："证明这个 token 账户在 3 月 3 日持有什么。"
- **恢复**："我们的索引器宕机了四个小时，需要精确重建我们错过的那些状态变化。"

你可能会以为 `getBlock` 能解决这个问题，但并不能。它返回的是某个过去 slot 的交易记录及其前后余额，而不是账户数据。对大多数应用来说，真正重要的状态存在于数据字节里：订单簿、价格喂价、持仓，或配置信息。

Solana Account Archive 把完整的历史重新带了回来。它可以回答 `getAccountInfo`，针对 2025 年 7 月以来任意 slot 的任意账户，使用的还是你已经在用的请求和响应结构。有两点使它与众不同：其一，归档从不清理，因此可查询的时间窗口只会随时间增长；其二，它跳过了链上自身逐 slot 的记账数据（投票账户以及每个 slot 都会重写的少数几个 sysvar），因为对应用开发者来说，这些只是噪音，不是有用的历史。

## API：getAccountInfo 的扩展

我们刻意没有发明新方法。这个归档使用标准的 JSON-RPC，实现的是 `getAccountInfo`，支持你已经熟悉的所有配置选项：`encoding`（`base64`、`base58`、`base64+zstd`、`jsonParsed`）、`commitment`、`dataSlice`、`minContextSlot`，再加上三个新增的、互斥的参数：

<EmbeddedTable
  table={{
    columns: [
      {
        key: "parameter",
        width: 200,
        title: "Parameter",
        dataType: "object",
      },
      {
        key: "semantics",
        width: 320,
        title: "Semantics",
        dataType: "object",
      },
      {
        key: "useItFor",
        width: 220,
        title: "Use it for",
        dataType: "object",
      },
    ],
    data: [
      {
        parameter: { title: "<code>slot</code>", tooltip: "", icon: "" },
        semantics: {
          title:
            "State as of slot <code>S</code> (inclusive: the latest write with <code>slot &lt;= S</code>)",
          tooltip: "",
          icon: "",
        },
        useItFor: { title: "Point-in-time snapshots", tooltip: "", icon: "" },
        id: 0,
      },
      {
        parameter: {
          title: "<code>lastUpdateBeforeSlot</code>",
          tooltip: "",
          icon: "",
        },
        semantics: {
          title: "The most recent write strictly before <code>S</code>",
          tooltip: "",
          icon: "",
        },
        useItFor: {
          title: "Walking an account's history backward",
          tooltip: "",
          icon: "",
        },
        id: 1,
      },
      {
        parameter: {
          title: "<code>firstUpdateAfterSlot</code>",
          tooltip: "",
          icon: "",
        },
        semantics: {
          title: "The first write strictly after <code>S</code>",
          tooltip: "",
          icon: "",
        },
        useItFor: {
          title: "Walking an account's history forward",
          tooltip: "",
          icon: "",
        },
        id: 2,
      },
    ],
  }}
/>

这三个参数彼此互斥，也不能与 `minContextSlot` 同时使用；两者搭配会被拒绝。三者都省略时，得到的就是普通的、最新状态的 `getAccountInfo`；这个归档是一个可直接替换的超集。下面是一个完整的定点读取示例，读取某个 token 账户在 slot 400,000,000 时的状态：

<CodeSnippet
  language="bash"
  code={`curl $ARCHIVE_URL -X POST -H "Content-Type: application/json" -d '{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "getAccountInfo",
  "params": [
    "3emsAVdmGKERbHjmGfQ6oZ1e35dkf5iYcS6U4CPKFVaa",
    { "encoding": "jsonParsed", "slot": 400000000 }
  ]
}'`}
/>

响应是标准的 `getAccountInfo` 结构：

<CodeSnippet
  language="json"
  code={`{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "context": { "apiVersion": "4.1.0", "slot": 400000000 },
    "value": {
      "lamports": 2040821,
      "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
      "data": {
        "program": "spl-token",
        "parsed": {
          "info": {
            "isNative": false,
            "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
            "owner": "7VHUFJHWu2CuExkJcJrzhQPJ2oygupTWkL2A2For4BmE",
            "state": "initialized",
            "tokenAmount": {
              "amount": "1056153754430274",
              "decimals": 6,
              "uiAmount": 1056153754.430274,
              "uiAmountString": "1056153754.430274"
            }
          },
          "type": "account"
        },
        "space": 165
      },
      "executable": false,
      "rentEpoch": 18446744073709551615,
      "space": 165
    }
  }
}`}
/>

注意其语义：`slot: S` 表示截至 `S` 时的状态。如果该账户最后一次写入是在 slot `S - 40,000`，此后未被改动，你得到的就是那次写入的结果——正是任何在 slot `S` 执行的程序所看到的状态。`jsonParsed` 对历史状态同样有效：标准 RPC 节点使用的解码器同样适用于这些历史字节，因此即便是覆盖窗口早期的 token 账户，返回的也是解码后的结果，而不是不透明的原始数据。

### 一个遍历账户历史的迭代器

`lastUpdateBeforeSlot` 和 `firstUpdateAfterSlot` 把这个归档变成了一个可以遍历账户每次状态变化的迭代器。

`lastUpdateBeforeSlot` 会在 `context.slot` 中返回它找到的那次写入的实际 slot，因此每个响应都可以作为下一次请求的游标：

<CodeSnippet
  language="python"
  code={`# Walk every state transition of an account, newest to oldest.
cursor = current_slot + 1
while True:
    try:
        result = rpc("getAccountInfo", [pubkey, {"lastUpdateBeforeSlot": cursor}])
    except RpcError as e:
        if e.code == -32020:           # walked past the edge of coverage; done
            break
        raise
    slot = result["context"]["slot"]   # the slot this write landed at
    handle(slot, result["value"])      # value is null if this write closed the account
    cursor = slot                      # strictly-before: no overlap, no gaps`}
/>

这个遍历过程只有一个停止点：错误 `-32020`，表示游标已经越过了归档覆盖范围的边界。途中遇到 `null` 的含义和它在 `getAccountInfo` 中一贯的含义一样：该账户在那个 slot 时并不存在——这里的原因是 `context.slot` 处的写入删除了它。把那个 slot 作为游标再传回去，遍历就会继续深入该账户更早的历史。

`firstUpdateAfterSlot` 则相反：它不是往回走，而是向前推进，返回给定 slot 之后严格意义上最近的一次写入。这非常适合一个从已知 slot 开始追赶进度的索引器，因为它需要的是每一个中间状态，而不只是最新状态。

结合这两者，你可以遍历一个账户的整个生命周期：每一次余额变化、每一次数据变更、每一次所有权变化，以及每一次变化确切落在哪个 slot 上。

## 实时记录新的写入

向前记录新状态是比较容易的部分。这个归档通过 Geyser 流监听网络，在账户写入发生时即时记录，并遵循两条规则来保证记录的可靠性：

只有已终结（finalized）的 slot 才会进入归档。Solana 在链的顶端会不断分叉，一个看似真实存在了几秒钟的区块完全可能凭空消失。归档会等到网络终结某个 slot 后，才把该 slot 的更新写入，因此永久记录中只包含真正发生过的事情。

不会出现静默丢失。Solana 的每个区块都指明其父区块，因此流中任何被漏掉的区块（网络抖动、重连、服务部署等）都会被立即检测到并补齐。

底层的存储层本身就值得单独讲一篇文章。这里重要的是结果：定点读取的速度足以承载在热路径中使用，中位延迟为微秒级（在我们内部服务层测得）。

## 重建过去一年的历史

从今天开始摄取数据，得到的归档要明年才有用。而我们也想要过去一年的历史，这意味着要为已经发生过的数千万个 slot 重建账户状态，而这条链本身并不保留历史。而且数据量巨大：Solana 大约每 400 毫秒产生一个区块，每天约 216,000 个 slot。一年下来大约是 7,800 万个 slot，数千亿次账户更新，超过一 PB 的原始账户数据，每个 slot 都带有成千上万次带完整数据负载的账户写入。

### 为什么"直接解析交易"行不通

有一个看似诱人的捷径：拉取历史区块，解析每笔交易对每个账户做了什么。但它因为一个根本原因而失败，这一点值得牢记：一笔 Solana 交易并不是对状态变化的描述，而是一次程序调用。

一个账户实际发生了什么，取决于程序的执行过程：CPI 调用扩散到其他程序、执行过程中读取的 sysvar、计算量计量，以及该 slot 时运行时的精确语义。区块记录了一笔交易触及了哪些账户、余额如何变动，但数据字节——你真正想要的部分——只能通过运行代码来确定。

要准确知道一笔交易对某个账户做了什么，只有一种忠实的方法：用真实的运行时，针对它当时执行时所处的真实状态，把它执行一遍。

### Replay：把链重新跑一遍

所以这正是我们的做法。这个回填流水线本质上就是一个"重新经历历史"的验证节点：

1. 从一个可信的快照启动。Solana Foundation 维护着历史快照的公共存档：某个特定 slot 上所有账户的完整快照。加载 slot `A` 处的快照，就能得到当时数亿个账户的完整、权威状态。
2. 向前重放每一个区块。从 `A + 1` 开始，每个历史区块都通过真实的验证节点运行时来执行：是真实执行，不是模拟，也不是解析日志。每笔交易都会被执行，运行时产生的账户写入会被逐个 slot 捕获并索引。
3. 在下一个快照处停下并加以验证。这一步是让整个流程可信的关键。当 replay 到达存在下一个权威快照的 slot `B` 时，我们会把重放得到的最终状态与它进行比对，包括 accounts lattice hash：这是网络自身计算并达成一致的、对整个账户集合的一种密码学承诺。只要范围内任何一个账户的哪怕一个字节出现偏差，哈希就不会匹配，该范围就会被拒绝。我们不会假定 replay 是正确的；每一段回填的范围在被信任之前都要与共识层的真实结果进行核对。

### 旧区块需要与其所处时代匹配的运行时

这正是让 replay 真正棘手的地方：Solana 的执行语义是按时间分版本的。feature gate 会在特定的 epoch 激活，计算预算规则会变化，系统调用会新增，边界情况的行为也会被修复。十二个月前的一笔交易，必须由一个行为与集群在那个 slot 时完全一致的运行时来重放。如果用今天的验证节点去重放它，第 3 步就会明确告诉你：你制造出了一段从未发生过的历史。

实际操作中，回填集群运行着若干个固定版本的验证节点谱系，各自负责它能忠实还原的那个时代，工作按 epoch 边界切分，确保每一段的起点和终点都落在可验证的快照上。要进一步向前扩展归档的覆盖范围，主要就是搭建对应时代的运行时，并投入相应的重放算力。而且由于每个按 epoch 对齐的分段都是相互独立的，历史处理可以完全并行；回填一年的历史，主要取决于你同时运行多少个重放工作节点。

## 持续验证的可信度

哈希验证的回填覆盖了过去；另一个需要关注的问题是，生产环境中的服务路径是否始终诚实。为此，一个独立的监控系统会持续将归档的回答与线上 RPC 节点进行交叉验证，走的是你会使用的同一套公共 API。正确性对我们至关重要，因此这项验证会在服务运行的每一分钟都重新执行。多层方法：以共识为锚点的哈希验证覆盖回填的历史，以终结状态为门槛的写入保证链的最新部分，再加上持续的线上交叉验证——三者结合，确保你可以信任这份随时间向前推进的真实记录。

## 你可以用它构建什么

本文开头提出的每一个问题，现在都变成了一次 API 调用：

- "那个 slot 上的 oracle 账户状态是什么？"一次带有 `slot` 参数的 `getAccountInfo`。拉取一笔失败交易执行时所读取的每个账户的确切状态。不再需要靠截图和猜测来还原 oracle 的输入数据。
- "重建这个池子六个月内的储备变化。"按固定的 slot 间隔采样该池子账户，用实际上链的数据来回测，而不是靠交易事件拼凑出的近似值。
- "证明这个 token 账户在 3 月 3 日持有什么。""在 slot S 时，账户 X 恰好包含这些字节。"这个问题是可以回答的，并且是锚定在网络自身达成一致的哈希之上的。
- "重建索引器错过的状态变化。"从你已知的最后一个 slot 开始用 `firstUpdateAfterSlot` 向前翻页，按顺序获取每一个中间状态。

还有一个开头没有提到的问题：完全不需要运行索引器,就能得到余额和持仓历史。用 `lastUpdateBeforeSlot` 向后遍历一个 token 账户，你就能得到它的完整时间线。若要查询某个钱包在过去某个 slot 时的全部 token 持仓，另见[历史 Solana token 余额](https://www.alchemy.com/blog/historical-solana-token-balances)。

## 开始使用

Solana 保持高速的方式之一，是让验证节点保持精简，只关注当前状态。历史账户状态一直存在于别处，而在此之前，整个生态系统一直在设法绕开这个问题。Solana Account Archive 改变了这一点。通过以终结状态为门槛的实时摄取，以及一个能够以符合各时代的运行时重新执行历史、并将结果与共识哈希进行验证的回填流水线，历史 `getAccountInfo` 变成了一次普通的 RPC 调用：相同的方法，相同的响应结构，只多了一个参数。

在后续文章中，我们会深入介绍存储层：一年份 Solana 账户写入的数据是如何在磁盘上组织的，使得摄取新更新和回答定点查询都能保持高速。归档索引的所有数据都经过压缩，至少保存 3 份副本以保证冗余，且从不清理，因此可查询的时间窗口只会持续增长。

[开始在 Solana 上使用](https://www.alchemy.com/docs/reference/solana-api-quickstart)，如果你的使用场景需要更深的历史覆盖，或者想在大规模场景下探索定制方案和定价，欢迎[联系我们](https://www.alchemy.com/contact-sales)。

## 常见问题

### 如何获取某个 Solana 账户在特定 slot 的状态？

在 Account Archive 上调用 `getAccountInfo`，传入该账户的 pubkey，并在配置对象中设置 `slot` 参数：`getAccountInfo(pubkey, { "slot": S })`。响应是标准的 `getAccountInfo` 结构，返回该账户在 slot `S` 时的状态，即截至 `S` 的最后一次写入。

### 如何获取一个 Solana 账户的完整更新历史？

使用游标参数翻页。`getAccountInfo(pubkey, { "lastUpdateBeforeSlot": S })` 返回严格早于 `S` 的最近一次写入，该次写入的实际 slot 会包含在 `context.slot` 中；把这个 slot 作为下一个游标传回去，就能向后遍历该账户的每一次状态变化。`firstUpdateAfterSlot` 以同样的方式向前遍历。

### slot、lastUpdateBeforeSlot 和 firstUpdateAfterSlot 有什么区别？

`slot` 是一次定点读取：截至 slot `S`（含）时的状态。`lastUpdateBeforeSlot` 和 `firstUpdateAfterSlot` 是排他性的历史游标：分别指严格早于或严格晚于 `S` 的最近一次写入。三者互斥；全部省略时得到的就是普通的最新状态读取。

### jsonParsed 编码对历史账户状态也适用吗？

适用。标准 Solana RPC 节点所使用的账户解码器同样适用于历史字节，因此 token 账户、mint 以及其他已知的程序账户，在覆盖范围内任意 slot 都能被解析返回。所有标准编码都支持：`base64`、`base58`、`base64+zstd`、`jsonParsed`，以及 `dataSlice`。

### 可以查询多久以前的数据？

目前的覆盖范围可回溯至 2025 年 7 月，且回填工作会持续将其进一步向前扩展。历史数据从不清理，因此所有已索引的数据都可持续查询，覆盖窗口只会不断扩大。

### 如何确保历史数据的正确性？

回填的数据范围是通过用对应时代的验证节点运行时重新执行每一笔交易生成的，得到的状态会与网络自身的共识产物进行验证，其中包括来自权威快照的 accounts lattice hash。实时摄取只提交已终结的 slot，并有一个独立的监控系统持续将服务返回的结果与线上 RPC 节点进行交叉核对。

### 是否覆盖所有账户？

除了纯粹逐 slot 的链上记账数据外，其余账户都被覆盖：投票账户以及每个 slot 都会重写的三个 sysvar（`SlotHashes`、`SlotHistory`、`RecentBlockhashes`）不包含在索引中。程序账户、token 账户、mint、PDA、钱包，以及其余的 sysvar（Clock、Rent 等）都在覆盖范围内。

### 我需要用新的 SDK 或客户端吗？

不需要。任何 Solana JSON-RPC 客户端都可以使用。这个归档实现的是标准的 `getAccountInfo`，历史相关的参数只是现有配置对象中新增的几个字段而已。
