数据库内核月报 - 2021 / 03

PolarDB · 引擎特性 · 物理复制热点页优化

PolarDB采用物理复制的方式来实现朱从节点间的数据同步,区别于Mysql官方的binlog复制,PolarDB在主从节点间通过传输Redo Log,并在从节点上对Redo Log进行Replay,从而完成用户在主节点上写入或更新的数据,在从节点上能被完整的访问到。但物理复制的架构本身也会带来一些新的挑战和约束,具体笔者将在本篇文章详细道来。

背景

物理复制是PolarDB的核心技术之一,结合底层分布式文件系统PolarStore,构建起了物理复制+共享存储的新一代云原生数据库架构。

在PolarDB中,定义了三种不同的节点,Primary,Replica,Standby,各自负责不同的职责,具体如下:

Primary:负责接收读写请求,又称为读写节点,可以理解为传统数据库中的主节点,一般只有1个。

Replica:负责接收读请求,又称为只读节点,可以理解传统数据库的从节点,支持有多个,可以随意扩展。

Standby:不提供读服务,主要用来备份,异地保证实例高可用,避免单点,一般只有1个,也可多个。

具体的架构如下:

如架构图所示,Primary和Replica节点共享同一个PFS(PolarStore File System),复用数据文件和日志文件,RO节点直接读取PFS上的Redo Log,并进行解析,并将其修改应用到自己Buffer Pool中的Page上,这样当用户的请求到达Replica节点后,就可以访问到最新的数据了。同时Replica和Primary节点间也会保持RPC通信,用于同步Replica当前日志的Apply位点,以及ReadView等信息。

Standby节点部署在其他Region中,拥有独立的PFS集群,拥有独立的数据和日志文件,Standby会向Primary节点建立连接,用于读取Primary节点上的Redo Log,并回发到Standby节点,Standby节点会将Redo Log保存自己本地,并解析这些Redo Log,将其完全在自己的Buffer Pool中进行回放,并通过周期性的刷脏操作将数据持久化到磁盘,最终实现数据同步。

共享存储架构下的约束

我们今天讨论的主要问题也是基于Primary和Replica节点之间,在共享存储的架构下,相比原有的InnoDB能够在不增加磁盘存储的情况下,实现更好的扩展读请求负载,能够快速的增加和删除Replia节点,并且能在Replica节点和Primary节点进行实时HA切换,大大提升了实例的可用性,天然契合云原生架构。在原有的InnoDB架构中,数据的持久化是由Page Cleaner线程周期的对脏页进行落盘来完成的,以避免用户线程同步刷脏而影响性能。在PolarDB的架构中,为了保证用户线程的读请求在Replica节点上访问Page时给用户返回的数据的一致性,Primary节点在对脏页进行刷脏操作时,需要保证该脏页的最新修改的LSN不能超过所有Replica节点的最小Redo log Apply的 LSN位点,以避免用户在Replica节点上访问到过新的数据,以及正在做SMO的数据。因此为了保证磁盘数据始终保持在连续一致的状态,Primary节点在刷脏时必须要考虑Replica节点的Apply LSN的位点,并且受Replica节点的Apply LSN位点的约束来完成数据落盘。

我们把所有Replica节点上的最小Apply LSN定义为Safe LSN,Primary节点在进行Flush Page时,一定要保证该Page的最新修改的LSN(new_modification_lsn)要小于Safe LSN,不然就不能对这个Page进行刷脏落盘,因此在某些情况下可能会导致Primary节点上的脏页无法得到及时刷脏,并且无法推进最老的Flush LSN(oldest_flush_lsn)。

而在Replica节点上,我们为了加速物理复制的同步效率,新增了运行时应用(Runtime Apply)的机制,Runtime Apply是指在物理复制中Apply Redo时,如果Page不在Buffer Pool中,将不会对这个Page进行Apply,避免了Replica节点上后台Apply线程频繁从共享存储读取Page,但需要把解析好的Redo缓存起来,保存在Parse Buffer中,以便后续用户的读请求到达时读取共享存储上的Page,并通过Runtime Apply应用Parse Buffer中缓存的针对这个Page的修改的所有Redo,最终返回最新的Page。在Parse Buffer中缓存的Redo log必须要等到Primary节点的oldest_flush_lsn推进之后才能进行清理,即意味着这段Redo修改对应的脏页已经在Primary节点上落盘,那这段Redo在Replica上就可以丢掉了。

在这种约束下,倘若出现热点Page的更新(即new_modification_lsn不停地在更新),或者Primary节点刷脏过慢,就会导致Replica节点的Parse Buffer中堆积大量的解析好的Redo,同时会影响Replica节点的Parse和Apply性能,导致Replica节点的Apply LSN推进过慢,反向又会导致Primary节点更加无法刷脏,最终影响用户线程的写入操作。如果Replica节点应用日志的速度慢到一定程度,会导致应用日志的速度和写节点产生的日志差距会越拉越大,最终使得复制延迟会持续增大。

如何解决这些问题?

为了解决在以上约束下产生的各种问题,PolarDB针对Primary节点的Buffer Pool进行了一些优化,具体如下:

  1. 为了让读写节点尽快将产生的脏页Flush到磁盘,从而减少Replica节点缓存在Parse Buffer中的Redo,Replica节点会把自己Apply Redo的位点实时同步给Primary节点,如果当前Primary节点的write_lsn和只读节点的Safe LSN差距超过设定的阈值时,我们会加快Primary节点的刷脏频率,主动推进oldest_flush_lsn位点,只读节点就能释放自己Parse Buffer中缓存的Redo,同时也减少了Runtime Apply时需要Apply的log信息,提升了Replica节点的性能。
  2. 正如我们上面所讲,Primary节点在刷脏时要求写磁盘的数据页最新修改LSN不能超过Replica节点应用日志的LSN。当一个Page被频繁更新时,就会出现此数据页的最新修改LSN(newest_modification)不断更新,其总也不满足刷脏的条件,导致此页无法写到磁盘数据文件中,从而无法推进刷新LSN,最终结果是Replica节点日志堆积在日志缓存里,使之没有缓冲接收新的日志。为了解决这个问题我们引入了Copy Page。Copy Page是当一个数据页在POLARDB架构下由于不满足刷脏条件导致其不能及时写出到磁盘数据文件的情况下,临时生成了一个数据的拷贝页。这个拷贝页的信息是生成这个拷贝页时的一个镜像,这个拷贝页里存放的数据、最老修改的LSN、最新修改的LSN都固定下来不再改变。这样在以后脏页刷新中就能顺利的把拷贝页刷新到磁盘上,然后把其对应的数据页的最老修改LSN(oldest_modification)更新为此拷贝页的最新修改LSN(newest_modification),从而推进了写节点的刷新LSN。
  3. 有一类数据页,需要频繁的访问,比如系统表空间以及回滚段表空间的回滚段表头页。为了提高执行效率和性能,对这些频繁访问的Page,实例启动后这些页面读进内存后从来不会换出,把这些数据页“钉”在Buffer Pool中,我们称这类Page为“pin pages”。这些页保留在缓冲池中一方面避免换进换出影响读节点应用日志的效率,另一方面这些数据页不会被读节点换出,意味着再次需要这些数据页时已经在内存中不需要重新从磁盘中再次读取,这样写节点在刷脏写出这些数据页时就可以不受上述页的最新修改LSN(newest_modification)必须不能大于读节点应用日志的LSN(min_replica_applied_lsn)的限制,使数据页在写节点刷脏时更加平滑,减少用户线程对用户响应时间的干扰。

通过这三个策略,我们对这种热点页带来的影响基本降到了最低,使得Primary和Replica节点之间的物理复制更加丝滑。

总结

Copy Page在热点页场景中的解决了热点页无法刷脏的问题,但同时也解决了在频繁修改热点页和刷脏之间对Page X锁和SX锁的争抢,在InnoDB的原生逻辑中,刷脏是需要长时间(整个IO期间)持有Page的SX锁的,但Copy Page只需要很少时间持有SX锁,从而大大降低了刷脏对写入请求的影响,这个议题是我们针对写入场景下的优化,也称之为Copy Page2.0,鉴于篇幅有限,后续会再写一个专题来介绍。

在PolarDB的物理复制+共享存储架构下,我们遇到了很多的挑战,在解决这些问题的过程中,也将PolarDB打磨的越来越好,相信在不远的将来,PolarDB也将会承载更多客户的信任和依托。