最終內容請以原文爲準: https://wangwei.one/posts/630...
在這一系列文章的最開始部分,咱們提到過區塊鏈是一個分佈式的數據庫。那時候,咱們決定跳過"分佈式"這一環節,而且聚焦於"數據存儲"這一環節。到目前爲止,咱們幾乎實現了區塊鏈的全部組成部分。在本篇文章中,咱們將會涉及一些在前面的文章中所忽略的一些機制,而且在下一篇文章中咱們將開始研究區塊鏈的分佈式特性。html
前面各個部份內容:java
在 持久化 & 命令行 這篇文章中,咱們研究了比特幣核心存儲區塊的方式。當中咱們提到過與區塊相關的數據存儲在 blocks 這個數據桶中,而交易數據則存儲在 chainstate 這個數據桶中,讓咱們來回憶一下,chainstate 數據桶的數據結構:node
'c' + 32-byte transaction hash -> unspent transaction output record for that transactiongit
某筆交易的UTXO記錄
'B' -> 32-byte block hash: the block hash up to which the database represents the unspent transaction outputsgithub
數據庫所表示的UTXO的區塊Hash
從那篇文章開始,咱們已經實現了比特幣的交易機制,可是咱們尚未用到 chainstate 數據桶去存儲咱們的交易輸出。因此,這將是咱們如今要去作的事情。算法
chainstate 不會去存儲交易數據。相反,它存儲的是 UTXO 集,也就是未被花費的交易輸出集合。除此以外,它還存儲了"數據庫所表示的UTXO的區塊Hash",咱們這裏先暫且忽略這一點,由於咱們尚未用到區塊高度(這一點咱們會在後面的文章進行實現)。shell
那麼,咱們爲何須要 UTXO 池呢?數據庫
一塊兒來看一下咱們前面實現的 findUnspentTransactions 方法:apache
/** * 查找錢包地址對應的全部未花費的交易 * * @param pubKeyHash 錢包公鑰Hash * @return */ private Transaction[] findUnspentTransactions(byte[] pubKeyHash) throws Exception { Map<String, int[]> allSpentTXOs = this.getAllSpentTXOs(pubKeyHash); Transaction[] unspentTxs = {}; // 再次遍歷全部區塊中的交易輸出 for (BlockchainIterator blockchainIterator = this.getBlockchainIterator(); blockchainIterator.hashNext(); ) { Block block = blockchainIterator.next(); for (Transaction transaction : block.getTransactions()) { String txId = Hex.encodeHexString(transaction.getTxId()); int[] spentOutIndexArray = allSpentTXOs.get(txId); for (int outIndex = 0; outIndex < transaction.getOutputs().length; outIndex++) { if (spentOutIndexArray != null && ArrayUtils.contains(spentOutIndexArray, outIndex)) { continue; } // 保存不存在 allSpentTXOs 中的交易 if (transaction.getOutputs()[outIndex].isLockedWithKey(pubKeyHash)) { unspentTxs = ArrayUtils.add(unspentTxs, transaction); } } } } return unspentTxs; }
該方法是用來查找錢包地址對應的包含未花費交易輸出的交易信息。因爲交易信息是存儲在區塊當中,因此咱們現有的作法是遍歷區塊鏈中的每一個區塊,而後遍歷每一個區塊中的交易信息,再而後遍歷每一個交易中的交易輸出,並檢查交易輸出是否被相應的錢包地址所鎖定,效率很是低下。截止2018年3月29號,比特幣中有 515698 個區塊,而且這些數據佔據了140+Gb 的磁盤空間。這也就意味着一我的必須運行全節點(下載全部的區塊數據)才能驗證交易信息。此外,驗證交易信息須要遍歷全部的區塊。緩存
針對這個問題的解決辦法是須要有一個存儲了全部UTXOs(未花費交易輸出)的索引,這就是 UTXOs 池所要作的事情:UTXOs池實際上是一個緩存空間,它所緩存的數據須要從構建區塊鏈中全部的交易數據中得到(經過遍歷全部的區塊鏈,不過這個構建操做只須要執行一次便可),而且它後續還會用於錢包餘額的計算以及新的交易數據的驗證。截止到2017年9月,UTXOs池大約爲 2.7Gb。
好了,讓咱們來想一下,爲了實現 UTXOs 池咱們須要作哪些事情。當前,有下列方法被用於查找交易信息:
Blockchain.findUnspentTransactions 方法。
如你所見,上面這些方法都須要去遍歷數據庫中的全部區塊。因爲UTXOs池只存儲未被花費的交易輸出,而不會存儲全部的交易信息,所以咱們不會對有 Blockchain.findTransaction 進行優化。
那麼,咱們須要下列這些方法:
這樣,兩個使用最頻繁的方法將從如今開始使用緩存!讓咱們開始編碼吧!
定義 UTXOSet:
@NoArgsConstructor @AllArgsConstructor @Slf4j public class UTXOSet { private Blockchain blockchain; }
重建 UTXO 池索引:
public class UTXOSet { ... /** * 重建 UTXO 池索引 */ @Synchronized public void reIndex() { log.info("Start to reIndex UTXO set !"); RocksDBUtils.getInstance().cleanChainStateBucket(); Map<String, TXOutput[]> allUTXOs = blockchain.findAllUTXOs(); for (Map.Entry<String, TXOutput[]> entry : allUTXOs.entrySet()) { RocksDBUtils.getInstance().putUTXOs(entry.getKey(), entry.getValue()); } log.info("ReIndex UTXO set finished ! "); } ... }
此方法用於初始化 UTXOSet。首先,須要清空 chainstate
數據桶,而後查詢全部未被花費的交易輸出,並將它們保存到 chainstate
數據桶中。
實現 findSpendableOutputs 方法,供 Transation.newUTXOTransaction 調用
public class UTXOSet { ... /** * 尋找可以花費的交易 * * @param pubKeyHash 錢包公鑰Hash * @param amount 花費金額 */ public SpendableOutputResult findSpendableOutputs(byte[] pubKeyHash, int amount) { Map<String, int[]> unspentOuts = Maps.newHashMap(); int accumulated = 0; Map<String, byte[]> chainstateBucket = RocksDBUtils.getInstance().getChainstateBucket(); for (Map.Entry<String, byte[]> entry : chainstateBucket.entrySet()) { String txId = entry.getKey(); TXOutput[] txOutputs = (TXOutput[]) SerializeUtils.deserialize(entry.getValue()); for (int outId = 0; outId < txOutputs.length; outId++) { TXOutput txOutput = txOutputs[outId]; if (txOutput.isLockedWithKey(pubKeyHash) && accumulated < amount) { accumulated += txOutput.getValue(); int[] outIds = unspentOuts.get(txId); if (outIds == null) { outIds = new int[]{outId}; } else { outIds = ArrayUtils.add(outIds, outId); } unspentOuts.put(txId, outIds); if (accumulated >= amount) { break; } } } } return new SpendableOutputResult(accumulated, unspentOuts); } ... }
實現 findUTXOs 接口,供 CLI.getBalance 調用:
public class UTXOSet { ... /** * 查找錢包地址對應的全部UTXO * * @param pubKeyHash 錢包公鑰Hash * @return */ public TXOutput[] findUTXOs(byte[] pubKeyHash) { TXOutput[] utxos = {}; Map<String, byte[]> chainstateBucket = RocksDBUtils.getInstance().getChainstateBucket(); if (chainstateBucket.isEmpty()) { return utxos; } for (byte[] value : chainstateBucket.values()) { TXOutput[] txOutputs = (TXOutput[]) SerializeUtils.deserialize(value); for (TXOutput txOutput : txOutputs) { if (txOutput.isLockedWithKey(pubKeyHash)) { utxos = ArrayUtils.add(utxos, txOutput); } } } return utxos; } ... }
以上這些方法都是先前 Blockchain 中相應方法的微調版,先前的方法將再也不使用。
有了UTXO池以後,意味着咱們的交易數據分開存儲到了兩個不一樣的數據桶中:交易數據存儲到了 block 數據桶中,而UTXO存儲到了 chainstate 數據桶中。這就須要一種同步機制來保證每當一個新的區塊產生時,UTXO池可以及時同步最新區塊中的交易數據,畢竟咱們不想頻地進行 reIndex 。所以,咱們須要以下方法:
更新UTXO池:
public class UTXOSet { ... /** * 更新UTXO池 * <p> * 當一個新的區塊產生時,須要去作兩件事情: * 1)從UTXO池中移除花費掉了的交易輸出; * 2)保存新的未花費交易輸出; * * @param tipBlock 最新的區塊 */ @Synchronized public void update(Block tipBlock) { if (tipBlock == null) { log.error("Fail to update UTXO set ! tipBlock is null !"); throw new RuntimeException("Fail to update UTXO set ! "); } for (Transaction transaction : tipBlock.getTransactions()) { // 根據交易輸入排查出剩餘未被使用的交易輸出 if (!transaction.isCoinbase()) { for (TXInput txInput : transaction.getInputs()) { // 餘下未被使用的交易輸出 TXOutput[] remainderUTXOs = {}; String txId = Hex.encodeHexString(txInput.getTxId()); TXOutput[] txOutputs = RocksDBUtils.getInstance().getUTXOs(txId); if (txOutputs == null) { continue; } for (int outIndex = 0; outIndex < txOutputs.length; outIndex++) { if (outIndex != txInput.getTxOutputIndex()) { remainderUTXOs = ArrayUtils.add(remainderUTXOs, txOutputs[outIndex]); } } // 沒有剩餘則刪除,不然更新 if (remainderUTXOs.length == 0) { RocksDBUtils.getInstance().deleteUTXOs(txId); } else { RocksDBUtils.getInstance().putUTXOs(txId, remainderUTXOs); } } } // 新的交易輸出保存到DB中 TXOutput[] txOutputs = transaction.getOutputs(); String txId = Hex.encodeHexString(transaction.getTxId()); RocksDBUtils.getInstance().putUTXOs(txId, txOutputs); } } ... }
讓咱們將 UTXOSet 用到它們所需之處去:
public class CLI { ... /** * 建立區塊鏈 * * @param address */ private void createBlockchain(String address) { Blockchain blockchain = Blockchain.createBlockchain(address); UTXOSet utxoSet = new UTXOSet(blockchain); utxoSet.reIndex(); log.info("Done ! "); } ... }
當建立一個新的區塊鏈是,咱們須要重建 UTXO 池索引。截止目前,這是惟一一處用到 reIndex 的地方,儘管看起有些多餘,由於在區塊鏈建立之初僅僅只有一個區塊和一筆交易。
修改 CLI.send 接口:
public class CLI { ... /** * 轉帳 * * @param from * @param to * @param amount */ private void send(String from, String to, int amount) throws Exception { ... Blockchain blockchain = Blockchain.createBlockchain(from); Transaction transaction = Transaction.newUTXOTransaction(from, to, amount, blockchain); Block newBlock = blockchain.mineBlock(new Transaction[]{transaction}); new UTXOSet(blockchain).update(newBlock); ... } ... }
當一個新的區塊產生後,須要去更新 UTXO 池數據。
讓咱們來檢查一下它們的運行狀況:
$ java -jar blockchain-java-jar-with-dependencies.jar createwallet wallet address : 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf $ java -jar blockchain-java-jar-with-dependencies.jar createwallet wallet address : 1HX7bWwCjvxkjq65GUgAVRFfTZy6yKWkoG $ java -jar blockchain-java-jar-with-dependencies.jar createwallet wallet address : 1L1RoFgyjCrNPCPHmSEBtNiV3h2wiF9mZV $ java -jar blockchain-java-jar-with-dependencies.jar createblockchain -address 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf Elapsed Time: 164.961 seconds correct hash Hex: 00225493862611bc517cb6b3610e99d26d98a6b52484c9fa745df6ceff93f445 Done ! $ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf Balance of '1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf': 10 $ java -jar blockchain-java-jar-with-dependencies.jar send -from 1HX7bWwCjvxkjq65GUgAVRFfTZy6yKWkoG -to 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf -amount 5 java.lang.Exception: ERROR: Not enough funds $ java -jar blockchain-java-jar-with-dependencies.jar send -from 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf -to 1HX7bWwCjvxkjq65GUgAVRFfTZy6yKWkoG -amount 2 Elapsed Time: 54.92 seconds correct hash Hex: 0001ab21f71ff2d6d532bf3b3388db790c2b03e28d7bd27bd669c5f6380a4e5b Success! $ java -jar blockchain-java-jar-with-dependencies.jar send -from 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf -to 1L1RoFgyjCrNPCPHmSEBtNiV3h2wiF9mZV -amount 2 Elapsed Time: 54.92 seconds correct hash Hex: 0009b925cc94e3db8bab2958b1fc2d1764aa15531e20756d92c3a93065c920f0 Success! $ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf Balance of '1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf': 6 $ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address 1HX7bWwCjvxkjq65GUgAVRFfTZy6yKWkoG Balance of '1HX7bWwCjvxkjq65GUgAVRFfTZy6yKWkoG': 2 $ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address 1L1RoFgyjCrNPCPHmSEBtNiV3h2wiF9mZV Balance of '1L1RoFgyjCrNPCPHmSEBtNiV3h2wiF9mZV': 2
前面的章節中咱們省略了礦工挖礦的獎勵機制。時機已經成熟,該實現它了。
礦工獎勵實際上是一個 coinbase 交易(創幣交易)。當一個礦工節點開始去生產一個新的區塊時,他會從隊列中取出一些交易數據,而且爲它們預製一個 coinbase 交易。這筆 coinbase 交易中僅有的交易輸出包含了礦工的公鑰hash。
只須要更新 send 命令接口,咱們就能夠輕鬆實現礦工的獎勵機制:
public class CLI { ... /** * 轉帳 * * @param from * @param to * @param amount */ private void send(String from, String to, int amount) throws Exception { ... Blockchain blockchain = Blockchain.createBlockchain(from); // 新交易 Transaction transaction = Transaction.newUTXOTransaction(from, to, amount, blockchain); // 獎勵 Transaction rewardTx = Transaction.newCoinbaseTX(from, ""); Block newBlock = blockchain.mineBlock(new Transaction[]{transaction, rewardTx}); new UTXOSet(blockchain).update(newBlock); ... } ... }
還須要修改交易驗證方法,coinbase 交易直接驗證經過:
public class Blockchain { /** * 交易簽名驗證 * * @param tx */ private boolean verifyTransactions(Transaction tx) { if (tx.isCoinbase()) { return true; } ... } ... }
在咱們的實現邏輯中,代幣的發送也是區塊的生產者,所以,獎勵也歸他全部。
讓咱們來驗證一下獎勵機制:
$ java -jar blockchain-java-jar-with-dependencies.jar createwallet wallet address : 1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD $ java -jar blockchain-java-jar-with-dependencies.jar createwallet wallet address : 17crpQoWy7TEkY9UPjZ3Qt9Fc2rWPUt8KX $ java -jar blockchain-java-jar-with-dependencies.jar createwallet wallet address : 12L868QZW1ySYzf2oT5ha9py9M5JrSRhvT $ java -jar blockchain-java-jar-with-dependencies.jar createblockchain -address 1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD Elapsed Time: 17.973 seconds correct hash Hex: 0000defe83a851a5db3803d5013bbc20c6234f176b2c52ae36fdb53d28b33d93 Done ! $ java -jar blockchain-java-jar-with-dependencies.jar send -from 1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD -to 17crpQoWy7TEkY9UPjZ3Qt9Fc2rWPUt8KX -amount 6 Elapsed Time: 30.887 seconds correct hash Hex: 00005fd36a2609b43fd940577f93b8622e88e854f5ccfd70e113f763b6df69f7 Success! $ java -jar blockchain-java-jar-with-dependencies.jar send -from 1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD -to 12L868QZW1ySYzf2oT5ha9py9M5JrSRhvT -amount 3 Elapsed Time: 45.267 seconds correct hash Hex: 00009fd7c59b830b60ec21ade7672921d2fb0962a1b06a42c245450e47582a13 Success! $ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address 1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD Balance of '1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD': 21 $ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address 17crpQoWy7TEkY9UPjZ3Qt9Fc2rWPUt8KX Balance of '17crpQoWy7TEkY9UPjZ3Qt9Fc2rWPUt8KX': 6 $ java -jar blockchain-java-jar-with-dependencies.jar getbalance -address 12L868QZW1ySYzf2oT5ha9py9M5JrSRhvT Balance of '12L868QZW1ySYzf2oT5ha9py9M5JrSRhvT': 3
1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD 這個地址一共收到了三份獎勵:
Merkle Tree(默克爾樹) 是這篇文章中咱們須要重點討論的一個機制。
正如我前面提到的那樣,整個比特幣的數據庫佔到了大約140G的磁盤空間。因爲比特幣的分佈式特性,網絡中的每個節點必須是獨立且自給自足的。每一個比特幣節點都是路由、區塊鏈數據庫、挖礦、錢包服務的功能集合。每一個節點都參與全網絡的路由功能,同時也可能包含其餘功能。每一個節點都參與驗證並傳播交易及區塊信息,發現並維持與對等節點的鏈接。一個全節點(full node)包括如下四個功能:
隨着愈來愈多的人開始使用比特幣,這條規則開始變得愈來愈難以遵循:讓每個人都去運行一個完整的節點不太現實。在中本聰發佈的 比特幣白皮書 中,針對這個問題提出了一個解決方案:Simplified Payment Verification (SPV)(簡易支付驗證)。SPV是比特幣的輕量級節點,它不須要下載全部的區塊鏈數據,也不須要驗證區塊和交易數據。相反,當SPV想要驗證一筆交易的有效性時,它會從它所鏈接的全節點上檢索所須要的一些數據。這種機制保證了在只有一個全節點的狀況,能夠運行多個SPV輕錢包節點。
更多有關SPV的介紹,請查看: 《精通比特幣(第二版)》第八章
爲了使SPV成爲可能,就須要有一種方法在沒有全量下載區塊數據的狀況下,來檢查一個區塊是否包含了某筆交易。這就是 Merkle Tree 發揮做用的地方了。
比特幣中所使用的Merkle Tree是爲了得到交易的Hash值,隨後這個已經被Pow(工做量證實)系統承認了的Hash值會被保存到區塊頭中。到目前爲止,咱們只是簡單地計算了一個區塊中每筆交易的Hash值,而後在準備Pow數據時,再對這些交易進行 SHA-256 計算。雖然這是一個用於獲取區塊交易惟一表示的一個不錯的途徑,可是它不具備到 Merkle Tree的優勢。
來看一下Merkle Tree的結構:
每個區塊都會構建一個Merkle Tree,它從最底部的葉子節點開始往上構建,每個交易的Hash就是一個葉子節點(比特幣中用的雙SHA256算法)。葉子節點的數量必須是偶數個,可是並非每個區塊都能包含偶數筆交易數據。若是存在奇數筆交易數據,那麼最後一筆交易數據將會被複制一份(這僅僅發生在Merkle Tree中,而不是區塊中)。
從下往上移動,葉子節點成對分組,它們的Hash值被鏈接到一塊兒,而且在此基礎上再次計算出新的Hash值。新的Hash 造成新的樹節點。這個過程不斷地被重複,直到最後僅剩一個被稱爲根節點的樹節點。這個根節點的Hash就是區塊中交易數據們的惟一表明,它會被保存到區塊頭中,並被用於參與POW系統的計算。
Merkle樹的好處是節點能夠在不下載整個塊的狀況下驗證某筆交易的合法性。 爲此,只須要交易Hash,Merkle樹根Hash和Merkle路徑。
Merkle Tree代碼實現以下:
package one.wangwei.blockchain.transaction; import com.google.common.collect.Lists; import lombok.Data; import one.wangwei.blockchain.util.ByteUtils; import org.apache.commons.codec.digest.DigestUtils; import java.util.List; /** * 默克爾樹 * * @author wangwei * @date 2018/04/15 */ @Data public class MerkleTree { /** * 根節點 */ private Node root; /** * 葉子節點Hash */ private byte[][] leafHashes; public MerkleTree(byte[][] leafHashes) { constructTree(leafHashes); } /** * 從底部葉子節點開始往上構建整個Merkle Tree * * @param leafHashes */ private void constructTree(byte[][] leafHashes) { if (leafHashes == null || leafHashes.length < 1) { throw new RuntimeException("ERROR:Fail to construct merkle tree ! leafHashes data invalid ! "); } this.leafHashes = leafHashes; List<Node> parents = bottomLevel(leafHashes); while (parents.size() > 1) { parents = internalLevel(parents); } root = parents.get(0); } /** * 構建一個層級節點 * * @param children * @return */ private List<Node> internalLevel(List<Node> children) { List<Node> parents = Lists.newArrayListWithCapacity(children.size() / 2); for (int i = 0; i < children.size() - 1; i += 2) { Node child1 = children.get(i); Node child2 = children.get(i + 1); Node parent = constructInternalNode(child1, child2); parents.add(parent); } // 內部節點奇數個,只對left節點進行計算 if (children.size() % 2 != 0) { Node child = children.get(children.size() - 1); Node parent = constructInternalNode(child, null); parents.add(parent); } return parents; } /** * 底部節點構建 * * @param hashes * @return */ private List<Node> bottomLevel(byte[][] hashes) { List<Node> parents = Lists.newArrayListWithCapacity(hashes.length / 2); for (int i = 0; i < hashes.length - 1; i += 2) { Node leaf1 = constructLeafNode(hashes[i]); Node leaf2 = constructLeafNode(hashes[i + 1]); Node parent = constructInternalNode(leaf1, leaf2); parents.add(parent); } if (hashes.length % 2 != 0) { Node leaf = constructLeafNode(hashes[hashes.length - 1]); // 奇數個節點的狀況,複製最後一個節點 Node parent = constructInternalNode(leaf, leaf); parents.add(parent); } return parents; } /** * 構建葉子節點 * * @param hash * @return */ private static Node constructLeafNode(byte[] hash) { Node leaf = new Node(); leaf.hash = hash; return leaf; } /** * 構建內部節點 * * @param leftChild * @param rightChild * @return */ private Node constructInternalNode(Node leftChild, Node rightChild) { Node parent = new Node(); if (rightChild == null) { parent.hash = leftChild.hash; } else { parent.hash = internalHash(leftChild.hash, rightChild.hash); } parent.left = leftChild; parent.right = rightChild; return parent; } /** * 計算內部節點Hash * * @param leftChildHash * @param rightChildHash * @return */ private byte[] internalHash(byte[] leftChildHash, byte[] rightChildHash) { byte[] mergedBytes = ByteUtils.merge(leftChildHash, rightChildHash); return DigestUtils.sha256(mergedBytes); } /** * Merkle Tree節點 */ @Data public static class Node { private byte[] hash; private Node left; private Node right; } }
而後修改 Block.hashTransaction 接口:
public class Block { ... /** * 對區塊中的交易信息進行Hash計算 * * @return */ public byte[] hashTransaction() { byte[][] txIdArrays = new byte[this.getTransactions().length][]; for (int i = 0; i < this.getTransactions().length; i++) { txIdArrays[i] = this.getTransactions()[i].hash(); } return new MerkleTree(txIdArrays).getRoot().getHash(); } ... }
MerkleTree的根節點的Hash值,就是區塊中交易信息的惟一表明。
這一節咱們主要是對前面的交易機制作了進一步的優化,加入UTXO池和Merkle Tree機制。