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

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 退回去、重跑,再算一次校验和。两个值必须相同。

这条标准的好处是它把一个模糊的工程品质(「我的索引器写得对不对」)变成了一个二值的自动化测试。做不到它的索引器,重组处理一定是错的——只是还没被发现。

它叫什么

Block Scanner区块扫描器

按高度顺序推进、逐段拉取区块与日志的那段循环。

它看起来是索引器的主体,其实是最简单的一部分。索引器的难度全部集中在它周围:断点、重组、幂等、副作用。

Event Index事件索引

把链上日志解析成结构化记录并落库的过程,产物是 T16 里那张事实表。

它的正确性由主键保证:主键是链上坐标,重复入库由数据库拒绝。

Reorg区块重组

链头的若干个区块被另一串区块取代。你已经处理过的那些区块,可能从此不属于这条链。

它是常规事件,不是故障。 检测它靠比较区块哈希,处理它靠回滚到分叉点。只比高度不比哈希的系统,永远检测不到它。

Fork Point分叉点

往回逐个比对哈希时,第一个本地记录与链上一致的高度。它之后的所有数据都是脏的。

注意是「第一个一致的」而不是「第一个不一致的」。这个边界决定了你删数据时用 > 还是 >=——差一位就会留下脏数据或者多删好数据,而且两种错误都不报错。

Checkpoint断点

「已经处理到哪个区块」的游标,必须同时包含高度和区块哈希。

规则只有一条:它和数据必须在同一个事务里提交;做不到,就让它排在数据后面。 排在前面会永久漏数据,排在后面只会重做一遍。

Backfill回填

补齐历史区块的过程。和追新共用同一段处理逻辑,区别在范围由谁决定。

并行回填时,进度不能是一个数字,必须是一张区间表。用最大完成高度当进度,中间的空洞会静默消失。

Outbox待发表

把不可回滚的副作用先写进数据库、由独立进程稍后投递的模式。

它同时解决两件事:副作用和数据在同一个事务里(不会出现数据没写却发了通知),以及副作用可以被重组回滚带走(没发出去的自然消失)。投递条件里那个「区块高度够深」,就是确认深度真正落地的地方。

动手

动手写一个最小索引器,能从任意高度重跑,并且重跑结果与原结果完全一致任意语言 + PostgreSQL + 测试网 JSON-RPC0 元。全程使用测试网与公共只读端点,不发任何交易,不需要任何私钥

全程测试网,只读。 这一章不签名、不广播、不花钱。

验收标准有三条,每一条都是可以自动跑的断言:

验收 1:同一段区块跑任意多遍,校验和不变
验收 2:伪造一次重组,回滚重跑之后,校验和回到重组前的值
验收 3:在处理过程中随机杀进程 20 次,每次重启后校验和都一致

沿用 T16 的三张表,再加两张。

transfer_eventsblocksindexer_checkpoints 原样搬过来,然后加上这一章的 outboxbackfill_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 各试一遍。

实现重组检测与分叉点回溯。

补上 isStillOnChainfindForkPoint,别忘了最大回溯深度以及超过它时停下报警。

特别注意分叉点的边界:返回的是第一个哈希一致的高度,删除时用 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

AI Lab让 AI 写重组回滚逻辑,自己构造一次分叉数据验证它有没有漏删Level 2 · AI Copilot

分三步,第三步是真正的考题。

第一步:
我有三张表:事实表(主键是 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 的更新拆到一个单独的事务里,并且放在了数据写入之前。平时完全看不出问题,直到一次发版重启恰好落在两个事务之间——checkpoint 已经前进,数据没写进去,那一个区块的所有事件永久消失。

没有报警,没有日志,没有任何查询会发现它。 因为对系统来说,那个区块「已经处理过了」。教训就是那句话:宁可重做,不可漏做。

并行回填留下一个空洞

五个分片并行回填,进度记录成「最大完成高度」。其中一个分片因为 RPC 限流失败退出,监控只看了最大高度,显示一切正常。

中间 100 个区块的数据永远缺失。几周后有用户反馈某段时间的记录查不到,才发现这个洞。

教训:并行任务的进度不是一个数字。 用区间表,把「完成」定义成「没有任何一行不是 done」——这是一个能被一条 SQL 回答的问题。

改一个变量

如果这条链有确定性的最终性,重组深度有明确上限

事情简单很多:超过那个深度的区块永远不会变,最大回溯深度可以设成一个有理论依据的值,安全高度也有了明确定义。

链头仍然会重组,所有检测和回滚逻辑一样要写,区别只是你现在知道「够深」到底是多深,而不是靠经验猜。不同链的最终性定义差别很大,把它当成配置表里的一行,而不是代码里的一个常量

如果你换了一家 RPC,新节点的链头比旧的落后十个区块

你的 checkpoint 指向的高度,在新节点看来还不存在。如果代码把「查不到这个高度」当成「哈希不一致」,它就会开始往回删——删掉十个完全正确的区块

正确做法:把「链头低于我的 checkpoint」识别成一个单独的状态,等待而不是回滚。「我领先了」和「我错了」是两件事。

如果重组深度超过了你的最大回溯深度

代码层面的正确行为是:停下、报警、不要继续往回删。 因为再往回走,你已经无法区分「真的是一次超深重组」和「我连错了网络」。

业务层面则要回答一个技术答不了的问题:已经发出去的通知、已经发放的奖励怎么办?这个问题应该在上线前就有答案,而不是在事发时现想。

如果你用大范围的日志查询一次拉一千个区块

速度快很多,但你拿到的那一批日志,有可能横跨两条分叉——请求发出时链头是一个样子,返回时已经变了。防御办法是:日志里自带它所属的区块哈希,拿它和你的区块表逐条核对,不一致的整批丢弃重来。

更一般的原则:不要相信一次批量请求的内部一致性。 范围越大,跨越重组的概率越高——所以靠近链头时缩小批量,远离链头时才放大。

带走的问题

1
它解决什么问题?

它解决什么问题?这一章解决的是「怎样让一份会被改写的历史,安全地落进一个不会自己改写的数据库」。难点从来不是抓取——抓取是个下午就能写完的循环。难的是重组、断点、幂等这三件事,而它们全部只在出事时才显形。

9
谁承担风险?

谁承担风险?索引错了,用户看到错误的余额、收到错误的通知、拿到错误的奖励,而你可能几个月都不知道。这类错误的特征是静默:它不报警,只是慢慢和链上分叉。所以验收标准必须是可自动跑的断言(校验和一致),而不是「跑起来看着对」。

17
AI 错误时谁承担损失?

AI 错误时谁承担损失?这一章的 AI Lab 里,模型最容易犯的是分叉点差一位这种错误——它的解释完全正确,代码差一个符号,而后果是一个永远留在库里的脏区块。这类错误 review 不出来,只有造数据跑一遍才能发现。 责任在运行这段代码的人。

本章自测

一句话带走

Indexer 的难点从来不是抓取,而是重组、断点续传与幂等。

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

本页目录