用Python从零实现一个区块链:哈希引用、PoW与链校验详解

发布时间:2026/9/10 20:12:09
用Python从零实现一个区块链:哈希引用、PoW与链校验详解 1. 链式结构到底在“链”什么一个比喻讲透核心思路1.1 哈希的“指纹”属性先聊一个很多人刚接触区块链时的疑惑这东西看起来不就是个链表吗跟我在数据结构课上学到的 LinkedList 有什么区别区别大了。普通链表的每个节点靠内存地址串联你把中间的某个节点拆掉再把前后指针改一下链表照样能跑没人知道你动手脚。而区块链里的“链”靠的不是地址是哈希引用。哈希这个东西你可以把它理解成一段数据的“指纹”同一段数据无论算多少次指纹都一样只要数据被改动哪怕一个标点符号、一个空格指纹就会面目全非。所以把区块A的哈希值存进区块B里本质上就是让区块B“记住”区块A的完整模样。这样一来整条链就像一串手铐铐环之间不是靠铁丝穿起来的而是靠指纹印在一起的。谁想修改中间某个区块的数据可以但这个区块的指纹立刻变了后面所有区块存的“上一块指纹”就全对不上了。区块链的防篡改能力根源就在这个极其朴素的设计上。我最早看比特币白皮书时被“去中心化”“共识机制”“默克尔树”这些词唬住了总觉得区块链门槛很高。后来用 Python 写了一个几十行的极简版才意识到最核心的那个齿轮其实就是“哈希引用”这四个字。后面那些复杂的东西都是为了让这个链条在无数台互不信任的机器上还能保持一致、还能防止恶意刷块而加上去的保险。1.2 为什么先用 Python 搞“教学链”如果用 C 或 Rust 来实现一个教学用的区块链光处理序列化、内存管理和字符串拼接就够你折腾半天很容易把注意力从核心逻辑上挪开。用 Python 的好处是数据结构直观标准库自带 hashlib 能直接算 SHA-256写出来的代码几乎可以当伪代码读。这篇文章适合两类人。第一类是把区块链原理看了个大概、但还没动手敲过代码的人你跟着敲一遍很多概念会瞬间落地。第二类是平时写脚本、写后端接口的 Python 开发者想搞明白区块链到底是怎么被“搭”出来的以便以后去看更复杂的开源项目时不发怵。你只需要预备这些基础会定义类、会用列表和字典、知道一点 json 和 hashlib 的用法。这些够用了。我们不会碰交易所、不会碰智能合约就老老实实在自己电脑上把一条链从无到有跑出来。2. 手写区块类与哈希指纹数据骨架的搭建过程2.1 Block 类的字段设计与哈希计算先设计一个最简单的区块。它至少需要这些信息index区块在链里的序号从 0 开始timestamp出块时间data区块存储的数据可以是任意字符串或结构化对象previous_hash前一个区块的哈希这是“链”的链接点hash当前区块的哈希nonce随机数现在先不展开到工作量证明那一章你就知道它干嘛用的代码不难import hashlib import json import time class Block: def __init__(self, index, timestamp, data, previous_hash, nonce0): self.index index self.timestamp timestamp self.data data 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, data: self.data, previous_hash: self.previous_hash, nonce: self.nonce, }, sort_keysTrue, separators(,, :) ) return hashlib.sha256(block_string.encode()).hexdigest()这里有几个细节是我实际写的时候踩过坑才注意到的。第一compute_hash 里千万不能包含 hash 字段本身。哈希是对“内容”的摘要如果你把 hash 字段自己也放进摘要里那这个摘要又依赖摘要自己根本没法算。我见过有人在网上抄的代码里把 hash 也塞进序列化字典然后导致每次计算结果都不一样找半天 bug。第二json.dumps 加 sort_keysTrue 很重要。Python 字典默认保持插入顺序同样一组字段如果你按不同顺序写入字典序列化出来的字符串就不同算出来的哈希也就不同。对于同一个区块我们希望在任意环境、任意时间重新计算时都能得到同一个哈希所以必须强制按键名排序。我在后面讲链校验的时候还会再提一次这个点。第三我用了 separators(,, :)去掉 JSON 序列化时多余的空白。这一步不是必须的但能让同样的数据在不同 Python 版本下序列化的字节完全一致减少环境差异带来的坑。2.2 创世区块和第一个“上链”操作有了 Block 类再来一个管理整条链的类。所谓“创世区块”就是链上的第一个区块index 为 0。它没有上一块所以 previous_hash 可以用 64 个 0 占位因为 SHA-256 计算出来的十六进制字符串长度正好是 64 位。class SimpleBlockchain: def __init__(self): self.chain [] self.create_genesis_block() def create_genesis_block(self): genesis Block( 0, int(time.time()), {message: genesis block}, previous_hash0 * 64, ) self.chain.append(genesis) def add_block(self, data): previous self.chain[-1] new_block Block( previous.index 1, int(time.time()), data, previous.hash, ) self.chain.append(new_block) return new_block这里我把时间戳强制转成了 int(time.time())。为什么不保留浮点数精度因为浮点数在 JSON 序列化和反序列化时尾数可能出现微小的变化不同机器、不同 Python 版本得到的结果可能不一样这对要“重新计算哈希来校验”的场景是致命的。教学项目里时间精度到秒就够了用 int 是为了保证确定性。跑一下看看效果if __name__ __main__: chain SimpleBlockchain() chain.add_block({user: alice, action: register}) chain.add_block({user: bob, action: transfer}) for block in chain.chain: print(f区块 {block.index}: hash {block.hash}) print(f previous_hash {block.previous_hash})输出大概是这样的区块 0: hash 0000a1b2c3d4e5f6... previous_hash 0000000000000000000000000000000000000000000000000000000000000000 区块 1: hash 0000f7e8d9c0b1a2... previous_hash 0000a1b2c3d4e5f6... 区块 2: hash 00004829c1d3e5f7... previous_hash 0000f7e8d9c0b1a2...看到没有每个区块的 previous_hash 都指向上一块的 hash一条链就串起来了。这一章你可能觉得太简单但它已经完成了区块链最核心的 50%。接下来的工作量证明才是让“人人都能加块”变成“加块要有门槛”的关键机制。3. 工作量证明把“挖矿”搬到本地单机3.1 没有 PoW别人可以“无成本搅局”回到一个现实问题按照第二章的逻辑任何人只要拿到链上最后一个区块的哈希就可以调一下 add_block立刻生成一个新区块加到链尾。如果在一个有多方参与的开放网络里这就等于谁都可以随时随地发公告而且公告一发出就永久记在链上成本为零。成本为零的后果是什么攻击者可以瞬间制造几百万个区块把网络刷爆也可以故意在链尾追加一些垃圾数据让其他节点的校验全部失效。所以现实中的区块链必须给“往链上追加区块”这件事设置一个成本这个成本就是计算量。比特币采用的办法是你要想生成一个新块必须不断猜一个随机数 nonce直到整个区块的哈希值小于某个目标值或者说哈希值开头要有足够多的 0。这个猜的过程非常消耗 CPU所以叫“工作量证明”。一旦你找到了满足条件的 nonce其他节点验证起来却极其便宜——只需要算一次哈希看看是不是以足够的 0 开头就行。你可以这么理解挖矿就是解一道超级简单的“验证难、求解慢”的题。就像拼图拼出来要花时间但检查拼得对不对扫一眼就行。3.2 用 nonce 和 sha256 实现一个可运行的 PoW现在给 Block 类加上挖矿方法。为了体现 PoW构造区块时不再立即计算最终哈希而是把 hash 先留空等挖矿函数找到满足难度的 nonce 后再填上。class Block: def __init__(self, index, timestamp, data, previous_hash, nonce0): self.index index self.timestamp timestamp self.data data self.previous_hash previous_hash self.nonce nonce self.hash # 先空着挖矿后填入 def compute_hash(self): block_string json.dumps( { index: self.index, timestamp: self.timestamp, data: self.data, previous_hash: self.previous_hash, nonce: self.nonce, }, sort_keysTrue, separators(,, :) ) return hashlib.sha256(block_string.encode()).hexdigest() def mine_block(self, difficulty): target 0 * difficulty while True: self.hash self.compute_hash() if self.hash.startswith(target): break self.nonce 1注意一个逻辑细节我先让 self.hash 等于当前 nonce 下的哈希然后判断是否以 difficulty 个 0 开头。如果满足停止如果不满足nonce 加 1再算。这是最直观的“暴力搜索”思路。区块链类也顺势调整加入难度参数class SimpleBlockchainWithPow: def __init__(self, difficulty3): self.chain [] self.difficulty difficulty self.create_genesis_block() def create_genesis_block(self): genesis Block(0, int(time.time()), {message: genesis block}, previous_hash0 * 64) genesis.mine_block(self.difficulty) self.chain.append(genesis) def add_block(self, data): previous self.chain[-1] new_block Block(previous.index 1, int(time.time()), data, previous.hash) new_block.mine_block(self.difficulty) self.chain.append(new_block) return new_block你可能注意到了连创世区块都要挖矿。这符合逻辑任何上链的区块都应当满足同一套难度规则否则校验逻辑会很别扭。跑一次把每个区块的 nonce 和 hash 打印出来区块 0: nonce 563, hash 00001a2b3c4d5e6f... 区块 1: nonce 781, hash 0000aba2cdef0123... 区块 2: nonce 234, hash 00009f88e7d6c5b4...你看前三个字符都是 000这就是“难度为 3”的效果。3.3 难度到底调到多少这是教学项目里很实际的问题难度设置得太低挖矿瞬间完成体现不出“证明”的意义难度设置得高又会让验证时间拉长影响演示效率。我在自己的机器上实测过一组数据给你参考难度平均需要尝试的 nonce 次数大致耗时1约 10~20 次毫秒级2约 200~300 次毫秒级3约 2000~4000 次约 0.1 秒4约 20000~40000 次约 1 秒5约 200000~400000 次约 5~10 秒为什么数量级大约是 16 的 difficulty 次方因为十六进制哈希的每一位有 16 种可能想要一位是 0概率是 1/16想要两位都是 0概率就是 1/256三位 0就是 1/4096。平均试 4096 次才能撞上一个。所以难度每加 1计算量大约翻 16 倍。演示时我建议把难度设成 3 或 4。太高了一堂课的时间全都耗在等挖矿上太低了又看不到明显的计算过程。另外有个小技巧可以把难度设计成构造函数的参数而不是写死在代码里。这样在测试的时候用难度 2跑通后想展示真实感再调到 4不用改任何逻辑。还有一个容易忽略的点工作量证明的难度是全网统一的。在比特币里所有节点必须使用同一个目标值不然不同节点挖出的块彼此不认账。但如果你是个人实验或者做一个私有链难度完全由你说了算没人跟你争那就无所谓了。4. 链完整性校验改动一个字符整条链如何“自曝”4.1 校验函数的四个检查点链搭出来了挖矿也跑通了。但光会生成链没用得会“验链”。这是区块链被信任的基础也是实际项目里每个新节点加入网络时做的第一件事。校验一个区块链是否健康至少要做四件事重新计算每个区块的哈希看是否等于区块上记录的 hash检查每个区块的 previous_hash 是否等于前一个区块的 hash检查每个区块的哈希是否满足工作量证明难度即开头是否有足够多的 0检查创世区块的特殊性防止有人把创世区块整个换掉。代码是这样的class SimpleBlockchainWithPow: # ... 上文已有的方法 ... def is_chain_valid(self): for i in range(1, len(self.chain)): current self.chain[i] previous self.chain[i - 1] if current.hash ! current.compute_hash(): return False, f区块 {i} 的哈希与内容不匹配 if current.previous_hash ! previous.hash: return False, f区块 {i} 的 previous_hash 与上一块哈希不一致 if not current.hash.startswith(0 * self.difficulty): return False, f区块 {i} 没有满足工作量证明难度 return True, 校验通过这个函数我写在第 3 章的类里。注意我返回了布尔值和原因字符串调试时很管用。你可以写成一个纯布尔函数但那样出了问题还得靠打印排查麻烦得多。4.2 篡改实验中间区块的 data 被改之后现在做一次现场实验。先构建一条包含 3 个区块的链然后模拟攻击者篡改第 1 个区块的 data 字段再运行 is_chain_valid 看看会发生什么。if __name__ __main__: bc SimpleBlockchainWithPow(difficulty3) bc.add_block({user: alice, action: register}) bc.add_block({user: bob, action: transfer}) print(篡改前:, bc.is_chain_valid()) # 模拟篡改把第 1 个区块的 action 改掉 bc.chain[1].data[action] register print(篡改后:, bc.is_chain_valid()) # 顺手看看整个链的哈希变化 for block in bc.chain: print(f区块 {block.index}: stored_hash{block.hash[:32]}... calc_hash{block.compute_hash()[:32]}...)输出大概是篡改前: (True, 校验通过) 篡改后: (False, 区块 1 的哈希与内容不匹配)关键点来了你以为只有第 1 个区块的校验会失败其实后面的区块全部会失败。因为第 1 个区块的内容变了它的哈希变了而第 2 个区块的 previous_hash 还指向原来的哈希两者对不上。这就是哈希引用的“连锁反应”。我在实验里特意观察到一件事如果攻击者不仅篡改了 data还顺手把第 1 个区块的 hash 字段也重新计算并改掉了那么第 1 个区块的自洽校验能通过但第 2 个区块的 previous_hash 校验依然会失败。所以篡改者必须从被改区块开始把后面每一个区块的哈希和 nonce 全部重新挖一遍才有可能让整条链“看起来正常”。而这恰恰就是 PoW 的意义所在。攻击者确实可以重新挖矿但每一块都要付出大量 CPU 时间。链越长重挖的成本越高。当然教学项目里链很短重挖也很快但这套机制在真实网络中就是靠这种不对称的成本来保障安全的。4.3 校验中躲不开的序列化细节写校验函数时最容易出 bug 的地方反而不是算法本身而是序列化的一致性。先说排序问题。我在第一章就强调过 sort_keysTrue。这里再给个反面例子如果把 compute_hash 里的 json.dumps 去掉 sort_keys那么同一个区块对象只要字典字段顺序不固定算出哈希的“正确性”就不稳。比如你在创建区块时先塞了 timestamp 再塞 data而校验时用另一个字典构造顺序是 data 在前、timestamp 在后两个字符串就不同哈希自然不同校验必然失败。再说数据类型。字典里同一个 key 的值如果创建时是整数 1加载时被 JSON 读成了浮点数 1.0或者字符串 “1”哈希也会变。所以我在代码里统一用 int 型时间戳量纲清晰也在构造和校验两端保持一致。最后哈希值本身不要参与序列化。也就是说compute_hash 里只能放 index、timestamp、data、previous_hash、nonce 这几个原始字段绝对不能包含 self.hash。否则就是“自己验自己”不仅逻辑循环而且一旦把 hash 字段存进文件再读出来哪怕内容没变整个校验都要出问题。5. 从“玩具”到“能用”交易、持久化与 REST 接口5.1 把 data 升级成交易列表前面几章的区块里data 字段就是一个普通字典完全没体现出区块链的典型应用场景。现在升级一下让区块里装的不再是单个对象而是一组交易。定义一个轻量交易类class Transaction: def __init__(self, sender, receiver, amount): self.sender sender self.receiver receiver self.amount amount def to_dict(self): return { sender: self.sender, receiver: self.receiver, amount: self.amount, }然后给区块链类加上待打包交易池class SimpleBlockchainWithTransactions(SimpleBlockchainWithPow): def __init__(self, difficulty3): super().__init__(difficulty) self.pending_transactions [] def add_transaction(self, tx: Transaction): self.pending_transactions.append(tx) def mine_pending_transactions(self): previous self.chain[-1] new_block Block( previous.index 1, int(time.time()), [tx.to_dict() for tx in self.pending_transactions], previous.hash, ) new_block.mine_block(self.difficulty) self.chain.append(new_block) self.pending_transactions [] return new_block区块的 data 字段变成了一个列表每个元素是一笔交易。在真实系统里交易还要有签名、非重复 ID、手续费等字段这里先不展开。现在这个模型已经能表达“多人转账打包进区块再被挖出来”的基本流程了。我把这种加交易的设计单独拿出来说是因为很多教程写到这里就停了导致读者学完之后只会添加一整块 data却不知道交易是怎么进入区块的。实际开发中最自然的方式就是先维护一个待确认交易池矿工从池子里取若干交易打包进区块再执行 PoW。这个模式也是比特币内存池mempool的一个极简影子。5.2 落盘保存和重启恢复目前这条链只活在内存里程序一关全没了。要让它“活”得久一点最省事的办法是序列化成 JSON 文件启动时再加载回来。def save_to_file(self, path): payload [] for block in self.chain: entry block.to_dict() entry[hash] block.hash payload.append(entry) with open(path, w) as f: json.dump(payload, f, indent2, defaultstr) def load_from_file(cls, path, difficulty3): with open(path, r) as f: payload json.load(f) new_chain cls(difficultydifficulty) new_chain.chain [] for entry in payload: block Block( indexentry[index], timestampentry[timestamp], dataentry[data], previous_hashentry[previous_hash], nonceentry[nonce], ) block.hash entry[hash] new_chain.chain.append(block) return new_chain注意两个细节。第一加载文件里的区块链后一定要立刻跑一遍 is_chain_valid确认文件没有被篡改过。我的做法是 load_from_file 之后紧接着调用校验函数如果校验不通过直接抛异常或者提示用户而不是默默把链加载进内存。原因很简单文件可能是别人给你的也可能是程序崩溃后遗留下来的脏数据你无法保证它一定可信。第二保存时我用 defaultstr 来处理交易对象序列化问题。如果你直接把 Transaction 对象交给 json.dump会报 TypeError因为默认情况下 json 不认识自定义对象。更稳的做法是先确保 Block 里的 data 已经是普通字典或列表结构再落盘。上面的 to_dict 方法就是干这个的。写的时候也要留意时间戳类型。如果之前用 int(time.time())加载回来还是 int校验没问题如果你某一天改成 time.time() 返回浮点数并保存到 JSON再加载时浮点精度可能变化校验就会莫名失败。我建议在 Block 构造时统一强制类型或者干脆全程用 int。5.3 用 Flask 暴露查询、交易、挖矿三个接口有了交易和持久化链已经像一个“有状态的核心服务”了。为了让其他程序也能调用它最自然的方式是包一层 HTTP 接口。这里我用 Flask 做一个非常精简的 API三个端点GET /chain返回整条链POST /transactions/new提交一笔新交易GET /mine挖矿把当前 pending_transactions 打包进新区块from flask import Flask, jsonify, request app Flask(__name__) blockchain SimpleBlockchainWithTransactions(difficulty3) app.route(/chain, methods[GET]) def full_chain(): return jsonify( [block.to_dict() for block in blockchain.chain] ) app.route(/transactions/new, methods[POST]) def new_transaction(): body request.get_json() required [sender, receiver, amount] if not body or any(key not in body for key in required): return jsonify({error: missing fields}), 400 tx Transaction( senderbody[sender], receiverbody[receiver], amountfloat(body[amount]), ) blockchain.add_transaction(tx) return jsonify({status: transaction received}), 201 app.route(/mine, methods[GET]) def mine(): block blockchain.mine_pending_transactions() return jsonify(block.to_dict()), 200 if __name__ __main__: app.run(host127.0.0.1, port5000)现在你可以用 curl 或者 Postman 试用curl -X POST http://127.0.0.1:5000/transactions/new \ -H Content-Type: application/json \ -d {sender: alice, receiver: bob, amount: 10} curl http://127.0.0.1:5000/mine curl http://127.0.0.1:5000/chain到这一步你就拥有一个可以提交交易、自动挖矿、通过 HTTP 查询的本地区块链了。虽然它没有真正的 P2P 网络也没有密钥签名但作为“区块链开发”的第一块踏板已经足够完整。6. 实测以后沉淀的几个经验代码敲到这里我把自己实际踩过的坑和调整过的思路整理成几条给你当参考。第一个经验是关于 nonce 与哈希的循环依赖。最初我写挖矿函数时在循环里只改了 nonce却忘记让哈希重新把 nonce 算进去结果发现无论怎么挖哈希都不变循环直接卡死。这种问题特别容易被忽略因为它不报错只是程序不往下走。排查时先打印一下 nonce 和当前哈希确认 nonce 变化后 compute_hash 的输出真的变了再往下查。第二个经验是难度参数可配置化。我建议把难度作为构造函数参数同时提供一个小工具函数可以根据上一块的出块时间动态调整难度。这个功能在真实区块链里是标准配置比特币每 2016 个块调整一次。你可以自己实现一个简单版本如果生成上一个块耗时太短就提高难度耗时太长就降低难度。虽然教学用不上但写一遍能帮你想清楚“难度到底在控制什么”。第三个经验是持久化之前先想清楚“信任边界”。把链保存成 JSON 文件方便是方便但文件是可以被手工改的。你下次加载时如果默认信任文件内容等于给攻击者开了后门。所以项目里只要有 load_from_file就一定紧接着调用 is_chain_valid。这个顺序我反复强调因为我在自己测试时偷懒跳过校验结果查了半天才发现是文件被外部改过。第四个经验是不要急于把 P2P 网络和密码学签名加进来。很多初学者一上来就想做去中心化网络结果代码量爆炸bug 一堆核心概念反而没吃透。从一个单机可运行的链开始把哈希引用、PoW、链校验这三个概念彻底弄明白后面的东西自然能接上。你可以把区块链想象成一辆车先把发动机哈希引用和变速箱PoW装配好再考虑给车装导航和音响网络层、应用层。如果你想让这版代码继续往下长我建议先做这几件事给交易加上签名和验签用 RSA 或 ECDSA 都行把单机链改成多节点用 HTTP 互相同步各自的链做一个简单的难度自动调整模块。做完这三样你对外说“我懂区块链核心原理”就完全有底气了。