基于 Web3 索引服务实践:实时监听、区块扫描与 MQ 解耦
在 Web3 业务里,链上数据不是一个“附加能力”,而是很多核心流程的起点。
比如用户转账、合约事件触发、资产状态变化、订单执行结果,这些信息最早都发生在链上。业务系统如果想感知这些变化,就必须先把链上数据稳定地采集下来,再转成自己能消费的内部事件。
最近我在做的一个项目叫 nexus-chain,它本质上就是一套链上索引服务。项目基于 Go 开发,使用 fx 管理生命周期,使用 ent 管理 PostgreSQL 表结构和数据访问,运行时分成两条核心链路:
- 一条是实时监听链路,通过 WebSocket 订阅 EVM 合约事件
- 一条是历史扫描链路,通过 RPC 定时扫描区块做数据回补
两条链路最终都会进入统一的事件处理器。处理器先把解析后的事件写入数据库做幂等去重,再将标准化消息投递到 RabbitMQ,由下游业务服务异步消费。
这一版实现里,索引服务本身不依赖其他业务服务。它只负责一件事:把链上事件稳定地采集、解析、持久化,并向外分发。
这篇文章不讲一个抽象的“理想架构”,而是直接结合 nexus-chain 当前实现,聊聊这套索引服务是怎么设计的、解决了什么问题,以及有哪些值得明确写进文章里的工程细节。
架构图
先看 nexus-chain 当前实现对应的整体架构。它不是一个“单纯订阅链上事件然后直接写库”的服务,而是围绕两条采集链路展开:
- 实时监听链路:通过 WebSocket 订阅链上日志
- 区块扫描链路:通过 RPC 按块高区间回补历史数据
两条链路最终都会汇聚到统一处理器,统一做解析、幂等落库和 MQ 分发。
flowchart LR
subgraph Chain["EVM Chain / Node"]
WS["WebSocket 节点"]
RPC["RPC 节点"]
end
subgraph Nexus["nexus-chain"]
CFG["配置加载\nmonitor_contracts\nmonitor_events"]
RT["实时监听器\ninternal/monitoring/realtime"]
SC["区块扫描器\ninternal/monitoring/scanner"]
PROC["统一处理器\ninternal/monitoring/shared/processor"]
end
DB[("PostgreSQL\nmonitor_contracts\nmonitor_events\nparsed_events_log")]
MQ[("RabbitMQ\nExchange / Queue")]
BIZ["下游业务服务"]
DB --> CFG
CFG --> RT
CFG --> SC
WS --> RT
RPC --> SC
RT --> PROC
SC --> PROC
PROC --> DB
PROC --> MQ
MQ --> BIZ
这张图里最重要的点有两个。
第一,数据库在这里不只是“存结果”,还承担了“配置中心 + 扫描进度存储 + 幂等去重”的作用。
第二,RabbitMQ 是统一对外出口,nexus-chain 不直接依赖业务服务,也不直接写业务库。
一、为什么要把索引服务单独做出来
最开始做链上业务时,一个常见做法是把监听逻辑直接放到业务服务里。谁需要链上数据,谁自己连节点、自己订阅事件、自己解析日志、自己入库。
这个方案前期很快,但只要业务一多,就会出现几个典型问题。
第一个问题是职责边界混乱。业务服务本来应该只处理业务状态,但一旦把监听、回补、重连、幂等等逻辑揉进去,服务会越来越重。
第二个问题是重复建设。资产服务写一套监听器,订单服务再写一套,风控服务再写一套,最后不同团队都在重复解决“怎么从链上拿数据”这个问题。
第三个问题是稳定性无法统一治理。链上采集本质上是一项基础设施能力,它天然要求持续运行、异常恢复、状态可追踪。如果它被拆散在各个业务服务里,后面很难统一优化。
所以 nexus-chain 的定位很明确:它不是某个具体业务流程的一部分,而是链上数据进入业务系统之前的一层基础设施。业务服务不关心订阅细节,也不关心区块扫描进度,只关心自己要消费什么消息。
从工程视角看,这就是典型的“采集层”和“业务层”解耦。
二、先看 nexus-chain 当前的整体结构
nexus-chain 的入口非常直接,在 cmd/nexus-chain/main.go 中通过 fx 启动几个核心模块:
config.New:加载环境配置database.NewEntClient:初始化 PostgreSQL 和 Entrabbitmq.New:初始化 RabbitMQ 客户端realtime.New:创建实时事件监听器scanner.New:创建区块扫描器net.NewHTTPServer:启动 HTTP 服务
应用启动之后,fx 会把这些模块串起来:
- 初始化配置和数据库连接
- 启动 RabbitMQ
- 启动实时监听服务
- 启动区块扫描服务
- 启动 HTTP 服务
这里最值得写进文章的一点是:实时监听和区块扫描不是二选一,而是同时存在。
实时监听解决时效性问题。
区块扫描解决完整性问题。
两者共用一套处理逻辑,避免出现两条链路各写各的、行为不一致的问题。
这也是我在看代码时觉得这个项目最合理的地方之一。
三、配置不是写死在代码里,而是来自数据库
nexus-chain 当前不是靠配置文件写死“监听哪个合约、哪个事件”,而是从数据库表里动态加载。
项目里有三张核心表:
monitor_contractsmonitor_eventsparsed_events_log
它们分别对应三类职责:
1. monitor_contracts
这张表定义“要监听哪些合约”。
关键字段包括:
idchain_idaddressnameabistatus
也就是说,一个合约只要在这张表里启用,系统就知道它是监听对象。
2. monitor_events
这张表定义“一个合约下要监听哪些事件”。
关键字段包括:
idcontract_idevent_namemq_routing_keystatuslast_block
last_block 很关键,它记录当前事件已经扫描到哪个块高。扫描链路是否可恢复,很大程度就取决于这个字段有没有被维护好。
3. parsed_events_log
这张表是解析结果落库表,字段里最重要的是:
uidevent_idblock_numbertx_hashlog_indexparsed_data
当前实现对 (tx_hash, log_index) 建了唯一索引,这意味着同一条链上日志即使被实时链路和扫描链路都处理到,最终也只会成功插入一次。
这就是整个系统幂等的第一道防线。
四、实时监听链路是怎么跑起来的
实时监听的核心实现放在 internal/monitoring/realtime 目录下。
启动时,EventListener 会先调用 refreshSubscriptions,从数据库中加载当前所有启用的监听配置,然后为每个事件创建一个独立订阅协程。后面它每分钟会刷新一次订阅列表,用来处理配置变更,比如:
- 新增了一个要监听的事件
- 某个事件被禁用了
- 合约 ABI 发生了变化
这个设计比“服务启动时一次性加载然后永不刷新”更适合线上运行,因为它允许你通过数据库动态变更监听配置,而不需要每次都重启进程。
代码里的思路大致是这样:
desiredSubscriptions, err := shared.LoadRealtimeSubscriptions(ctx, l.db, l.cfg, l.rabbitmqClient)
拿到订阅列表后,服务会按 event_id 维护自己的订阅集合。如果配置签名发生变化,就先取消旧订阅,再启动新订阅。
真正订阅链上日志时,项目使用的是 go-ethereum 的 SubscribeFilterLogs:
query := ethutil.NewLogFilterQuery(sub.Contract.Address, sub.ABIEvent.ID.Hex())
subscription, err := client.SubscribeFilterLogs(ctx, query, logsCh)
也就是说,当前实现是按“合约地址 + 事件 topic”进行过滤,拿到日志后交给统一处理器:
if err := shared.ProcessRealtimeLog(ctx, l.db, l.rabbitmqClient, sub, vLog); err != nil {
// log error
}
以 ERC20 Transfer 事件为例
假设现在我们要监听 USDT 合约的 Transfer 事件,数据库里可以先准备两条配置。
monitor_contracts 示例:
INSERT INTO monitor_contracts (id, chain_id, address, name, abi, status)
VALUES (
gen_random_uuid(),
1,
'0xdAC17F958D2ee523a2206206994597C13D831ec7',
'USDT',
'[{"anonymous":false,"inputs":[{"indexed":true,"name":"from","type":"address"},{"indexed":true,"name":"to","type":"address"},{"indexed":false,"name":"value","type":"uint256"}],"name":"Transfer","type":"event"}]',
1
);
monitor_events 示例:
INSERT INTO monitor_events (id, contract_id, event_name, mq_routing_key, status, last_block)
VALUES (
gen_random_uuid(),
'上一步插入的 contract_id',
'Transfer',
'evm.transfer',
1,
0
);
服务启动后,会读取这两张表,解析 ABI,找到 Transfer 对应的 event 定义,并建立实时订阅。
一旦链上出现新的 Transfer 日志,系统就会进入统一处理流程。
实时链路与扫描链路时序图
如果把系统运行过程展开来看,nexus-chain 实际上有两条入口不同、后半段统一的时序。
实时链路负责尽快接住最新事件。
扫描链路负责在事后补齐遗漏数据。
两者最后都调用同一个处理器。
sequenceDiagram
participant DB as PostgreSQL
participant RT as Realtime Listener
participant SC as Block Scanner
participant WS as WebSocket Node
participant RPC as RPC Node
participant P as Shared Processor
participant MQ as RabbitMQ
participant B as Business Consumer
Note over RT,DB: 启动阶段
RT->>DB: 加载 monitor_contracts / monitor_events
SC->>DB: 加载 monitor_contracts / monitor_events
Note over RT,WS: 实时链路
RT->>WS: SubscribeFilterLogs(contract + topic)
WS-->>RT: 推送新日志 vLog
RT->>P: ProcessRealtimeLog(vLog)
Note over SC,RPC: 扫描链路
SC->>RPC: 查询 latest block
SC->>DB: 读取 event.last_block
SC->>RPC: FilterLogs(fromBlock, toBlock)
RPC-->>SC: 返回历史日志列表
loop 每条历史日志
SC->>P: ProcessHistoricalLog(vLog)
end
SC->>DB: 更新 monitor_events.last_block
Note over P,DB: 统一处理逻辑
P->>P: ABI 解码日志
P->>DB: 插入 parsed_events_log
alt 首次插入成功
P->>MQ: PublishEvent(routing_key, payload)
MQ-->>B: 投递标准化事件
else 命中唯一约束
P-->>P: 跳过重复日志
end
这张图想表达的核心点是:实时链路和扫描链路只是“入口不同”,真正的数据处理逻辑只有一套。
这会带来两个直接收益:
- 行为一致,避免实时链路和扫描链路出现不同的解析、去重、投递逻辑
- 维护成本更低,后续如果要扩展消息结构、补字段、改幂等策略,只需要改一处
五、区块扫描链路为什么必须存在
很多人一开始做索引服务,会觉得有 WebSocket 订阅就够了。理论上链上有新事件时,系统不是已经能实时收到吗?
但线上环境不是理想状态。
WebSocket 可能断。
节点可能抖。
服务可能重启。
部署过程中也可能错过某一段时间内的事件。
如果系统只有实时链路,那这些异常造成的漏数就很难自动补回来。
所以 nexus-chain 里还有一条区块扫描链路,代码在 internal/monitoring/scanner。
扫描器默认每 30 秒执行一次 scanOnce。它会从数据库中读取启用中的监听目标,然后对每个目标独立扫描。
扫描逻辑的核心流程是:
- 用 RPC 连接节点
- 获取当前最新块高
- 根据
last_block计算下一次扫描起点 - 按批次查询区块范围内的日志
- 逐条进入统一处理器
- 每处理完一个批次,更新
last_block
代码里最关键的一段是:
fromBlock := nextScanStart(target.Event.LastBlock, latestBlock)
toBlock := int64(latestBlock)
以及:
if err := s.db.MonitorEvent.UpdateOneID(target.Event.ID).
SetLastBlock(end).
Exec(ctx); err != nil {
return fmt.Errorf("update last_block to %d: %w", end, err)
}
也就是说,扫描器会把自己的处理进度持久化到数据库。服务就算重启,也能从上次扫到的位置继续往后推进。
扫描链路如何补回漏掉的数据
假设某个事件当前在 monitor_events.last_block = 21000000,而链上最新块高已经到了 21001050。
那么下一轮扫描会这样推进:
- 第一批扫描
[21000001, 21001000] - 第二批扫描
[21001001, 21001050]
每个批次结束后,last_block 都会更新。
如果第一批已经成功执行,第二批执行时服务异常退出,那么数据库里至少已经记住:
last_block = 21001000
下次服务重启后,扫描器会从 21001001 继续,而不是从头再扫一遍。
这就是“进度可恢复”的核心。
为什么扫描链路能兜住实时监听漏数
假设服务在区块 21000520 到 21000540 期间,因为 WebSocket 断连没有收到任何事件。
如果没有扫描器,这 20 个区块里的事件就丢了。
但有了扫描器之后,只要 last_block 还没推进到那个范围,下一轮扫描仍然会把这段区块重新扫到,把遗漏日志补回来。
所以从系统设计上说:
- 实时链路负责快
- 扫描链路负责全
两者结合,系统才真正可用于生产环境。
六、实时和扫描两条链路,为什么要共用一个处理器
这套项目里一个我很认可的实现,是把两条链路的“后半段”完全收敛到 internal/monitoring/shared/processor.go。
无论日志来自实时订阅还是历史扫描,最终都会调用:
ProcessRealtimeLogProcessHistoricalLog
这两个方法内部再统一进入 processLog。
统一处理器做了几件事:
- 解析 event log
- 组装标准化字段
- 生成唯一 UID
- 写入
parsed_events_log - 成功插入后发送 RabbitMQ 消息
其中最核心的一段流程是:
parsedData, err := ethutil.DecodeLog(sub.ABIEvent, vLog)
uid := buildUID(vLog.TxHash.Hex(), int64(vLog.Index))
_, inserted, err := insertParsedEventLog(ctx, db, sub, vLog, uid, parsedData)
if !inserted {
return nil
}
这个顺序很关键。
它不是先发 MQ 再落库,而是先落库去重,成功后再发 MQ。这样同一条日志如果被处理过一次,第二次再进来时会因为唯一约束插入失败,后续 MQ 也不会重复发送。
当同一条日志被两条链路都处理到
假设某条日志信息是:
tx_hash = 0xabc123log_index = 7
实时监听先处理到它,系统会生成:
uid = 0xabc123:7
然后写入 parsed_events_log。
后面扫描器又扫到了同一条日志,也会生成相同的 UID,并尝试再次写库。由于 (tx_hash, log_index) 已经存在唯一记录,这次写入不会成功,inserted = false,函数会直接返回。
结果就是:
- 数据库不会重复插入
- RabbitMQ 不会重复发送
这就是“共享处理器 + 数据库唯一约束”组合带来的好处。
七、RabbitMQ 在这套系统里承担什么角色
当前项目里,RabbitMQ 的职责不是“存一份链上原始数据”,而是作为索引服务的统一对外分发出口。
处理器成功落库之后,会构造一个 EventMessage 并发布到 RabbitMQ。消息体里已经包含了业务消费需要的大部分上下文,比如:
uidevent_idchain_idcontract_idcontract_addressevent_nameevent_topicrouting_keyblock_numbertx_hashlog_indexsourceparsed_datapublished_at
其中 source 很有意思,当前实现会标记消息来自:
realtimescanner
这个字段在排查线上问题时很有帮助。你至少可以知道,这条消息到底是实时抓到的,还是后面补扫补回来的。
一条 RabbitMQ 消息长什么样
以一条 Transfer 事件为例,最终发出的消息大致会是这样:
{
"uid": "0xabc123:7",
"event_id": "b7d09b8d-7d63-4d9d-8d61-c1df2b86ef65",
"chain_id": 1,
"contract_id": "64537d88-020a-4e62-9f97-95e51f2d6b62",
"contract_address": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"event_name": "Transfer",
"event_topic": "0xddf252ad...",
"routing_key": "evm.transfer",
"block_number": 21000528,
"tx_hash": "0xabc123",
"log_index": 7,
"source": "realtime",
"parsed_data": {
"from": "0x1111...",
"to": "0x2222...",
"value": "1000000",
"contract_address": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"event_name": "Transfer",
"event_topic": "0xddf252ad..."
},
"published_at": "2026-03-23T10:00:00Z"
}
对于业务服务来说,这已经是一份可以直接消费的标准事件,不需要它再去连链节点,也不需要它再去手动解析 ABI。
下游消费者如何使用这条消息
比如资产服务订阅 evm.transfer,拿到消息后可以这样处理:
- 用
uid做幂等校验 - 从
parsed_data.from、parsed_data.to、parsed_data.value取出业务字段 - 根据
contract_address判断是哪个代币 - 根据
block_number和tx_hash建立业务流水
整个消费过程里,业务服务并不需要知道日志是从 WebSocket 订阅来的,还是从扫描器补回来的。它拿到的是统一的业务事件。
这就是 MQ 解耦最直接的价值。
八、当前实现里,有几个非常适合写进文章的工程细节
如果你只是写“我们实现了实时监听和区块扫描”,文章会显得比较虚。真正让读者觉得内容扎实的,往往是这些实现细节。
1. 订阅列表会定期刷新
实时监听服务不是只在启动时读一次配置,而是每分钟刷新一次订阅列表。
这意味着数据库配置更新后,系统可以动态增删订阅,不需要手工重启整个进程。
2. 订阅断了会自动重连
实时链路内部有一个循环,只要订阅退出且上下文没结束,就会等待 5 秒后重新建立连接。
这对线上环境非常重要,因为节点短暂抖动在实际使用里并不罕见。
3. RabbitMQ 发送失败会尝试重连后重发
当前 PublishEvent 的实现里,如果发送失败,会先 reset,再重新建立连接并重试一次发送。
这至少说明系统不是“MQ 一次失败就直接丢消息”的处理方式。
4. RabbitMQ 绑定是按 routing key 动态确保的
在加载订阅配置时,系统会对每个事件调用 EnsureBinding,确保对应的 routing key 已经和队列绑定。
也就是说,事件配置和消息路由是一起准备好的,不需要额外手工操作。
5. ABI 校验是在加载订阅时就做的
如果某个合约 ABI 非法,或者事件名在 ABI 中不存在,系统会跳过该配置,而不是等到运行时处理日志时才暴露问题。
这能把很多配置错误提前发现。
九、再补几个更贴近落地的场景
除了上面的流程例子,还可以在文章里多放一些更贴近落地的场景。
新增一个事件监听,不改代码只改数据库
假设你已经在监听某个 ERC20 合约的 Transfer 事件,现在还想监听 Approval 事件。
你不需要改 Go 代码,只需要往 monitor_events 再插一条记录:
INSERT INTO monitor_events (id, contract_id, event_name, mq_routing_key, status, last_block)
VALUES (
gen_random_uuid(),
'已有 contract_id',
'Approval',
'evm.approval',
1,
0
);
下一轮订阅刷新时,实时监听器就会自动发现这个新事件,并为它启动新的订阅协程。
这类例子很能体现“配置驱动”的价值。
禁用一个事件监听
如果某个事件不再需要监听,可以直接更新状态:
UPDATE monitor_events
SET status = 0
WHERE event_name = 'Approval';
下一轮刷新时,监听器会发现这条配置已经不在启用列表里,于是取消对应订阅。
这个过程同样不需要改代码,也不需要重启服务。
当前实现里的历史回补起点
当前实现里,如果 last_block = 0,扫描器会直接把当前最新块高作为起点:
func nextScanStart(lastBlock int64, latestBlock uint64) int64 {
if lastBlock > 0 {
return lastBlock + 1
}
return int64(latestBlock)
}
这意味着现在的扫描器更像“从当前时刻开始建立持续扫描进度”,而不是“第一次启动就自动回扫全历史数据”。
这个点在文章里很值得专门说明,因为它体现的是当前实现边界,不是理论上的理想状态。
你可以直接写:
当前版本的
nexus-chain更偏向生产监听链路搭建,而不是全历史索引器。首次启动时,扫描器会从当前最新块高开始建立扫描进度,因此如果有全历史回补需求,还需要额外扩展初始化块高或补数任务能力。
这样文章会显得更诚实,也更贴近工程实践。
日志解析后会自动补充上下文字段
在统一处理器里,解码完 ABI 之后,系统还会主动往 parsed_data 里补三个字段:
contract_addressevent_nameevent_topic
所以即使下游只看 parsed_data,也能拿到足够完整的上下文,而不必再次拼装。
这类“小设计”其实很有价值,因为它能降低下游的接入成本。
十、这套实现的优点和当前边界
如果站在工程落地角度看,nexus-chain 当前实现有几个非常明确的优点。
首先,它已经把索引服务最核心的骨架搭起来了:数据库配置驱动、实时监听、历史扫描、统一处理、幂等落库、消息投递。这说明这不是一个“只能本地跑 demo”的原型,而是一套已经具备基础生产思路的索引服务。
其次,实时和扫描两条链路共用处理器,减少了重复逻辑,也降低了两条链路处理行为不一致的风险。
再次,利用数据库唯一约束做去重,是一个简单但有效的工程选择,比在内存里维护复杂状态更稳。
但同时,当前实现也有一些非常适合在文章里诚实说明的边界。
第一,扫描器当前首次启动不会主动回扫历史全量数据,而是从当前最新块高开始。
第二,当前还没有看到区块确认数或重组处理逻辑。
第三,当前 HTTP API 还比较轻,主要只有健康检查,没有提供监听配置管理接口。
第四,monitor_contracts 表当前结构里没有直接存 rpc_url 和 ws_url,节点地址来自全局环境配置,这意味着不同合约使用不同节点的能力还没有落到当前 schema。
这些都不一定是问题,但它们是当前版本的真实边界。写进文章里,反而会让你的文章更像真正做过项目的人写的,而不是泛泛总结。
十一、如果我要把这套项目总结成一句话
如果让我用一句话概括 nexus-chain 当前这套实现,我会这样写:
nexus-chain本质上是一套基于数据库配置驱动的 EVM 链上索引服务,通过 WebSocket 实时监听和 RPC 定时扫描双链路采集链上事件,统一完成日志解析、幂等入库和 RabbitMQ 分发,为下游业务系统提供稳定、解耦的链上事件输入。
这句话基本可以作为文章开头摘要,或者结尾的系统总结。
十二、写在最后
很多时候,大家会把 Web3 索引服务理解成“监听合约事件”的一层薄逻辑。但真正落到工程实现里,你会发现它解决的其实是一个经典后端问题:
如何从一个外部、持续变化、可能不稳定的数据源里,稳定地拿到数据,保证尽量不丢、尽量不重,并把它可靠地交给下游系统。
nexus-chain 当前这套实现,已经把这个问题拆成了几个很明确的模块:
- 配置加载
- 实时监听
- 历史扫描
- 统一处理
- 幂等落库
- MQ 分发
这套结构的价值不在于“它多复杂”,而在于它已经具备了索引服务应有的基本工程形态。
对于链上业务来说,真正麻烦的从来不是“能不能监听到一条日志”,而是“服务挂了之后能不能补回来”“数据会不会重复”“业务方能不能方便地接入”。这些问题如果没有提前设计,后面几乎都会变成线上问题。
而索引服务的意义,恰恰就在于把这些问题集中解决。