MapReduce 简介
MapReduce 是一种编程模型,用于在集群上使用并行分布式算法处理和生成大数据集。其基本思想是将输入数据拆分成更小、可管理的块,以便可以并行处理(Map 阶段),然后将结果组合以生成最终输出(Reduce 阶段)。
MapReduce 的工作原理
1. Map 阶段(Mapper):
输入数据被划分为小块,并分配到多个“mapper ”上。
每个mapper 独立处理其数据块,并输出键值对(<key, value>
)。
2. Shuffle 和 Order:
在所有 mapper 生成键值对后,系统根据键对所有值进行分组(洗牌和排序阶段),确保相同键的所有值都发送到同一个 reducer 。
3. Reduce 阶段(Reducer):
每个reducer 处理一组具有相同键的键值对。它对每个键的值执行某种操作以聚合或总结这些值,并生成最终结果。
总结分析:
其实 mapper 并没有什么特殊,它只不过担任一个 worker 的角色,接收数据(比如来自 Stdin),处理数据(既可以自己处理,也可以发给 Server 进行处理)
唯一的特殊就是: 有一个 Master 程序负责动态的生成多个 workers,每个 worker 都是一个独立的执行线程,去执行 mapper 所实现的功能,mapper 只需处理一小部分数据。然后把处理后的结果发送给 Master 程序进行 reduce 或 combine。当然,也可以发送给专门的 reducers 来整合,最后再发给 Master 汇总。
下面是我绘制的一个大致的流程图:
接下来我们使用 MapReduce 的原理来实战一个 word count 程序
package main
import (
"fmt"
"strings"
"sync"
)
// Mapper function: splits the input text into words and emits <word, 1> pairs
func mapper(text string) map[string]int {
words := strings.Fields(text)
result := make(map[string]int)
for _, word := range words {
result[word]++
}
return result
}
// Reducer function: combines the results from multiple mappers by summing up the values for each word
func reducer(mappedData []map[string]int) map[string]int {
finalResult := make(map[string]int)
for _, partialResult := range mappedData {
for word, count := range partialResult {
finalResult[word] += count
}
}
return finalResult
}
func main() {
// Input data: a list of text blocks
textBlocks := []string{
"hello world",
"world of code",
"hello gophers",
"hello world of gophers",
}
// Step 1: Map Phase
var wg sync.WaitGroup
var mu sync.Mutex
mappedData := make([]map[string]int, len(textBlocks))
for i, text := range textBlocks {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
result := mapper(text)
mu.Lock()
mappedData[i] = result
mu.Unlock()
}(i, text)
}
wg.Wait()
// Step 2: Reduce Phase
result := reducer(mappedData)
// Output the final word count result
for word, count := range result {
fmt.Printf("%s: %d\n", word, count)
}
}
输出结果:
hello: 3
world: 2
of: 2
code: 1
gophers: 2
代码解释:
-
Mapper:mapper 函数接收一段文本并将其拆分为单词。对于每个单词,它发出一个键值对,其中键是单词,值是数字 1。结果是该文本块的单词计数键值对(
<word: count>
)。 -
Reducer:reducer 函数接收多个 mapper 的结果(映射列表)并将它们合并。它对所有 mapper 结果中的每个单词的计数进行累加,生成每个单词的最终计数。
-
并行执行:主函数使用 goroutines 并行处理每个文本块。sync.WaitGroup 确保程序等待所有goroutines 完成,而 sync.Mutex 确保对共享的 mappedData 切片的线程安全写入。
-
最终输出:最终结果是所有输入文本块中每个单词的计数。
升级
这个 word count 是最典型的使用 MapReduce 思想的应用。程序非常简单,接下来我们增加一点复杂度。 下面是新的要求:
-
程序从磁盘文件读取数据(假设文件名为
data.txt
)。 -
data.txt
包含大量用空格分隔的单词,可以根据测试数据量的需要来控制行数。 -
实现一个生成器程序来生成
data.txt
,该程序应从字典文件dict.txt
中读取单词,以随机生成单词。 -
主程序只需计数预定义的单词列表,忽略其他单词。假设它只关注五个单词:Happy、Sex、Food、Bank 和 Physics。
-
忽略单词的大小写。
-
主程序需要决定实际需要多少个mapper ,以避免给系统带来过重的负担。换句话说,它应该限制生成的最大 goroutines 数量,可能需要实现一个 goroutine 池。
为了方便理解,我绘制了一个流程图:
实现
我们需要实现两个程序,一个是 data generator 用于生成测试数据,另一个是主程序,使用 mapreduce 思想来统计单词数量。这里省去 data generator。
MapReduce 主程序:
mapred_wc1.go
package main
import (
"bufio"
"fmt"
"os"
"strings"
"sync"
"time"
)
// Words of interest to count (all lowercase now)
var wordsOfInterest = map[string]bool{
"happy": true,
"sex": true,
"food": true,
"bank": true,
"physics": true,
}
// Mapper function: counts only the words of interest in a given line (case insensitive)
func mapper(line string) map[string]int {
result := make(map[string]int)
words := strings.Fields(line)
for _, word := range words {
normalizedWord := strings.ToLower(word) // Normalize to lowercase
if wordsOfInterest[normalizedWord] {
result[normalizedWord]++
}
}
return result
}
// Reducer function: combines the results from multiple mappers
func reducer(mappedData []map[string]int) map[string]int {
finalResult := make(map[string]int)
for _, partialResult := range mappedData {
for word, count := range partialResult {
finalResult[word] += count
}
}
return finalResult
}
// Goroutine worker pool to process lines with a limited number of workers
func workerPool(jobs <-chan string, results chan<- map[string]int, wg *sync.WaitGroup) {
defer wg.Done()
for line := range jobs {
results <- mapper(line)
}
}
func main() {
startTime := time.Now()
// Open file for reading
file, err := os.Open("data100k.txt")
if err != nil {
panic(err)
}
defer file.Close()
// Channel for job distribution and result collection
jobs := make(chan string, 100) // Buffer size 100 for incoming lines
results := make(chan map[string]int) // For the mapper's output
// Restrict goroutines to a maximum of 10 workers
const maxWorkers = 15
var wg sync.WaitGroup
// Start worker pool
for i := 0; i < maxWorkers; i++ {
wg.Add(1)
go workerPool(jobs, results, &wg)
}
// Collect results asynchronously
var resultWg sync.WaitGroup
resultWg.Add(1)
go func() {
defer resultWg.Done()
var allResults []map[string]int
for res := range results {
allResults = append(allResults, res)
}
// Final reduction after all results are gathered
finalResult := reducer(allResults)
for word, count := range finalResult {
fmt.Printf("%s: %d\n", word, count)
}
}()
// Read file line by line and send to jobs channel
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
jobs <- line
}
close(jobs)
// Wait for all workers to finish
wg.Wait()
close(results)
// Wait for results collection and reduction to finish
resultWg.Wait()
fmt.Printf("Execution Time: %s\n", time.Since(startTime))
}
点评:
这个程序存在 goroutine 的一些竞争问题,效率并不算高,再加上 goroutine 的创建销毁等开销,增加 goroutine 的数量反而可能会伤害性能。
为了进一步提升性能,我再次设计了一个优化的流程,使用多个 reducer。
下面是具体的要求:
-
生成多个工作线程(最多10个)以从
data.txt
中读取单词行。 -
每个工作线程查找感兴趣的 word,并将其计数发送到相应的 word channel。
-
word channel 的数量与预定义的感兴趣的 word 数量相同。
-
reducer 工作线程的数量与 word channel 的数量相同。
-
每个 reducer 工作线程统计其 word chennel 的总数,并将结果发送到 combine channel。
-
主程序从 combine channel 读取数据并打印结果。
同样,为了方便理解,我绘制了程序的执行流程图。
主程序
mapred_wc2.go
package main
import (
"bufio"
"fmt"
"os"
"strings"
"sync"
"time"
)
// Words of interest
var wordsOfInterest = []string{"happy", "sex", "food", "bank", "physics"}
// Mapper function: Counts occurrences of words of interest in a line (case-insensitive)
func mapper(line string) map[string]int {
result := make(map[string]int)
words := strings.Fields(line)
for _, word := range words {
normalizedWord := strings.ToLower(word) // Normalize to lowercase
for _, interestWord := range wordsOfInterest {
if normalizedWord == interestWord {
result[normalizedWord]++
}
}
}
return result
}
// Worker function to process lines and send word counts to designated channels
func worker(jobs <-chan string, wordChans map[string]chan int, wg *sync.WaitGroup) {
defer wg.Done()
for line := range jobs {
counts := mapper(line) // Map the line
for word, count := range counts {
wordChans[word] <- count // Send counts to corresponding channels
}
}
}
// Reducer function to aggregate word counts from its designated channel
func reducer(word string, wordChan <-chan int, combineChan chan<- map[string]int, wg *sync.WaitGroup) {
defer wg.Done()
totalCount := 0
for count := range wordChan {
totalCount += count
}
// Send final count to the combine channel
combineChan <- map[string]int{word: totalCount}
}
func main() {
startTime := time.Now()
// Open file for reading
file, err := os.Open("data100m.txt")
if err != nil {
panic(err)
}
defer file.Close()
// Channels
jobs := make(chan string, 100) // Channel for distributing lines to workers
wordChans := make(map[string]chan int) // Channels for each word of interest
combineChan := make(chan map[string]int, len(wordsOfInterest)) // Channel to collect final results
// Initialize word-specific channels
for _, word := range wordsOfInterest {
wordChans[word] = make(chan int, 100) // Buffer size of 100 for each word channel
}
// Workers
var workerWg sync.WaitGroup
maxWorkers := 5
for i := 0; i < maxWorkers; i++ {
workerWg.Add(1)
go worker(jobs, wordChans, &workerWg)
}
// Reducers for each word
var reducerWg sync.WaitGroup
for _, word := range wordsOfInterest {
reducerWg.Add(1)
go reducer(word, wordChans[word], combineChan, &reducerWg)
}
// Read lines from file and distribute them to workers
go func() {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
jobs <- line
}
close(jobs)
workerWg.Wait() // Wait for all workers to finish
for _, ch := range wordChans {
close(ch) // Close all word-specific channels after workers are done
}
}()
// Wait for all reducers to finish
go func() {
reducerWg.Wait()
close(combineChan) // Close combine channel after all reducers are done
}()
// Print final results from combine channel
finalResults := make(map[string]int)
for result := range combineChan {
for word, count := range result {
finalResults[word] = count
}
}
// Output the results
fmt.Println("Final word counts:")
for word, count := range finalResults {
fmt.Printf("%s: %d\n", word, count)
}
fmt.Printf("Execution Time: %s\n", time.Since(startTime))
}
测试效果:
(1) 100万数据:
mapred_wc1: 677.334292ms
mapred_wc2: 561.095166ms
(2) 1000万数据:
mapred_wc1: 6.7s
mapred_wc2: 3.9s
(3) 1亿数据:
mapred_wc1: 1m18.787939375s
mapred_wc2: 43.700050417s
点评与分析
数据量越大的时候,mapred_wc2
的优势越明显。
注意到,无论是 wc1 还是 wc2,mapper 都不做很复杂的工作。worker 线程主要的开销可能来源于创建销毁,以及访问 channel 时的竞争。
因为我们可以看到,在 wc1 中,worker 线程在从 input channel 中取到数据之后,它很快就能处理完并写入 result channel,当 worker 数量增加之后,多个 worker 会同时向同一个 channel 中写数据,这会导致可能的竞争问题,从而拖累性能。另外,多个 worker 从 input channel 中取数据也可能存在竞争。
而在 wc2 中,把 result channel 细化成了 5 个不同的 word channel,worker 往 word channel 中写数据,虽然也存在多个 workers 同时向同一个 word channel 写数据而导致竞争的情况,但是几率会小很多。其他方面,worker 的开销是与 wc1 中差不多的。总体来说,性能会有不错的提高。
全文完!
如果你喜欢我的文章,欢迎关注我的微信公众号 deliverit。