关键词:Solidity storage、以太坊架构、State Root、Storage Root、SSTORE、SLOAD、Merkle Patricia Trie、以太坊账户、EVM、状态数据库
以太坊架构:用 30 秒看懂全景图
大多数开发者第一次见到以太坊架构图都会头大:箭头密集、名词堆叠。其实它可以用一句话概括——
区块头保存“根”,根指向“树”,树把“账户”和“合约存储”层层串起。
下面我们分片拆解,一旦走完这一圈,你就能从区块头一路定位到具体合约的某一个 32 字节存储槽位。
区块头(Block Header):站在时间的最前沿
每一区块都长着同样一张“身份证”;列出的字段全部经过共识算法固化,缺一不可。以下字段与本文主题强相关:
- Prev Hash:父区块哈希
- Timestamp:出块时间
- State Root:世界状态树的 Merkle 根,下文重点
- Transaction Root / Receipt Root:事务树与收据树的根,不展开
- Gas Limit / Gas Used:链上资源 Metering
在 Geth 的Header
Struct 中,这些字段均有对应命名,方便在代码里直接索引。真正要找合约 storage,必须从 State Root 下手。
State Root:世界状态的“封面”
State Root 是一个 32 字节 Keccak256 哈希,任何账户余额、合约字节码、存储插槽只要改动一位,就会变成另一串看似随机的字符。
在它的下面是一棵 Merkle Patricia Trie (MPT),把:
keccak256(账户地址)
→RLP(账户结构)
的键值对组织成可验证的树形结构。
一句话记忆:State Root 是封面目录,它告诉你怎么翻到某账户的章节。
以太坊账户(Ethereum Account):四件套的一生
一旦定位到具体账户,下面是它铁打不动的 State Account 结构:
字段 | 长度 | 通俗解释 |
---|---|---|
Nonce | 8 B | 此生发了多少交易 |
Balance | 32 B | 钱包此刻的 wei 余额 |
Code Hash | 32 B | 部署的合约字节码散列 |
Storage Root | 32 B | 合约存储树的 根 |
这段结构体在 Geth 的 state_account.go
中对应 StateAccount
类型。我们要读的合约数据,就藏在 Storage Root 指向的另一棵 MPT 里。
Storage Root:掀开合约的抽屉
Storage Root 又带我们再下一层。新树不再是“地址→账户”,而是:
keccak256(Storage Slot Index)
→RLP(Slot Value 32 B)
。
合约开发者写下的 mapping(uint256 => uint256)
、状态变量 uint256 cnt;
,都会在编译期被编译器编排成插槽编号,进而成为这棵树的 key。
它们在 Solidity 里对应 storage 关键词,占用链上持久化空间。
注意:任何一次 SSTORE 都会修改这棵树,继而让 Storage Root 变化 → State Root 变化 → Block Header 变化。
你在交易中付的 gas,多数就消耗在这些逐级蔓延的哈希重算里。
StateDB → stateObject → StateAccount:Geth 的状态管理三明治
为了同时满足查询、缓存、回滚需求,Geth 在内存里又包了三层:
- StateDB
面向协议的统一接口,负责所有账户和合约存储的读取/写入。 - stateObject
“缓存包装器”,这一次交易里待修改数据临时放在 dirtyStorage 映射里。 - StateAccount
共识所需的最小字段,唯一直视磁盘。
在这种分层下,你可以把 StateDB 看作“书柜”,stateObject 是一本正在被批注的书,StateAccount 则是那本书印刷后的硬壳封面。
初始化一个新账户:在 StateDB 开页
当合约初次部署时,系统会执行:
func (db *StateDB) createObject(addr common.Address) *stateObject
它会:
- 检查地址是否存在
- 不存在则使用 空 StateAccount 生成一个新的 stateObject
- 将该 stateObject 再注册到它内部的
stateObjects
映射
至此,崭新的合约账户在位,但磁盘上尚无数据。
SSTORE:从高级语言到底层哈希的漫漫长路
在 Solidity 写一句 counter += 1;
,编译后将翻译成:
PUSH1 0x00 // slot 0
SLOAD // 取值
PUSH1 0x01
ADD
PUSH1 0x00
SSTORE // 覆写 slot 0
Geth 中执行 opSstore 的路线:
instructions.go
里的opSstore()
弹出(slot, newValue)
StateDB.SetState(addr, slot, newValue)
找到或新建对应 stateObjectstateObject.SetState()
更新 dirtyStorage 表,写一次 日志 journal,便于回滚事务- 区块最终提交时,dirty → pending → originStorage → 写入 MPT → 磁盘
👉 点击即刻体验实时 watch storage 变动并同步可视化
SLOAD:只读之旅却一点也不轻松
对应 uint256 x = myMap[42];
的 EVM:
PUSH1 0x2A // map 的 uint256 key = 42
KECCAK256 // 先把 key 做哈希,形成真实插槽
SLOAD // 取出该槽 32 B 值
在 Geth 路线:
opSload()
弹出 slotStateDB.GetState(addr, slot)
先查 stateObject- stateObject 的 查找优先级:
- dirtyStorage(本交易最新缓存)
- pendingStorage(未被 commit 的状态缓存)
- originStorage(磁盘 trie 数据)
- 向上返回 32 B value 喂给 EVM 继续执行
一句话总结:SLOAD 并不是从“磁盘”读,而是从 多层缓存 + Trie 的复合世界里找最新值。
实战 FAQ:你的疑问即时解
Q1:一次 SSTORE 花费多少 gas?为什么会有「冷槽」概念?
A:EIP-2929 以后,首次访问插槽需付 2100 gas「冷槽费用」,随后是 100 gas「温槽」。SSTORE 真正的 20000/5000 gas 消耗只在值从 0→非0 或反之置零时收取。
Q2:状态在内存、MPT、LevelDB 三处都有拷贝,数据会膨胀吗?
A:区块提交后只有 Trie + LevelDB 真正落地,内存对象会随交易结束回收。节点也可启用快照机制降低 StateDB 读取开销。
Q3:如何快速计算 mapping 元素在 storage 里的具体 slot?
A:先对 mapping 起始槽 s
求 keccak256(h256(k) . h256(s))
,就是目标插槽索引。Remix IDE 和 Foundry cast
指令都提供脚本工具。
Q4:部署空合约也占位盘么?
A:是的。即便 storage 初始为空,也会在 MPT 写一条 Storage Root==空树 root
的记录,但因无 data 量,磁盘占用极低。
Q5:删一个 mapping 能降低磁盘空间吗?
A:Solidity 里没有真正delete mapping
,只有手动置零各 slot。把 key 对应的 value 写 0,叶节点会从 trie 摘除,下次 StateDB 会写得更精简。
Q6:客户端崩溃后 dirtyStorage 会丢吗?
A:会,但只影响未提交交易。MPT 最终一致性靠打包区块后 commit 保证,单节点故障不会破坏链上共识数据。
小结 & 收藏级口诀
- 区块头 的世界 → 通过 State Root 翻页
- 世界页展示 以太坊账户 → 翻开 Storage Root 再下一页
- StateDB 缓存 sandwich,SSTORE/SLOAD 在中间修订与翻看
- 32 字节的哈希路径把 Solidity 的
storage
变量钉死在了链上地球