从 MapReduce 到 SQL

从 MapReduce 到 SQL

在最近的工作中,为了做数据分析,我开始写一些复杂的 HiveSQL。每次执行

HiveSQL 时,都会看到 Map/Reduce jobs

被调度、执行,直到最后展示出数据。渐渐地我心中多了两个疑问:

MapReduce 引擎如何工作?

SQL 是如何被翻译成 MapReduce job 的?

为了解决这两个疑问,我用比较熟悉的 Go 语言实现了一个玩具版本的

MapReduce 引擎,然后基于此实现基本的 select,join。

本文相关的源码放在仓库 ZhengHe-MD/pset

· GitHub 中,欢迎查阅。

1. MapReduce 的基本过程

如果你读过 Google 的这篇论文 MapReduce:

Simplified Data Processing on Large

Clusters,可以直接跳过本节。如果你没读过或者有些遗忘,则不妨往下看。

MapReduce job 的一般执行过程如下图所示:

注:图中每个方块代表一个文件

输入数据是一个巨大的文件,需要先把它切分成若干子文件;

Map

过程将单个子文件中的原始数据转化成一组键值对。键值对中键值的含义由开发者定义。每个子文件产生的键值对会按照对应的键散列到若干个临时文件中,在下文中我将称这些临时文件为

mapped file。键的散列值 (hash(key))

相同的数据会被写入同一个临时文件;

Reduce

过程先读取对应散列值的临时文件,根据需要重排里边的键值对,最后依照目标聚合数据;

Reduce 产出的文件,既可以被用作另一个 MapReduce job

的输入子文件,即第 1

步的输出结果,也可以被直接合并成结果文件输出。

以图中的「词频计算」为例,Map

过程读取文件中的每一个词,生成键为词语、值为常数 1 的键值对,如 “Deer,

1”、“Car, 1”。键散列值相同的数据会被散列到相同的临时文件中,交给对应的

Reduce 过程。Reduce

过程获取散列值相同的键值对后,先做一次排序,使键相同的数据物理上相邻,如图中的

3 个 “Car, 1”,然后直接加总计数值即可得到 “Car, 3”,即单词 “Car” 出现 3

次。合并所有输出文件就得到了所有词语的频度统计结果。

从性能上看,Map 和 Reduce 的执行实例可以依据 job 的大小自由扩展,使得

Map 阶段和 Reduce

阶段各自可以横向扩容计算能力。另外,整个过程中的发生的数据读写形式只有顺序

I/O,能充分满足大数据对吞吐的需求。

2. 实现一个玩具版 MapReduce

引擎

💡 What I cannot create, I do not understand.

在进入正文之前,我首先声明:这个引擎并不是我从无到有造出来的。在开干之前,我想起

4 年前做过的 MIT-6.824 作业中第一个 lab 就是

MapReduce。于是我先通读了一遍课程设计者提供的源码,随后才撸起袖子开干,这个过程肯定会有一些参考和借鉴。

2.1 领域实体 (Entities)

MapReduce 引擎需要能接收用户提交的任务,并将它拆解成多个小的计算

(map/reduce) 子任务,分发到计算节点上执行,过程如下图所示:

这里至少涉及 5 个实体:Cluster、Master、Worker、Job 和 Task。Cluster

负责管理集群的元信息、启动和关闭;master 节点负责 worker

节点的注册、发现,接收用户提交的 job,拆解成子任务 task,分发给 worker

节点执行。由于 map 与 reduce

子任务在元数据和执行过程并不相同,为了源码的可读性和可维护性,Task

又被进一步拆分成 MapTask 与 ReduceTask 两个子类。

备注:为了表述清晰,下文中会将执行 map task 的 worker 称为

mapper,执行 reduce task 的 worker 称为 reducer。

2.2 领域过程 (Interfaces)

谈到 MapReduce 引擎的领域过程,自然少不了 map 和

reduce。此外,数据在计算子任务之间是以文件的形式共享,因此数据的编解码也是引擎里重要的过程。

mapper 将输入数据按计算目标解析成键值对:

1234// Mapper is the interface that wraps the basic Map method.type Mapper interface { Map(data []byte) ([]KeyValue, error)}

reducer

将键相同的键值对聚合,由于输出的键与输入相同,在返回值中只需要给出计算结果即可:

1234// Reducer is the interface that wraps the basic Reduce method.type Reducer interface { Reduce(key string, values []string) (string, error)}

由于每个计算任务的 map 和 reduce

过程本就是为了完成某个特定的计算目标,二者的逻辑有很密切的联系,因此工程师一般会对二者同时设计、编码和测试。于是在这里额外定义一个

MapReducer,方便开发者管理 mapper 和 reducer 的逻辑。

12345// MapReducer is the interface that groups the basic Map and Reduce methods.type MapReducer interface { Mapper Reducer}

Encoder 和 Decoder 定义数据的编解码过程:

123456789// Encoder is the interface that wraps the basic Encode method.type Encoder interface { Encode(v any) error}// Decoder is the interface that wraps the basic Decode method.type Decoder interface { Decode(v any) error}

尽管在实现过程中使用的是标准库中的 json.Encoder 和

json.Decoder,但这里很有必要将这两个过程提炼出来,强调它的重要性。

2.3 业务规则 (Business Rules)

2.3.1 一次任务关联的文件

在一次 MapReduce job 执行过程中会涉及多少个不同的文件?假设共有 M 个

map task,R 个 reduce task,我以文件视角画了一张示意图:

一般拆分后的输入子文件会被放在文件系统 (如 HDFS)

的某个目录下。目录中文件的数量即为需要执行的 map task 数量 M。由于在

reduce 阶段,我们需要将键相同的键值对放进同一个 reduce task 中处理,而

map task 的输入文件中可能包含任意键值对,因此每个 map task

在执行时都可能输出到 R 个 mapped file 里,中间文件的总数为 MR。每个

reduce task 会读取散列值相同的所有 mapped file,然后执行排序和 reduce

逻辑,输出到一个 reduced file 里。将所有 reduced files

合并就能得到最终结果文件。

综上所述,一次任务关联的文件总数为

M + MR + R + 1。在我们的计算引擎中,用户在提交任务时,会通过输入文件目录

(InputDir) 所含的文件数量确定 M,通过 Job 中的字段 R 指定 R。

123456789type Job struct { Id string InputDir string // the directory where input files reside OutputDir string // the directory where output files reside ProcessorName string // the name of a MapReducer defined in mapreducers.go R int // number of reduce tasks operation *Operation // job status}

2.3.2 执行 Map/Reduce Task

引擎中,MapTask 和 ReduceTask 之间有一个隐藏的约定 —— mapped file

的命名规则:

12345// mappedFile constructs the name of the mapped file which a MapTask// generates for the corresponding ReduceTask.func mappedFile(jobId string, mapTask string, reduceTask string) string { return fmt.Sprintf("mrtmp.%s-%s-%s", jobId, mapTask, reduceTask)}

每个 mapped file 名字由 job,map task 和 reduce task

三者的标识共同决定。通过这个约定,mapper 和 reducer 都能够利用 M、R 以及

job 信息计算得到所有 mapped files 的地址。

2.3.2.1 MapTask

以下是 map task 的数据结构,其中 InputFile

是输入的子文件。

123456// MapTask provides all the information needed to run a map task.type MapTask struct { Id string InputFile string // the input file to map phase. Job *Job}

执行 map 过程时,需要打开 1 个输入文件和 R

个输出文件,利用开发者自定义的 mapper

将原始数据转化成键值对,然后根据键的哈希值选择数据应被写进的输出文件:

12345678910111213141516171819202122232425262728func (mt *MapTask) Do() (err error) { // 1. 打开输入文件,并读取原始数据 byt, err := ioutil.ReadFile(mt.InputFile) if err != nil { return } // 2. 将原始数据传给开发者自定义的 Map kvs, err := mapReducer.Map(byt) if err != nil { return } // 3. 打开 mapped files mappedFiles := make([]*os.File, 0, mt.Job.R) encoders := make([]Encoder, 0, mt.Job.R) // ... // 4. 遍历键值对,并利用 encoder 写出到相应的输出文件 var hsh int for _, kv := range kvs { if hsh, err = hash(kv.Key); err != nil { return } if err = encoders[hsh%mt.Job.R].Encode(&kv); err != nil { return } } return}

2.3.2.2 ReduceTask

以下是 reduce task 的数据结构:

123456// ReduceTask provides all the information needed to run a reduce task.type ReduceTask struct { Id string M int // number of map tasks Job *Job}

执行 reduce 时,需要打开 M 个输入文件和 1 个输出文件,先读取 M

个输入文件中的所有数据,按键排序后,将键相同的数据一组一组地交由开发者定义的

reducer

处理,并将得到的计算结果写进输出文件。在论文中提到过,如果无法在内存中完成所有数据的排序,将使用外部排序算法,本项目为了简单直接忽略了这种情况。

123456789101112131415161718192021222324252627282930313233343536func (rt *ReduceTask) Do() (err error) { var kvs []KeyValue // 1. 读取 M 个输入文件中的键值对到 kvs 中 var mf *os.File for i := 0; i < rt.M; i++ { mf, err = os.Open(mappedFile(rt.Job.Id, strconv.Itoa(i), rt.Id)) // handle err and close file... var shard []KeyValue // decode map file into shard... kvs = append(kvs, shard...) } // 2. 按键排序 sort.Slice(kvs, func(i, j int) bool { return kvs[i].Key < kvs[j].Key }) // 3. 将键相同的数据一组一组地传入 Reduce var rkvs []KeyValue var k, v string var vs []string var i int for i < len(kvs) { k, vs = kvs[i].Key, append(vs, kvs[i].Value) for i+1 < len(kvs) && kvs[i+1].Key == k { i += 1 vs = append(vs, kvs[i].Value) } if v, err = mapReducer.Reduce(k, vs); err != nil { return } rkvs = append(rkvs, KeyValue{Key: k, Value: v}) vs = vs[:0] i += 1 } // 4. 将结果写出 // (省略)}

2.3.3 执行引擎

2.3.3.1 通信协议

MapReduce job 的执行过程中涉及到了进程间通信,本项目使用 go

标准库里的 net/rpc 作为通信协议。MapReduce

集群中有两种节点:Master 和 Worker,Master

负责任务的调度和结果汇总,Worker 负责执行具体的计算过程。

master 中需要存储所有注册节点的地址,即结构体 Master 中的 workers

字段。

12345678910111213// Master is the concrete type for Master node described in the original paper.type Master struct { Address string lsn net.Listener mu sync.Mutex // protects the following fields. wi int // index of current worker, used to implement round-robin strategy. workers []string // registered worker addresses. clients map[string]*rpc.Client // map worker (address) to it's rpc client. jobs map[string]*Job // in-memory job store, which maps operation id to job. shutdown chan struct{}}

单个 worker 启动后通过 master 暴露的 Register

接口将自己的服务信息注册上去,

123456789101112131415// RegisterArgs represents arguments passed when a worker node calls Register.type RegisterArgs struct { // the communication endpoint of worker process, // such as IPC socket or Network socket Worker string}// Register is called when a Worker node wants to register itself to the Master node.func (m *Master) Register(args *RegisterArgs, _ *struct{}) error { m.mu.Lock() defer m.mu.Unlock() // ignore all sanity checks. m.workers = append(m.workers, args.Worker) return nil}

之后,master 就可以根据用户提交的 job,将对应的 map task 和 reduce

task 派发给若干 worker 节点,这里需要使用的是 worker 节点提供的

DoMapTask 和 DoReduceTask 接口。

123456789101112131415161718192021// DoMapTaskArgs represents arguments passed to Worker.DoMapTask.type DoMapTaskArgs struct { MapTask *MapTask}// DoMapTask executes the given map task synchronously.func (w *Worker) DoMapTask(args *DoMapTaskArgs, _ *struct{}) error { log.Printf("worker %s start doing map task %s\n", w.address, args.MapTask.Id) return args.MapTask.Do()}// DoReduceTaskArgs represents arguments passed to Worker.DoReduceTask.type DoReduceTaskArgs struct { ReduceTask *ReduceTask}// DoReduceTask executes the given reduce task synchronously.func (w *Worker) DoReduceTask(args *DoReduceTaskArgs, _ *struct{}) error { log.Printf("worker %s start doing reduce task %s\n", w.address, args.ReduceTask.Id) return args.ReduceTask.Do()}

2.3.3.2 顺序引擎

顺序执行引擎只用一个 worker 来执行所有的 map/reduce

task,其本身的存在意义并不大,主要用于验证概念,确认核心流程的正确性。实现顺序引擎后再实现分布式引擎就会更胸有成竹一些。

忽略异常处理逻辑,顺序引擎的大体执行过程如下:

123456789101112131415161718192021222324252627282930// sequential runs the map/reduce job sequentially on an arbitrary Worker node.func (m *Master) sequential(args *SubmitArgs, operation *Operation) (err error) { job := args.Job files, _ := ioutil.ReadDir(job.InputDir) client, _ := m.getClient() // map phase for i, file := range files { doTaskArgs := &DoMapTaskArgs{ MapTask: &MapTask{ Id: strconv.Itoa(i), InputFile: path.Join(job.InputDir, file.Name()), Job: job, }, } _ = client.Call("Worker.DoMapTask", doTaskArgs, nil) } // reduce phase for i := 0; i < job.R; i++ { doTaskArgs := &DoReduceTaskArgs{ ReduceTask: &ReduceTask{ Id: strconv.Itoa(i), Job: job, M: len(files), }, } _ = client.Call("Worker.DoReduceTask", doTaskArgs, nil) } // remove temporary files... return}

2.3.3.3 分布式引擎

与顺序引擎不同,分布式引擎会将所有 map/reduce task

分发给当前可用的多个

worker,实现计算的横向扩容。核心逻辑与顺序引擎大体相同,只是会额外使用

sync.WaitGroup 在 Map 阶段和 Reduce 阶段分别实现计算逻辑的

fan-out 和 fan-in。Map 阶段实现片段如下:

12345678910111213141516171819202122232425262728293031// distributed runs the map/reduce job on available Worker nodes in a distributed manner.func (m *Master) distributed(args *SubmitArgs, operation *Operation) (err error) { // ... // map phase var mwg sync.WaitGroup mwg.Add(len(files)) var client *rpc.Client for i, file := range files { doTaskArgs := &DoMapTaskArgs{ MapTask: &MapTask{ Id: strconv.Itoa(i), InputFile: path.Join(job.InputDir, file.Name()), Job: job, }, } client, _ = m.getClient() go func() { rpcErr := client.Call("Worker.DoMapTask", doTaskArgs, nil) if rpcErr != nil { operation.Error = rpcErr } mwg.Done() }() } mwg.Wait() log.Println("MapTask phase done.") // ...}

2.4 API

对用户而言,MapReduce

引擎只需要暴露提交计算任务的接口即可。对于不同的计算任务,所消耗的时间可从分钟级、小时级到天级甚至更长的时间,同步的接口并不是一个好的选择。这里借鉴了

googleapis 中的 Long

Running Operations API,用户通过 SubmitJob 接口提交 MapReduce

job,然后得到一个 Operation 结构,表示一个正在执行的任务:

1234567891011121314151617// SubmitArgs represents arguments passed when a client calls SubmitJob.type SubmitArgs struct { Job *Job // description of the job to submit Distributed bool // indicates whether the job should be scheduled distributively}// Operation is the reply from Master node when a client calls SubmitJob.type Operation struct { Id string Done bool Error error}// SubmitJob is called when a client wants to submit a new job to Master node.func (m *Master) SubmitJob(args *SubmitArgs, operation *Operation) error { // ...}

用户可以利用 operation 信息和 GetOperation

接口随时查询任务的执行状态:

123456789// GetOperationArgs represents arguments passed when a client calls GetOperation.type GetOperationArgs struct { Id string}// GetOperation requests the operation status of a map/reduce job.func (m *Master) GetOperation(args *GetOperationArgs, operation *Operation) error { // ...}

2.5 Demo

在 Go

运行时中动态加载用户自定义的函数并不是一件很容易的事,由于这部分功能并非本项目的主要关注点,本项目直接将每个任务对应的

MapReducer 统一定义到项目源码的 mapreducers.go

中。受限于这个选择,这个玩具版 MapReduce

引擎并无法真正支持用户提交任意自定义 MapReduce job。

2.5.1 WordCount

WordCount 即「词频计算」,是 MapReduce 世界的 “hello, world”:

123456789101112131415type WordCount struct{}func (wc WordCount) Map(data []byte) (kvs []KeyValue, err error) { for _, byt := range bytes.Fields(data) { kvs = append(kvs, KeyValue{ Key: string(byt), Value: "1", }) } return}func (wc WordCount) Reduce(key string, values []string) (string, error) { return strconv.Itoa(len(values)), nil}

WordCount 的 mapper 只需要将文本分词后输出 “[word], 1”。reducer

执行时,任意一个词,如 “hello” 对应的键值对 “hello, 1”

会被合并,因此这里 Reduce 函数的输入 key 为 “hello”,values

为一个字符串数组

["1", "1", ..., "1"],该函数只需返回这个数组的长度即得到单词

“hello” 的出现次数。

以莎士比亚的节选为例,执行测试如下:

12345678910111213141516t.Run("word count (sequential)", func(t *testing.T) { operation := new(Operation) err = client.Call("Master.SubmitJob", &SubmitArgs{ Job: &Job{ InputDir: "./mixtures/wc/input", OutputDir: "./mixtures/wc/output", ProcessorName: "wc", R: 2, }, Distributed: false, }, operation) if err != nil { t.Fatal(err) } _wait(t, client, operation)})

可以在 ./mixtures/wc/output 文件夹中看到相应的 reduced

files,其中一个输出片段如下:

1234567891011{"Key":"Feed'st","Value":"1"}{"Key":"From","Value":"1"}{"Key":"His","Value":"1"}{"Key":"How","Value":"1"}{"Key":"Pity","Value":"1"}{"Key":"Shall","Value":"1"}{"Key":"This","Value":"1"}{"Key":"Thou","Value":"1"}{"Key":"When","Value":"1"}{"Key":"Will","Value":"1"}...

克隆仓库 ZhengHe-MD/pset

· GitHub,执行对应的测试看看吧?

2.5.2 Average

假设有一个巨大的文件,文件中每行包含一个整数,想要计算这些整数的平均值,要怎么做?求平均值需要两个值:cnt

和 sum。在 Map 阶段我们可以针对每个子文件输出一个或多个

cnt 和 sum,然后在 Reduce 阶段计算

total(sum)/total(cnt) 即可,相关逻辑如下:

12345678910111213141516171819202122232425262728293031type Avg struct{}func (a Avg) Map(data []byte) (kvs []KeyValue, err error) { var cnt, sum, num int for _, byt := range bytes.Fields(data) { num, err = strconv.Atoi(string(byt)) if err != nil { return } sum += num cnt += 1 } kvs = append(kvs, KeyValue{Key: "sum", Value: strconv.Itoa(sum)}, KeyValue{Key: "cnt", Value: strconv.Itoa(cnt)}) return}func (a Avg) Reduce(key string, values []string) (value string, err error) { var sum, num int for _, v := range values { num, err = strconv.Atoi(v) if err != nil { return } sum += num } value = strconv.Itoa(sum) return}

由于所有输入子文件的输出键值对都是

{"sum": "xxx", "cnt": "yyy"},我们只能用同一个 reducer

聚合计算结果,因此 Job.R 设置为 1。

3. SQL to MapReduce Job

3.1 Select

假设有一张大表 students

记录着学生的基本数据,这张表被横向拆分成两张子表,其内容如下:

1234id,name,age10001,Michael,2410002,Jill,2610003,Richard,18

12345id,name,age10004,Hazard,2710005,Max,2510010,Alice,2010018,Jill,29

现在想实现这样一个查询:

1SELECT * FROM students WHERE name = "Jill";

应该怎么写这个

mapreducer?用肉眼我们可以直接观察到它的结果应该是:

123id,name,age10002,Jill,2610018,Jill,29

由于 select 过程只有过滤没有聚合计算,这基本意味着 reduce

阶段可以是一个 no-op。那唯一需要做的就是在 Map

阶段过滤掉不符合条件的数据即可,键值对中的键取行的唯一 id

即可,处理过程示意如下:

具体实现请参考 pset/mapreducers.go

at main · ZhengHe-MD/pset · GitHub。

3.2 Join

假设有两张表,students 和

enrollments,分别记录学生信息和他们的选课信息。它们分别被拆分成两张子表:

12345678910111213# students-1.csvstudentsid,name,age10001,Michael,2410002,Jill,2610003,Richard,18# students-2.csvstudentsid,name,age10004,Hazard,2710005,Max,2510010,Alice,2010011,Bob,29

1234567891011121314151617# enrollments-1.csvenrollmentsid,student_id,course20001,10002,Math20002,10002,Physics20003,10003,Math20004,10003,History20005,10001,Physics# enrollments-2.csvenrollmentsid,student_id,course20006,10001,History20007,10004,Music20008,10005,Music20009,10010,Chemistry20010,10011,Biology20010,10011,Math

现在需要实现这样一个查询:

123456789SELECT enrollments.id, enrollments.course students.id, students.name, students.age,FROM enrollmentsLEFT JOIN students ON enrollments.student_id = students.id;

应该怎么写 mapreducer?因为 join 条件是

students.id = enrollments.student_id,一个比较容易想到的思路就是选择被

join 的字段为键值对中的键,然后对同一个键中两表的数据执行

join,前者对应的就是 map 阶段,后者为 reduce

阶段,处理过程示意如下:

具体实现请参考 pset/mapreducers.go

at main · ZhengHe-MD/pset · GitHub。

4. 尾声

实现这个玩具引擎,的确让我对 MapReduce

有了比通读论文更深的理解。诚然,这个引擎还有诸多问题,比如:

使用本地文件系统使得其只能利用一台机器的多个

CPU,而非多台机器

无法支持开发者提交自定义的 MapReducer

Select/Join 任务实际上有很多可能的参数组合并未支持

不过目前的认识暂时够用,希望有一天在生产环境中遇到相关的问题,能有机会进一步探索大数据系统的奥义。

5. 参考

MapReduce:

Simplified Data Processing on Large Clusters

MIT-6.824, GitHub

- ZhengHe-MD/distributed-system-lab-codes-2018

ZhengHe-MD/pset

· GitHub

🌟 相关推荐

那些洗脑的英文歌
365娱乐头条

那些洗脑的英文歌

📅 01-08 👁️ 9632
迷雾世界升级攻略
棋牌365大厅

迷雾世界升级攻略

📅 07-30 👁️ 4551
浙江卫视节目表
棋牌365大厅

浙江卫视节目表

📅 06-13 👁️ 4730