如果同时执行许多操作来更改数据库架构,则该服务在记录时将无法正常工作。 开发人员Vladimir Kolyasinsky解释了PostgreSQL中的哪些操作需要长期锁定,以及Yandex.Connect团队如何在此类操作期间向服务提供近100%的写访问权限。 此外,您还将了解Django的库,该库旨在使描述的过程的一部分自动化。
我们负担很重,数千个RPS,几分钟之内的停机时间,更不用说更长的时间,是无法接受的。 用户必须注意迁移的发生。 有了这样的负载,就不可能早上四点起床,在没有负载的情况下滚动东西,然后再上床睡觉-因为负载是全天候的。
-大家晚上好! 我的名字叫弗拉基米尔(Vladimir),我在Yandex工作了五年。 在过去的两年中,我一直在为组织开发内部服务和服务。
关于这些服务对组织的意义。 一段时间以来,我们一直在使用大量内部服务:用于存储和交换数据的Wiki,用于与同事快速通信的Messenger,用于组织工作流程的跟踪器,用于进行内外调查的表格以及许多其他服务。
不久前,我们认为我们的服务很酷,不仅对Yandex内部有用,而且对外部人员也有用。 我们开始将它们引入统一的Yandex.Connect平台,并在那里添加现有的外部服务,例如用于域的Mail。

我目前正在开发表单设计器和Wiki。 所使用的堆栈主要是第二版和第三版的Python服务。 Django 1.9-1.11。 作为数据库,大多数是PostgreSQL。 它也是由MongoDB和SQS作为经纪人的Celery。 所有这些都在Docker中起作用。
让我们继续我们面临的问题。 服务很受欢迎,每天有数十万人使用它们,数据不断积累,表越来越多,并且随着时间的流逝,过去用户未注意到的许多更改数据库模式的操作开始干扰服务的正常运行。
今天,我们将讨论如何应对这种情况以及如何实现读写服务的高可用性。
首先,让我们考虑使用PostgreSQL进行的哪些操作需要在表上进行长锁。 所谓锁,是指阻止表正常运行的任何类型的锁-它是访问排它的,从而阻止写入和读取,或者是较弱的锁定级别,其仅阻止写入。
接下来,我们将看到如何在此类操作期间避免锁定。 然后,我们将讨论使用PostgreSQL进行的哪些操作最初是快速的,不需要长锁。 最后,让我们谈谈我们的库zero_downtime_migrations,我们使用它来自动化一些先前描述的技术以避免长时间锁定。
需要长时间锁定的操作:

创建索引。 默认情况下,它不会阻止表中的读取操作,但是在创建索引的整个过程中,所有写入操作都会被阻止;因此,该服务将是只读的。
此外,此类操作还包括添加具有默认值的新列,因为PostgreSQL会覆盖整个表,因此这次它将被禁止进行读取和写入。 此外,其所有索引都将被覆盖。
关于更改列的类型-将会发生类似的情况,该图版也将再次被覆盖。 应该注意的是,这不仅在大型表上花费很长的时间,而且在短时间内需要将表占用的可用内存量增加一倍。
同样,VACUUM FULL操作需要与之前的操作相同级别的锁定-这是访问专用的。 VACUUM FULL也将阻止对表的所有读取和写入操作。
最后两个操作是向该列添加唯一属性,并且通常添加CONSTRAINT。 它们还需要在数据验证期间锁定,尽管它们比以前考虑的时间要少得多,因为它们不会在后台覆盖表。


创建索引。 这很简单,可以使用CONCURRENTLY关键字创建。 有什么区别? 此操作将花费更多时间,因为将执行一次而不是一次,但将执行多次遍历表的操作,并且还将等待所有可能更改索引的当前操作完成。 而且它也会失败-例如,如果在创建唯一索引时违反了唯一索引。 然后,索引将被标记为无效,并且将需要删除并重新创建。 不建议使用REINDEX命令,因为它的作用与常规的CREATE INDEX相同,也就是说,它将锁定表以进行写入。
关于索引的删除-从9.3版开始,您也可以同时删除索引,以避免在删除索引时发生阻塞,尽管通常这样操作很快。

让我们看一下添加具有默认值的新列。 这是当我们要执行这样的命令时执行的标准操作,包括Django执行这样的操作。
如何重写它以避免覆盖表? 首先,在一个事务中,添加没有默认值的新列,然后在单独的请求中添加默认值。 这里有什么区别? 当我们向现有列添加默认值时,这不会更改表中的现有数据。 仅元数据更改。 也就是说,对于所有新行,此默认值将已经得到保证。 执行此命令时,我们仍然需要更新表中所有现有的行。 为了避免长时间阻塞大量数据,我们将分批复制几千份。
更新完所有数据后,如果我们创建NOT NULL列,则仅保留执行SET NOT NULL的操作。 如果我们不创建,那就不要。 这样,您可以避免在进行此类更改时覆盖表。
这样的命令序列比常规命令执行所花费的时间更长,因为它取决于表的大小和表中索引的数量,而通常的命令只是阻塞所有操作并覆盖表而与负载无关,因为此时没有负载。 但这无关紧要,因为在操作过程中,该表可供读取和写入。 这需要很长时间,您只需要遵循它即可。

关于更改列的类型。 该方法类似于添加具有默认值的列。 我们首先添加所需类型的单独列,然后添加触发器以更改原始列中的数据,以一次写入两个列,并将其写入具有所需数据类型的新列。 对于所有新条目,它们将立即转到这两个列。 我们需要更新所有现有的。 与上一张幻灯片一样,我们分部分进行的操作与此类似。
之后,删除触发器,删除旧列并将旧列重命名为新列将保留在一个事务中。 因此,我们获得了相同的结果:我们更改了列的类型,而锁定表的时间并不长。

关于添加唯一列。 创建时将获取一个锁。 如果您知道通过建立唯一索引可以保证PostgreSQL中的唯一性,则可以避免这种情况。 我们自己可以使用CONCURRENTLY建立所需的唯一索引。 并在建立此索引之后,使用此索引创建CONSTRAINT。 此后,表中初始索引的定义将消失,执行这两个操作后,表的定义将向我们显示的结果将没有任何不同。

通常,在添加CONSTRAINT时。 您可以使用此技术来避免在检查数据时阻塞。 我们首先用关键字NOT VALID添加CONSTRAINT。 这意味着不能保证对表中的所有行都执行此CONSTRAINT。 但是同时,对于所有新行,将已经应用此CONSTRAINT,并且如果未执行,则会引发相应的异常。
我们只能验证所有现有值,这可以通过单独的VALIDATE CONSTRAINT命令来完成,并且与此同时,该命令不再干扰表的读取或写入。 这次的表格将可用。
最初在PostgreSQL中可以快速运行并且不需要长锁的操作:

这些操作之一是添加没有默认值和任何限制的列。 由于未对表本身进行任何更改,因此仅更改了其元数据。 我们将SELECT的结果看到的所有NULL值简单地混合在输出中。
另外,将默认值添加到现有标签是一种快速的操作,因为只有元数据会更改。 表和锁实际上是输入此信息所需的几毫秒。
同样,设置SET NOT NULL的快速操作比以前描述的要花一些时间,每3000万条记录的表需要几秒钟的时间。 如果重要的话,也可以避免这段时间。
重命名列,更改列的长度也不会导致覆盖列。 删除列以及通常在PostgreSQL中的许多实体也是一种快速的操作。

关于添加NOT NULL列。 为了避免在验证期间阻塞,您可以执行前面提到的方法-添加与CHECK(列IS NOT NULL)NOT VALID对应的CONSTRAINT,然后使用单独的命令对其进行验证。
通常的区别是,此限制将在表级别存在,而不在表定义的列级别存在。 另一个区别是它会影响性能,大约百分之一。 在这种情况下,就不会有阻塞,如果服务负载很高,那么即使阻塞几秒钟也可能导致大量的事务积累,并且服务会出现问题。

在PostgreSQL中删除数据通常是一种快速的操作,因为不会立即删除数据,只是在表的属性中将列标记为已过时,并且实际上只有在下一次清理开始之后才会删除数据。

让我们谈谈
图书馆 。 我说的是Django,迁移。 通常,Django是Python的一个库,它是一个Web框架,最初是为了快速创建新闻网站而创建的,此后,Django进行了重大升级。 有一个ORM系统,可让您与数据库中的记录以及表进行通信,就好像它们是Python对象或类一样。 也就是说,每个表在Python中都有自己的类。 当我们对Python代码进行更改时,也就是说,我们向表中添加了诸如列之类的新属性,Django在创建迁移过程中会注意到这些更改,并创建迁移文件以对数据库本身进行镜像更改,以使它们不会发散。
编写该库是为了使某些先前讨论的技术自动化,从而避免在此类迁移过程中长时间锁定表。 从1.8版本到2.1版本开始,它一直与Django一起工作;从2.7版本到3.7版本开始,它一直与python一起工作。
关于库的当前功能,这是添加具有无锁的默认值的列(可为空或不可为空),这将创建CONCURRENTLY索引,并在崩溃时重新启动。 在标准的Django实现中,如果我们添加带有默认值的列,则表被锁定,如果表很大,则以我的经验锁定可能需要40分钟。 该表已锁定,仅此而已,请等待复制并进行更改。 30分钟过去了-他们捕获到数据库的连接错误,迁移失败,更改未提交,您必须重新启动,再次等待40分钟,这次再次阻塞了表。

该库使您可以从中断的地方继续迁移。 当您崩溃并重新启动时,将显示一个对话框,其中有多种操作选项,即可以说要继续更新数据。 这通常是数据更新,因为它是最长的过程。 迁移将从中断处继续。 与使用表锁定的标准操作相比,这种操作还需要更长的时间,但是与此同时,服务此时仍保持可操作状态。

关于整个连接。 有文档; 简而言之,您需要使用库中的引擎替换Django数据库设置中的引擎。 如果您使用引擎进行连接,则还有各种混合。

一个工作示例是有关添加具有默认值的列。 在这里,我们添加具有布尔值(默认为True)的列。 标准SchemaEditor执行哪些操作? 您可以查看是否运行SQL迁移的操作。 这是非常有用的,根据迁移的类型,并不总是很清楚Django可以在此实际更改什么。 开始查看我们期望的操作是否已完成以及是否有多余和不必要的内容,这对您很有用。
SchemaEditor执行哪些命令? 首先,将新列添加到一个事务中,并添加默认值。 然后,直到此类更新返回其已更新零为止,数据将被更新。
然后,在该列中设置SET NOT NULL,然后将删除默认值,重复Django的行为,该行为将默认值存储在数据库中,而不是在代码中以其逻辑级别存储。
一般来说,这里也有成长的空间。 例如,您可以建立一个辅助索引,以在更新整个表时快速找到带有NULL值的行。

您也可以在开始迁移时为更新时间确定最大ID,以便通过ID可以快速找到我们尚未更新的值。
通常,图书馆正在开发中,我们接受池请求。 谁在乎-加入。
值得注意的是,随着数据库的增长,迁移具有不可避免的减慢性能。 您需要跟踪表需要使用哪些锁,运行SQL迁移以查看应用了哪些操作。 就我们而言,在Yandex.Connect中,我们在功能允许的范围内使用此库。 在他们不允许的地方,我们自己动手执行伪造的Django迁移,运行我们的SQL查询。
因此,我们实现了读写服务的高可用性。 我们负担很重,数千个RPS,几分钟之内的停机时间,更不用说更长的时间,是无法接受的。 用户必须注意迁移的发生。 有了这样的负载,就不可能早上四点起床,在没有负载的情况下滚动东西,然后再上床睡觉-因为负载是全天候的。
值得注意的是,由于PostgreSQL中锁队列的工作方式,即使PostgreSQL中的快速操作仍然会导致服务速度下降和错误。
想象一下,即使启动了几毫秒的操作,也需要独占访问权限。 此类操作的一个示例是添加没有默认值的列。 想象一下,在另一个事务中启动它时,还有其他一些长操作-例如带有聚合的SELECT。 在这种情况下,我们的操作将为她排队。 发生这种情况是因为访问排他性与所有其他类型的锁冲突。
当我们添加列的操作正在等待锁时,所有其他锁将排队等待该锁,直到锁完成才执行。 同时,正在执行的操作-具有聚合的SELECT-可能不会与其他操作冲突,并且如果不是为了创建列,则它们不会站在队列中,而是会并行执行。
这种情况会给服务带来很大的问题。 因此,在启动ALTER TABLE或需要访问独占锁定的任何其他操作之前,您需要先进行查找,以免长时间查询不会进入数据库。 或者,您可以简单地插入一个很小的日志超时。 然后,如果不可能快速进行锁定,则操作将失败。 我们可以重新启动它,并且长时间不锁定表,而操作将等待授予锁的授予。 就这样,谢谢。