这是我参与「第四届青训营 」笔记创作活动的第1天
一、需求分析
1.NameNode发现并添加网络中存活的DataNode
当管理员不知道网段中有哪些可用的dataNode节点时,能够自我发现并添加管理使用
2.NameNodes高可用且元数据一致性
元数据服务模块为了实现高可用性,需要运行多个服务节点,当提供服务的主节点切换后,需要保证数据的一致性。使用分布式共识协议实现可以使自动化程度更高,这里建议考虑开源的Raft协议实现。
3.心跳检测DataNode节点
当dataNode节点dead的时候,作为管理它的nameNode节点,需要感知,才能采取下一步的行动,比如正在读取数据的时候,用户可以无感知的正常读取数据或者是当节点死亡时,得将死亡节点的数据重新备份到新的节点上去
4.DataNode故障时数据迁移+负载均衡
存储节点单点故障时,元数据节点能感知并对受影响的数据块做副本数补全
二、详细设计
1.NameNode发现并添加网络中存活的DataNode
当客户端未给出dataNodes参数,触发遍历发现可用的dataNodes节点,通过availableNumberOfDataNodes == 0 来判断。默认设置固定端口号范围,7000-7050
挨个做rpc连接。光能连接上不行,还需要验证是否能应答,所以需要写一个简单的rpc方法(ping)。
if availableNumberOfDataNodes == 0 {
log.Printf( "No DataNodes specified, discovering ...\n" )
host := "localhost"
serverPort := 7000
pingRequest := datanode.NameNodePingRequest{Host: host, Port: nameNodeInstance.Port}
var pingResponse datanode.NameNodePingResponse
//从7000-7050遍历尝试连接发现,并加入listOfDataNodes
for serverPort < 7050 {
dataNodeUri := host + ":" + strconv.Itoa(serverPort)
dataNodeInstance, initErr := rpc.Dial( "tcp" , dataNodeUri)
if initErr == nil {
*listOfDataNodes = append(*listOfDataNodes, dataNodeUri)
log.Printf( "Discovered DataNode %s\n" , dataNodeUri)
pingErr := dataNodeInstance.Call( "Service.Ping" , pingRequest, &pingResponse)
if pingErr != nil {
log.Println(pingErr)
}
if pingResponse.Ack {
log.Printf( "Ack received from %s\n" , dataNodeUri)
} else {
log.Printf( "No ack received from %s\n" , dataNodeUri)
}
}
serverPort += 1
}
}
2.NameNodes高可用且元数据一致性
当我们在使用的时候,万一nameNode节点挂了,这个时候不就代表dataNodes节点失去管理。
可行的方法有多种,简单描述三种做法。
- 通过第三方组件etcd,使用raft协议,给nameNodes建立一个集群,都注册在etcd中,通过它自身的选举机制,确定leader,follows,同时操作元数据也每次往etcd中存取,数据量很小,可行。follows监听元数据主题的变化,然后实现同步。
- 将元数据写入非关系数据库,如Redis,key-value存取,通过Rabbitmq进行读取和写入,做到削峰填谷。nameNodes节点心跳检测主nameNode主节点,当感知故障后,通过一定的选举机制,选出主节点,读取redis中的元数据信息。坏处:可能会丢失一部分数据
- nameNodes心跳检测主节点,协程开始定时任务,rpc调用方法主节点的服务,返回主节点的元数据,同步给备份节点。当主节点死亡时,副节点升级为主节点,并继续操控dataNodes节点。
这边因为资源有限,元数据都存储在内存,所以实现了第三种方案。
//当端口号不是主节点端口号时,说明是备份nameNode节点,开启协程进行元数据间的备份
atoi, _ := strconv.Atoi(nameNodeInstance.PrimaryPort)
if nameNodeInstance.Port != uint16(atoi) {
go ReplicationnameNode(nameNodeInstance)
}
// ReplicationnameNode 同步主nameNode节点的元数据信息:FileNameToBlocks,IdToDataNodes,BlockToDataNodeIds
// FileNameSize,DirectoryToFileName
func ReplicationnameNode(nameNode *namenode.Service) {
//开启定时任务,每隔1s进行一次元数据同步
for range time.Tick(time.Second * 1) {
//与主节点建立rpc连接,当连接不上时说明主节点挂了,不需要再同步,直接使用备份节点进行操作,并将备份节点升级成为主节点
nameNodeInstance, err := rpc.Dial( "tcp" , "localhost:" +nameNode.PrimaryPort)
if err != nil {
log.Println( "primary nameNode is dead,second nameNode become primary nameNode" )
nameNode.PrimaryPort = strconv.FormatUint(uint64(nameNode.Port), 10)
log.Printf( "now primary nameNode port is %s\n" , nameNode.PrimaryPort)
return
}
defer nameNodeInstance.Close()
request := true
var reply namenode.Service
//通过rpc调用同步nameNode元数据方法,返回元数据
err = nameNodeInstance.Call( "Service.ReplicationnameNode" , request, &reply)
if err != nil {
log.Println(err)
continue
}
//更新备份nameNode的元数据信息
nameNode.FileNameToBlocks = reply.FileNameToBlocks
nameNode.IdToDataNodes = reply.IdToDataNodes
nameNode.BlockToDataNodeIds = reply.BlockToDataNodeIds
nameNode.FileNameSize = reply.FileNameSize
nameNode.DirectoryToFileName = reply.DirectoryToFileName
}
}
// ReplicationnameNode 获取主nameNode节点的元数据信息:FileNameToBlocks,IdToDataNodes,BlockToDataNodeIds,FileNameSize,DirectoryToFileName
func (nameNode *Service) ReplicationnameNode(request *bool, reply *Service) error {
if *request {
*reply = Service{
IdToDataNodes: nameNode.IdToDataNodes,
FileNameSize: nameNode.FileNameSize,
BlockToDataNodeIds: nameNode.BlockToDataNodeIds,
FileNameToBlocks: nameNode.FileNameToBlocks,
DirectoryToFileName: nameNode.DirectoryToFileName,
}
return nil
}
return errors.New( "获取元数据失败" )
}
3.心跳检测DataNode节点
心跳检测dataNodes的必要性,涉及了管理dataNodes存储。以及数据的及时备份。
当心跳检测失败时,需要及时的进行数据迁移。
- 方法:在初始化nameNode节点时,开启协程对每个dataNode进行监控
在heartbeatToDataNodes()中实现当检测失败,立即触发数据迁移服务(备份节点足够的情况下)
以及更新nameNodes节点中元数据的信息,及时让用户数据不再往dead的节点存储。
// InitializeNameNodeUtil 初始化nameNode节点进程
func InitializeNameNodeUtil(primaryPort string, serverHost string, serverPort int, blockSize int, replicationFactor int, listOfDataNodes []string) {
// 生成nameNode实例
nameNodeInstance := namenode.NewService(primaryPort, serverHost, uint64(blockSize), uint64(replicationFactor), uint16(serverPort))
// 发现当前存在的dataNodes或者终端给的,并维护到listOfDataNodes
err := discoverDataNodes(nameNodeInstance, &listOfDataNodes)
if err != nil {
log.Println(err)
return
}
// 协程:对管理的dataNodes进行心跳检测
go heartbeatToDataNodes(listOfDataNodes, nameNodeInstance)
4.DataNode故障时数据迁移+负载均衡
存储节点单点故障时,元数据节点能感知并对受影响的数据块做副本数补全,但是这个补全采取什么机制合理呢?我们尝试了两种方法,对比后选取后者。
- 当出现单节点故障时,首先能获取dataNode的Id,然后元数据中保存了该dataNode节点和Block的关系,取出所有与该节点相关的BlockId,进行副本写入,策略是重写数与设置的副本数相同,所以要做到读取数据,以及删除原先所有副本。然后通过随机数里来选择新的副本节点写入,简单做到负载均衡,并修改nameNode中的元数据。
导致的bug:
- 因为nameNodes节点存在主副备份,所以当进行数据迁移时,由于不是用户指定的,用户是无感知的,是由心跳检测触发的,所以导致主副节点都会去操作数据迁移,导致数据冗余,双主现象。
解决方案:
元数据中加入 PrimaryPort ,主节点端口号,来进行鉴权。只有一个nameNode可以操作数据迁移。
- 完成上述的修缮的时候,发现当get数据时,如果某个nameNode节点死亡,正常应该无感知的正常读取数据,因为存在备份,但是这时候同时触发了数据迁移,数据迁移牵涉到删除所有的原本副本数据,这就导致正好在读取的时候可能某些数据被删除了,导致失败。
解决方案:
1.当触发数据迁移时,所有其他的进程进行阻塞,但是这样子就违背了用户的无感知,会觉得读取数据很慢,万一dead节点的数据量很大,迁移很慢。
2.修改副本写入策略,见下。
- 大部分的策略与上述的策略相同,就是写入副本数设置为1,原有的副本并不删除,这样子在get数据时,即使有节点死亡,也不会影响数据的正常读取。同时这个副本的存储策略就很重要,不能分配在已经有这个副本的存储节点上,需要进行筛选得到可用的dataNode节点集合,然后在进行随机数选取,做到负载均衡,正常的策略是遍历节点依次存储,但是这边简单实现。
// ReDistributeData 当有dataNode节点dead,将死亡的节点上的数据进行备份,重新分配备份节点写入
func (nameNode *Service) ReDistributeData(request *ReDistributeDataRequest, reply *bool) error {
log.Printf( "DataNode %s is dead, trying to redistribute data\n" , request.DataNodeUri)
deadDataNodeSlice := strings.Split(request.DataNodeUri, ":" )
var deadDataNodeId uint64
// 遍历元数据IdToDataNodes,确定是IdToDataNodes哪个key:Id是dead的节点
for id, dn := range nameNode.IdToDataNodes {
if dn.Host == deadDataNodeSlice[0] && dn.ServicePort == deadDataNodeSlice[1] {
deadDataNodeId = id
break
}
}
//维护元数据IdToDataNodes,删除dead的key-value
delete(nameNode.IdToDataNodes, deadDataNodeId)
//创建需要复制块列表
var underReplicatedBlocksList []UnderReplicatedBlocks
// 遍历BlockToDataNodeIds,得到blockId:{对应的dataNodes}
for blockId, dnIds := range nameNode.BlockToDataNodeIds {
for i, dnId := range dnIds {
//当blockId中的dataNodes匹配上deadDataNodeId时,记录1个备份的DataNodeId作为healthyDataNodeId
//并跳出循环,因为一个节点的BlockId损坏只会匹配一个DataNodeId
if dnId == deadDataNodeId {
//备份的DataNodeId有多个,都存在BlockToDataNodeIds[blockId],如果是坏的恰好是最后一个就会有问题,所以取余
healthyDataNodeId := nameNode.BlockToDataNodeIds[blockId][(i+1)%len(dnIds)]
//包装UnderReplicatedBlocks{blockId,备份的节点Id}
underReplicatedBlocksList = append(
underReplicatedBlocksList,
UnderReplicatedBlocks{blockId, healthyDataNodeId},
)
break
}
}
}
// 判断当前可用dataNodes节点数是否足够
if len(nameNode.IdToDataNodes) < int(nameNode.ReplicationFactor) {
log.Println( "Replication not possible due to unavailability of sufficient DataNode(s)" )
return nil
}
// 遍历需要复制块列表
for _, blockToReplicate := range underReplicatedBlocksList {
var remoteFilePath string
flag := false
// 只有要复制的BlockId,要得到文件的有效的路径名(不包含文件名),为了读取数据BlockId中的数据
for filename, fileblockIds := range nameNode.FileNameToBlocks {
// 遍历每个文件对应的fileblockIds,当要复制的BlockId与其匹配时,当前的filename:path+name 就是路径
for _, id := range fileblockIds {
if blockToReplicate.BlockId == id {
flag = true
break
}
}
if flag {
// 将filename切分,得到只含路径名
split := strings.Split(filename, "/" )
for i := 0; i < len(split)-1; i++ {
remoteFilePath += split[i] + "/"
}
break
}
}
// 从要复制的节点读取数据
healthyDataNode := nameNode.IdToDataNodes[blockToReplicate.HealthyDataNodeId]
dataNodeInstance, rpcErr := rpc.Dial( "tcp" , healthyDataNode.Host+ ":" +healthyDataNode.ServicePort)
if rpcErr != nil {
log.Println(rpcErr)
continue
}
defer dataNodeInstance.Close()
getRequest := datanode.DataNodeGetRequest{
RemoteFilePath: remoteFilePath,
BlockId: blockToReplicate.BlockId,
}
var getReply datanode.DataNodeData
rpcErr = dataNodeInstance.Call( "Service.GetData" , getRequest, &getReply)
if rpcErr != nil {
log.Println(rpcErr)
continue
}
// 存放BlockId读取的数据
blockContents := getReply.Data
//重新写入数据给节点,更新BlockToDataNodeIds元数据
//分配给哪个备份节点,得到目标节点,必须得不在备份的所有节点上
var availableNodes []uint64
//遍历所有的当前存活的dataNodeId,只有这个Id与所有备份节点的Id不一样时,才说明是可用的节点,加入availableNodes
for id, _ := range nameNode.IdToDataNodes {
for i, BlockId := range nameNode.BlockToDataNodeIds[blockToReplicate.BlockId] {
if id == BlockId {
break
}
if i == len(nameNode.BlockToDataNodeIds[blockToReplicate.BlockId])-1 {
availableNodes = append(availableNodes, id)
}
}
}
//副本数为1,在availableNodes中取1个随机数,返回的是一个长度为1的slice[]
targetDataNodeIds := selectRandomNumbers(availableNodes, 1)
//得到目标存储节点
targetDataNodeId := targetDataNodeIds[0]
//更新BlockToDataNodeIds元数据,不涉及其他元数据的变更,只是BlockId与dataNodes的映射
//删除坏的deadDataNodeId,添加新的targetDataNodeId
var newBlockToDataNodeIds []uint64
for _, dataNodeId := range nameNode.BlockToDataNodeIds[blockToReplicate.BlockId] {
if dataNodeId != deadDataNodeId {
newBlockToDataNodeIds = append(newBlockToDataNodeIds, dataNodeId)
}
}
newBlockToDataNodeIds = append(newBlockToDataNodeIds, targetDataNodeId)
nameNode.BlockToDataNodeIds[blockToReplicate.BlockId] = newBlockToDataNodeIds
//写入节点赋值,remainingDataNodes为空
startingDataNode := nameNode.IdToDataNodes[targetDataNodeId]
targetDataNodeInstance, rpcErr := rpc.Dial( "tcp" , startingDataNode.Host+ ":" +startingDataNode.ServicePort)
if rpcErr != nil {
log.Println(rpcErr)
continue
}
defer targetDataNodeInstance.Close()
putRequest := datanode.DataNodePutRequest{
RemoteFilePath: remoteFilePath,
BlockId: blockToReplicate.BlockId,
Data: blockContents,
}
var putReply datanode.DataNodeReplyStatus
// 将数据写入对用的节点
rpcErr = targetDataNodeInstance.Call( "Service.PutData" , putRequest, &putReply)
if rpcErr != nil {
log.Println(rpcErr)
continue
}
// 打印重新分配的数据的写入分布情况
log.Printf( "Block %s replication completed for %+v,current distribution is %+v\n" , blockToReplicate.BlockId, targetDataNodeIds, newBlockToDataNodeIds)
}
return nil
}