T1 · Blockchain 数据究竟是什么
一条链上真正存了什么?
- 练习的能力
- BuilderOnchain Literacy
- 动手
- 写一个脚本,拉取最近 10 个区块,打印每个区块的交易数、Gas 用量与第一笔交易的接收方。
- AI Lab
- 让 AI 解释 State 与 Event 的区别,再让它举一个只看 Event 会得出错误结论的例子。
一个现实问题
产品给你一个需求:某个地址一收到 USDC,就给用户推送通知。
看起来五分钟就能做完。于是你打开文档,开始纠结第一个问题:这个「收到」,我该去哪里读?
- 每隔几秒查一次这个地址的余额,变多了就推送?
- 监听 USDC 合约的 Transfer 事件?
- 把每个区块的交易都拉下来,逐笔解析?
- 直接用第三方 API,省事?
四种做法都能做出一个 demo,但它们在真实世界里的表现差异巨大:有的会漏消息,有的会重复推送,有的会在链重组时推送一条不存在的转账。
选错的代价不是性能,是正确性。要选对,先得搞清楚:链上到底存了什么。
思想实验
把一条链想成两样东西,而不是一样。
第一样:一本只能追加的流水账。
每隔一段时间产生一个区块,区块里按顺序装着若干笔交易。写下去就不能改,也不能插队。这本账只记录动作:谁调用了什么、带了什么参数、付了多少手续费。
第二样:一张由流水账推导出来的当前状态表。
每个账户现在有多少余额、每个合约的每个存储槽现在是什么值。这张表不是被「写」进链里的,它是从创世块开始把所有交易依次执行一遍得到的结果。
现在关键的问题来了:一笔交易执行时,改了哪些状态?
流水账里没有直接写。它只记了「调用了合约 A 的 transfer 函数,参数是地址 B 和数量 100」。至于这次调用最终改了多少个存储槽、有没有顺带触发别的合约,都要真的执行一遍才知道。
这就是为什么会有第三样东西:日志。合约在执行时主动喊一声「我刚刚做了一次转账,从 B 到 C,数量 100」。这一声被记录在交易回执里,专门给外部世界听。
- Transaction 我要做什么
- State 世界变成了什么样
- Event 我主动告诉你我做了什么
你来决定
回到那个推送需求。你选哪一种?
观察结果
四个选项暴露出同一件事:你以为在读「链上数据」,其实在读它的某一层投影。
| 你读的东西 | 它真正是什么 | 会漏掉什么 |
|---|---|---|
| 账户余额 | 当前状态的一个切片 | 中间过程,同区块内的多次变化 |
| 交易列表 | 外部发起的动作 | 合约内部的调用与转账 |
| Event 日志 | 合约自愿发出的说明 | 不发 Event 或不按标准发的情况 |
| 执行追踪 | 完整的调用树 | 基本不漏,但又慢又贵 |
选哪一层,取决于你的业务能容忍什么。做一个余额展示,读状态就够;做资金对账,必须用追踪;做通知和索引,Event 是性价比最高的选择。
没有「最准确的那一层」,只有「和你的需求匹配的那一层」。
建立模型
把一条链的数据结构按层排开:
Block 区块:一批交易的容器
├─ header 头:父哈希、高度、时间戳、状态根
└─ transactions[] 体:按顺序排列的交易
└─ Transaction 一笔交易:from、to、value、data、nonce、gas
└─ Receipt 回执:执行之后才产生
├─ status 成功还是失败
├─ gasUsed 实际消耗
└─ logs[] 合约发出的事件
└─ Log topics(可索引,便于过滤)+ data(内容)
State 状态:由所有交易依次执行推导出来
├─ account balance 账户余额
├─ contract code 合约代码
└─ contract storage 合约存储槽四个要点,记住它们能让你少走很多弯路:
- Receipt 在执行之后才存在。 交易被打包不等于成功,
status要单独看。前端把「已广播」当成「成功」是最常见的 bug(T14 会展开)。 - Log 的 topics 是可索引的,data 不是。 这决定了你能按什么条件高效过滤:合约地址和 topics 可以,事件里的普通字段不行。
- State 只有「现在」。 想查历史某个高度的状态,需要归档节点(T4 会讲)。这是一个常见的成本陷阱。
- Event 不是状态。 合约可以发一个和实际状态完全不符的事件——这在攻击中被用过。真正要确认状态,得去读状态。
它叫什么
一批交易的容器。区块头里的 parentHash 把它们串成一条链,改动任何一个历史区块都会让后面全部失效。
一次由外部账户发起的状态变更请求。它只记录「要做什么」,不记录「结果是什么」。
当前所有账户余额、合约代码与存储的总和。它是所有历史交易执行后的结果,不是被单独存储的一份数据。
交易执行后的结果记录:成功与否、消耗了多少 Gas、产生了哪些日志。
查一笔交易成没成功,看 Receipt 的 status,而不是看它在不在区块里。
合约主动写入回执的结构化记录,供链下系统读取。合约本身读不到它。
topics 是可索引字段(第一个通常是事件签名的哈希),data 是其余内容。
合约在执行过程中发起的调用或转账。
它不是一笔独立的交易,不会出现在交易列表里,只能通过执行追踪还原。很多「为什么浏览器上有这笔钱、我的程序却没读到」的问题,根源都在这里。
动手
不用任何 SDK,直接调 JSON-RPC。目的是让你看清数据的原始形状。
拿到最新高度。
curl -s -X POST <你的-RPC-地址> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'返回的是十六进制。这一点会坑你很多次:链上返回的数值几乎都是十六进制字符串。
取一个区块的完整内容。
curl -s -X POST <你的-RPC-地址> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["latest",true]}'第二个参数是 true 时返回完整交易,false 时只返回交易哈希。看一眼两者的体积差异,你就明白为什么索引器要控制这个参数。
取一笔交易的回执,找到 status 和 logs。
curl -s -X POST <你的-RPC-地址> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["<交易哈希>"]}'对照区块浏览器上的同一笔交易,确认你看到的字段和它显示的一致。
按事件过滤一段区块范围。
curl -s -X POST <你的-RPC-地址> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getLogs","params":[{
"fromBlock":"0x...","toBlock":"0x...",
"address":"<代币合约地址>",
"topics":["<Transfer 事件签名的哈希>"]}]}'这一步是所有索引器的核心。试着把区块范围调大,看 RPC 从什么跨度开始拒绝你——这个限制会直接决定你的索引器怎么分片。
写成脚本,输出一张表。
拉最近 10 个区块,打印:高度、时间戳、交易数、总 Gas 用量、第一笔交易的接收方。
这就是你的阶段作品的第一版。
全程使用测试网或公共只读端点,不需要任何私钥。 这一章不发交易。
AI Lab
分两步问,第二步比第一步重要得多:
第一步:
解释 Blockchain 上 State 和 Event 的区别,说明为什么两者可能不一致。
第二步:
给我三个具体场景,在这些场景下,只监听 Transfer 事件的索引器会得出错误结论。
每个场景说明:会漏掉什么、后果是什么、正确做法是什么。第二步是这个 Lab 的价值所在。让模型去找自己方案的反例,比让它给方案有用得多。
拿到三个场景后,自己去链上找一个真实例子来验证。找得到,说明这个坑是真的;找不到,就去问它「请给出一个真实的合约地址或交易哈希」——然后你多半会发现,它给的那个哈希并不存在。
这正是你需要亲身经历一次的事情。
AI 说完之后,你必须自己验证
- 它给的 JSON-RPC 方法名和参数顺序,与官方规范一致
- 它说的「可索引字段」确实只包括 topics,没有把 data 里的字段说成可过滤
- 它举的「只看 Event 会漏掉」的例子,你能在真实链上找到一个对应的案例
- 它写的示例代码里,十六进制与十进制的转换没有搞错
- 它有没有把「交易在区块里」当成「交易成功」
真实案例
ETH、SOL 这类原生币的转账,不经过任何合约,因此不会产生 Transfer 事件。
只监听 Transfer 的索引器会完全看不到这类转账。这是新手最常踩的第一个坑:代币转账收得到通知,主币转账一条都没有。
正确做法:原生币走交易本身的 value 字段,或者走执行追踪来覆盖内部转账。
有些代币在转账时会自动扣掉一部分作为税费或销毁。
这类代币的 Transfer 事件里写的是「转出 100」,但接收方实际只收到 97。按事件记账的系统会算出一个永远对不上的余额。
正确做法:涉及资金对账时,以状态为准,事件只作为索引线索。
用户通过一个聚合器合约做转账,最外层交易的 to 是聚合器,不是最终接收方。
只看交易列表的系统会把这笔钱归错人。区块浏览器上能看到「内部交易」,是因为它跑了完整的执行追踪。
正确做法:需要完整资金流时,用追踪;只做通知时,用事件。
链的末端会发生重组:你已经处理过的区块被回滚,交易可能进入另一个区块,也可能永远不进。
如果你在一个区块被确认的瞬间就推送了通知,重组之后就会出现「通知了一笔不存在的转账」。
正确做法:按业务风险设置确认数,并且让索引器能够回滚。T17 会完整讲这件事。
改一个变量
链上的状态变化仍然正确发生,但链下系统完全看不见。
这说明一件重要的事:可观测性是合约设计的一部分,不是链自带的功能。 T7 写合约时,什么时候发事件、发哪些字段,是要专门设计的。
普通节点只保留最近一段时间的历史状态,查不到就会报错。你需要归档节点,而它的存储成本高出一个数量级。
很多数据产品的成本结构,就是在这里被决定的。T4 会讲怎样在成本和能力之间做取舍。
模型的形状完全不同:没有全局的状态树,数据散落在一个个账户里;交易必须提前声明它会读写哪些账户,因此可以并行执行。
日志的处理方式也不一样。T6 会做完整对比——对比是理解任何一种模型的最快方式。
带走的问题
这一章解决的是「怎样正确地读到链上发生了什么」。听起来基础,但它是后面所有章节的前提:读错了数据,上面的一切都是错的。
为什么需要 Blockchain?在这个问题上,它给出的独特答案是:任何人都能独立验证同一份历史。你自己跑一个节点,就能得到和所有人一致的结果,不需要相信任何 API 提供方。
如果你的索引器漏了一笔转账,谁承担损失?通常是用户,而你事后甚至可能不知道漏了。这就是为什么索引的正确性要用「可重放」来保证,而不是靠「跑起来看着对」。
本章自测
不是。失败的交易同样会被打包,并且照样扣手续费。
判断成功与否要看回执里的 status。前端把「已上链」显示成「已成功」,是这一行最常见的 bug。
可能。Event 是合约自愿发出的说明,合约完全可以发出一个与实际状态变化不符的事件。
所以:索引和通知可以用 Event,资金对账必须以 State 为准。
因为只有 topics 是被索引的,data 不是。合约作者在定义事件时决定哪些字段进 topics(通常最多三个)。
这个设计约束会直接影响你的索引方案:想按某个字段过滤,那个字段必须在定义事件时就被标成可索引。
参考答案:
- 用
eth_getLogs按合约地址加 Transfer 主题,按区块范围批量拉取,而不是轮询余额。 - 等待若干个确认再推送,确认数按业务能承受的风险定。
- 记录已处理到的区块高度作为断点,支持从任意高度重放。
- 处理重组:检测到父哈希不连续时,回滚受影响的区块并重新处理。
- 用状态查询定期对账,发现差异时报警。
能写出第 3、4 条,说明你已经领先大多数只做过 demo 的人。
一句话带走
链存的是状态和让状态改变的交易,Event 只是给外部世界看的投影。