夜雨聆风学习资料网

ARTICLE · 1162282

比特币源码精读——UTXO模型与Coin结构设计

比特币源码精读——UTXO模型与Coin结构设计

比特币 UTXO 模型源码解析:为什么没有「余额」

从 Coin 结构的 31 位高度字段,看一套支撑全网运行二十年的账本设计

比特币源码精读 · 第 02 期

💡 你将学到:UTXO 与账户模型的根本差异、Coin 结构体的位域压缩技巧、UTXO 集合"只增不存"的特性

⏱ 阅读时间:约 10 分钟

01链上不存在「余额」

打开任何一个比特币区块浏览器,随便点进一个地址,你会看到一个数字:这个地址的余额。这个数字看起来像是链上存着的一个字段,随时可读。

但如果你去源码里找它,会发现找不到。Bitcoin Core 里没有任何一个结构体叫「余额」,也没有任何一个 map 把地址映射到金额。

链上真实存着的,是一堆未花费的交易输出(Unspent Transaction Output)。所谓「余额」,是节点在查询时把所有属于该地址的未花费输出加总出来的结果——它不是一个被存储的数据,而是一个被计算出来的数据。

这个差异不是实现上的偷懒,而是一个 foundational 的架构选择。要理解它,得先看清 UTXO 在内存里到底长什么样。

02Coin 结构体:把两个标志塞进一个整数

UTXO 集合里存的基本单位叫 Coin。我们直接看 src/coins.h。

src/coins.h — Coin 结构体定义(真实源码)

classCoin

{

public:

//! unspent transaction output

CTxOut out;

//! whether containing transaction was a coinbase

bool fCoinBase :1;

//! at which height this containing transaction was included

uint32_t nHeight :31;

Coin(constCTxOut& outIn, int nHeightIn, bool fCoinBaseIn)

:out(outIn),fCoinBase(fCoinBaseIn),nHeight(nHeightIn){}

};

先注意 out:它就是前文提到过的 CTxOut,装着金额和锁定脚本。这部分是必须的,不用多说。

真正有意思的是后面这两个字段——它们都是位域(bit-field),这是一个在内存优化里很常见、但很少在业务代码里见到的写法。

字段

声明

实际占用

含义

fCoinBase

bool fCoinBase : 1

1 个二进制位

这笔输出是否来自矿工打包的 coinbase 交易

nHeight

uint32_t nHeight : 31

31 个二进制位

这笔输出在第几个区块被确认(上限约 21 亿)

bool 在多数平台上占 1 个字节(8 位),这里只用了 1 位,省下 7 位。uint32_t 本来占 32 位,这里砍到 31 位,省下 1 位。加起来 8 位——整整一个字节。

为什么要省这一个字节?因为 UTXO 集合里的 Coin 数量级极大。主网当前有上亿个未花费输出,每一个都省一个字节,乘起来就是几百 MB 的磁盘和内存。这不是理论上的洁癖,是实打实的资源。

更有意思的是为什么要留那1 位的 fCoinBase。因为 coinbase 交易是仅有的一种可以凭空创建新币的交易,它的输出不能像普通输出那样被立即花费——必须等 100 个区块确认(也就是约 16.7 小时),这笔钱才算成熟。如果不标记这个字段,节点就得反查这笔输出来自哪个交易、那个交易是不是 coinbase,多一次数据库查询。存成 1 位,省下的是成千上万次磁盘 IO。

共识与存储的分工注意 nHeight 用的是 31 位。如果比特币活到第 21 亿个区块,这个字段会溢出。这个上限远在现实之内,但它提醒你一件事——UTXO 集合是有容量边界的,而账户模型没有。这也是比特币必须做 UTXO 压缩(TxOutCompression)的原因。

03序列化:一个整数装下两个字段

内存里省了字节,存到磁盘上还得省。Coin 的序列化代码把这两个字段又打包了一次。

src/coins.h — 打包与解包

template<typename Stream>

voidSerialize(Stream &s)const{

assert(!IsSpent());

uint32_t code{(uint32_t{nHeight}<<1)|uint32_t{fCoinBase}};

::Serialize(s, VARINT(code));

::Serialize(s, Using<TxOutCompression>(out));

}

template<typename Stream>

voidUnserialize(Stream &s){

uint32_t code =0;

::Unserialize(s, VARINT(code));

nHeight= code >>1;// 右移一位拿回高度

fCoinBase= code &1;// 与 1 取 AND 拿回标志

::Unserialize(s, Using<TxOutCompression>(out));

}

核心是这一行:把 31 位的高度左移一位,空出最低位,然后把 coinbase 标志塞进去。反序列化时对应地拆开:右移一位拿回高度,与 1 取 AND 拿回标志。

这是教科书级的位操作,但它还多了一层:VARINT(code)。比特币的变长整数编码——数值小的时候占用字节少,数值大的时候才多占。绝大多数 UTXO 是在近几年的区块里创建的,高度值都在几十万到几百万之间,VARINT 让这些数据比固定 4 字节省了将近一半。

注释里那句 assert(!IsSpent()) 也值得注意:Coin 只有在未花费状态下才会被序列化。已花费的 UTXO 会被直接从集合里删除,不存在「记录它曾经被花掉」这个步骤。这条设计决定了 UTXO 集合的体积会随着时间推移先涨后稳——被花掉的都清掉了,只留下还没被用掉的。

04UTXO 集合的三条关键性质

把上面的实现串起来,UTXO 集合有三条性质,每一条都是对比账户模型的胜负手。对比着看会更容易理解为什么比特币选择了这条不太直觉的路。

先说最容易被误解的一点。很多人以为 UTXO 的优势是「更省空间」或者「更快」,其实都不是—— UTXO 在这两个指标上并不必然优于账户模型。它真正的优势在于验证的严谨性:全节点必须持有完整集合,任何数据缺失都直接导致验证能力失效。这个「要么全对、要么全废」的特性,恰恰是资产类系统最需要的性质。

性质一:没有「创建」,只有「添加」和「删除」。UTXO 集合只有两个基本操作——AddCoin(写入交易输出时)和 SpendCoin(花费时)。没有 SetBalance,没有 Transfer。移动一笔钱在 UTXO 世界里不是「改个数字」,而是「删掉旧 UTXO + 加上新 UTXO」。整个系统里没有「转账」这个概念,只有 UTXO 的生灭。

这个设计带来一个有意思的推论:一笔资金的「身份」其实是一串哈希指针。比如A 花掉了一个 UTXO,这个 UTXO 就永远消失了;而 A 得到的新 UTXO,是一批全新数据结构的记录。资金在 UTXO 模型里不像账户模型那样「一直在同一个账户里加加减减」,而是不断被销毁又重新创建。这也解释了为什么 UTXO 会有「碎片化」问题——每次找零都会产生新的 UTXO,金额越来越零碎,最终手续费占比越来越高。

src/coins.h — 集合只有增删两个接口

/**!

* Add an entry to the cache. If the coin already exists in the cache,

* the entry is overwritten.

*/

voidAddCoin(constCOutPoint& outpoint, Coin&& coin, bool possible_overwrite);

/**!

* Spend a COutPoint and remove it from the cache.

*/

boolSpendCoin(constCOutPoint&outpoint, Coin* moveto =nullptr);

性质二:全节点必须存下所有未花费输出,且必须全量校验。要验证一笔新交易是否合法,节点必须能回答两个问题:这个输入指向的 UTXO 存在吗?它被花掉了吗(双花检查)?这要求每个全节点持有完整的 UTXO 集合。任何一个节点数据不完整,就无法独立验证区块。

而账户模型下,验证交易只需要访问涉及的那几个账户的状态,不必持有全网所有账户的完整历史。这个差异在实践中的表现是:UTXO 节点的验证成本随「当前活跃 UTXO 数量」增长,而账户节点的验证成本随「被触及的账户数」增长。前者对普通用户来说是可以承受的(主网 UTXO 集合约 1 亿条,磁盘占用几个 GB),后者在账户数量爆炸时会是灾难。

还有一层含义:双花检测的成本。UTXO 模型的防双花靠的是「这个 outpoint 在集合里吗」,一次哈希表查询就够。而账户模型要防止的是「同一笔余额被花两次」,这需要节点维护更复杂的状态转移检查,且必须保证所有节点对「余额」的计算完全一致——任何一个顺序差异都可能导致共识分裂。

性质三:状态空间与历史长度解耦。这一点是最容易被低估的。UTXO 集合的规模取决于当前还没被花掉的钱,而不是链跑了多久。链上跑了 80 万个区块、经过了无数次转账,但 UTXO 集合的规模大致稳定在「总流通量」的量级。

这个性质的长期意义可能比前两条都大。设想一条运行了五十年的链:如果状态规模随历史线性增长,那么五十年后新节点同步需要的时间和存储都是不可接受的。而 UTXO 的这个特性意味着再过几十年,同步一份完整链的成本不会指数级增长——它会稳定在一个由「总流通量」决定的水位上。

给开发者的判断依据如果你在设计一个需要长期存活的链上系统,问自己一个问题——状态集合的规模是随历史线性增长,还是随当前活跃量增长?后者几乎总是更可持续。这一点在比特币上线时还不是共识,是被二十年的运行验证出来的。

05回到那个查询

现在可以回答开头的问题了。当区块浏览器显示一个地址的「余额」时,底层发生的事大致是:扫描 UTXO 集合,找出所有 scriptPubKey 属于该地址的 Coin;把它们的 out.nValue 加总;结果就是显示的金额。

这意味着:「余额」不是一个可以脱离其他数据独立存在的事实。如果 UTXO 集合里的某个 Coin 因为数据库损坏丢失了,或者某个节点同步到了不同的 UTXO 状态,余额就会算错。而账户模型下,余额是一个独立的键值对,丢了就是丢了,范围更小。

所以 UTXO 模型对节点的数据完整性要求更高——它必须是对的,而且必须完整。这也是为什么 Bitcoin Core 在启动时要花大量精力做数据库校验、磁盘空间检查、失败自动重建索引。这些在前文的启动流程里都能看到对应代码。

06UTXO 压缩:把脚本前缀也省掉

前面提到了 TxOutCompression。UTXO 里的 scriptPubKey 通常是 P2PKH 或 P2SH 脚本,这两类脚本有固定的前缀格式且高度重复。压缩的思路是:识别出常见前缀,用一个字节的「类型标记」代替整段脚本,不匹配的才存完整内容。

这属于纯粹的实现优化,对共识规则没有影响——反序列化出来的 CTxOut 完全一致,只是磁盘占用和校验速度的差别。Bitcoin Core 里这个优化是显式写在序列化层的,Coin 序列化里的 Using(out) 就是启用它。

值得一提的是,Bitcoin Core 早期的 UTXO 压缩(把输出金额也编码进去)曾经因为极端情况下的多义性引发过一次共识分歧,后来被修正为「压缩只影响存储,不影响共识」的形态。这段历史值得记住的教训是:任何参与共识的编码方式,都必须保证「序列化后反序列化再序列化」是幂等的,否则数据库里同一笔数据可能存在两种合法表示,最终导致节点间对同一区块算出不同的状态根。

07写在最后

UTXO 模型常被简化成「比特币用 UTXO,以太坊用账户」这样一句话。但真正值得记住的是为什么选 UTXO。

· 状态可验证性:全节点必须完整持有 UTXO 集合,任何数据缺失都会让验证能力失效。这是把数据完整性要求推到了顶点的设计。

· 空间效率:位域压缩 + VARINT + 脚本压缩,Coin 结构省下的每一个字节都在上亿条记录上被放大。

· 历史解耦:状态规模取决于当前活跃 UTXO 数量,而非链的长度,这是长期可持续性的关键。

而 UTXO 的代价也很明确:它不支持「部分花费」。你不能从一个 10 BTC 的输出里花出 3 BTC 而找零——输出是原子的。要实现部分转账,必须构造新交易把剩余部分打回自己的地址。链上常见的「找零地址」机制、UTXO 碎片化带来的手续费上升,都是这个特性的直接后果。

下一期我们进区块结构本身:CBlock 里到底装了哪些字段,以及默克尔树是怎么用 O(log n) 的代价让轻节点也能验证「我的交易在不在这个区块里」。

本文代码说明代码取自 Bitcoin Core master 分支,文件路径见各节标注。

本文由「区块链编程」原创出品

未经授权,禁止转载

如有转载需求,请联系作者

👉 关注「区块链编程」

关注我,解锁更多可能

相关学习资料