Solidity Gas 优化:存储布局、数据位置与代理模式

深入 Solidity Gas 优化实战:EIP-1559 计费机制、存储读写成本模型、数据位置选择、状态变量打包、EIP-1167 最小代理,以及 Uniswap 级优化案例与基准对比。

在 EVM 的世界里,每一行代码都要付费。Gas 不仅是交易成本,更是用户留存与产品竞争力。一个精心优化的合约可能比粗暴实现便宜 30%-70%。本文从 EIP-1559 计费机制出发,深入存储布局、数据位置、变量打包与最小代理模式,最后以 Uniswap 为案例给出可执行的优化清单。

一、Gas 机制与 EIP-1559

交易费用构成

自 EIP-1559(伦敦升级,2021-08)后,以太坊费用模型为:

totalFee = (baseFeePerGas + priorityFeePerGas) × gasUsed
  ├─ baseFeePerGas  :动态调节,按区块利用率调整,被销毁
  └─ priorityFeePerGas:小费,给验证者,用于激励打包

maxFeePerGas = 用户愿意支付的单位费用上限
maxPriorityFeePerGas = 小费上限

Base Fee 的调节规则

若上一个区块 gasUsed > 目标 15M → baseFee 上升(最多 +12.5%)
若 gasUsed < 15M → baseFee 下降
场景baseFee 表现对开发者的意义
网络拥堵指数上升合约调用成本波动大
空闲期逐步下降批处理写入成本降低
EIP-4844(blob)L2 数据费显著降低zk/op Rollup 性价比提升

一句话:EIP-1559 把"油价"动态化,开发者无法控制 baseFee,只能减少 gasUsed——这正是本文的全部主题。

二、存储读写成本:最贵的操作

EVM 三类数据位置的 Gas 差异

操作Gas说明
SSTORE(0 → 非 0,冷槽位)22,100首次写入 + 冷访问附加费
SSTORE(非 0 → 非 0,冷槽位)7,100改写 + 冷访问附加费
SSTORE(热槽位,0→非0)20,000同交易内已访问过
SSTORE(热槽位,非0→非0)5,000同交易内已访问过
SSTORE(值不变/脏写)100写相同值最便宜
SLOAD(冷)2,100EIP-2929 引入冷/热区分
SLOAD(热)100同交易内已访问
清空槽位(非0→0)5,000 + 4,800 退款退款上限为本次交易 gas 的 1/5

⚠️ EIP-3529 把清空槽位退款从 15,000 降到 4,800,并限制退款最多为消耗 gas 的 1/5——“写垃圾再清空"的套利模式已失效。

一次写入的真实成本

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract StorageCost {
    // 三个 uint256 占用 3 个槽位(slots 0,1,2)
    uint256 public a;
    uint256 public b;
    uint256 public c;

    // 冷访问:3 次 SLOAD = 3 × 2100 = 6300 gas
    function readAll() external view returns (uint256, uint256, uint256) {
        return (a, b, c);
    }

    // 热访问:a 在上一行已读,本次读 b、c 各 100 gas
    function readTwice() external view returns (uint256) {
        uint256 x = a;              // 冷 SLOAD 2100
        return x + b;               // 热 SLOAD 100
    }
}

一句话:一次冷 SLOAD(2100)相当于约 700 次基础算术运算——把数据"读一次、算多次、写一次"是优化的第一原则。

三、数据位置:storage / memory / calldata

三者的本质差异

维度storagememorycalldata
位置链上持久化交易内临时交易的输入参数
成本最贵(读写都贵)中(扩展呈二次方)读最便宜(CALLDATALOAD=3)
可变性可读写可读写只读
生命周期永久函数调用内本次调用内
拷贝成本读 storage 到 memory 需 COPY 费用—复制 calldata 到 memory 也花钱

选择策略

  1. 入参优先 calldata:external 函数的 string/bytes/array 用 calldata,避免拷贝
  2. 计算中间态用 memory:不要频繁读写 storage
  3. 存储指针直接操作:StorageStruct storage s = arr[i] 不产生拷贝费用
  4. 避免 storage → memory → storage 往返:直接在 storage 上累计
// ❌ 差:把 calldata 拷贝进 memory,再写入 storage
function bad(string memory name) external {
    names.push(name);
}

// ✅ 好:calldata 只读,直接写入 storage
function good(string calldata name) external {
    names.push(name);
}

memory 扩展的二次方成本

使用 0-64 字节约 3 gas/字
之后按 内存大小 的二次方公式计费:
memoryCost(n) = 3 × n + n² / 512   (n 为 32 字节字数量)

⚠️ 避免在循环中无界地增长 memory 数组——成本会非线性上涨。

四、状态变量打包与结构体优化

打包规则

EVM 状态以 32 字节(1 字) 为槽位。Solidity 按声明顺序连续存放,能塞进同一槽的变量共用槽:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

// ❌ 差:3 个变量各占一个完整槽位 = 3 个槽位
contract Bad {
    uint256 a;   // slot 0(完整 32B)
    uint8   b;   // slot 1(只用 1B,其余 31B 浪费)
    uint256 c;   // slot 2
}

// ✅ 好:打包到 1 个槽位 = 1 个槽位
contract Good {
    uint128 a;   // slot 0 前 16B
    uint64  b;   // slot 0 中 8B
    uint64  c;   // slot 0 后 8B
}

结构体打包

// ❌ 差:address(20B)+uint96(12B) 可打包却被隔开
struct BadUser {
    uint256 id;       // slot 0
    address addr;     // slot 1(20B,浪费 12B)
    uint96 balance;   // slot 2
}

// ✅ 好:同类小类型紧邻,塞进一个槽
struct GoodUser {
    address addr;     // slot 0 前 20B
    uint96  balance;  // slot 0 后 12B
    uint256 id;       // slot 1
}

打包要点

  • 把 uint8/uint16/uint32/uint64/uint128/address/bool 等小类型聚拢声明
  • 变量顺序不可随意乱排——重排可能增槽位
  • bool 只占 1 字节,可与 uint8、address 打包
  • 数组/映射的元素不受打包影响(各自独立槽位)

一句话:减少"槽位数量"就是减少 SLOAD/SSTORE 次数——每省一个槽位,读可省 2100、写可省 22,100。

五、EIP-1167 最小代理(Minimal Proxy)

痛点

工厂批量部署合约时,每次完整部署都昂贵。EIP-1167 只部署一个 45 字节的代理,把逻辑委托给已部署的实现合约:

部署成本对比:
完整合约:   ~2,000,000+ gas(依逻辑复杂度)
最小代理:   ~100 gas + 存储   ← 省 99%+

代理的运行时字节码

EIP-1167 运行时字节码(45 字节):
363d3d373d3d3d363d73 <20字节目标地址> 5af43d82803e903d91602b57fd5bf3

语义:复制 calldata → delegatecall 到目标地址 → 返回结果

用 OpenZeppelin Clones 部署

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TokenFactory {
    address public immutable implementation;
    address[] public allTokens;

    event TokenDeployed(address token, address owner);

    constructor() {
        // 先部署一次完整实现(逻辑)
        implementation = address(new ERC20Impl());
    }

    function createToken(string calldata name, string calldata symbol)
        external returns (address)
    {
        address clone = Clones.clone(implementation);   // 45 字节代理
        ERC20Impl(clone).initialize(name, symbol, msg.sender);
        allTokens.push(clone);
        emit TokenDeployed(clone, msg.sender);
        return clone;
    }
}

// 用 initialize 代替 constructor(代理无法运行构造函数)
contract ERC20Impl is ERC20 {
    function initialize(string memory name, string memory symbol, address owner) external {
        // 一次性初始化逻辑...
    }
}

代理模式的注意事项

风险说明对策
构造函数不可用逻辑在代理中不执行使用 initialize + 初始化锁
实现合约升级风险升级影响所有代理实现合约"永久锁定"或走 UUPS
存储冲突代理与实现共享存储布局布局升级需谨慎
delegatecall 安全实现可被 delegatecall 滥用实现内禁止 selfdestruct

一句话:EIP-1167 用"45 字节 + delegatecall"把批量部署成本压缩一个数量级,代价是把构造逻辑换成可重入保护的 initialize。

六、Uniswap 式优化案例

v2 Pair:极致打包 + Library

Uniswap v2 把三种状态打包进一个槽位:

// Uniswap V2 Pair 的经典打包
struct Reserves {
    uint112 reserve0;      // 8+4=12 字节
    uint112 reserve1;      // 12 字节
    uint32 blockTimestampLast;  // 4 字节
}                            // 合计 32 字节 = 1 槽位

配合全库化逻辑(library Pair,全部函数 internal),每次 swap 的存储访问被降到最低。

现代优化的其他手段

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

// 自定义错误:比 require("message") 省 ~50 gas + 部署更省
error InsufficientOutput(uint256 have, uint256 want);
error Expired(uint256 deadline, uint256 blockTimestamp);

contract SwapRouter {
    address public immutable weth;   // immutable 内联,免 SLOAD
    uint256 internal constant BPS = 10000;  // constant 编译期内联

    function swapExactETHForTokens(
        uint256 minOut,
        uint256 deadline,
        address[] calldata path
    ) external payable returns (uint256 amountOut) {
        if (block.timestamp > deadline) revert Expired(deadline, block.timestamp);
        // 核心 swap 逻辑...
        if (amountOut < minOut) revert InsufficientOutput(amountOut, minOut);
        return amountOut;
    }
}

优化手段清单

手段收益说明
自定义错误revert 省 ~50 gas + 部署省数千Solidity ≥ 0.8.4
immutable/constant免 SLOAD,内联仅用于部署后不变的值
library(internal)代码复用,避免 delegatecall注意 internal 是内联
CREATE2确定性地址,省 1 次 SLOAD适合工厂与路由
uint256 单变量少算数指令不用小类型装大值
缓存到 memory循环内避免重复 SLOAD循环外读一次
批量转移每笔省固定开销合并多笔小额转账

七、Gas 基准对比

以下为 Foundry forge test --gas-report 风格的典型对比数据(示意量级):

场景朴素实现优化实现节省
单次状态写入(0→非0)22,10020,000(预触热)10%
批量部署 10 个 Token~20M gas~1M gas(EIP-1167)95%
读 3 个打包变量6,300(3×SLOAD冷)100(1×SLOAD热+复用)98%
revert 带消息基线基线 -50 gas/次少量
循环读数组元素N×SLOAD1×SLOAD + 内存拷贝视 N 而定
转帐结算(含手续费)2 次转账1 次净额转账~21,000

⚠️ 注意:gas 报告必须用真实测试数据,本文数字为教学量级。用 forge snapshot 记录每个测试的 gas,作为优化前后的基准。

# Foundry 生成 gas 快照,跟踪优化效果
forge snapshot --diff .gas-snapshot

八、最佳实践清单

检查表

  1. 外部函数入参用 calldata:string/bytes/array 立即生效
  2. 读一次写一次:函数内把 storage 读到 memory 缓存
  3. 变量打包:小类型聚拢,结构体重排
  4. 用 immutable 存常量地址:Token 地址、Router 地址
  5. 自定义错误替代 require 字符串
  6. 批量操作合并:mint/burn/transfer 批量化
  7. 最小代理:工厂场景优先 EIP-1167
  8. 避免无界循环:既贵又易触发 gas 上限
  9. 减少事件成本:事件仅记录必要字段(索引字段最多 3 个)
  10. 部署前先 benchmark:forge snapshot 作为基线

反模式警示

❌ 循环内写 storage 累计变量
❌ 大数组整体拷贝到 memory 再操作
❌ 多次 require(msg.sender == owner)(用 modifier 缓存?不——用地址存 immutable)
❌ 用 string 存结构化数据(应编码或用 bytes32)
❌ 忽略脏写优化(写相同值最便宜,但不要依赖)

总结

维度核心要点
计费机制EIP-1559:baseFee 动态销毁 + priority 小费,只能减 gasUsed
存储成本SSTORE 最高 22,100、SLOAD 冷 2,100,读写是最贵的操作
数据位置入参 calldata、中间态 memory、直接操作 storage 指针
打包小类型聚拢,一槽省一次 SLOAD/SSTORE
最小代理EIP-1167:45 字节、部署省 95%+、需 initialize 模式
Uniswap 案例112+112+32 打包、library 内联、自定义错误、immutable
工程方法forge snapshot 基准 + 真实测试数据驱动优化

Gas 优化不是玄学,而是对 EVM 计费模型的工程化运用:能少写 storage 就少写,能打包就打包,能缓存就缓存,能内联就内联。每一个决定都有明确的气体成本数字支撑。建议每次代码评审都附带 forge snapshot 对比,让优化从"经验"变成"可量化指标”——这既是用户友好,也是与竞争对手拉开成本差距的关键竞争力。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「区块链 Web3」更多文章

  1. 非 EVM 生态:Solana 与 Move 系公链开发
  2. MEV 与区块构建市场:抢跑、三明治与 PBS
  3. NFT 市场合约开发:ERC-721/1155 深入与版税