ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零搭建最小区块链:Python实现哈希、工作量证明与链校验

从零搭建最小区块链:Python实现哈希、工作量证明与链校验

我至今记得第一次亲手搭出一个能跑的区块链时的心情:那感觉有点像看着自己拼的乐高终于按图纸转了起来。别误会,我不是让你去造一台矿机,也不是让你去研究什么交易策略,而是想建议每个想学区块链的新手,都能用半天时间从零构建一个最小可运行的区块链原型。当你亲手把「区块」「哈希」「工作量证明」「链校验」这几个名词写进代码后,很多网上说得神乎其神的概念都会变得具体起来。这篇文章就是我自己的思路、代码和踩坑记录,适合完全没写过区块链、但有最基础编程经验的读者。

1. 动手写代码前,先想清楚这条链到底要做什么

很多人一上来就搜《比特币白皮书》《以太坊黄皮书》,结果在梅克尔树、椭圆曲线签名、虚拟机这些术语里越陷越深。我的建议是反过来:先别追求完整,先想清楚一个最小区块链要解决什么核心问题,然后用最简单的代码把它们体现出来。

1.1 区块链解决的三个原始问题

第一个问题是数据不可篡改。传统数据库里管理员可以改记录,或者拿到权限的人可以直接UPDATE一行数据。区块链用“哈希链接”让每个区块都指着前一个区块,想改中间任何一个历史块,后面所有块都要跟着改,而且全网节点都能发现异常。

第二个问题是记账权怎么分配。既然大家都能记一笔,凭什么你记的账别人要认?最早期的方案就是工作量证明:你要先花成本算出符合条件的哈希,才有资格把新区块广播出去。这个成本是用算力和电力换的,不是嘴上说说。

第三个问题是账本怎么保持一致。一个去中心系统里,每个人手里都有一份账本副本,难免出现分叉或不同步。于是大家约定:只认最长的链。这条准则看起来简单,实际上就是所有共识协议的地基。

1.2 最小原型的边界:先做一个诚实的单机节点

我在第一版里故意没有碰P2P网络,也没有做数字签名,甚至没有做真正意义上的钱包地址。为什么?因为对一个新手来说,单机版已经足够理解“区块+哈希+工作量证明”这条主线了。P2P是让这条链“跑”起来的东西,但如果你连链本身都没有搭好,谈网络同步就是空中楼阁。

所以我建议你第一步只做一个诚实节点的本地账本。所谓诚实,就是默认我们写的代码不会故意作恶,只是按照规则生成区块、验证区块。等单机版本跑通了,再考虑多节点、广播、冲突解决,路会顺很多。

1.3 技术选型:为什么用Python

选Python并不是因为它性能最好。恰恰相反,它性能一般,但对新手来说可读性极高,而且标准库就带了hashlib和json,不需要装任何第三方依赖。用Python写出来的代码,每一行都接近自然语言,你能一眼看出“这个字段是前一个区块的哈希”“这个循环是在找符合条件的nonce”。

我记得某技术社区有人用C++写了一个迷你区块链,代码里全是指针和内存管理,虽然性能好,但新手很容易被底层细节带偏。等你用Python把概念完全理解透了,再去看C++或Go的工程实现,是加分项。

2. 区块的数据结构:一个“自带防篡改标记”的容器

区块链里的“区块”本质上就是一个数据结构。你可以把它想成一个带封条的快递盒,盒子里装着交易数据,封条上的编号由盒子内容计算出来。如果有人拆开盒子改了内容,封条编号必然对不上。

2.1 一个最小区块需要哪些字段

我在第一版里只用了五个字段:

  • index:区块在链上的序号,一般创世区块是0
  • timestamp:区块生成时间,用Unix时间戳
  • transactions:交易列表,先随便放字符串,后面再结构化
  • previous_hash:前一个区块的完整哈希,这是形成链条的关键
  • nonce:工作量证明用的随机数,后面细说

还有一个字段叫hash,它是由前面所有字段联合计算出来的当前区块哈希。这里有一个很重要的设计决策:hash不能由用户随便赋值,它必须永远等于“用当前字段重新计算出的值”,否则整个验证逻辑就崩了。

2.2 用Python实现Block类

import hashlib import json import time class Block: def __init__(self, index, transactions, previous_hash, nonce=0): self.index = index self.transactions = transactions self.timestamp = time.time() self.previous_hash = previous_hash self.nonce = nonce self.hash = self.compute_hash() def compute_hash(self): block_string = json.dumps({ "index": self.index, "transactions": self.transactions, "timestamp": self.timestamp, "previous_hash": self.previous_hash, "nonce": self.nonce }, sort_keys=True).encode() return hashlib.sha256(block_string).hexdigest()

注意json.dumps里的sort_keys=True,这非常关键。如果字典的键顺序不稳定,同一份数据可能生成不同的字符串,哈希也就对不上。我在第一次调试时没加这个参数,Python 3.6之前字典还是无序的,同一个区块竟然算出了两个哈希,排查了半天才发现是序列化顺序问题。

2.3 哈希字段为什么是“自我引用”

很多新手会问:self.hash计算的时候并没有包含self.hash,那这个哈希到底是不是区块的一部分?答案是:哈希是“结果”,不是“输入”。它就像一个人的身份证号,是由出生日期、姓名、地点等信息算出来的标识,而不是这些信息的一部分。

这样就会形成一个有趣的约束:区块里任何字段发生变化,compute_hash()的结果就会立刻不同。所以当我说“一个区块是自校验的”,意思就是“它的完整性由它自己的内容决定,不需要依赖外部数据库”。

3. 哈希链接:验证整条链完整性的核心逻辑

区块本身有了自校验能力,但一条链光靠单个区块还不够。你必须把后一个区块的previous_hash指向前一个区块的hash,这样所有区块才形成一个“一荣俱荣、一损俱损”的链条。

3.1 哈希链接的生活类比

想象一条拉链,每个齿都咬合上一个齿。如果有人想改第三块布的颜色,那他必须同时改第三块布上的齿形状,还要改第四块布上的咬合口,再改第五块、第六块……改到最后,整条拉链都要重新做。

哈希链接比这个更强。因为previous_hash是“前一个区块的全部内容”的指纹,所以哪怕你只把第二个区块里的一个字符串从“hello”改成“hello!”,第三个区块里记录的previous_hash就会失效。这就像你改了昨天账本里的数字,今天账本封面的指纹立刻对不上一样。

3.2 链的校验算法

有了区块类和哈希链接,我们还需要一个方法来从头到尾验证整条链是否有效。我写了一个很朴素的版本:

class Blockchain: def __init__(self, difficulty=4): self.chain = [] self.difficulty = difficulty self.pending_transactions = [] self.create_genesis_block() def create_genesis_block(self): genesis_block = Block(0, [], "0") genesis_block = self.mine_block(genesis_block) self.chain.append(genesis_block) def mine_block(self, block): target = "0" * self.difficulty while not block.hash.startswith(target): block.nonce += 1 block.hash = block.compute_hash() return block def is_chain_valid(self): for i in range(1, len(self.chain)): current = self.chain[i] previous = self.chain[i - 1] if current.previous_hash != previous.hash: print(f"区块 {i} 的 previous_hash 与前一区块哈希不一致") return False if current.hash != current.compute_hash(): print(f"区块 {i} 的内容被修改,哈希不匹配") return False target = "0" * self.difficulty if not current.hash.startswith(target): print(f"区块 {i} 没有满足工作量证明要求") return False return True

这里最需要理解的环节是循环里的第一个判断:current.previous_hash != previous.hash。它把“链条是否连续”这个问题,简化成了“前后两个字符串是否相等”的问题。

3.3 改一个区块会发生什么

你可以自己做个实验:在测试代码里先把第二个区块的交易列表改掉,然后调用完整校验。这时你会得到两个错误:

  • 被改区块自身的哈希和重新计算的结果对不上
  • 它后面所有区块的previous_hash也都对不上

也就是说,篡改不能“只改一个地方”。每改一个区块,你就必须重新计算从那个位置到链尾的所有区块,而且每个新区块都要再次满足工作量证明条件。这可比改一行的数据库记录难多了。

4. 工作量证明:挖矿不是碰运气,是可控的成本

很多新手第一次听到“挖矿”时,第一反应是“靠运气撞大运”。其实工作量证明的数学逻辑非常朴素:找一个字符串,让它的SHA-256哈希值以若干个0开头。这个计算过程没有捷径,只能逐个试,但验证结果却非常快。

4.1 为什么区块不是想加就能加

如果没有工作量证明,任何人都可以毫秒级生成大量区块,把账本撑爆,或者故意构造一条超长链来覆盖其他人的记录。所以必须给“生成区块”这件事加上成本。成本越高,攻击者越难通过伪造大量区块来操纵系统。

在比特币场景里,这个成本就是算力;在学术和工程模拟里,这个成本就是CPU时间。不管哪种,目的都是一样的:让“记账权”变得稀缺,让诚实节点有时间对账。

4.2 难度与目标字符串

难度是一个整数,一般用difficulty表示。如果difficulty = 4,目标就是让哈希的前4个字符全是'0'。由于SHA-256哈希值是十六进制字符串,每一位有16种可能,所以单次尝试满足条件的概率是 1/16^4,也就是大约 1/65536。

这意味着平均要试六万多次,才能找到一个符合条件的nonce。难度每增加1,找到合法哈希的期望尝试次数就变成原来的16倍,成本也是16倍。这是一个指数增长的过程,也是“工作量”的真实含义。

4.3 proof_of_work实现与演示

我上一节代码里已经有mine_block方法了,核心逻辑就是循环递增 nonce 并重新计算哈希:

def mine_block(self, block): target = "0" * self.difficulty while not block.hash.startswith(target): block.nonce += 1 block.hash = block.compute_hash() return block

有个细节新手很容易忽略:block.nonce += 1改变了区块内容,所以哈希必须重新调用compute_hash(),否则循环永远跳不出去。很多第一次实现的人会卡在这里,敲了半天发现 nonce 一直在涨,哈希却纹丝不动。

我在测试difficulty=4时,挖一个空区块大概是几万次计算,在我电脑上要卡一两秒。如果你用的是老旧笔记本,调到5可能就要等几十秒,这恰恰能让你直观感受到难度增长的恐怖。

4.4 关于难度调整的一点补充

真实世界的区块链并不是固定难度,而是每隔一段时间根据全网算力动态调整。目标很简单:让出块时间保持在一个稳定区间。比如如果你期望每10分钟出一个块,但实测平均只用了5分钟,那就说明难度太低,要把难度调高。

项目原型阶段不需要做这么复杂的调参。但你要明白,难度不是随意设置的,它应该和“你的实验环境能承受的计算量”匹配。调到太大,你写测试时每次都会卡住;调到太小,又看不到计算的成本感。新手阶段我建议从4开始。

5. 交易与余额:把最小闭环补完整

到这一步,我们已经有了一个能挖矿、能校验的单机链。但如果你让它处理真实业务,会发现它只支持“随便塞字符串”,这连最小账本都算不上。接下来要做的,是让区块内部长出自洽的交易结构。

5.1 交易结构:从字符串到字段

最简单的交易至少要有发送方、接收方、金额三个字段。我经常用四个字段:

  • from:付款方地址
  • to:收款方地址
  • amount:转账金额
  • signature:签名,先占位

真实系统里地址是公钥的哈希,签名由私钥生成。但新手项目如果逼着自己实现非对称加密,代码复杂度会瞬间上来。我更推荐你第一版先用普通字符串“alice”“bob”当地址,签名留空,等主链路跑通之后再补RS256或者椭圆曲线签名。

def add_transaction(self, from_addr, to_addr, amount): # 第一版不做签名验证,只做余额检查 if self.get_balance(from_addr) < amount: print("余额不足,交易被拒绝") return False self.pending_transactions.append({ "from": from_addr, "to": to_addr, "amount": amount, }) return True

5.2 余额检查与UTXO的伏笔

余额怎么算?最直接的想法是扫描整条链上所有和from_addr相关的交易,把收到的加总,把发出的减掉。如果你默认系统初始给每个人发了一笔钱,那还得在创世区块里写好初始分配。

def get_balance(self, address): balance = 0 for block in self.chain: for tx in block.transactions: if tx["from"] == address: balance -= tx["amount"] if tx["to"] == address: balance += tx["amount"] return balance

这种“根据历史交易汇总余额”的方式非常直观,但真实区块链里为了效率,往往采用UTXO模型。简单理解,UTXO不存“我账户有多少钱”,而是存“我还拥有哪些未花费的产出”。你付钱时不是“余额减一”,而是“把我手里的几个未花费产出标记为已花,并生成新的产出”。

新手第一次能跑通“余额遍历”就够了。UTXO是性能优化,也是防止双花的技术细节,放在单机项目里学有点超前。

5.3 新交易如何打包进新区块

有了pending_transactions之后,挖矿方法就不能只做一个空区块。正确的步骤是:

  1. 把你本地缓存的所有待处理交易复制出来
  2. 创建一个新区块,transactions字段填这些交易
  3. 新区块的previous_hash指向当前链最后一个区块的哈希
  4. 执行工作量证明
  5. 校验通过后,把区块追加到链尾
  6. 清空待处理交易

这样每一笔交易都要经过“打包进区块 + 挖矿 + 链校验”,才算真正被账本确认。如果你只是把交易放在内存里,没有写进区块,那它随时可能丢,这在项目里是很严重的隐患。

6. 从单机链到多节点网络:共识才是区块链的灵魂

单机版跑通后,你可能会产生一个错觉:这不就是一个带哈希校验的List吗?别急着下结论,真正让区块链从“特殊数据结构”变成“区块链”的,是多节点之间的共识机制。

6.1 为什么只有一条链不算区块链

人的记忆是靠对比纠错的。如果全世界只有一个账本,物理机一坏,账本原地蒸发。更重要的是,如果只有一个记账者,他完全可以偷偷修改旧记录而没有旁观者发现。

所以分布式是必要条件。每个节点都保存一份完整的账本副本,任何节点对账本的修改都必须通过广播让其他节点知道。你说的“链”,只有在“多个无关的机器都认可同一条链”这个前提下,才配叫区块链。

6.2 P2P同步与最长链规则

最简单的多节点同步可以这样做:每个节点启动一个HTTP接口,允许其他节点拉取自己的链;当一个节点挖出新区块,就把区块广播给所有已知节点;收到区块的节点,先验证再决定要不要替换本地链。

这里有个经典规则,也是最容易被新手误解的:出现分叉时,各节点不会比对谁“更像官方”,而是直接选总工作量最大的链。深度上就体现为最长链。为什么最长链就一定可信?因为它经过了最多的哈希计算,作恶者要追上这条链,必须付出高于全网的支持算力,这在大型网络里几乎不可能。

6.3 共识不是投票,是矿工遵守的协议

新手经常把共识理解成“大家投票表决”。实际上,大多数公链的共识并没有“投票”这么有仪式感,它更像“大家默认遵守同一套出块和校验规则”。区块是否合法由规则决定,而不是由社区表态决定。

具体到代码层面,共识可以精简成三条约定:

  • 所有节点使用同样的区块结构
  • 所有节点用同样的方式验证哈希和难度
  • 所有节点在出现冲突时,选择最长链

只要这三条一致,哪怕节点彼此讨厌,也能保持账本最终一致。这就是共识的奇妙之处:它不要求大家“互相信任”,只要求大家“遵守同一套校验规则”。

7. 新手最常见的四个坑,以及我的三个调试习惯

最后分享一些我自己踩过的坑。这些东西在教科书和源码里不容易看到,但对新手来说非常关键。

7.1 坑一:误以为区块链就是普通链表

链表强调的是“地址顺序”,区块链强调的则是“哈希引用”。普通链表里,你把某个节点的指针改了,后面的节点还能找到;区块链里,只要你改了一个区块的内容,后面所有区块的校验立即失败。这不是一个可选的加固,而是不可分割的设计。

我见过有人把区块类写成了类似“自定义Node”的结构,里面没有previous_hash,只靠一个next指针连接。这种结构就算哈希算得再漂亮,也只是一个封装过的链表,不是区块链。

7.2 坑二:把哈希当成加密

哈希和加密的本质区别在于:加密是可逆的,哈希是不可逆的。SHA-256就像指纹采集器,输入任意数据都能得到一个固定长度的摘要,但你几乎不可能从摘要反推出原始数据。

这个区别很重要,因为很多新手会以为“哈希越复杂,就是加密越安全”。实际上密码学里加密的目标是保密性,哈希的目标是完整性。你在区块链里需要的是“别人改了内容你立刻知道”,而不是“把内容藏起来不让人看到”,所以核心用的是哈希而不是加密。

7.3 坑三:创世区块没有做特殊处理

创世区块是整条链的祖先,它的previous_hash没有真实的前一区块。我见过两个处理方式:

  • 直接设置previous_hash = "0",最简单,我推荐
  • 设置成一个随机字符串,比如genesis,也行

最怕的是有人把创世区块的previous_hash设置成空字符串,然后在compute_hash()里序列化时出现了两个不同的结果。规范做法就是固定写一个非空字符串,并且把它写进生成逻辑里,不要靠输入参数去猜。

7.4 坑四:难度设置太高或太低

我在测试时一开始把难度设为6,结果每次挖矿要等一分钟,测试节奏全被打乱;后来又改成0,所有区块都瞬间生成,完全感受不到工作量证明的作用。

新区块建议从4开始,如果你的电脑比较弱可以降到3。核心原则是:既要让你感受到“挖矿需要等待”,又不要让每次测试都卡到让人想放弃。等你确认整条链路逻辑无误后,再临时调高标准,去测试难度对出块时间的影响。

7.5 三个调试习惯

如果你也卡在某个报错上,不妨试试我的三个习惯,它们帮我节省了大量时间。

第一个习惯是打印区块哈希和nonce变化。挖矿循环跑很久没结束时,看一眼nonce是不是在涨、哈希的前缀有没有变化,能立刻判断出是难度太大还是代码死循环。

第二个习惯是单独写一个verify_hash()函数,只比较“当前字段重新计算出来的哈希”和“区块上保存的哈希”。这个函数越简单越好,因为它是所有校验的基础。万一整条链验证失败,你先把验证函数单独测一遍,能排除掉逻辑帮凶。

第三个习惯是每次改完数据结构,都重新生成创世区块和整条链,而不是复用旧的持久化数据。区块结构一变,旧哈希自然全旧失效,没有最糟糕的痛苦是面对一堆过期哈希还以为是新代码出了bug。

我已经用这套方法带着几个完全没接触过区块链的朋友跑通过最小原型。他们最后都会感叹,原来一直觉得“好高级、好神秘”的东西,拆开之后也就是“哈希加链表加成本控制”的组合。接下来你想往哪个方向深入,就要看你的应用场景了。如果你对智能合约或者权限控制感兴趣,我后面可以再单独写一篇从共识层往上搭建的完整思路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进