- Migration Style Guide
- Migration Style Guide
- Schema Changes
- What Requires Downtime?
- Downtime Tagging
- Reversibility
- Atomicity
- Heavy operations in a single transaction
- Retry mechanism when acquiring database locks
- Multi-Threading
- Removing indexes
- Adding indexes
- Adding foreign-key constraints
NOT NULL
constraints- Adding Columns With Default Values
- Changing the column default
- Updating an existing column
- Dropping a database table
- Integer column type
- Strings and the Text data type
- Timestamp column type
- Storing JSON in database
- Testing
- Data migration
Migration Style Guide
原文:https://docs.gitlab.com/ee/development/migration_style_guide.html
- Schema Changes
- What Requires Downtime?
- Downtime Tagging
- Reversibility
- Atomicity
- Heavy operations in a single transaction
- Retry mechanism when acquiring database locks
- Multi-Threading
- Removing indexes
- Adding indexes
- Adding foreign-key constraints
NOT NULL
constraints- Adding Columns With Default Values
- Changing the column default
- Updating an existing column
- Dropping a database table
- Integer column type
- Strings and the Text data type
- Timestamp column type
- Storing JSON in database
- Testing
- Data migration
Migration Style Guide
在为 GitLab 编写迁移时,必须考虑到这些迁移将由成千上万个各种规模的组织来运行,其中一些组织的数据库中包含多年的数据.
此外,对于大多数组织来说,必须使服务器脱机以进行较小或较大的升级是一大负担. 因此,重要的是要仔细编写迁移文件,可以在线进行迁移,并遵守下面的样式指南.
迁移不准要求 GitLab 设施采取离线,除非绝对必要的 .
如果需要停机,则必须通过以下方式批准迁移:
- 工程副总裁
- 后端维护者
- 数据库维护者
可以在https://about.gitlab.com/company/team/中找到持有这些头衔的人员的最新列表.
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
).
Schema Changes
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/migrate
或db/post_migrate
找到的所有迁移,因此,如果您不想将任何迁移提交到架构,请重命名或删除它们. 如果您的分支不是以master
为目标,则可以设置TARGET
环境变量.
# Regenerate schema against `master`
scripts/regenerate-schema
# Regenerate schema against `12-9-stable-ee`
TARGET=12-9-stable-ee scripts/regenerate-schema
What Requires Downtime?
文档“什么需要停机?” 指定各种数据库操作,例如
- dropping and renaming columns
- changing column constraints and types
- adding and dropping indexes, tables, and foreign keys
以及他们是否需要停机,以及如何尽可能地进行停机.
Downtime Tagging
每个迁移都必须指定是否需要停机,并且如果需要停机,还必须指定原因. 这是即使在迁移的 99%,不需要停机,因为这使得它更容易地发现, 确实需要停机迁移所需.
要标记迁移,请在迁移类的主体中添加以下两个常量:
DOWNTIME
:一个布尔值,当设置为true
指示迁移需要停机时间.DOWNTIME_REASON
:一个字符串,其中包含需要停机的迁移原因. 当DOWNTIME
设置为true
时, 必须设置此常数.
例如:
class MyMigration < ActiveRecord::Migration[6.0]
DOWNTIME = true
DOWNTIME_REASON = 'This migration requires downtime because ...'
def change
...
end
end
如果迁移类中缺少DOWNTIME
常量,则会出现错误(即 CI 将失败).
Reversibility
您的迁移必须是可逆的. 这非常重要,因为在出现漏洞或错误的情况下应该可以降级.
在迁移中,添加一条注释,描述如何测试迁移的可逆性.
某些迁移无法撤消. 例如,某些数据迁移无法撤消,因为在迁移之前,我们丢失了有关数据库状态的信息. 您仍然应该创建一个带注释的down
方法,解释为什么不能撤消up
方法执行的更改,以便即使撤消迁移期间执行的更改也可以撤消迁移本身:
def down
# no-op
# comment explaining why changes performed by `up` cannot be reversed.
end
Atomicity
默认情况下,迁移是单个事务. 即,在迁移开始时打开一个事务,并在处理完所有步骤后提交.
在单个事务中运行迁移可确保如果其中一个步骤失败,则将不会执行任何步骤,从而使数据库保持有效状态. 因此,要么:
- 将所有迁移都放在一个单事务迁移中.
- 如有必要,将大多数操作放入一个迁移中,并为无法在单个事务中完成的步骤创建一个单独的迁移.
例如,如果创建一个空表并需要为其建立索引,则建议使用常规的单事务迁移和默认的 rails schema 语句: add_index
. 这是一个阻塞操作,但不会引起问题,因为该表尚未使用,因此它还没有任何记录.
Heavy operations in a single transaction
使用单事务迁移时,事务将在迁移期间保持数据库连接,因此您必须确保迁移中的操作不会花费太多时间:通常,在迁移中执行的查询需要在 GitLab.com 上舒适地放置15s
.
如果您需要插入,更新或删除大量数据,请执行以下操作:
- 必须使用
disable_ddl_transaction!
禁用单个事务disable_ddl_transaction!
. - 应该考虑在后台迁移中这样做.
Retry mechanism when acquiring database locks
更改数据库架构时,我们使用辅助方法来调用 DDL(数据定义语言)语句. 在某些情况下,这些 DDL 语句需要特定的数据库锁.
Example:
def change
remove_column :users, :full_name, :string
end
要执行此迁移,需要对users
表进行排他锁定. 当表被其他进程同时访问和修改时,获取锁定可能需要一段时间. 锁定请求正在队列中等待,一旦进入队列,它也可能阻止users
表上的其他查询.
有关 PostgresSQL 锁的更多信息: 显式锁定
出于稳定性考虑,GitLab.com 设置了特定的statement_timeout
. 调用迁移时,任何数据库查询都将有固定的执行时间. 在最坏的情况下,请求将坐在锁定队列中,在配置的语句超时时间内阻止其他查询,然后canceling statement due to statement timeout
错误canceling statement due to statement timeout
失败.
此问题可能导致应用程序升级过程失败,甚至导致应用程序稳定性问题,因为该表可能会在短时间内无法访问.
为了提高数据库迁移的可靠性和稳定性,GitLab 代码库提供了一种辅助方法,以使用不同的lock_timeout
设置重试操作,并在两次尝试之间等待时间. 为了获得必要的锁定,进行了多次较小的尝试,从而使数据库可以处理其他语句.
Examples
删除列:
include Gitlab::Database::MigrationHelpers
def up
with_lock_retries do
remove_column :users, :full_name
end
end
def down
with_lock_retries do
add_column :users, :full_name, :string
end
end
删除外键:
include Gitlab::Database::MigrationHelpers
def up
with_lock_retries do
remove_foreign_key :issues, :projects
end
end
def down
with_lock_retries do
add_foreign_key :issues, :projects
end
end
更改列的默认值:
include Gitlab::Database::MigrationHelpers
def up
with_lock_retries do
change_column_default :merge_requests, :lock_version, from: nil, to: 0
end
end
def down
with_lock_retries do
change_column_default :merge_requests, :lock_version, from: 0, to: nil
end
end
用外键创建一个新表:
我们可以简单地用with_lock_retries
包装create_table
方法:
def up
with_lock_retries do
create_table :issues do |t|
t.references :project, index: true, null: false, foreign_key: { on_delete: :cascade }
t.string :title, limit: 255
end
end
end
def down
drop_table :issues
end
当我们有两个外键时创建一个新表:
为此,我们需要进行三个迁移:
- 创建不带外键(带有索引)的表.
- 将外键添加到第一个表.
- 将外键添加到第二个表.
创建表:
def up
create_table :imports do |t|
t.bigint :project_id, null: false
t.bigint :user_id, null: false
t.string :jid, limit: 255
end
add_index :imports, :project_id
add_index :imports, :user_id
end
def down
drop_table :imports
end
在projects
添加外键:
include Gitlab::Database::MigrationHelpers
def up
with_lock_retries do
add_foreign_key :imports, :projects, column: :project_id, on_delete: :cascade
end
end
def down
with_lock_retries do
remove_foreign_key :imports, column: :project_id
end
end
向users
添加外键:
include Gitlab::Database::MigrationHelpers
def up
with_lock_retries do
add_foreign_key :imports, :users, column: :user_id, on_delete: :cascade
end
end
def down
with_lock_retries do
remove_foreign_key :imports, column: :user_id
end
end
与disable_ddl_transaction!
配合使用disable_ddl_transaction!
通常, with_lock_retries
帮助程序应与disabled_ddl_transaction!
. 定制的 RuboCop 规则可确保只能将允许的方法放在锁重试块中.
disable_ddl_transaction!
def up
with_lock_retries do
add_column :users, :name, :text
end
add_text_limit :users, :name, 255 # Includes constraint validation (full table scan)
end
RuboCop 规则通常允许使用下面列出的标准 Rails 迁移方法. 此示例将导致 Rubocop 犯罪:
disabled_ddl_transaction!
def up
with_lock_retries do
add_concurrent_index :users, :name
end
end
When to use the helper method
通常使用标准的 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
方法中使用,必须手动定义up
和down
方法以使迁移可逆.
How the helper method works
- 重复 50 次.
- 对于每次迭代,设置一个预配置的
lock_timeout
. - 尝试执行给定的块. (
remove_column
). - 如果出现
LockWaitTimeout
错误,请为预配置的sleep_time
睡眠,然后重试该块. - 如果未引发错误,则当前迭代已成功执行该块.
有关更多信息,请检查Gitlab::Database::WithLockRetries
类. with_lock_retries
帮助器方法在Gitlab::Database::MigrationHelpers
模块中实现.
在最坏的情况下,该方法:
- 在 40 分钟内最多执行 50 次该块.
- 大部分时间都花费在每次迭代后的预先配置的睡眠时段中.
- 第 50 次重试之后,将像标准迁移调用一样在没有
lock_timeout
情况下执行该块. - 如果无法获取锁,则迁移将失败,并出现
statement timeout
错误.
如果访问users
表的事务运行时间很长(超过 40 分钟),则迁移可能会失败.
Multi-Threading
有时,迁移可能需要使用多个 Ruby 线程来加快迁移速度. 为此,您的迁移需要包括模块Gitlab::Database::MultiThreadedMigration
:
class MyMigration < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
include Gitlab::Database::MultiThreadedMigration
end
然后可以使用with_multiple_threads
方法在单独的线程中执行工作. 例如:
class MyMigration < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
include Gitlab::Database::MultiThreadedMigration
def up
with_multiple_threads(4) do
disable_statement_timeout
# ...
end
end
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!
在您的迁移类的主体中,如下所示:
class MyMigration < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
def up
remove_concurrent_index :table_name, :column_name
end
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!
在您的迁移类的主体中,如下所示:
class MyMigration < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :table, :column
end
def down
remove_concurrent_index :table, :column
end
end
如果需要添加唯一索引,请记住,数据库中可能存在现有重复项. 这意味着在添加唯一索引之前,应始终首先添加一个删除所有重复项的迁移.
对于小型表(例如空表或少于1,000
条记录的表),建议在单事务迁移中使用add_index
,并将其与不需要disable_ddl_transaction!
其他操作结合使用disable_ddl_transaction!
.
Adding foreign-key constraints
向现有列或新列添加外键约束时,还请记住在该列上添加索引.
这是所有外键所必需的,例如,为了支持有效的级联删除:当删除表中的许多行时,也需要删除引用的记录. 数据库必须在引用的表中查找相应的记录. 没有索引,这将导致对表进行顺序扫描,这可能需要很长时间.
这是一个示例,其中我们添加了一个带有外键约束的新列. 请注意,它包括index: true
为其创建索引.
class Migration < ActiveRecord::Migration[6.0]
def change
add_reference :model, :other_model, index: true, foreign_key: { on_delete: :cascade }
end
end
当向非空表中的现有列添加外键约束时,我们必须使用add_concurrent_foreign_key
和add_concurrent_index
而不是add_reference
.
对于空表(例如新表),建议在单事务迁移中使用add_reference
,并将其与不需要disable_ddl_transaction!
其他操作结合使用disable_ddl_transaction!
.
NOT NULL
constraints
Introduced in GitLab 13.0.
有关更多信息,请参见关于NOT NULL
约束的样式指南.
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
更改默认列对于较大的表来说是一项昂贵且破坏性的操作,但实际上并非如此.
以以下迁移为例:
class DefaultRequestAccessGroups < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
change_column_default(:namespaces, :request_access_enabled, from: false, to: true)
end
end
上面的迁移更改了我们最大的表之一的默认列值: namespaces
. 可以将其翻译为:
ALTER TABLE namespaces
ALTER COLUMN request_access_enabled
DEFAULT false
在这种情况下,默认值存在,我们只是更改了request_access_enabled
列的元数据,这并不意味着重写namespaces
表中的所有现有记录. 仅当使用默认值创建新列时,所有记录才会被重写.
注意: PostgresSQL 11.0 引入了更快的ALTER TABLE ADD COLUMN(具有非空默认值 ),从而消除了在添加具有默认值的新列时重写表的需求.
由于上述原因,在单事务迁移中使用change_column_default
是安全的,而不需要disable_ddl_transaction!
.
Updating an existing column
要将现有列更新为特定值,可以使用update_column_in_batches
. 这会将更新分为批次,因此我们不会在单个语句中更新太多行.
这some_column
projects
表中的foo
列更新为 10,其中some_column
为'hello'
:
update_column_in_batches(:projects, :foo, 10) do |table, query|
query.where(table[:some_column].eq('hello'))
end
如果需要计算的更新,则可以将值包装在Arel.sql
,因此 Arel 将其视为 SQL 文字. 这也是Rails 6的必需弃用.
下面的示例与上面的示例相同,但是该值设置为bar
和baz
列的乘积:
update_value = Arel.sql('bar * baz')
update_column_in_batches(:projects, :foo, update_value) do |table, query|
query.where(table[:some_column].eq('hello'))
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
方法即可.
def change
drop_table :my_table
end
Table 有记录 but 没有外键:
- 第一版:删除与表相关的应用程序代码,例如模型,控制器和服务.
- 第二版:在迁移中使用
drop_table
方法.
def up
drop_table :my_table
end
def down
# create_table ...
end
Table 有外键:
- 第一版:删除与表相关的应用程序代码,例如模型,控制器和服务.
- 第二版:使用
with_lock_retries
帮助器方法删除外键. 在另一个迁移文件中使用drop_table
.
第二个版本的迁移:
删除projects
表上的外键:
# first migration file
def up
with_lock_retries do
remove_foreign_key :my_table, :projects
end
end
def down
with_lock_retries do
add_foreign_key :my_table, :projects
end
end
放下桌子:
# second migration file
def up
drop_table :my_table
end
def down
# create_table ...
end
Integer column type
默认情况下,整数列最多可容纳 4 个字节(32 位)的数字. 最大值为 2,147,483,647. 创建一个以字节为单位保存文件大小的列时,请注意这一点. 如果以字节为单位跟踪文件大小,这会将最大文件大小限制为刚好超过 2GB.
要允许整数列最多容纳 8 个字节(64 位)的数字,请将限制明确设置为 8 个字节. 这将使该列最多9,223,372,036,854,775,807
个值.
Rails 迁移示例:
add_column(:projects, :foo, :integer, default: 10, limit: 8)
Strings and the Text data type
在 GitLab 13.0 中引入 .
有关更多信息,请参见文本数据类型样式指南.
Timestamp column type
默认情况下,Rails 使用timestamp
数据类型来存储没有时区信息的时间戳数据. 通过调用add_timestamps
或timestamps
方法来使用timestamp
数据类型.
另外,Rails 将:datetime
数据类型转换为timestamp
一.
Example:
# timestamps
create_table :users do |t|
t.timestamps
end
# add_timestamps
def up
add_timestamps :users
end
# :datetime
def up
add_column :users, :last_sign_in, :datetime
end
代替使用这些方法,应该使用以下方法来存储带有时区的时间戳:
add_timestamps_with_timezone
timestamps_with_timezone
datetime_with_timezone
这样可以确保所有时间戳都有指定的时区. 反过来,这意味着当系统的时区更改时,现有时间戳不会突然使用其他时区. 这也使得非常清楚,首先使用了哪个时区.
Storing JSON in database
Rails 5 本机支持JSONB
(二进制 JSON)列类型. 添加此列的示例迁移:
class AddOptionsToBuildMetadata < ActiveRecord::Migration[5.0]
DOWNTIME = false
def change
add_column :ci_builds_metadata, :config_options, :jsonb
end
end
您必须使用序列化器来提供翻译层:
class BuildMetadata
serialize :config_options, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize
end
使用JSONB
列时,请使用JsonSchemaValidator来控制随时间推移插入的数据.
class BuildMetadata
validates :config_options, json_schema: { filename: 'build_metadata_config_option' }
end
Testing
请参阅” 测试 Rails 迁移样式”指南.
Data migration
请比一般 ActiveRecord 语法更喜欢 Arel 和普通 SQL. 如果使用普通 SQL,则需要使用quote_string
helper 手动引用所有输入.
Arel 的示例:
users = Arel::Table.new(:users)
users.group(users[:user_id]).having(users[:id].count.gt(5))
#update other tables with these results
带有普通 SQL 和quote_string
帮助器的示例:
select_all("SELECT name, COUNT(id) as cnt FROM tags GROUP BY name HAVING COUNT(id) > 1").each do |tag|
tag_name = quote_string(tag["name"])
duplicate_ids = select_all("SELECT id FROM tags WHERE name = '#{tag_name}'").map{|tag| tag["id"]}
origin_tag_id = duplicate_ids.first
duplicate_ids.delete origin_tag_id
execute("UPDATE taggings SET tag_id = #{origin_tag_id} WHERE tag_id IN(#{duplicate_ids.join(",")})")
execute("DELETE FROM tags WHERE id IN(#{duplicate_ids.join(",")})")
end
如果需要更复杂的逻辑,则可以定义和使用迁移本地模型. 例如:
class MyMigration < ActiveRecord::Migration[6.0]
class Project < ActiveRecord::Base
self.table_name = 'projects'
end
def up
# Reset the column information of all the models that update the database
# to ensure the Active Record's knowledge of the table structure is current
Project.reset_column_information
# ... ...
end
end
这样做时,请确保显式设置模型的表名,这样它就不会派生自类名或名称空间.
最后,对于所有更新数据库的本地模型,请确保在迁移的up
方法中运行reset_column_information
.
这样做的原因是所有迁移类都在开始时加载( db:migrate
启动时),因此,如果另一个迁移更新了该模式,它们将与它们映射的表模式不同步. 这将导致在尝试插入基础表或对其进行更新时数据迁移失败,因为ActiveRecord
将新列报告为unknown attribute
.
Renaming reserved paths
引入新的项目路线时,它可能与任何现有记录冲突. 这些记录的路径应重命名,并且相关数据应在磁盘上移动.
由于我们已经必须做几次,因此现在有一些帮助程序可以帮助您.
要使用此功能,您可以在迁移中包括Gitlab::Database::RenameReservedPathsMigration::V1
. 这将提供 3 种方法,您可以通过一种或多种需要拒绝的路径.
rename_root_paths
:这将使用给定名称重命名所有没有parent_id
名称空间的路径.
rename_child_paths
:这将使用给定名称重命名所有具有parent_id
名称空间的路径.
rename_wildcard_paths
:这将重命名所有项目的路径以及所有具有project_id
命名空间 .
这些行的path
列将重命名为其先前的值,后跟一个整数. 例如: users
将变成users0