想理解区块链,最快的方式不是读白皮书,而是亲手写一条。这个项目是我花了一个周末做的极简区块链——没有P2P 网络、没有智能合约,只保留了区块、哈希链、工作量证明、签名交易和 Merkle 树这几个最核心的骨架。整套代码用 Python 实现,跑通之后再回头看那些概念文档,之前总觉得虚的部分一下子踏实了。如果你已经有 Python 基础语法的手感,想真正弄懂“区块到底怎么串起来”“转账是怎么验证的”“最长链规则凭什么让大家服气”,这篇东西可以给你一份能直接跑通的参考实现。
1. 动手之前,先把“最小区块链”的边界画清楚
很多人一听到“用 Python 写区块链”,第一反应是去抄一个现成库,或者直接拉一条以太坊代码下来研究。我的建议是:别。你需要的不是一个功能完备的生产链,而是一条能暴露所有核心机制的教学链。先想清楚什么该做、什么不该做,这比写代码更重要。
1.1 哪些组件必须保留,哪些可以砍掉
区块链这个概念拆开看,其实就四层:数据层、网络层、共识层、应用层。数据层是区块和交易怎么组织,网络层是节点之间怎么同步,共识层是大家怎么对同一本账达成一致,应用层是智能合约这类业务逻辑。
对于“简单区块链”,我的取舍如下:
| 组件 | 是否实现 | 理由 |
|---|---|---|
| 区块结构与哈希链 | 实现 | 这是区块链的根基,不写等于没写 |
| 工作量证明(PoW) | 实现 | 共识机制必须亲自动手才算理解 |
| 数字签名交易 | 实现 | 不解决“谁有权花这笔钱”就是玩具 |
| Merkle 树 | 实现 | 轻量级验证的经典方案,值得完整跑一遍 |
| P2P 网络 | 砍掉 | 多节点通信是工程问题,不是机制问题,单机可以先复现核心逻辑 |
| 智能合约 | 砍掉 | 属于应用层,跟链本身无关 |
| UTXO 模型 | 简化为余额模型 | UTXO 理解成本高,先用余额模型讲清签名和记账,再扩展不迟 |
这个取舍表帮我省掉了很多纠结。单机版最大的价值在于:你可以用肉眼看到每一个区块的数据结构、每一条交易的验签过程、每一次 PoW 的哈希变化。如果一开始就堆网络模块,你会被 socket、握手、广播协议淹没,反而看不清链本身。
1.2 技术选型:用标准库 + 一个密码学库
我用的环境是 Python 3.10,依赖只有pycryptodome一个,用于生成 RSA 密钥和签名验签。哈希直接走标准库的hashlib,持久化用内置sqlite3,命令行入口用argparse。
为什么不用Crypto之外的重型库?因为区块链的密码学部分在实际生产中大量使用椭圆曲线签名,但 RSA 的签名验签代码更容易读懂,密钥生成和 JSON 序列化也更直观。教学项目里,机制比算法选型重要,先跑通 RSA,以后再换 ECDSA 也不是难事。
文件结构也非常简单:
mini_chain/ ├── blockchain.py # 区块、PoW、链验证、Merkle树 ├── wallet.py # 密钥生成、地址派生、交易签名 ├── cli.py # 命令行入口 └── mini_chain.db # SQLite 生成文件(运行时产生)我强烈建议你把这个项目当成“能跑的最小系统”来写,而不是一个多目录的框架。框架感太重,代码量翻倍,学习收益却不会翻倍。
2. 区块结构和哈希链:先让数据变得不可篡改
任何区块链项目,第一步都是定义区块。区块是账本的“页”,页页相扣,形成链条。我这里实现的区块字段如下:
class Block: def __init__(self, index, timestamp, transactions, prev_hash, nonce=0): self.index = index self.timestamp = timestamp self.transactions = transactions # 交易列表,每个交易是 dict self.prev_hash = prev_hash # 上一个区块的哈希 self.nonce = nonce # 工作量证明的随机数 self.merkle_root = self._compute_merkle_root(transactions) self.hash = self.compute_hash() def compute_hash(self): header = { "index": self.index, "timestamp": self.timestamp, "transactions": self.transactions, "prev_hash": self.prev_hash, "nonce": self.nonce, "merkle_root": self.merkle_root, } encoded = json.dumps(header, sort_keys=True, ensure_ascii=False).encode("utf-8") return hashlib.sha256(encoded).hexdigest()2.1 为什么每个字段都参与哈希计算
区块的哈希不是只算一个“区块头摘要”,而是把交易、索引、时间戳、前块哈希、随机数全部序列化之后再做 SHA256。这里最容易被新手忽略的坑是:序列化必须稳定。
compute_hash()里我特意加了sort_keys=True。原因很简单——Python 字典在 3.7 之后保留插入顺序,但如果你在另一台机器、另一种语言里重建这个区块,字段顺序可能不同,而json.dumps对字段顺序敏感,同样的内容会得到完全不同的哈希。sort_keys=True保证“内容一样,哈希必然一样”。
prev_hash字段是整个哈希链的关键。它把每个区块“绑”在前一个区块上。我习惯用一个类比向同事解释这件事:每个新同学不仅要写自己的作业,还要把上一个同学的作业指纹抄在开头。你如果想改前面某一页的内容,从那一页开始,后面每一页的指纹都会对不上,任何人一校验就能发现篡改。
这里顺便说一句:创世区块的prev_hash没有前一页可抄,所以我约定它固定为 64 个0。这算一个行业通用惯例,很多链的创世区块都是这么处理的。
2.2 区块校验:不是计算出来就完事
光会算哈希还不够,链要保证每个区块都合法。我实现了is_valid_block:
def is_valid_block(new_block, previous_block, difficulty): if new_block.index != previous_block.index + 1: return False if new_block.prev_hash != previous_block.hash: return False if new_block.compute_hash() != new_block.hash: return False if new_block.timestamp <= previous_block.timestamp: return False if not new_block.hash.startswith("0" * difficulty): return False return True第一个条件防止区块跳号,第二个条件强制链条连续,第三个条件防止有人声称一个区块哈希是什么,实际内容却对不上,第四个条件防止时间戳倒挂,最后一个条件确保工作量证明被真实完成。
我后来在调试中发现,第四和第五个条件之间有个微妙的配合。PoW 要求哈希前导零,但理论上如果 someone 反复试 nonce,有可能试出一个时间戳更早但仍然满足难度的哈希。所以在挖矿代码里我会把时间戳固定在“当前时间”,在校验时再检查时间戳递增,双保险。
3. 工作量证明:从一个可调节的运气门槛说起
在去中心化系统里,没有任何中央机构来决定“下一个区块由谁来写”。那怎么保证大家不乱写?比特币给出的答案是一个很朴素的思路:你证明你费了劲,你才有资格记账。这个“费劲”不是拼算力谁更强,而是拼谁能先撞上一个指定难度的哈希。
3.1 hashcash 思想在 Python 里的落地
PoW 的实现核心是让区块哈希满足一定条件——最常见的是哈希结果以若干个0开头。出块难度越高,需要的0越多,尝试次数呈指数级上升。
class Blockchain: def __init__(self, difficulty=5): self.chain = [] self.pending_transactions = [] self.difficulty = difficulty self._create_genesis_block() def _create_genesis_block(self): genesis = Block(0, time.time(), [], "0" * 64) genesis.hash = genesis.compute_hash() self.chain.append(genesis) def proof_of_work(self, block): block.nonce = 0 calculated_hash = block.compute_hash() while not calculated_hash.startswith("0" * self.difficulty): block.nonce += 1 calculated_hash = block.compute_hash() return calculated_hash这里difficulty=5的意思是哈希必须以前 5 个字符00000开头。字符串哈希是十六进制,每个字符有 16 种取值,所以每个字符为 0 的概率是 1/16,5 个 0 的平均尝试次数大约是 16^5 = 1048576 次。这个量级在单机上跑大约几秒到十几秒,非常适合教学演示——既有等待感,又不会等到失去耐心。
如果你想调快一点,difficulty=4平均只要 65536 次尝试,基本眨眼就出块;想看更“真实”的挖矿感,可以调到 6,大约一两分钟一个块。我建议初学者先用 4 或 5 把流程跑通,再慢慢加难度。
3.2 为什么这个方法能形成共识
PoW 的美妙之处在于:它把“记账权”变成了一个可调节的运气门槛。系统不关心你看了多少资料、有多高的社会地位,只关心你有没有先找到满足条件的nonce。只要大多数人遵守“哈希有效且链条最长”的规则,整体账本就会沿着最长的合法链条稳定生长。
在实际比特币实现里,难度是动态调整的:全网算力变强,出块变快,就调高难度;算力下降、出块过慢,就调低难度。我这里没有纠结复杂的调参公式,而是用一个非常简化的版本说明原理:
def adjust_difficulty(self, target_block_time=30): if len(self.chain) % 10 == 0: actual = self.chain[-1].timestamp - self.chain[-10].timestamp expected = target_block_time * 10 if actual < expected / 2: self.difficulty += 1 elif actual > expected * 2: self.difficulty -= 1这个代码属于教学补充,真实 BTC 是每 2016 个块调整一次,目标出块时间 10 分钟。原理完全一样:实际时间远快于目标,就加大难度;远慢于目标,就减小难度。难度调整是“让全网络出块速度保持稳定”的关键机制,没有它,算力波动会直接摧毁整个共识节奏。
4. 交易与 Merkle 树:从字符串记账到可验证的分布式账本
很多入门教程到这里就停了,区块里的data字段就是一个字符串,比如“转账 100 元”。但真实区块链绝不会这么做,因为字符串无法验证“到底是谁发起这笔转账”,也无法高效地证明“某个交易确实存在”。所以我把这一节作为整条链的进阶核心。
4.1 钱包与地址:谁拥有这笔钱
我实现了一个简单的钱包模块,负责生成 RSA 密钥对,并把公钥派生为地址。地址不是公钥本身,而是公钥的哈希,这样做一方面缩短了长度,另一方面避免直接暴露公钥(公钥只在签名验证时才需要公开)。
from Crypto.PublicKey import RSA from Crypto.Hash import SHA256, RIPEMD160 def create_wallet(): key = RSA.generate(2048) return key def address_from_public_key(pub_key_hex): pub_bytes = bytes.fromhex(pub_key_hex) sha = SHA256.new(pub_bytes).digest() ripe = RIPEMD160.new(sha).hexdigest() return ripe地址生成后面向用户的部分我用 Base58 再编码一下会更像真实钱包,但这里为了调试方便,直接输出十六进制字符串。地址的本质就是把公钥的指纹编码成人类可读的形式,内部逻辑完全一样。
4.2 交易签名与验签
交易最核心的问题是:我怎么知道一笔转账确实是地址 A 发起,而不是有人冒充?答案就是数字签名。签名时用私钥签,验签时用公钥验。私钥只有你自己有,别人拿不到,所以签名无法伪造。
def sign_transaction(private_key, tx_data): serialized = json.dumps(tx_data, sort_keys=True).encode("utf-8") digest = SHA256.new(serialized) signature = pkcs1_15.new(private_key).sign(digest) return signature.hex()注意这里我故意对tx_data(不含签名的交易主体)做序列化后签名,而不是对整个交易字典签名。因为签名一旦生成,如果再被塞进交易字典参与哈希,就产生了循环依赖——签名依赖内容,内容又包含签名。正确的做法是:签名的范围严格限定在“除签名以外的所有字段”,验证时也按同样范围重算。
验签完整流程是:先检查交易中的公钥能否派生出 sender 地址,再对交易体做 SHA256 后用公钥验签。两步都通过,这笔交易才被认可。
在余额模型下,我还给每笔交易加了一个timestamp防重放,并约定同一笔交易不能重复被打包。这还远远不够生产级,但能帮你理解基本思路。
4.3 Merkle 树:我如何快速证明一笔交易存在
区块里有好几笔交易,我不可能每次都对整个区块做哈希来核对。Merkle 树的作用是:把所有交易的哈希两两配对,逐层向上合并,直到生成一个根哈希。你只需要知道根哈希和一条“验证路径”,就能证明某笔交易在某个区块里,而不需要下载全部交易。
def _compute_merkle_root(self, transactions): if not transactions: return hashlib.sha256(b"").hexdigest() hashes = [] for tx in transactions: data = json.dumps(tx, sort_keys=True).encode("utf-8") hashes.append(hashlib.sha256(data).hexdigest()) while len(hashes) > 1: if len(hashes) % 2 == 1: hashes.append(hashes[-1]) merged = [] for i in range(0, len(hashes), 2): merged.append(hashlib.sha256((hashes[i] + hashes[i + 1]).encode()).hexdigest()) hashes = merged return hashes[0]两个细节值得注意。第一,空交易的区块也必须有一个 Merkle 根,我约定为对空字符串做 SHA256,返回的是一个固定常量,这样区块结构始终完整。第二,当交易数为奇数时,我会复制最后一个节点的哈希再配对,这叫“奇数节点自我复制”,是 Merkle 树的通用处理方式。
Merkle 根被放进区块哈希参与计算,这意味着哪怕改动一笔交易的一丁点内容,根哈希会变,区块哈希会变,整条链从那个位置开始全部被破坏。所以 Merkle 树不是锦上添花,而是让区块链能在“保留大量交易的同时还能轻量校验”的关键支撑。
5. 链验证与分叉处理:最长链规则为什么能平息分歧
单机上跑几条手工链,很容易产生一个错觉:区块链就是一条直线往前走。但在真实网络中,两个矿工几乎同时挖出区块是常态,这时会出现分叉——两条链在同一个高度都合法,到底听谁的?
5.1 验证整条链:预防篡改的最后一道闸门
我先实现了一个简单的整链验证方法,它会把从创世区块开始的每一个区块都重新校验一遍:
def verify_chain(self): for i in range(1, len(self.chain)): if not is_valid_block(self.chain[i], self.chain[i - 1], self.difficulty): return False return True别小看这个函数。调试区块链时,90% 的 bug 都是靠它发现的。比如我之前有一位同事写区块哈希时漏掉了nonce,导致同一个区块算出不同哈希,最后整链验证直接失败。你以后在真实项目里,维护一个“随时能重新算出全链并检验”的接口,对排查问题极其重要。
5.2 最长链规则:用累计工作量投票
当链出现分叉时,节点选择的规则是:接收累计工作量更大的那条链。在等难度出块的简化模型里,累计工作量约等于区块高度,所以最简单的方式是比“谁更长”。
我在Blockchain类里加了替代链替换逻辑:
def replace_chain(self, new_chain, new_pending): if len(new_chain) > len(self.chain) and self.verify_chain(new_chain): self.chain = new_chain self.pending_transactions = new_pending这个思路讲出来很朴素:每个节点都把自己看到的最长合法链作为权威,短链被丢弃。为什么大家会信这条规则?因为对诚实矿工来说,算力不会说谎。制造一条比主链更长的链,必须付出至少相当于全网多数算力的努力,这在经济上不可行。规则听上去简单,背后其实是“用物理成本换信任”的经济学设计。
5.3 模拟一次分叉合并
为了亲眼看到最长链规则生效,我写了一个小场景:节点 A 和节点 B 各自持有同一份链的副本,A 先挖出第 1 个块,B 也同时挖出一个不同的第 1 个块(因为 pending 交易不同),这时两边各执一词。我给 A 自己继续往后挖了两个块,A 的链变长,B 节点同步时发现 A 的链更完整,于是丢弃自己的短链,切换过去。
这个演示让我真正体会到了“分叉不是异常,而是日常”。区块链系统的魅力不在于永远不分叉,而在于分叉之后能有机制自动收敛。只要不是全网长期分裂,最长链规则会把所有节点拉回同一条主线。
5.4 为什么比“最长”而不是“包含最多交易”
有些运行者以为“最长链”就是交易最多的链,其实不准确。如果某条链虽然短,但每个块都塞满了交易,是不是应该选它?真实比特币不会这么做,因为判断“交易最多”需要遍历区块内容,成本高且容易被伪造;判断“最长”只需要对比高度与累计难度,成本低且作弊成本高。
所以规则的准确说法是“累计工作量最大的链”,高度只是它的简化表现。理解了这一层,你再去读白皮书里的“longest chain”就不会望文生义了。
6. 持久化与 CLI:把五分钟的 Demo 变成能一直跑的小系统
代码写到这里,区块链已经能挖矿、能转账、能验签,但它还活在内存里,进程一关就全没了。真正的链需要持久化,需要一个命令行入口,让整个系统“活”起来。这一节,我把它接到 SQLite 上,并配了完整的 CLI 命令。
6.1 SQLite 表结构:区块即文档
区块链的持久化有个很自然的思路:区块本身就是一个完整对象,我把它整体序列化成 JSON 存一行。SQLite 的TEXT字段足够容纳。我采用的表结构非常简单:
CREATE TABLE IF NOT EXISTS blocks ( height INTEGER PRIMARY KEY, block_json TEXT NOT NULL, created_at REAL NOT NULL );读写时,block_json用json.loads还原为字典,再重建Block对象。这里一个关键点是:从数据库恢复区块时,不能直接调用Block.__init__重新计算哈希(因为hash字段是当时挖矿算出的结果,重新计算会得到同样的值,但如果数据库内容被篡改,重启时你会立刻发现验证失败)。我专门写了一个from_dict类方法,只恢复字段,不重新挖矿,然后把整链交给verify_chain做检查。
6.2 CLI 命令一览
我用argparse定义了 6 个命令,构成了一个可演示的最小闭环:
| 命令 | 功能 | 关键输出 |
|---|---|---|
init | 初始化链,创建创世区块 | 创世块哈希 |
wallet | 生成/加载钱包,打印地址 | 地址与公钥 |
balance | 查询某地址余额 | 数字余额 |
transfer | 发起一笔签名交易 | 交易 ID |
mine | 打包 pending 交易并挖一个新块 | 新区块哈希 |
chain | 打印整条链 | 每个区块概览 |
verify | 全链验证 | 验证结果 |
transfer命令里包含了完整的签名流程,它读钱包、构造交易字典、调用sign_transaction,最后把交易放进 pending 队列。只有执行mine,pending 里的交易才会被真正塞进区块。这个设计模拟了真实世界的节奏:转账请求先广播到交易池,矿工再把交易池中的交易打包出块。
实际跑一个完整流程的输出大致长这样:
$ python cli.py init 创世块生成: 00000e3ea7f0c1ecb0f0dd1d8ce6c9d6f9e83bc64bf4a2c5adfe37fba73c5e41 $ python cli.py wallet 地址: 1b2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b 公钥: 30820122300d06092a864886f70d01010105000382010f0030... $ python cli.py transfer --recipient 2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e --amount 25 交易已签名: a1b2c3d4... $ python cli.py mine 新区块 #1 生成: 000001a9ebf1d0f5d9c0...6.3 自己动手时会踩的几个坑
这几条是我跑通之后复盘记下来的,每条都曾让我多花了一到两小时。
第一,时间戳单位必须统一。我在签名交易里用了秒,但在区块里一开始用毫秒,导致同一个交易在签名时和打包后时间不同,验签死活不过。后来统一到秒,问题立刻消失。真实系统里,单位不一致是跨语言协作最常见的事故源。
第二,私钥文件不要带多余权限。钱包保存时,我明确只写当前用户可读写的权限,否则同机其他用户可能读到私钥。以下代码在wallet.py里帮忙挡住了这个问题:
with open(wallet_path, "w") as f: f.write(private_key_export) os.chmod(wallet_path, 0o600)第三,哈希计算中的浮点陷阱。time.time()返回浮点数,JSON 序列化浮点在跨语言环境中可能有精度差异。解决方式是转成整数:int(time.time()),这样序列化结果在任何语言下都一致。
第四,Merkle 树对空交易的根哈希要固定。如果空交易返回None,后续所有依赖根哈希的校验逻辑都要判空,容易埋雷。我给空交易一个固定根哈希,整个区块结构永远完整。
第五,重放攻击是余额模型的天然弱点。同一笔已签名的交易如果被反复广播,接收方可能重复入账。真实系统用 UTXO 模型和交易序号来解决。我在教学实现里加了交易去重检查——每个地址的交易序号必须单调递增,打包时发现旧序号直接拒绝。虽然简化为单节点版本,但思路是一致的。
写在最后的体会
把这条链完整跑起来之后,我对之前背过的那些概念有了完全不一样的感觉。哈希链不是抽象图,是我能在调试器里看到变量一步步串起来的东西;PoW 不是一个神秘的数学问题,无非是找一个 nonce 让哈希前导零;Merkle 树不是高端协议,只是把节点两两合并哈希的一棵树。如果你也想自己写一条链,我建议从本文这份最小骨架开始,把difficulty从 5 降到 4 跑通流程,再慢慢加难度、加交易、加持久化,一步步把它扩展成你自己真正理解和掌控的小账本。