
从 ClickHouse 到 RocksDB:我们如何重建 Solana 的归档层
当我们告诉别人,已经把超过 300TB 的 Solana 归档数据从 ClickHouse 迁移到 RocksDB 时,得到的反应几乎总是一样:为什么要这么做?
对于 PB 级分析工作负载,ClickHouse 显然是合适的选择,而 RocksDB 并不是。
全球一些最大的数据使用方都信赖 ClickHouse。
例如,Cloudflare 在一千多个副本上运行 ClickHouse,每秒处理数亿次写入。
Anthropic 运行着定制的物理隔离 ClickHouse 部署,为 Claude 的可观测性提供支持。Uber、eBay 和 Bloomberg 也已在生产环境中使用它多年。
另一方面,RocksDB 是一种文档较少的嵌入式键值存储,主要用作其他数据库(例如 CockroachDB、TiKV、MyRocks)的引擎,而非直接作为面向客户的历史数据服务基础。
然而,这次迁移将压缩后的存储占用从 ~330TB 降至 ~190TB,解决了最慢查询的长尾问题(例如,getTransactionsForAddress 调用的 p95 延迟从 350ms 降至 30ms),也让我们得以创新 Solana 的读取层——在 Helius 所要求的性能和规模下,使用 ClickHouse 不可能做到这一点。
本文将介绍我们为何从 ClickHouse 迁移、在此过程中对工作负载有了哪些认识,以及如何在生产环境中大规模使用 RocksDB。
什么是 Solana 归档,它为何重要
从根本上说,Solana 是一个由节点组成的网络,这些节点相互通信,无需彼此信任即可就新信息达成共识。
节点是 Solana 上运行客户端(例如 Agave、Firedancer)的计算机。该客户端遵循一套特定规则,帮助网络就新信息达成共识。
验证者是通过生成区块(即把一组交易追加到 Solana 的账本)并对其他区块的有效性投票来保护网络的 Solana 节点。
RPC 节点是不参与区块生成或投票的验证者。它们改为观察网络并跟踪网络产生的所有新信息。RPC 允许用户通过 JSON-RPC 规范查询网络中的特定数据。
然而,这些节点不会永久保留这些数据。
为了满足 Solana 的硬件要求,较旧的区块、交易和账户状态会被清理,因此节点只保留 Solana 历史记录的最新视图。
如果要查询一年前的交易签名、提取某个钱包整个生命周期内涉及它的所有签名,或者扫描某个程序的完整执行历史,这就会成为问题。
广义上,归档是指存储 Solana 自创世以来所有数据的整个层。它记录并索引链产生的一切——每个区块、交易、账户交互、跨程序调用(CPI)和交易日志——并让这些数据在标准的 ~2 天(即 1 个纪元)短期存储窗口过后仍可查询。
正是归档让历史查询成为可能。
在 Solana 上存储归档数据的默认方案一直是 Google BigTable。Anza 维护着一个 BigTable 实例,RPC 提供商可按需访问它来响应历史查询。
它确实可用,但 BigTable 成本高昂,出站流量费用让自行运行副本代价不菲,而且你几乎无法从工程层面进行任何加速——只能受制于 Google 的存储,承担它的全部成本结构,却没有任何控制权。
Old Faithful 是由 Triton One 维护的一套工具,可从账本 RocksDB 归档生成内容寻址归档(CAR),并通过 Solana 的标准 RPC 和 gRPC 接口提供服务。它是推动 Solana 归档层去中心化的重要一步,也提供了有价值、可验证的冗余历史数据源。
然而,它在开发者体验和性能方面的取舍(例如,开始使用时需要定制工具,而不是团队可直接对接的接口;它针对持久归档而非性能和规模进行了优化)使其无法成为延迟敏感型工作负载的有效替代方案。
归档的核心问题在于,你有 N PB 的原始数据。面对超过 5000 亿笔交易、~1.3 万亿行账户到交易索引和随机访问模式,如何实现低于 10ms 的查找?端到端低于 50ms 的查询又该如何实现?Google BigTable 和 Old Faithful 都无法提供解决方案。
此外,如果你想构建自定义筛选和排序选项,或者提供比标准 JSON-RPC 方法更丰富的功能来改善开发者体验,就需要自己的归档索引。
所以,我们自己构建了一个。
ClickHouse:务实的首次尝试
要着手构建新的归档系统,ClickHouse 是最务实的起点。它让我们能以最快速度交付一个已经优于 BigTable 方案的产品。
ClickHouse 是一个成熟的列式数据库,拥有出色的工具和文档。它能很好地压缩时间序列数据,这对 Solana 尤其有用,因为其区块数据本质上按时间排序。
创建数据库、编写 SQL、迭代 schema 并针对上市速度进行优化都很直接,因此可以较快地向客户交付可用产品。
ClickHouse 并非独自承载流量。它是我们存储栈的最底层,而整个存储栈按数据的新旧程度组织。
最新的数据(即大约最近一两分钟或几百个 slot)存放在内存存储中。最近大约两周的数据存放在 Postgres 中。ClickHouse 则保存 Solana 的其余历史数据。
这三个数据源前有一个智能路由器:点查询会被发送到包含相应数据的最热层,范围查询则会拆分到不同层,再拼接成单一结果。
对于其设计目标内的工作负载,ClickHouse 表现得相当不错。例如,“给我这个 slot 范围内的每个区块”或“扫描这个账户在一段时间内的活动”。
时间序列查询没有问题。我们可以回填数据、运行临时分析,并回答 RPC 提供商历来需要处理的大多数问题。写入端也毫无压力;我们只是通过 LaserStream 以 ~10MB/s 的速度写入索引。
选择 ClickHouse 本身并不是错误。真正的错误,是假设我们的工作负载会一直保持在 ClickHouse 能够大规模处理的形态。
ClickHouse 在哪里遇到了瓶颈
我们关注的 Solana 历史方法并非全都是时间序列查询。Solana 上的签名(即通用交易标识符)本质上是 64 字节的随机数据。
当有人针对给定签名调用 getTransaction 时,这是一次均匀随机的点查询。没有 slot、时间范围或任何可利用的局部性。
“获取涉及此账户的所有签名”等其他查询也是如此,因为账户密钥实际上也是随机的。只要主键是 UUID、哈希或其他高熵标识符,就会出现这个问题。
列式引擎并非为解决这个问题而构建。
ClickHouse 将数据存储在已排序的数据片段中,并使用稀疏主索引。这意味着随机键查询几乎没有可裁剪的空间。最终,你会读取比预期更多的数据粒度。单次签名查询可能涉及 20 个数据粒度——约 10,000 行和 ~60 次磁盘 I/O——这还未计入二分查找或 CPU 开销。
仅计算 I/O,一次查询就大约需要 5ms。由于采用列式存储,读取一笔交易意味着要在一个 512 行的数据粒度中,以独立 I/O 读取 ~15 列。
例如,当一次 getTransactionsForAddress 调用请求完整交易详情时,最多会扇出为 100 次此类查询。这使得延迟下限在序列化任何一个字节之前就接近 100ms。
大型 getTransaction 批次(即最多 1,000 个)会把这个下限推近整整一秒。
让扫描更快的抽象机制(例如向量化执行、延迟物化)无法完全解决这个问题。查询已经优化到了极限。我们并没有缺少某个索引,也没有可以添加的投影。
简而言之,底层数据布局与我们的需求不匹配。
我们新增的成本最高的方法(例如 getTransactionsForAddress、getTransfersByAddress),恰恰采用 ClickHouse 最不擅长处理的随机键模式。
其中一些查询耗时 2–3 秒,而它们本应只需几毫秒。我们不断收到工作负载性能下降的告警,却无法从根本上解决问题。
考虑到机构和企业客户正在大规模使用 Helius,长远来看,这完全不可接受。
要让 ClickHouse 扩展以支持这种工作负载,就必须增加服务器数量;以我们的规模,这意味着将工作负载放大数倍。单纯砸钱购买硬件解决不了问题。
Solana 的 RPC 栈正日趋成熟。生态系统已经到达一个转折点:标准 JSON-RPC 方法不再够用。客户需要丰富的历史查询,而不会有其他人在 BigTable 之上构建这些功能。
如果想继续开拓这个前沿,存储层就必须改变。
RocksDB:不是数据库
尽管名字里有 DB,RocksDB 并不是数据库,而是一个库。
它没有传统数据库意义上的查询语言、客户端/服务器协议、SQL 引擎、连接或索引。它提供的接口相当于:“这里有一个键(一些字节)和一个值(也是一些字节),把它持久化到磁盘;之后,再把这个键对应的值返回给我。”仅此而已。
它是一种原语。甚至可以说,是构建存储引擎的核心原语。
传统数据库应有的一切(例如查询规划器、线协议、复制、可观测性)都需要自行构建。
这听起来像是缺点,直到你理解它带来了什么。由磁盘上的日志结构合并(LSM)树支持的纯键值存储,恰好非常适合以高吞吐量执行均匀随机键查询。没有查询规划器引入额外开销,无需协调列式布局假设,也不用为 SQL 前端预留资源。
你只需持久化字节、读取字节,并在合适的位置部署布隆过滤器和缓存,让随机读取保持低成本。
正是在这里,所谓反模式的论点不攻自破。
我们并不是用 RocksDB 替换了 ClickHouse,而是用一个以 RocksDB 为存储引擎的自定义数据库替换了 ClickHouse。这两者有本质区别。
这也是大多数生产数据库在底层采用的模式。例如,CockroachDB、TiKV、MyRocks 和 Kafka Streams 状态存储都构建在 RocksDB 之上。
区别在于,它们先用另一层数据库封装 RocksDB,而我们围绕 RocksDB 自行构建数据库,并针对归档所需的具体访问模式进行调优。
RocksDB 如何解决 ClickHouse 的问题
那么,键值存储究竟如何解决列式引擎无法处理的随机键问题?关键完全在于字节在磁盘上的存储方式。
RocksDB 使用分层压缩。新写入的数据进入第 0 层,这是一个未排序的数据堆放区——实际上与 ClickHouse 的情况相同——其中分区内的数据片段并未全局排序。
但第 1 层到第 N 层都是完全排序的运行段。数据完成压缩后,最小值/最大值元数据就能真正裁剪搜索范围,即使面对均匀随机键也一样,因为该层已经全局有序。从某种意义上说,ClickHouse 中的一切都像 RocksDB 的第 0 层。
我们在回填时充分利用了这一点。
构建签名索引时,我们预先对完整历史记录进行排序,再直接加载到最底层。此后,只有最新数据会进入第 0 层,并迅速向下合并。
结果是,几乎每次查询都只需从单个已排序运行段读取。我们实际上用 RocksDB 对随机数据完成了排序。
我们在其上构建了什么
ClickHouse 开箱即用地提供许多功能:查询引擎、线协议、客户端和数据服务机制。RocksDB 不提供任何这些选项;它只负责持久化字节。其他一切都必须由我们构建。
因此,我们在 RocksDB 之上构建了自己的数据库,专门针对 Solana 归档访问模式进行了优化。
目前,RocksDB 中有两个索引:
- 签名 -> 位置(即签名索引,将交易的 64 字节签名映射到其在区块中的 slot 和位置)。
- Slot -> 区块(即 slot 到区块索引,将上述位置映射到交易数据)。
值得注意的是,签名无法直接指向其交易数据,而这两个索引的存在正是为了解决这一问题。
签名本质上是一个随机的 64 字节标识符。真正能说明交易位于何处的是它的 slot 及其在该区块内的索引。因此,获取一笔交易需要两次跳转(即先将签名解析为给定位置,再从该位置检索交易详情),而获取区块只需一次跳转,因为调用方已经提供了 slot。
这些索引共同支持 Helius 所提供的一些负载最重的方法(例如 getBlock、getTransaction 和 getTransactionsForAddress,尤其是将 details 设为 full 时)。
读取路径与使用 ClickHouse 时大致相同。请求进入后,我们的内部客户端发起数据库调用,然后返回结果。变化的是底层引擎。每个方法都有一条硬编码、手工调优的路径。当用户查询某笔交易时,专门构建的 getTransaction 路径会直接访问相应字节。
这条路径速度很快,因为我们控制了它的每一层。我们使用 io_uring 进行文件和网络 I/O,将数据流式传出 RocksDB。如果数据以未压缩形式存放在磁盘上,我们会直接将其从磁盘复制到网卡,不绕道用户空间。
方法经过硬编码,I/O 经过端到端调优,中间没有任何多余环节。
生产环境中的收益非常明显。
最近,我们连续五到六个小时维持了 150 Gbit/s 的 getBlock 流量,期间大多数网卡都处于满载状态。零故障、零告警,原始性能始终不变。
总体而言,
- 压缩后的存储量近乎减半,从 ~330TB 降至 ~190TB
getTransaction调用的 P95 延迟从 7ms 降至 1msgetTransactionsForAddress调用的 P95 延迟从 350ms 降至 30msgetBlock调用的 P95 延迟从 50ms 降至 35ms
正如预期,getBlock 调用的改善最小。ClickHouse 已经以 512 行为一块存储交易,这摊薄了区块规模读取的列式开销。getBlock 的端到端时间很大一部分用于 Base58 和 JSON 编码及区块重组,因此更换存储引擎不会加快这一过程。
这背后更深入的故事——我们如何让 io_uring、异步 Rust 和 RocksDB 这样的同步库在高吞吐量网络工作负载下协同工作——值得未来单独撰文介绍。
不过,下一节会深入介绍我们在使用 RocksDB 时发现的一些实用优化。
针对规模优化 RocksDB
不存在唯一“快速”的 RocksDB 配置。正确的设置完全取决于正在调优的索引访问模式。
我们的签名 -> 位置索引和 slot -> 区块索引运行在相同硬件的同一进程中,但两者的调优方式几乎截然相反。
因此,我们不会提供一份未必适合你工作负载的配置文件;本节将介绍我们如何分析那些具有普适性的取舍。
按索引而非按数据库调优
我们的每个索引都是独立的列族,并拥有各自的选项。一个处理均匀随机的点查询,另一个按顺序获取大型、可压缩的值。以相同方式处理它们,会错失许多性能优化机会。下文几乎每个决策都应理解为:“对于这种访问模式,执行 X。”
判断你是否真的需要 WAL
归档数据可以重建。也就是说,它从 LaserStream 流式传入,并由链本身派生。
我们在禁用 Write Ahead Log(WAL)的情况下写入,因此无需为不需要的预写日志持久性付出代价。
微妙之处在于,关闭 WAL 也会禁用 RocksDB 默认的跨列族崩溃一致性,因此必须通过其他方式恢复一致性。这里的经验是,持久性设置应与数据的可恢复性相匹配。可以从上游来源重建的数据,与记录系统中的数据有着完全不同的要求。
让布隆过滤器匹配命中/未命中比例
布隆过滤器通过低成本判断查询中是否不存在某个键来体现其内存价值,这意味着它只对未命中有帮助。
签名查询几乎总会命中,因为调用方持有签名,并希望获取对应的交易数据。
最底层的 LSM 层保存了我们的绝大多数数据。当大部分数据都位于单一层级,且工作负载以命中为主时,该层的过滤器占用最多内存,所做的工作却最少。在这种情况下,值得重新考虑是否真的需要在那里部署过滤器。
压缩可压缩的数据,而非热点数据
是否压缩应按索引决定。64 字节签名等高熵数据无法压缩到 1.0 以下的比率,这意味着压缩只会为每次查询的热点路径增加解压成本。因此,我们将压缩设置为 None。
相比之下,区块数据体积更大、重复性更高,因此压缩效果很好。所以,我们的 slot -> 区块索引使用 zstd。可以看到,两个索引使用同一个数据库,却受益于不同的选择。这完全取决于字节是否可压缩,以及索引受延迟还是存储容量限制。
这种按索引区分的策略,是我们能够在不损害点查询延迟的情况下,将存储占用从 ~330TB 降至 ~190TB 的重要原因。
考虑直接 I/O
我们使用直接 I/O 进行读取,也将其用于刷新和压缩。在这种规模下,操作系统页面缓存会与我们自己的块缓存争夺相同的 RAM;对于随机点查询,这种双重缓存大多是浪费。我们更希望自己维护一个大型块缓存,以提供可预测的延迟。
请注意,这取决于工作负载:
直接 I/O 可能损害以扫描为主或资源配置不足的环境,因此应该进行 A/B 测试,而不是盲目采用。
选择能承受竞争的缓存
当热点键承受高并发负载时,标准共享 LRU 缓存会因锁竞争而成为瓶颈。我们对热点索引使用 RocksDB 的 HyperClockCache。当许多线程同时反复访问相同的热门条目时,它的表现要好得多。
并行执行多键查询,而不是将其串行化
RocksDB 不必将 N 次读取串行化,而是可以通过 io_uring 并发发起多次 I/O,并让它们并行完成。对于 getTransactionsForAddress 这种会扇出为许多底层点查询的方法,这决定了延迟是随键数量增长,还是大致保持不变。
RocksDB 提供了所有选择,但不会替你对数据做任何判断。我们的成果来自理解自身的访问模式,并根据每个索引的具体形态分别调优,而不是寻找一套在所有方面都表现良好的全局配置。
我们正在实现的目标
目前,归档服务运行在 EWR、FRA 和东京等大型区域。如今,存储引擎能以微秒级速度返回查询结果,并跑满我们的网卡,数据库已不再是让我们夜不能寐的问题;解决一个瓶颈,往往会暴露下一个。
当查询实际上已无成本时,主要成本就会从软件转向用户与服务器之间的距离。请求仍必须从用户所在地传输到后端所在地,再返回用户。此时,你面对的是物理规律。
这正是 Gatekeeper 要解决的问题。
Gatekeeper 是我们内部开发的边缘网关,使用 Rust 编写并构建在 Hyper 之上。它在靠近用户的位置终止连接,并通过最短的可用路径将每个请求路由到后端。如今,延迟之战就在这里展开:连接池、TLS 和套接字调优、感知距离与健康状态的路由,以及在全球节点中实现零停机部署——所有这些,都是为了从通往字节数据的路径中再削减几毫秒,而归档已经能在几微秒内提供这些数据。
让单个请求变快是数据库问题。让来自地球任何位置的每个请求都变快,同时没有维护窗口、不会断开连接,则是完全不同的问题。这也是我们接下来的重点。
这就是我们所说的创新 Solana 读取层:让每一层都做到位,从响应查询的存储引擎,到决定响应以多快速度到达最终用户的边缘网关。
归档最终都归结为随机键点查询。由于结构性原因,这种访问模式会让列式引擎失效。这与调优、分片或视图如何排序无关。我们在 ClickHouse 上遇到的限制,是这一选择的固有结果,而不是配置偶然导致的。决定 Solana 历史工作负载的是其最困难的访问模式,而不是最简单的模式。
对于一个致力于成为全球金融结算层的网络,其读取层不能只是我们已经超越的旧方案的优化版本。依赖丰富历史查询的机构和应用,需要默认技术栈无法提供的方法、覆盖范围和延迟表现。Solana 的读取层必须从一开始就构建在适合困难场景的基础之上。这是我们选择 RocksDB 所下的赌注,也是我们衡量其他一切的标准。
如果你有兴趣大规模构建金融的未来,欢迎与我们一起实现它。Solana 正加速成为全球金融的结算层,而归档只是更大拼图中的一小块。如果你对 LSM 压缩和按索引调优感兴趣,或希望在金钱所能买到的顶级硬件上解决棘手的系统问题,这里会让你如鱼得水。
我们的工程团队正在招聘。访问 helius.dev/careers 查看所有空缺职位。
相关文章
订阅 Helius
及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新


