ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

链上持有人查询工具开发实践:HolderLookup架构与踩坑

链上持有人查询工具开发实践:HolderLookup架构与踩坑 HolderLookup 这个项目最初是我在接一个社区活动需求时顺手做出来的工具。当时对方要搞一次持仓统计活动需要拉出一份完整准确的持有人名单并且要能每日更新。我翻遍钱包、区块浏览器、第三方 API发现要么是要手动导出、字段不全要么是工具只支持特定链想按自己的业务逻辑过滤一下都不行。最后没办法只能自己写一个专门做持有者查询的内部服务——也就是 HolderLookup 的雏形。这篇文章不打算贴一堆“项目介绍”式的套话直接把这套工具的完整设计思路、核心代码逻辑、踩过的坑和后期优化记录摆出来。如果你也在做链上数据分析、社区活动工具、空投名单生成或者老板突然扔给你一句“我们想要一个功能输入合约地址就能看到所有持有人”那这篇文章能帮你少走很多弯路。哪怕你只是刚接触链上数据开发里面关于转账日志解析、事件索引、缓存策略的内容也能当一份实打实的实践参考。1. HolderLookup 要解决的问题比“查名单”复杂得多1.1 链上数据查询的三种典型姿势先说清楚我为什么没有直接用现成的方案。接触过链上数据的朋友都知道查询一个合约的持有人大概有三条路。第一条路是直接用区块浏览器。Etherscan 这类浏览器能看到 Holder 列表但问题也很明显数据是人家按自己索引周期更新的接口有频率限制字段是固定的想按时间切片、过滤某些特殊地址、或者导出 CSV 对接自己的活动系统基本都要“人工处理到怀疑人生”。第二条路是调用第三方数据服务商的 API比如现在常见的 NFT 持有者查询接口、Token 持仓排行接口。这类服务确实省心注册个 Key 就能拉数据对中小型项目来说很香。但如果你被业务方要求“我们必须 5 分钟更新一次”“我们可能要自己跑分类逻辑”那就会遇到两个绕不开的坎一个是费用调用量上来之后基本都是按量计费另一个是数据 Black Box你拿到的是对方处理完的结果中间它有没有漏块、有没有因为 RPC 波动少扫了几个区块、它的余额计算方式是什么你完全不可控。等你发现问题的时候活动可能已经出乌龙了。第三条路就是自建索引。直接跟链上节点打交道拉合约的转账日志自己维护一张address - balance/holdings的账本。这条路前期开发成本最高但后期自由度也最爽数据是你自己的、更新频率你说了算、出问题能一行一行代码去查。HolderLookup 走的就是这条路。1.2 需求收敛首版只做什么做这个工具的时候我先没有急着实现一堆花哨功能而是把需求收敛成四个核心能力输入一个 ERC-20 合约地址返回当前所有持有人地址及其余额按余额降序。输入一个 ERC-721 合约地址返回每个 tokenId 的当前归属以及每个地址持有的数量。对余额/持仓快照做增量更新也就是新转账发生后账本能自动跟上。提供一份可导出的 CSV 名单方便交给运营使用。锁住这四条之后后面所有技术选型都围绕它们展开。等你做完一版再往多链、WebSocket 实时推送、持有人画像聚类那些方向上扩。2. 架构选型为什么最终选了“懒索引 状态快照”方案2.1 “实时扫描”和“查询时扫描”的取舍技术选型阶段我先推翻了一个很诱人的方案实时扫描。也就是服务一启动就订阅节点的事件流每来一个新区块就把里面有 Transfer 日志的合约解析一遍并入库。这个方案听起来很美但实际处理起来有个细节容易失控——你要做的是“给任意合约提供查询”本地没人知道用户什么时候会丢来一个冷门合约也没人知道这个合约的历史有多深。如果每个合约都要从头扫那服务端一启动任务队列就直接爆了全在扫老区块新数据反而跟不上。所以我改成了懒索引用户第一次查询某个合约时再从头开始拉这个合约的转账历史建好快照之后后续只做增量更新。这种模式的好处有几点不受合约数量限制永远只有当前被查询过的合约占用索引任务。冷启动成本低部署之后不需要预热。快照和增量天然脱钩重建某个合约只要清掉这张表的数据重放一遍就行。2.2 从节点拉日志的技术骨架建索引这件事本身核心动作就是连续调用 JSON-RPC 的eth_getLogs。不管底层用 ethers.js、viem还是直接裸调 HTTP骨架都是一样的// 以 viem 为例拉取指定区块范围内的全部 Transfer 日志 const logs await publicClient.getLogs({ address: contractAddress, event: parseAbi([ event Transfer(address indexed from, address indexed to, uint256 value) ]), fromBlock: BigInt(startBlock), toBlock: BigInt(endBlock) });这里有一个会直接影响索引效率的细节fromBlock和toBlock的跨度大小。区块跨度太大会导致节点返回的数据量超过限制报query returned more than 10000 results跨度太小会导致请求次数成倍上涨被节点限速更快。我后来测下来不同合约的日志密度差异很大热门合约一个块恨不得几十条 Transfer冷门合约几百个块没有一条日志。所以同一个跨度值是不科学的。我最终的方案是做一个“动态分块”逻辑初始给一个相对保守的跨度比如 2000 个区块返回的数量接近上限时把跨度减半重试如果日志数非常少则把跨度翻倍减少请求次数。这个逻辑实现不复杂但对扫描速度的提升非常明显。2.3 快照存储为什么用了 PostgreSQL 普通表存储这块我一开始犹豫过要不要上 ClickHouse。后来想想这个场景的数据量远没到需要列式数据库的地步。一个 ERC-20 合约就算有 200 万次 Transfer 历史解析出来的账本行数也不超过 10 万行。PostgreSQL 处理这种规模绰绰有余而且能方便地和业务数据做 JOIN对运营场景更友好。表结构倒是有一点设计心得。持有人的余额表如果只存“地址对余额”两个字段那增量更新的时候会很吃力——你得去 update 那一行。但如果保存每一次余额变化的历史记录又会长成事实表查询当前余额反而麻烦。我最终用了“当前快照表 变更流水表”双表结构holder_snapshot保存当前地址对合约的余额/持有数量主键是(contract_address, holder_address)。transfer_logs保存原始转账日志数据主要用于重建快照和排查问题。这点很建议你直接抄作业。只要把holder_snapshot做对后续查询的 SQL 就很简单transfer_logs则相当于给全流程上了一份保险哪天推导出的余额对不上可以直接回放日志。3. 核心模块拆解从合约事件到持有者台账3.1 转账日志的关键字段以及零地址怎么处理ERC-20 的转账日志字段比较简单就三个from、to、value。但在把日志转化成“持有者台账”时有两个细节必须先处理干净。第一个是零地址。转入源from等于零地址时代表这是一笔铸造mint没有谁余额减少只有to的余额增加to等于零地址时代表销毁burn只有from的余额减少。如果你用普通转账的更新逻辑去处理等于在跟空气做加减法账本会越记越乱。第二个是跨合约调用产生的日志。同一个区块里A 合约调 B 合约再调 C 合约日志顺序并不完全等于业务顺序。不过对于“维护余额快照”这个目标来说我们并不需要关心业务逻辑顺序只要按日志挨个应用即可——因为每一笔 Transfer 的from减少、to增加都是原子的最终状态和真实链上状态是一致的。这一点稍微想一下就通。3.2 增量更新与断点续扫的实现顺序增量更新的逻辑我把它设计成了一段无状态的处理管道从配置表读出这个合约的当前处理高度last_processed_block。用eth_getLogs拉取(last_processed_block, latest_block]区间的日志。逐条应用日志from减余额to加余额。更新last_processed_block。这段逻辑的关键在于第 2 步的区块区间。如果只是热血地每次从当前区块一直拉到链的最高高度一旦中途失败重试就会大量重复处理日志轻则浪费配额重则把余额算错。所以我把last_processed_block的更新放在日志处理完之后同时配合一个“先读最新块再处理日志”的顺序。这个顺序虽然可能导致一次增量消费时错过极个别刚落进最新块但还没被拉取的日志但下一次增量会自然补上最终一致性完全没问题。我踩过一次印象深刻的坑最初为了减少节点请求次数我把增量的区块区间左闭右闭也就是[lastProcessedBlock, newLatestBlock]。然后我发现同一个区块被处理了两遍——因为上一轮处理到 100 后更新游标时出了点问题下一轮又从 100 开始拉。对于普通 Token 问题不大但对那种高频转账的合约来说双倍计账直接把余额搞坏了。所以左开右闭区间也就是(lastProcessedBlock, newLatestBlock]加上游标自增才是正确姿势。3.3 ERC-721 的特殊场景tokenId 归属做完了 ERC-20接下来是 ERC-721。ERC-721 的 Transfer 事件可不太一样它的第三个参数不是数量而是 tokenId并且在事件定义上多一个indexed标记。标准定义是event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);和 ERC-20 放在一起对比你会发现一个问题ERC-20 的value没被 indexedERC-721 的tokenId被 indexed 了。这直接导致在eth_getLogs里做过滤时需要往 topics 的第三个参数位置塞 tokenId。解析日志的时候对 indexed 参数要用topics[3]来拿对非 indexed 参数要去data里解析。我在项目里专门写了一个parseLog方法来区分这两种情况避免解析出来的字符串变成乱码或者数字变成十六进制后忘了转。对于 ERC-721 的持有者台账我表结构里多加了一个token_id字段主键变成(contract_address, token_id)。每次 Transfer 直接覆盖当前归属即可。这样查询“某个地址持有多少个 NFT”只需要做个count(*) group by holder_address。4. 查询 API 设计与缓存别把索引拖垮4.1 接口的形态按目录分词和行为接口分离第一版的接口我偷了个懒直接让运营对着 PostgreSQL 跑 SQL。结果一个月之后运营同事表示“虽然你教了我怎么查但我们想做个排行榜页面没法直接连数据库”。于是第二版补了统一的查询 API分两类目录接口比如GET /v1/contracts/{address}/holders用于明确知道合约地址要拉最新持有者列表的场景。行为接口比如POST /v1/contracts/{address}/snapshot用于导出某个时点的快照支持指定时间、指定持有数量阈值等参数。目录接口相对简单MySQL/PostgreSQL 单表order by balance desc limit 20就够。行为接口反而复杂一些因为它会涉及到“时点数据”。为了不引入过于复杂的时序逻辑我在holder_snapshot表里加了一个snapshot_block字段每次增量更新后把当前块的余额更新成一个副本。查询历史时点快照时就直接把这个字段当成过滤条件用。这算是一种“伪时点实现”但对 99% 的运营统计场景已经绰绰有余。4.2 游标分页与 Redis 缓存持有者列表的分页我吃过 offset 的暗亏。数据量上到小几万后offset 翻到很后面时查询会明显变慢因为数据库要扫掉前面所有行。所以我统一改成了游标分页// 游标分页示例按余额降序排列时cursor 余额_地址 const [cursorBalance, cursorAddress] cursor.split(_); const rows await db.execute( SELECT holder_address, balance FROM holder_snapshot WHERE contract_address $1 AND (balance $2 OR (balance $2 AND holder_address $3)) ORDER BY balance DESC, holder_address ASC LIMIT $4, [contract, cursorBalance, cursorAddress, limit] );这套做法的核心思路很简单不要把“第几页”当成查询条件而是把“上一页最后一条记录”当成查询条件。好处是翻页再深查询时间都稳定而且天然适合做缓存。缓存这块我最初把整个列表都塞进 Redis键是holders:{contractAddress}TTL 设 5 分钟。后来发现一个尴尬事冷门合约的持有人列表很小缓存命中率不高但每次都把整个列表序列化成 JSON内存开销不小热门合约则相反列表巨大、频繁查询但重复数据太多。最终我按“单合约的持有者数量”分级缓存少于 1000 个持有者全列表缓存超过 1000 个则只缓存前 100 名后面的走数据库。这样一来热点数据命中率高了内存也不会被撑爆。4.3 并发与限流给运营系统用的接口不追求高并发这个工具毕竟不是面向 C 端的高并发服务所以我没有上消息队列那套重型中间件。在查询接口层做了一层简单的令牌桶限流每个合约地址每分钟最多允许 60 次查询。索引任务用的是简单的任务表 定时调度器先到先得。这里我要特别说明一下为什么索引任务不放在查询接口里同步触发。如果第一次查询某个合约时直接在 HTTP 请求里同步扫描历史日志那这个请求可能几十秒甚至几分钟才返回。运营人员第一次查的时候会以为服务挂了。所以我的流程是新合约丢进来后先返回一个processing状态异步建索引索引建好之后后续查询立即返回。页面端做一次轮询索引完成了再显示结果。这个交互细节是实际使用体验提升的关键。5. 实测踩坑记录三件让我抓头的事5.1 日志拉取数量限制两千个区块一拉就爆第一次跑全量索引时我遇到的是上述提到的10000 results限制。那是个热门 Meme 合约两千个区块就能拉出上万条日志。我以为是代码写错了查了半天才发现是节点在保护自己。后来通过动态分块解决了但这里我有一个建议分块逻辑里不要用“固定跨度减半”的简单策略最好带一个最小跨度保护比如Math.max(100, Math.floor(currentSpan / 2))。因为有些极端区块跨度已经缩很小了结果还不能满足节点要求就说明这个节点支持不了这个合约的索引继续减半只会卡死。5.2 余额总是对不上账的教训转账费代币有一类 ERC-20 合约转账时会扣掉一部分作为手续费也就是说事件里的value不等于接收方实际到账。初期我用事件里的 value 直接更新账本结果运营同学拿某做市商地址的余额去和区块浏览器对比差了几万个以为是索引少扫了排查很久才发现是这个机制。如果要完全精确就得去解析合约内部的转账逻辑这对通用工具来说不现实。我的处理策略非常“工程化”在服务里加一个“合约类型覆盖”配置。如果某个合约确认有转账费用机制后台把它的余额同步方式切换成“调用balanceOf接口覆写快照”。触发时机是每次增量完成后再额外发一次balanceOf请求校正头部持有者。这个方案不能说 100% 精确但已经能保证头部数据准确对大多数应用场景足够了。5.3 归档节点 vs 全节点历史日志去哪拉在这个项目的早期我用的是一个普通全节点结果在拉一个两年前的冷门合约日志时发现返回结果一直是空的。相关经验都告诉我普通全节点只会保留最近 128 个区块的状态并且对很老的日志请求不做完整响应。也就是说不是你的代码有问题是节点不给数据。这个问题没有代码层面的解只能在部署上想辙要么接入能提供历史日志服务的第三方节点要么自建归档节点。归档节点磁盘占用很大主网全量数据体积不小并不适合所有团队。如果你只是想跑通功能最省事的办法是先用第三方节点把历史日志拉一次之后增量同步用普通节点跑磁盘成本能低很多。6. 还能往哪些方向扩展6.1 多链支持HolderLookup 这版只支持单一 EVM 链。要往多链扩其实改动比你想的小很多把 RPC 地址池做成配置索引框架层抽一个ChainAdapter接口不同链只差异在是否兼容eth_getLogs以及新区块确认的最终性高度是多少。像一些链 3 秒就出块但可能经常回滚另一些链 15 秒才出一块但基本不回滚。增量游标更新的策略需要按链适配这个不能套用一套参数。6.2 WebSocket 实时推送持变化如果你想让运营系统在大户买入卖出的瞬间就收到通知可以加一条 WebSocket 通道。实现方式是在增量处理完日志后把本次涉及地址和余额变化量的数据推给订阅方。要注意的是这个推送是“最终一致”的也就是说你得接受链重排后可能产生一次已推送数据需要被撤回的情况。推送内容里带上新区块高度的字段让订阅方自己去判断重排风险是最稳妥的做法。6.3 CSV 导出与活动对接最后补一个很实用的小模块把查询结果导出 CSV。这个需求几乎一定会出现所以我直接在后台生成导出任务业务方触发后异步生成文件完成后通过回调地址通知。文件内容加上一行表头注释注明生成时的区块高度这样万一哪天链上数据因为重排变了运营那边至少能追溯这份名单是基于哪个高度生成的。关于这个项目我内心最想说的一点是链上数据工具的核心不在于花哨的算法而在于把“事件日志怎么解析”“增量怎么更新”“查不出来时怎么排查”这些基础环节稳扎稳打做好。HolderLookup 从第一行代码到能稳定跑数据中间修改最多的不是新功能而是这些平时没人提的边界场景。做这类工具真不建议一上来就追实时大数据架构先把一份准确的快照做出来哪怕每天增量更新一次也远超一堆跑不通的花架子。
返回列表