;)
Solidity 存储与优化进阶
写 Solidity 最容易产生的错觉就是:它长得像 JavaScript,所以行为也应该像 JavaScript。
直到你部署了合约,看到一笔简单的交互烧掉了你半个月的测试网 ETH,才意识到:在 EVM 里,存储就是金钱,而金钱是很贵的。
这篇不扯什么“区块链改变世界”,只聊点实在的——怎么在存储这块把 Gas 压下来,顺便避开那些让新手头皮发麻的坑。
存储位置三剑客:storage / memory / calldata
这三个词你肯定见过,但很多人只是机械地背:“状态变量用 storage,局部变量用 memory”。
稍微深究一下:为什么外部函数的参数推荐用 calldata 而不是 memory?
本质区别
- **
storage**:永久存在链上,读写最贵(SLOAD/SSTORE)。改它等于改账本。 - **
memory**:函数执行期间存在内存里,按字分配。每次调用都要花钱 copy 数据进来。 calldata:只读,数据直接躺在交易 payload 里。不用 copy,所以最便宜。
1 | // ❌ 别这么干 |
[!tip] 什么时候必须用 memory?
当你需要修改参数内容,或者要把数据传给内部函数(internal/private)时。calldata是只读的,内部函数也不认calldata。
映射(mapping)与数组:没有银弹
新手最爱 array,因为能 push、能遍历、感觉像在用普通编程语言。
老手偏爱 mapping,因为 O(1) 查询,Gas 稳定。
怎么选?
| 需求 | 推荐 | 原因 |
|---|---|---|
| 查某个地址的余额 | mapping(address => uint) |
快,不依赖数据量 |
| 需要遍历所有用户 | array 或 mapping + index数组 |
mapping 本身不可遍历 |
| 频繁删除中间元素 | 别用普通 array,用 swap-and-pop | 删除中间元素要搬移数据,Gas 爆炸 |
数组删除的坑
1 | // ❌ 删除中间元素,后面的全要往前挪 |
[!warning] Gas Refund 没了
EIP-3529 之后,删除 storage 变量不再退还 Gas 了。所以别指望靠delete省钱,该省还是得从设计层面省。
常量(constant)与不可变变量(immutable)
这两兄弟是省 Gas 的利器,原理很简单:不占存储槽,直接编译进字节码。
constant:编译期就能确定的
1 | uint256 public constant MAX_SUPPLY = 1000000; |
编译器会直接把 1000000 塞进操作码里,运行时根本不读 storage。
immutable:部署期才能确定的
1 | address public immutable owner; |
immutable 在部署时赋值,之后存在合约代码里(CODECOPY 读取),比 storage 便宜得多。
[!example] 什么时候用?
- 代币的 name、symbol、decimals →
constant- 部署者地址、依赖的合约地址 →
immutable- 任何部署后不会变的状态,都别用普通
storage
打包存储(Packed Storage):榨干每一个存储槽
EVM 的一个存储槽是 256 bit(32 字节)。如果你定义:
1 | uint8 a; // 占 1 字节 |
编译器会自动把它们塞进同一个槽里,只花一次 SSTORE 的钱。这就是 Storage Packing。
手动打包的艺术
但自动打包很蠢,它按声明顺序塞。如果你这么写:
1 | uint128 a; // 槽 1(占 16 字节,剩 16 字节) |
正确姿势:把小变量凑在一起
1 | uint128 a; |
[!bug] 别过度优化
Packing 省的是存储写入的 Gas。如果你的变量经常被单独修改,打包反而可能因为“读-改-写整个槽”增加开销。频繁一起变的变量才适合打包。
create / create2:部署不只是 new 一下
new Contract() 底层调用的是 CREATE 操作码。地址是算出来的,但不可控。
create2 的确定性地址
CREATE2 允许你通过 (deployer, salt, bytecode) 预先算出合约地址。
1 | address deployed = address(new Contract{salt: _salt}()); |
为什么重要?
- Permit2 / 无 Gas 交易:提前算好地址,用户先授权,等有钱了再部署激活。
- 工厂模式:确保同一参数只部署一次合约(单例模式)。
- 地址预注册:在合约还没部署时,就能把地址写进其他协议的白名单。
委托调用(delegatecall):借尸还魂
call 是去别人家执行代码,用别人的环境。delegatecall 是把别人的代码拉到自家执行,用自家的环境(storage、balance、address)。
这是所有代理合约(Proxy)和库合约(Library)的基石。
1 | // 假设 LogicContract 里有 function update(uint x) |
致命陷阱:Storage 碰撞
delegatecall 执行时,读写的是调用方的 storage。如果两个合约的变量声明顺序或类型不一致,就会写错位。
1 | // Proxy 合约 |
[!danger] 代理模式的铁律
如果使用透明代理或 UUPS,永远不要在代理合约里乱加状态变量。所有 storage 必须通过专门的存储槽(如 ERC-1967)或保持严格一致的继承结构。
写在最后
存储优化不是玄学,它只是 EVM 设计逻辑的自然延伸:
- 链上存储贵 → 尽量少写,用
calldata和memory过渡 - 存储槽固定 32 字节 → 小变量凑一起打包
- 不变的数据别占槽 →
constant/immutable安排上 - 代码复用和升级 →
delegatecall和create2是绕不开的路
刚开始写合约,跑通逻辑最重要。等 Gas 账单教做人了,再回头看这些优化,你会觉得:原来 EVM 早就把答案写在文档里了。







