以太坊的存储层技术分析之三:以太坊区块读写LevelDB相关代码分析

写入:

以太坊区块生成并写入区块链数据库,分为创世区块和普通区块两种。其写入过程是相同的,区别在于区块生成过程。以生成创世区块为例子,总体流程是从genesis.json读取配置,写入内存的数据结构,再写入磁盘leveldb文件。

(注:以上是go语言版本geth的分析, java版本Ethereumj目前没有命令行init的功能,需要自行调用相关类去实现)

在core/genesis.go中WriteGenesisBlock函数将创世区块写入数据库作为第0号区块,其函数入参是数据库链接实例和ioReader实例,输出一个块Block对象。

函数定义:

func WriteGenesisBlock(chainDb ethdb.Database, reader io.Reader) 

创世区块生成时,按以太坊黄皮书要求,需要配置文件genesis.json将一些参数传给区块链应用    

contents, err := ioutil.ReadAll(reader)   //读取了磁盘上的genesis.json

读取配置文件后,如下是内存中对应的结构:

var genesis struct {

        ChainConfig *params.ChainConfig `json:"config"`

        Nonce       string //工作量证明的随机数,用于挖矿算法

        Timestamp   string //创建时机器时间,自19700101起到区块产生时的总秒数

        ParentHash  string //创世区块没有父块,一般设置为全0

        ExtraData   string //创造者留言,比如比特币的这个写的是泰晤士报纸的标题

        GasLimit    string //区块所允许交易消耗gas的最大量

        Difficulty  string //挖矿难度,数值越大代表困难越高

        Mixhash     string //挖矿过程所产生的中间过程hash值

        Coinbase    string //矿工的主账号,挖矿收益存入本账号

        Alloc       map[string]struct {  //预置账号

                Code    string

                Storage map[string]string

                Balance string

        }

}

 

读取创世区块配置文件后,对数据库需要如下2个动作,一是要根据配置文件中的初始化账号的余额,对账户状态数据库(stateDB)进行更新;二是要根据配置文件参数,生成第一个区块,并更新区块链数据库(chainDB)。

1)数据库动作一,更新账户状态数据库(stateDB):        

        // statedb提供了写入磁盘前的缓冲层(前面章节提到过这个特性)

        statedb, _ := state.New(common.Hash{}, chainDb)

        for addr, account := range genesis.Alloc {    

        //如果初始化json里面有账户余额预先存入的,就预先存入

                address := common.HexToAddress(addr)

                statedb.AddBalance(address, (account.Balance))

                …

                for key, value := range account.Storage {

                        statedb.SetState(address, (key), (value))

                }

        }

        root, stateBatch := statedb.CommitBatch(false)  //提交到数据库

 

内存中先用读取的json文件的配置值给struct结构赋值:

然后也会先读一下数据库,看看是否存在同hash的块,有说明创世区块已有了,会报错。

若没有则写入区块链数据库(写入过程都是先RLP编码化,再通过关键要素字符串拼接方式形成key,最后用db.Put(key, value)方式存入数据库):

 


图1 区块写入数据库的过程

 

注意,其中写入区块体时,写入的key是块号加上hash,为什么要加上块号?是为了避免hash的碰撞,如果两个区块碰巧产生了一样的hash,通过块号的区别也保证了key不同;另外分叉的时候,块号一样,但是hash就不一样了,the DAO事件后,ETH和ETC的不同,就是产生了分叉,见附录图

而普通区块的写入动作其流程是相同的,所不同的是其生成过程,创世区块很多值是通过配置文件取得值的,而普通区块是通过计算得到的。

另外,数据库中不会写入所有前台查询时展示的数据,比如区块的size: 803,这个值在go和java版本的区块定义中没有,也不会保存到数据库中占用额外体积,打印的时候计算打印出来就可以了。
 

读取:

说了写入,再谈一下读取。总体过程是从数据库中以block区块号为主键,读出块头,这个动作类似于关系型传统数据库根据索引找到记录。而此时账户状态数据库中已经保存的块号和头部hash之间关系就像是索引(图5,写header步骤)。

// GetHeaderByNumber retrieves a block header from the database by number,

// caching it (associated with its hash) if found.

func (self *BlockChain) GetHeaderByNumber(number uint64) *types.Header {

        return self.hc.GetHeaderByNumber(number)

}

找到headerchain.go的代码中确实是按照如上二阶段流程(先读hash再读头部)读取区块头部的

func (hc *HeaderChain) GetHeaderByNumber(number uint64) *types.Header {

        hash := GetCanonicalHash(hc.chainDb, number)

        if hash == (common.Hash{}) {

                return nil

        }

        return hc.GetHeader(hash, number)

}

 

如上是读取头部的代码,进一步地,可以读取区块头部的详细内容,获取总挖矿难度,也是之前就保存了这样的数据,所以可以查询获得难度系数:

// GetBlockNumber retrieves the block number belonging to the given hash

// from the cache or database

func (hc *HeaderChain) GetBlockNumber(hash common.Hash) uint64

 

另外,还可以通过编程直接读取以太坊LevelDB方式,理解以太坊后台的存储结构,以及读取的过程。

 

My first leveldb app!!

block reuslt: [6650a0ac6c5e805475e7ca48eae5df0e32a2147a154bb2222731c770ddb5c158]

 


图2 编程直接读取以太坊存储的例子

 

如上代码演示了如何通过编码直接读取以太坊保存的数据,程序main函数入口后,首先告诉程序数据库文件所在地址,采用api打开数据库链接,然后是用“LastBlockKey”获取最后一个区块的hash,然后根据其区块头部的parentHash再一个个往前查找,直到创世区块,可以用于区块链数据的分析。

另外要注意的是,如果客户端采用了fast syncing方法(快速同步方法),历史区块的如上部分内容就不会同步到本地数据库了,这样加快了同步速度,减少了数据体积。后续需要区块体时,根据区块头部信息从网络获取区块体信息。快速同步(fast syncing),是以太坊存储层技术的重要改进。带来更快的同步速度,更小的体积占用(相当于“臃肿”的以太坊存储数据“瘦身”)。