我理解的 Solidity 存储模型:Storage、Memory 与 Calldata

- (5 min read)

写在前面

刚开始学 Solidity 的时候,我觉得 Storage、Memory、Calldata 不就是"变量放哪儿"的区别嘛,能有多复杂?直到我写了一个 Token 合约,改了个用户的余额,链上查的时候发现余额纹丝不动——代码没报错,测试也过了,但数据就是没写进去。

后来排查了半天才发现:我把 storage 写成了 memory,改的是副本,链上的数据根本没动。

这件事之后我才认真去搞 EVM 的存储模型到底是怎么回事。这篇文章就是那时候的笔记,把踩过的坑和学到的东西都记下来。


一、先搞清楚三者的本质

维度StorageMemoryCalldata
生命周期永久写入区块链函数执行期间临时存在交易数据,只读不可修改
Gas 成本(写)20,0003/字不可写
Gas 成本(读)2,100几乎免费几乎免费
典型用途状态变量、用户余额临时变量、函数内部计算外部函数的数组/字符串参数

一句话总结:Storage 是硬盘,Memory 是内存,Calldata 是只读缓存。记住这个,后面选起来就清楚了。

二、Storage:写进去就是钱

任何需要跨交易保留的数据,只能放 Storage。没有替代方案。

状态变量天然在 Storage 里:

contract Bank {
    // 这些状态变量自动存在 Storage 里,交易结束数据还在
    mapping(address => uint256) public balances;
    address public owner;
    uint256 public totalDeposits;

    function deposit() external payable {
        balances[msg.sender] += msg.value;  // 写 Storage,20000 Gas,肉疼但必须花
        totalDeposits += msg.value;
    }
}

用户存了钱,下次来必须还能查到余额——这就是 Storage 的价值。

但反过来,不要把临时计算结果往 Storage 里塞

contract BadExample {
    uint256 public tempResult;  // ❌ 这玩意儿没必要永久存链上

    function calculate(uint256 a, uint256 b) external {
        tempResult = a + b;     // ❌ 每次调用花 20000 Gas 存一个临时结果
    }
}

tempResult 就是个中间结果,没有跨交易保留的必要。改成 pure 函数,一分钱 Storage 费用都不用花:

contract GoodExample {
    // ✅ 纯计算,不碰 Storage,Gas 几乎为零
    function calculate(uint256 a, uint256 b) external pure returns (uint256) {
        return a + b;
    }
}

三、Memory:函数结束就消失

Memory 用于函数执行期间的临时数据。函数返回,数据消失,链上不留痕迹。

我踩的那个坑

就是开头说的那个事儿,简化一下大概长这样:

contract BadToken {
    struct User {
        uint256 balance;
        uint256 lastDeposit;
    }
    mapping(address => User) public users;

    function updateLastDeposit(address user) external {
        // 这里用 memory,创建的是 Storage 数据的副本
        User memory u = users[user];
        // 改的是副本!链上的 users[user] 纹丝不动
        // 代码能跑,测试能过(如果你没重新读 Storage 验证的话)
        // 但数据就是没写进去——静默错误,最恶心
        u.lastDeposit = block.timestamp;
    }
}

修复方法:把 memory 换成 storage,变成引用:

contract GoodToken {
    struct User {
        uint256 balance;
        uint256 lastDeposit;
    }
    mapping(address => User) public users;

    function updateLastDeposit(address user) external {
        // storage 引用,u 就是 users[user] 本身
        User storage u = users[user];
        u.lastDeposit = block.timestamp;  // ✅ 直接改链上数据
    }
}

这个区分一定要记住:

  • storage = 引用,修改直接影响链上
  • memory = 副本,修改只在函数内有效
  • 不写关键字时,结构体/数组默认看上下文——状态变量是 storage,局部变量是 memory

四、Calldata:最便宜但只读

Calldata 是交易数据的存储区域,只读、不可修改。它是三种位置中最便宜的。

外部函数的数组参数,默认就该用 calldata

contract BatchTransfer {
    // calldata 直接引用交易数据,不复制,省 Gas
    // 传 100 个地址进来,省掉 100 次 Memory 分配
    function batchSend(
        address[] calldata recipients,
        uint256[] calldata amounts
    ) external {
        require(recipients.length == amounts.length, "Length mismatch");
        for (uint256 i = 0; i < recipients.length; i++) {
            // 直接读 calldata,不占 Memory
        }
    }
}

如果写成 memory,交易数据会被完整复制一份。外部函数只读不改,这份复制纯粹浪费钱:

contract BadBatch {
    // ❌ memory 会把整个数组复制一份到 Memory
    // 100 个地址 = 3200 字节,白白多花的 Gas
    function batchSend(
        address[] memory recipients,
        uint256[] memory amounts
    ) external {
    }
}

当然,如果你需要修改输入数据,那只能复制到 Memory:

contract SortExample {
    // calldata 不可修改,要排序只能复制到 memory
    function sort(uint256[] memory data) external pure returns (uint256[] memory) {
        for (uint256 i = 0; i < data.length; i++) {
            for (uint256 j = i + 1; j < data.length; j++) {
                if (data[i] > data[j]) {
                    (data[i], data[j]) = (data[j], data[i]);
                }
            }
        }
        return data;
    }
}

五、Storage 引用的 Gas 优化

搞明白 storage 引用之后,我发现这不只是正确性的问题,还能省不少 Gas。

一次定位 vs 多次定位

contract MultiUser {
    struct User {
        uint256 balance;
        bool isActive;
    }
    mapping(address => User) public users;

    function updateUser(address user, uint256 newBalance, bool active) external {
        // 一次 storage 定位(2100 Gas),后续字段修改都在这一次定位内完成
        User storage u = users[user];
        u.balance = newBalance;   // ✅ 不用再花 Gas 定位
        u.isActive = active;      // ✅ 同上
    }
}

如果不引用,每次都重新定位:

contract Inefficient {
    mapping(address => uint256) public balances;

    function update(address user) external {
        balances[user] += 10;   // ❌ 第一次定位
        balances[user] += 20;   // ❌ 第二次定位
        balances[user] += 30;   // ❌ 第三次定位
        // 三次独立的 Storage 读写,每次都花 Gas
    }
}

更好的做法是缓存到 Memory 再写回:

contract Efficient {
    mapping(address => uint256) public balances;

    function update(address user) external {
        uint256 temp = balances[user];  // ✅ 只读一次 Storage
        temp += 10;
        temp += 20;
        temp += 30;
        balances[user] = temp;          // ✅ 只写一次 Storage
    }
}

六、决策流程

遇到一个新变量,按这个顺序判断就行:

  1. 需要跨交易保留? → Storage
  2. 只是函数内部临时用? → Memory
  3. 外部传入的只读数组/字符串? → Calldata
  4. 需要修改外部传入的数据? → Memory(复制后修改)

本文代码基于 Solidity 0.8.x 版本。