塊鏈:從哈希指針到工作量證明的完整實現(xiàn))
1. 從零構(gòu)建一條鏈為什么用Python手搓區(qū)塊鏈?zhǔn)抢斫夥植际劫~本的最短路徑很多人第一次接觸區(qū)塊鏈這個概念腦子里冒出來的都是比特幣、以太坊、智能合約、挖礦這些詞感覺離自己特別遠(yuǎn)。但如果你把那些金融屬性、共識博弈、代幣經(jīng)濟全部剝掉只看最核心的數(shù)據(jù)結(jié)構(gòu)會發(fā)現(xiàn)它其實簡單得有點出人意料——本質(zhì)上就是一個帶哈希指針的鏈表再加上一套校驗規(guī)則和共識機制。我當(dāng)初決定用Python手搓一個區(qū)塊鏈動機很樸素看再多白皮書不如自己寫一遍。Python在這件事上有天然優(yōu)勢——語法干凈、不需要處理內(nèi)存管理、哈希庫和JSON序列化都是標(biāo)準(zhǔn)庫自帶你可以把全部注意力放在結(jié)構(gòu)設(shè)計和邏輯驗證上而不是被語言本身的復(fù)雜度分散精力。這篇文章適合兩類人一類是剛學(xué)完P(guān)ython基礎(chǔ)語法想找一個有挑戰(zhàn)性但又不至于勸退的練手項目另一類是有后端開發(fā)經(jīng)驗想搞清楚區(qū)塊鏈到底是怎么串起來的工程師。我會從最基礎(chǔ)的數(shù)據(jù)結(jié)構(gòu)開始一步步加上哈希計算、工作量證明、交易打包、鏈校驗最后給出一個可以直接跑起來的完整實現(xiàn)。整個過程不依賴任何第三方區(qū)塊鏈框架只用Python標(biāo)準(zhǔn)庫。需要提前說明的是這里實現(xiàn)的是教學(xué)性質(zhì)的簡化區(qū)塊鏈它不具備生產(chǎn)級區(qū)塊鏈的完整特性——沒有P2P網(wǎng)絡(luò)層、沒有真正的分布式共識、沒有賬戶簽名體系。但它的核心數(shù)據(jù)結(jié)構(gòu)和校驗邏輯和真實區(qū)塊鏈的骨架是一致的。理解了這套骨架你再去看比特幣或以太坊的源碼就不會覺得無從下手。2. 區(qū)塊與鏈的骨架設(shè)計哈希指針到底是怎么串起來的2.1 區(qū)塊應(yīng)該包含哪些字段一個區(qū)塊本質(zhì)上就是一條記錄它需要回答幾個問題我是誰區(qū)塊高度、我什么時候產(chǎn)生的時間戳、我里面裝了什么交易數(shù)據(jù)、我前面是誰前一個區(qū)塊的哈希、我怎么證明自己是被認(rèn)真計算出來的工作量證明?;谶@幾個問題我設(shè)計的區(qū)塊字段如下字段名類型作用indexint區(qū)塊高度從0開始遞增timestampfloat區(qū)塊創(chuàng)建時的Unix時間戳transactionslist打包的交易列表previous_hashstr前一個區(qū)塊的哈希值nonceint工作量證明的計數(shù)器hashstr當(dāng)前區(qū)塊自身的哈希值這里有一個設(shè)計決策值得展開說為什么用previous_hash而不是直接存前一個區(qū)塊對象的引用因為區(qū)塊鏈的核心特性是不可篡改而不可篡改的實現(xiàn)方式就是每個區(qū)塊的哈希都依賴于前一個區(qū)塊的哈希。如果你改了任何一個歷史區(qū)塊的數(shù)據(jù)它的哈希就會變后面所有區(qū)塊的previous_hash就對不上了整條鏈的校驗就會失敗。用哈希值而不是對象引用是為了讓這個依賴關(guān)系變成密碼學(xué)層面的而不是內(nèi)存層面的。2.2 哈希函數(shù)的選型與計算范圍Python標(biāo)準(zhǔn)庫的hashlib提供了SHA-256這是比特幣也在用的哈希算法輸出256位64個十六進制字符。選它的理由很直接雪崩效應(yīng)足夠強輸入改一個字符輸出面目全非、計算速度快、碰撞概率極低。計算區(qū)塊哈希時需要把區(qū)塊的所有關(guān)鍵字段拼成一個字符串再哈希。這里有個容易踩的坑字段拼接的順序和格式必須固定否則同一個區(qū)塊在不同時間計算出來的哈希會不一樣。我的做法是用JSON序列化并且指定sort_keysTrue和separators保證輸出格式穩(wěn)定import hashlib import json def compute_hash(index, timestamp, transactions, previous_hash, nonce): block_string json.dumps({ index: index, timestamp: timestamp, transactions: transactions, previous_hash: previous_hash, nonce: nonce }, sort_keysTrue, separators(,, :)).encode() return hashlib.sha256(block_string).hexdigest()注意這里沒有把hash字段本身納入計算——這是必須的否則就成了用哈希算哈希的循環(huán)依賴。hash字段是計算結(jié)果不是輸入。2.3 創(chuàng)世區(qū)塊的特殊處理鏈上的第一個區(qū)塊叫創(chuàng)世區(qū)塊它沒有前一個區(qū)塊。通常的做法是把previous_hash設(shè)成0或者空字符串index設(shè)為0。創(chuàng)世區(qū)塊一般由系統(tǒng)在初始化時自動生成不包含實際交易或者包含一條創(chuàng)世備注。def create_genesis_block(): return Block( index0, timestamptime.time(), transactions[Genesis Block], previous_hash0 )創(chuàng)世區(qū)塊的nonce也需要通過工作量證明算出來不能隨便填。這一點很多人第一次寫的時候會忽略導(dǎo)致鏈的校驗邏輯不一致。3. 工作量證明為什么必須讓計算變得困難3.1 從直接算哈希到必須滿足條件如果只是把區(qū)塊數(shù)據(jù)哈希一下就算完那任何人改完數(shù)據(jù)重新算一遍哈希就行了篡改成本為零。工作量證明的核心思想是給哈希計算加一個門檻讓算出合法哈希需要消耗計算資源。具體做法是引入一個難度值difficulty要求區(qū)塊哈希的前difficulty個字符必須是0。比如difficulty4哈希就必須長這樣0000a3f2...。因為SHA-256的輸出是均勻分布的你沒法預(yù)測改哪個nonce能得到前四位為0的結(jié)果只能一個一個試。def mine_block(block, difficulty4): target 0 * difficulty while not block.hash.startswith(target): block.nonce 1 block.hash compute_hash( block.index, block.timestamp, block.transactions, block.previous_hash, block.nonce ) return block3.2 難度值的實際影響與調(diào)整策略難度值每增加1期望的計算次數(shù)大約翻16倍因為十六進制每一位有16種可能。我實測過在普通筆記本上難度值平均耗時平均嘗試次數(shù)3 0.01秒約 4,000 次4約 0.05秒約 65,000 次5約 0.8秒約 1,000,000 次6約 13秒約 16,000,000 次教學(xué)演示用difficulty4比較合適既有挖礦的感覺又不會讓你等太久。如果你想體驗一下真實挖礦的難度可以試試difficulty6但建議只在單獨測試時用。注意難度值不是越高越好。在單機環(huán)境下難度太高會導(dǎo)致每次添加區(qū)塊都要等很久調(diào)試體驗極差。建議先用difficulty2跑通邏輯確認(rèn)無誤后再調(diào)到4。3.3 nonce的起始值與溢出問題nonce從0開始遞增Python的整數(shù)沒有溢出問題所以理論上可以無限試下去。但實際測試中difficulty4的情況下nonce通常在幾萬到幾十萬之間就能找到解。如果你發(fā)現(xiàn)nonce已經(jīng)漲到幾百萬還沒出結(jié)果大概率是哈希計算的輸入有問題——檢查一下字段拼接格式是否穩(wěn)定。4. 交易打包與鏈的校驗邏輯怎么判斷一條鏈有沒有被篡改4.1 交易的簡化表示教學(xué)場景下交易不需要搞得太復(fù)雜。我用一個字典表示一筆交易transaction { sender: Alice, receiver: Bob, amount: 10 }真實區(qū)塊鏈里交易還需要數(shù)字簽名、手續(xù)費、nonce防重放等機制但那些屬于賬戶體系的范疇和鏈結(jié)構(gòu)本身關(guān)系不大。這里先用最簡形式把注意力集中在鏈的串聯(lián)和校驗上。4.2 添加新區(qū)塊的完整流程添加一個新區(qū)塊需要做四件事獲取上一個區(qū)塊、構(gòu)造新塊、挖礦、追加到鏈上。def add_block(blockchain, transactions, difficulty4): previous_block blockchain[-1] new_block Block( indexprevious_block.index 1, timestamptime.time(), transactionstransactions, previous_hashprevious_block.hash ) mine_block(new_block, difficulty) blockchain.append(new_block) return new_block這里的關(guān)鍵點是新區(qū)塊的previous_hash必須取自上一個區(qū)塊的hash字段而不是重新計算上一個區(qū)塊的哈希。因為上一個區(qū)塊的hash是挖礦時確定下來的重新計算可能會因為時間戳精度等問題得到不同的結(jié)果。4.3 鏈校驗的完整邏輯校驗一條鏈?zhǔn)欠窈戏ㄐ枰饓K檢查三件事當(dāng)前區(qū)塊存儲的hash是否等于重新計算出來的hash防止區(qū)塊內(nèi)部數(shù)據(jù)被改當(dāng)前區(qū)塊的previous_hash是否等于前一個區(qū)塊的hash防止區(qū)塊之間的鏈接被斷當(dāng)前區(qū)塊的hash是否滿足難度要求防止有人繞過挖礦直接偽造def validate_chain(blockchain, difficulty4): target 0 * difficulty for i in range(1, len(blockchain)): current blockchain[i] previous blockchain[i - 1] # 檢查當(dāng)前區(qū)塊的哈希是否被篡改 recalculated compute_hash( current.index, current.timestamp, current.transactions, current.previous_hash, current.nonce ) if current.hash ! recalculated: print(f區(qū)塊 {i} 的哈希不匹配) return False # 檢查與前一個區(qū)塊的鏈接 if current.previous_hash ! previous.hash: print(f區(qū)塊 {i} 與前一個區(qū)塊的鏈接斷裂) return False # 檢查工作量證明 if not current.hash.startswith(target): print(f區(qū)塊 {i} 的工作量證明不合法) return False return True4.4 篡改實驗親手改一個區(qū)塊看看會發(fā)生什么理解校驗邏輯最好的方式就是親手篡改一個區(qū)塊然后看校驗怎么失敗。你可以這樣試blockchain [create_genesis_block()] add_block(blockchain, [{sender: A, receiver: B, amount: 5}]) add_block(blockchain, [{sender: B, receiver: C, amount: 3}]) print(validate_chain(blockchain)) # True # 篡改第一個區(qū)塊的交易數(shù)據(jù) blockchain[1].transactions [{sender: A, receiver: B, amount: 9999}] print(validate_chain(blockchain)) # False篡改之后區(qū)塊1的哈希會變導(dǎo)致區(qū)塊2的previous_hash對不上校驗在區(qū)塊1的哈希檢查就會失敗。這就是區(qū)塊鏈不可篡改的底層邏輯——改一個塊后面全崩。5. 實操中容易踩的坑與調(diào)試技巧5.1 時間戳精度導(dǎo)致的哈希不一致Python的time.time()返回浮點數(shù)不同平臺或不同調(diào)用時刻的精度可能不同。如果你在計算哈希時用了時間戳然后在校驗時重新獲取時間戳那哈??隙▽Σ簧稀U_做法是把時間戳作為區(qū)塊的一個固定字段存下來計算和校驗都用這個存儲值不要重新調(diào)用time.time()。5.2 JSON序列化的鍵順序問題Python 3.7的字典是有序的但如果你不指定sort_keysTrue不同版本或不同構(gòu)造方式可能導(dǎo)致鍵的順序不一致進而導(dǎo)致哈希不一致。我在第一次實現(xiàn)時就踩過這個坑構(gòu)造區(qū)塊時字典的鍵順序是index、timestamp、transactions但校驗時從對象屬性重建字典的順序變成了timestamp、index、transactions結(jié)果哈希完全對不上。加上sort_keysTrue之后問題消失。5.3 難度值不一致導(dǎo)致的校驗失敗挖礦時用的difficulty和校驗時用的difficulty必須一致。如果你挖礦用4校驗用3那校驗會通過因為前3位確實是0但這意味著難度要求被降低了。建議把difficulty作為鏈的一個全局參數(shù)存下來或者作為配置項統(tǒng)一管理。5.4 創(chuàng)世區(qū)塊的校驗跳過校驗循環(huán)從i1開始跳過創(chuàng)世區(qū)塊。這是因為創(chuàng)世區(qū)塊沒有previous_hash可校驗而且它的previous_hash是0這種特殊值。如果你把創(chuàng)世區(qū)塊也納入校驗需要單獨處理它的previous_hash檢查邏輯。5.5 調(diào)試時打印中間哈希值當(dāng)校驗失敗時最有效的調(diào)試手段是把每個區(qū)塊的存儲哈希和重新計算的哈希都打印出來對比看是哪一塊開始出現(xiàn)不一致。我通常會在validate_chain里加一個verbose參數(shù)失敗時輸出詳細(xì)信息if current.hash ! recalculated: print(f區(qū)塊 {i}: 存儲哈希{current.hash}) print(f區(qū)塊 {i}: 計算哈希{recalculated}) print(f區(qū)塊 {i}: nonce{current.nonce}) print(f區(qū)塊 {i}: previous_hash{current.previous_hash}) return False這樣一眼就能看出是數(shù)據(jù)被改了還是哈希計算方式不一致。6. 從教學(xué)實現(xiàn)到真實區(qū)塊鏈還差哪些關(guān)鍵拼圖6.1 網(wǎng)絡(luò)層與節(jié)點通信目前這條鏈只存在于單個進程的內(nèi)存里。真實區(qū)塊鏈需要多個節(jié)點各自維護一份鏈通過P2P網(wǎng)絡(luò)廣播新區(qū)塊和交易。節(jié)點收到新區(qū)塊后要校驗合法性然后決定是否追加到自己的鏈上。如果兩個節(jié)點同時挖出新區(qū)塊還需要一套共識規(guī)則來決定哪條鏈?zhǔn)侵麈湵忍貛庞玫氖亲铋L鏈原則。6.2 賬戶體系與數(shù)字簽名現(xiàn)在的交易只是明文字典任何人都可以偽造一筆A轉(zhuǎn)給B 100塊的交易。真實系統(tǒng)需要用非對稱加密發(fā)送方用私鑰對交易簽名接收方用公鑰驗證簽名。只有簽名合法的交易才會被打包進區(qū)塊。6.3 共識機制的引入單機環(huán)境下你自己就是唯一的節(jié)點想怎么改就怎么改。但在分布式環(huán)境下需要一套機制來防止惡意節(jié)點篡改數(shù)據(jù)。工作量證明本身就是一種共識機制——它讓篡改歷史鏈的成本變得極高因為你需要重算所有后續(xù)區(qū)塊的工作量證明并且要超過其他誠實節(jié)點的算力總和。6.4 交易池與手續(xù)費市場真實區(qū)塊鏈不會每來一筆交易就立刻打包而是先把交易放進交易池礦工從池子里挑選手續(xù)費高的交易打包。這涉及到交易優(yōu)先級、手續(xù)費估算、交易替換等復(fù)雜邏輯。教學(xué)實現(xiàn)里可以簡化為來一筆打包一筆。6.5 狀態(tài)存儲與智能合約比特幣的鏈上只存交易賬戶余額需要通過遍歷所有歷史交易來計算。以太坊則引入了狀態(tài)樹每個區(qū)塊頭里存了全局狀態(tài)的根哈希還支持在鏈上部署和執(zhí)行智能合約。這部分屬于進階內(nèi)容建議先把基礎(chǔ)鏈結(jié)構(gòu)吃透再研究。7. 完整可運行代碼與實測效果把前面的片段拼起來就是一個完整的可運行實現(xiàn)。我把它整理成一個文件你可以直接復(fù)制到本地跑import hashlib import json import time class Block: def __init__(self, index, timestamp, transactions, previous_hash, nonce0): self.index index self.timestamp timestamp self.transactions transactions self.previous_hash previous_hash self.nonce nonce self.hash self.compute_hash() def compute_hash(self): block_string json.dumps({ index: self.index, timestamp: self.timestamp, transactions: self.transactions, previous_hash: self.previous_hash, nonce: self.nonce }, sort_keysTrue, separators(,, :)).encode() return hashlib.sha256(block_string).hexdigest() def mine_block(block, difficulty4): target 0 * difficulty while not block.hash.startswith(target): block.nonce 1 block.hash block.compute_hash() return block def create_genesis_block(difficulty4): genesis Block(0, time.time(), [Genesis Block], 0) return mine_block(genesis, difficulty) def add_block(blockchain, transactions, difficulty4): previous blockchain[-1] new_block Block( indexprevious.index 1, timestamptime.time(), transactionstransactions, previous_hashprevious.hash ) mine_block(new_block, difficulty) blockchain.append(new_block) return new_block def validate_chain(blockchain, difficulty4): target 0 * difficulty for i in range(1, len(blockchain)): current blockchain[i] previous blockchain[i - 1] if current.hash ! current.compute_hash(): return False, f區(qū)塊 {i} 哈希不匹配 if current.previous_hash ! previous.hash: return False, f區(qū)塊 {i} 鏈接斷裂 if not current.hash.startswith(target): return False, f區(qū)塊 {i} 工作量證明不合法 return True, 鏈校驗通過 if __name__ __main__: difficulty 4 chain [create_genesis_block(difficulty)] add_block(chain, [{sender: Alice, receiver: Bob, amount: 10}], difficulty) add_block(chain, [{sender: Bob, receiver: Carol, amount: 5}], difficulty) add_block(chain, [{sender: Carol, receiver: Dave, amount: 2}], difficulty) for block in chain: print(f區(qū)塊 {block.index} | nonce{block.nonce} | hash{block.hash[:16]}...) ok, msg validate_chain(chain, difficulty) print(f校驗結(jié)果: {ok} - {msg}) # 篡改實驗 chain[1].transactions [{sender: Alice, receiver: Bob, amount: 99999}] ok, msg validate_chain(chain, difficulty) print(f篡改后校驗: {ok} - {msg})實測輸出大致如下區(qū)塊 0 | nonce48213 | hash0000a3f2c8d1... 區(qū)塊 1 | nonce91204 | hash0000b7e1f4a2... 區(qū)塊 2 | nonce33781 | hash0000c9d3e5b6... 區(qū)塊 3 | nonce67092 | hash0000d1f7a8c4... 校驗結(jié)果: True - 鏈校驗通過 篡改后校驗: False - 區(qū)塊 1 哈希不匹配每次運行的nonce和哈希都會不同因為時間戳變了。但校驗邏輯是穩(wěn)定的不改數(shù)據(jù)就通過改了數(shù)據(jù)就失敗。8. 幾個值得繼續(xù)折騰的擴展方向如果你已經(jīng)把基礎(chǔ)版本跑通了可以試試這幾個擴展每一個都能讓你對區(qū)塊鏈的理解更深一層第一個擴展是持久化存儲。目前鏈只存在內(nèi)存里程序一關(guān)就沒了。你可以用JSON文件或SQLite把鏈存下來啟動時加載關(guān)閉時保存。這會讓你接觸到序列化和反序列化過程中的哈希一致性問題——存進去再讀出來哈希還能對得上嗎第二個擴展是簡單的P2P模擬。不需要真的搞網(wǎng)絡(luò)編程可以用多線程模擬多個節(jié)點每個節(jié)點維護自己的鏈通過隊列互相廣播新區(qū)塊。當(dāng)一個節(jié)點收到新區(qū)塊時校驗并決定是否追加。這能讓你直觀感受到最長鏈原則是怎么工作的。第三個擴展是交易簽名。用Python的cryptography庫生成一對密鑰發(fā)送方用私鑰簽名交易節(jié)點用公鑰驗證。只有簽名合法的交易才能被打包。這會讓你理解為什么區(qū)塊鏈上的交易是不可抵賴的。第四個擴展是難度動態(tài)調(diào)整。讓程序根據(jù)出塊速度自動調(diào)整difficulty如果最近10個區(qū)塊的平均出塊時間小于目標(biāo)值就提高難度大于目標(biāo)值就降低難度。這是比特幣維持穩(wěn)定出塊間隔的核心機制。我在實際寫這套代碼的過程中最大的體會是區(qū)塊鏈的復(fù)雜性不在單個模塊而在模塊之間的耦合關(guān)系。哈希、鏈?zhǔn)浇Y(jié)構(gòu)、工作量證明、校驗邏輯每一個單獨看都不難但把它們串在一起就形成了一個改一處而動全身的系統(tǒng)。這種耦合不是設(shè)計缺陷而是刻意為之——它正是不可篡改特性的來源。你在調(diào)試時如果發(fā)現(xiàn)改了一個字段導(dǎo)致整條鏈校驗失敗不要覺得煩那說明你的實現(xiàn)是對的。