全節點處理區塊流程分析
https://www.jianshu.com/p/9112625f660f
我們已經分析過了全節點處理單筆交易(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流程.jpg
接着對重要步驟作出說明
❖時間戳檢查
我們看到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函數,如果通過就放入交易池。我們可以看出,舊鏈失效,原本在賬本中的交易被剔除出來,需要重新驗證才能加入交易池,如果交易池中有惡意的雙花交易與此筆想要重新加入交易池的交易衝突,那此筆交易就會被雙花交易擠掉。這就是“雙花”攻擊成功的一種現象,本來已經有一個確認的交易沒能繼續在區塊鏈賬本中“生存”。當然如果原主鏈發力奪回最長鏈的話,劇本還將改寫,這裏只是提示一個確認並不安全,越多確認越安全。
至此,全節點處理區塊的流程也分析完了,這個流程還是比較複雜的,涉及到很多細節,梳理的過程中會有很多疑問。帶着疑問找到流程圖的相應部分,再找到相應的代碼閱讀,對解決疑問有很大幫助,可以避免迷失在代碼的海洋中。