miniob-transaction.md 15.3 KB
Newer Older
羽飞's avatar
羽飞 已提交
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
本篇文档介绍 MiniOB 中的事务模块是如何工作的。

# 背景
事务是数据库中非常基础的一个模块,也是非常核心的功能。事务有一些基本的概念,叫做ACID,分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。如果你对事务的基本概念不太清楚,建议先学习了解事务的基本概念,比如学习[事务处理](lectures/lecture-6.md)章节,或者在网上搜索更多资料。

# MiniOB 中的事务
## 实现简介
MiniOB 作为一个帮助学习数据库的代码,为了使其学习起来更简单,实现了两种类型的事务。一个叫做Vacuous,另一个叫做MVCC,可以在启动observer时,选择特定的事务模块。

**Vacuous(真空)**

顾名思义,这个事务模块,将不会做任何事务相关的处理,这保留了原始的简单性,有利于学习其它模块时,简化调试。

**MVCC**

多版本并发控制,是一种常见的事务实现。著名的MySQL数据库也支持MVCC,[OceanBase](https://github.com/oceanbase/oceanbase)也实现了此机制。

简单来说,MVCC 会在修改数据——MiniOB当前支持插入和删除——时,不会直接在现有的数据上修改,而是创建一个新的行记录,将旧数据复制出来,在新数据上做修改。并将新旧数据使用链表的方式串联起来。每个数据都会有自己的版本号(或者称为时间戳),而版本号通常使用单调递增的数字表示,每个事务根据自己的版本号与数据的版本号,来判断当前能够访问哪个版本的数据。由此可见,MVCC 一个优点就是可以提高只读事务的并发度,它不与其它的写事务产生冲突,因为它访问旧版本的数据就可以了。

> NOTE: 不同的数据库,会有不同的实现方式。对MVCC感兴趣的同学,可以阅读一些相关的论文。

## 如何运行与测试
当前MiniOB支持两种类型的事务模型,并且默认情况下是Vacuous,即不开启事务特性。

**测试MVCC**

编译时增加选项 `-DCONCURRENCY=ON`:
```bash
cmake -DCONCURRENCY=ON ..
```
然后在build目录执行 make。编译完成后启动 observer 服务端进程。

羽飞's avatar
羽飞 已提交
33 34
> 也可以使用 bash build.sh -DCONCURRENCY=ON 来编译

羽飞's avatar
羽飞 已提交
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196
可以在启动observer时,增加 `-t mvcc` 选项来开启MVCC,假设当前目录是build(或build_debug之类):

```bash
./bin/observer -f ../etc/observer.ini -s miniob.sock -t mvcc
```

>-f 是配置文件,-s 指使用unix socket,-t 指定使用的事务模型

启动observer后,可以使用obclient连接observer:

```bash
./bin/obclient -s miniob.sock
```

可以开启多个客户端。在命令行界面执行 `begin` 可以开启事务,执行 `commit` 提交事务,`rollback` 回滚事务。

## 更多的实现原理

事务代码位于 `src/observer/storage/trx` 目录下,代码很少。

### 事务模型选择
trx.h 文件中有一个抽象类 `TrxKit`,它可以根据运行时参数传入的名字来创建对应的 `VacuousTrxKit``MvccTrxKit`。这两个类可以创建相应的事务对象,并且按照需要,初始化行数据中事务需要的额外表字段。当前 Vacuous 什么字段都不需要,而MVCC会额外使用一些表字段。

### 事务接口
不同的事务模型,使用了一些统一的接口,这些接口定义在 `Trx` 中。

**事务本身相关的操作**

- start_if_need。开启一个事务。在SQL请求处理过程中,通常需要开启一个事务;
- commit。提交一个事务;
- rollback。回滚一个事务。

**行数据相关的操作**

- insert_record。插入一行数据。事务可能需要对记录做一些修改,然后调用table的插入记录接口。提交之前插入的记录通常对其它事务不可见;
- delete_record。删除一行数据。与插入记录类似,也会对记录做一些修改,对MVCC来说,并不是真正的将其删除,而是让他对其它事务不可见(提交后);
- visit_record。访问一行数据。当遍历记录,访问某条数据时,需要由事务来判断一下,这条数据是否对当前事务可见,或者事务有访问冲突。

### MVCC 相关实现
**版本号与可见性**

与常见的MVCC实现方案相似,这里也使用单调递增的数字来作为版本号。并且在表上增加两个额外的字段来表示这条记录有效的版本范围。两个版本字段是`begin_xid``end_xid`。每个事务在开始时,就会生成一个自己的版本号,当访问某条记录时,判断自己的版本号是否在该条记录的版本号的范围内,如果在,就是可见的,否则就不可见。

> 有些文章或者某些数据库实现中,使用"时间戳"来表示版本号。如果可以保证时间戳也是单调递增的,那这个时间戳确实更好可以作为版本号,并且在分布式系统中,比单纯的单调递增数字更好用。

**记录版本号与事务版本号**

行数据上的版本号,是事务设置的,这个版本号也是事务的版本号。一个写事务,通常会有两个版本号,在启动时,会生成一个版本号,用来在运行时做数据的可见性判断。在提交时,会再生成一个版本号,这个版本号是最终设置在记录上的。

```cpp
trx start:
  trx_id = next_id()
  read record: is_visible(trx_id, record_begin_xid, record_end_xid)

trx commit:
  commit_id = next_id()
  foreach updated record: update record begin/end xid with commit_id
```

>Q:为什么一定要在提交时生成一个新的版本号?只用该事务之前的版本号不行吗?会有什么问题?

**版本号与插入删除**

新插入的记录,在提交后,它的版本号是 `begin_xid` = 事务提交版本号,`end_xid` = 无穷大。表示此数据从当前事务开始生效,对此后所有的新事务都可见。
而删除相反,`begin_xid` 保持不变,而 `end_xid` 变成了当前事务提交的版本号。表示这条数据对当前事务之后的新事务,就不可见了。
记录还有一个中间状态,就是事务刚插入或者删除,但是还没有提交时,这里的修改对其它事务应该都是不可见的。比如新插入一条数据,只有当前事务可见,而新删除的数据,只有当前事务不可见。需要使用一种特殊的方法来标记,当然也是在版本号上做动作。对插入的数据,`begin_xid` 改为 (-当前事务版本号)(负数),删除记录将`end_xid`改为 (-当前事务版本号)。在做可见性判断时,对负版本号做特殊处理即可。

假设某个事务运行时trx id是 Ta,提交时是 Tc

| operation | trx state | begin xid | end xid |
| --------- | --------- | --------- | ------- |
| inserted  | committed | Tc | +∞ |
| deleted   | committed | some trx_id | Tc |
| insert    | uncommit  | -Ta | +∞ |
| delete    | uncommit  | some trx_id | -Ta |

**并发冲突处理**

MVCC很好的处理了只读事务与写事务的并发,只读事务可以在其它事务修改了某个记录后,访问它的旧版本。但是写事务与写事务之间,依然是有冲突的。这里解决的方法简单粗暴,就是当一个写事务想要修改某个记录时,如果看到有另一个事务也在修改,就直接回滚。如何判断其它事务在修改?判断`begin_xid``end_xid`是否为负数就可以。

**隔离级别**

我们通常在聊事务隔离级别时,都会说脏读(Read Uncommitted)、读提交(Read Committed)、可重复读(Repeatable Read)和可串行化(Serializable),说这些时也通常都会提到大名鼎鼎的MySQL。但实际上隔离级别不止是这4种。
不过这里也没有对隔离级别做特殊的处理,让它顺其自然。

>Q: 通过上面的描述,你知道这里的MVCC是什么隔离级别吗?


## 遗留问题和扩展
当前的MVCC是一个简化版本,还有一些功能没有实现,并且还有一些已知BUG。同时还可以扩展更多的事务模型。

- 事务提交时,对外原子可见

  当前事务在提交时,会逐个修改之前修改过的行数据,调整版本号。这造成的问题是,在某个时刻,有些行数据的版本号已经修改了,有些还没有。那可能会存在一个事务,能够看到已经修改完成版本号的行,但是看不到未修改的行。
  比如事务A,插入了3条数据,在提交的时候,逐个修改版本号,某个情况下可能会存在下面的场景(假设A的事务ID是90,commit id是100):

  | record | begin xid | end xid | data |
  | ------ | --------- | ------- | ---- |
  | R1     | 100       | +∞      | ...  |
  | R2     | 100       | +∞      | ...  |
  | R3     | -90       | +∞      | ...  |

  此时有一个新的事务,假设事务号是 110,那么它可以看到记录R1和R2,但是看不到R3,因为R3从记录状态来看,还没有提交。

- 垃圾回收

  随着数据库进程的运行,不断有事务更新数据,不断产生新版本的数据,会占用越来越多的资源。此时需要一种机制,来回收对任何事务都不再可见的数据,这称为垃圾回收。垃圾回收也是一个很有趣的话题,实现方式有很多种。最常见的是,开启一个或多个后台线程,定期的扫描所有的行数据,检查它们的版本。如果某个数据对当前所有活跃事务都不可见,那就认为此条数据是垃圾,可以回收掉。当然,这种回收方法最简单,也是最低效的,同学们如何优化或者实现新的回收方法。

- 多版本存储

  当前miniob仅实现了插入和删除,并不支持更新操作。而插入和删除最多会存在两个版本的数据,从实现上来看,最多需要一条数据就可以。这大大简化了MVCC的实现。但是也因此没有涉及到MVCC非常核心的多版本数据存储问题。如何合理的存储多个版本的数据,对数据库的性能影响也是巨大的。比如多个版本数据串联时,使用从新到旧,还是从旧到新。两种方式都有合理性,适用于不同的场景。另外还有,多版本的数据存储在哪里?内存还是磁盘,是与原有的数据放在同一个存储空间,还是规划单独的空间,各有什么优缺点,都适用于什么场景。还有,更新数据时,复制整行数据,还是仅记录更新的字段。各有什么优缺点,各适用于什么场景。

- 持久化事务

  持久性是事务的基本要素之一,是指事务修改后的数据,在数据库重启或者出现异常时,能够从磁盘中将数据恢复出来。除了将修改的数据直接写入到磁盘,还有一个常用的技术手段是WAL,比如Redo日志和Undo日志。那么什么情况下使用Redo,什么时候使用Undo,以及如果只使用Redo或者只使用Undo会有什么问题。另外还有如何存储这些日志,B+树的持久化怎么处理等。有兴趣的同学可以再了解一下 `Steal/No-Steal``Force/No-Force` 的概念。

- MVCC的并发控制
  
  如前文描述,这里的写事务并发冲突处理过于简单粗暴,以至于可以避免的冲突却没有避免。

- 基于锁的并发控制

  MVCC的并发控制通常认为是乐观事务,就是我们认为此系统中事务之间大部分情况下不会存在冲突。但是在OLTP系统中,访问冲突可能是非常频繁发生的,这时候使用悲观事务,效率会更高一点。常见的悲观事务实现方法就是基于锁来实现。假设使用记录锁(行锁)来实现并发,在读数据时加读锁,写时加写锁,也可以实现多种级别的隔离机制。另外,还可以将使用基于锁的机制与MVCC结合起来,实现更好的并发控制。

  顺便提一下一个常见的问题,就是在使用行锁时,如何与页面锁(latch)协调?
  大家都知道,latch 都是短锁,在latch保护范围内,都不应该出现长期等待的事情。另外,latch没有死锁检测,不处理锁冲突。而行锁是一种长锁,需要做锁冲突处理,可能需要等待。那在拿着某个latch时,需要等待行锁时,如何处理?

  > 这是很多做了CMU 15445课程的同学没有考虑的问题,15445 课程中将 `Lock Manager` 模块单独拎出来让同学们做练习。但是当行锁与latch同时工作时,它的复杂度将提升好几个量级。


# 进一步学习

事务是非常复杂非常有趣的,相关的话题也已经有非常多的研究。如果对事务感兴趣,可以在了解数据库整体实现基础之上,深入研究事务的实现原理。
这里推荐一些介绍数据库入门的书籍:
- 《数据库系统概念》该书是数据库领域的经典教材之一,涵盖了数据库基本概念、关系型数据库设计、事务处理、并发控制等方面的内容,也可以着重阅读事务相关的内容
- 《数据库系统实现》:该书是一本数据库系统实现方面的教材,讲解了数据库系统的核心组成部分,包括事务处理、索引、查询优化等方面的内容,对事务处理机制进行了较为细致的讲解
- 《MySQL技术内幕:InnoDB存储引擎》:该书是一本MySQL数据库方面的重要教材,对InnoDB存储引擎的事务处理机制进行了详细的阐述,包括事务的隔离级别、MVCC实现、锁机制等方面。

想直接上手看工业届的事务实现原理,欢迎阅读:
- [分布式事务并发控制的思考与实现](https://open.oceanbase.com/blog/1100128)
- [OceanBase事务引擎的技术创新](https://open.oceanbase.com/blog/1100194)
- [深入浅出事务的本质,附OceanBase事务解析14问!](https://open.oceanbase.com/blog/1100248)

内功深厚想要直接阅读源码:[OceanBase 事务源码](https://github.com/oceanbase/oceanbase/tree/master/src/storage/tx)

还有一些著名开放课程,理论结合实践,比如 
- [CMU 15445](https://15445.courses.cs.cmu.edu/spring2023/)。理论结合实践,不仅有课程讲解,还有实验项目,对理解数据库实现原理帮助非常大
- [CMU 15721](https://15721.courses.cs.cmu.edu/spring2023/)。卡内基梅陇大学的数据库进阶课程

如果上面的还感觉太浅,可以持续找一些事务的论文研读:

- [A Critique of ANSI SQL Isolation Levels](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf) 该论文针对ANSI SQL标准中隔离级别的定义进行了深入的分析,提出了一些改进的建议。天天看到RC/RR名词的,可以看看这篇论文,了解更详细一点。

- [Granularity of Locks and Degrees of Consistency in a Shared Data Base](https://web.stanford.edu/class/cs245/readings/granularity-of-locks.pdf) 这也是一个又老又香的论文,提出了基于锁的并发控制。

- [An Empirical Evaluation of InMemory Multi-Version Concurrency Control](https://www.vldb.org/pvldb/vol10/p781-Wu.pdf) 介绍MVCC可扩展性的。通过这篇论文可以对MVCC有非常清晰的认识。

- [Scalable Garbage Collection for In-Memory MVCC Systems](http://www.vldb.org/pvldb/vol13/p128-bottcher.pdf) 这里对各种垃圾回收算法做了说明,并且有些创新算法。

大家看了这些论文会发现,都是一些陈年老论文。数据库领域发展这么多年了,技术依然不过时。

如果这些还不够,可以问问ChatGPT还有啥资料。如果觉得单机上的玩腻了,可以再看看分布式事务,总之希望你能玩得愉快。