Migration Style Guide

Migration Style Guide

在为 GitLab 编写迁移时,必须考虑到这些迁移将由成千上万个各种规模的组织来运行,其中一些组织的数据库中包含多年的数据.

此外,对于大多数组织来说,必须使服务器脱机以进行较小或较大的升级是一大负担. 因此,重要的是要仔细编写迁移文件,可以在线进行迁移,并遵守下面的样式指南.

迁移不准要求 GitLab 设施采取离线,除非绝对必要的 .

如果需要停机,则必须通过以下方式批准迁移:

  1. 工程副总裁
  2. 后端维护者
  3. 数据库维护者

可以在找到持有这些头衔的人员的最新列表.

When writing your migrations, also consider that databases might have stale data or inconsistencies and guard for that. Try to make as few assumptions as possible about the state of the database.

请不要依赖特定于 GitLab 的代码,因为它可能会在将来的版本中更改. 如果需要,将 GitLab 代码复制粘贴到迁移中以使其向前兼容.

对于 GitLab.com,请考虑到在部署 Canary之前运行常规迁移(在db/migrate ),在完成生产部署后运行部署后的迁移( db/post_migrate ).

Changes to the schema should be committed to db/structure.sql. This file is automatically generated by Rails, so you normally should not edit this file by hand. If your migration is adding a column to a table, that column will be added at the bottom. Please do not reorder columns manually for existing tables as this will cause confusing to other people using db/structure.sql generated by Rails.

当您的 GDK 中的本地数据库与master数据库中的模式不同时,可能很难将模式更改完全提交给 Git. 在这种情况下,您可以使用scripts/regenerate-schema脚本为要添加的迁移重新生成干净的db/structure.sql . 该脚本将应用在db/migratedb/post_migrate找到的所有迁移,因此,如果您不想将任何迁移提交到架构,请重命名或删除它们. 如果您的分支不是以master为目标,则可以设置TARGET环境变量.

What Requires Downtime?

文档“什么需要停机?” 指定各种数据库操作,例如

以及他们是否需要停机,以及如何尽可能地进行停机.

Downtime Tagging

每个迁移都必须指定是否需要停机,并且如果需要停机,还必须指定原因. 这是即使在迁移的 99%,不需要停机,因为这使得它更容易地发现, 确实需要停机迁移所需.

要标记迁移,请在迁移类的主体中添加以下两个常量:

  • DOWNTIME :一个布尔值,当设置为true指示迁移需要停机时间.
  • DOWNTIME_REASON :一个字符串,其中包含需要停机的迁移原因. 当DOWNTIME设置为true时, 必须设置此常数.

例如:

  1. class MyMigration < ActiveRecord::Migration[6.0]
  2. DOWNTIME = true
  3. DOWNTIME_REASON = 'This migration requires downtime because ...'
  4. def change
  5. ...
  6. end
  7. end

如果迁移类中缺少DOWNTIME常量,则会出现错误(即 CI 将失败).

Reversibility

您的迁移必须是可逆的. 这非常重要,因为在出​​现漏洞或错误的情况下应该可以降级.

在迁移中,添加一条注释,描述如何测试迁移的可逆性.

某些迁移无法撤消. 例如,某些数据迁移无法撤消,因为在迁移之前,我们丢失了有关数据库状态的信息. 您仍然应该创建一个带注释的down方法,解释为什么不能撤消up方法执行的更改,以便即使撤消迁移期间执行的更改也可以撤消迁移本身:

  1. def down
  2. # no-op
  3. # comment explaining why changes performed by `up` cannot be reversed.
  4. end

Atomicity

默认情况下,迁移是单个事务. 即,在迁移开始时打开一个事务,并在处理完所有步骤后提交.

在单个事务中运行迁移可确保如果其中一个步骤失败,则将不会执行任何步骤,从而使数据库保持有效状态. 因此,要么:

  • 将所有迁移都放在一个单事务迁移中.
  • 如有必要,将大多数操作放入一个迁移中,并为无法在单个事务中完成的步骤创建一个单独的迁移.

例如,如果创建一个空表并需要为其建立索引,则建议使用常规的单事务迁移和默认的 rails schema 语句: . 这是一个阻塞操作,但不会引起问题,因为该表尚未使用,因此它还没有任何记录.

Heavy operations in a single transaction

使用单事务迁移时,事务将在迁移期间保持数据库连接,因此您必须确保迁移中的操作不会花费太多时间:通常,在迁移中执行的查询需要在 GitLab.com 上舒适地放置15s .

如果您需要插入,更新或删除大量数据,请执行以下操作:

  • 必须使用disable_ddl_transaction!禁用单个事务disable_ddl_transaction! .
  • 应该考虑在这样做.

Retry mechanism when acquiring database locks

更改数据库架构时,我们使用辅助方法来调用 DDL(数据定义语言)语句. 在某些情况下,这些 DDL 语句需要特定的数据库锁.

Example:

  1. def change
  2. remove_column :users, :full_name, :string
  3. end

要执行此迁移,需要对users表进行排他锁定. 当表被其他进程同时访问和修改时,获取锁定可能需要一段时间. 锁定请求正在队列中等待,一旦进入队列,它也可能阻止users表上的其他查询.

有关 PostgresSQL 锁的更多信息:

出于稳定性考虑,GitLab.com 设置了特定的statement_timeout . 调用迁移时,任何数据库查询都将有固定的执行时间. 在最坏的情况下,请求将坐在锁定队列中,在配置的语句超时时间内阻止其他查询,然后canceling statement due to statement timeout错误canceling statement due to statement timeout失败.

此问题可能导致应用程序升级过程失败,甚至导致应用程序稳定性问题,因为该表可能会在短时间内无法访问.

为了提高数据库迁移的可靠性和稳定性,GitLab 代码库提供了一种辅助方法,以使用不同的lock_timeout设置重试操作,并在两次尝试之间等待时间. 为了获得必要的锁定,进行了多次较小的尝试,从而使数据库可以处理其他语句.

删除列:

  1. include Gitlab::Database::MigrationHelpers
  2. def up
  3. with_lock_retries do
  4. remove_column :users, :full_name
  5. end
  6. end
  7. def down
  8. with_lock_retries do
  9. add_column :users, :full_name, :string
  10. end
  11. end

删除外键:

  1. include Gitlab::Database::MigrationHelpers
  2. def up
  3. with_lock_retries do
  4. remove_foreign_key :issues, :projects
  5. end
  6. end
  7. def down
  8. with_lock_retries do
  9. add_foreign_key :issues, :projects
  10. end
  11. end

更改列的默认值:

  1. include Gitlab::Database::MigrationHelpers
  2. def up
  3. with_lock_retries do
  4. change_column_default :merge_requests, :lock_version, from: nil, to: 0
  5. end
  6. end
  7. with_lock_retries do
  8. change_column_default :merge_requests, :lock_version, from: 0, to: nil
  9. end
  10. end

用外键创建一个新表:

我们可以简单地用with_lock_retries包装create_table方法:

  1. def up
  2. with_lock_retries do
  3. create_table :issues do |t|
  4. t.references :project, index: true, null: false, foreign_key: { on_delete: :cascade }
  5. t.string :title, limit: 255
  6. end
  7. end
  8. end
  9. def down
  10. drop_table :issues
  11. end

当我们有两个外键时创建一个新表:

为此,我们需要进行三个迁移:

  1. 创建不带外键(带有索引)的表.
  2. 将外键添加到第一个表.
  3. 将外键添加到第二个表.

创建表:

  1. def up
  2. create_table :imports do |t|
  3. t.bigint :project_id, null: false
  4. t.bigint :user_id, null: false
  5. t.string :jid, limit: 255
  6. end
  7. add_index :imports, :project_id
  8. add_index :imports, :user_id
  9. end
  10. def down
  11. drop_table :imports
  12. end

projects添加外键:

  1. include Gitlab::Database::MigrationHelpers
  2. def up
  3. with_lock_retries do
  4. add_foreign_key :imports, :projects, column: :project_id, on_delete: :cascade
  5. end
  6. end
  7. def down
  8. with_lock_retries do
  9. remove_foreign_key :imports, column: :project_id
  10. end
  11. end
  1. include Gitlab::Database::MigrationHelpers
  2. def up
  3. with_lock_retries do
  4. add_foreign_key :imports, :users, column: :user_id, on_delete: :cascade
  5. end
  6. def down
  7. with_lock_retries do
  8. remove_foreign_key :imports, column: :user_id
  9. end
  10. end

disable_ddl_transaction!配合使用disable_ddl_transaction!

通常, with_lock_retries帮助程序应与disabled_ddl_transaction! . 定制的 RuboCop 规则可确保只能将允许的方法放在锁重试块中.

RuboCop 规则通常允许使用下面列出的标准 Rails 迁移方法. 此示例将导致 Rubocop 犯罪:

  1. disabled_ddl_transaction!
  2. def up
  3. with_lock_retries do
  4. add_concurrent_index :users, :name
  5. end
  6. end

通常使用标准的 Rails 迁移帮助器方法时,可以使用with_lock_retries帮助器方法. 如果在同一个表上执行多个迁移助手,则调用它们不是问题.

当数据库迁移涉及高流量表之一时,建议使用with_lock_retries帮助程序方法:

  • users
  • projects
  • namespaces
  • issues
  • merge_requests
  • ci_pipelines
  • ci_builds
  • notes

更改示例:

  • add_foreign_key / remove_foreign_key
  • add_column / remove_column
  • change_column_default
  • create_table / drop_table

注意: with_lock_retries方法不能change方法中使用,必须手动定义updown方法以使迁移可逆.

  1. 重复 50 次.
  2. 对于每次迭代,设置一个预配置的lock_timeout .
  3. 尝试执行给定的块. ( remove_column ).
  4. 如果出现LockWaitTimeout错误,请为预配置的sleep_time睡眠,然后重试该块.
  5. 如果未引发错误,则当前迭代已成功执行该块.

有关更多信息,请检查类. with_lock_retries帮助器方法在Gitlab::Database::MigrationHelpers模块中实现.

在最坏的情况下,该方法:

  • 在 40 分钟内最多执行 50 次该块.
    • 大部分时间都花费在每次迭代后的预先配置的睡眠时段中.
  • 第 50 次重试之后,将像标准迁移调用一样在没有lock_timeout情况下执行该块.
  • 如果无法获取锁,则迁移将失败,并出现statement timeout错误.

如果访问users表的事务运行时间很长(超过 40 分钟),则迁移可能会失败.

有时,迁移可能需要使用多个 Ruby 线程来加快迁移速度. 为此,您的迁移需要包括模块Gitlab::Database::MultiThreadedMigration

  1. class MyMigration < ActiveRecord::Migration[6.0]
  2. include Gitlab::Database::MigrationHelpers
  3. include Gitlab::Database::MultiThreadedMigration
  4. end

然后可以使用with_multiple_threads方法在单独的线程中执行工作. 例如:

  1. class MyMigration < ActiveRecord::Migration[6.0]
  2. include Gitlab::Database::MigrationHelpers
  3. include Gitlab::Database::MultiThreadedMigration
  4. def up
  5. with_multiple_threads(4) do
  6. disable_statement_timeout
  7. # ...
  8. end
  9. end
  10. end

在这里,对disable_statement_timeout的调用将使用with_multiple_threads块本地的连接,而不是重新使用全局连接池. 这样可以确保每个线程都有自己的连接对象,并且在尝试获取一个连接对象时不会超时.

注意: PostgreSQL 具有允许的最大连接数. 此限制因安装而异. 因此,建议您一次迁移不要使用超过 32 个线程. 通常,4-8 个线程应该绰绰有余.

Removing indexes

如果删除索引时表不为空,请确保使用方法remove_concurrent_index而不是常规的remove_index方法. remove_concurrent_index方法同时删除索引,因此不需要锁定,也无需停机. 要使用此方法,必须通过调用方法disable_ddl_transaction!禁用单交易模式disable_ddl_transaction! 在您的迁移类的主体中,如下所示:

  1. class MyMigration < ActiveRecord::Migration[6.0]
  2. include Gitlab::Database::MigrationHelpers
  3. disable_ddl_transaction!
  4. def up
  5. remove_concurrent_index :table_name, :column_name
  6. end
  7. end

请注意,在删除索引之前不必检查索引是否存在.

对于较小的表(例如空表或少于1,000条记录的表),建议在单事务迁移中使用remove_index ,并将其与不需要disable_ddl_transaction!其他操作结合使用disable_ddl_transaction! .

Adding indexes

在添加索引之前,请考虑该索引是否必要. 在某些情况下可能不需要索引,例如:

  • 该表很小(少于1,000条记录),并且预计大小不会成倍增长.
  • 任何现有索引都会过滤掉足够的行.
  • 添加索引后查询时间的减少并不明显.

另外,不需要宽索引来匹配查询的所有过滤条件,我们只需要覆盖足够多的列即可使索引查找具有足够小的选择性. 请查看我们的指南以获取更多详细信息.

将索引添加到非空表时,请确保使用方法add_concurrent_index而不是常规的add_index方法. 使用 PostgreSQL 时, add_concurrent_index方法自动创建并发索引,从而消除了停机时间.

要使用此方法,必须通过调用方法disable_ddl_transaction!禁用单交易模式disable_ddl_transaction! 在您的迁移类的主体中,如下所示:

  1. class MyMigration < ActiveRecord::Migration[6.0]
  2. DOWNTIME = false
  3. disable_ddl_transaction!
  4. def up
  5. add_concurrent_index :table, :column
  6. end
  7. def down
  8. remove_concurrent_index :table, :column
  9. end
  10. end

如果需要添加唯一索引,请记住,数据库中可能存在现有重复项. 这意味着在添加唯一索引之前,应始终首先添加一个删除所有重复项的迁移.

对于小型表(例如空表或少于1,000条记录的表),建议在单事务迁移中使用add_index ,并将其与不需要disable_ddl_transaction!其他操作结合使用disable_ddl_transaction! .

Adding foreign-key constraints

向现有列或新列添加外键约束时,还请记住在该列上添加索引.

这是所有外键所必需的,例如,为了支持有效的级联删除:当删除表中的许多行时,也需要删除引用的记录. 数据库必须在引用的表中查找相应的记录. 没有索引,这将导致对表进行顺序扫描,这可能需要很长时间.

这是一个示例,其中我们添加了一个带有外键约束的新列. 请注意,它包括index: true为其创建索引.

  1. class Migration < ActiveRecord::Migration[6.0]
  2. def change
  3. add_reference :model, :other_model, index: true, foreign_key: { on_delete: :cascade }
  4. end
  5. end

当向非空表中的现有列添加外键约束时,我们必须使用add_concurrent_foreign_keyadd_concurrent_index而不是add_reference .

对于空表(例如新表),建议在单事务迁移中使用add_reference ,并将其与不需要disable_ddl_transaction!其他操作结合使用disable_ddl_transaction! .

您可以阅读有关将添加到现有列的更多信息 .

NOT NULL constraints

Introduced in GitLab 13.0.

有关更多信息,请参见关于的样式指南.

Adding Columns With Default Values

PostgreSQL 11 是自 GitLab 13.0 以来的最低版本,添加具有默认值的列变得更加容易,并且在所有情况下都应使用标准的add_column帮助器.

在 PostgreSQL 11 之前,添加带有默认值的列是有问题的,因为这会导致全表重写. 相应的帮助程序add_column_with_default已被弃用,并将在以后的版本中删除.

注意:如果%12.9 或更早版本需要向后端口添加具有默认值的列,则应使用add_column_with_default帮助器. 如果涉及 ,则禁止向后移植到%12.9.

Changing the column default

可能有人认为,使用change_column_default更改默认列对于较大的表来说是一项昂贵且破坏性的操作,但实际上并非如此.

以以下迁移为例:

  1. class DefaultRequestAccessGroups < ActiveRecord::Migration[5.2]
  2. DOWNTIME = false
  3. def change
  4. change_column_default(:namespaces, :request_access_enabled, from: false, to: true)
  5. end

上面的迁移更改了我们最大的表之一的默认列值: namespaces . 可以将其翻译为:

  1. ALTER TABLE namespaces
  2. ALTER COLUMN request_access_enabled
  3. DEFAULT false

在这种情况下,默认值存在,我们只是更改了request_access_enabled列的元数据,这并不意味着重写namespaces表中的所有现有记录. 仅当使用默认值创建新列时,所有记录才会被重写.

注意: PostgresSQL 11.0 引入更快的ALTER TABLE ADD COLUMN(具有非空默认值 ),从而消除了在添加具有默认值的新列时重写表的需求.

由于上述原因,在单事务迁移中使用change_column_default是安全的,而不需要disable_ddl_transaction! .

some_column projects表中的foo列更新为 10,其中some_column'hello'

  1. update_column_in_batches(:projects, :foo, 10) do |table, query|
  2. query.where(table[:some_column].eq('hello'))
  3. end

如果需要计算的更新,则可以将值包装在Arel.sql ,因此 Arel 将其视为 SQL 文字. 这也是的必需弃用.

下面的示例与上面的示例相同,但是该值设置为barbaz列的乘积:

  1. update_value = Arel.sql('bar * baz')
  2. update_column_in_batches(:projects, :foo, update_value) do |table, query|
  3. query.where(table[:some_column].eq('hello'))
  4. end

add_column_with_default一样,有一个 RuboCop cop 可以检测大表上的用法. 在update_column_in_batches的情况下,可以在大表上运行,只要它仅更新表中行的一小部分,而在未在 GitLab.com 登台环境上进行验证的情况下不要忽略它-或事先请别人为您这样做.

Dropping a database table

删除数据库表并不常见,Rails 提供的drop_table方法通常被认为是安全的. 删除表格之前,请考虑以下因素:

如果您的表在高流量表上具有外键(如projects ),则DROP TABLE语句可能会失败,并出现语句超时错误. 确定哪些表是高流量可能很困难. 自我管理的实例可能会以不同的使用模式使用 GitLab 的不同功能,因此仅基于 GitLab.com 进行假设是不够的.

没有记录 (功能从未使用过),也没有外键

  • 只需在迁移中使用drop_table方法即可.

Table 有记录 but 没有外键:

  • 第一版:删除与表相关的应用程序代码,例如模型,控制器和服务.
  • 第二版:在迁移中使用drop_table方法.
  1. def up
  2. drop_table :my_table
  3. end
  4. def down
  5. # create_table ...
  6. end

Table 有外键:

  • 第一版:删除与表相关的应用程序代码,例如模型,控制器和服务.
  • 第二版:使用with_lock_retries帮助器方法删除外键. 在另一个迁移文件中使用drop_table .

第二个版本的迁移:

删除projects表上的外键:

  1. # first migration file
  2. def up
  3. with_lock_retries do
  4. remove_foreign_key :my_table, :projects
  5. end
  6. end
  7. def down
  8. with_lock_retries do
  9. add_foreign_key :my_table, :projects
  10. end
  11. end

放下桌子:

  1. # second migration file
  2. def up
  3. drop_table :my_table
  4. end
  5. def down
  6. # create_table ...
  7. end

Integer column type

默认情况下,整数列最多可容纳 4 个字节(32 位)的数字. 最大值为 2,147,483,647. 创建一个以字节为单位保存文件大小的列时,请注意这一点. 如果以字节为单位跟踪文件大小,这会将最大文件大小限制为刚好超过 2GB.

要允许整数列最多容纳 8 个字节(64 位)的数字,请将限制明确设置为 8 个字节. 这将使该列最多9,223,372,036,854,775,807个值.

Rails 迁移示例:

  1. add_column(:projects, :foo, :integer, default: 10, limit: 8)

Strings and the Text data type

在 GitLab 13.0 中 .

有关更多信息,请参见文本数据类型样式指南.

Timestamp column type

默认情况下,Rails 使用timestamp数据类型来存储没有时区信息的时间戳数据. 通过调用add_timestampstimestamps方法来使用timestamp数据类型.

另外,Rails 将:datetime数据类型转换为timestamp一.

Example:

  1. # timestamps
  2. create_table :users do |t|
  3. t.timestamps
  4. end
  5. # add_timestamps
  6. def up
  7. add_timestamps :users
  8. end
  9. # :datetime
  10. def up
  11. add_column :users, :last_sign_in, :datetime
  12. end

代替使用这些方法,应该使用以下方法来存储带有时区的时间戳:

  • add_timestamps_with_timezone
  • timestamps_with_timezone
  • datetime_with_timezone

这样可以确保所有时间戳都有指定的时区. 反过来,这意味着当系统的时区更改时,现有时间戳不会突然使用其他时区. 这也使得非常清楚,首先使用了哪个时区.

Storing JSON in database

Rails 5 本机支持JSONB (二进制 JSON)列类型. 添加此列的示例迁移:

  1. class AddOptionsToBuildMetadata < ActiveRecord::Migration[5.0]
  2. DOWNTIME = false
  3. def change
  4. add_column :ci_builds_metadata, :config_options, :jsonb
  5. end
  6. end

您必须使用序列化器来提供翻译层:

  1. class BuildMetadata
  2. serialize :config_options, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize
  3. end

使用JSONB列时,请使用来控制随时间推移插入的数据.

  1. class BuildMetadata
  2. validates :config_options, json_schema: { filename: 'build_metadata_config_option' }
  3. end

Testing

请参阅” 样式”指南.

请比一般 ActiveRecord 语法更喜欢 Arel 和普通 SQL. 如果使用普通 SQL,则需要使用quote_string helper 手动引用所有输入.

Arel 的示例:

  1. users = Arel::Table.new(:users)
  2. users.group(users[:user_id]).having(users[:id].count.gt(5))
  3. #update other tables with these results

带有普通 SQL 和quote_string帮助器的示例:

  1. select_all("SELECT name, COUNT(id) as cnt FROM tags GROUP BY name HAVING COUNT(id) > 1").each do |tag|
  2. tag_name = quote_string(tag["name"])
  3. duplicate_ids = select_all("SELECT id FROM tags WHERE name = '#{tag_name}'").map{|tag| tag["id"]}
  4. origin_tag_id = duplicate_ids.first
  5. duplicate_ids.delete origin_tag_id
  6. execute("UPDATE taggings SET tag_id = #{origin_tag_id} WHERE tag_id IN(#{duplicate_ids.join(",")})")
  7. execute("DELETE FROM tags WHERE id IN(#{duplicate_ids.join(",")})")
  8. end

如果需要更复杂的逻辑,则可以定义和使用迁移本地模型. 例如:

这样做时,请确保显式设置模型的表名,这样它就不会派生自类名或名称空间.

最后,对于所有更新数据库的本地模型,请确保在迁移的up方法中运行reset_column_information .

这样做的原因是所有迁移类都在开始时加载( db:migrate启动时),因此,如果另一个迁移更新了该模式,它们将与它们映射的表模式不同步. 这将导致在尝试插入基础表或对其进行更新时数据迁移失败,因为ActiveRecord将新列报告为unknown attribute .

引入新的项目路线时,它可能与任何现有记录冲突. 这些记录的路径应重命名,并且相关数据应在磁盘上移动.

由于我们已经必须做几次,因此现在有一些帮助程序可以帮助您.

要使用此功能,您可以在迁移中包括Gitlab::Database::RenameReservedPathsMigration::V1 . 这将提供 3 种方法,您可以通过一种或多种需要拒绝的路径.

rename_root_paths :这将使用给定名称重命名所有没有parent_id名称空间的路径.

rename_child_paths :这将使用给定名称重命名所有具有parent_id名称空间的路径.

rename_wildcard_paths :这将重命名所有项目的路径以及所有具有project_id 命名空间 .

这些行的path列将重命名为其先前的值,后跟一个整数. 例如: users将变成