分布式存储系统进阶功能讲解 | 青训营笔记

96 阅读8分钟

这是我参与「第四届青训营 」笔记创作活动的第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:

  1. 因为nameNodes节点存在主副备份,所以当进行数据迁移时,由于不是用户指定的,用户是无感知的,是由心跳检测触发的,所以导致主副节点都会去操作数据迁移,导致数据冗余,双主现象。

解决方案:

元数据中加入 PrimaryPort ,主节点端口号,来进行鉴权。只有一个nameNode可以操作数据迁移。

  1. 完成上述的修缮的时候,发现当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
}