如何用Go打造区块链(6)—交易记录(二)
介绍 Introduction
在本系列文章的前面部分我说过区块链是一个分布式数据库。但那时候,我们决定暂时跳过“分布式”的部分我先专注于“数据库”部分。到目前为止,我们已经基本实现区块链作为一个数据库的所有部分。我们会覆盖一些前面部分跳过的一些机制,然后在下一部分我们会在区块链的分布式特性方面进行工作。
前面的部分:
- 如何用Go打造区块链(1)—基础原型
- 如何用Go打造区块链(2)—工作证明机制(PoW)
- 如何用Go打造区块链(3)—数据存储及命令行(CLI)
- 如何用Go打造区块链(4)—交易记录(一)
- 如何用Go打造区块链(5)—地址
这部分主要介绍关键代码变动,因此这里解释所有的代码不是特别有意义。可以参考这个页面看与上一部分文章之间的代码变动。
奖励 Reward
在前面的文章当中跳过的一个小事情是挖矿的奖励。为了实现它的所有元素我们已经准备好了。
奖励(reward)只是一个币基交易记录(coinbase transaction)。当以挖矿节点开始挖一个新的区块,从队列中取得交易记录并给区块预留一个币基交易记录。币基交易记录的唯一输出包含矿工(miner)的公钥哈希值。
实现奖励只要通过更新一下 send 命令就可以:
1 | func (cli *CLI) send(from, to string, amount int) { |
在我们的实现当中,创建交易记录的人去挖新的区块,然后得到一个奖励。
未花费交易记录输出集 The UTXO Set
在如何用Go打造区块链(3)—数据存储及命令行(CLI)当中,我们研究了比特币内核将区块存储在数据库中的方式。区块存储在 blocks (区块)数据库当中,交易记录输出存储在 chainstate (链状态)数据库当中。让我再回顾一下 chainstate 的结构:
- ‘c’ + 32-byte transaction hash -> unspent transaction output record for that transaction
- ‘B’ -> 32-byte block hash: the block hash up to which the database represents the unspent transaction outputs
在那篇文章当中,我们已经实现了交易记录,但是我们并没有 chainstate 来存储输出。这是我们即将打算要去做的。
chainstate 不存储交易记录。反而,它存储叫做UTXO set的东西,或者叫未花费交易记录输出的集合。除此之外,它还存储“到代表未消费输出的数据库的区块哈希值”,因为我们不使用区块高度,我们暂时忽略它(但是我们将在下一篇文章中实现)。
那么,为什么我们要有 UTXO set呢?
看一下我们早先实现的 Blockchain.FindUnspentTransactions 方法:
1 | func (bc *Blockchain) FindUnspentTransactions(pubKeyHash []byte) []Transaction { |
这个函数寻找有未消费输出的交易记录。因为交易记录存储在区块当中,它遍历每一个区块然后检查其中的每一个交易记录。截止2017年9月18日,在比特币当中一共有485, 860个区块,所有的数据库大约占用140+Gb的容量。这意味着你必须运行一个完整的节点来验证交易记录。同时,验证交易记录需要遍历很多区块。
这个问题的解决方案是引入一个只存储未消费输出的索引值,这就是UTXO set所做的事情:这是一个从所有区块链交易记录(通过遍历区块,是的,但是只做一次)构建的快速缓存,随后会被用于计算余额和验证新的交易记录。截止2017年9月份,UTXO set差不多2.7Gb。
好了,让我们想想我们需要做什么来实现UTXO set。目前,下面的方法用于寻找交易记录:
Blockchain.FindUnspentTransactions– 寻找含未消费输出的交易记录的主函数。在这个函数当中我们遍历所有的区块。Blockchain.FindSpendableOutputs– 当一个新的交易记录被创建时,这个函数会被使用。如果要找到足够数量的持有满足需求的足额输出的话。会用到Blockchain.FindUnspentTransactions.Blockchain.FindUTXO– 为一个特定的公钥哈希寻找未花费输出,用于获取余额。会用到Blockchain.FindUnspentTransactions.Blockchain.FindTransaction– 通过ID 在区块链中找到一个交易记录。它会遍历所有的区块,直到找到满足要求的交易记录。
正如你所看到的,所有的方法都需要遍历数据库中的区块。但是目前我们还不能对全部进行优化,但是UTXO set 并不存储所有的交易记录,而只是那些有未消费输出的。这样,在 Blockchain.FindTransaction 中无法使用。
这样,我们想要下列方法:
Blockchain.FindUTXO– 通过遍历区块寻找所有的未消费输出UTXOSet.Reindex— 用FindUTXO来寻找未消费输出,然后将他们存储在一个数据库当中,这是缓存发生的地方UTXOSet.FindSpendableOutputs– 与Blockchain.FindSpendableOutputs类似, 不过使用UTXO setUTXOSet.FindUTXO– 与Blockchain.FindUTXO类似, 不过使用UTXO setBlockchain.FindTransaction保持不变
这样,两个最频繁使用的函数将会从现在开始使用缓存!让我们开始编码吧!
1 | type UTXOSet struct { |
我们将使用同一个数据库,不过将UTXO set 存储在另外一个不同的 bucket 当中。UTXOSet 将与 Blockchain 相结合。
1 | func (u UTXOSet) Reindex() { |
这个方法将创建并初始化 UTXO set。首先,移除已经存在的 bucket,然后从区块链当中获得所有的未消费输出,最后将输出保存到 bucket 当中。
Blockchain.FindUTXO 和 Blockchain.FindUnspentTransactions 几乎完全一样, 但是现在它返回一个含 TransactionID → TransactionOutputs 对的图(map)。
现在,UTXO set 可以用来发送比特币了:
1 | func (u UTXOSet) FindSpendableOutputs(pubkeyHash []byte, amount int) (int, map[string][]int) { |
或者检查余额:
1 | func (u UTXOSet) FindUTXO(pubKeyHash []byte) []TXOutput { |
这些都是与Blockchain 相关的略微修改过的版本。原先的那些 Blockchain 方法已经不需要了。
有了UTXO set 以后意味着我们的数据(交易记录)现在分块存储:实际的交易记录存储在区块链中,未消费输出存储在 UTXO set中。这样的分离需要有可靠的同步机制,因为我们要UTXO set 能够一直更新并存储最新交易记录的输出。但是我们也不愿意每次一个新的区块被挖出来就重新建立一次索引,因为我们要避免频繁的区块链扫描。所以,我们还需要一个更新 UTXO set 的机制。
1 | func (u UTXOSet) Update(block *Block) { |
这个方法看起来非常复杂,但是它所干的事情却非常简单。当挖出一个新的区块,UTXO set 应该相应地更新。更新意味着将已经花出去的移除,然后将新挖区块的未消费的加进去。加入一个交易记录的输出被移除的,不含任何输出,它本身也将会被移除。非常简单!
现在让我们把 UTXO set 用在需要的地方:
1 | func (cli *CLI) createBlockchain(address string) { |
当一个新的区块链产生的时候对UTXO set重新索引(Reindex)。到目前为止,这是唯一要重新索引的地方,即便在这里看起来有些过分,因为在一个区块链刚刚被创建的时候,只有一个区块,一个交易记录,Update 方法可以来代替 Reindex()。但是在不久的将来,我们需要重新索引机制。
1 | func (cli *CLI) send(from, to string, amount int) { |
在一个新的区块被挖出来以后更新了 UTXO set。
让我们来检查它的工作情况。
1 | $ blockchain_go createblockchain -address 1JnMDSqVoHi4TEFXNw5wJ8skPsPf4LHkQ1 |
非常好!1JnMDSqVoHi4TEFXNw5wJ8skPsPf4LHkQ1 地址3次收到奖励:
- 第一次来自对创始区块(genesis blocks)的挖矿
- 第二次来自对以下区块的挖矿:
0000001f75cb3a5033aeecbf6a8d378e15b25d026fb0a665c7721a5bb0faa21b. - 第三次来自对以下区块的挖矿:
000000cc51e665d53c78af5e65774a72fc7b864140a8224bf4e7709d8e0fa433.
Merkle Tree
在这篇文章当中还有一个优化选项我还想要讨论以下的。
正如前面所言,完整的比特币数据库(区块链)大约占140Gb 的磁盘空间。因为比特币的分布式存储的特性,网络中的每一个节点都要独立且要自我实现,比如每个节点都要存储一份整个区块链的完整拷贝。随着很多人开始使用比特币,这条规则变得越来越难以坚持:每个人都运行一个完整的节点不太可能。并且,因为节点是网络的成熟的参与者,它们有责任:它们必须验证交易记录和区块。更进一步,它们与其它节点交流并下载新的节点也需要一定的带宽需求。
在中本聪(Satoshi Nakamoto)发布的白皮书当中,有一个对应的解决方案:简单支付验证(Simplified Payment Verification:SPV)。SPV 是一个轻量的比特币节点,不下载整个区块链,也不对所有的区块和交易记录进行验证。相应的,它在区块中寻找交易记录(验证支付)然后链接到一个完整节点仅取得需要的数据。这样的机制允许有很多的轻量钱包节点而只运行一个完整的节点。
为了SPV 的可行性,需要一种不需要下载整个区块就能够确认一个区块是否含有交易记录的机制。这也正是 Merkle tree 所要扮演的角色。
比特币用 Merkle trees 来获得交易记录哈希值,这个哈希值保存在区块头部数据当中并在PoW 系统当中会被考虑。直到现在,我们只是将每个区块中的每个交易记录的哈希值组合在一起然后再使用 SHA-256,只是取得区块交易记录唯一证明的好方式,但是却没有 Merkle trees 的一些好处。
让我们看一个 Merkle tree:

每一个区块构建一个 Merkle tree,它从叶子(tree的底部)开始,每一个叶子就是一个交易记录的哈希(比特币采用 双 SHA-256 计算哈希值)。叶子的数量必须是偶数,但是并不是每一个区块都包含偶数个的交易记录。在奇数个交易记录的情况下,最后一个交易记录会被复制(只是在 Merkle tree中复制,并不是在区块当中!)。
从下往上走,叶子一对一对地分组,它们的哈希值被组合到一起,并产生一个新的哈希值来代替组合后的哈希。新的哈希组成新的树的节点。这个过程不断重复直到剩下仅有的一个节点,称之为树的根。树根的哈希就被用来作为所有交易记录的唯一表征,保存在区块头部数据当中,并在PoW 系统当中使用。
Merkle trees 的好处是一个节点可以在不下载整个区块的情况下验证特定交易记录的身份。一个交易记录哈希,一个 Merkle 树根哈希,然后一个Merkle 的路径就够了。
最后,让我们来写代码:
1 | type MerkleTree struct { |
我们从一个结构体开始。每一个 MerkleNode 保存着数据还到其分支的链接。
MerkleTree 实际上是链接到下一个节点的根节点,它们就这样依次与更多的节点相连。
让我们先创建一个新的节点:
1 | func NewMerkleNode(left, right *MerkleNode, data []byte) *MerkleNode { |
每一个节点包含一些数据。当一个节点是一片叶子,数据将从外面传入(在我们的案例中是一个序列化的交易记录)。当一个节点链接到其它节点时,它将获取它们的数据然后组合组合并对数据进行哈希计算。
1 | func NewMerkleTree(data [][]byte) *MerkleTree { |
当一个新的树被创建出来以后,首先要确保的是它有偶数片树叶。在这之后,数据(序列化交易记录的数组)将被转化为树叶,然后树从这些树叶开始生长。
现在,让我们修改 Block.HashTransactions,它被用在PoW 系统当中获取交易记录哈希值:
1 | func (b *Block) HashTransactions() []byte { |
首先,交易记录被序列化(采用 encoding/gob 包),然后让他们来构建一个 Merkle tree。树根会用来作为区块的交易记录的唯一识别特征。
P2PKH
还有一个事情我想要再详细讨论一下。
你还记得,在比特币当中有一个 脚本(Script)编程语言,用于交易记录输出的锁定;然后交易记录输入提供数据来对它进行解锁。这个语言本身非常简单,在这个语言当中编程知识一个数据和操作符的序列。看下面这个例子:
1 | 5 2 OP_ADD 7 OP_EQUAL |
5,2,和7是数据。OP_ADD 和OP_EQUAL 是操作符。 Script 代码从左往右执行:每一段数据放入栈中(stack),后面的操作符作用于栈顶的数据。Script 的✅知识一个简单的FILO (先进后出)内存存储器:栈中的第一个数据最后被取用,后面来的的元素放到前面数据之前。
让我们分步执行前面的脚本:
1 | Stack: empty. Script: 5 2 OP_ADD 7 OP_EQUAL. |
OP_ADD 从堆中取得两个元素,将他们相加,然后将结果放入堆中。OP_EQUAL 从堆中取得两个数据然后进行比较:假如它们相等就将true放入堆中;否则放入 false. 一段脚本的执行结果是堆顶元素的值:在我们的案例中,它是真值(true),这意味着脚本成功的执行完成了。
现在让我们看看在比特币中执行支付的脚本:
1 | <signature> <pubKey> OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG |
这段脚本叫做 Pay to Public Key Hash (P2PKH),在比特币中这是最常用的一段脚本。它按照指令向一个公钥哈希进行支付,并用一个特定的公钥锁定比特币。这是比特币支付的核心:没有账户,没有账户之间的资金转移;只有一段检查输入的签名和公钥是匹配的脚本。
脚本分两部分进行存储:
- 第一部分,
<signature> <pubKey>, 存储在输入的ScriptSig字段 - 第二部分,
OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG存储在输出的ScriptPubKey字段
这样,它的输出定义了解锁逻辑,输入提供用于解锁输出的数据,让我们的执行这段脚本:
1 | Stack: empty |
OP_DUP 复制栈顶的元素。OP_HASH160 获得栈顶的元素用RIPEMD160 算法对它进行哈希运算;结算结果重新放回栈中。
OP_EQUALVERIFY 比较连个堆顶的元素,如果两者不相等,结束脚本。OP_CHECKSIG 通过对交易记录进行哈希计算以及<signature> 和 <pubKey>数据验证一个交易记录的签名。后面的操作符非常复杂:首先不完整复制一份交易记录,求取哈希值(因为它是一个签名过的交易记录的哈希),然后用输入的<signature> 和 <pubKey> 验证签名是正确的。
有这样的脚本语言给比特币成为一个智能合约平台也创造了条件:脚本语言让其它的支付方案变得可能,不再是单一的比特币。
结论 Conclusion
然后就这样!我们已经基本实现了基于区块链的数字货币的所有特性。我们有区块链、地址、挖矿、还有交易记录。但是还有一个给这些机制以生命并让比特币成为一个全球系统:共识机制(consensus)。在下一篇文章当中,我们将开始开始实现区块链的“分布式”(decengtralized)部分。
敬请继续关注!