我们已经分析过了全节点处理单笔交易(loose transaction)的详细流程,这篇文章将分析全节点收到一个区块后的处理流程,内容包括如何验证这个区块,如何更新本地的区块链账本。
注1:本文的分析以中本聪的代码v0.1.15版本为蓝本,节点指代为全节点。
注2:如果对比特币区块的基本知识还不清楚,请先阅读MasteringBitcoin这本书的相应章节,此书中文版叫做《精通比特币》。
注3:文中提到的关键步骤会贴出相应源码,非关键部分请参考流程图自行去看源码
1. 区块链存储方式
我们知道,比特币的精髓是去中心化,用白话讲就是人人记账,当然这里的人人指代的是全节点。每个全节点都会在本地维护一个区块链账本的副本。有人会问,区块链的主账本存储在哪?其实并没有主账本,各节点通过遵守比特币的共识机制,他们的账本是一致性收敛的。
那么区块链在本地是如何存储的?比特币使用了Berkeley Db数据库,这是一个文件型数据库,数据都是以键值对的形式存储在文件中的。区块的数据存储在blkxxxx.dat(x代表十进制数字)里,从文件的名字可以看出,需要大量的.dat文件存储区块数据。那么在账本中查找一笔交易会不会很慢?并不会,节点会维护一个名叫blkindex.dat的文件,这个文件就像一个地图一样,详细的记录了交易在账本中的位置。除了交易信息,blkindex.data还保存区块索引、主链参数等信息。
举例说明blkindex.dat文件存储数据的格式:
- 交易位置 key: pair<'tx', 交易hash> value:位置信息
- 区块索引 key: pair<'blockindex', 区块hash> value:区块索引信息+前后块hash
- 主链参数 key: "hashBestChain" value:最高块hash
2. Berkeley数据库操作
Berkeley数据库提供了一套C语言的操作接口,中本聪用C++在上面封了一层。基类CDB提供了数据库的增删改查等基本操作,类CTxDB、CWallletDB等继承自CDB,完成一些特定场景的数据库操作。本文讨论节点处理区块的流程,只用到了CTxDB处理区块数据, 与之相对应的文件是blkindex.dat
下面简单介绍操作数据库的方式:
- 构造数据库操作对象:CTxDB txdb("r+")
- 写区块索引信息: txdb.WriteBlockIndex(blockindexPrev)
- 中止事务: txdb.TxnAbort()
- 提交事务: txdb.Commit()
注:写数据库事务并不会立即更新文件内容,提交事务才会**
3. 关键数据结构
3.1 关键类
首先是区块类,由区块头、交易集合、merkle树构成
class CBlock
{
public:
// header
int nVersion;
uint256 hashPrevBlock;
uint256 hashMerkleRoot;
unsigned int nTime;
unsigned int nBits;
unsigned int nNonce;
// network and disk
vector<CTransaction> vtx;
// memory only
mutable vector<uint256> vMerkleTree;
/*成员函数略*/
}
下来是区块索引类,可以看出这个类主要保存了区块的位置信息、与前后块的联系、区块头的信息。
注意:在内存中几乎都是在对区块索引操作,所以此类很重要
class CBlockIndex
{
public:
const uint256* phashBlock; //区块hash指针
CBlockIndex* pprev; //前向指针
CBlockIndex* pnext; //后向指针
unsigned int nFile; //第几个文件block*.dat
unsigned int nBlockPos; //在文件中的位置
int nHeight; //区块高度
// block header 区块头信息
int nVersion; //版本
uint256 hashMerkleRoot; //MerkleRoot哈希值
unsigned int nTime; //时间戳
unsigned int nBits; //难度
unsigned int nNonce; //随机值
/*成员函数略*/
}
3.2 关键全局变量
首先是区块索引集合,通过这个集合可以找到所有的区块索引信息,它相当于厚重的账本在内存中的轻量级映射
map<uint256, CBlockIndex*> mapBlockIndex; //key:区块hash value:区块索引指针
下面是孤块区, 我们看到,与孤儿交易池类似,map的value存的是指针,实际上孤块数据都是在堆中保存的。第二个map是multimap,因为区块链会发生发叉,一个父区块可能有多个子块。
map<uint256, CBlock*> mapOrphanBlocks; // key:hash value: 区块指针
multimap<uint256, CBlock*> mapOrphanBlocksByPrev; //key: 父区块 hash value:区块指针
4. 总体流程
下面分析节点处理区块的整体流程,先贴出整体流程图
接下来对重要的步骤进行说明
❖常规检查
常规检查是对块本身的合法性进行检查,并不涉及与其他块的联系,比较简单。主要包括以下几点
- 区块大小限制检查 (包括上限下限)
- 时间戳检查 (允许时间戳+本地时间2小时内的区块)
- 块中第一笔交易必须是coinbase交易,其余必须不是
- 块中每笔交易的常规检查 (处理交易流程提到)
- 工作量检查 (区块hash小于难度设定)
- merkle根检查 (构建merlele树,验证merkele根计算是否正确)
❖查找父区块
常规检查通过后,需要在mapBlockIndex中查找父区块,如果找不到,指针要被放到孤块区,然后节点会向块的来源发getblock命令,试图拿到这个块的父区块;如果能找到父区块才会调用AcceptBlock函数对区块进一步检查。此处贴出这个步骤的代码
// If don't already have its previous block, shunt it off to holding area until we get it
//在mapBlcokIndex中找不到前向区块,分流到孤块区
if (!mapBlockIndex.count(pblock->hashPrevBlock))
{
printf("ProcessBlock: ORPHAN BLOCK, prev=%s\n", pblock->hashPrevBlock.ToString().substr(0,14).c_str());
mapOrphanBlocks.insert(make_pair(hash, pblock));
mapOrphanBlocksByPrev.insert(make_pair(pblock->hashPrevBlock, pblock));
// Ask this guy to fill in what we're missing
//如果这个节点给我们发的是孤块,那么向这个节点发getblocks,试图获得丢掉的块
if (pfrom)
pfrom->PushMessage("getblocks", CBlockLocator(pindexBest), GetOrphanRoot(pblock));
//是孤块直接返回true
return true;
}
❖AcceptBlock
与验证交易的函数AcceptTransaction相似,AcceptBlock是个复杂的流程,会在后面单独展开。继续向下分析主流程
❖整理孤块区
如果区块通过AcceptBlock验证,接下来要进行孤块区的整理工作。与排查整理孤儿交易池的操作类似(还要简单一些),首先将区块hash放入集合vWorkQueue中,然后遍历这个集合,通过查mapOrphanBlocksByPrev获取以此块为父块的孤块集合,接着遍历孤块集合,对每个块都调用AcceptBlock函数,将验证通过的块的hash值继续放入vWorkQueue, 形成递归。
无论是否通过AcceptBlock验证,子块都己经不是孤块,需要从mapOrphanBlocks中删除。
遍历完孤块集合后,要在mapOrphanBlocksByPrev中把以父块为key的条目也删掉。流程图中下方的两个循环展示了这个流程,贴出此处代码
// Recursively process any orphan blocks that depended on this one
vector<uint256> vWorkQueue;
vWorkQueue.push_back(hash);
for (int i = 0; i < vWorkQueue.size(); i++)
{
uint256 hashPrev = vWorkQueue[i];
for (multimap<uint256, CBlock*>::iterator mi = mapOrphanBlocksByPrev.lower_bound(hashPrev);
mi != mapOrphanBlocksByPrev.upper_bound(hashPrev);
++mi)
{
CBlock* pblockOrphan = (*mi).second;
if (pblockOrphan->AcceptBlock())
vWorkQueue.push_back(pblockOrphan->GetHash());
mapOrphanBlocks.erase(pblockOrphan->GetHash());
delete pblockOrphan;
}
mapOrphanBlocksByPrev.erase(hashPrev);
}
5. AcceptBlock流程
下面分析AcceptBlock函数,先贴出流程图
接着对重要步骤作出说明
❖时间戳检查
我们看到AcceptBlock流程中除了重复块检查外其余的检查都和区块链的历史区块产生了联系,此处的时间戳检查会将此块的时间戳与过去11个块的时间戳中位数进行对比。举个通俗的例子作类比,如果过去11个块的时间戳中位数是12点,新块的时间戳是11点59,那么新块会被拒绝,如果新块的时间戳是12点01,新块会被接受,贴出代码
// Check timestamp against prev 比较时间戳与前11个区块中位数
if (nTime <= pindexPrev->GetMedianTimePast())
return error("AcceptBlock() : block's timestamp is too early");
❖难度检查
难度检查会以过去2016个块的难度为基准推算下一个块的难度,要检查新块的难度是否等于推算的难度。
// Check proof of work 从前一个区块推,检查难度对不对
if (nBits != GetNextWorkRequired(pindexPrev))
return error("AcceptBlock() : incorrect proof of work");
❖区块写入硬盘
接下来检查硬盘空间是否充足,然后把区块写入硬盘,此处有两个传出参数,记录区块的位置。
注:区块数据是直接被附加到最新的blkxxxx.dat文件中的,不走数据库事务
// Write block to history file 检查硬盘空间是否充足
if (!CheckDiskSpace(::GetSerializeSize(*this, SER_DISK)))
return error("AcceptBlock() : out of disk space");
unsigned int nFile; //第几个文件 blk000x.dat
unsigned int nBlockPos; //文件中的位置
//写入硬盘, nFile和nBlockPos是传出参数
if (!WriteToDisk(!fClient, nFile, nBlockPos))
return error("AcceptBlock() : WriteToDisk failed");
❖AddToBlockIndex
接下来又是关键的一步,区块已经写入硬盘,位置信息作为参数传入AddToBlockIndex函数。从函数名就可以看出,要对mapBlockIndex进行操作了,实际上没那么简单,在这个函数里会对区块继续做一系列的检查,先不展开分析,继续向下分析流程图。
❖转播区块
如果AddToBlockIndex也通过了,会判断这个区块是不是在主链上,如果在就会向其他节点转播这个区块
if (hashBestChain == hash)
RelayInventory(CInv(MSG_BLOCK, hash));
注意:区块链会发生分叉,产生主链和备用链,共识机制会认可难度最大的链,通常就是长度最长的链。
6. AddToBlockIndex流程
下面分析AddToBlockIndex函,先贴出流程图
接下来解释关键步骤
❖更新mapBlockIndex
上一步获取了新区块的位置,作为参数传了进来,接下来构造出新的区块索引对象,将键值对插入到mapBlockIndex中,然后找到父区块,新区块索引前向指针指向父区块索引,然后高度+1。用文字描述比较拗口,贴出代码
// 根据这个块生成新的区块索引
CBlockIndex* pindexNew = new CBlockIndex(nFile, nBlockPos, *this);
if (!pindexNew)
return error("AddToBlockIndex() : new CBlockIndex failed");
//新的区块索引插入mapBlockIndex中
map<uint256, CBlockIndex*>::iterator mi = mapBlockIndex.insert(make_pair(hash, pindexNew)).first;
pindexNew->phashBlock = &((*mi).first);
//mapBlockIndex中查找父区块索引
map<uint256, CBlockIndex*>::iterator miPrev = mapBlockIndex.find(hashPrevBlock);
//新区块索引前向指针指向父块,高度 = 父区块高度 + 1
if (miPrev != mapBlockIndex.end())
{
pindexNew->pprev = (*miPrev).second;
pindexNew->nHeight = pindexNew->pprev->nHeight + 1;
}
❖新区块索引信息写入数据库
注意,此时只是写数据库事务,还没有commit,数据还未真正写入硬盘。区块索引在blkindex.dat文件中的保存形式在文中已经提到过。
//写入blkindex.data
CTxDB txdb;
txdb.TxnBegin();
txdb.WriteBlockIndex(CDiskBlockIndex(pindexNew));
❖判断区块高度是否创新高
如果区块高度没创新高,说明此块在一条短链上,暂时不需要对这个区块做其他检查了,待到这条链“逆袭”成为主链时再对这个块做其余的检查也不迟,数据库直接commit,流程就结束了。
如果区块高度创了新高,说明此块一定在主链上,至于此块在原主链上还是在新主链上还需要判断父区块在不在主链上。
❖检查输入ConnectBlock
如果父区块在主链上,接下来要对新块执行ConnectBlock函数,检查输入。ConnectBlock函数相对来讲流程较少,直接贴出流程图
我们看到ConnectBlock函数遍历了块中的交易,对每笔交易执行了ConnectInputs函数,ConectInputs函数的流程在分析交易处理的文章中已经详细的分析过了。如果ConnectInputs验证通过,检查coinbase交易的金额是否正确,我们知道比特币的产量是递减的,大概每四年减半。接着写数据库事务,更新父区块的索引信息(就是更新后向区块的hash值),然后将块中交易同本节点有关(发给本节点的)的加入钱包,贴出ConnectBlock源码
bool CBlock::ConnectBlock(CTxDB& txdb, CBlockIndex* pindex)
{
//// issue here: it doesn't know the version
//交易的位置
unsigned int nTxPos = pindex->nBlockPos + ::GetSerializeSize(CBlock(), SER_DISK) - 1 + GetSizeOfCompactSize(vtx.size());
map<uint256, CTxIndex> mapUnused;
int64 nFees = 0;
foreach(CTransaction& tx, vtx)
{
CDiskTxPos posThisTx(pindex->nFile, pindex->nBlockPos, nTxPos);
nTxPos += ::GetSerializeSize(tx, SER_DISK);
//对块中每一笔交易进行ConnectInputs验证
if (!tx.ConnectInputs(txdb, mapUnused, posThisTx, pindex->nHeight, nFees, true, false))
return false;
}
if (vtx[0].GetValueOut() > GetBlockValue(nFees))
return false;
// Update block index on disk without changing it in memory.
// The memory index structure will be changed after the db commits.
//写数据库事务,更新父区块索引信息
if (pindex->pprev)
{
CDiskBlockIndex blockindexPrev(pindex->pprev);
blockindexPrev.hashNext = pindex->GetBlockHash();
txdb.WriteBlockIndex(blockindexPrev);
}
// Watch for transactions paying to me
//如果块中中交易是给自己的,加入钱包
foreach(CTransaction& tx, vtx)
AddToWalletIfMine(tx, this);
return true;
}
❖ConnectBlock返回True
如果ConnectBlock函数返回True,对区块的所有检查都结束了,仅剩下一些收尾工作。流程图已经标注的很清晰了。
❖ConnectBlock返回False
如果ConnectBlock函数返回False, 区块的验证失败,需要从硬盘将区块数据删除,从mapBlockIndex中删除这个区块索引。
if (!ConnectBlock(txdb, pindexNew) || !txdb.WriteHashBestChain(hash))
{
txdb.TxnAbort();
pindexNew->EraseBlockFromDisk();
mapBlockIndex.erase(pindexNew->GetBlockHash());
delete pindexNew;
return error("AddToBlockIndex() : ConnectBlock failed");
}
7. 重整主链(Reorganize)
在AddToBlockIndex的流程图中,我们只分析了父区块在主链上这一种情况。如果父区块不在主链上,此时区块高度还创了新高,那么只有一种情况,就是备用链实现了“逆袭”,成为了主链,这是需要调用Reorganize函数,重整主链。这块内容可以结合主链维护算法那篇文章一起看。贴出Reorganize函数流程图
下面对重要步骤作出解释
❖回退区块指针至分叉点
就是将新链和旧链指针回退,直到找到分叉的位置。
// Find the fork
CBlockIndex* pfork = pindexBest;
CBlockIndex* plonger = pindexNew;
while (pfork != plonger)
{
//pfork从pindexBest回退一步,找到分叉的位置
if (!(pfork = pfork->pprev))
return error("Reorganize() : pfork->pprev is null");
//plonger从另一个叉也回退到分叉的位置
while (plonger->nHeight > pfork->nHeight)
if (!(plonger = plonger->pprev))
return error("Reorganize() : plonger->pprev is null");
}
❖三个集合vConnect、vDisconnect、vResurrect
- vConnect: 从分叉点开始算,存储新杈上的区块索引
- vDisconnect: 从分叉点开始算,存储旧杈上的区块索引
- vResurrent: 存储旧杈上的区块包含的除coinbase交易外的所有交易
// List of what to disconnect
//把原Bestchain从tip到分叉点的节点放入vDisconnect
vector<CBlockIndex*> vDisconnect;
for (CBlockIndex* pindex = pindexBest; pindex != pfork; pindex = pindex->pprev)
vDisconnect.push_back(pindex);
// List of what to connect
//从分叉点到新tip的节点放入vConnect
vector<CBlockIndex*> vConnect;
for (CBlockIndex* pindex = pindexNew; pindex != pfork; pindex = pindex->pprev)
vConnect.push_back(pindex);
reverse(vConnect.begin(), vConnect.end());
// Disconnect shorter branch
//原来的BestChain中,需要重新整理的那几个块的交易集合
vector<CTransaction> vResurrect;
❖遍历处理集合vDisconnect
这是一条即将失效的链,有很多工作要做。这里讲下主要流程,遍历这个集合,从硬盘中加载区块数据,然后对块中所有交易执行DisconnectInputs函数,这个函数会写数据库事务,将失效块中交易的父交易的输出花费标志位置null,然后保存。如果Reorganize函数执行失败了,数据库不会commit的,所以这里不必担心意外情况。接着把失效块中的交易除coinbase交易外放入vResurrent中
foreach(CBlockIndex* pindex, vDisconnect)
{
CBlock block;
if (!block.ReadFromDisk(pindex->nFile, pindex->nBlockPos, true))
return error("Reorganize() : ReadFromDisk for disconnect failed");
//此块中的交易数据需要断掉与之前Input数据的关联
if (!block.DisconnectBlock(txdb, pindex))
return error("Reorganize() : DisconnectBlock failed");
// Queue memory transactions to resurrect
//这些块中的交易,除了coinbase交易都放入vResurrent中
foreach(const CTransaction& tx, block.vtx)
if (!tx.IsCoinBase())
vResurrect.push_back(tx);
}
❖遍历处理集合vConnect
这是一条即将生效的链,也有很多工作要做。我们记得上文中提到过“待到这条链逆袭时再检查不迟”,现在就是检查的时候了。从硬盘中加载区块数据,对每个块调用ConnectBlock函数,如果有某个块验证失败,以此块为起点的整条链都是错误的,要删除链上这部分的数据。如果全部通过,将块中的交易放到集合vDelete中, 待从交易池删除。
// Connect longer branch
vector<CTransaction> vDelete;
for (int i = 0; i < vConnect.size(); i++)
{
CBlockIndex* pindex = vConnect[i];
CBlock block;
if (!block.ReadFromDisk(pindex->nFile, pindex->nBlockPos, true))
return error("Reorganize() : ReadFromDisk for connect failed");
if (!block.ConnectBlock(txdb, pindex))
{
// Invalid block, delete the rest of this branch
txdb.TxnAbort();
for (int j = i; j < vConnect.size(); j++)
{
CBlockIndex* pindex = vConnect[j];
pindex->EraseBlockFromDisk();
txdb.EraseBlockIndex(pindex->GetBlockHash());
mapBlockIndex.erase(pindex->GetBlockHash());
delete pindex;
}
return error("Reorganize() : ConnectBlock failed");
}
// Queue memory transactions to delete
foreach(const CTransaction& tx, block.vtx)
vDelete.push_back(tx);
}
❖重整主链收尾工作
流程图中收尾工作的流程很清晰,需要解释的是要对vResurrect集合中的交易重新调用AcceptTransaction函数,如果通过就放入交易池。我们可以看出,旧链失效,原本在账本中的交易被剔除出来,需要重新验证才能加入交易池,如果交易池中有恶意的双花交易与此笔想要重新加入交易池的交易冲突,那此笔交易就会被双花交易挤掉。这就是“双花”攻击成功的一种现象,本来已经有一个确认的交易没能继续在区块链账本中“生存”。当然如果原主链发力夺回最长链的话,剧本还将改写,这里只是提示一个确认并不安全,越多确认越安全。
至此,全节点处理区块的流程也分析完了,这个流程还是比较复杂的,涉及到很多细节,梳理的过程中会有很多疑问。带着疑问找到流程图的相应部分,再找到相应的代码阅读,对解决疑问有很大帮助,可以避免迷失在代码的海洋中。
BTech原创,未经许可不得转载