Solidity 存储与优化进阶

写 Solidity 最容易产生的错觉就是:它长得像 JavaScript,所以行为也应该像 JavaScript。

直到你部署了合约,看到一笔简单的交互烧掉了你半个月的测试网 ETH,才意识到:在 EVM 里,存储就是金钱,而金钱是很贵的。

这篇不扯什么“区块链改变世界”,只聊点实在的——怎么在存储这块把 Gas 压下来,顺便避开那些让新手头皮发麻的坑。


存储位置三剑客:storage / memory / calldata

这三个词你肯定见过,但很多人只是机械地背:“状态变量用 storage,局部变量用 memory”。

稍微深究一下:为什么外部函数的参数推荐用 calldata 而不是 memory

本质区别

  • **storage**:永久存在链上,读写最贵(SLOAD/SSTORE)。改它等于改账本。
  • **memory**:函数执行期间存在内存里,按字分配。每次调用都要花钱 copy 数据进来。
  • calldata:只读,数据直接躺在交易 payload 里。不用 copy,所以最便宜。
1
2
3
4
5
6
7
8
9
// ❌ 别这么干
function processUsers(address[] memory users) external {
// memory 会把交易数据完整拷贝一份到内存,费 Gas
}

// ✅ 外部函数参数默认用 calldata
function processUsers(address[] calldata users) external {
// 直接读原始交易数据,省了一笔拷贝费
}

[!tip] 什么时候必须用 memory?
当你需要修改参数内容,或者要把数据传给内部函数(internal/private)时。calldata 是只读的,内部函数也不认 calldata


映射(mapping)与数组:没有银弹

新手最爱 array,因为能 push、能遍历、感觉像在用普通编程语言。
老手偏爱 mapping,因为 O(1) 查询,Gas 稳定。

怎么选?

需求 推荐 原因
查某个地址的余额 mapping(address => uint) 快,不依赖数据量
需要遍历所有用户 arraymapping + index数组 mapping 本身不可遍历
频繁删除中间元素 别用普通 array,用 swap-and-pop 删除中间元素要搬移数据,Gas 爆炸

数组删除的坑

1
2
3
4
5
6
7
8
9
// ❌ 删除中间元素,后面的全要往前挪
for (uint i = index; i < arr.length - 1; i++) {
arr[i] = arr[i + 1];
}
arr.pop();

// ✅ 如果顺序不重要,把最后一个填过来直接 pop
arr[index] = arr[arr.length - 1];
arr.pop();

[!warning] Gas Refund 没了
EIP-3529 之后,删除 storage 变量不再退还 Gas 了。所以别指望靠 delete 省钱,该省还是得从设计层面省。


常量(constant)与不可变变量(immutable)

这两兄弟是省 Gas 的利器,原理很简单:不占存储槽,直接编译进字节码。

constant:编译期就能确定的

1
2
uint256 public constant MAX_SUPPLY = 1000000;
address public constant DEAD = 0x000...dEaD;

编译器会直接把 1000000 塞进操作码里,运行时根本不读 storage。

immutable:部署期才能确定的

1
2
3
4
5
address public immutable owner;

constructor() {
owner = msg.sender; // 部署时写入代码段,之后只读
}

immutable 在部署时赋值,之后存在合约代码里(CODECOPY 读取),比 storage 便宜得多。

[!example] 什么时候用?

  • 代币的 name、symbol、decimals → constant
  • 部署者地址、依赖的合约地址 → immutable
  • 任何部署后不会变的状态,都别用普通 storage

打包存储(Packed Storage):榨干每一个存储槽

EVM 的一个存储槽是 256 bit(32 字节)。如果你定义:

1
2
3
uint8 a; // 占 1 字节
uint8 b; // 占 1 字节
uint8 c; // 占 1 字节

编译器会自动把它们塞进同一个槽里,只花一次 SSTORE 的钱。这就是 Storage Packing

手动打包的艺术

但自动打包很蠢,它按声明顺序塞。如果你这么写:

1
2
3
uint128 a; // 槽 1(占 16 字节,剩 16 字节)
uint256 b; // 槽 2(占满,a 剩下的空间浪费了)
uint128 c; // 槽 3(占 16 字节)

正确姿势:把小变量凑在一起

1
2
3
uint128 a;
uint128 c; // 和 a 凑满 32 字节,共用槽 1
uint256 b; // 单独占槽 2

[!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
2
3
4
// 假设 LogicContract 里有 function update(uint x)
(bool success, ) = logicAddress.delegatecall(
abi.encodeWithSignature("update(uint256)", _x)
);

致命陷阱:Storage 碰撞

delegatecall 执行时,读写的是调用方的 storage。如果两个合约的变量声明顺序或类型不一致,就会写错位。

1
2
3
4
5
6
7
// Proxy 合约
address public owner;
uint256 public value;

// Logic 合约
uint256 public value; // 错位!这里会覆盖 Proxy 的 owner
address public owner;

[!danger] 代理模式的铁律
如果使用透明代理或 UUPS,永远不要在代理合约里乱加状态变量。所有 storage 必须通过专门的存储槽(如 ERC-1967)或保持严格一致的继承结构。


写在最后

存储优化不是玄学,它只是 EVM 设计逻辑的自然延伸:

  • 链上存储贵 → 尽量少写,用 calldatamemory 过渡
  • 存储槽固定 32 字节 → 小变量凑一起打包
  • 不变的数据别占槽 → constant / immutable 安排上
  • 代码复用和升级 → delegatecallcreate2 是绕不开的路

刚开始写合约,跑通逻辑最重要。等 Gas 账单教做人了,再回头看这些优化,你会觉得:原来 EVM 早就把答案写在文档里了。