T16 · 数据库与链上数据
链上数据应该怎样落库?
- 练习的能力
- BuilderOnchain Literacy
- 动手
- 为一个协议的转账事件设计表结构,写出重复入库不会出错的写入逻辑。
- AI Lab
- 让 AI 设计一份 Schema,自己用一次链重组的场景检验它会不会产生脏数据。
一个现实问题
你建了一张表,三个字段:
create table balances (
address text primary key,
token text,
amount numeric
);索引器跑起来,每读到一条转账事件就 update。第一天数字看着都对,你很满意。
第三天,运营说有个地址的余额不对,少了一笔。
你打开这张表,看着那一行:地址、代币、一个数字。然后你意识到一件事——
你没有任何办法查出这个数字是怎么来的。
它是几百次加减的累积结果。你不知道少的那笔是哪一笔,不知道是在哪个区块漏的,不知道是漏了一次转入还是多算了一次转出,甚至不知道这个错误是今天产生的还是三天前就有了。
你唯一能做的是:清库,从头重跑。跑完对上了——但你仍然不知道为什么错过,所以你也不知道它会不会再错一次。
三天后它又错了。
这不是一个 bug,这是一个数据模型问题。 这张表从建立的第一天起,就丢掉了链上最有价值的东西。
思想实验
会计有两种记账方式。
第一种:只记余额。 一张纸,上面写着「现金:8500」。花了 300,划掉,改成 8200。收了 1000,划掉,改成 9200。
这种账的特点是:看当前状态最快,查任何问题都不可能。 一个月后发现少了 200 块,你唯一的线索是纸上那个数字,而它什么都不告诉你。
第二种:记流水,余额由流水算出来。 每一笔都有日期、对方、金额、凭证号。余额不是一个被维护的数字,是一个从流水推导出来的结论。
这种账查起来慢一点,但它有一个压倒性的优势:任何一个数字都能被追回到一张凭证。对不上账时,你可以逐笔核对,可以只重算某一段,可以在发现某张凭证作废后只撤销它的影响。
现在回头看 T1 讲的那件事:链本身就是第一种账的反面。 它存的是交易(流水),状态是被推导出来的。链之所以能让任何人独立验证同一份历史,正是因为它记的是流水。
那么问题就很尖锐了:
链花了这么大代价记流水,你在自己的数据库里把它压成了一个数字。
你不仅丢掉了可追溯性,还丢掉了这条链最核心的性质。
而且还有一件第一种账根本无法处理的事:链上的凭证是会被撤回的。 链头重组时,你昨天记下的那几笔可能从来没发生过。如果你只有一个数字,你连「该撤回哪一笔」都不知道。
你来决定
给一个协议的转账事件设计表结构。四种做法:
观察结果
四种做法的差别,可以用四个问题问出来:
| 能追溯到交易 | 能从任意高度重放 | 能回滚重组 | 查询成本 | |
|---|---|---|---|---|
| 只存最终状态 | 否 | 否 | 否 | 最低 |
| 只存事件流水 | 是 | 是 | 是 | 高 |
| 流水 + 物化余额 | 是 | 是 | 是 | 低 |
| 原样存 JSON | 是 | 勉强 | 勉强 | 很高 |
前三个问题全都指向同一件事:这一行数据的出处还在不在。
于是这一章的规则可以写成一句话,贴在你的表设计评审清单上:
数据库里的每一行,都必须能回答三个问题:它来自哪一笔交易?它属于哪个区块高度?那个区块现在还在链上吗?
第三个问题最容易被漏掉,也最要命。前两个问题只要存了 tx_hash 和 block_number 就能回答,第三个问题需要你还存了那个区块的哈希——否则重组发生时,你根本不知道自己手里的数据是哪条分叉上的。
建立模型
三类表,职责严格分开:
- Fact 事实表 只追加
- Derived 派生表 可重建
- Checkpoint 进度表 记录游标
事实表:主键就是链上坐标
-- 一行 = 链上的一条日志。只追加,永不 update。
create table transfer_events (
chain_id integer not null,
block_number bigint not null,
log_index integer not null,
block_hash bytea not null, -- 重组检测靠它
block_time timestamptz not null, -- 来自区块头,不是 now()
tx_hash bytea not null,
tx_index integer not null,
contract bytea not null,
from_addr bytea not null,
to_addr bytea not null,
amount numeric(78,0) not null, -- 256 位整数放得下
primary key (chain_id, block_number, log_index)
);
create index on transfer_events (chain_id, contract, from_addr, block_number);
create index on transfer_events (chain_id, contract, to_addr, block_number);这段 DDL 里有六个决定,每一个都对应一类事故:
主键是链上坐标,不是自增 ID。 一条日志在链上的位置由「哪条链、哪个区块、区块里第几条日志」唯一确定。用它做主键,重复入库天然被拒绝——这是幂等入库的全部基础。用自增 ID 的表,重跑一次数据就翻一倍。
chain_id 必须在主键里。 多链之后,不同链的区块高度会重叠。少了这一列,两条链的数据会互相覆盖,而且症状极其诡异。
amount 用 numeric(78,0)。 链上的数量是 256 位无符号整数,bigint 装不下,float 会丢精度——用浮点数存代币数量,是这一行最不可原谅的错误。78 位十进制足够覆盖 256 位整数的范围。
block_hash 一定要存。 它是你判断「我手里这条数据属于哪条分叉」的唯一依据。不存它,重组之后你的数据会永久变脏,而且你不会知道。
block_time 来自区块头,不是 now()。 用入库时间当业务时间,会让所有按时间的统计在回填时全部错位——backfill 一年前的数据,时间戳全都是今天。
地址和哈希的表示要统一。 用 bytea 最省也最不会出错。如果用文本,必须全局统一成小写,否则同一个地址会因为大小写不同而变成两行,并且所有 join 都会莫名其妙地少数据。
进度表与区块表
-- 每条链一行:索引到哪了。
create table indexer_checkpoints (
chain_id integer primary key,
last_block_number bigint not null,
last_block_hash bytea not null,
updated_at timestamptz not null default now()
);
-- 保留最近若干个区块的哈希链,用于重组回溯。
create table blocks (
chain_id integer not null,
block_number bigint not null,
block_hash bytea not null,
parent_hash bytea not null, -- 校验连续性靠它
block_time timestamptz not null,
primary key (chain_id, block_number)
);blocks 表不是可选项。没有它,你只能知道「我索引到了 1000 号」,但不知道「我当时看到的 1000 号长什么样」——重组检测就无从谈起。T17 会把它用到极致。
幂等入库:一条语句
insert into transfer_events
(chain_id, block_number, log_index, block_hash, block_time,
tx_hash, tx_index, contract, from_addr, to_addr, amount)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (chain_id, block_number, log_index) do nothing;就这么多。同一段区块重跑一百遍,行数不会变。幂等不是靠代码里判断「这条我处理过吗」,是靠主键。
派生表:最容易写错的地方
create table token_balances (
chain_id integer not null,
contract bytea not null,
holder bytea not null,
balance numeric(78,0) not null,
updated_at_block bigint not null,
primary key (chain_id, contract, holder)
);现在是关键问题:怎么更新它?
一个自然的写法是「插入事件,然后给收款方加上金额」。这个写法在重跑时会把余额加两遍——因为事件插入被 do nothing 挡住了,而余额更新照常执行。
正确的做法是把两件事绑进同一条语句,让余额只在「事件确实是第一次插入」时才动:
with inserted as (
insert into transfer_events
(chain_id, block_number, log_index, block_hash, block_time,
tx_hash, tx_index, contract, from_addr, to_addr, amount)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (chain_id, block_number, log_index) do nothing
returning chain_id, block_number, contract, from_addr, to_addr, amount
),
deltas as (
select chain_id, contract, to_addr as holder, amount as delta, block_number from inserted
union all
select chain_id, contract, from_addr as holder, -amount as delta, block_number from inserted
)
insert into token_balances as b (chain_id, contract, holder, balance, updated_at_block)
select chain_id, contract, holder, delta, block_number from deltas
on conflict (chain_id, contract, holder) do update
set balance = b.balance + excluded.balance,
updated_at_block = greatest(b.updated_at_block, excluded.updated_at_block);这段 SQL 值得读三遍。它的关键在 returning:冲突时 do nothing 不返回任何行,于是 deltas 为空,余额一行都不会动。
把这句话记住:
把「这条日志是不是第一次见到」和「要不要更新派生表」绑在同一个事务的同一条语句里。
分开写,就会在重跑、重试、并发这三种情况下各错一次。
重组时怎么删
事实表按高度删就行:
begin;
delete from transfer_events where chain_id = $1 and block_number > $2;
delete from blocks where chain_id = $1 and block_number > $2;
update indexer_checkpoints
set last_block_number = $2,
last_block_hash = (select block_hash from blocks where chain_id = $1 and block_number = $2)
where chain_id = $1;
commit;但派生表删不掉——它是累加出来的,删掉事件不会自动把余额减回去。
两条路:
- 可重算:把受影响的 holder 的余额清零,从事实表重新聚合。慢,但绝对正确,而且实现简单。
- 记录来源:派生表的每次变更都记一行「由哪条日志引起」,回滚时按高度反向应用。快,但表大一倍,逻辑复杂一倍。
第一版永远选第一条。 正确性比性能重要,而且重算的范围通常很小——重组只影响链头的几个区块,受影响的地址是有限的。
Redis 放什么
一句话:Redis 里的任何数据丢了,都必须能从 Postgres 重建。
可以放的:查询缓存、去重窗口、限流计数、排行榜、最新高度。 不能放的:任何一条「链上发生过什么」的事实。
理由不是 Redis 不可靠,而是事实只能有一份。放两份,迟早分叉,而且你会在最需要它的时候发现分叉。
它叫什么
以链上日志为事实、以派生表为投影的数据组织方式。事实只追加,投影可随时重建。
它和事件溯源(Event Sourcing)是同一个思路,但在 Crypto 里你连「设计事件」这一步都省了——链已经替你定义好了事件,而且它们是不可篡改的。
用数据本身的属性做主键,而不是数据库生成的自增 ID。
在这里它是「链 ID + 区块高度 + 日志序号」。用它做主键,幂等入库是数据库帮你完成的,不需要写一行去重代码。
插入时若主键冲突则改为更新(或什么都不做)的写法。
Crypto 场景里的两种用法要分清:事实表用「冲突就什么都不做」,派生表用「冲突就更新」。用反了,要么重复入库,要么数据翻倍。
记录「已经处理到哪个区块」的游标。它必须同时包含高度和区块哈希——只有高度的 checkpoint 无法检测重组。
它和数据必须在同一个事务里提交。这一条在 T17 会被反复强调,因为它的两种错法后果完全不同。
由事实表推导出来的表,用于加速查询:余额、持仓、排行、统计。
判断一张表是不是合格的派生表,只有一个标准:把它整张删掉,能不能从事实表完整重建? 不能,说明它里面混进了事实,那就是你未来对不上账的地方。
一个区块需要经过多少个后续区块,才被你的业务当作不可逆。
它是业务参数而非技术参数:展示类数据可以 0 确认,资金结算可能要几十个。把它写进配置,而不是散在代码里。
动手
全程使用测试网和公共只读端点,不需要任何私钥。 这一章不发交易。
验收标准只有一条,但它很硬:同一段区块跑任意多遍,数据库的内容完全一致。
建三张表。
把上面的 transfer_events、blocks、indexer_checkpoints 原样建出来。
建完之后自己问一遍那三个问题:任意一行,能不能追到交易?能不能知道高度?能不能判断那个区块还在不在链上?
拉 500 个区块的日志入库。
在测试网上挑一个活跃的代币合约,用 eth_getLogs 按区块范围拉取 Transfer 事件(T1 的 Lab 做过这一步)。
同时把区块头也拉下来写进 blocks 表,parent_hash 不要漏。
重跑三遍,验证行数不变。
把同一段区块再跑两遍,每次跑完执行:
select count(*) from transfer_events;三次结果必须完全一致。如果变多了,说明你的主键设计或 on conflict 写错了——这是最基础的一关,过不了就不用往下走。
加派生的余额表,用那条 CTE 写法。
把 token_balances 建起来,用上面那段带 with inserted as (...) 的语句写入。
然后再重跑一遍那 500 个区块。余额必须一个数字都不变。
如果余额翻倍了,说明你把事件插入和余额更新拆成了两条语句——回去合并。这一步是整个 Lab 的核心。
伪造一次重组。
假设当前索引到了高度 N。手动执行:
delete from transfer_events where chain_id = $1 and block_number > $2; -- $2 = N - 5
delete from blocks where chain_id = $1 and block_number > $2;然后把 token_balances 里受影响的地址清零并从事实表重算,把 checkpoint 退回 N-5。
再从 N-5 重跑到 N。重跑之后的余额表必须和删除之前完全一致。
这一步验证的是「派生表可重建」。做不到,就说明你的余额表里混进了事实。
做一次对账。
随机抽 20 个 holder,用 RPC 调 balanceOf 查它们在当前高度的真实余额,和你库里的数字逐个对比。
对不上是正常的,重要的是你现在能查出原因——因为你有流水。常见原因:这个代币有转账时扣费的行为、有 mint/burn 没被 Transfer 覆盖到、或者你漏了某个区块。
T1 的「真实案例」列了几种,回去对照一下。
写一条校验和查询,为 T17 做准备。
select md5(string_agg(t.tx_hash::text || t.amount::text, '|'
order by t.chain_id, t.block_number, t.log_index))
from transfer_events t
where t.chain_id = $1 and t.block_number <= $2;把它存下来。T17 的验收标准就是:从任意高度重跑之后,这个校验和不变。
AI Lab
两步,第二步是真正的考题:
第一步:
为一条链上某个 ERC-20 代币的 Transfer 事件设计 PostgreSQL 表结构。
需求:支持按地址查历史、按地址查当前余额、支持多条链、数据量到亿级。
给出完整 DDL 和写入语句。
第二步:
现在发生了一次深度为 5 个区块的链重组:我已经入库的最后 5 个区块被回滚,
链上换成了另外 5 个区块,其中有 3 条转账事件消失了、多了 2 条新的。
按你给的 Schema 和写入逻辑,逐步说明:
1. 我怎么发现重组发生了?
2. 我要删掉哪些行?用什么语句?
3. 余额表会变成什么样?还对吗?
4. 重新索引这 5 个区块之后,余额表和重组前的正确值一致吗?第一步模型基本都答得出来,但十有八九会用自增 ID 做主键——因为绝大多数建表教程都这么写。这一条是最高频的错误,也是最致命的:自增 ID 的表重跑一次数据就翻一倍。
第二步会暴露出真正的问题。最常见的两个失败是:它检测重组的办法是比较高度而不是比较哈希(那就永远检测不到),以及它删掉了事件却没管余额表(余额永久变脏)。
如果它第二步答得不错,再追加一问:「如果我的余额表是靠累加维护的,你怎么保证重算之后和重组前的正确值完全一致?」这一问能把「说得对」和「真的想清楚了」区分开。
每一条都自己在真库上跑一遍。 Schema 的错误在 review 时看起来都很合理,只有在数据进去之后才会显形。
AI 说完之后,你必须自己验证
- 主键是链 ID 加区块高度加日志序号,还是一个自增 ID——自增 ID 的表无法幂等,必须改
- chain_id 在不在主键里:不在的话,接第二条链时两条链的高度会互相覆盖
- 数量字段的类型能不能放下 256 位整数:bigint 会溢出,浮点会丢精度
- 有没有存 block_hash 和 parent_hash——没有这两列,重组根本检测不了
- 它的 upsert 语句在同一段区块重跑时,会不会把派生表的余额加两遍:自己跑一遍验证
- 它有没有把区块时间写成 now()——这会让所有回填数据的时间戳全错
- 地址字段的大小写有没有统一规则,还是留给调用方随便传
- checkpoint 的更新和数据写入在不在同一个事务里
真实案例
开头那个场景。一张 balances 表,出了问题唯一的手段是从创世块重跑。
它的真正代价不是重跑那几个小时,是你永远不知道为什么错。修不了根因,就只能等它再错一次。这类系统通常会演化出一个荒诞的运维习惯:每周定期清库重跑一次「保平安」。
教训:可追溯性不是锦上添花,它是你排查问题的唯一入口。
用 bigint 存代币数量。绝大多数代币的日常转账都在范围内,所以跑了很久都没事。
直到某个精度 18 位的代币出现了一笔大额转账,数字超过了 bigint 的上限。运气好的报错,运气差的静默截断——账目从此永久错误,而且看不出哪里错了。
教训:链上数量是 256 位整数,用能装下它的十进制类型。这不是优化,是正确性。
一部分数据从 RPC 来,是小写;一部分从某个接口来,带校验和大小写。两者写进同一列。
结果:同一个地址在库里有两行,余额被拆成两半;所有 join 都少数据;用户查自己的记录只看到一半。
更糟的是这个问题不报错,只是让数字悄悄变小。教训:地址要么统一存二进制,要么在入库前强制小写,并且在数据库层面加约束。
索引器只记了「处理到 1000 号」。链头重组,1000 号换了内容,索引器接着从 1001 号往下跑。
它永远不会发现 995 到 1000 那几个区块的数据已经不属于当前链了。这些数据会一直留在库里,混在正确数据中间,不会被任何查询标记出来。
几个月后有人发现对不上账,排查时最痛苦的一点是:库里没有任何信息能告诉你哪些行是脏的。
教训:block_hash 是一列非常便宜的保险。T17 会把它变成完整的重组处理机制。
改一个变量
同一个合约地址,升级前后发出的事件结构不同。用一张表硬接,要么解析失败,要么字段错位。
处理办法是给事实表加一列「事件版本」或「ABI 版本」,按区块高度区分:某个高度之前用旧 ABI 解析,之后用新的。
更深的一层教训:合约地址不是稳定的类型标识。 设计 Schema 时不要假设「这个地址的事件永远长这样」——T25 会讲数据基础设施怎么系统地处理这个问题。
chain_id 进主键这件事,从「良好习惯」变成「不做就崩」。不同链的高度会大量重叠,少了这一列,数据互相覆盖。
同时冒出来的还有一堆新问题:每条链的确认深度不同、出块速度差几十倍、数量精度规则可能不同、区块时间的可信度也不同。
这些差异应该全部收在一张配置表里,而不是散在代码的 if 分支里。写死一条链的参数,是多链化时最大的技术债。
按 block_number 范围做分区。好处不只是查询变快:删除整段区块变成了删除一个分区,重组回滚和历史归档都快得多。
另一件事会同时发生:按地址查历史的索引会变得非常大。这时候通常要引入专门的查询表或者列式存储,而事实表退回它最本质的角色——一份可以重放出一切的原始记录。
这正是它该在的位置。
你的 token_balances 表只有当前值,答不了这个问题。
两条路:从事实表重放到那个高度(慢但不占空间,且永远正确),或者定期存快照(快但占空间,且要决定快照间隔)。
大多数系统会选混合:按天存快照,查询时从最近的快照往前重放一小段。这个方案之所以可行,前提仍然是事实表完整——又一次回到同一个地方。
带走的问题
它解决什么问题?这一章解决的是「怎样让链上数据在你的系统里仍然保持可验证」。落库的过程最容易把可验证性丢掉——一旦丢了,你的数据库就成了一个谁都无法核对的黑盒。
谁承担风险?数据错了,用户看到错误的余额、错误的记录,而你可能几个月都不知道。这类错误的特征是静默——它不报警,只是慢慢和现实分叉。定期对账是唯一能及时发现它的手段。
AI 错误时谁承担损失?这一章的 AI Lab 让模型设计 Schema,而模型最容易犯的恰恰是「自增 ID 做主键」这种在普通业务里完全正确、在这里却致命的错误。责任在使用它的人,所以 verify 清单里的每一条都要求你在真库上跑一遍。
本章自测
因为自增 ID 对「同一条日志」没有任何约束。重跑一段区块,同样的日志会被插入第二次,拿到一个新 ID,数据翻倍。
用「链 ID + 区块高度 + 日志序号」做主键,重复插入由数据库直接拒绝,配上 on conflict do nothing,幂等就是免费的——不需要写一行去重代码。
这也是为什么可重放的索引器从表设计的第一行就开始了。
一个标准:整张删掉,能不能从另一张表完整重建?
能重建的是派生表:余额、持仓、统计、排行。它们只是投影,删了再算一遍就有了。
不能重建的是事实表:链上日志。它是真相的副本,删了就得回链上重新拉。
如果你有一张表既不能重建、又不是从链上直接来的——比如后端自己算出来的某个数字——那它就是你未来对不上账的地方。这条规则在 T15 的结尾已经埋过一次。
因为它们必须共享同一个判断:这条日志是不是第一次见到。
拆成两条语句时,重跑会出现这种情况:事件插入被 do nothing 挡掉了(正确),但余额更新照常执行(错误),于是余额被加了两遍。
用 with inserted as (... returning ...) 把两者绑在一起,冲突时 returning 不返回行,后面的余额更新自然跳过。一条语句,两种正确性。
因为高度不能唯一确定一个区块。重组之后,同一个高度上会是另一个区块,内容完全不同。
存了 block_hash,你才能回答「我手里这条数据属于哪条分叉」,才能在重组时精确地找到分叉点并只删掉受影响的部分。
不存它,你的重组处理只有两种结局:要么检测不到,要么只能全量重跑。 T17 会把这一列用成整个重组机制的支点。
没有标准答案,检查这六件事:
- 主键是链上坐标吗?
chain_id在里面吗? - 数量字段能装下 256 位整数吗?有没有用浮点数?
- 存了
block_hash和parent_hash吗? - 时间字段来自区块头还是
now()? - 派生表整张删掉,能从事实表重建吗?
- checkpoint 里有区块哈希吗?它和数据写入在同一个事务里吗?
第 5 条和第 6 条是 T17 的入场券。能把这六条都答对,你的索引器已经赢了一半。
一句话带走
以事件为事实,以区块高度为版本,任何一行都要能追回它的来源交易。