跳至内容
0%

Solidity gas 优化:12 种让智能合约更省 gas、更高效的技巧

Usman Asim headshot

作者 Usman Asim

发布于 2025年11月19日4 分钟阅读

表示 Solidity gas 优化的加油站插图

Ethereum 的 gas 费用一直是生态系统的历史痛点:谁愿意为一笔简单的链上交易支付 $20+ 的 gas?虽然过去几年的 Ethereum 升级已经大幅降低了用户的 gas 成本,但优化 Solidity 代码中的 gas 仍然是在不让用户承受高额费用的前提下,在你的应用中实现复杂链上操作的主要方式。

无论你是在代码审查中降低风险,还是只是想写出更简洁的合约,正确地进行 gas 优化意味着能够构建安全、低成本且可扩展到数百万用户的应用。在这篇指南中,我们将介绍 gas 的基础知识、优化为何重要(剧透一下:相比未优化的代码,它可以降低 20-50% 的成本),并分享 12 种带代码示例的优化技巧。

什么是 Solidity 中的 gas 和 gas 优化?

Gas 是衡量在 Ethereum 上执行特定操作所需计算工作量的单位,而 Solidity gas 优化就是让你的 Solidity 智能合约代码执行成本更低的过程。

在 Ethereum 上交易时,每笔交易都有成本——写入存储或处理交易的成本,这个成本恰如其分地被称为"gas"。如果你不支付 gas 费用,什么都不会发生,就像汽车需要汽油才能行驶一样。

合约同样需要这种 gas。以下是部署和执行智能合约时发生的过程:

  1. 你编写 Solidity 代码。这是用于 Ethereum 智能合约的高级、人类可读的编程语言。
  2. 编译器将其转换为字节码。 当你编译 Solidity 代码时,编译器会将其翻译成字节码——一种底层指令的十六进制表示。这段字节码才是真正存储在区块链上的内容。
  3. 字节码由操作码组成。 该字节码由操作码(opcodes,即操作代码)构成,这些操作码是 EVM 的指令集:可以把它们理解为 Ethereum 的汇编语言。每个操作码代表一个具体操作,比如"两数相加"、"从存储中读取"或"跳转到另一条指令"。
  4. EVM 执行操作码。 当有人调用你的智能合约时,网络中各节点上运行的 Ethereum Virtual Machine(EVM)会读取字节码,将其解码为单独的操作码并逐一执行。每个操作码都有固定的 gas 成本。

例如,ADD 操作码(用于两数相加)的成本是 3 gas,而 SSTORE(写入存储)的成本至少是 20,000 gas。EVM 会累计交易执行过程中每个操作码的 gas 成本。

关于 Ethereum gas 模型的更多信息,请查看官方文档

Gas 优化就是调整你的代码,以更少的操作完成同样的任务,从而降低执行成本。如上所述,每笔交易都需要 gas(以 ETH 或不同链上的等价物支付)。Dencun 升级之后,得益于 blob,数据可用性变得更便宜了,但执行 gas(计算成本)仍然可能累加起来。优化后的合约不仅能为用户节省费用,还能通过更精简的代码库防止 DoS 攻击。深入了解操作码,请参阅"Gas Optimizer" 文档

为什么 gas 优化对开发者很重要?

高 gas = 令人沮丧的用户体验,可能导致用户停止使用你的应用,尤其是在 gas 费用可能飙升的流量高峰期。

将代码优化为低 gas 消耗,可以降低用户的费用,提供更好的用户体验,使原本因手续费而不划算的低价值交易变得可行,并能在不触及区块 gas 上限的情况下应对高使用量。

未优化的合约可能多消耗 20-50% 的额外 gas,从而抬高成本并打开被利用的漏洞。随着 DeFi TVL 在 2025 年 10 月接近 1500 亿美元,gas 高效的合约已不仅仅是锦上添花,而是能左右用户采用度的竞争优势。

要么你的应用因复杂的智能合约逻辑而让用户承担与之相应的高额费用,要么优化你的代码,让用户的最终体验更好、更便宜。

12 大 Solidity gas 优化技巧

以下是经过实战检验的代码 gas 优化方法。我们会解释每个例子、它如何节省 gas,并展示代码。建议你在 Remix 或 Hardhat 中亲自测试,看看差异。

1. 使用映射(mapping)而不是数组

Solidity 提供了两种主要的数据结构来存储数据列表:数组和映射。虽然它们的语法看起来相似,但用途和 gas 成本却大相径庭。

数组是有序、可迭代的集合,元素按顺序存储在内存或存储中。当你需要遍历所有条目或保持特定顺序时,数组很有用。但是,在数组中查找特定条目需要迭代:EVM 必须逐一检查每个元素,直到找到匹配项。这意味着查找操作的复杂度是 O(n),随着数组增长而变得更昂贵。

映射(也叫哈希表)的工作方式完全不同。它们使用键值结构,你可以通过键直接检索任何值,无论存储了多少条目,查找都是 O(1) 常数时间。这是因为 Solidity 使用哈希函数直接从键计算出存储槽位,无需在数据中搜索。代价是映射不可迭代,你无法遍历所有条目,也无法在不单独追踪的情况下知道存在哪些键。

Gas 影响:由于没有迭代开销,创建和访问映射条目比数组操作要便宜得多。只有在你确实需要遍历所有条目或保持插入顺序时才使用数组。在其他所有情况下,尤其是用户余额、所有权记录或任何基于键的查找,映射都是明显更优的选择。

下面是使用数组的例子(访问成本更高):

solidity
Copied
string[] public cars = ["ford", "audi", "chevrolet"]; // To find "audi", you'd need to loop through the array function findCar(string memory target) public view returns (bool) { for (uint i = 0; i < cars.length; i++) { if (keccak256(bytes(cars[i])) == keccak256(bytes(target))) { return true; // Cost increases with array size } } return false; }

下面是使用映射的相同数据(查找成本低得多):

solidity
Copied
mapping(uint => string) public cars; constructor() { cars[101] = "Ford"; cars[102] = "Audi"; cars[103] = "Chevrolet"; } // Direct access - O(1) constant time regardless of data size function getCar(uint id) public view returns (string memory) { return cars[id]; // Single storage read, minimal gas }

使用整数键可以在不产生数组迭代成本的情况下模拟有序列表。这对于需要通过 ID 快速直接访问的用户数据(比如余额或所有权记录)尤其有用。想了解映射底层的工作原理,请参阅 Solidity 映射文档

2. 启用 Solidity 编译器优化器

Solidity 编译器优化器是一个能显著降低 gas 成本的强大工具,但需要根据你的具体用例进行配置。优化器通过分析代码并应用各种转换来工作:它会简化表达式、移除无用代码、内联小函数以消除昂贵的跳转操作,并复用重复的代码片段。所有这些改动都会减少 EVM 需要执行的操作码数量。

不过,这里有一个由 "runs" 参数控制的重要权衡。这个数字告诉优化器,你预期合约中每个操作码在其生命周期内会被执行多少次。优化器利用这个信息在两个相互竞争的目标之间取得平衡:最小化部署成本(一次性发生)与最小化运行时执行成本(每次函数调用都会重复发生)。

runs 参数的工作方式:

  • 低 runs(如 200):优化器优先考虑更小的字节码,从而实现更便宜的部署。这意味着更少的代码重复,以及在可复用代码段之间更多的跳转。最适合那些会频繁部署但很少被调用的合约——比如工厂合约或一次性使用的部署脚本。
  • 高 runs(如 10,000+):优化器通过复制代码来避免跳转并大量内联函数,从而优先考虑运行时效率。这会产生更大的字节码(部署更昂贵),但函数执行更快、更便宜。最适合交易量高的合约:比如 DEX 路由合约、质押合约或 NFT 交易市场。

下面是一个部署频繁的应用使用低 runs(200)的例子:

javascript
Copied
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, // Set to true for opt runs: 200, }, }, },};

下面是一个部署较少、为高 runs(10000)优化的应用示例:

javascript
Copied
module.exports = { solidity: { version: "0.8.9", settings: { optimizer: { enabled: true, runs: 10000, }, }, },};

选择合适的 runs 值

两种方式各有权衡。想想你合约的生命周期。一个只部署一次但有数百万次转账交易的治理代币应该使用高 runs。一个不断创建新合约但这些合约很少被调用的部署工厂应该使用低 runs。如果拿不准,200 是一个能较好平衡两方面考虑的安全默认值。你可以根据自己合约的特点自行测试,并可以在 Hardhat config 或 Remix 中启用其中任意一种设置。

3. 最小化链上数据

链上存储是 Solidity 中最昂贵的单项操作。每个 SSTORE 操作码(写入存储)的成本可能超过 20,000 gas(以 Gwei 计量),修改已有的存储槽位仍需 2,900-5,000 gas。相比之下,内存操作的成本只有 3 gas,你很快就能明白为什么存储优化至关重要。这里的基本原则很简单:只在链上存储绝对必要的内容,其余一切都通过 API、预言机或索引服务在链下处理。

除了减少存储写入之外,你还应该巧妙地对操作进行分组,以避免冗余成本。这能让你的合约保持精简,同时降低部署和运行时费用,并让攻击者更难利用可能被用于 DoS 攻击的计算密集型函数。

在存储变量中保存数据

对什么值得存储要有取舍。例如,用户余额、所有权记录以及必须防篡改且全局可访问的合约状态:这些应该放在链上。如果要为 gas 优化,其余一切都应放在链下。例如,如果你需要外部价格数据,请使用 Chainlink 等预言机在执行期间获取,而不是存储历史价格。如果你需要为前端追踪交易历史,请触发事件(emit events),而不是存储数组。

关于事件有一个关键陷阱:虽然触发事件的成本很低(约 375 gas 基础 + 每个 topic 375 gas),但合约无法读取自己触发的事件。事件的存在纯粹是为了供索引器和前端进行链下消费。永远不要用事件来替代合约逻辑需要访问的存储。

批量操作

与其要求用户提交多笔单独的交易,不如将相关操作打包成一笔交易。这样可以节省 21,000 gas 的基础交易费(每笔交易无论做什么都要支付),还能减少冗余操作,比如多次检查 msg.sender、重复加载相同的存储变量,或为多次交易提交支付 calldata 成本。

这种模式对于多步骤流程尤其有效,例如代币授权后再转账,或原子性地执行多个 DeFi 操作(先兑换,再质押,再领取奖励)。

下面是一个批量发送函数的例子:

solidity
Copied
Struct Call { address recipient; uint256 gas; uint256 value; bytes data; } function batchSend(Call[] memory _calls) public payable { for(uint256 i = 0; i < _calls.length; i++) { (bool _success, bytes memory _data) = _calls[i].recipient.call{ gas: _calls[i].gas, value: _calls[i].value }(_calls[i].data); if (!_success) { assembly { revert(add(0x20, _data), mload(_data)) } } } }

这种模式通过消除重复的 msg.sender 验证、减少 calldata 开销(函数选择器只传递一次),以及只支付一次基础交易费而不是每次操作都支付,能显著节省 gas。

循环

循环是 gas 的乘数,每次迭代都会重复相同的操作,成本呈线性累加。对 100 个条目进行存储操作的循环轻松就能消耗超过 500,000 gas,而对无界数组的循环甚至可能超出区块 gas 上限,使你的函数永久无法调用。

解决方案几乎总是完全消除循环。使用映射实现 O(1) 常数时间查找,而不是 O(n) 数组迭代。如果实在必须迭代,请严格限制数组大小,或者更好的做法是,将迭代移到链下,让用户提交具体的索引或键。

关于 gas 退款的说明

Solidity 曾经为清空存储(将值设为零)提供 gas 退款,但 EIP-3529 大幅削减了这些退款。虽然清空存储仍能获得少量退款,但它已不再是一项主要的优化策略。关于当前退款机制的详情,请参阅 Ethereum gas 退款提案

4. 使用带索引的事件

事件是一种轻量级的日志记录机制,其成本仅为存储操作的一小部分,约 375 gas 基础成本加上每个索引参数 375 gas,相比之下写入存储需要 20,000+ gas。事件被写入交易收据树(transaction receipt trie),这与合约状态存储是分开的,非常适合用来记录链下应用需要追踪的信息。

关键限制在于:从合约的角度看,事件是只写的。一旦触发,你的合约代码就无法再读回它们。事件的存在纯粹是为了供前端、索引器和监控工具进行外部消费。将事件用于通知和链下系统需要的历史记录,但绝不要用于合约逻辑依赖的数据。

这可以卸载大量的 gas 消耗。与其把每笔交易都存储在昂贵的存储数组中,不如触发一个事件,让链下索引器(比如 The Graph 或 Alchemy 的 API)为你的前端构建这段历史记录。

以下是声明和触发事件的方法:

solidity
Copied
event MyFirstEvent(address indexed sender, uint256 indexed amount, string message); function doSomething(uint256 _amount, string memory _message) public { // Your contract logic here // Emit the event - cheap logging instead of expensive storage emit MyFirstEvent(msg.sender, _amount, _message); }

索引参数

你最多可以将 3 个参数标记为 indexed,这使它们可以在日志查询中被检索。例如,将 senderamount 设为索引后,你可以快速筛选"所有 sender = 0x123... 的事件",而无需扫描每一个事件。像 message 这样未索引的参数仍会被记录,但无法直接检索。

常见用例包括代币转账、所有权变更、状态转换以及用户活动追踪——任何你需要为链下消费保留记录但不需要链上访问的场景。更多详情请参阅 Solidity 事件文档

5. 打包你的变量

EVM 以 32 字节的槽位存储数据,每个存储槽位写入都需要花费 gas(新槽位 20,000+ gas,更新已有槽位 2,900-5,000 gas)。通过策略性地将小变量分组在一起,你可以将多个变量放入单个槽位,从而大幅减少合约所需的 SSTORE 操作次数。

可以把它想象成高效地打包行李箱:物品排列的顺序决定了浪费多少空间。变量是按你声明的顺序打包的,因此仔细安排至关重要。

优化前(浪费了 3 个槽位的空间):

solidity
Copied
contract MyContract { uint128 c; // Slot 0 (uses 16 bytes, wastes 16 bytes) uint256 b; // Slot 1 (uses full 32 bytes) uint128 a; // Slot 2 (uses 16 bytes, wastes 16 bytes) }

即使数据只需要 2.5 个槽位的空间,这里也使用了 3 个存储槽位。

优化后(压缩到 2 个槽位):

solidity
Copied
contract MyContract { uint128 a; // Slot 0 (first 16 bytes) uint128 c; // Slot 0 (second 16 bytes) - packed together! uint256 b; // Slot 1 (full 32 bytes) }

通过连续声明两个 uint128 变量,它们共享一个槽位,每次同时写入这两个变量时就能节省整整一次 SSTORE 操作。

关键打包规则:

  • 变量按声明顺序打包
  • 当下一个变量放不进当前槽位时,会开启一个新槽位
  • uint8uint128address(20 字节)这样更小的类型是打包的理想候选
  • 即使一个小类型单独存在,它仍然会占用完整的 32 字节槽位,所以总是尽量把它们配对

例如,两个 address 变量(各 20 字节)无法打包进一个槽位,因为 40 字节超过了 32 字节。但一个 address(20 字节)加上一个 uint96(12 字节)刚好可以完美地放进一个 32 字节槽位。

关于 Solidity 如何排列存储的更多细节,请查看存储布局文档

6. 释放未使用的存储

当你通过将存储变量重置为默认值(整数为 0,地址为 address\(0\),布尔值为 false)来清空它们时,EVM 会提供 gas 退款。虽然 EIP-3529 相比原始额度大幅削减了这些退款,但每清空一个存储槽位仍能拿回 4,800 gas——在清理过时数据时是一笔有意义的回收。

这之所以有效,是因为重置存储槽位能减少区块链的状态大小,因此 Ethereum 会激励这种清理行为。这是一种在数据过时后收回部分成本、同时保持合约状态精简高效的方式。

以下是清空变量的方法:

solidity
Copied
delete myVariable; // Resets to default value and triggers refund// Or explicitly: myInt = 0; myAddress = address(0); myBool = false;

关于映射的重要说明:delete 关键字对整个映射不起作用,因为映射不会追踪存在哪些键。相反,你必须逐个删除映射条目:

solidity
Copied
mapping(address => uint256) public balances; // This won't work - can't delete entire mapping// delete balances;// Instead, delete specific keys: delete balances[msg.sender]; // Clears this specific entry

实际用例

Gas 退款在数据具有明确生命周期的合约中最有用,比如可以在完成后清理的托管合约、会过期的临时授权,或变得陈旧的缓存数据。不要为了追求退款而扭曲你的合约逻辑,但当数据自然过时时,清理它是双赢的做法。

关于当前的退款机制和限制,请查看 EIP-3529 gas 退款文档

7. 对某些函数参数使用 calldata 而不是 memory 存储数据

在声明函数参数时,对于数组、字符串和结构体等引用类型,你需要在 memorycalldata 之间做选择。理解两者的区别可以节省大量 gas,尤其是对于参数较大的 external 函数。

Calldata 是只读存储,直接存在于交易数据中。当你使用 calldata 时,函数直接从交易中读取参数,无需将其复制到任何地方。这是最便宜的选项,因为它完全避免了内存分配和复制操作。

Memory 则需要 EVM 分配空间,并将数据从 calldata 复制到内存中,执行多次 MLOADMSTORE 操作。对于大型数组或字符串,这种复制开销会变得昂贵:每复制一个元素都会额外消耗 gas。

规则:对于只需要读取的 external 函数参数,使用 calldata。只有当你需要在函数内修改数据时,才使用 memory

以下是使用 calldata 的例子(对只读访问更便宜):

solidity
Copied
function processNumbers(uint[] calldata nums) external { for (uint i = 0; i < nums.length; i++) { // Read-only operations - no copy needed uint value = nums[i]; // Do something with value } }

对比使用 memory 的情况(由于复制而更昂贵):

solidity
Copied
function processNumbers(uint[] memory nums) external { // Data gets copied from calldata to memory first (costs gas) for (uint i = 0; i < nums.length; i++) { uint value = nums[i]; } }

gas 节省的幅度随数据量增大而扩大,一个 100 个元素的数组用 calldata 传递,相比 memory 能节省数千 gas。

必须使用 memory 的情况:如果你的函数需要修改数组、向其追加内容或构建新的数据结构,那么就需要 memory,因为 calldata 是不可变的。但对于纯读取操作,calldata 始终是更好的选择。

关于存储位置差异的更多信息,请参阅 calldata 与 memory 文档

8. 使用 immutable 和 constant 修饰固定值

标记为 constantimmutable 的变量根本不使用存储槽位:它们的值会在部署时直接烘焙进合约的字节码中。这就消除了每次访问它们时昂贵的 SLOAD 操作(每次 2,100 gas),用便宜的字节码读取取而代之。

Constant:值必须在编译时设定,且不能改变。用于那些在所有部署中都永不改变的硬编码值。

Immutable:值在构造函数中设定一次,之后不能改变。用于在不同部署之间会有差异(例如代币地址或所有者地址),但部署后固定不变的值。

以下是两者的用法:

solidity
Copied
contract MyContract { uint256 constant FEE_RATE = 10; // Set at compile time address immutable owner; // Set once at deployment constructor(address _owner) { owner = _owner; // Can only set in constructor } function calculateFee(uint256 amount) public pure returns (uint256) { return amount * FEE_RATE / 100; // No SLOAD - reads from bytecode } }

Gas 节省示例:如果你在一个函数中读取一个普通存储变量 10 次,那就是 21,000 gas 的 SLOAD 操作。而使用 constantimmutable,这些读取几乎不花费任何成本:只需执行基本算术操作的 gas。

常见用例:

  • 协议费率或百分比(constant
  • 小数位数或缩放因子等数学常量(constant
  • 来自构造函数参数的代币地址(immutable
  • 合约所有者或管理员地址(immutable
  • 不会改变的外部合约地址(immutable

constantimmutable 变量也都可以在合约外部的文件级别声明,使其可以在同一文件中的多个合约间复用。

更多详情请参阅常量与不可变量文档

9. 使用 external 可见性修饰符

函数可见性修饰符会影响 EVM 处理函数调用的方式,并可能影响 gas 成本。关键区别在于:external 函数专门针对来自合约外部的调用进行了优化,而 public 函数必须同时处理外部和内部调用,从而增加了开销。

External 函数只能从合约外部调用(通过交易或其他合约)。当被外部调用时,它们直接从 calldata 读取参数而无需复制,因而效率略高。你不能通过 functionName\(\) 在内部调用一个 external 函数——你需要使用 this.functionName\(\),而这会产生一次昂贵的外部调用。

Public 函数既可以被外部调用,也可以被内部调用。这种灵活性要求编译器生成额外代码来处理这两种调用类型,即使在被外部调用时,也会增加一点 gas 开销。

一般经验法则是:对于属于合约公共 API、只会被用户或其他合约调用的函数,使用 external。对于只有你合约自身函数需要调用的辅助函数,使用 internalprivate

以下是一个 external 函数的例子(针对外部调用优化):

solidity
Copied
function updateMessage(string calldata _newMessage) external returns (string memory) { message = _newMessage; return message; }

对比 public 的情况(同时处理内部和外部调用):

solidity
Copied
function updateMessage(string memory _newMessage) public returns (string memory) { message = _newMessage; return message; }

gas 节省是适中的:通常每次调用大约节省 20-50 gas,但经过成千上万笔交易累积起来也很可观。更重要的是,使用 external 能清楚地表明意图:这个函数是为了从合约外部调用而设计的。

额外提示:注意到 external 版本对字符串参数使用了 calldata 吗?External 函数与 calldata 参数是天生一对,因为两者都是针对外部调用优化的。

关于函数可见性和最佳实践的更多信息,请参阅可见性文档

10. 安全地使用 unchecked 算术

从 Solidity 0.8.0 开始,编译器会自动为所有算术操作添加溢出和下溢检查。这虽然防止了 bug,但每次操作大约会花费 30-40 gas。当你确定溢出/下溢在数学上是不可能发生的时,可以将操作包裹在 unchecked 代码块中,跳过这些检查以节省 gas。

什么时候安全:边界已知的循环计数器、输入已经过验证的算术运算,或者在数学上不可能发生溢出的操作。

什么时候要避免:未经验证的用户输入值、金融计算,或任何溢出可能引发漏洞的场景。

以下是一个基本示例:

solidity
Copied
function add(uint x, uint y) external pure returns (uint) { unchecked { return x + y; // Skips overflow check - saves ~30 gas } }

以下是另一个展示循环计数器的例子,这是该技巧最常见的用例:

solidity
Copied
function processArray(uint[] calldata items) external { for (uint i = 0; i < items.length;) { // Process item unchecked { ++i; // i can never realistically overflow } } }

在这个循环中,i 从 0 开始,每次迭代递增 1。i 要溢出,数组需要有 2^256 个元素,考虑到区块链的限制,这在物理上是不可能的。这正是使用 unchecked 的理想场景。

重要安全提示:如果你使用 unchecked,请添加手动 require 语句来验证可能引发问题的输入:

solidity
Copied
function subtract(uint x, uint y) external pure returns (uint) { require(x >= y, "Underflow prevented"); unchecked { return x - y; // Safe because validated above } }

在循环中或被频繁调用的函数中,unchecked 代码块带来的 gas 节省会迅速累积。更多细节和边界情况,请参阅 unchecked 文档

11. 最小化外部调用

每次调用另一个合约,仅 CALL 操作码本身至少花费 100 gas,再加上 calldata 以及被调用合约中任何状态变更的额外成本。如果外部合约发生 revert,这些调用还可能不可预测地失败,这使得它们既昂贵又有风险。当你需要从外部合约获取数据时,将多次调用批量处理,或者缓存结果,以避免在同一笔交易中重复调用。

高 gas 效率的方法:

solidity
Copied
interface IExternal { function getData() external view returns (uint); } function processData(address addr) external { // Call once and cache the result uint data = IExternal(addr).getData(); // Reuse cached value multiple times - no additional calls uint result1 = data * 2; uint result2 = data + 100; uint result3 = data / 5; }

低效的方法(应避免):

solidity
Copied
function processData(address addr) external { // Calling three separate times - wastes ~300 gas uint result1 = IExternal(addr).getData() * 2; uint result2 = IExternal(addr).getData() + 100; uint result3 = IExternal(addr).getData() / 5; }

如果你在一笔交易中多次需要相同的数据,务必将其缓存到一个本地变量中。如果你需要跨多笔交易使用外部数据,可以考虑将其存储下来(不过要权衡 20,000 gas 的 SSTORE 成本与外部调用的频率)。

关于外部调用的模式和安全注意事项,请参阅外部调用最佳实践

12. 在关键路径使用汇编

对于性能关键的代码,比如紧凑循环或频繁调用的函数,你可以下沉到 Yul 汇编层面,手动进行超出 Solidity 编译器能力范围的优化。汇编让你能直接控制内存管理,可以跳过安全检查,并消除抽象层带来的开销。然而,这是一把双刃剑——汇编会绕过 Solidity 的所有安全特性,使代码更难阅读,也极易出错。

什么时候考虑使用汇编:高频操作、复杂的位操作、自定义内存布局,或者执行成千上万次、每一单位 gas 都很重要的循环。

什么时候要避免使用汇编:除上述情况之外的任何地方。gas 节省很少能抵得上增加的 bug 风险、安全漏洞和维护负担。

以下是使用汇编对数组求和的例子:

solidity
Copied
function sum(uint[] memory arr) public pure returns (uint s) { assembly { let len := mload(arr) // Load array length let data := add(arr, 0x20) // Skip length, point to data for { let i := 0 } lt(i, len) { i := add(i, 1) } { s := add(s, mload(add(data, mul(i, 0x20)))) // Load and sum each element } } }

这个汇编版本通过直接操作内存指针并跳过边界检查来节省 gas,但相比等效的 Solidity 代码,它明显更难理解和审计。

关键安全实践

  • 用边界情况彻底测试汇编代码
  • 添加详尽的注释解释每一步操作
  • 让汇编部分由安全专家审计
  • 只有在穷尽 Solidity 优化手段后,才将汇编作为最后手段使用

对于大多数开发者和大多数用例来说,本指南中的其他 11 种技巧能以低得多的风险带来更好的 gas 节省效果。只有当你已经对合约进行了性能分析、确定了具体的瓶颈,并确认 gas 节省能够证明增加的复杂度是值得的时候,才应该考虑使用汇编。

关于学习 Yul 语法和能力,请参阅 Yul 文档

部署前测试你的智能合约

在部署到主网之前,使用开发工具严格测试你的 gas 使用情况。Hardhat 提供了 gas 报告插件,可以生成每个函数 gas 成本的详细分解。Remix IDE 会在你测试时实时显示 gas 估算。将优化重点放在面向用户的函数上,比如铸造、转账和兑换:这些是被调用最频繁、对用户体验影响最大的函数。

要进行更深入的测试,可以使用 Foundry 的模糊测试功能,在成千上万个随机化输入上对你的优化效果进行基准测试,确保你的 gas 节省在真实场景和边界情况下依然成立。

总结:开始优化吧

现在你已经掌握了 12 种经过实战检验的技巧,能让你的 Solidity 合约更精简、执行成本更低。gas 优化不仅仅是省钱:它关乎为用户构建更好的体验,打造他们真正愿意使用的应用。

先从容易实现的部分入手:启用编译器优化器,用映射代替数组,并将固定值标记为 constant 或 immutable。然后对你的合约进行性能分析,找出瓶颈,并在能产生最大影响的地方应用更高级的技巧。

持续学习资源

现在就出发开始优化吧,你的用户(以及他们的钱包)会感谢你的!

常见问题

在 Solidity 智能合约中降低 gas 成本最有效的方法有哪些?

最有影响力的技巧包括:用映射代替数组进行查找、启用带合适 runs 设置的 Solidity 编译器优化器、将固定值标记为 constantimmutable、最小化存储操作,以及对只读函数参数使用 calldata 而不是 memory

使用 constantimmutable 变量如何帮助进行 gas 优化?

constantimmutable 的值会直接嵌入合约字节码中,而不是存储在昂贵的存储槽位中,从而消除了成本高昂的 SLOAD 操作(每次 2,100 gas),代之以廉价的字节码读取。

为什么在数据查找中应该使用映射而不是数组?

无论数据量多大,映射都能提供 O(1) 常数时间的查找,而数组需要 O(n) 迭代,随着数组增长成本会越来越高。对于基于键的访问模式(比如用户余额或所有权记录),映射的成本明显更低。

Solidity 编译器优化器是如何工作的?我应该使用什么样的 runs 设置?

优化器通过简化表达式、移除无用代码和内联函数来降低 gas。对于你会频繁部署但很少调用的合约,使用低 runs(200);对于运行时效率比部署成本更重要的高交易量合约,使用高 runs(10,000+)。

在 gas 优化中使用 calldatamemorystorage 有什么区别?

对于你只读取的 external 函数参数使用 calldata(避免复制成本),对临时数据操作使用 memory,只有在需要持久化状态时才使用 storage。对于只读操作,calldata 始终比 memory 更便宜。

什么时候应该安全地使用 unchecked 算术代码块?

在溢出在数学上不可能发生的操作中使用 unchecked,比如边界已知的循环计数器或已验证的算术操作。这通过跳过 Solidity 0.8.0 引入的自动溢出检查,每次操作可以节省约 30-40 gas。

我该如何优化存储操作以降低 gas 成本?

将多个小变量打包进单个 32 字节存储槽位,删除未使用的存储以获得 gas 退款(每清空一个槽位 4,800 gas),并在函数执行过程中通过在内存中缓存值来最小化存储写入。

使用 external 可见性修饰符相比 public 有什么好处?

external 函数专门针对来自合约外部的调用进行了优化,并能与 calldata 参数高效配合使用,相比必须同时处理内部和外部调用的 public 函数,每次调用可节省 20-50 gas。

Background gradient

构建区块链应用

Alchemy 将最强大的 Web3 开发者产品和工具与资源、社区及专业支持结合在一起。