分布式系统经典文献索引与语言实践


一、MapReduce

1.1 概述

MapReduce: Simplified Data Processing on Large Clusters提出了一种针对大数据处理的编程模型和实现,使得编程人员无需并行和分布式系统经验就可以轻松构建大数据处理应用。该模型将大数据处理问题拆解为两步,即 mapreducemap 阶段将一组输入的键值对转化为中间结果键值对,reduce 阶段对中间结果键值对按照相同的键进行值的合并,从而得到最终的结果。

1.2 背景

对于 Google 来说,每天运行的系统会产生大量的原始数据,同时又要对这些原始数据进行加工产生各种衍生数据,虽然大部分数据加工的逻辑都较为简单,然而由于数据量过于庞大,为了在合理的时间内完成数据处理,通常需要将待处理的数据分发到几百或几千台机器上并行计算,这就存在几个问题:

    1. 如何使计算可并行
    1. 如何分发数据
    1. 如何处理异常

如果每一个数据加工任务都需要独立去解决上述的问题,一方面会使得原本简单的代码逻辑变得庞大、复杂和难以维护,另一方面也是在重复工作。受 Lisp 等其他函数式编程语言中的 mapreduce 函数的启发,Google 的工程师们发现大部分的数据处理遵循如下的模式:

    1. 对输入的每一条数据应用一个 map 函数产生一组中间结果键值对
    1. 对中间结果键值对按照相同的键聚合后,应用 reduce 函数生成最终的衍生数据

因此,Google 的工程师们抽象出了 MapReduce 框架,使得应用开发人员可以专注于计算逻辑实现而无需关心底层运行细节,统一由框架层处理并行、容错、数据分发和负载均衡等系统问题。现在再来看前面提到的问题是如何解决的:

    1. 如何使计算可并行:在 map 阶段,对数据分发后,各任务间无依赖,可并行执行;在 reduce 阶段,不同 key 的数据处理间无依赖,可并行执行
    1. 如何分发数据:在 map 阶段,可按执行 map 任务的节点数量平均分发(这只是一种可能的策略,具体分发策略见后文描述);在 reduce 阶段,可按 key 相同的数据聚合后分发
    1. 如何处理异常:重新执行某个节点上失败的 mapreduce 任务作为首要的容错手段

1.3 系统架构与关键流程

map 执行阶段,框架会自动将输入数据分为 M 片,从而将 map 任务分发到多台机器上并行执行,每台机器只处理某一片的数据。同样的,在 reduce 阶段,框架首先将中间结果数据根据分片函数(例如 hash(key) mod R)拆分为 R 片,然后分发给 reduce 任务执行,用户可自行指定 R 的值和实现具体的分片函数。

下图展示了 Google 所实现的 MapReduce 框架的整体执行流程:
alt

    1. 首先 MapReduce 框架将输入数据分为 M 片,每片数据大小一般为 16 MB 至 64 MB(具体大小可由用户入参控制),然后将 MapReduce 程序复制到集群中的一批机器上运行。
    1. 在所有的程序拷贝中,某台机器上的程序会成为主节点(master),其余称为工作节点(worker),由主节点向工作节点分派任务,一共有 M 个 map 任务和 R 个 reduce 任务需要分派。主节点会选择空闲的工作节点分派 map 或 reduce 任务。
    1. 如果某个工作节点被分派了 map 任务则会读取当前的数据分片,然后将输入数据解析为一组键值对后传递给用户自定义的 map 函数执行。map 函数产生的中间结果键值对会暂存在内存中。
    1. 暂存在内存中的中间结果键值对会周期性的写入到本地磁盘中,并根据某个分片函数将这些数据写入到本地磁盘下的 R 个区,这样相同键的中间结果数据在不同的 map 节点下属于同一个区号,就可以在后续将同一个键的中间结果数据全部发给同一个 reduce 节点。同时,这些数据写入后的地址会回传给 master 节点,master 节点会将这些数据的地址发送给相应的 reduce 节点。
    1. 当 reduce 节点接收到 master 节点发送的中间结果数据地址通知后,将通过 RPC 请求根据数据地址读取 map 节点生成的数据。在所有中间结果数据都读取完成后,reduce 节点会先将所有中间结果数据按照键进行排序,这样所有键相同的数据就聚合在了一起。之所以要排序是因为一个 reduce 节点会分发处理多个键下的中间结果数据。如果中间结果数据量太大不足以完全载入内存,则需要使用外部排序。
    1. reduce 节点执行时会先遍历排序后的中间结果数据,每遇到一个新的键就会将该键及其对应的所有中间结果数据传递给用户自定义的 reduce 函数执行。reduce 函数执行的结果数据会追加到当前 reduce 节点的最终输出文件里。
    1. 当所有 map 任务和 reduce 任务都执行完成后,master 节点会唤醒用户程序,并将控制权交还给用户代码。

二、Raft

2.1 概述

Raft是一种分布式一致性算法,旨在解决分布式系统中的数据一致性和容错性问题。它通过选举机制确保系统中的节点选出一个领导者,领导者负责提交客户端的请求,并将一致性日志复制到其他节点,以保持系统的一致性。Raft的设计简单明了,容易理解和实现,使得分布式系统的开发和维护更加可靠和可管理。

2.2 背景

共识算法允许多台机器作为一个集群协同工作,并且在其中的某几台机器出故障时集群仍然能正常工作。正因为如此,共识算法在建立可靠的大规模软件系统方面发挥了重要作用。在过去十年中,Paxos 主导了关于共识算法的讨论:大多数共识性的实现都是基于 Paxos 或受其影响,Paxos 已经成为教授学生关于共识知识的主要工具。

比较遗憾的是,尽管很多人一直在努力尝试使 Paxos 更易懂,Paxos 还是太难理解了。此外,Paxos 的架构需要复杂的改变来支持实际系统。这导致的结果就是系统开发者和学生在学生和使用 Paxos 过程中都很挣扎。

因此Raft应运而生,Raft是一种比 Paxos 更适合用于实际工程实现并且更易懂的共识算法,在设计 Raft 时,使用了特定的技术来提高它的可理解性,包括:

    1. 分解(Raft 分离出三个关键点:leader election、log replication、safety
    1. 减少状态空间(相比于 Paxos,Raft 降低了不确定性的程度和服务器之间的不一致)

Raft 在许多方面类似于现有的公式算法,但它有几个新特性:

    1. Strong leader(强领导性):相比于其他算法,Raft 使用了更强的领导形式。比如,日志条目只能从 leader 流向 follower(集群中除 leader 外其他的服务器)。这在使 Raft 更易懂的同时简化了日志复制的管理流程。
    1. Leader election(领导选举):Raft 使用随机计时器来进行领导选举。任何共识算法都需要心跳机制(heartbeats),Raft 只需要在这个基础上,添加少量机制,就可以简单快速地解决冲突。
    1. Membership changes(成员变更):Raft 在更改集群中服务器集的机制中使用了一个 联合共识(joint consensus) 的方法。在联合共识(joint consensus)下,在集群配置的转换过程中,新旧两种配置大多数是重叠的,这使得集群在配置更改期间可以继续正常运行。

2.3 系统架构与关键流程

Raft的系统架构:

    1. 节点角色:
    • Leader(领导者):每个Raft集群中都有一个领导者,负责处理客户端的请求,管理日志复制,以及在需要时发起选举。
    • Follower(跟随者):跟随者是集群中的其他节点,它们听从领导者的指令并复制领导者的日志。
    • Candidate(候选人):当没有稳定的领导者时,跟随者可以变成候选人,尝试发起选举以成为新的领导者。
    1. 通信方式:节点之间通过RPC(Remote Procedure Call)进行通信,用于发送选票、心跳和日志复制等信息。

Raft的关键流程:
alt

    1. 领导者选举:
    • Raft开始时没有领导者,所有节点都是跟随者状态。
    • 跟随者定期向其他节点发送心跳,以维持活跃状态。
    • 如果一个跟随者在一段时间内没有收到心跳,它可以变成候选人并发起选举。
    • 在选举中,候选人请求其他节点的选票,节点投票给第一个请求选票的候选人。
    • 候选人获得多数选票后,成为新的领导者。
    1. 日志复制:
    • 客户端的请求首先发送给领导者。
    • 领导者将请求添加到自己的日志中,并将该日志条目分发给所有跟随者。
    • 跟随者复制领导者的日志,并在确认成功复制后向领导者发送响应。
    • 当大多数节点都成功复制了一条日志条目,该条目被视为已提交。
    • 领导者通知其他节点将已提交的日志条目应用到状态机中,以确保所有节点达成一致的状态。
    1. 安全性:
    • Raft通过多数派投票来确保安全性。只有大多数节点(超过半数)同意的操作才会被提交,以防止分裂大多数的情况。
    1. 故障处理:
    • 如果领导者崩溃或失去联系,跟随者会在一段时间后发起新的选举,选择新的领导者。
    • Raft的设计考虑了网络分区、节点崩溃和消息丢失等各种故障情况,并能够自动适应。

三、Golang语言编程基础实践

3.1 尝试多种方式实现两个goroutine交替打印数字与字母

例如:12AB34CD…

方式一:
由于本题不涉及复杂的同步互斥操作,所以我们可以简单的使用全局变量来控制两个goroutine的执行顺序。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import "fmt"
import "time"

var flag bool = false

func PrintNum(){
i := 1;
for true {
if flag == false {
fmt.Printf("%d%d",i,i+1)
i += 2
flag = true
}
time.Sleep(50 * time.Millisecond)
}

}
func PrintAlphabet(){
c := 'A'
for true {
if flag == true {
fmt.Printf("%c%c",c,c+1)
c += 2
if( c > 'Z') {
c = 'A'
}
flag = false
}
time.Sleep(50 * time.Millisecond)
}

}
func main() {
go PrintNum()
go PrintAlphabet()
for true {
}
}

结果如下:
alt

方式二:
使用Go语言的WaitGroupchannel来协调执行顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

import (
"fmt"
"sync"
)

func main() {
// 使用一个WaitGroup来等待两个goroutine完成
var wg sync.WaitGroup

// 创建一个channel来协调两个goroutine的输出
ch := make(chan int)

// 启动第一个goroutine,负责打印数字
wg.Add(1)
go printNumbers(ch, &wg)

// 启动第二个goroutine,负责打印字母
wg.Add(1)
go printLetters(ch, &wg)

// 等待两个goroutine完成
wg.Wait()
fmt.Printf("\n")
}

// 打印数字的goroutine
func printNumbers(ch chan int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 26; i+=2 {
// 发送数字到channel
ch <- i
// 等待从另一个goroutine接收到信号后再继续
<-ch
fmt.Print(i)
fmt.Print(i+1)
}
}

// 打印字母的goroutine
func printLetters(ch chan int, wg *sync.WaitGroup) {
defer wg.Done()
for char := 'A'; char <= 'Z'; char+=2 {
// 等待从另一个goroutine接收到信号后再继续
<-ch
fmt.Printf("%c", char)
fmt.Printf("%c", char+1)
// 发送信号到channel
ch <- 0
}
}

结果如下:
alt

3.2 实现整型堆排序

方法一:借助container/heap包

见代码注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main
import (
"container/heap"
"fmt"
)

// IntHeap 实现了 heap.Interface 接口
type IntHeap []int

// 实现必要的接口方法(Len、Less、Swap、Push 和 Pop)
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x interface{}) {
*h = append(*h, x.(int))
}
func (h *IntHeap) Pop() interface{} {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}

func heapSort(arr []int) {
// 构建最小堆
h := &IntHeap{}
*h = append(*h, arr...)
heap.Init(h)

// 依次从堆中弹出最小值,并存入原数组中
for i := 0; i < len(arr); i++ {
arr[i] = heap.Pop(h).(int)
}
}

func main() {
// 待排序的整数切片
arr := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5}
// 调用堆排序函数
heapSort(arr)
// 打印排序后的结果
fmt.Println(arr)
}

结果如下:
alt

方法二:数组模拟
首先构建一个最大堆,然后逐个将最大元素移到数组的末尾,并在每一步重新构建堆。heapify 函数用于维护堆的性质,将一个节点与其子节点进行比较并交换,以确保根节点是最大元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package main
import "fmt"

func main() {
arr := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5}
heapSort(arr)
fmt.Println(arr)
}

func heapSort(arr []int) {
n := len(arr)

// 构建最大堆
for i := n/2 - 1; i >= 0; i-- {
heapify(arr, n, i)
}

// 逐个将最大元素移到数组末尾,然后重新构建堆
for i := n - 1; i > 0; i-- {
// 将当前根节点(最大元素)移到末尾
arr[0], arr[i] = arr[i], arr[0]
// 重新构建堆,排除已排序的元素
heapify(arr, i, 0)
}
}

func heapify(arr []int, n, i int) {
largest := i
left := 2*i + 1
right := 2*i + 2

// 找到左子节点和右子节点中较大的节点
if left < n && arr[left] > arr[largest] {
largest = left
}
if right < n && arr[right] > arr[largest] {
largest = right
}

// 如果较大的节点不是根节点,则交换它们,并继续向下构建堆
if largest != i {
arr[i], arr[largest] = arr[largest], arr[i]
heapify(arr, n, largest)
}
}

结果如下:
alt

3.3 Golang总结

goc++的异同点:

  • 相同点:

      • 静态类型:Go和C++都是静态类型语言,编译时检查类型。
      • 支持并发:Go和C++都支持并发编程,但它们的并发模型和语法不同。Go使用goroutine和channel来实现并发,而C++可以使用多线程和原生线程库来处理并发。
  • 不同点:

      • 语法和语言设计:Go和C++的语法和语言设计有很大差异。Go以简洁、清晰和易于阅读的语法著称,而C++则更加复杂和灵活,允许更多底层控制。
      • 内存管理:Go具有自动垃圾回收(Garbage Collection)机制,开发者无需手动管理内存。C++需要手动管理内存,这可能导致内存泄漏和悬挂指针等问题。
      • 面向对象编程:C++是一种多范式语言,支持面向对象编程(OOP)和其他范式。Go也支持OOP,但其面向对象模型相对简单,没有继承的概念,而是使用接口来实现多态。
      • 生态系统:Go拥有强大的标准库和丰富的第三方库,特别适用于构建网络服务和分布式系统。C++有广泛的库和框架,适用于系统编程、游戏开发等领域。
      • 编译和运行时间:Go具有快速的编译时间和运行时间,适合开发迅速的应用程序。C++的编译时间可能较长,但可以生成高性能的本机代码。