面试官:PostgreSQL的MVCC是怎样实现的?
大家好,我是君哥。
为了解决读写冲突,数据库提供了多种手段,比如表级和行级锁。而为了进一步提升并发性能,引入了 MVCC(多版本并发控制)。今天来聊一聊 PostgreSQL 的 MVCC 是怎样实现的。
1.隔离级别
1.1 四类问题
使用数据库时,事务的并发常常会遇到四类问题:
脏读:事务 A 读取了事务 B 未提交的修改数据。如果事务 B 回滚,事务 A 读取的数据就是无效的脏数据。 不可重复读:同一事务内多次读取同一行数据,这条数据因为被其他事务修改过并且已经提交事务,导致多次读取到的结果不一致。 幻读:同一事务内多次查询同一范围内的数据,因其他事务插入或删除符合条件的数据,导致事务在后面读取到的结果集不一样,像产生了幻觉。 序列化异常:成功提交一个事务,结果可能跟按照不同顺序提交这组事务的结果不一致。
1.2 隔离级别
针对脏读、幻读、不可重复读的问题,数据库引入了 4 种标准隔离级别:
串行化(Serializable):事务对数据读写都是串行化的。 可重复读(Repeatable Read):事务执行过程中,多次读取同一行数据,读取结果一致。 读已提交数据(Read Committed):事务执行过程中,如果有其他事务修改了数据并且提交事务,当前事务可以读取到最新提交的数据。 读未提交数据(Read Uncommitted):事务执行过程中,可以读取到其他事务未提交的数据。
在多数数据库中,我们可以使用四个标准事务隔离级别中的任何一个。 但其实 PostgreSQL 内部只实现了三个不同的隔离级别,PostgreSQL 的 Read Uncommitted 级别跟 Read Committed 类似。这是将标准隔离级别映射到 PostgreSQL 的 MVCC 架构的唯一合理方法。如下表格:
这个表格也可以看出,在 Repeatable read 这个隔离级别下,PostgreSQL 是不允许出现幻读的。这个也可以看出 PostgreSQL 的隔离级别比 SQL 标准有更高要求的保障。
PostgreSQL 的一些数据类型和函数对事务有特殊的规则,比如 sequence 的修改对其他事务立即可见,即使修改 sequence 的事务回滚,sequence 的修改也不能回滚。
2.MVCC 原理
我们知道,在 MySQL 中,InnoDB 采用聚簇索引保存数据,MVCC 则通过数据的版本链来实现,版本链中旧版本的数据保存在 Undo Log,数据表中只保留最新版本的数据。
PostgreSQL 采用堆表存储数据,MVCC 实现机制也有所不同。当数据被修改时,旧版本的数据仍然保存在数据表中,新版本的数据作为新的行插入。通过在每行数据中额外维护版本相关字段,实现 MVCC。
2.1 存储结构
在 PostgreSQL 的堆表结构中,存储数据的单元是 tuple(元组)。我们可以把每个 tuple 比作一行数据的一个版本,结构如下:
在用户数据之前,会增加 8 个字段,其中 null bitmap 和 object ID 是可选,就是说会固定有 23 个字节。
t_xmin:最小事务 id,也就是插入这一行数据的事务 id。 t_xmax:最大事务 id,也就是删除这一行数据的事务 id。 t_cid:CommandId,用于记录插入或删除操作是事务中的第几条命令。 t_xvac:事务 id,用来记录 VACUUM 移动行的版本。 t_ctid:数据指针,指向事务对此行数据修改的当前版本或者更新的版本。结构为(页号, 行偏移),例如 (0,1) 表示第 0 页的第 1 行。 t_infomask2:表中字段数量和各种标志位。 t_infomask:各种标志位。 t_hoff:记录用户数据的偏移量。
null bitmap:记录表中的空值,只有 HEAP_HASNULL 标识被设置在 t_infomask,null bitmap 才会有值。如果有值,则为表中每个字段分配一个 bit(1 代表非空,0 代表空),也就是说这里定义的 bit 的数量等于 t_infomask2 定义的字段数量。如果 null bitmap 没有值,则表中所有字段都是非空。 object ID:兼容老版本,记录 OID(每个表、索引、视图、函数、类型等都会被分配一个唯一的 OID)。如果 HEAP_HASOID_OLD 标识被设置在 t_infomask,则 object ID 就会有值。
2.2 更新操作
下面我们看一下 PostgreSQL 存储结构的使用。首先我们执行一条插入 SQL:
insertinto tb_test(id, name) value(1, 'jam');
这条 SQL 执行的事务 xid=10,执行这个事务后,插入一条 tuple,记为 tuple1, tuple1 的值是 [t_xmin:10, t_xmax:0, t_cid:0, t_ctid: (0,1)]
t_xmin:10,插入这个 tuple 的事务 xid=10。 t_xmax:0,这行数据未被删除。 t_cid:0,事务 xid=10 这个事务的第一个 SQL 命令。 t_ctid:设置为 (0,1),指向自身,因为这是该 tuple 的最新版本。
接着,我们执行第二个事务 xid=11,里面包含一条更新语句。
update tb_test setname = 'tom'whereid = 1;
这条 SQL 更新这条数据的 name 为 tom,这时需要插入一条新的 tuple,记为 tuple2,tuple2 的值是 [t_xmin:11, t_xmax:0, t_cid:0, t_ctid: (0,2)]
t_xmin:11,插入这个 tuple 的事务 xid=11。 t_xmax:0,这行数据未被删除。 t_cid:0,事务 id xid=11 这个事务的第 1 个 SQL 命令。 t_ctid:设置为 (0,2),指向自身。
这时 tuple1 会变成 [t_xmin:10, t_xmax:11, t_cid:0, t_ctid: (0,2)]
t_xmax:11,这个 tuple 被 xid=11 这个事务逻辑删除。 t_ctid:设置为 (0,2),指向 tuple2。
接着再执行第三个事务 xid=12,里面包含一条删除语句:
deletefrom tb_test whereid = 1;
这条 SQL 删除了 id=1 的这条数据,这时 tuple2 变成 [t_xmin:11, t_xmax:12, t_cid:0, t_ctid: (0,2)]
t_xmin:11,插入个 tuple 的事务 xid=11。 t_xmax:12,这行数据被 xid=12 这个事务删除。 t_cid:0,事务 id 等于 xid=12 这个事务的第 1 个 SQL 命令。 t_ctid:(0,2),指向自身。
整个过程如下图:
2.3 可见性
当隔离级别是 Read committed 时,判断 tuple 对一个事务 x 是否可见,需要满足下面条件:
创建这个 tuple 的事务在事务 x 中查询语句执行之前已经提交; 删除这个 tuple 的事务在事务 x 中查询语句执行之前未提交或者已经回滚; 事务 x 创建的 tuple,未提交也能看到。
当隔离级别是 Repeatable Read 时,可见性需要满足创建这个 tuple 的事务在事务 x 开始之前已经提交。事务 x 开始后,即使这个 tuple 被其他事务修改或删除,这些变化对事务 x 也是不可见的。
3.总结
PostgreSQL 基于数据库表存储历史版本的方式实现了 MVCC,这种实现方式的优点是读取历史版本更加方便,不用像 MySQL 那样依赖额外的 Undo Log 存储。缺点是在高并发的场景下,数据库表会保存大量的过期 tuple(dead tuples),可能会导致表膨胀。
PostgreSQL 使用 VACUUM 机制清理过期的 tuple 数据,步骤包括识别过期 tuple、清理过期 tuple、标记过期 tuple 空间可重用、更新系统统计信息。
从 MySQL 迁移到 GoldenDB,上来就踩了一个坑。
面试官:MySQL Redo Log 和 Undo Log 有什么区别?分别用在什么场景?
感谢阅读,如果对你有帮助,请点赞和在看。欢迎加我微信:zhujinjun86。
号内回复 seata,下载《阿里分布式中间件Seata从入门到精通》
号内回复 beijing,下载我总结的北京上百家知名科技公司
号内回复 aqs,下载《40张图精通Java AQS》