T17 · Indexer
区块重组以后,你的数据怎么办?
- 练习的能力
- BuilderOnchain Literacy
- 动手
- 写一个最小索引器,能从任意高度重跑,并且重跑结果与原结果完全一致。
- AI Lab
- 让 AI 写重组回滚逻辑,自己构造一次分叉数据验证它有没有漏删。
一个现实问题
你的索引器跑了两周,监控上没有任何红色:没有崩溃、没有报错、没有漏掉的区块、延迟稳定在三秒以内。
早上你收到一条用户投诉:他昨晚收到一条「收款 500」的通知,但钱包里没有这笔钱。
你去库里查,那条记录躺得好好的:交易哈希有、区块高度有、金额有、时间有。你把交易哈希复制到区块浏览器——
「交易不存在。」
你再去查那个区块高度。那个高度上确实有一个区块,但里面没有这笔交易,整个区块的内容和你库里记的完全不一样。
你的索引器没有崩、没有漏、没有报错。它只是忠实地记录了一段后来不存在的历史,然后按这段历史给用户发了一条通知。
T1 说过这件事会发生。T16 让你把 block_hash 存了下来。现在要把它真正用起来。
因为问题已经变得很尖锐了:链头的历史是可以被改写的,而你的数据库不会自己改写自己。两者之间要放什么?
思想实验
河对岸有一块公告栏,你在这岸用望远镜抄。公告栏有一个规矩:最新贴上去的那几条随时可能被撕掉换成别的内容,压在下面的旧公告则从不变。
三种抄法。
第一种:只记「我抄到第几条了」。 本子上写「已抄到 1000」,下次从 1001 接着抄。
如果 996 到 1000 被撕掉换过,你永远不会知道。本子里留着五条从未存在过的公告,混在几千条真的中间,没有任何标记能把它们认出来。
第二种:每抄一条,同时记下它的内容指纹,以及它声称的上一条的指纹。 你的本子上于是有了一条链。看到 1001 号时,先检查它声称的上一条指纹,是不是等于你本子上 1000 号的指纹。
不相等,就说明中间被换过。 这是一个你在自己这岸、不问任何人、当场就能做出的判断。
第三种:只抄那些已经被压得很深的旧公告。 永远不会错,代价是你总比现实慢一截。
第二种抄法里还藏着一个问题:你发现 1001 对不上,但不知道被换掉的是几条。 可能只换了 1000,也可能 996 之后全换了。
唯一的办法是往回倒着核对:拿本子上 1000 号的指纹和公告栏现在的 1000 号比,不一样就比 999,再比 998……直到找到第一条对得上的。那一条就是分叉点,它之后的全是脏的。
这个推演已经给出了这一章的全部骨架,剩下的都是怎么把它写成不会出错的代码。
你来决定
给你的索引器选一种重组策略。
观察结果
四种做法的对照:
| 延迟 | 依赖重组检测吗 | 恢复成本 | 实现复杂度 | |
|---|---|---|---|---|
| 只索引够深的区块 | 高 | 基本不需要 | 无 | 最低 |
| 链头 + 整库重跑 | 低 | 需要 | 极高 | 中 |
| 链头 + 回滚到分叉点 | 低 | 需要 | 低 | 高 |
| 双轨确认状态 | 最低 | 需要 | 低 | 最高 |
第二列是重点:除了第一种做法,其余三种全部依赖「能检测到重组」这一件事。 检测不到,后面的策略再精巧都没有意义——这就是为什么 T16 要在表里留下那一列区块哈希。
第一条结论:
重组不是异常,是链的正常行为。你的索引器必须把它当成一条常规代码路径,而不是一个告警。
第二条结论更重要,而且它正好解释了开头那个投诉:
重组毁掉的不是数据,数据你删得掉。它毁掉的是那些已经泼出去、收不回来的东西。
已经推送的通知、已经发出的邮件、已经打出去的款——这些没有 delete 语句。所以重组处理天然分成两半:库内的部分可以回滚,办法是精确删除加重算;库外的部分不可回滚,唯一的办法是在足够深之前不要做。
两半用的是完全不同的机制。混在一起写,就是开头那条通知的来源。
建立模型
主循环
先把整个索引器写成一段能读的伪代码:
loop:
head = rpc.blockNumber()
cp = db.readCheckpoint(chainId) # 高度 + 区块哈希
if not isStillOnChain(cp): # 1. 前进之前先确认自己还在这条链上
rollbackTo(findForkPoint(cp)) # 一个事务
continue
from = cp.number + 1 # 2. 这一轮的范围
to = min(cp.number + batchSize, head)
if from > to: sleep(pollInterval); continue
for n in from..to: # 3. 每个区块一个事务
block = rpc.getBlockHeader(n)
if block.parentHash != lastHash: break # 拉取途中链头变了,下一轮重判
logs = rpc.getLogs(n, n, filters)
db.transaction:
insertBlock(block)
insertLogs(logs) # 幂等,靠主键
enqueueOutbox(logs, n) # 副作用先入库,不直接发
writeCheckpoint(n, block.hash) # 和数据同一个事务
lastHash = block.hash这段伪代码里有五个决定,下面逐个拆开。
检测重组:两个地方,缺一不可
第一处,前进时: 要处理的第一个区块,它的 parentHash 必须等于 checkpoint 里的 last_block_hash。不等,就是重组。
这一处能抓住绝大多数情况,但它有一个盲区:如果重组发生在更靠前的位置,而你已经越过去了,或者你的 RPC 后面挂着多个节点、这一秒回答你的那个节点落后了几个块,parentHash 可能照样对得上。
第二处,定期回扫: 每隔一段时间,把 blocks 表里最近 D 个已入库区块的哈希取出来,逐个和链上同高度的哈希再对一遍。D 通常取「你见过的最大重组深度的几倍」。
这个回扫很便宜,却能抓住第一处漏掉的那一类,而那一类恰恰是最难排查的。
找分叉点:往回走,不要猜
findForkPoint(cp):
n = cp.number
while n > cp.number - maxRollbackDepth:
onchain = rpc.getBlockHeader(n).hash
local = db.blockHash(chainId, n)
if local is null: return n # 库里没有,说明这里就是边界
if onchain == local: return n # 第一个对得上的高度 = 分叉点
n = n - 1
raise DeepReorgError(n)三个要点:
分叉点是「第一个对得上的高度」,不是「第一个对不上的高度」。 这一个字的差别决定了你用 > 还是 >= 去删——差一位,要么留下一个脏区块,要么多删一个好区块。两种错误都不会报错,都只会安静地让数据变脏。
必须有最大回溯深度。 没有它,一个异常的 RPC(比如你连到了一个完全不同的网络,或者一个刚同步到一半的节点)会让你一路回溯到创世块,把整个库删光。这是一个真实发生过的事故形态。
超过最大回溯深度是一个需要人介入的事件。 停下来、报警、不要自作主张。这是整个索引器里少数几个真正应该叫醒人的地方。
线性回溯还是二分?用线性。 重组深度在绝大多数情况下是个位数,线性回溯只多花几次 RPC 调用,但它简单到不会写错。二分在这里是一个典型的过早优化。
回滚:一个事务
沿用 T16 的三张表,加一张待发副作用表:
begin;
-- 0. 先把受影响的地址收集出来。必须在删除之前,删完就查不到了。
create temp table touched_holders on commit drop as
select distinct holder from (
select from_addr as holder from transfer_events
where chain_id = $1 and block_number > $fork
union
select to_addr as holder from transfer_events
where chain_id = $1 and block_number > $fork
) x;
-- 1. 事实表与区块表:按高度删
delete from transfer_events where chain_id = $1 and block_number > $fork;
delete from blocks where chain_id = $1 and block_number > $fork;
-- 2. 还没发出去的副作用,跟着一起删掉
delete from outbox
where chain_id = $1 and block_number > $fork and delivered_at is null;
-- 3. 派生表:受影响的 holder 清零,从剩下的事实表重算
delete from token_balances b
where b.chain_id = $1
and exists (select 1 from touched_holders t where t.holder = b.holder);
insert into token_balances (chain_id, contract, holder, balance, updated_at_block)
select ... from transfer_events
where chain_id = $1 and to_addr in (select holder from touched_holders)
or chain_id = $1 and from_addr in (select holder from touched_holders)
group by chain_id, contract, holder;
-- 4. checkpoint 退回分叉点
update indexer_checkpoints
set last_block_number = $fork, last_block_hash = $forkHash
where chain_id = $1;
commit;五步必须在同一个事务里。 中间任何一步之后崩溃,你都会得到一个自相矛盾的数据库,而且它不会报错。
第 0 步的顺序是最容易写反的地方:收集受影响地址必须发生在删除之前。很多实现把它放在删除之后,于是重算的范围是空的,派生表永远停在重组前的错误值上。
Checkpoint 的两种错法,后果完全不同
这是整个索引器最该记住的一条规则。
错法一:先提交 checkpoint,再写数据
崩溃点落在中间 → checkpoint 说「处理过了」,但数据没写
→ 永久漏数据,而且没有任何东西会发现
错法二:先写数据,再提交 checkpoint(两个事务)
崩溃点落在中间 → 数据写了,checkpoint 没更新
→ 重启后重做这一段 → 幂等写入把它挡掉 → 无害
正确:数据和 checkpoint 在同一个事务里提交如果做不到同一个事务,就让 checkpoint 排在后面。宁可重做,不可漏做。
这条规则的价值在于它把「崩溃」这件事从一个正确性问题降级成了一个性能问题。而它能成立的前提是 T16 那条 on conflict do nothing ——幂等是断点续传的地基,不是它的优化。
回填与追新:一段代码,两种驱动
回填历史和跟踪链头是同一段处理逻辑,区别只在谁来决定范围:追新的范围来自 checkpoint 加一,必须串行,每一轮都要检查重组;回填的范围来自一张待处理区间表,可以多分片并行,且不用管重组(历史区块已经稳定)。
并行回填有一个坑值得单独说:进度不能是一个数字。
五个分片分别跑 1-100、101-200、201-300、301-400、401-500。第 1、2、4 片跑完了,第 3 片挂了。如果进度只是「最大完成高度」,它会显示 400——中间那个 100 个区块的空洞就这样消失了,而且永远不会被发现。
正确做法是一张区间表,一行一个待处理区间,带状态和重试次数:
create table backfill_ranges (
chain_id integer not null,
from_block bigint not null,
to_block bigint not null,
status text not null, -- pending / running / done / failed
attempts integer not null default 0,
primary key (chain_id, from_block)
);回填完成的定义于是变成:这张表里没有任何一行不是 done。 这是一个能被一条 SQL 回答的问题,比「进度到哪了」可靠得多。
副作用:把不可回滚的部分推到安全高度之后
开头那条通知的根因在这里。
不可回滚的动作不能在处理区块的事务里直接做。正确的做法是分成两步:
create table outbox (
id bigserial primary key,
chain_id integer not null,
block_number bigint not null, -- 它属于哪个高度,用来判断够不够深
event_key text not null, -- 链上坐标,投递方去重用
payload jsonb not null,
delivered_at timestamptz,
unique (chain_id, event_key)
);第一步,处理区块时,把「要发什么」和数据写在同一个事务里,但不发出去。事务回滚,它跟着消失;重组回滚,它被一起删掉。
第二步,一个独立的投递器去发,条件是:
select * from outbox
where chain_id = $1
and delivered_at is null
and block_number <= $safeHeight -- 只发够深的
order by block_number, id
limit 500;那个 safeHeight 就是确认深度在起作用的地方。一条事件在没有埋到足够深之前,永远不会离开你的数据库。
到这里,T1 里那个「推送了一笔不存在的转账」的问题被彻底关掉了:那条通知在重组发生时还躺在 outbox 里没发,回滚时被 delete 带走了。
确认深度不是一个全局常量,是一张按业务风险分档的配置:
| 用途 | 参考取法 |
|---|---|
| 界面上显示「处理中」 | 0,立刻显示 |
| 列表、统计、图表 | 浅,可接受偶尔跳动 |
| 推送通知、发邮件 | 中,一旦发出收不回 |
| 发放奖励、记账、结算 | 深,且要能对账 |
同一条链上不同业务用不同的深度,是正常且正确的。 把它写进配置表,不要散在代码里。
验收:一个可以自动跑的标准
这一章的验收标准非常硬,而且可以写成一条断言:
从任意高度重跑,最终结果必须与原结果逐字节一致。
具体做法就是 T16 结尾那条校验和查询:跑完记下校验和,删掉某个高度之后的数据、把 checkpoint 退回去、重跑,再算一次校验和。两个值必须相同。
这条标准的好处是它把一个模糊的工程品质(「我的索引器写得对不对」)变成了一个二值的自动化测试。做不到它的索引器,重组处理一定是错的——只是还没被发现。
它叫什么
按高度顺序推进、逐段拉取区块与日志的那段循环。
它看起来是索引器的主体,其实是最简单的一部分。索引器的难度全部集中在它周围:断点、重组、幂等、副作用。
把链上日志解析成结构化记录并落库的过程,产物是 T16 里那张事实表。
它的正确性由主键保证:主键是链上坐标,重复入库由数据库拒绝。
链头的若干个区块被另一串区块取代。你已经处理过的那些区块,可能从此不属于这条链。
它是常规事件,不是故障。 检测它靠比较区块哈希,处理它靠回滚到分叉点。只比高度不比哈希的系统,永远检测不到它。
往回逐个比对哈希时,第一个本地记录与链上一致的高度。它之后的所有数据都是脏的。
注意是「第一个一致的」而不是「第一个不一致的」。这个边界决定了你删数据时用 > 还是 >=——差一位就会留下脏数据或者多删好数据,而且两种错误都不报错。
「已经处理到哪个区块」的游标,必须同时包含高度和区块哈希。
规则只有一条:它和数据必须在同一个事务里提交;做不到,就让它排在数据后面。 排在前面会永久漏数据,排在后面只会重做一遍。
补齐历史区块的过程。和追新共用同一段处理逻辑,区别在范围由谁决定。
并行回填时,进度不能是一个数字,必须是一张区间表。用最大完成高度当进度,中间的空洞会静默消失。
把不可回滚的副作用先写进数据库、由独立进程稍后投递的模式。
它同时解决两件事:副作用和数据在同一个事务里(不会出现数据没写却发了通知),以及副作用可以被重组回滚带走(没发出去的自然消失)。投递条件里那个「区块高度够深」,就是确认深度真正落地的地方。
动手
全程测试网,只读。 这一章不签名、不广播、不花钱。
验收标准有三条,每一条都是可以自动跑的断言:
验收 1:同一段区块跑任意多遍,校验和不变
验收 2:伪造一次重组,回滚重跑之后,校验和回到重组前的值
验收 3:在处理过程中随机杀进程 20 次,每次重启后校验和都一致沿用 T16 的三张表,再加两张。
transfer_events、blocks、indexer_checkpoints 原样搬过来,然后加上这一章的 outbox 和 backfill_ranges。
建完先自问一遍:任意一行数据,能不能回答「来自哪笔交易、属于哪个高度、那个区块还在不在链上」这三个问题?
写主循环,先不写重组处理。
按上面那段伪代码实现,暂时跳过 isStillOnChain 那一段。
关键是把这几件事一次写对:
- 每个区块一个事务
- 事件用 on conflict do nothing 入库
- checkpoint 和数据在同一个事务里提交
- 区块头写进 blocks 表,parent_hash 不要漏跑 500 个区块,然后用 T16 结尾那条查询算出校验和,记下来。
验收 1:从任意高度重跑。
-- 挑一个高度 N,把它之后的全删掉,checkpoint 退回去
delete from transfer_events where chain_id = $1 and block_number > $N;
delete from blocks where chain_id = $1 and block_number > $N;
update indexer_checkpoints set last_block_number = $N,
last_block_hash = (select block_hash from blocks
where chain_id = $1 and block_number = $N)
where chain_id = $1;重启索引器,跑回原来的高度,再算一次校验和。
两个校验和必须一模一样。 不一样就停在这里——后面所有步骤都建立在这一条上。换三个不同的 N 各试一遍。
实现重组检测与分叉点回溯。
补上 isStillOnChain 和 findForkPoint,别忘了最大回溯深度以及超过它时停下报警。
特别注意分叉点的边界:返回的是第一个哈希一致的高度,删除时用 block_number > fork。下一步就验证这个边界。
验收 2:伪造一次重组。 不要等真的重组,自己造一个:
-- 把某个高度的区块哈希改掉一个字节,模拟「这个区块已经不是链上那个了」
update blocks
set block_hash = decode('00' || encode(block_hash, 'hex'), 'hex')
where chain_id = $1 and block_number = $N - 3;同时往那之后的高度插几条假的转账事件,模拟脏数据。
重启索引器。正确的行为是:检测到不连续,回溯找到分叉点(应该正好是 N - 4),删掉之后的全部数据,重新索引。
跑完之后的校验和,必须等于你在第二步记下的那个值。 假事件必须全部消失。
然后做一个边界实验:故意把删除条件从 > 改成 >=,再跑一次,观察校验和怎么变。亲眼看一次差一位的后果,比记住这条规则有用得多。
验收 3:随机杀进程。 让索引器跑起来,在随机时间点发强制终止信号再重启,重复 20 次。每次重启后检查三件事:
- 校验和和对照组一致吗?
- checkpoint 的高度和 blocks 表的最大高度对得上吗?
- 有没有出现「checkpoint 说到了 N,但 N 的数据不存在」?出现最后那种情况,说明 checkpoint 的提交顺序错了。 这一步最容易暴露真问题,因为它模拟的正是运维里每天都在发生的事:重启、发版、被 OOM、机器挂掉。
加上 outbox,验证副作用不会提前泄漏。
把「要推送的通知」写进 outbox,和事件同事务;投递器只取高度小于等于安全高度的行,投递后写 delivered_at。
然后重复一次验收 2,检查被回滚掉的那些高度上,有没有任何一条 outbox 记录的 delivered_at 不为空。必须是零条。 这一条为零,开头那个用户投诉就再也不会发生了。
贴着链头跑一周,记录真实的重组。
把确认深度设成 0,在一条出块较快的测试网上贴着链头跑,每检测到一次重组记一行:时间、分叉点高度、重组深度。
一周之后统计深度分布。这份数据会直接告诉你,你的业务该把确认深度设成多少——比抄任何一篇文章里的推荐值都靠谱。
AI Lab
分三步,第三步是真正的考题。
第一步:
我有三张表:事实表(主键是 chain_id + block_number + log_index)、
区块表(存 block_hash 和 parent_hash)、checkpoint 表(存高度和哈希)。
写出完整的重组检测与回滚逻辑,包括如何找到分叉点。
给出伪代码和 SQL。
第二步:
现在给你一个具体场景:
我的库里有高度 1000 到 1010 的数据。链上发生重组,
1006 到 1010 被替换成了另外 5 个区块,1005 及之前不变。
按你给的逻辑,逐步说明:
1. 我在处理 1011 时会在哪一步发现异常?
2. findForkPoint 会依次比较哪些高度,返回什么?
3. 删除语句的条件写出来,删掉的是哪几个高度?
4. 如果我把删除条件的大于号写成大于等于,会多删什么、后果是什么?
第三步:
在重组发生的那一刻,我的系统已经为 1006 到 1010 的事件
推送了通知、发了邮件、给用户发了积分。
这些动作删不掉。请设计一个方案,让这类事情不再发生,
并说明你的方案在什么情况下仍然会失效。第一步模型通常给得不错,因为「重组回滚」是一道被写过很多遍的题。要盯的是三个细节:哈希还是高度、有没有最大回溯深度、事务边界画在哪。
第二步是一道算术题,而模型在「逐步推演具体数字」这类任务上的错误率比你想象的高。最常见的错误就是分叉点差一位——它会说 findForkPoint 返回 1006,然后用 > 1006 去删,于是 1006 这个脏区块被留了下来。这个错误 review 时极难发现,在数据里却是永久的。
第三步是这个 Lab 的价值所在。它问的不是技术,是边界在哪:哪些东西你能回滚,哪些不能。好的回答会给出 outbox 加确认深度,并且主动说明它仍然会失效的情况。答得不错就追加一问:「确认深度是 12,却发生了一次深度 30 的重组,已经发出去的通知怎么办?」这一问没有技术答案,只有业务答案,能不能意识到这一点,是「会写代码」和「能负责一个系统」的分水岭。
最后必须你自己做:照着 Lab 第五步造一份分叉数据,把它的代码跑一遍,用校验和对比。 T16 的 AI Lab 里说过同一句话——这类错误在 review 时看起来都很合理,只有数据进去之后才会显形。
AI 说完之后,你必须自己验证
- 它检测重组用的是区块哈希还是仅仅比较高度:只比高度的方案永远检测不到重组,直接退回
- 它找分叉点的方式:是往回逐个比对哈希,还是假设重组深度是一个固定值(后者在深度超出假设时会留下脏数据)
- 有没有设最大回溯深度,以及超过之后的行为是停下报警还是继续往回删
- 删除条件用的是大于分叉点还是大于等于分叉点:自己构造数据跑一遍,两种写法的校验和对比
- 派生表有没有跟着回滚;如果是重算,收集受影响地址的那一步在删除之前还是之后
- checkpoint 的更新和数据删除在不在同一个事务里,顺序对不对
- 有没有处理已经发出去的通知、邮件这类不可回滚的副作用
- 有没有考虑 RPC 节点自身滞后或多节点之间不一致的情况
- 最后自己造一份分叉数据跑一遍,用校验和对比,不要只看它的解释
真实案例
开头那个场景,也是 T1 在第一章就预告过的问题。
索引器在区块刚产生的瞬间就推送了通知。重组之后,那笔交易再也没有上链。用户收到了一条关于一笔不存在的转账的通知。
它的破坏力不在于这一条通知,而在于用户从此不再相信你的通知。而修复它只需要一张 outbox 表和一个安全高度的判断。
索引器记录的是「处理到 1000 号」。链头重组之后,它从 1001 号接着往下跑,parentHash 的检查从来没有写。
那五个区块的数据一直躺在库里,和正确数据混在一起,没有任何查询会把它们标记出来。三个月后有人做对账,发现总数对不上,排查时最痛苦的一点是:库里没有任何信息能告诉你哪些行是脏的。
这正是 T16 最后那个案例的续集。教训是同一条:区块哈希是一列非常便宜的保险。
为了「减少事务大小」,有人把 checkpoint 的更新拆到一个单独的事务里,并且放在了数据写入之前。平时完全看不出问题,直到一次发版重启恰好落在两个事务之间——checkpoint 已经前进,数据没写进去,那一个区块的所有事件永久消失。
没有报警,没有日志,没有任何查询会发现它。 因为对系统来说,那个区块「已经处理过了」。教训就是那句话:宁可重做,不可漏做。
五个分片并行回填,进度记录成「最大完成高度」。其中一个分片因为 RPC 限流失败退出,监控只看了最大高度,显示一切正常。
中间 100 个区块的数据永远缺失。几周后有用户反馈某段时间的记录查不到,才发现这个洞。
教训:并行任务的进度不是一个数字。 用区间表,把「完成」定义成「没有任何一行不是 done」——这是一个能被一条 SQL 回答的问题。
改一个变量
事情简单很多:超过那个深度的区块永远不会变,最大回溯深度可以设成一个有理论依据的值,安全高度也有了明确定义。
但链头仍然会重组,所有检测和回滚逻辑一样要写,区别只是你现在知道「够深」到底是多深,而不是靠经验猜。不同链的最终性定义差别很大,把它当成配置表里的一行,而不是代码里的一个常量。
你的 checkpoint 指向的高度,在新节点看来还不存在。如果代码把「查不到这个高度」当成「哈希不一致」,它就会开始往回删——删掉十个完全正确的区块。
正确做法:把「链头低于我的 checkpoint」识别成一个单独的状态,等待而不是回滚。「我领先了」和「我错了」是两件事。
代码层面的正确行为是:停下、报警、不要继续往回删。 因为再往回走,你已经无法区分「真的是一次超深重组」和「我连错了网络」。
业务层面则要回答一个技术答不了的问题:已经发出去的通知、已经发放的奖励怎么办?这个问题应该在上线前就有答案,而不是在事发时现想。
速度快很多,但你拿到的那一批日志,有可能横跨两条分叉——请求发出时链头是一个样子,返回时已经变了。防御办法是:日志里自带它所属的区块哈希,拿它和你的区块表逐条核对,不一致的整批丢弃重来。
更一般的原则:不要相信一次批量请求的内部一致性。 范围越大,跨越重组的概率越高——所以靠近链头时缩小批量,远离链头时才放大。
带走的问题
它解决什么问题?这一章解决的是「怎样让一份会被改写的历史,安全地落进一个不会自己改写的数据库」。难点从来不是抓取——抓取是个下午就能写完的循环。难的是重组、断点、幂等这三件事,而它们全部只在出事时才显形。
谁承担风险?索引错了,用户看到错误的余额、收到错误的通知、拿到错误的奖励,而你可能几个月都不知道。这类错误的特征是静默:它不报警,只是慢慢和链上分叉。所以验收标准必须是可自动跑的断言(校验和一致),而不是「跑起来看着对」。
AI 错误时谁承担损失?这一章的 AI Lab 里,模型最容易犯的是分叉点差一位这种错误——它的解释完全正确,代码差一个符号,而后果是一个永远留在库里的脏区块。这类错误 review 不出来,只有造数据跑一遍才能发现。 责任在运行这段代码的人。
本章自测
因为高度不能唯一确定一个区块。重组之后,同一个高度上会是一个内容完全不同的区块。
只有高度的 checkpoint 没有办法回答「我当时处理的那个区块,还是不是现在链上的那个」。于是重组发生时,索引器会若无其事地接着往下跑,把脏数据永久留在库里,而且不会有任何查询把它们标记出来。
checkpoint 必须同时包含高度和区块哈希,区块表里还要存 parent_hash,这样哈希链的连续性才能在本地被独立验证。
第一个对得上的高度。 它本身是干净的,它之后的才是脏的,所以删除条件是严格大于它。
写成大于等于,会多删一个完全正确的区块;反过来如果返回的是「第一个对不上的高度」而你又用了严格大于,那个脏区块会被留下来。两种错误方向相反,症状都是安静的,不报错,只是让数据和链上永久差一截。
验证办法只有一个:造一份分叉数据,两种写法各跑一遍,对比校验和。
因为崩溃可以发生在任何两条语句之间,而两种顺序的后果完全不同。
checkpoint 在前:崩溃后系统认为那个区块处理过了,但数据不在。这是永久漏数据,而且没有任何东西会发现它。checkpoint 在后:崩溃后系统会重做那一段,幂等写入把重复的部分挡掉,结果正确,只是多跑了一次。
所以规则是:放进同一个事务;做不到,就让 checkpoint 排在后面。宁可重做,不可漏做。 它能成立的前提是主键幂等,所以幂等不是优化,是断点续传的地基。
因为通知、邮件、打款这些动作没有 delete 语句。数据库里的脏数据你删得掉,已经发出去的消息你收不回来。
outbox 同时解决两件事:副作用和数据在同一个事务里写入,不会出现「数据没写成功却发了通知」;以及副作用可以被重组回滚带走,还没投递的行会跟着被删掉。
投递器只发「区块高度足够深」的那些行,这就是确认深度真正落地的地方。而确认深度应该按业务分档:显示可以是 0,发奖励要很深。
没有标准答案,检查这几件事:
- 同一段区块跑任意多遍,校验和不变——用一条 SQL 断言。
- 从任意高度删除后重跑,校验和回到原值——换三个不同高度各试一遍。
- 伪造一次重组(改掉某个高度的哈希),回滚重跑之后校验和恢复。
- 随机杀进程 20 次,每次重启后 checkpoint 和数据都不矛盾。
- 被回滚的高度上,没有任何一条 outbox 记录已经投递出去。
- 并行回填完成的定义是区间表里没有非 done 的行,不是最大高度。
- 最大回溯深度被触发时,进程是停下报警而不是继续删。
- 链头低于 checkpoint 时,行为是等待而不是回滚。
前五条都能写成自动化测试,这正是这一章想给你的东西:把「我的索引器写得对不对」从一个玄学问题,变成一条会红会绿的断言。
一句话带走
Indexer 的难点从来不是抓取,而是重组、断点续传与幂等。