英国《卫报》是如何在不停机的情况下从MongoDB迁移到Postgres的?这篇文章解释了英国《卫报Guardian》为什么以及如何从Mongo迁移到Postgres。直到最近,它才得到运行在AWS上的MongoDB数据库的支持。这个MongoDB数据库是卫报所有在线发布内容的“真实来源”——大约230万个内容项。为了迁移到AWS,我们决定购买OpsManager-Mongo的数据库管理软件,并使用OpsManager来管理备份、处理编排并为我们的数据库集群提供监控。因为Mongo不提供任何工具来轻松地在AWS上进行设置——我们需要手动编写cloudformation代码来定义所有基础设施,最重要的是我们编写了数百行ruby??脚本来处理安装监控/自动化代理和编排新的数据库实例。自从迁移到AWS以来,由于MongoDB数据库问题,我们发生了两次严重的中断,每次都阻止内容在theguardian.com上发布至少一个小时。在这两种情况下,OpsManager和Mongo的支持服务都无法帮助我们,我们最终解决了这个问题:时钟非常重要-不要将VPC锁定到NTP停止工作的地步。在应用程序启动时自动生成数据库索引可能不是一个好主意。数据库管理既重要又困难——我们宁愿自己不做。OpsManager并没有真正兑现其无忧数据库管理的承诺。例如,实际管理OpsManager本身——尤其是从OpsManager1升级到2——非常耗时,并且需要关于我们的OpsManager设置的专业知识。由于不同版本的MongoDB之间的身份验证模式发生了变化,它也没有实现其“一键升级”的承诺。我们每年至少花费两个月的工程时间来完成这项数据库管理工作。所有这些问题,加上我们为支持合同和OpsManager支付的高额年费,促使我们寻找替代数据库选项,并满足以下要求:需要最少的数据库管理。支持静态加密。Mongo的可行迁移路径。由于我们所有其他服务都在AWS中运行,因此显而易见的选择是DynamoDB——亚马逊的NoSQL数据库产品。不幸的是,当时Dynamo不支持静态加密。在等待添加此功能大约9个月后,我们终于放弃并寻找其他东西,最终选择在AWSRDS上使用Postgres。“但是postgres不是文件存储!”我听到你哭了。嗯,不,不是,但它确实有一个JSONB列类型,支持对JSONblob中的字段进行索引。我们希望通过使用JSONB类型,我们可以将Mongo迁移到Postgres,同时对我们的数据模型进行最小的更改。此外,如果我们想在未来转向更相关的模型,我们有这个选择。Postgres的另一个优点是它的成熟度:在大多数情况下,我们想问的每个问题都已经在StackOverflow上得到了解答。从性能的角度来看,我们相信Postgres可以处理它-而Composer是一个写入密集型工具(每次记者停止输入时写入数据库)-通常只有几百个并发用户-而不是HPC!第二部分-20年的无停机内容迁移以下是我们迁移数据库所采取的步骤:创建一个新数据库。创建一个方法(新API)来写入新数据库。创建一个使用旧数据库作为主数据库并将流量发送到旧数据库和新数据库的代理。将记录从旧数据库迁移到新数据库。使新数据库成为主数据库。删除旧数据库。鉴于我们正在迁移的数据库为我们的CMS提供支持,迁移对我们的记者的影响将微乎其微。毕竟,新闻永远不会停止。新API:2017年7月末,一个新的Postgres驱动的API开始运行。于是我们的旅程开始了。我们简化的CMS架构是这样的:一个数据库、一个API和几个与之交互的应用程序(例如Web前端)。该堆栈过去是,现在仍然是使用Scala、ScalatraFramework和Angular.js构建的,它已经有四年的历史了。经过一些调查,我们得出结论,在迁移现有内容之前,我们需要一种方法来与新的PostgreSQL数据库对话,并且仍然像往常一样运行旧的API。毕竟,Mongo数据库是我们的真实来源。在试验新的API时,它为我们提供了一个安全毯。这就是为什么不能在旧API之上构建的原因之一。原始API中几乎没有关注点分离,甚至在控制器级别也能找到MongoDB的详细信息。因此,向现有API添加另一种数据库类型的任务风险太大。我们采取了不同的路线并复制了旧的API。APIV2就这样诞生了。它或多或少是Mongo的复制品,包含相同的端点和功能。我们使用了doobie,一个用于Scala的纯功能JDBC层,添加了Docker用于在本地运行和测试,并改进了日志记录和关注点分离。APIV2将是一个快速且现代的API。到2017年8月底,我们部署了一个使用PostgreSQL作为其数据库的新API。但这仅仅是开始。Mongo数据库中的文章最初是二十年前创建的,所有这些文章都需要移动到Postgres数据库中。迁移我们需要能够编辑站点上的任何文章,无论它们是何时发布的,因此所有文章都作为单一“真实来源”存在于我们的数据库中。虽然所有文章都保存在为应用程序和网站提供支持的Guardian的内容API(CAPI)中,但正确的迁移是关键,因为我们的数据库是“真相之源”。如果CAPI的Elasticsearch集群出现任何问题,那么我们将从Composer的数据库中重新索引它。因此,我们必须确信在关闭Mongo之前,对Postgres驱动的API和Mongo驱动的API的相同请求将返回相同的响应。为此,我们需要将所有内容复制到新的Postgres数据库中。这是使用直接与旧API和新API对话的脚本来完成的。这样做的好处是API已经提供了一个经过良好测试的接口,用于从数据库读取和写入文章,而不是直接编写访问相关数据库的内容。迁移的基本流程是:从Mongo中获取内容。将内容发布到Postgres。从Postgres获取内容。检查一个和三个数据库迁移的响应是否相同,如果您的最终用户完全没有意识到它已经发生并且好的迁移脚本始终是其中的重要组成部分。考虑到这一点,我们需要一个能够:发出HTTP请求的脚本。确保在迁移一段内容后,来自两个API的响应匹配。如果发生错误,请停止。生成详细日志以帮助诊断问题。出错后从正确的点重新开始。我们开始使用Ammonite,它允许您使用我们团队的主要语言Scala编写脚本。这是一个很好的机会来尝试我们以前没有使用过的东西,看看它是否适合我们。虽然Ammonite让我们可以使用熟悉的语言,但它也有缺点。虽然Intellij现在支持Ammonite,但当时不支持,这意味着我们失去了自动完成和自动导入功能。也不可能长时间运行Ammonite脚本。最终,Ammonite不是正确的工具,我们使用sbt项目来执行迁移。我们采用的方法允许我们使用我们有信心的语言工作并执行多次“测试迁移”,直到我们有信心可以在生产中运行它。快进到2017年1月,是时候在我们的预生产环境CODE中测试完整迁移了。与我们的大多数系统一样,CODE和PROD之间的唯一相似之处在于它们运行的??应用程序的版本。支持CODE环境的AWS基础设施远不及PROD强大,因为它的利用率要低得多。在CODE上运行迁移将帮助我们:估计在PROD上迁移需要多长时间。评估迁移的性能影响(如果有)。为了准确衡量这些指标,我们必须匹配这两种环境。这包括将PRODmongo数据库的备份恢复到CODE并更新AWS支持的基础设施。迁移超过200万个项目需要很长时间,肯定比办公时间还长。所以我们一夜之间在屏幕上运行脚本。为了衡量迁移的进度,我们将结构化日志(使用标记)发送到ELK堆栈。从这里,我们可以创建详细的仪表板来跟踪成功迁移的文章数量、失败数量和总体进度。此外,这些都显示在团队附近的大屏幕上,以提高可见性。迁移完成后,我们使用相同的技术检查Mongo中与Postgres匹配的每个文档。第三部分-代理和在生产中运行现在新的Postgres驱动的API正在运行,我们需要用真实的流量和数据访问模式来测试它以确保它可靠和稳定。有两种可能的方法可以实现这一点:更新每个与MongoAPI通信的客户端以与两个API通信;或运行执行此操作的代理。我们使用AkkaStreams在Scala中编写了一个代理。代理操作相当简单:接受来自负载均衡器的流量。将流量转发到主api并返回。将相同的流量异步转发到辅助api。计算两个响应之间的任何差异并记录下来。一开始,代理记录了两个API的响应之间的许多差异,并且在需要修复的API中出现了一些非常细微但重要的行为差异。结构化日志记录我们在Guardian上进行日志记录的方式是使用ELK堆栈。使用Kibana可以让我们灵活地以对我们最有用的方式显示日志。Kibana使用相当容易学习的Lucene查询语法。但我们很快意识到,在当前设置中无法过滤掉日志或对它们进行分组。例如,我们无法过滤掉为GET请求发送的日志。我们的解决方案是向Kibana发送更多结构化日志,而不仅仅是消息。日志条目包含多个字段,例如时间戳、发送日志或堆栈的应用程序的名称。以编程方式添加新字段非常容易。这些结构化字段称为标记,可以使用logstash-logback-encoder库来实现。对于每个请求,我们提取有用的信息(例如路径、方法、状态代码)并创建一个包含我们需要记录的附加信息的映射。看看下面的例子。objectLogging{valrootLogger:LogbackLogger=LoggerFactory.getLogger(SLFLogger.ROOT_LOGGER_NAME).asInstanceOf[LogbackLogger]privateefsetMarkers(request:HttpRequest)={valmarkers=Map("path"->request.uri.path.toString(),"method"->request.method.value)Markers.appendEntries(markers.asJava)}definfoWithMarkers(message:String,akkaRequest:HttpRequest)=rootLogger.info(setMarkers(akkaRequest),message)}日志中的附加结构允许我们构建有用的仪表板并为我们的差异添加更多上下文,这有助于我们识别API之间的一些较小的不一致。重复流量和代理重构:将内容迁移到CODE数据库后,我们最终得到了PROD数据库几乎相同的副本。主要区别在于CODE没有流量。为了将真实流量复制到CODE环境中,我们使用了一个名为GoReplay(gor)的开源工具。它的设置非常简单,并且可以根据您的要求进行定制。由于我们API的所有流量都首先访问代理,因此在代理服务器上安装gor是有意义的。请参阅下文如何在您的机器上下载gor并开始捕获端口80上的流量并将其发送到另一台服务器。wgethttps://github.com/buger/goreplay/releases/download/v0.16.0.2/gor_0.16.0_x64.tar.gztar-xzfgor_0.16.0_x64.tar.gzgorsudogor--input-raw:80--output-http://apiv2.code.co.uk有一段时间一切都运行良好,但很快我们的代理遇到了几分钟的生产中断。经过排查,我们发现agent运行的三个box都在同时循环。我们怀疑gor使用了过多的资源并导致代理失败。在进一步调查中,我们在AWS控制台中发现这些盒子定期循环,但不是同时循环。在深入研究之前,我们试图找到一种方法来继续运行gor,但这次不会对代理施加任何压力。解决方案来自于Composer的二级栈。该堆栈仅供紧急情况使用,并通过我们的生产监控工具不断进行测试。将流量从这个堆栈重新映射到CODE,速度加倍,这次没有任何问题。新发现提出了许多问题。该代理可能没有像其他应用程序那样精心构建。此外,它是使用AkkaHttp构建的,之前没有团队成员使用过。代码很乱,快速修复。我们决定开始一项重大的重构工作以提高可读性,包括使用推导式代替我们之前不断增长的嵌套逻辑,并添加更多日志记录标志。我们希望通过花时间去了解一切是如何工作的,并通过简化逻辑,我们将能够停止骑行。但它没有用。在尝试使代理更可靠大约两周后,我们开始觉得我们正在越陷越深。必须做出决定。我们同意承担风险并放弃风险,因为花在实际迁移上的时间比尝试修复将在一个月内消失的软件要好。我们通过两次以上的生产中断来验证这个决定,每次中断大约持续两分钟,但总的来说这是正确的做法。快进到2017年3月,我们现在已经完成了迁移CODE,并且没有对API的性能或CMS中的用户体验产生任何不利影响。我们现在可以开始考虑在CODE中停用代理。第一阶段是更改API的优先级,以便代理首先与Postgres通信。如前所述,这是基于配置的。然而,有一个复杂的问题。更新文档时,Composer在Kinesis流上发送消息。为避免重复消息,应该只有一个API发送这些消息。API为此配置了一个标志;该值对于Mongo支持的API为true,对于Postgres支持的API为false。仅仅更改代理以与Postgres对话是不够的,因为在请求到达Mongo之前,消息不会在Kinesis流上发送。太晚了。为了解决这个问题,我们创建了HTTP端点来即时更改负载均衡器中所有实例的内存配置。这使我们能够非常快速地切换哪个API是主要的,而无需编辑配置文件和重新部署。此外,这可以编写脚本,减少人为干预和错误。所有请求现在都是Postgres,API2正在与Kinesis对话,可以通过配置和重新部署进行更改。下一步是完全删除代理,让客户端单独与PostgresAPI通信。由于有很多客户端,单独更新每个客户端并不可行。所以我们将其推送到DNS。即我们在DNS中创建了一个CNAME,先指向代理的ELB,然后改为指向APIELB。这允许进行单独的更改,而不是更新API的每个单独的客户端。现在是迁移PROD的时候了。这有点可怕,因为它是生产。这个过程相对简单,因为一切都基于配置。此外,当我们将阶段标记添加到日志时,我们还可以通过更新Kibana过滤器来重新缩放之前构建的仪表板。关闭代理和MongoDB在10个月和240万个迁移帖子之后,我们终于可以关闭所有与Mongo相关的基础设施。但首先是我们一直在等待的时刻:这个杀死代理的小软件给我们带来了很多问题,我们迫不及待地想将其关闭!我们需要做的就是更新CNAME记录以直接指向APIV2负载均衡器。团队聚集在一台计算机周围。只需单击一下即可切换开关。没有人再呼吸了。完全沉默。点击!并改变了。什么都没发生!我们都松了口气。出乎意料的是,删除旧的MongoDBAPI是另一个挑战。在疯狂删除旧代码的同时,我们发现我们的集成测试从未更改为使用新API。一切都很快变红了。幸运的是,大多数问题都与配置相关,因此很容易修复。但是测试捕获的PostgreSQL查询存在一些问题。我们考虑了我们可以做些什么来避免这个错误,并且我们意识到,在开始大量工作时,您还必须接受您会犯错误的事实。之后发生的一切都很顺利。我们从OpsManager中分离所有Mongo实例并终止它们。剩下唯一要做的就是庆祝。
