PBFT算法源码详解

真的好久没有写博客了,正好最近在研究PBFT,那就从PBFT开始写起吧!

先奉上大佬@byron1st写的PBFT代码:https://github.com/bigpicturelabs/simple_pbft

我会带着大家鹿一遍源代码!因为看到网上好多的博客都是互相抄袭,对大家一点帮助没有
在这里插入图片描述

整个代码逻辑都是围绕这张图来的!
PBFT算法基础理论部分:https://www.jianshu.com/p/cf1010f39b84

1.服务的启动主函数

func main() {
	nodeID := os.Args[1]  // 这边就是传进来的公司的名称
	server := network.NewServer(nodeID)
	server.Start()
}

这是整个PBFT的启动主函数,可以看到会获取用户输入的数值作为nodeID,然后调用network包中的NewServer函数定义一个服务(可以理解成一个节点的client窗口),然后开始启动server.Start()

2.NewServer函数

这里会给完整的流程图,等我写完所有的部分会重新画的

func NewServer(nodeID string) *Server {
	// 根据传进来的NodeID新建了一个节点,节点的默认视图是1000000,并且该节点启动了三个协程:dispatchMsg、alarmToDispatcher、resolveMsg
	node := NewNode(nodeID)
	// 启动服务
	server := &Server{node.NodeTable[nodeID], node}
	// 设置路由
	server.setRoute()

	return server
}

我们可以看到NewServer函数主要干了三件事:

  • 根据传进来的nodeID创建一个节点
  • 创建一个server服务
  • 设置路由

2.1 NewNode(nodeID)

我们先来看第一件事,就是创建一个node,进入node类,看一下node到底在干嘛!

func NewNode(nodeID string) *Node {
	const viewID = 10000000000 // temporary.

	node := &Node{
		// Hard-coded for test.
		NodeID: nodeID,
		NodeTable: map[string]string{
			"Apple": "localhost:1111",
			"MS": "localhost:1112",
			"Google": "localhost:1113",
			"IBM": "localhost:1114",
		},
		View: &View{
			ID: viewID,
			Primary: "Apple",   // 主节点是Apple公司
		},

		// Consensus-related struct
		CurrentState: nil,
		CommittedMsgs: make([]*consensus.RequestMsg, 0),  // 被提交的信息
		MsgBuffer: &MsgBuffer{
			ReqMsgs:        make([]*consensus.RequestMsg, 0),
			PrePrepareMsgs: make([]*consensus.PrePrepareMsg, 0),
			PrepareMsgs:    make([]*consensus.VoteMsg, 0),
			CommitMsgs:     make([]*consensus.VoteMsg, 0),
		},

		// Channels
		MsgEntrance: make(chan interface{}),   // 无缓冲的信息接收通道
		MsgDelivery: make(chan interface{}),   // 无缓冲的信息发送通道
		Alarm: make(chan bool),
	}

	// 启动消息调度器
	go node.dispatchMsg()

	// Start alarm trigger
	go node.alarmToDispatcher()

	// 开始信息表决
	go node.resolveMsg()

 	return node
}

这边的NewNode其实就是新建了一个节点,并且定义了node结构体中的一些属性信息,node结构体如下:

// 节点
type Node struct {
	NodeID        string
	NodeTable     map[string]string // key=nodeID, value=url
	View          *View
	CurrentState  *consensus.State
	CommittedMsgs []*consensus.RequestMsg // kinda block.
	MsgBuffer     *MsgBuffer
	MsgEntrance   chan interface{}
	MsgDelivery   chan interface{}
	Alarm         chan bool
}

我们一步步剖析NewNode

  • 初始化视图编号 const viewID = 10000000000
  • 定义了node结构体中的一些属性信息
属性说明
NodeIDnodeID节点ID
NodeTablemap[string]string节点索引表
View&View设置视图编号和主节点
CurrentStatenil默认当前节点的状态为nil
CommittedMsgsmake([]*consensus.RequestMsg, 0)被提交的信息
MsgBuffer&MsgBuffer四种消息类型缓冲列表
MsgEntrancemake(chan interface{})无缓冲的信息接收通道
MsgDeliverymake(chan interface{})无缓冲的信息发送通道
Alarmmake(chan bool)警告通道
  • 紧接着我们可以看到每一个node都开启了3个goroutine
    • dispatchMsg
    • alarmToDispatcher
    • resolveMsg
      我们先重点关注一下两个协程goroutine
2.1.1 协程1:dispatchMsg
func (node *Node) dispatchMsg() {
	for {
		select {
		case msg := <-node.MsgEntrance:   // 如果MsgEntrance通道有消息传送过来,拿到msg
			err := node.routeMsg(msg)   // 进行routeMsg
			if err != nil {
				fmt.Println(err)
				// TODO: send err to ErrorChannel
			}
		case <-node.Alarm:
			err := node.routeMsgWhenAlarmed()
			if err != nil {
				fmt.Println(err)
				// TODO: send err to ErrorChannel
			}
		}
	}
}

这里需要你对for select多路复用有所了解!

我们可以从dispatchMsg的代码中看到,只要MsgEnrance通道中有值,就会传递给一个中间变量msg,然后进行消息路由转发routeMsg

func (node *Node) routeMsg(msg interface{}) []error {
	switch msg.(type) {
	case *consensus.RequestMsg:
		if node.CurrentState == nil {
			// Copy buffered messages first.
			msgs := make([]*consensus.RequestMsg, len(node.MsgBuffer.ReqMsgs))
			copy(msgs, node.MsgBuffer.ReqMsgs)

			// Append a newly arrived message.
			msgs = append(msgs, msg.(*consensus.RequestMsg))

			// Empty the buffer.
			node.MsgBuffer.ReqMsgs = make([]*consensus.RequestMsg, 0)

			// Send messages.
			node.MsgDelivery <- msgs
		} else {
			node.MsgBuffer.ReqMsgs = append(node.MsgBuffer.ReqMsgs, msg.(*consensus.RequestMsg))
		}
	
		... 其他类型的信息数据

	return nil
}

我们刚开始先以RequestMsg为例,因为其他类型的消息数据的执行逻辑与RequestMsg几乎是一致的!这是节点会面临两种选择:

  • 当CurrentState 为 nil 时,将consensus.RequestMsg的信息复制给中间变量msgs,然后清空重置!并将msgs中的信息发送给MsgDelivery通道!(我们还记得node一直开了3个goroutine嘛,这边传出去,另外一个goroutineresolveMsg会立马接受这个通道中的信息)
  • 当CurrentState 不为 nil 时,直接往MsgBuffer缓冲通道中进行添加,这边会涉及到数组扩容的问题(可以了解一下)
2.1.2 协程2:resolveMsg
func (node *Node) resolveMsg() {
	for {
		// 从调度器中获取缓存信息
		msgs := <-node.MsgDelivery
		switch msgs.(type) {
		case []*consensus.RequestMsg:
			// 节点表决决策信息
			errs := node.resolveRequestMsg(msgs.([]*consensus.RequestMsg))
			if len(errs) != 0 {
				for _, err := range errs {
					fmt.Println(err)
				}
				// TODO: send err to ErrorChannel
			}
			... 替他类型的信息数据
		}
	}
}

这边我也先只以RequestMsg进行讲解!我们可以清楚的看到node.MsgDelivery这边在等着调度器dispatchMsg那边传递消息过来,因为两个缓冲通道都是无缓冲的,没有消息会一直阻塞在这边!

下面就是执行resolveRequestMsg函数了:

// 节点表决请求阶段的信息
func (node *Node) resolveRequestMsg(msgs []*consensus.RequestMsg) []error {
	errs := make([]error, 0)

	// 表决信息
	for _, reqMsg := range msgs {
		err := node.GetReq(reqMsg)
		if err != nil {
			errs = append(errs, err)
		}
	}

	if len(errs) != 0 {
		return errs
	}

	return nil
}

在resolveRequestMsg代码块中,我们可以看到主要的执行逻辑就是调用了GetReq函数

// 主节点开始全局的共识
func (node *Node) GetReq(reqMsg *consensus.RequestMsg) error {
	LogMsg(reqMsg)

	// Create a new state for the new consensus.
	err := node.createStateForNewConsensus()
	if err != nil {
		return err
	}

	// 开始共识程序
	prePrepareMsg, err := node.CurrentState.StartConsensus(reqMsg)
	if err != nil {
		return err
	}

	LogStage(fmt.Sprintf("Consensus Process (ViewID:%d)", node.CurrentState.ViewID), false)

	// 发现getPrePrepare信息
	// 这边主节点开始向其他节点发送预准备消息了
	if prePrepareMsg != nil {
		node.Broadcast(prePrepareMsg, "/preprepare")
		LogStage("Pre-prepare", true)
	}

	return nil
}
  1. 创建一个新的共识状态createStateForNewConsensus
  2. 对节点当前状态开始共识StartConsensus
  3. 共识完成之后,主节点向其他节点广播prePrepareMsg阶段的信息!

这边先去看第3章共识过程

2.2 setRoute函数

func (server *Server) setRoute() {
	http.HandleFunc("/req", server.getReq)
	http.HandleFunc("/preprepare", server.getPrePrepare)
	http.HandleFunc("/prepare", server.getPrepare)
	http.HandleFunc("/commit", server.getCommit)
	http.HandleFunc("/reply", server.getReply)
}

3.Request共识过程

3.0 共识流程图

这张流程图是为了方便大家理解,等你梳理完整个流程,再回过头来重新看我画的这个流程图,思路应该会更清晰一点!
在这里插入图片描述

一只安慕嘻是我的B站账号,大家也可以关注 o(´^`)o

3.1 创建状态createStateForNewConsensus

func (node *Node) createStateForNewConsensus() error {
	// Check if there is an ongoing consensus process.
	if node.CurrentState != nil {
		return errors.New("another consensus is ongoing")
	}

	// Get the last sequence ID
	var lastSequenceID int64
	if len(node.CommittedMsgs) == 0 {
		lastSequenceID = -1
	} else {
		lastSequenceID = node.CommittedMsgs[len(node.CommittedMsgs) - 1].SequenceID
	}

	// Create a new state for this new consensus process in the Primary
	node.CurrentState = consensus.CreateState(node.View.ID, lastSequenceID)

	LogStage("Create the replica status", true)

	return nil
}
  • 首选判断当前节点的状态是不是为nil,也就是说判断当前节点是不是处于其他阶段(预准备阶段或者准备阶段等等)
  • 判断当前阶段是否已经发送过消息,如果是首次进行共识,则上一个序列号lastSequenceID设置为-1,否则我们取出上一个序列号
  • 创建状态CreateState,主要是初始化State之后返回
func CreateState(viewID int64, lastSequenceID int64) *State {
	return &State{
		ViewID: viewID,
		MsgLogs: &MsgLogs{
			ReqMsg:nil,
			PrepareMsgs:make(map[string]*VoteMsg),
			CommitMsgs:make(map[string]*VoteMsg),
		},
		LastSequenceID: lastSequenceID,
		CurrentStage: Idle,
	}
}

3.2 开始共识StartConsensus

// 主节点开始共识
func (state *State) StartConsensus(request *RequestMsg) (*PrePrepareMsg, error) {
	// 消息的序号为sequenceID,也就是当前时间
	sequenceID := time.Now().UnixNano()

	// Find the unique and largest number for the sequence ID
	if state.LastSequenceID != -1 {
		for state.LastSequenceID >= sequenceID {
			sequenceID += 1
		}
	}

	// Assign a new sequence ID to the request message object.
	request.SequenceID = sequenceID

	// Save ReqMsgs to its logs.
	state.MsgLogs.ReqMsg = request

	// 得到客户端请求消息requestMsg的摘要
	digest, err := digest(request)
	if err != nil {
		fmt.Println(err)
		return nil, err
	}

	// 将当前阶段转换到PrePrepared阶段
	state.CurrentStage = PrePrepared

	// 这边其实就是主节点向其他节点发送消息的格式:视图ID,请求的序列号,请求信息和请求信息的摘要
	return &PrePrepareMsg{
		ViewID: state.ViewID,
		SequenceID: sequenceID,
		Digest: digest,
		RequestMsg: request,
	}, nil
}
  • 首先使用当前的时间戳作为序列号
  • 如果上一个序列号 >= 当前序列号,当前序列号+1,目的很简单,主节点每开始一次共识,序列号+1
  • 进行序号号SequenceID 的更新,获取请求信息的摘要digest(request),将当前状态转化为PrePrepared

3.3 广播消息Broadcast

// 节点广播函数
func (node *Node) Broadcast(msg interface{}, path string) map[string]error {
	errorMap := make(map[string]error)

	for nodeID, url := range node.NodeTable {
		// 因为不需要向自己进行广播了,所以就直接跳过
		if nodeID == node.NodeID {
			continue
		}

		// 将msg信息编码成json格式
		jsonMsg, err := json.Marshal(msg)
		if err != nil {
			errorMap[nodeID] = err
			continue
		}

		// 将json格式传送给其他的节点
		send(url + path, jsonMsg) // url localhost:1111  path:/prepare等等
		// send函数:http.Post("http://"+url, "application/json", buff)
	}

	if len(errorMap) == 0 {
		return nil
	} else {
		return errorMap
	}
}
  • 遍历node节点初始化时自带的节点列表node.NodeTable
  • 将msg信息编码成json格式json.Marshal(msg),然后发送给其他节点send(url + path, jsonMsg)
func send(url string, msg []byte) {
	buff := bytes.NewBuffer(msg)
	_, _ = http.Post("http://"+url, "application/json", buff)
}

4.PrePrepare共识过程

顺便帮助大家回忆一下,在resolveMsg相应的goroutine中会判断传进来的
信息类型,前面一直说的是request类型,现在我们开始鹿PrePrepare的代码

case []*consensus.PrePrepareMsg:
	errs := node.resolvePrePrepareMsg(msgs.([]*consensus.PrePrepareMsg))
	if len(errs) != 0 {
		for _, err := range errs {
			fmt.Println(err)
		}
		// TODO: send err to ErrorChannel
	}

调用resolvePrePrepareMsg函数,开启prePrepareMsg的共识

func (node *Node) resolvePrePrepareMsg(msgs []*consensus.PrePrepareMsg) []error {
	errs := make([]error, 0)

	// Resolve messages
	for _, prePrepareMsg := range msgs {
		err := node.GetPrePrepare(prePrepareMsg)
		if err != nil {
			errs = append(errs, err)
		}
	}

	if len(errs) != 0 {
		return errs
	}

	return nil
}

对于每一个prePrepareMsg 信息会调用GetPrePrepare函数,下面正式开始共识!

// 到了预准备阶段,所有节点开始参与共识
func (node *Node) GetPrePrepare(prePrepareMsg *consensus.PrePrepareMsg) error {
	LogMsg(prePrepareMsg)

	// Create a new state for the new consensus.
	err := node.createStateForNewConsensus()
	if err != nil {
		return err
	}

	prePareMsg, err := node.CurrentState.PrePrepare(prePrepareMsg)
	if err != nil {
		return err
	}

	if prePareMsg != nil {
		// Attach node ID to the message
		prePareMsg.NodeID = node.NodeID

		LogStage("Pre-prepare", true)
		node.Broadcast(prePareMsg, "/prepare")
		LogStage("Prepare", false)
	}

	return nil
}

4.1 创建状态createStateForNewConsensus

这边跟request阶段是一样的,所以就省略啦!

4.2 开始共识PrePrepare

// 预准备阶段的共识
func (state *State) PrePrepare(prePrepareMsg *PrePrepareMsg) (*VoteMsg, error) {
	// Get ReqMsgs and save it to its logs like the primary.
	state.MsgLogs.ReqMsg = prePrepareMsg.RequestMsg

	// 验证视图ID、消息序列号和信息摘要是否正确
	if !state.verifyMsg(prePrepareMsg.ViewID, prePrepareMsg.SequenceID, prePrepareMsg.Digest) {
		return nil, errors.New("当前节点预准备阶段消息是错误的")
	}

	// Change the stage to pre-prepared.
	state.CurrentStage = PrePrepared

	return &VoteMsg{
		ViewID: state.ViewID,
		SequenceID: prePrepareMsg.SequenceID,
		Digest: prePrepareMsg.Digest,
		MsgType: PrepareMsg,
	}, nil
}
  • 进行状态验证
  • 将当前阶段改为PrePrepared
4.2.1 验证过程
// 验证
func (state *State) verifyMsg(viewID int64, sequenceID int64, digestGot string) bool {
	// Wrong view. That is, wrong configurations of peers to start the consensus.
	if state.ViewID != viewID {
		return false
	}

	// Check if the Primary sent fault sequence number. => Faulty primary.
	// TODO: adopt upper/lower bound check.
	if state.LastSequenceID != -1 {
		if state.LastSequenceID >= sequenceID {
			return false
		}
	}

	digest, err := digest(state.MsgLogs.ReqMsg)
	if err != nil {
		fmt.Println(err)
		return false
	}

	// Check digest.
	if digestGot != digest {
		return false
	}

	return true
}
  • 验证传进来的视图ID是否与当前状态的视图ID一致
  • 验证序号号是否为当前最大值
  • 验证当前的摘要是否与原始的request信息摘要是一致的

4.3 广播Broadcast

广播和request阶段也是一样的,所以也忽略啦!

  • 12
    点赞
  • 70
    收藏
    觉得还不错? 一键收藏
  • 13
    评论
​什么是共识算法背景分布式系统集群设计中面临着一个不可回避的问题,一致性问题对于系统中的多个服务节点,给定一系列操作,如何试图使全局对局部处理结果达成某种程度的一致?这个一致性问题大致有如下的场景:节点之间通讯不可靠的,延迟和阻塞节点的处理可能是错误的,甚至节点自身随时可能宕机节点作恶举例说明,就比如有两家电影院同时售卖总量一定的电影票,在这样的场景下,要如何设计方式来保证两家电影院协调同步不出现超卖或者错卖的问题呢?共识算法,就是解决对某一提案(目标,投票等各种协作工作),大家达成一致意见的过程比如上述的买票问题,就可以有如下的设计:1.每次卖票打电话给其他电影院,确认当前票数2.协商售卖时间,比如一三五A卖,二四六B卖3.成立个第三方存票机构,它统一发票通过以上的设计,可以看出一个很重要的解决一致性算法的解决思路,即:将可能引发不一致的并行操作进行串行化,就是现在计算机系统里处理分布式一致性问题基础思路和唯一秘诀 著名的共识设计理论FLP 不可能性原理  共识算法的理论下限提出该定理的论文是由 Fischer, Lynch 和 Patterson 三位作者于 1985 年发表,该论文后来获得了 Dijkstra(就是发明最短路径算法的那位)奖。FLP 原理认为对于允许节点失效情况下,纯粹异步系统无法确保一致性在有限时间内完成。三人三房间投票例子三个人在不同房间,进行投票(投票结果是 0 或者 1)。三个人彼此可以通过电话进行沟通,但经常会有人时不时地睡着。比如某个时候,A 投票 0,B 投票 1,C 收到了两人的投票,然后 C 睡着了。A 和 B 则永远无法在有限时间内获知最终的结果。如果可以重新投票,则类似情形每次在取得结果前发生带入到计算机领域就是说,即便在网络通信可靠情况下,一个可扩展的分布式系统的共识问题的下限是无解。即可靠性的下限是0%CAP  分布式系统领域的重要原理CAP 原理最早由 Eric Brewer 在 2000 年,ACM 组织的一个研讨会上提出猜想,后来 Lynch 等人进行了证明• C(一致性):所有的节点上的数据时刻保持同步,即数据一致• A(可用性):每个请求都能在一定时间内接受到一个响应,即低延迟• P(分区容错):当系统发生分区时仍然可以运行的定理:任何分布式系统只可同时满足二点,没法三者兼顾。即数据一致,响应及时,可分区执行不可能同时满足。举个例子:一个分布式网路上,某一个节点有一组依赖数据A,当网络无延迟,无阻塞时,依赖于X的操作可正常进行。但网络无延迟阻塞在现实世界中是没法100%保证的,那么当网络异常时,必然会产生分布式系统的分区和孤岛,那当一个执行操作在A分区之外时,如果要保证P,即当系统发生分区时仍可运行,就需要在分布式系统中多个节点有X的备份数据,以应对分区情况。则这时候就需要在C,A之间做出选择。假如选择C,即要保证数据在分布式网络中的一致性,那么就需要在X每次改动时,需要将全网节点的X数据同步刷新成最新的状态,那么在等待数据刷新完成之前,分布式系统是不可响应X的依赖操作的,即A的功能缺失假如选择A,即要突出低延迟的实时响应。那么在响应的时候,可能全节点的X数据并没有同步到最新的状态,则会导致C的缺失。上面看上去有些绕,那么你只要记住这句话,CAP原理在分布式网络系统的应用讨论,其实就是讨论在允许网络发生故障的系统中,该选择一致性还是可靠性?如果系统重视一致性,那么可以基于ACID原则做系统设计即 Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。ACID 原则描述了对分布式数据库的一致性需求,同时付出了可用性的代价。• Atomicity:每次操作是原子的,要么成功,要么不执行;• Consistency:数据库的状态是一致的,无中间状态;• Isolation:各种操作彼此互相不影响;• Durability:状态的改变是持久的,不会失效相应的有一个BASE原则,(Basic Availiability,Soft state,Eventually Consistency)则强调了可用性。 经典的共识算法设计业内,针对节点异常的情况,会有两种分类1.故障的,不响应的节点,成为非拜占庭错误2.恶意响应的节点,称为非拜占庭错误Paxos 最早的共识算法  非拜占庭算法的代表Paxos有三种角色:• proposer:提出一个提案,等待大家批准为结案。客户端担任该角色;• acceptor:负责对提案进行投票。往往是服务端担任该角色;• learner:被告知结案结果,并与之统一,不参与投票过程。即普通节点系统运行由proposer驱动,当合法提案在一定时间内收到1/2以上投票后达成共识。因此,可得出无法达成共识的条件:1.proposer故障2.二分之一以上acceptor故障拜占庭问题与BFT(Byzantine Fault Tolerant) 算法Leslie Lamport 1982 年提出用来解释一致性问题的一个虚构模型。拜占庭是古代东罗马帝国的首都,由于地域宽广,守卫边境的多个将军(系统中的多个节点)需要通过信使来传递消息,达成某些一致的决定。但由于将军中可能存在叛徒(系统中节点出错),这些叛徒将努力向不同的将军发送不同的消息,试图会干扰一致性的达成。拜占庭问题即为在此情况下,如何让忠诚的将军们能达成行动的一致。对于拜占庭问题来说,假如将军总数为 N,叛变将军数为 F,则当N>=3F+1 时,问题才有解,即叛变的将军不超过1/3时,存在有效的算法,如BFT,不论叛变者如何折腾,忠诚的将军们总能达成一致的结果。这是一个数学论证的结论,有兴趣的同学可以自行推导。PBFT  一种高效拜占庭容错共识算法PBFT是Practical Byzantine Fault Tolerance的缩写,意为实用拜占庭容错算法。该算法是Miguel Castro 和Barbara Liskov(2008年图灵奖得主)在1999年提出来的,解决了原始拜占庭容错算法效率不高的问题。他的核心思想是:对于每一个收到命令的将军,都要去询问其他人,他们收到的命令是什么。如上图,假设命令由A将军分发,假如A是作恶异常,分发给B,C,D的操作分别是1,2,3.意图扰乱共识。拜占庭容错算法上设计实现是,当B,C,D收到命令后,相互之间也会沟通从A收到的命令是否一致,从而达到识破干扰的目的。其容错的极限值就是N>=3F+1。PBFT 在区块链上的实现区块链的节点分为记账节点和普通节点两个角色记账节点负责向全网提供记账服务,并维护全局账本,每过一段时间从记账节点中选一个议长,进行命令的分发,其他记账节点则作为议员进行验证将军就是记账节点,拥有全局账本,并验证交易的有效性,过互相传达验证结果,在f共识的一般流程如下:1.任一节点接收到发送者签名的交易数据请求后,向全网广播2.所有记账节点均独立监听全网的交易数据,并记录在内存3.议长在经过t后发送共识请求提案request4.议员在收到提案后,进行相关验证,发送响应response5.任意节点在限定时间内收到至少F+1个response后,共识达成,把交易记录入区块并发布给全网,如果超时,则更换视图和议长6.任意节点在收到完整区块后,把包含的交易从内存中删除开始下一个共识循环区块产生间隔t,    记账节点n,  可容错节点数f, 视图编号v,  区块高度h, 议长编号p,  议员编号i p=(h-v)%n  未来的发展POW算法建立了比特币帝国,具有划时代的意义。但其能耗和速度问题却是制约区块链普及的两大难以解决的问题。目前POS算法是一大趋势,以太坊的Casper,EOS的DPos等都是借鉴了上述前人的设计理念做的基于应用场景的优化改造,但万变不离其宗,我和大家一样,需要不断的学习和思考,没准,能有发明出自己的共识算法的一天呢。 

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值