Commit 41606403 authored by yxq's avatar yxq

ref docs

parent 49549002
1. 技术背景
1. 技术背景
由于区块链可追溯、不可篡改的特性,公链通常需要保存所有历史数据。而随着区块高度的不断增长以及交易数的不断增多,历史数据会无限膨胀。而每一个节点都保存一份历史数据是对存储资源的极大浪费。在此背景下希望通过分布式存储数据来节约存储资源,同时依然保证历史数据可查可信。
2. p2p网络结构
Chain33公链的p2p网络使用的DHT(Distributed Hash Tables)分布式哈希表技术
DHT是一个分布式系统, 它提供了一个类似哈希表一样的查询服务: 键值对存储在DHT中, 任何参与的节点都可以有效的检索给定键对应的值. 键值对的映射由网络中所有的节点维护, 每个节点负责一小部分路由和数据存储. 这样即使有节点加入或者离开, 对整个网络的影响都很小, 于是DHT可以扩展到非常庞大的节点(上千万)。DHT广泛应用于各种点对点系统, 用来存储节点的元数据。
<br/>
在我们Chain33公链上使用是Kademlia DHT,它具有如下特点:
* 高效查询:查询的平均复杂度是 log2(n),例如:10,000,000个节点只需要20次查询
* 低开销:优化了发往其它节点的控制消息的数量
* 可以抵御各种攻击:广泛应用于各种点对点系统,包括:Guntella和BitTorrent,可以构建超过2千万个节点的网络
基于上述优点,我们设计了DHT的数据存储系统,在Chain33 P2P 网络中,我们实现了分布式存储,对于每一个区块只保存在部分节点上。我们把整个体统抽象出两种空间:资源空间和节点空间。资源空间就是所有节点保存的资源集合,节点空间就是所有节点的集合。对所有资源和节点分别进行编号,如把资源名称或内容用 Hash 函数变成一个数值(这也是 DHT 常用的一种方法),这样,每个资源就有对应的一个 ID,每个节点也有一个 ID,资源 ID 和节点 ID 之间建立起一种映射关系,比如,将资源 n 的所有索引信息存放到节点 n 上,那要搜索资源 n 时,只要找到节点 n 即可,从而就可以避免泛洪广播,能更快速而又准确地路由和定位数据。当然,在实际应用中,资源 ID 和节点 ID 之间是无法做到一一对应的,但因为 ID 都是数字,就存在大小关系或偏序关系等,基于这些关系就能建立两者的映射关系。这就是 DHT 的核心思想。DHT 算法在资源编号和节点编号上就是使用了分布式哈希表,使得资源空间和节点空间的编号有唯一性、均匀分布式等较好的性质,能够适合结构化分布式网络的要求。
3. 数据保存
在常规的公链系统中,每一个区块数据都在所有节点上存有一个备份。在实现了分布式存储的公链系统中,对于每一个区块只保存在部分节点上。
对于区块应该保存于哪些节点上,则需要一个特定的算法来决定,这也是所有诚实节点需要共同遵守的协议。具体算法描述如下:
1. 每一个区块数据都有一个唯一的区块哈希,每一个节点都有一个唯一的哈希格式的 node ID,因此可以将区块哈希和 node ID 映射于同一数据空间下。对区块哈希和 node ID 执行异或操作得到区块和节点的逻辑距离。对于每一个区块,将其存储于到该区块逻辑距离最小的 K 个节点上。
2. 寻找全网逻辑距离最小的节点,KAD 网络结构使得该查询变得高效。平均只需要 logN 次迭代查询即可定位到距离最小的节点。对于一个10亿节点数的网络,定位大约需要 30 次路由迭代。考虑到新区块需要广播到全网,因此一种更高效的寻找最近节点的办法是用 “局部最近” 代替 “全局最近”。每个节点的本地路由表可以容纳的节点数在 10^3 级别,且路由表中的节点逻辑分布是分散的。因此如果一个节点在本地路由表中是逻辑距离最小的,那么有很大概率也是全网逻辑距离最小的。
4. 数据检索
因为数据只保存在部分节点上,因此需要根据数据保存时采用的算法来定位数据的位置。因此对于给定区块哈希,找到逻辑距离最小的 K 个节点即可找到区块。
一种备选方案是当节点将区块保存在本地之后,对全网声称自己持有该区块。每个节点上将维护一个缓存,用于记录区块位于哪些节点。因此当节点需要查找本地未保存的区块时,可以通过访问该缓存来查询区块保存的位置。
由于是区块哈希作为 key,而区块内容作为 value,因此只需要将 value 做哈希运算后与 key 比对即可完成数据校验。
5. 动态扩展性
动态扩展性是分布式存储中最核心的问题。整个网络是去中心化的,因此随时会有节点加入或退出。一个理想状态是当节点加入或退出时数据的备份数量扔能稳定在 K 份左右。
为实现动态扩展,令每个节点上保存的数据都有一个过期时间(比如24小时)。每个节点对于自身持有的数据,定期(比如每隔3小时)像本地路由表中逻辑距离最近的 K-1 个节点发出数据保存通知。收到通知的节点重置该数据的过期时间(KAD算法)。
当新节点加入网络时,会收到来自其它节点的数据保存通知,进而获取相应的数据并保存到本地。当有节点宕机时,本该通知该宕机节点的节点会通知其它活跃节点(凑齐 K-1个),因此本该保存于该宕机节点上的数据会由其它活跃节点来保存。
由于每个节点需要针对本地持有的数据定期发出数据保存通知,因此通知消息的数量与本地数据数量成正比关系。如果节点持有数据数目变少,那么数据保存通知的消息数量也会变少。由此提出一种区块数据归档操作,将连续的 N 个区块打包成一个更大的归档数据,该归档数据作为一个整体存储于某些节点上。数据归档之后新节点同步历史区块时可以一次定位到1000个区块,而不需要每个区块都在全网进行一次路由查询,提高同步效率。
6. 故障恢复
该分布式存储中数据的安全性与网络稳定性有关,上述方案很难保证在极端网络环境下数据安全性。因此进一步提出一种全节点-分片节点混合网络架构。全节点保存所有历史区块数据,且不会发出和接收数据保存通知,因此全节点的数据安全不依赖于网络状况。当用户在分片网络中找不到数据时会进一步向全节点请求数据,全节点把丢失的数据同步到相应的分片节点进行数据恢复。任何节点都可以配置成全节点模式。
7. Chain33数据分片与IPFS对比
- | Chain33 |IPFS
---|--- |----
网络模型 |KAD DHT|KAD DHT|
数据类型 |区块数据|多媒体数据
存储格式 |简单KV对|Merkle DAG
用户激励 |义务存储|收费存储,需要存储证明
节点类型 |全节点+分片节点|对等的分片节点
数据持久性| 永久保存|很久不查询的数据会丢失
chain33借鉴了IPFS的网络模型DHT网络,通过该网络进行内容寻址和路由。IPFS是对整个互联网的重构,因此目标是希望存储全网的数据。由于chain33的分布式存储只需要保存历史区块数据,因此在实现上要比IPFS精简和高效很多。
IPFS保存的是多媒体数据,因此数据大小差异较大且数据会修改。chain33分布式存储只保存历史区块数据,区块大小有固定范围且不会修改,因此不存在数据版本问题,只需要用简单的kv对存储即可。IPFS还需要一层激励层Filecoin用于存储激励,同时存储的节点需要给出存储证明来保证数据的安全性。chain33不需要激励以及存储证明,取而代之的是保存所有数据的全节点。chain33默认大多数节点是诚实的,当有节点作弊不存储数据时,全节点的存在依然可以保证全网数据的安全性。当分片节点作弊导致用户在分片网络中查不到数据时,会自动到全节点查询数据,同时全节点会把数据同步到诚实的分片节点上去,保证数据安全可靠。
同时chain33实现了网络的动态平衡功能。当老节点检测到有新节点加入时,老节点上的部分数据会同步一部分到新节点上,从而保证每个分片节点上存储的数据规模基本一致,且节点越多,则每个节点需要保存的数据就越少。
// chain33/common/db/mvcc.go L:427
// chain33/common/db/mvcc.go L:427
func pad(version int64) []byte {
s := fmt.Sprintf("%020d", version)
return []byte(s)
}
//优化后性能2倍:
func pad2(version int64) []byte {
sInt := strconv.FormatInt(version, 10)
result := []byte("00000000000000000000")
copy(result[20-len(sInt):], sInt)
return result
}
//优化2,补齐长度参数化,更通用,性能比优化前提升1.5倍
func pad3(version int64, padLen int) []byte {
sInt := strconv.FormatInt(version, 10)
result := make([]byte, padLen)
for i := range result {
if i < len(sInt) {
result[padLen-1-i] = sInt[len(sInt)-1-i]
} else {
result[padLen-1-i] = '0'
}
}
return result
}
// chain33/common/db/mvcc.go L:432
func GetKeyPerfix(key []byte) []byte {
b := append([]byte{}, mvccData...)
newkey := append(b, key...)
newkey = append(newkey, []byte(".")...)
return newkey
}
// 优化后内存消耗减少,降低gc压力,性能提升30%
func GetKeyPerfix2(key []byte) []byte {
newKey := make([]byte, 0, len(mvccData)+len(key)+1)
newKey = append(newKey, mvccData...)
newKey = append(newKey, key...)
newKey = append(newKey, []byte(".")...)
return newKey
}
// chain33/util/exec.go L:226
func DelDupKey(kvs []*types.KeyValue) []*types.KeyValue {
dupindex := make(map[string]int)
n := 0
for _, kv := range kvs {
skey := string(kv.Key)
if index, ok := dupindex[skey]; ok {
//重复的key 替换老的key
kvs[index] = kv
} else {
dupindex[skey] = n
kvs[n] = kv
n++
}
}
return kvs[0:n]
}
// 优化后,相同的[]byte转string会复用string
func DelDupKey2(kvs []*types.KeyValue) []*types.KeyValue {
dupindex := make(map[string]int)
n := 0
for _, kv := range kvs {
if index, ok := dupindex[string(kv.Key)]; ok {
//重复的key 替换老的key
kvs[index] = kv
} else {
dupindex[string(kv.Key)] = n
kvs[n] = kv
n++
}
}
return kvs[0:n]
}
/*
BenchmarkDelDupKey 100 10892014 ns/op 3534438 B/op 20280 allocs/op
BenchmarkDelDupKey2 171 7748627 ns/op 2254615 B/op 10281 allocs/op
解析:
由于[]byte不可比较因此不能作为map的key,通常把[]byte转换成string后作为map的key。如果在map的方括号内进行类型转换,Go编译器会进行优化,复用相同的string,避免重复申请内存
*/
[store.sub.kvmvccmavl]
# 开启支持最新状态数据遍历,关闭可以节约磁盘空间
enableMVCCIter=false
# 开启状态数据库精简,只保留最新的10w高度
enableMVCCPrune=true
# 每隔100w高度精简一次状态数据库
pruneMVCCHeight=1000000
[exec]
# 关闭地址相关统计,主要用于浏览器信息
disableAddrIndex=true
# 关闭每个高度总的交易费统计
disableFeeIndex=true
# 开启后会进一步精简localdb,用户查询合约功能会受影响,纯挖矿节点可以开启节省磁盘空间
# 开启后自动挖矿功能受影响,需要进一步完善
disableExecLocal=true
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment