入职第三周,我坐在工位前盯着终端里那一串 0x 开头的十六进制字符串——这是从 RPC 节点拉回来的原始交易日志。旁边是业务方刚提的需求:查某个合约过去三十天的转账记录,按小时聚合,能按地址、按金额筛选。我翻了半天区块数据,终于认怂:Web3 运维不掌握从 Hex 到 SQL 的整条链路,很多问题根本收不了尾。
这是我做 Web3 运维的第十一天。前十天,我还在用 curl 测 JSON-RPC、盯区块高度、检查验证节点同步延迟,这些都属于"节点活着就算成功"的范畴。但从这一天起,我发现运维的活开始变了——业务不再满足于"节点同步正常",而是要求"把链上数据变成可以查询的资产"。这不是 DBA 的专属任务,运维同样跑不掉,因为只有运维最清楚节点的数据从哪来、会有什么脏数据、同步哪里会断。于是,我花了整整一天,从 Hex 到 SQL,把一个能用的链上数据仓库搭了起来。
1. 为什么运维非得自己"造"一个数据仓库
1.1 从一次需求说起:业务方要的,节点给不了
先还原一下当时的需求。业务方那边发来一个合约地址,问了三件事:
- 过去三十天,这个合约的
Transfer事件每天发生多少次; - 按小时统计转账金额分布;
- 找出转账金额超过某个阈值的地址列表。
这三件事单独看都不复杂,但用 JSON-RPC 去查就非常难受。最直接的方案是调 eth_getLogs,在区块范围里过滤合约地址和事件主题,拿回来一堆日志对象。然而每一笔日志的 topics 和 data 都是 0x 开头的十六进制字符串,里面编码的是发送方、接收方、金额这些字段。想按金额筛选?得先把 hex 转换成十进制。想按小时聚合?得先把区块号映射到区块时间。想找出高频地址?还得自己写脚本统计。
更麻烦的是,eth_getLogs 的区块范围有上限,不同节点限制还不一样,有的只允许拉最近 10000 个区块。面对三十天的历史数据,这个方案基本等于不可用。我当时的第一反应是"数据量太大,不适合走 RPC",但业务方不会因为"不适合"就放弃需求。最后只能给自己搭一条数据管线。
1.2 链上数据的原生格式天然不适合查询
链上的数据形态和传统关系型数据库完全是两个物种。在一个区块里,你能拿到的是:
- 区块头:高度、哈希、时间戳、父哈希、状态根等;
- 交易列表:每笔交易包含
from、to、value、input、gasLimit等字段; - 交易收据:每笔交易产生的事件日志(
logs),日志里是原始的主题数组和十六进制 data。
这些字段在节点层面都是"一次性"的,节点只负责存储和广播,不负责给你做聚合分析。区块的数据结构也是按"块"组织的,不是按"业务对象"组织的。你想查"某个地址在某个时间段的转账记录",要么自己扫全链,要么先建索引。本质上,节点是一个原始数据源,不是一个查询引擎。
这就引出了自建数据仓库的核心价值:把链上无序的、十六进制编码的原始数据,转换成结构化的、可索引的、能跑 SQL 的关系型数据。运维在这里的角色,是把"查询链上数据"从小时级变成秒级,把业务方从"求节点接口"变成"连数据库就能查"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先从读懂 Hex 入手:解析链上原始数据
2.1 一条区块/交易到底长什么样
在动手建表之前,必须先把链上数据的结构摸清楚。用 eth_getBlockByNumber 拉一个区块,返回的核心字段大概是下面这种形式:
json复制{
"block": {
"number": "0x10d4f",
"hash": "0x1a2b3c...",
"timestamp": "0x65f0f1a2",
"parentHash": "0x...",
"transactions": []
},
"tx": {
"hash": "0x...",
"from": "0x...",
"to": "0x...",
"value": "0xde0b6b3a7640000",
"input": "0xa9059cbb0000000000000000000000004a2f8e...0000000000000000000000000000000000000000000000000de0b6b3a7640000"
},
"log": {
"address": "0x...",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
],
"data": "0x0000000000000000000000000000000000000000000000000000000000000000"
}
}
这里最让人头疼的是 input 和 data。它们是 ABI 编码的十六进制数据,把函数签名、事件字段按 32 字节对齐排列。比如 input 里的 0xa9059cbb 是 ERC-20 的 transfer(address,uint256) 函数选择器,后面跟着两个 32 字节参数:第一个是接收方地址(前 12 字节是 0 填充,后 20 字节是真实地址),第二个是金额(uint256 大端序)。
事件日志的 topics 也类似:topics[0] 是事件签名哈希,比如 ERC-20 Transfer 事件的哈希是固定的 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef。索引过的参数会占用 topics[1]、topics[2],非索引参数放在 data 里。
2.2 从十六进制到人话:手动解析事件的完整过程
理解了结构之后,解析其实就是一个"切字段"的活儿。以最常见的 ERC-20 Transfer 事件为例,当年我写了一个最简解析函数:
python复制import binascii
TRANSFER_EVENT_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
def decode_hex_padded(data: str) -> int:
return int(data, 16)
def parse_transfer_event(log: dict):
assert log["topics"][0].lower() == TRANSFER_EVENT_TOPIC
# topics[1] 是 from,topics[2] 是 to
# 都是 32 字节,地址只占后 20 字节
from_addr = "0x" + log["topics"][1][-40:]
to_addr = "0x" + log["topics"][2][-40:]
# data 里是 uint256 金额
value = int(log["data"], 16)
return from_addr, to_addr, value
这段代码的要点在于:
topics[1][-40:]:地址在 topic 里是 32 字节(64 个十六进制字符),前面 12 字节是零填充,真正有效的是后 20 字节(40 个字符)。直接取最后 40 个字符即可;int(log["data"], 16):金额是 uint256 大端序,Python 的int可以直接解析,不会像 JS 那样遇到超过Number.MAX_SAFE_INTEGER的数值就丢精度;- 大小写问题:topic 哈希可能带大写也可能带小写,比较前先统一
lower(),避免踩坑。
如果你不想手写解析,直接用 web3.py 的 contract.events.Transfer().process_receipt(receipt) 也可以,但底层逻辑还是上面这一套。手写一次的好处是,你会真正明白以太坊 ABI 编码是怎么回事,后面遇到自定义合约事件也不慌。
2.3 解析工具的选型与边界
在真正搭建数据仓库之前,我试过几种工具,给它们的适用边界做个总结:
| 工具/方法 | 适用场景 | 不适用场景 |
|---|---|---|
| Etherscan / 区块浏览器 API | 临时查看、单合约少量事件 | 大数据量批量拉取,容易触发限流 |
cast(Foundry 套件) |
命令行快速解析 calldata、事件 | 不适合做长期定时任务 |
Python + web3.py / eth_abi |
自定义解析逻辑,灵活可控 | 性能和并发需要自己优化 |
ethereum-etl |
全量导出、跨链数据管道 | 需要额外适配目标库,运维成本中等 |
对于这个第 11 日的项目,我最终选择了 Python + web3.py 做解析层,因为团队主语言是 Python,而且后续要对事件做复杂逻辑处理,脚本自由度更高。至于 ethereum-etl,我建议等已经理解了全链路之后再用,否则出了问题很难排查。
3. 自建数据仓库的分层设计与表结构落地
3.1 先定分层:ODS、DWD、DWS、ADS 各干什么
链上数据仓库和传统数仓的分层思想是一致的,但具体到每一层做什么,必须结合链上数据的特性来定。
- ODS 层(原始数据层):存储从节点直接拿到的区块、交易、日志、收据,几乎不做加工。字段保留 hex 原始格式,只做基本的数据类型转换(比如区块号从 hex 转成 bigint)。这一层的作用是保留原始数据,方便后续重算和排查问题。
- DWD 层(明细数据层):把 hex 字段解析成人能读懂的内容。地址统一小写(或统一校验和格式)、事件字段拆列、时间戳转成 UTC 时间、合约调用参数解码。这一层是查询的主力。
- DWS 层(汇总数据层):按业务维度做聚合,比如按小时/天统计转账量、活跃地址数、Gas 消耗。运维和业务常用指标直接在这里查,速度非常快。
- ADS 层(应用数据层):面向具体报表和告警接口的宽表,比如"过去 24 小时大额转账 TOP 100"、Grafana 面板直接读这类表。
为什么一定要分层?因为如果所有查询都压在解析过的明细上,每次都要扫描大量行,性能会很差。分层的作用是"一次解析、多次复用,一次聚合、处处快查"。尤其是链上数据量增长很快,不提前分层,三个月后仓库就变成谁也跑不动的死库。
3.2 PostgreSQL 建表实战:区块表、交易表、事件日志表
数据库我选了 PostgreSQL,原因很实际:初期数据量在几百万到几千万行级别,PG 完全能扛住;支持 JSONB,可以兜底存一些解析失败的原始结构;团队对 PG 比较熟,不需要额外引入 ClickHouse 或 BigQuery 这类重组件。等以后量级上来,再迁到更专业的 OLAP 引擎也不迟。
下面是我当天落地的三张核心表结构,这里给出精简后的 DDL:
sql复制-- ODS 层:区块表
CREATE TABLE ods_blocks (
block_number BIGINT PRIMARY KEY,
block_hash TEXT NOT NULL,
parent_hash TEXT NOT NULL,
block_time TIMESTAMPTZ NOT NULL,
tx_count INT NOT NULL,
raw JSONB
);
-- ODS 层:交易表
CREATE TABLE ods_transactions (
tx_hash TEXT PRIMARY KEY,
block_number BIGINT NOT NULL REFERENCES ods_blocks(block_number),
from_addr TEXT NOT NULL,
to_addr TEXT,
value NUMERIC NOT NULL,
input TEXT,
gas_used NUMERIC,
tx_status SMALLINT
);
-- ODS 层:事件日志表
CREATE TABLE ods_event_logs (
log_id BIGSERIAL PRIMARY KEY,
block_number BIGINT NOT NULL REFERENCES ods_blocks(block_number),
tx_hash TEXT NOT NULL,
log_index INT NOT NULL,
address TEXT NOT NULL,
topic0 TEXT,
topic1 TEXT,
topic2 TEXT,
topic3 TEXT,
data TEXT,
UNIQUE (tx_hash, log_index)
);
重点说明几个设计决策:
block_time用TIMESTAMPTZ,UTC 时区,绝不用本地时间。链上区块时间戳是 Unix 秒数,直接to_timestamp(...)转成 UTC 即可,查询时再用date_trunc按需要聚合。value和金额字段用NUMERIC,绝对不用FLOAT8。链上金额经常超过 2^53,FLOAT8会丢精度,这属于运维事故级别的坑。- 事件日志表加了唯一约束
(tx_hash, log_index),为后面的幂等插入做准备。
DWD 层我建了一张 dwd_transfer_log,把 Transfer 事件解析后的字段拆成独立列:
sql复制CREATE TABLE dwd_transfer_log (
block_number BIGINT NOT NULL,
block_time TIMESTAMPTZ NOT NULL,
tx_hash TEXT NOT NULL,
contract_address TEXT NOT NULL,
from_addr TEXT NOT NULL,
to_addr TEXT NOT NULL,
value NUMERIC NOT NULL,
PRIMARY KEY (tx_hash, log_index)
);
3.3 索引怎么建才能让查询不崩
建表容易,索引才是关键。链上数据查询通常是"按时间范围 + 按地址 + 按合约"的组合,所以索引要跟着查询走。我给几个实际用下来效果明显的索引:
sql复制CREATE INDEX idx_blocks_time ON ods_blocks (block_time);
CREATE INDEX idx_txn_block_from ON ods_transactions (block_number, from_addr);
CREATE INDEX idx_txn_block_to ON ods_transactions (block_number, to_addr);
CREATE INDEX idx_logs_address_topic0 ON ods_event_logs (address, topic0, block_number);
CREATE INDEX idx_transfer_from_time ON dwd_transfer_log (from_addr, block_time);
CREATE INDEX idx_transfer_to_time ON dwd_transfer_log (to_addr, block_time);
CREATE INDEX idx_transfer_contract_time ON dwd_transfer_log (contract_address, block_time);
这里我要特别提醒一句:别给 raw、input、data 这类大字段建 B-tree 索引,那样索引比表还大,查询也不会变快。真要全文检索时,等数据量上来了再用别的方案,初期完全没有必要。
4. 数据流水线:从节点同步到仓库的增量更新
4.1 同步方案对比:RPC 轮询、事件订阅、公共索引源
建好表之后,最核心的问题是:数据怎么持续不断地进仓库?我对比了几种常见方案:
| 方案 | 优点 | 缺点 | 我的判断 |
|---|---|---|---|
RPC 轮询(eth_getBlockByNumber) |
实现简单,能拿到完整区块和交易 | 并发高容易触发节点限流,性能一般 | 初期首选 |
| WebSocket 订阅新区块 | 实时性高,延迟低 | 断线续传麻烦,历史数据还要另补 | 适合做实时告警,不适合全量同步 |
| 公共索引 API(如 Etherscan) | 解析好、查询方便 | 受第三方限流,字段格式固定,改造成本高 | 不适合自建仓库 |
| 自建专用索引器 | 完整可控 | 复杂度高,还要维护节点状态 | 数据量大了再考虑 |
当天我直接走了 RPC 轮询路线。原因很朴素:我们已经自建了节点,不额外依赖第三方,而且轮询逻辑非常直观——从 last_block + 1 开始,一格格往最新高度同步。
4.2 增量同步的游标设计与幂等去重
增量同步最重要的是游标管理。我建了一张 sync_state 表,保存每个网络同步到哪了:
sql复制CREATE TABLE sync_state (
network TEXT PRIMARY KEY,
last_block BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
然后同步脚本运行时做的事情就是四步:
- 从
sync_state读取last_block; - 通过
eth_blockNumber获取节点最新高度; - 从
last_block + 1到min(latest_height, last_block + batch_size)拉取区块,解析后写入; - 全部写入成功后更新
last_block;如果有失败,保留游标不动,下次重试。
这里最关键的设计是幂等。区块数据不像传统数据库那样有明确的"一条记录",同一条交易可能因为重试被写入两次。为了避免主键冲突或重复计数,我在 ODS 表的唯一约束基础上,用 INSERT ... ON CONFLICT DO NOTHING 做插入:
sql复制INSERT INTO ods_blocks (block_number, block_hash, parent_hash, block_time, tx_count, raw)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (block_number) DO NOTHING;
这样即使脚本中途崩溃,下次重跑也不会产生脏数据。这个习惯救了我很多次,尤其是后面加入并发拉块后,重复写入的概率大大增加。
4.3 链重组(Reorg)是运维最怕的坑
链上数据有一个传统数据库没有的问题:已经入库的区块可能在链重组(reorg)时被推翻。意思是矿工或验证者短暂地在一条分叉上出了块,随后主链选择了另一条分叉,你刚同步的区块就成了"孤儿块"。
早期我没做 reorg 保护,在某个测试网上同步做得太激进,最后发现链回滚了 20 多个块,结果 DWS 聚合表里全是垃圾数据,只能按块号重刷。后来我做了两个措施:
- 延迟确认:不同步最新高度,而是同步到
latest_height - confirmations,confirmations 根据链的最终确定性设一个合理值,比如以太坊保守一点设 12~16,测试网设 20; - 哈希校验:每次新增区块前,检查上一块的
parent_hash是否和库里最后一块的block_hash一致,不一致就说明发生了 reorg,需要回滚游标并删除冲突区块。
python复制last_row = db.query("SELECT block_number, block_hash FROM ods_blocks ORDER BY block_number DESC LIMIT 1")
new_block = node.get_block(last_row.block_number + 1)
if new_block["parentHash"] != last_row.block_hash:
# 出现 reorg,回滚到 parentHash 匹配的位置
rollback_to(new_block["parentHash"])
这套逻辑加完之后,仓库才真正能放心用于业务统计。链上数据的 reorg 处理是运维最容易忽略、一旦忽略就会出大事故的点,建议所有想自建链上数据仓库的人优先考虑。
5. 运维日常:数据质量校验与巡检告警
5.1 数据完整性的三层校验法
数据仓库跑起来之后,不能指望脚本永远正确。运维的职责之一,是给数据质量上一道保险。我总结了三个层次的校验:
第一层:区块高度连续性。 检查 ODS 层有没有空洞,比如链上出了 1000 个新区块,仓库只同步到 997,中间缺了 3 个。用窗口函数可以快速查出缺口:
sql复制SELECT block_number + 1 AS gap_start,
next_block - 1 AS gap_end
FROM (
SELECT block_number,
lead(block_number) OVER (ORDER BY block_number) AS next_block
FROM ods_blocks
) t
WHERE next_block != block_number + 1;
第二层:区块哈希一致性。 节点返回的块哈希必须和仓库里的一致。每天跑一次抽查,随机抽几百个区块,调用 eth_getBlockByNumber 对比 hash。这一步能抓出很多同步错位问题。
第三层:业务对账。 拿 DWS 聚合结果和链上直接统计对比。比如当天某合约的 Transfer 事件总数,用 eth_getLogs 拉一遍当天日志数量,和 dwd_transfer_log 里按 contract_address 统计的数量对比,差异超过阈值就告警。这样能发现解析逻辑有没有出错。
这三层校验不需要都做成高频任务,第一层可以每 5 分钟跑一次,第二、三层每天定时跑即可。
5.2 告警阈值与并发控制:别让仓库拖垮节点
同步脚本如果太激进,会把 RPC 节点打满,导致节点自身同步也落后,形成恶性循环。我踩过这个坑之后,给同步脚本加了两个限制:
- 并发数控制在 8 以内,每个块拉取后先落 ODS,再批量解析到 DWD,避免长时间占用连接;
- 每次拉区块前检查节点最新高度和当前库内最新区块的差值,如果差值小于 10,说明已经追上,降低轮询频率;如果差值很大,才批量拉取。
告警规则方面,我最关注三个指标:
| 指标 | 告警条件 | 原因 |
|---|---|---|
| 同步落后高度 | latest_height - last_synced_block > 50 |
数据延迟,业务查询结果不准 |
| 解析失败率 | 某批次解析失败数超过 5% | 合约逻辑变更或解析代码有 bug |
| 仓库增长速率 | 单日新增数据量超过预估 2 倍 | 可能存在异常同步或数据膨胀 |
把这些指标接到 Grafana 上,用简单的 PromQL 或 SQL 查询就能实现。运维不是"搭完就跑",数据质量监控才是长期工作。
5.3 踩坑记录:时区、大小写与地址校验和
这部分是我真正"付费"学到的教训,专门记下来:
- 时区陷阱:区块时间戳是 Unix 秒数,转换时如果用
datetime.fromtimestamp(ts)会得到服务器本地时间,导致聚合结果差 8 个小时(国内服务器)。正确做法是先datetime.fromtimestamp(ts, tz=timezone.utc),再存TIMESTAMPTZ。 - 地址大小写:EIP-55 校验和地址包含大写字母,如果直接存储、直接比较,可能出现
0xabc...和0xABC...被认为是两个地址的情况。我的建议是 DWD 层统一lower(),查询时也统一转小写,避免误判。 - 金额精度:ERC-20 的
value是原始单位,和decimals相关的展示精度不同。比如 USDC 是 6 位小数,ETH 是 18 位。聚合之前先明确单位,否则报表会差好几个数量级。最好在建表时就加一列decimal字段,或者在 DWS 层统一换算。
6. SQL 上岗:把数据仓库用在真实运维场景
6.1 场景一:按小时统计合约转账量
业务方要的第一个结果,现在就是一条 SQL 的事:
sql复制SELECT date_trunc('hour', block_time) AS hour,
count(*) AS transfer_count,
sum(value) / 1e18 AS total_value
FROM dwd_transfer_log
WHERE contract_address = lower('0x...')
AND block_time >= now() - interval '30 days'
GROUP BY 1
ORDER BY 1;
这里 sum(value) / 1e18 是因为该合约的 decimals 是 18。如果合约是 6 位小数,就该除以 1e6。实际中我会先在配置表里维护好每个合约的 decimals,查询时动态计算,避免写死。
6.2 场景二:定位异常交易和可疑地址
链上安全事件频繁,运维经常被问到"这个地址最近转出了多少""有没有异常的大额转账"。这类查询在数据仓库里非常顺手:
sql复制SELECT from_addr,
count(*) AS tx_count,
sum(value) / 1e18 AS total_value
FROM dwd_transfer_log
WHERE block_time >= now() - interval '7 days'
GROUP BY from_addr
HAVING count(*) > 100
ORDER BY total_value DESC
LIMIT 100;
查出高频和高金额地址之后,再结合 ods_transactions 里的 input 数据做进一步分析,整个过程从“不可能”变成“分钟级”。
6.3 场景三:给 Grafana 喂数据写告警
数据仓库的价值不只是给人查,还可以直接作为监控数据源。我做了两个面板:
第一个面板是"数据新鲜度",用一条 SQL 查出仓库里最新的区块时间:
sql复制SELECT max(block_time) AS latest_block_time
FROM ods_blocks;
再配一个告警规则:如果最新区块时间距现在超过 5 分钟,就触发告警。这比盯节点日志直观得多。
第二个面板是"大额转账实时监控",直接查 dwd_transfer_log 里最近 5 分钟超过阈值的转账:
sql复制SELECT block_time, tx_hash, from_addr, to_addr, value / 1e18 AS amount
FROM dwd_transfer_log
WHERE block_time >= now() - interval '5 minutes'
AND value / 1e18 > 10000
ORDER BY value DESC;
通过 Grafana 的 webhook 通知到钉钉或 Slack,异常转账基本能做到分钟级感知。
7. 第 11 日复盘:从 Hex 到 SQL 的认知转变
7.1 我在这个过程中犯过的错
这一天并不顺利,我至少犯了三个低级错误:
第一个是在生产节点上直接跑全量同步脚本。当时想着省事,结果并发拉区块把节点的 HTTP 连接池占满了,节点同步高度直接落后,吓得我赶紧把脚本杀了,改成限速同步。
第二个是没有先做 reorg 保护。测试网上一度同步到 10000 多块,结果 reorg 回滚,我又愣是花了一个多小时修复,才去处理聚合表。
第三个是用 FLOAT8 存了 value 列。因为早期测试时数据量小,没发现精度问题,等到统计大额转账时发现金额差了几千美金才意识到。后来把 value 改成 NUMERIC,再重刷全表。
这三个错误放在一起,反映的是一个共性问题:把链上数据当成了普通数据库数据来处理,忽略了区块链特有的高度游标、reorg、精度这三个特性。这也是为什么我觉得自建链上数据仓库这件事,运维必须亲自上手一遍,光靠读文档是体会不到这些坑的。
7.2 给刚转 Web3 运维的朋友的五条建议
这一天的经历让我对一个好的链上数据管道有了更具体的判断,也给后来人留几条建议:
- 先小步快跑,别一上来就整分布式。单机 PostgreSQL + 限速 RPC 轮询能撑住绝大多数初期场景,结构比性能重要,先跑通链路再优化。
- 数据结构设计比脚本性能重要。地址、金额、时间这些字段类型一开始就要定对,否则后面重刷成本极高。
- 把"节点运维"思维升级为"数据运维"思维。节点只是起点,数据可信、可用、可追踪才是最终目标。
- 善用工具但不要依赖工具。
ethereum-etl、web3.py、cast都能帮你减少重复造轮子,但底层原理必须懂,否则出问题只能抓瞎。 - 数据校验永远要做。不管脚本写得多完美,都要有对账机制,因为链上数据是外部系统产生的,它的变化很多时候不归你控制。
最后再分享一点个人体会:自建链上数据仓库这件事,表面上是"从 Hex 到 SQL"的技术活,实际上真正的难点是建立一种"数据可信"的工作习惯。当你把区块高度、交易哈希、事件日志这些原始素材变成能随时回答业务问题的数据资产时,运维的价值就不再只是"保证节点在线",而是真正参与到了业务的数据基建里。这一天过得狼狈,但回头看,是值得的。
