最近,我不得不使用
以太坊区块链 。 我正在研究的想法要求直接在区块链上存储大量整数,以便智能合约可以方便地访问它们。 开发智能合约的大部分经验告诉我们:“不要在区块链上存储大量数据,这很昂贵!” 但是“很多”是多少,而实际使用中价格却过高了多少? 我不得不找出原因,因为我们无法将数据脱链,整个想法崩溃了。
我刚刚开始使用Solidity和EVM,所以本文并没有声称它是最终的真理,但是我找不到用俄语或英语
撰写的与此主题相关的其他材料(尽管很糟糕,我之前没有碰到
这篇文章 ),因此希望对某人有用。 好吧,或者作为不得已的办法,如果有经验的同志告诉我我在其中怎么做以及在哪里做错了,这可能对我有用。
首先,我决定快速弄清楚我们是否可以做到? 我们来看看标准的,广泛使用的合同类型
-ERC20令牌。 至少,这样的合同在区块链中存储了购买代币的人的地址与其余额的对应关系。 实际上,仅存储余额,每个余额占用32个字节(实际上,由于
Solidity和EVM的功能,在此处保存是没有意义的)。 一个或多或少成功的令牌可以轻松拥有成千上万的所有者,因此,我们可以完全接受在区块链中存储约320,000个字节。 而且我们不需要更多!
天真的方法
好吧,让我们尝试保存我们的数据。 其中很大一部分是8位无符号整数,因此我们将把它们的数组传输到协定中,并尝试将它们写入只读存储器:
uint8[] m_test; function test(uint8[] data) public { m_test = data; }
高飞! 此功能会消耗气体,好像本身没有气体一样。 尝试保存100个值将花费814033 gas,每字节8100 gas!
呼气,然后退回到理论上。 在以太坊区块链上存储数据的最低成本是多少? 必须记住,数据存储在32个字节的块中。 EVM一次只能读取或写入整个块,因此理想情况下,应尽可能高效地打包要写入的数据,以便单个写入命令可以更立即保存。 因为同一条记录命令-SSTORE-仅
花费20,000瓦斯 (如果我们写入之前未写入的存储单元)。 因此,我们的理论最低费用(不考虑所有其他费用)约为每字节625汽油。 与上面示例中的8100相比,相差甚远! 现在是时候进行更深入的挖掘,找出谁在吃我们的天然气,以及如何阻止它。
我们的第一个冲动应该是查看Solidity编译器从我们的单独行(m_test = data)生成的代码,因为没有更多可看的东西了。 这是一个很好的,正确的冲动,它将使我们熟悉一个可怕的事实-此处的编译器产生了一些您一眼无法理解的古老恐怖! 快速浏览一下清单,我们不仅看到SSTORE(预期),而且还看到SLOAD(从只读内存加载),甚至EXP(幂)! 总而言之,这看起来是一种非常昂贵的数据记录方式。 最糟糕的是,很明显也经常调用SSTORE。 这是怎么回事
几件事。 事实证明,存储8位整数几乎是使用EVM / Solidity可以做的最糟糕的事情(本文是我在开头引用的链接,对此进行了讨论)。 我们每时每刻都失去生产力(这意味着我们要支付更多的汽油)。 首先,当我们将8位值的数组传递给函数的输入时,它们每个都
扩展为256位。 也就是说,仅凭交易数据的大小,我们就已经损失了32倍! 好啊 但是,细心的读者会注意到,存储字节的开销仍然仅比理论最小值高13倍,而不是32倍,这意味着当合同永久保存到内存中时,一切都还不错。 事情是这样的:保存时,它仍会打包数据,并且我们的8位数字将以最有效的方式存储在合同的永久存储器中,每个存储块32个。 这就引出了一个问题,但是如何将函数输入中出现的“ 256位”未打包数字转换为打包形式呢? 答案是“我能想象的最愚蠢的方式”。
如果我们以简化形式记下所有发生的事情,那么我们孤独的代码行将变成一个怪异的循环:
for(uint i = 0; i < data.length; ++i) {
此代码的外观几乎不受打开或关闭优化的影响(至少在Solidity编译器版本0.4.24中),并且如您所见,它调用SSTORE(作为set_storage_data_at_offset的一部分)的次数比必要的次数多32次(每个8位数字一次,对于32个这样的数字一次)。 使我们免于彻底惨败的原因是,在同一单元中进行重新记录的成本不是20,000,而是5,000瓦斯。 因此,每32个字节要花费我们20,000 + 5,000 * 31 = 125,000个气体,或每个字节大约4,000个气体。 我们上面看到的其余值来自读取内存(也不是便宜的操作)以及函数上方的代码中隐藏的其他计算(其中有很多)。
好吧,我们不能使用编译器做任何事情,
因此我们将寻找一个button 。 仅需得出结论,就不必以这种方式在8位数字的合约数组中进行传输和存储。
8位数字的简单解决方案
那有什么必要呢? 依此类推:
bytes m_test; function test(bytes data) public { m_test = data; }
我们在字节类型的所有字段中进行操作。 使用这种方法,保存100个值将花费129914 gas-每字节仅1300 gas,比使用uint8 []好6倍! 这样做会带来一些不便-字节类型数组的元素为字节1类型,它不会自动转换为任何常用的整数类型,因此您必须将显式类型转换放在正确的位置。 不是很好,但是增益是录制成本的6倍,我认为这是值得的! 而且,是的,与将每个数字存储为256位相比,在处理此数据时,然后在读取时,我们会损失一点点,但是这里的规模开始变得很重要:以打包形式保存一千个或两个8位数字所获得的收益可以,根据任务,在以后阅读时,其损失要大于损失。
在采用这种方法之前,我首先尝试编写一个更有效的函数以将数据保存到本地宏汇编程序
JULIA中 ,但是遇到了一些问题,这些问题使我的解决方案的效率降低了一点,并且消耗了大约1530瓦斯每字节。 但是,本文仍然对我们有用,因此这项工作没有白费。
另外,我注意到一次保存的数据越多,每字节的成本就越少,这表明部分成本是固定的。 例如,如果保存3000个值,则在接近字节时,每个字节将获得900 gas。
更一般的解决方案
好吧,那一切都很好,一切都很好,对吧? 但是我们的问题并没有就此结束,因为有时我们不仅要向协定存储器中写入8位数字,还要写入与字节类型不直接对应的其他数据类型。 也就是说,很明显,任何东西都可以编码到字节缓冲区中,但是稍后再从那里获取它可能不再方便,甚至由于将原始内存转换为所需类型的不必要手势而变得昂贵。 因此,将传输的字节数组保存为所需类型的数组的函数对我们仍然有用。 这很简单,但是花了我很长时间才能找到所有必要的信息,并了解EVM和JULIA编写的信息,而所有这些信息并没有收集在一个地方。 因此,我认为将所挖掘的内容带到这里会很有用。
首先,让我们谈谈Solidity如何在内存中存储数组。 数组是仅存在于Solidity框架中的概念,EVM对其一无所知,而只是存储2 ^ 256个32字节块的虚拟数组。 很明显,没有存储空块,但是实际上,我们有一个非空块表,其关键是一个256位数字。 EVM SSTORE和SLOAD命令恰好接受此数字(从文档中并不完全可以看出)。
要存储数组,Solidity会做一件
棘手的事情 :首先,将“ main”块数组分配给它在常量内存中的某个位置,并按照合同成员(或结构,但这是一首单独的歌曲)的通常放置顺序,好像是常规256位数字。 这样可以确保该数组接收一个完整的块,而与其他存储的变量无关。 该块存储数组的长度。 但是由于它是未知的并且可以更改(我们在这里谈论动态数组),Solidity的作者必须弄清楚将数组数据放置在哪里,以便它们不会意外地与另一个数组的数据相交。 严格来说,这是一项艰巨的任务:如果创建两个长于2 ^ 128长的数组,那么可以保证它们在不放置它们的地方相交,但是实际上没有人应该这样做,因此使用了一个简单的技巧:从数组主块的编号中提取SHA3哈希,然后将所得的数字用作块表中的键(我记得是2 ^ 256)。 通过此键,放置数组数据的第一个块,然后放置剩余的数组(如果需要的话)。 非巨型阵列发生碰撞的可能性非常小。
因此,从理论上讲,我们要做的就是找到数组数据所在的位置,然后逐块复制传递给我们的字节缓冲区。 当我们使用小于块大小一半的类型时,我们至少会稍微赢得编译器生成的“幼稚”解决方案。
剩下的只有一个问题-如果一切都这样做,那么数组中的字节将向后翻转。 因为EVM是big-endian。 当然,最简单,最有效的方法是在发送时部署字节,但是为了简化API,我决定在合同代码中这样做。 如果要保存更多内容,请随意丢弃此部分功能,并在发送时进行所有操作。
这是我将字节数组转换为64位带符号整数数组的功能(但是,可以很容易地将其适应于其他类型):
function assign_int64_storage_from_bytes(int64[] storage to, bytes memory from) internal {
与64位数字相比,与编译器生成的代码相比,与8位数字相比,我们获得的收益并不多,但尽管如此,此功能消耗718466气体(每个数字7184气体,每个字节898气体),而天真的1003225解决方案(每个数字1003个气体,每个字节1254个气体),这使其使用变得非常有意义。 并且如上所述,您可以通过删除调用方的字节地址来节省更多空间。
值得注意的是,以太坊中的每单位天然气限额限制了我们在一次交易中可以记录多少数据的限制。 更糟的是,将数据追加到一个已经填充的数组上要困难得多,除非将数组的最后一个使用的块填充到极限(在这种情况下,您可以使用相同的函数,但缩进不同)。 目前,每个区块的气体限制约为600万,这意味着我们一次或多或少可以节省6Kb的数据,但实际上,由于其他支出,甚至更少。
即将发生的变化
10月以太坊网络即将发生的变化将随着
君士坦丁堡 EIP的激活而发生,这将使保存数据变得更容易和更便宜-EIP
1087建议不对每个SSTORE命令收取数据存储费,但针对更改的块数,这将使编译器使用幼稚的方法,几乎与手动编写的JULIA代码一样有利可图(但不完全是,特别是对于8位值,很多额外的主体运动会保留在那里)。 计划过渡到WebAssembly作为EVM的基础语言将进一步改变这种状况,但这仍然是一个遥不可及的前景,我们现在需要解决问题。
这篇文章并不声称是解决该问题的最佳方法,如果有人提供更有效的解决方案,我将感到非常高兴-我刚刚开始使用以太坊,并且可能会忽略一些可以帮助我的EVM功能。 但是在网上搜索中,我没有看到任何关于此问题的信息,也许上述想法和代码对于某人进行优化的起点很有用。