Crypto OS
Technical Crypto OS第三阶段 · Full-stack Onchain Application

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_hashblock_number 就能回答,第三个问题需要你还存了那个区块的哈希——否则重组发生时,你根本不知道自己手里的数据是哪条分叉上的。

建立模型

三类表,职责严格分开:

  1. Fact 事实表 只追加
  2. Derived 派生表 可重建
  3. 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 必须在主键里。 多链之后,不同链的区块高度会重叠。少了这一列,两条链的数据会互相覆盖,而且症状极其诡异。

amountnumeric(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 Model事件模型

以链上日志为事实、以派生表为投影的数据组织方式。事实只追加,投影可随时重建。

它和事件溯源(Event Sourcing)是同一个思路,但在 Crypto 里你连「设计事件」这一步都省了——链已经替你定义好了事件,而且它们是不可篡改的

Natural Key自然键

用数据本身的属性做主键,而不是数据库生成的自增 ID。

在这里它是「链 ID + 区块高度 + 日志序号」。用它做主键,幂等入库是数据库帮你完成的,不需要写一行去重代码。

Upsert插入或更新

插入时若主键冲突则改为更新(或什么都不做)的写法。

Crypto 场景里的两种用法要分清:事实表用「冲突就什么都不做」,派生表用「冲突就更新」。用反了,要么重复入库,要么数据翻倍。

Checkpoint断点

记录「已经处理到哪个区块」的游标。它必须同时包含高度和区块哈希——只有高度的 checkpoint 无法检测重组。

它和数据必须在同一个事务里提交。这一条在 T17 会被反复强调,因为它的两种错法后果完全不同。

Derived Table派生表

由事实表推导出来的表,用于加速查询:余额、持仓、排行、统计。

判断一张表是不是合格的派生表,只有一个标准:把它整张删掉,能不能从事实表完整重建? 不能,说明它里面混进了事实,那就是你未来对不上账的地方。

Finality Depth确认深度

一个区块需要经过多少个后续区块,才被你的业务当作不可逆。

它是业务参数而非技术参数:展示类数据可以 0 确认,资金结算可能要几十个。把它写进配置,而不是散在代码里。

动手

动手为一个协议的转账事件设计表结构,并写出重复入库不会出错的写入逻辑PostgreSQL + 任意语言 + 测试网 RPC0 元,全程测试网

全程使用测试网和公共只读端点,不需要任何私钥。 这一章不发交易。

验收标准只有一条,但它很硬:同一段区块跑任意多遍,数据库的内容完全一致。

建三张表。

把上面的 transfer_eventsblocksindexer_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

AI Lab让 AI 设计一份 Schema,再用一次链重组的场景检验它会不会产生脏数据Level 2 · AI Copilot

两步,第二步是真正的考题:

第一步:
为一条链上某个 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 会讲数据基础设施怎么系统地处理这个问题。

如果要同时索引 20 条链

chain_id 进主键这件事,从「良好习惯」变成「不做就崩」。不同链的高度会大量重叠,少了这一列,数据互相覆盖。

同时冒出来的还有一堆新问题:每条链的确认深度不同、出块速度差几十倍、数量精度规则可能不同、区块时间的可信度也不同。

这些差异应该全部收在一张配置表里,而不是散在代码的 if 分支里。写死一条链的参数,是多链化时最大的技术债。

如果事实表涨到十亿行

block_number 范围做分区。好处不只是查询变快:删除整段区块变成了删除一个分区,重组回滚和历史归档都快得多。

另一件事会同时发生:按地址查历史的索引会变得非常大。这时候通常要引入专门的查询表或者列式存储,而事实表退回它最本质的角色——一份可以重放出一切的原始记录

这正是它该在的位置。

如果产品要查「去年某一天某个地址的余额」

你的 token_balances 表只有当前值,答不了这个问题。

两条路:从事实表重放到那个高度(慢但不占空间,且永远正确),或者定期存快照(快但占空间,且要决定快照间隔)。

大多数系统会选混合:按天存快照,查询时从最近的快照往前重放一小段。这个方案之所以可行,前提仍然是事实表完整——又一次回到同一个地方。

带走的问题

1
它解决什么问题?

它解决什么问题?这一章解决的是「怎样让链上数据在你的系统里仍然保持可验证」。落库的过程最容易把可验证性丢掉——一旦丢了,你的数据库就成了一个谁都无法核对的黑盒。

9
谁承担风险?

谁承担风险?数据错了,用户看到错误的余额、错误的记录,而你可能几个月都不知道。这类错误的特征是静默——它不报警,只是慢慢和现实分叉。定期对账是唯一能及时发现它的手段。

17
AI 错误时谁承担损失?

AI 错误时谁承担损失?这一章的 AI Lab 让模型设计 Schema,而模型最容易犯的恰恰是「自增 ID 做主键」这种在普通业务里完全正确、在这里却致命的错误。责任在使用它的人,所以 verify 清单里的每一条都要求你在真库上跑一遍。

本章自测

一句话带走

以事件为事实,以区块高度为版本,任何一行都要能追回它的来源交易。

做完这一章的动手环节了?勾上它查看全部进度

本页目录