每次交易被发送到区块链上,必须支付Gas费用。消耗的Gas与交易所需的计算量有关,即:EVM执行交易所需的计算量(如果交易不涉及EVM,例如简单的以太币转账,Gas的数量是固定的)。
你可以设计和实现你的智能合约,使其具有Gas效率。在本博客中将讨论两种 "类型"的Gas :
- 交易Gas:用户每次与智能合约交互时支付的 Gas 量。实现 Gas 高效的函数,必须尽可能地减少Gas消耗。
- 部署Gas :每次部署智能合约时,需要支付的Gas量。部署智能合约通常只发生一次,尽管如此,仍然可以节省 Gas 也是很有趣的。
有时,减少一种Gas的技术会导致另一种Gas的增加,这是我们必须处理的权衡......
这是一份清单,在处理你的智能合约时应该记住的事情,以便节省Gas。
- 尽量减少链上数据(使用事件、IPFS、无状态合约、merkle证明)。
- 最小化链上操作(字符串、返回存储值、循环、本地存储、批处理)
- 内存位置(calldata、栈、内存、存储)。
- 变量顺序
- 首选的数据类型
- 库(嵌入式库,独立部署库合约)。
- 最小代理(Minimal Proxy)
- 构造函数
- 合约大小(消息、修改器、函数)。
- Solidity编译器优化器
尽量减少链上数据
在区块链存储上保存数据是很昂贵的,设法将需要在区块链上存储的信息量减少到最小,将会节省大量的交易 Gas。
- 事件(Events) :你可以考虑使用事件来 "存储" 区块链上的数据。一个事件是一段信息,它实际上将被存储在区块链上,只是它不会成为合约存储的一部分,事实上,智能合约不可能以任何方式读取或使用事件。事件只对读取区块链的链外应用程序可用。这就是为什么如果智能合约需要这些信息,就不能使用事件。如果是仅用于阅读目的,就应该考虑使用时间来把数据持久化在区块链上。参考: 智能合约不是数据库
- IPFS : 如果你需要以去中心化的方式保存文件(文档、视频......),应该考虑IPFS(一种分布式的、廉价的文件存储)。每个存储在IPFS上的文件将有一个唯一的ID,你可以将其存储在区块链上以供参考,但实际文件将存储在IPFS中。
- 无状态合约:如果你只需要将区块链作为一个去中心化的数据库来存储一些 "简单" 的数据,如键/值对或类似的数据,你可以使用无状态合约。思路是部署一个带有定义一些输入参数的函数,但不真正存储任何数据。用户调用方法时,输入参数作为交易数据的一部分。交易将永远存储在区块链上,这意味着你将永远能够从链外应用程序中读取交易数据的内容(其中包含输入参数)。这里的缺点是,你将需要实现一个强大的后端,能够跟踪并从区块链上提取这些值。事件更容易跟踪、过滤和提取,但事件更昂贵。
- Merkle Proofs (默克尔证明):如果你需要使用区块链来验证一些信息是否有效,你可以使用merkle证明。Merkle证明使用单一的数据块来证明更大的数据量的有效性。思路是只需要在区块链上存储Merkle树根(Hash12345678),以便能够验证多个交易(Tx1 .... Tx8)。例如,如果有人想证明 "Tx4 "的有效性,他将需要提供Tx4、Hash3、Hash12和Hash5678,然后你的合约将能够重新计算Merkle根(Hash12345678),并检查它是否与存储在区块链上的根相一致。你将不需要存储所有交易的哈希值。例如,这个文章介绍了 使用 默克尔树来实现白名单控制
尽量减少链上操作
在智能合约上执行的功能,应该只在出于安全、法律或任何其他非常好的理由下才添加。把所有剩下的任务放在链外,放在专门的后端甚至是前端,这样你就可以节省交易 Gas。
- 字符串:字符串对以太坊来说也是bytes。即使两种数据类型都存在,EVM也会将字符串作为bytes来处理,这需要一些开销,这意味着如果你可以使用bytes而不是字符串时,就使用bytes。如果你仍然需要使用字符串,那么尽量将字符串操作(连接等......)放在智能合约之外处理。
- 返回存储值:如果你需要在执行某些功能后返回存储值。按原样返回,不进行转换,让检索数据的链外应用程序做这些工作(从数组中提取某些值等等)。
- 循环:避免在长数组中循环,这不仅会花费大量的Gas,而且如果Gas成本增加到很高的程度(超过BlockGas限制),会使合约无法执行。使用映射来代替长数组,映射是一个哈希表,可以让你在一次操作中使用其键来访问任何值,而不是在数组中循环,直到找到你要找的键。
- 本地存储变量:本地存储变量是方法的本地变量,它指向一个实际的状态变量(存储在区块链存储中)。与其在内存中复制/粘贴存储数组以便操作它们,然后将它们复制回存储,不如简单地使用本地存储变量,直接在存储上操作。
- 批处理:与其让用户用不同的值多次调用同一个函数(通过向区块链发送多个交易),不如让他们通过传递动态大小的数组,以便可以在一个单一的交易中批量执行相同的功能。这将能够节省一些交易基础开销成本。
内存位置(或存储位置)
以太坊有4个内存位置,从最便宜的到最贵的:calldata,栈,内存和存储。如果使用得当,你将节省大量的交易 Gas。
- Calldata :只适用于输入参数且参数是外部函数的引用数据类型(数组,字符串 ...)。Calldata参数是只读的,如果你有一些需要传递给函数的引用类型,总是考虑使用calldata,因为它是最便宜的。
- 栈:只对方法中定义的值类型数据有效。
- 内存:内存是易丢失的RAM,在EVM终止运行的时候会被移除。你可以用它来存储引用数据类型,它比存储更便宜。当向其他函数传递参数,或在你的函数中声明临时变量时,除非你严格需要使用存储,否则应该总是使用内存。
- 存储:是最昂贵的存储位置。存储数据在区块链上持久存在,正如这个列表的第一个元素所说,应该总是尽量减少链上数据。
变量顺序
Solidity 存储槽的长度为32字节,但并不是所有的数据类型都需要这么大的空间:bool, int8 ... int128, bytes1 ... bytes31和地址需要的空间小于32字节。
solidity编译器会尝试将变量打包在一个槽中,但需要这些变量定义在彼此的旁边。
例如,如果你接连定义了2个uint128,它们都会被打包到同一个存储槽中,因为它们各占16字节。然而,如果你定义了一个uint128,接着是一个unit256,然后是另一个int128,你将使用3个存储槽,因为在两个int128之间的unit256需要一个完整的存储槽。
你可以通过调整变量顺序节省存储空间和交易 Gas。
首选的数据类型
如果你要定义变量将占用一个完整存储槽,最好使用**实际占用完整存储槽的变量。
让我用一个例子来解释一下:
若我们的智能合约只需要一个状态变量,一个永远不会大于255的无符号整数。我们会很想使用uint8作为数据类型。问题是,以太坊操作码被设计为使用256位的变量(EVM堆栈的大小),而uint8只需要8位,然后EVM会在剩余的位上填上 "0",以便能够操作它。这个由EVM执行的填 "0" 操作将花费Gas,因此为了节省交易 Gas,最好使用uint256而不是uint8。
库
如果你打算在智能合约中重复使用代码,那么最好将所有的代码打包到一个库中,部署它,并通过import 方式使合约指向它。
库可以有两种类型:
- 嵌入式库:包含内部函数的库。这些库不会被单独部署,而是嵌入到合约中,这意味着你将把库代码与智能合约代码一起部署。因此你不会复用使用任何东西,也不能用嵌入式库节约Gas....。
- 独立部署库合约:包含public或外部函数的库。这些库被部署一次,然后所有导入这些库的智能合约将实际委托给它们调用。这意味着库的代码只被部署一次,然后被所有智能合约使用。如果你使用独立部署库,将会节省部署 Gas。
最小代理(ERC 1167)
如果你需要部署多个功能完全相同的合约,应该考虑使用 "最小代理"(在ERC 1167中定义)
最小的代理只是一个合约,它将把所有的调用委托给一个预先定义的实现合约。它有一个定义好的字节码,代表最小代理合约的编译代码,你只需要把你的实现合约地址插入其中,你就可以根据需要部署最小代理的多个副本。 参考ERC 1167 相关文章,了解如何使用最小代理)。
由于最小的代理字节码非常小,部署它的成本也低到不能再低,因此节省一堆部署 Gas。
使用最小代理的注意事项,你应该牢记:最小代理的实现合约地址不能改变,这意味着你将不能升级他们的代码。
构造函数
构造器方法只执行一次,在合约创建期间,但如果你设法简化它,将节省部署 Gas。
- 常量及不可变量(immutable):常量和不可变的状态变量在合约被部署后不能被改变。区别在于,常量必须在编译时定义,而不可变量可以在构造函数中定义。总是尽量使用常量,以便使构造函数更便宜。
合约大小
合约的部署成本取决于几个方面,其中之一是你要部署的合约的大小(以KB为单位,请记住,单个合约的限制是24KB)。
减少部署 Gas的一个简单方法,就是尽可能地使实现合约更小。
- 日志/信息:使revert和assert 提示信息尽可能的短。
- 修改器:修改器(modifier)代码是内联的,这意味着它会被添加在所修改的函数的开头或结尾。在使用修改器时减少合约大小的一个技巧是编写一个实现修改器逻辑的函数,然后让修改器调用该函数。这样实现修改器的代码就不会被复制,只有函数调用会被复制。这种技术只在同一修改器被多次使用时有效。
- 函数:在实现你的功能时,尽量少用操作码。这并不总是可能的,甚至在Gas方面也不那么有效,因为有些操作码比其他操作码更昂贵,你可能会节省部署Gas,但会增加交易Gas......
Solidity编译器优化器
在部署前编译你的代码时,不要忘记激活 solidity 编译器Gas优化器。这个功能告诉编译器优化将被生成并部署到区块链上的字节码,这样就可以减少部署和交易Gas。
总的来说,优化器试图简化复杂的表达式,从而减少代码大小和执行成本。它还对函数进行形式化或内联。函数内联是一个可能导致更大的代码的操作,但它经常被做,因为它带来更多简化(运算)的机会。
本翻译由 Duet Protocol 赞助支持。