不停机从 PostgreSQL 迁移到 MySQL

文摘   科技   2024-11-20 16:58   上海  

原文地址 https://engineeringblog.yelp.com/2024/10/migrating-from-postgres-to-mysql.html

Yelp Reservations 服务 (yelp_res) 是为 Yelp 上的预订提供支持的服务。它与 Seatme 一起于 2013 年被收购,是一个 Django 服务和网络应用程序。它为 Yelp Guest Manager(Yelp 客户管理)的预订后台和逻辑提供动力,并处理创建预订的用餐者和合作伙伴流程。此外,它还为我们的 Yelp Reservations 应用程序提供用户界面和后台 API,该应用程序已被 Yelp Guest Manager 取代,但我们的许多餐厅客户仍在使用。

该服务采用以数据库为中心的架构,并使用「数据库同步」范例(一种客户维护本地数据库的方法,其中包含与其相关的数据副本)与旧有客户同步数据。它还依靠数据库触发器来执行某些业务逻辑。这里所使用的数据库是 PostgreSQL,在 Yelp 的其他任何地方都没有使用过,这意味着只有一小部分长期工作的员工对 PostgreSQL 非常熟悉,足以胜任故障响应工作。这就造成了维护、可见性和故障响应时间方面的问题。负责餐厅产品的团队不是基础架构团队,而整个 Yelp 的基础架构团队(可以理解)专注于 Yelp 标准的基础架构。因此,当我们发现 PostgreSQL 出现问题时,往往需要匆忙寻找具有相关知识的人。
因此,我们用 Yelp 标准的 MySQL 数据库就地替换了这个数据库。
由于餐厅依赖我们的产品开展业务,因此系统不能离线维护,而且任何数据丢失都是不可接受的 —— 不能让用户在预订后发现该预定消失了。这就导致了这个项目的复杂性,因为在两个数据存储之间的不停机切换带来了新的挑战。我们能找到的有关这方面的文档大多使用非常初级的示例,或假定进行干净利落的停止、迁移和重启,因此这也是一个未开发的领域(因此才有了这篇博文!)。

代码

Django 支持 MySQL。作为 2022 年年中的概念验证,我们将开发数据库(本地数据库并根据需要进行设置)转换为 MySQL 数据库,并更新了迁移代码。我们成功地启动了服务,在 MySQL 中正确设置了数据库,并成功响应了一些请求。虽然这只是最简单的部分,但它有助于证明迁移是可行的。
PostgreSQL 特有的功能
PostgreSQL 有很多 MySQL 不支持的功能。我们还使用了一些 MySQL 支持但我们的基础架构团队不支持的功能。
举个例子:PostgreSQL 本身支持数组列。我们使用它们将数据库中餐厅每张桌子的时间表存储为整数数组。我们重新实现了这一行为,将这些数据打包成字符串,由于数组及其每个元素的长度都是恒定的,因此运行起来非常简洁。
要摆脱数据库触发器,还需要进行一系列更复杂的更改。一般来说,MySQL 支持触发器,但我们的 MySQL 基础架构不支持触发器。我们的代码使用触发器来传播数据(当某些数据库表发生变化时触发),并执行有关防止重复预订表的约束。
在数据传播方面,我们的旧系统依赖于将某些表的数据库变更作为高级消息队列协议(AMQP)事件发布到 rabbitmq (https://www.rabbitmq.com/) 中,然后由订阅与之相关变更的多个客户端使用。这是由 PostgreSQL 特定的数据库扩展提供的,该扩展与 PostgreSQL 的事务管理集成,确保客户端在相应事务提交之前不会收到消息。在我们的新系统中,我们在 Django 模型的 save() 函数中添加了逻辑,以添加一个提交后触发器来发布到一个新的 AMQP 主题,并重构了代码以消除「批量」操作,这种操作会在不调用 save() 的情况下写入数据库。这意味着,即使在更新 MySQL 表时,我们现有的观察者也可以监听这个新的 AMQP 主题。我们还引入了事务分组,在事务块开始时为每个事务生成一个通用唯一标识符(UUID)。该标识符与变更数据一起写入,以便按事务对变更进行分组。然后,我们对现有主题和新主题进行监控,确保数据匹配,然后再切换到使用新主题。
为了防止重复预订,我们使用了一种名为「原子块保留」的机制。如果一个表上的块(「块 」是一个预订,或其他任何东西,表示一个表在某个时间不能被预订)与现有的块重叠,就会使用数据库触发器引发异常并阻止写入。为了在不使用触发器的情况下复制这种行为,我们创建了一个名为 TableTimeSlotBlock 的新表,其中包含以表 id 和每个现有阻塞时段的 15 分钟时段为键的行。然后,应用代码通过执行 SELECT ... FOR UPDATE 查询来检查冲突并锁定行(即使它们还不存在)。我们将这一逻辑放在代码中比现有数据库触发器更早的位置,因此通过检查日志,我们可以确保现有触发器不再被使用,这意味着新解决方案的限制性至少与现状相同。
为了迁移到这个系统,我们还必须在这个新表中为所有未来的预订创建行,由于每个现有「块」覆盖多个时间段,这意味着要在新表中添加数百万条记录。

发布‍‍

这是最可怕的部分。我们希望能够逐步发布新数据库,并在需要时返回到 PostgreSQL。这意味着我们需要在一段时间内保持两个数据库同步。Django 支持多 DB,但这是为了向不同的 DB 写入/读取不同的内容,而不是在多个 DB 之间保持数据完全同步。
为了实现写入功能,我们
  • 在代码中所有模型的继承层次中添加一个名为 AlsoWriteToMysqlModel 的新模型。该模型重新定义了 save() 和其他对象级 DB 写入函数,以便首先写入「主」DB,然后将对象保存到「次」DB
  • 对代码中所有查询集的 AlsoWriteToMysqlQuerySet 和 queryset 操作也做了同样的处理
    • 在 Django 中,并非所有操作都是在对象上执行的;例如,您可以拥有一个带有过滤器的 queryset,然后将其删除,这样就可以使用该过滤器执行一次 DB 查询,而无需加载实际对象。
  • 为我们无法控制的模型(如 User 模型或第三方模型)添加了 post_save 和 pre_delete 信号处理器,它们也会执行同样的操作。
    • 我们本可以对所有模型都使用这种技术,但我们认为在模型内部设置逻辑更易于理解,同时也让数据库写入尽量集中在一起。
  • 用在 MySQL 事务中嵌套 PostgreSQL 事务的装饰器取代了 Django 的默认事务装饰器。这意味着只要我们还在事务中,任何数据库故障都会导致两个数据库的回滚。
    • 但在 MySQL 提交时出现的故障除外;这里的逻辑是,数据库触发器有时会导致 PostgreSQL 提交失败,而 MySQL 提交应该始终成功,除非有内核问题。我们最初为了降低在上线期间引入新的 PostgreSQL 写入失败的风险而颠倒了提交顺序,结果在向 MySQL 提交写入后,一些事务因 DB 触发器而失败,导致整个数据库的数据不一致。这是一个有趣的例子,在一个维度上的「稳妥行事」实际上导致了错误。
对于读取,我们
  • 为路由器(Django 类,决定我们读取/写入哪个数据库)添加逻辑,以分离「读取数据库」和 「写入数据库」。
  • 添加了中间件,以在我们希望从 MySQL 读取请求时设置一个标志,路由器会遵守该标志
    • 在中间件堆栈中进行任何数据库读/写操作前都会设置该标记,以确保每个请求只从一个数据库读取数据
在发布过程中,我们首先在 PostgreSQL 上保持读取,以便在写入 MySQL 的同时保持与现状相同的行为。这样,我们就可以在不影响客户的情况下,随时交叉检查数据库并修复不一致和错误。然后,我们逐渐将请求切换为从 MySQL 读取,再将写入逻辑切换为首先写入 MySQL,最后(几个月后)完全关闭了 Postgres 写入,并清理了我们编写的大部分代码。
在几个月的时间里,发布过程相对比较顺利,但也有一些意外情况,我们将在下文介绍。

惊喜和有趣的收获

  • 最初,我们计划了一个过渡期,在这个过渡期内,「主」数据库可以根据每次请求而有所不同。但是,这会导致自动递增主键的问题。具体来说,PostgreSQL 维护一个序列,只有在没有设置主键的情况下插入记录时才会递增。这意味着,要么始终设置主键,要么永远不设置主键。否则,每次在设置了主键的情况下写入(比如写入 MySQL,然后将对象保存到 PostgreSQL),都会导致 PostgreSQL今后在未设置主键的情况下写入失败。在推广过程中,我们花了一些时间才弄明白这一点,因为症状是现状流程中出现少量错误,但 在固定使用 MySQL 的请求中没有错误。
  • Django 用随机字符串命名数据库保存点。在我们的基础架构中,ProxySQL 位于客户端和数据库之间,存储查询摘要以用于度量。这些摘要是查询的通用表示,不依赖于写入或读取的实际数据,但摘要中包含了保存点名称,导致使用保存点的每个查询都有一个唯一的摘要。这导致 ProxySQL 内存使用量不断攀升,并出现了一些生产问题,直到我们解决了这个问题。我们通过更改 ProxySQL 实例中的设置解决了这个问题。
  • 从「批量」操作转换到单个对象级操作意义重大,可能会导致逻辑问题(因为批量操作中不会调用 save() 等操作)和性能问题(因为可能会执行更多数量级的数据库查询)。
    • 在一个实例中,这意味着要重写存档批处理作业以使用原始 SQL,但除此之外,MySQL 可以轻松处理我们的写入量。
  • 尽早执行初始数据加载(回填)对于测试非常有用,但我们本应在修复所有错误后再执行一次完整、干净的第二次回填。相反,我们专门修复了我们发现的破损数据,但这导致了由已修复代码引起的错误,而这些错误本来是可以避免的。
  • 不要忽视数据库的其他用户。我们的分析流水线原本直接从 PostgreSQL 获取数据,而将其转移到使用 MySQL 耗费了大量时间。这是淘汰旧数据库的最后障碍。
  • 使用 Yelp 标准栈提高了性能。这并不是因为 MySQL 本身更具性能优势,而是通过使用公司内部标准的技术栈,我们能够从多个同事监控和优化数据库性能的努力中受益。

致谢‍‍

这是一个耗时一年多的大型项目。为了简明扼要,我只集中介绍了餐厅所做工作的一部分,但这些工作对项目的成功都至关重要。在此,我要特别感谢数据库可靠性工程团队和生产工程团队,以及所有参与该项目的餐厅员工,尤其是 Boris Madza、Carol Fu 和 Daniel Groppe。

数据库 DevSecOps 的守护者们|(内附惊喜🥵)

Bytebase 3.0.1 - 可配置在 SQL 编辑器执行 DDL/DML

Bytebase 产品介绍

全方位对比 Postgres 和 MySQL (2023 版)


Bytebase
百万下载量的开源 SQL 审核,数据库 DevSecOps 和 CI/CD 团队协同工具,专为开发者, DBA 和安全团队打造。同时被 CNCF Landscape 和 Platform Engineering 组织收录。
 最新文章