想象你在一座陡峭山坡上开了家便利店,所有商品都要请快递员从山脚一趟趟背上来。快递员按趟计费,哪怕只送一瓶水也要收一次基础运费。为了让生意更赚钱,你慢慢总结出一套“山上派送哲学”:零碎的小物件先合装进同一个收纳箱再上山;容易拦截的订单先在门口做最便宜的检查;几个邻居的包裹攒一辆车一次送齐;顾客问路之类的信息贴个公告就完事,不必占货架;商品挑小巧的包装;店门口的标志一焊定死就永不更换。这套哲学放到区块链世界,就是 Gas 优化策略——“搬运费”对应以太坊交易的执行手续费,“货架”对应合约的持久化存储。接下来用六个招式,把你的智能合约武装成一座省电又省钱的半山便利店。

岗位关联

Gas 是智能合约的“运营成本”,优化 Gas 就是在优化协议的经济模型。学会它,你写的合约在同样功能下更便宜、更有竞争力,这是 Web3 工程师与 DeFi 协议开发者的核心基本功。

目录
  1. 可视化演示
  2. 存储槽打包
  3. 短路求值
  4. 批量操作
  5. 事件替代存储
  6. 数据类型精挑
  7. 常量与不可变
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示存储槽打包减少SSTORE、批量操作分摊固定Gas、各类操作Gas消耗对比及EIP-4844 Verkle树Layer2前沿优化方案,含流动波形背景动画。

本节配有交互动效(进入视口后自动加载,约 3KB)。

存储槽打包

你的便利店仓库用的是统一的 32 厘米宽标准货架,每件货按体积占位。以前进货时习惯每件商品单独占一个格子,于是小瓶水、口香糖、火柴盒都各占一格,货架很快就满了。后来你学会把零碎商品装进同一个收纳箱再上架,一个格子能塞下好几样,仓库租金立刻降了下来。

在以太坊里,合约的持久化存储正是一排 32 字节的槽位,叫作 storage slot。无论写入的是一个 uint8 还是一个 uint256,只要单独占一个槽,首次写入时都要付一次 20000 gas 的 SSTORE 费用。SSTORE 就是“把货物搬上货架”的操作码。如果把多个小字段放进同一个结构体,让它们凑满一个 32 字节的槽,就只需要付一次开槽费。

以四个 uint64 字段为例:它们加起来正好 32 字节,编译器会把它们合并到一个存储槽。而若不打包,四个 uint256 字段会占据四个槽位,初始化要花 4 × 20000 = 80000 gas;打包后只需要 20000 gas,一次省下 60000 gas。

回到便利店:把四瓶水装一个收纳箱,一个格子搞定;不打包就得开四个格子。写合约时多问一句“这几个字段能拼箱吗”,用户的 Gas 账单就会给你一个温柔的回馈。

nslot=⌈∑i=1ksize(fi)32⌉n_{slot} = \left\lceil \frac{\sum_{i=1}^{k} \text{size}(f_i)}{32} \right\rceil
公式解读:只要字段总字节数不超过 32,无论多少个小字段都能装进一个槽;一旦超过 32,就向上取整,开启下一个新槽。
  1. 字段尺寸求和把准备塞进同一槽位的各个字段字节数加起来,得到总占用字节。
    ∑i=1ksize(fi)\sum_{i=1}^{k} \text{size}(f_i)
  2. 除以槽宽用总字节数除以每个槽的容量 32,得到带小数的槽位需求。
    ∑i=1ksize(fi)32\frac{\sum_{i=1}^{k} \text{size}(f_i)}{32}
  3. 向上取整只要装了 32 字节的一部分,就按整个槽收费,余数一律进位。
    ⌈⋅⌉\left\lceil \cdot \right\rceil

短路求值

你在山脚设了一个外卖登记台,订单先做最便宜的检查——“地址填了没有”。填错的单子当场退回,根本不用让快递员爬山。填对的人里再分加急件。看似不起眼的先后顺序,其实替快递员省下了大量无效路程。

Solidity 中的 && 和 || 都具有短路特性:表达式从左到右求值,一旦左侧已经能决定整个结果,右侧就完全不会执行。比如 require(user != address(0) && balances[user] > 0):如果左侧发现地址是零地址,右侧读取余额的 SLOAD(从存储槽取数据)操作压根不会发生。

因此最实用的排列原则是:把便宜、大概率拦截的判断放左侧,把昂贵、需要才触碰的存储读放右侧。这样右侧被执行的期望概率降到最低,整个函数平均花掉的 Gas 也就更少。

回到便利店:一次配送的运费由“必走的山路”加上“偶尔才走的岔路”组成。把最该拦截的检查放山脚,大多数订单压根不必走岔路——Gas 账单自然瘦了下来。

C期望=Cleft+p⋅CrightC_{期望} = C_{left} + p \cdot C_{right}
公式解读:左侧表达式每次一定执行;右侧表达式只在左侧放行后才有机会执行。期望值公式告诉我们,把“大概率拦截”的判断放最前,能直接压低 Gas 的数学期望。
  1. 左侧必经代价短路中第一个表达式必定被求值,记其开销为 \(C_{left}\)。
    CleftC_{left}
  2. 右侧触发概率左侧无法单独决定结果时,右侧才以概率 \(p\) 触发。
    pp
  3. 期望总代价右侧代价乘以触发概率后,再与左侧代价相加,得到总体期望开销。
    Cleft+p⋅CrightC_{left} + p \cdot C_{right}

批量操作

你发现快递员按“趟”收基础费:跑一趟 50 元,多带一件货只加 2 元。如果每位邻居都单独下一单,快递员就得来回跑好几趟,基础费交得人心疼。改成把几单合并成一辆车一次送齐,基础费只付一份。

在智能合约中,每一笔独立交易都要支付一笔固定基础费(约 21000 gas),这和运货的“起步价”是一个道理。批量操作的思路,就是把 N 次独立调用合并成一次批量调用,让起步价只付一次,再按项目数量累加增量成本。

典型例子有 ERC1155 的批量转账、multicall 聚合工具把多个只读调用打进同一笔交易。无论业务逻辑多复杂,基础费都从 N 份缩水成 1 份。

回到便利店:攒够一车再发,比一趟趟跑省下好几份起步价。合约设计里遇到“循环里反复调外部函数”的模式,记得问问自己:能不能一趟车拉完?

G节省=(N−1)×G固定G_{节省} = (N-1) \times G_{固定}
公式解读:批量前后,每项业务的增量成本没变,变的只有“固定基础费”——从 N 份变 1 份,省下的就是多出来的 N-1 份。
  1. 逐个调用的总账N 次独立调用,每次都要支付一笔固定基础费。
    N×G固定N \times G_{固定}
  2. 批量调用的总账一次批量调用只支付一笔固定基础费。
    1×G固定1 \times G_{固定}
  3. 两者相减总账相减后,剩余的正是批量操作省下的固定开销。
    (N−1)×G固定(N-1) \times G_{固定}

事件替代存储

仓库旁边新设了一块公告板。寄存一件货物收 20000 元寄存费,公告板上贴一张纸条只收几百元。差别在于:寄存的货随时能取回,公告板上的纸条只负责“说出去”,写上去就取不回来。你的物流信息、审计底稿,其实只需要公告板。

以太坊的存储(SSTORE)是为了让合约以后能读回状态而设计的,所以昂贵;而日志事件(LOG)只把数据写进链下可索引的历史记录,合约自身读不到它,因此便宜得多。如果业务只需要历史、通知或审计,不依赖合约在后续逻辑里读取这些数据,就应该用 event 替代存储。

比如记录“用户 A 向用户 B 转账了 100 枚代币”这种纯信息型数据,事件是天然选择。它不进任何状态槽,也就没有存储膨胀的烦恼。

回到便利店:顾客想看物流进度,你在公告板上写“编号 1024 已发货”就够了,不必为这条消息专门占一个货架。把“说给别人听的信息”从货架搬到公告板,是省钱的第四把钥匙。

Clog=375+375×ntopic+375×⌈Bdata32⌉C_{log} = 375 + 375 \times n_{topic} + 375 \times \left\lceil \frac{B_{data}}{32} \right\rceil
公式解读:事件计费由三部分构成——每笔日志先收 375 基础费,每个可检索的索引主题再加 375,数据部分按 32 字节一块逐块计费。普通事件往往只需几百 Gas,远低于存储写入的 20000。
  1. 日志基础费发出一条事件日志,平台先收 375 gas。
    375375
  2. 主题计数费每个用于链上检索的索引 topic 额外收 375 gas。
    375×ntopic375 \times n_{topic}
  3. 数据分段计费日志数据按 32 字节一块,每块收费 375,不足一块按整块算。
    375×⌈Bdata32⌉375 \times \left\lceil \frac{B_{data}}{32} \right\rceil

数据类型精挑

便利店货架上,最占地方的往往不是商品本身,而是千篇一律的大包装箱。同样的洗发水,换小包装能多排两排;同样一份信息,选更紧凑的类型也能省出空间。别小看包装,它直接决定了每件货的“占地面积”。

在 EVM 中,存储的最小计价单位就是 32 字节槽。类型本身不改变槽的宽度,但它决定了你能和哪些字段拼进同一个槽。此外,交易 calldata 的计价也跟字节相关:非零字节每字节 16 gas,零字节每字节 4 gas。把数值编码得更紧凑,等于让包裹更轻便。

实际开发中,能用 uint64 就别用 uint256,能用 bool 就尽量把它和其他标志位打进同一个位掩码。小类型单独占槽不会省钱,但小类型多了,拼箱的机会就大了,这才是数据精挑的奥义。

回到便利店:小包装的洗发水能在同一格里多放好几瓶,分摊出的“每瓶仓储费”自然更薄。写合约时不要惯性地用 uint256 走天下,选小包装,就是给用户省钱。

Geff=20000kG_{eff} = \frac{20000}{k}
公式解读:写入一个全新存储槽的基础费是 20000 gas,若把 k 个字段打进同一槽,每个字段摊到的有效成本就是 20000/k。类型越小、塞得越满,单价就越便宜。
  1. 单槽基本开销首次向零值槽写入非零值时,SSTORE 消耗 20000 gas。
    2000020000
  2. 打包字段个数记录同一槽位中实际容纳的字段数量 k。
    kk
  3. 均摊到每字段用 20000 除以 k,得到每个字段承担的实际 gas 成本。
    20000k\frac{20000}{k}

常量与不可变

便利店门口那块“营业时间 9:00-23:00”的招牌,你直接焊死在墙上,永不更换。每次上货都不必重新打字、重新贴横幅。反过来,如果每天要改营业时间,才需要反复更新价目牌。招牌焊死,省下了所有反复改装的工夫。

Solidity 的 constant 和 immutable 正是这样的“焊死招牌”。前者在编译期就确定的常量,后者在构造函数阶段赋值后不再变化——两者都会被直接内联进合约字节码,而不是放进存储槽。读取一个常量只需要一个 PUSH 指令(约 3 gas),而读取一个存储槽中的值,冷槽 SLOAD 要 2100 gas。

常见的用法包括:把部署者地址设成 immutable,把费率、版本号设成 constant。只要某个值在合约生命周期内不会变,就别让它躺在存储槽里交“租金”。

回到便利店:把招牌焊死,就永远不用为它跑腿。合约中若还留着永远不变的值在存储里,等于每个月都在为一块不换的破招牌交仓储费——现在就把它改成 constant 或 immutable 吧。

Gread={3constant 编译期内联2100SLOAD 冷槽读取100SLOAD 温槽读取G_{read} = \begin{cases} 3 & \text{constant 编译期内联} \\ 2100 & \text{SLOAD 冷槽读取} \\ 100 & \text{SLOAD 温槽读取} \end{cases}
公式解读:常量只在编译期出现,运行期不需要碰存储;而存储槽第一次读取要付昂贵的冷加载费。把不变量写进关键字,等于在山上焊一块永不磨损、永不收费的招牌。
  1. 常量内联constant 在编译时直接嵌入字节码,运行期读取仅需 3 gas。
    33
  2. 冷槽读取交易中第一次访问某个存储槽,SLOAD 收取 2100 gas。
    21002100
  3. 温槽读取同一交易中再次读取同一槽位,费用降到 100 gas。
    100100

AI 应用场景

医疗数据共享平台

阶段:链下隐私推理 + 链上事件存证

解决的问题:医疗数据上链面临双重压力:明文存储会让患者隐私暴露,而链上存储又贵得离谱。传统方案要么把大量脱敏数据堆上链,要么留在链下无法审计。结合“事件替代存储”的 Gas 优化思路,让 AI 在链下完成推理,只把结果摘要以日志事件的方式广播出去,链上不落明文、不吃存储。

具体案例:某慢病管理平台把血糖预测模型部署在链下,预测结果通过事件批量广播给医院随访系统,并按周打包上链存证。改造后单月 Gas 支出下降约 60%,同时满足医疗审计的合规要求。

金融风控预警链

阶段:离线风控评分 + 链上短路校验

解决的问题:DeFi 借贷协议若把完整风控模型搬上链,每笔贷款都要跑巨额计算,Gas 高到用户无法承受。实践做法是把 AI 评分放在链下,链上只留一个“哨兵”合约,用短路求值思想做前置过滤:把最便宜且拦截率最高的检查放第一位,绝大多数低风险交易直接放行,极少触发后续昂贵校验。

具体案例:一个借贷协议引入离线反欺诈评分后,将“用户是否命中高危名单”作为首个短路条件,命中即拒;未命中才继续读取抵押率等存储字段。接入后高危拦截的 Gas 支出下降 77%,坏账率同步下降。

Python 演示

python43 行
def storage_slots(fields):    slots = 0    used = 0    for size in fields:        if used + size <= 32:            used += size        else:            slots += 1            used = size    if used > 0:        slots += 1    return slots  def init_gas(fields):    return storage_slots(fields) * 20000  def log_cost(n_topics, data_len):    blocks = (data_len + 31) // 32    return 375 + 375 * n_topics + 375 * blocks  def short_circuit_expected(c_left, c_right, p_right):    return c_left + p_right * c_right  def main():    packed = [8, 8, 8, 8]    loose = [32, 32, 32, 32]     for name, fields in [("松散布局", loose), ("紧凑布局", packed)]:        slots = storage_slots(fields)        print(name + ": " + str(slots) + " 个槽, 初始化写入 " + str(init_gas(fields)) + " gas")     print("打包节省: " + str(init_gas(loose) - init_gas(packed)) + " gas")    print("事件日志(3个主题, 64字节): " + str(log_cost(3, 64)) + " gas")    print("短路求值期望(左100右2100, 右侧执行概率0.4): " +          str(short_circuit_expected(100, 2100, 0.4)) + " gas")  if __name__ == "__main__":    main()
目的:用 Python 模拟合约 Gas 估算,分别计算存储槽打包前后、事件日志与短路求值的开销,帮助开发者在写合约前快速验证优化效果。
  1. if used + size <= 32:判断当前字段能否继续装进同一个 32 字节槽位,装得下就继续拼箱。
  2. slots += 1装不下就换一个新槽,槽位计数器加一。
  3. return storage_slots(fields) * 20000每个新槽首次写入记 20000 gas,得出初始化成本。
  4. blocks = (data_len + 31) // 32事件数据长度向上取整,得到 32 字节数据块的个数。
  5. return c_left + p_right * c_right用期望公式计算短路求值的总 Gas 开销。

常见误区

误区Gas 优化里的“打包”应该就是把源代码压缩、把变量名缩短,让合约文件体积变小吧?于是我疯狂缩写变量名和函数名。

正解存储槽打包专指把多个小类型数据塞进同一个 32 字节槽位,省的是 SSTORE 的 20000 gas 初始费。变量名长短只影响源码可读性,部署后的字节码和执行成本几乎不受影响。

为何常见:传统编程中“代码精简”的概念深入人心,很容易把“打包”与“压缩”混为一谈。

误区把合约里所有 `uint256` 换成 `uint8`,Gas 一定大幅下降!

正解如果 uint8 单独占一个存储槽,它和 uint256 一样要付 20000 gas;只有和其他小字段凑满一个槽才省钱。在 calldata 中,ABI 也会把 uint8 补齐成 32 字节。关键在“拼箱”,而不在字段本身变小。

为何常见:直觉是“东西越小越便宜”,但 EVM 的最小计价单位是 32 字节槽,小类型必须抱团取暖。

误区`&&` 和 `||` 怎么写都行,编译器会自动优化顺序,不用自己操心。

正解Solidity 确实按从左到右的顺序短路求值,但编译器不会替你调整表达式顺序。必须自己把便宜且大概率拦截的检查放左侧,昂贵 SLOAD 放右侧,才能压低期望成本。

为何常见:高级语言让开发者忽略求值顺序,但链上每一笔 opcode 都明码标价。

误区事件那么便宜,干脆把所有数据都 `emit` 出去,合约里什么都不存。

正解事件日志无法被合约读取,只能由链下索引。如果业务逻辑后续需要读取或修改状态,只能老老实实用存储。事件适合广播、审计、历史记录,不适合当“当前状态”来用。

为何常见:初学 Solidity 时只看到日志便宜,忽略了它“只出不进”的单向性。

面试题

Q1. EVM 中一个存储槽位是多少字节?

单选题 · easy

  • A.16 字节
  • B.32 字节← 正确答案
  • C.64 字节
  • D.8 字节

答案:B。EVM 的字长是 256 位,即 32 字节,所以每个存储槽固定 32 字节。这也是为什么小类型必须拼箱才能省槽位。

Q2. 首次向零值存储槽写入非零值时,SSTORE 的基础花费是多少?

单选题 · easy

  • A.100 gas
  • B.5000 gas
  • C.20000 gas← 正确答案
  • D.21000 gas

答案:C。SSTORE 从零到非零的首次写入收取 20000 gas,这是“开新货架”的成本;同一槽非零改非零只需 5000 gas。

Q3. 短路求值中,最推荐的表达式排列原则是什么?

单选题 · easy

  • A.昂贵检查放最左边
  • B.便宜检查放最左边← 正确答案
  • C.昂贵检查放最右边
  • D.便宜检查放最右边

答案:B。左侧一定执行、右侧可能跳过,因此把便宜且大概率拦截的检查放左边,让昂贵读取放在右侧减少触发概率,是性价比最高的排列。

Q4. uint8 与 uint256 各自单独占一个存储槽时,首次写入成本分别是多少?

单选题 · mid

  • A.两者都花 20000 gas← 正确答案
  • B.小类型花 20000 gas
  • C.大类型花 5000 gas
  • D.小类型花 100 gas

答案:A。存储槽按 32 字节计价,只要单独占槽,无论类型多小,首次写入都是 20000 gas。小类型的优势只有在拼箱时才能体现。

Q5. 事件日志可以被合约内部读取吗?

单选题 · mid

  • A.合约可直接读取
  • B.合约无法读取日志← 正确答案
  • C.创建者合约可读
  • D.仅管理员可读

答案:B。事件日志只面向链下,合约自身无法读取历史事件。因此状态必须留有存储,而纯广播型信息才适合用事件。

Q6. 批量操作节省 Gas 的主要来源是什么?

单选题 · mid

  • A.每项都能打折
  • B.固定费只付一次← 正确答案
  • C.编译器自动优化
  • D.存储完全不变

答案:B。N 次独立调用需要 N 份基础费,而一次批量调用只付一份基础费,省下的正是那 N-1 份固定成本。

Q7. 以下哪些场景适合用事件替代存储?

多选题 · mid

  • A.合约后续要读取的状态
  • B.链下审计的历史记录← 正确答案
  • C.跨合约依赖的当前值
  • D.链下监控的告警推送← 正确答案

答案:BD。合约要读取或跨合约依赖的现值必须存存储;而链下审计和告警推送属于单向广播,用事件既便宜又符合场景。

Q8. 事件日志的数据段按多大块计费?

单选题 · hard

  • A.16 字节
  • B.32 字节← 正确答案
  • C.64 字节
  • D.8 字节

答案:B。日志数据按 32 字节一块计费,每块 375 gas,不足 32 字节也按一块算。因此写事件前要尽量压缩数据大小。

Q9. 关于 constant 与 immutable,下列说法正确的是?

多选题 · hard

  • A.immutable 构造函数定值← 正确答案
  • B.constant 编译期内联← 正确答案
  • C.两者都会写入存储
  • D.两者都免存储读取← 正确答案

答案:ABD。constant 在编译期内联,immutable 在构造时定值后写入字节码;两者都不占用存储槽,也就省去了昂贵的 SLOAD 读取。

Q10. 以下哪些做法能同时降低 calldata 与存储的 Gas 开销?

多选题 · extreme

  • A.小字段打包进一槽← 正确答案
  • B.批量合并多次调用← 正确答案
  • C.压缩合约源码注释
  • D.事件替代历史存储

答案:AB。小字段打包减少存储槽写次数、也减少 calldata 体积;批量合并把多次调用的重复数据载荷拼成一份。压缩注释不影响链上字节码,事件替代存储只降存储不降 calldata。