在原生 Postgres 实现中,全文搜索由B 树或GIN(广义倒排索引)结构支持。这些索引针对相对快速的查找进行了优化,但受限于 B 树的写入吞吐量。
当我们构建pg_searchPostgres 搜索和分析扩展时,我们的优先级有所不同。为了成为 Elasticsearch 的有效替代方案,我们需要支持高效的实时扫描。我们选择了一种更适合密集发布列表位图和高数据采集工作负载的数据结构:日志结构化合并 ( LSM ) 树。
然而,当我们在物理复制(允许 Postgres 将数据从主节点复制到一个或多个只读副本的两种机制之一)下测试 LSM 树时,我们遇到了一些波折。最令人惊讶的是,我们发现 Postgres 基于预写日志 (WAL) 传输机制构建的开箱即用的物理复制支持不足以让LSM 树这样的高级数据结构实现复制安全。在本文中,我们将深入探讨:
- LSM 树的复制安全意味着什么
- Postgres 的 WAL 传输如何保证物理一致性
- 为什么原子日志对于逻辑一致性是必要的
- 我们如何利用鲜为人知但功能强大的 Postgres 设置hot_standby_feedback
什么是 LSM 树?
日志结构合并树 (LSM 树) 是一种写优化数据结构,常用于 RocksDB 和 Cassandra 等系统。
LSM 树的核心思想是将随机写入转换为顺序写入。传入的写入首先存储在内存缓冲区(称为 memtable)中,该缓冲区更新速度很快。一旦 memtable 写满,它将被刷新到磁盘,形成一个已排序的、不可变的段文件(通常称为 SSTable)。
这些段文件按大小组织成层或级别。较新的数据写入最顶层。随着时间的推移,数据通过称为“压缩”的过程逐渐下推到较低的级别。在此过程中,较小段中的数据会被合并、去重,然后重写到较大的段中。
复制安全是什么意思?
可靠的分布式数据存储(保证“复制安全”)必须证明跨数据库副本的物理和逻辑一致性。
- 物理一致性意味着副本包含结构有效的数据——磁盘上的每个页面或块都是格式良好的,并且对应于主节点上某个时刻存在的状态。
- 逻辑一致性确保副本上的数据反映数据库的一致且稳定的视图,这是主数据库上的事务可以看到的。
物理一致的状态并不总是逻辑一致的状态。具体来说,如果在复制正在进行的事务时拍摄物理一致的副本的快照,则它可能在逻辑上不一致。一个很好的比喻是想象复制一本书。物理一致性就像精确地复制每一页,即使正在复制某一章的中间部分——保证有真实的页面,但最终可能会缺少半句话或脚注。逻辑一致性就像等到章节完成后再复制,确保结果对读者有意义。
WAL 传输:Postgres 如何保证物理一致性
在主备物理复制设置中,主服务器与备用服务器配对,备用服务器充当其主服务器的只读副本。服务器通过使用预写日志 (WAL) 来保持同步,在发生任何二进制更改之前,这些更改都会记录在主服务器上的存储块上。然后,对此仅追加的 WAL 文件的更改将流式传输到备用服务器(此过程称为“日志传送”),并按接收的顺序应用。此过程使两台服务器之间能够实现近乎实时的数据同步,因此有“热备”的说法。
为什么原子性是物理一致性的必要条件
原子性是物理一致性的必要条件,因为 Postgres 的锁不会在副本服务器上重放。这是因为重放主服务器上获取的每个锁需要严格的时间同步,这会严重影响性能,并妨碍备用服务器提供读取服务的能力。相反,WAL 使用每个缓冲区的锁来按特定顺序增量重放编辑:它获取缓冲区(块在内存中的表示形式)的独占锁,进行更改,然后释放它。
当修改跨越多个 Postgres 缓冲区的数据结构时,就会出现问题。由于无法保证操作在整个结构上是原子的,这些修改可能会导致结构损坏。
例如:pg_search使用Postgres 缓冲区的展开链表,其中每个节点保存 LSM 树中一批段的读取有效性。为了确保主服务器永远不会发现损坏的链表,我们使用了手动锁定(也称为锁耦合)来保证该列表在主服务器上保持物理一致性。列表中的每个缓冲区被修改后,其 WAL 条目将在副本上以原子方式可见。
但是,当我们想要“一次性”(原子地)编辑列表中的多个条目时,例如当一个新压缩的段替换多个旧段时,会发生什么情况?如果只有主服务器重要,那么我们可以通过在列表本身上应用全局锁来保护多个列表节点的逻辑完整性,确保列表内容仅在有效状态下可见。但是副本服务器无法访问全局锁,因此无法同时协调跨多个节点(和多个缓冲区)的编辑。
相反,对于多节点操作,pg_search使用列表的写时复制 (CoW) 克隆,并在头部进行原子交换。更一般地说,原子操作通过消除对粗粒度锁的依赖,使免受危险。
问题:真空破坏逻辑一致性
调整算法以在块级别原子地工作是物理复制的赌注:如果不这样做,您的数据结构就会被破坏,并且您将无法一致地使用它们。
但即使单个 WAL 操作和数据结构在原子上兼容,VACUUM也会干扰跨多个 WAL 条目的并发事务的执行,并损害逻辑一致性。
为了说明这个问题,假设主数据库有一个包含一定数量行的表。为了确保并发写入操作能够安全地进行,而不会相互阻塞,Postgres 使用了一种称为多版本并发控制 ( MVCC ) 的机制,该机制会为修改后的行(或元组)创建多个版本,而不是就地更新元组。当更新或删除一行时,先前的元组不会被立即删除,而是被标记为“已死”。
这些“死”的元组会一直保留在磁盘上,直到运行名为 VACUUM 的定期维护操作。与其他操作一样,VACUUM 操作会记录在 WAL 中,然后发送到备用数据库,并在那里重放。
由于元组的“失效”发生在服务器本地,而 VACUUM 操作则会在全局范围内重放,因此如果过早地从备库中对某个元组执行 VACUUM 操作,则可能会出现错误。备库可能正在读取某个元组,并对其进行迭代(遍历多个 WAL 条目),而主库可能并发地决定执行 VACUUM 操作,将该元组从数据库中移除。由于备库缺乏任何锁协调或对并发操作的感知,会在前一个事务仍在进行时重放 VACUUM 操作。如果一个长时间运行的查询尝试访问已执行 VACUUM 操作的元组,则可能导致查询失败。
为什么 LSM 树特别容易受到这个问题的影响
如果您的 Postgres 配置了只读副本,并且写入量很大,那么即使使用 B 树索引,您可能也已经遇到过这个问题。如果在查询命中只读副本的同时,主服务器上正在运行 VACUUM,Postgres 可能会中止读取操作。但是,在典型的 Postgres 设置中,这些错误可能很少发生,并且可以容忍,因为 VACUUM 每隔几个小时运行一次。
但 LSM 树的情况则不同,因为压缩是系统的核心,并且是持续执行的部分。在高写入吞吐量的系统中,压缩每分钟甚至每秒都可能发生多次。这增加了发生冲突的可能性。
与 VACUUM 类似,压缩会重写主服务器上的数据,并且需要知道正在进行的查询何时不再需要该数据,以便能够安全地删除旧段。
合理的解决方案:热备反馈
此时,Postgres 中一个可选的设置hot_standby_feedback就派上用场了。启用后,hot_standby_feedback备用服务器可以告知主服务器从副本服务器的角度来看哪些数据是可以安全清理的。此信息显著降低了元组被过早 VACUUM 的可能性,并允许pg_search确定何时可以安全删除段。
要理解实际传递的信息hot_standby_feedback,我们必须首先了解 Postgres 中元组版本控制的工作原理。Postgres 中的每个元组都有两个关键的元数据属性: xmin 和 xmax。存储创建 或 插入 该特定元组版本的xmin事务的事务 ID (XID) ,而 存储更新 或 删除该元组版本的事务的 XID ,从而有效地将其标记为已过时。当元组被删除时,其 值将使用删除事务的 XID 进行更新。由于 XID 是按顺序分配的,因此后续事务的 XID 会被分配更大的编号,因此另一种思考方式是将其作为元组的“创建时间”和“上次更新或删除时间”的代理。
启用后hot_standby_feedback,副本将定期传达xmin其任何活动查询当前固定的最小(最早的“创建时间”),这xmin标识了备用服务器上仍在使用的最旧元组。
有了这些信息,主服务器就可以更明智地决定何时允许执行清理操作(例如 VACUUM)。如果它发现备用查询仍在对原本会被视为“死亡”的元组进行操作,则可以将清理操作推迟到该查询完成。
最后的想法
即使借助hot_standby_feedback,备用服务器也基本上要依赖 WAL 来提供按接收顺序和时间安全执行的指令。本地激励与全局需求之间的矛盾,只是在分布式 Postgres 系统中实现完全复制安全性的挑战性维度之一。
为了实现物理和逻辑一致性,pg_search我们实现了原子记录的 LSM 树,而为了实现逻辑一致性,我们依赖于hot_standby_feedback。
这项挑战值得挑战,因为它能够在不牺牲一致性的情况下实现最快的搜索性能。要查看实际效果。