用Python写迷你区块链:哈希链与工作量证明核心原理 前阵子在社区看到一个提问用Python写一个区块链是不是得先学会P2P网络、共识算法、加密签名这些硬核东西底下还有人说至少要搭个Web框架才能展示。我当时就乐了——如果只是想理解区块链的核心原理一个能跑通的迷你版本用纯Python几十行就够了。当然这个版本没有真正的去中心化也没有防攻击能力但区块结构、哈希链、工作量证明这些骨架全都在。这篇就把我实际写过的那个demo拆开讲从数据结构到挖矿从链校验到篡改验证每一步都给代码顺带说说我踩过的坑。适合已经会Python基础语法、但还不清楚区块链内部机制的同学。1. 别被区块链吓到它本质是一个带校验的链表1.1 从哈希函数说起这一节决定了后面所有代码很多人一听到区块链就联想到复杂的加密算法其实最核心的密码学工具只有一个哈希函数。它就像一个内容指纹生成器——输入任意长度的数据输出固定长度的十六进制字符串。以SHA-256为例输入hello得到2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824输入hello!得到的却是完全不同的ce06092d948e9c56ea9b3a8b4b0b3b3f0b0b3b3e3f...实际值和前面几乎没有任何连续性。这种差一个字符哈希值面目全非的特性叫雪崩效应它保证了数据被修改后指纹一定变化。哈希函数还有两个朴素但关键的性质确定性同一输入永远同一输出和不可逆性只能由输入推输出不能由输出反推输入。所以它天然适合做完整性校验。在区块链里每个区块都用一个哈希值作为自己的身份证。1.2 区块和链为什么叫链区块链的最小单元是区块Block。一个区块除了要装业务数据比如转账记录、投票信息甚至一句废话还必须包含三样东西时间戳、自己的哈希、前一个区块的哈希。这里的前一个区块的哈希就是链的起源——每个区块都牵着一根绳子指向前一个区块于是所有区块串成一条链。假设有人篡改了第3个区块里的数据那么这个区块的哈希必然改变。可是第4个区块里存的是第3个区块的原始哈希对不上于是第4个区块的完整性也崩了紧接着第5、第6……整条链全部崩溃。这个设计就是不可篡改的来源不是不能改而是改了之后所有人一眼就能发现。所以链的校验逻辑可以概括成两个等式当前区块的哈希值是否等于根据其内容重新计算的哈希当前区块记录的前一哈希是否等于前一个区块的实际哈希。这两个等式就是我后面写is_valid()的全部内容。2. 动手前的准备环境与设计取舍2.1 只用Python标准库少一点依赖多一点理解写这个demo时我刻意没有用任何第三方库。用的是Python自带的hashlib算SHA-256、json序列化区块数据、time取时间戳。为什么不装flask、requests因为这里的目标是讲清楚区块链本身而不是过早引入网络层。装了框架反而容易让新手把注意力放在路由怎么写上忘了核心数据结构。建议用Python 3.8以上版本3.7以下在类型注解上有些别扭但不影响运行。我本地用的是3.10后面所有代码在这个版本下测试通过。如果你还在用2.x请赶紧换这一篇完全不兼容。2.2 数据结构用类还是字典两种选择我都试过。直接用一个字典表示区块代码可能更短但写着写着会发现属性名容易手滑。比如block[timestamp]和block[time_stamp]排查半天找不到问题。所以我最终用了两个类Block和Blockchain。Block负责存储一个区块的字段并提供计算自身哈希、拼装待哈希数据的方法Blockchain负责管理区块列表、创建创世区块、添加新区块、校验链完整性。这种划分其实模仿了比特币源码里的CBlock和CBlockIndex的思路虽然简化了很多但心智模型是对的。另外所有区块数据最终需要用json.dumps转成字符串再哈希这里有个大坑Python字典的键顺序在3.7之前不保证在3.7之后才保持插入顺序。如果你要跨机器、跨版本复现相同哈希务必把所有字段按固定顺序放进字典并且用sort_keysTrue确保键按字母序排列。我在后面代码里统一用json.dumps(block_content, sort_keysTrue)就是为了让每次哈希结果稳定。3. 写代码区块类和区块链类3.1 区块结构定义先看Block类的完整代码import hashlib import json import time class Block: def __init__(self, index, transactions, timestamp, previous_hash, nonce0): self.index index self.transactions transactions self.timestamp timestamp self.previous_hash previous_hash self.nonce nonce def compute_hash(self): 计算当前区块的SHA-256哈希 block_content { index: self.index, transactions: self.transactions, timestamp: self.timestamp, previous_hash: self.previous_hash, nonce: self.nonce } block_string json.dumps(block_content, sort_keysTrue, ensure_asciiFalse) return hashlib.sha256(block_string.encode(utf-8)).hexdigest()字段设计说明index是区块在链上的位置transactions是业务数据为了通用我直接用了一个列表你完全可以放字符串或任意JSON可序列化的对象timestamp是生成区块的时间previous_hash是前一个区块的哈希nonce是后面讲工作量证明时用的随机数先默认0。compute_hash()做的就是把所有字段打包成JSON字符串然后算SHA-256。这里有一个初学者最容易掉进去的坑直接把对象的__dict__丢给json.dumps结果哈希每次不一样因为对象里可能混入了不可序列化的东西。所以最稳妥的方式是手动构造一个只含基础类型的字典再序列化。3.2 创世区块与添加区块有了Block类接下来是Blockchain类。创世区块是链上的第一个区块index为0previous_hash可以随便给一个值业界惯例是一串0或者干脆写0*64表示没有前驱。我习惯用0乘64这样看着像哈希。class Blockchain: def __init__(self): self.chain [] self.create_genesis_block() def create_genesis_block(self): genesis Block(0, Genesis Block, time.time(), 0*64) genesis_hash genesis.compute_hash() self.chain.append(genesis) # 创世区块的哈希没有存到自身后续区块会把它作为previous_hash self.last_hash genesis_hash def add_block(self, transactions): index len(self.chain) previous_hash self.last_hash timestamp time.time() new_block Block(index, transactions, timestamp, previous_hash) new_block.hash new_block.compute_hash() # 这里只是方便查看 self.chain.append(new_block) self.last_hash new_block.hash这里有一个设计细节我并没有在Block类里加一个hash字段而是把新计算出的哈希作为属性动态挂上去。为什么因为严格说哈希是由区块内容推导出来的不应该作为一个可被修改的原始字段存在。但后续校验时又需要拿这个哈希和下一个区块的previous_hash对比所以动态挂一个hash属性做缓存。add_block看起来很简单但前提是新区块必须知道上一个区块的哈希。这个last_hash是创建链时维护的尾巴指针每次添加区块后更新。如果直接去self.chain[-1].compute_hash()也可以但那样每加一个区块就要重算一次逻辑上不冲突只是多此一举。3.3 链完整性校验核心中的核心校验函数是判断一个链是否被篡改的裁判。我会遍历整条链从第1个区块开始跳过创世做两件事def is_valid(self): for i in range(1, len(self.chain)): current self.chain[i] previous self.chain[i-1] # 检查当前区块记录的previous_hash是否等于前一个区块的哈希 if current.previous_hash ! previous.compute_hash(): return False # 检查当前区块自身的哈希是否被篡改重算哈希对不上 if current.compute_hash() ! current.hash: return False return True为什么明明previous_hash已经等于前一个区块的哈希了还要再算一次current.compute_hash()与current.hash对比因为攻击者可能把当前区块的previous_hash、transactions和自身记录的那个hash字段全改了伪造出一条自洽的假链。只有把每个区块重新哈希一遍才能验证当前区块本身的内容是否被改动过。这个校验函数很朴素但它把区块链最核心的防篡改逻辑清楚表达出来了。后面我会用它演示改一个字符整条链崩掉的过程。4. 让生成区块变慢工作量证明(PoW)4.1 为什么需要Nonce和难度值一个纯粹的链式结构其实很容易被伪造我改完所有区块然后重新计算哈希照样能通过上面的校验。所以中本聪引入了工作量证明——不让生成区块变得太容易。简单说每个区块的哈希必须满足某些特殊条件例如前几位必须是0。这种条件没有捷径只能不断修改区块里的nonce值重新算哈希直到撞到符合条件的结果。nonce就是彩票号码难度值就是要开出前几位是0的彩票。设置难度为4表示哈希必须以前面4个0开头难度为5就要5个0。每增加1位0平均尝试次数大约是原来的16倍因为每个十六进制位有16种可能。所以控制难度就能控制挖矿耗时。4.2 实现基本挖矿逻辑给Block类加一个mine_block方法def mine_block(self, difficulty): prefix 0 * difficulty while not self.compute_hash().startswith(prefix): self.nonce 1 self.hash self.compute_hash() return self.hash逻辑就一段while循环不停给nonce加1计算哈希检查是否以difficulty个0开头。一旦满足就把算好的哈希挂到self.hash上。这里有个小优化每次循环都调用compute_hash()而compute_hash()里每次都做一次json.dumps。在demo里无所谓但如果想快一点可以把json.dumps后的字符串缓存起来循环里只改变nonce。不过为了代码可读性我没有优化。说实话难度设成3的时候几毫秒就能挖出来根本没必要优化。4.3 把挖矿集成到添加区块流程add_block需要升级让它先挖矿再上链def add_block(self, transactions, difficulty2): index len(self.chain) previous_hash self.last_hash timestamp time.time() new_block Block(index, transactions, timestamp, previous_hash) new_block.mine_block(difficulty) self.chain.append(new_block) self.last_hash new_block.hash这时生成一个区块的时间取决于difficulty。如果设为2平均256次尝试左右设为4平均65536次尝试。我建议演示时用2或3不然等太久读者会以为程序卡死了。有一个需要提醒的细节挖矿的哈希条件和校验哈希的条件必须一致。我的is_valid里校验的是compute_hash()和current.hash是不是一致而current.hash是由mine_block算出来的满足前导零条件的哈希。所以只要重新计算出来的哈希不等于current.hash就会返回False。这样既验证了内容完整性也验证了工作量。5. 实际跑一遍挖几个块验证链5.1 命令行演示从创世到三个新区块我们来写个简单的测试脚本加上一些输出看看整条链是怎么生长的if __name__ __main__: my_blockchain Blockchain() print(创世区块哈希:, my_blockchain.chain[0].compute_hash()) my_blockchain.add_block([小明转给小刚 10], difficulty3) my_blockchain.add_block([小刚转给小红 5], difficulty3) my_blockchain.add_block([小红转给小明 2], difficulty3) print(链长度:, len(my_blockchain.chain)) for block in my_blockchain.chain: print(fIndex: {block.index}, Hash: {block.hash}, Nonce: {block.nonce})跑一次输出类似创世区块哈希: 0000e4a4b6d2c1...很长一串 链长度: 4 Index: 0, Hash: 0000e4a4b6d2c1...创世区块因为没有挖矿所以开头不一定有0 Index: 1, Hash: 0001a8f3c9b2..., Nonce: 312 Index: 2, Hash: 0000f5e2d8a7..., Nonce: 711 Index: 3, Hash: 0001c2e9d4f5..., Nonce: 523注意创世区块哈希可能不以0开头这完全正常。创世区块不参与挖矿它只是链的起点。而第1、2、3个区块的哈希都满足前导3个0的条件。5.2 篡改一个字段看校验怎么报警这是全场最有意思的环节。我故意把第一个新区块的交易内容从小明转给小刚 10改成小明转给小刚 999然后重新调用is_valid()# 恶意篡改 my_blockchain.chain[1].transactions 小明转给小刚 999 # 注意我们这里不重新挖矿也不更新nonce和hash print(校验结果:, my_blockchain.is_valid()) # False输出False。原因很直观重新计算chain[1]的哈希因为transactions变了新哈希必然不满足前导0条件而且和它自己记录的旧hash不同于是第一个校验就失败了。即使我把chain[1].hash改成新哈希它往下还会和chain[2].previous_hash对不上照样失败。这就是链式校验的威力。你不需要跑全网节点只要有一个节点保存着某个区块的原始哈希就能识别篡改。5.3 常见坑JSON序列化、类型转换、float时间戳我在写这个demo时踩过几个特别典型的坑写在这里帮后浪们避雷。第一个坑是json.dumps时的时间戳。time.time()返回的是浮点数在Python里浮点数转字符串可能会带上很多小数位不同机器上可能精度不同。如果你在A机器挖出来的哈希到B机器重新算可能因为浮点精度不一致得到不同结果。解决方法有两个取整int(time.time())或者用Decimal、datetime字符串。demo里我直接用了int(time.time())简单且稳定。第二个坑是挖矿循环条件。如果你把startswith(prefix)写成了startwithPython会抛AttributeError。这种错误属于低级手滑但真在我身上发生过。所以复制代码时留意一下拼写。第三个坑是compute_hash()里忘了把字符串编码成UTF-8。hashlib.sha256()只接受字节串不接受str。很多新人直接写hashlib.sha256(block_string)然后报错TypeError: Strings must be encoded before hashing。所以要记得.encode(utf-8)。第四个坑其实是设计问题如果你把nonce和hash都当成区块链的原始数据那成本就太高了。我在第一版代码里把hash写进了compute_hash的序列化字段结果每次算出来的哈希都依赖于上一个哈希但当前块的哈希还没算出来于是陷入死循环。后来才想明白当前块的哈希只能由非哈希字段计算出来哈希本身不能参与对自身的计算。6. 从demo往前再走半步加交易列表和节点接口6.1 把交易列表换成梅克尔根Merkle Root很多区块链科普里会提到梅克尔树我一开始也觉得玄乎。在迷你版本里最简单的思路是把一个区块里的多笔交易拼成一个数组然后一层层两两哈希最终得到的一个根哈希叫梅克尔根。只要任意一笔交易变动根哈希就会变。如果嫌梅克尔树太麻烦有一个平替方案直接对所有交易做排序后的大字符串哈希。这种方式虽然也能起到交易完整性摘要的作用但无法支持轻量级验证比如证明某笔交易存在而不需要下载全部交易。如果你只是做教学demo用排序后的json.dumps再哈希完全够用。我建议先跑通这个再去学真正的梅克尔树。6.2 打通HTTP接口让外部可以查看和提交虽然我前面强调不引入flask但如果你打算把demo变成一个真正可以被访问的小型服务那flask是最省的方案。一个只读接口和一个提交交易接口就够了代码量不到30行from flask import Flask, jsonify, request app Flask(__name__) my_blockchain Blockchain() difficulty 3 app.route(/chain, methods[GET]) def get_chain(): chain_data [] for block in my_blockchain.chain: chain_data.append({ index: block.index, transactions: block.transactions, timestamp: block.timestamp, previous_hash: block.previous_hash, nonce: block.nonce, hash: block.hash }) return jsonify({length: len(chain_data), chain: chain_data}) app.route(/add, methods[POST]) def add_block(): data request.get_json() transactions data.get(transactions, []) my_blockchain.add_block(transactions, difficulty) return jsonify({status: added, last_hash: my_blockchain.last_hash})这里有两个注意点真实节点之间需要用P2P协议广播新区块而HTTP接口只是让别人能请求你加块这已经算半个节点了。另外flask自带的开发服务器不适合生产环境只是演示用。6.3 我的个人体会动手写一遍比看十遍书有用这套demo我从大学一直用到带新人每次重新写一遍都有新理解。第一次写出来的时候我特别得意觉得这就区块链了。后来仔细看了比特币白皮书才发现自己写的这个连玩具都算不上——没有P2P网络、没有共识机制、没有任何数字签名连区块容量限制都没有。但恰恰是这个玩具让我真正理解了哈希指针、工作量证明、链式校验这三个概念。当你亲眼看到chain[1].transactions改一个字之后校验函数果断返回False时那些玄乎的不可篡改就落地了。如果你愿意还能在Block里加一个validator字段模拟权益证明或者把难度改成动态调整让出块时间稳定在10秒左右。这些我都试过改起来都不复杂。但这一步一个脚印的乐趣我留给你自己体会。