事务指南
本文档提供了一些在应用程序代码中使用数据库事务的示例。
如需进一步参考,请查看 PostgreSQL 关于事务的文档。
数据库拆分和分片
Tenant Scale 小组计划 将主 GitLab 数据库拆分,并将部分数据库表移动到其他数据库服务器。
我们首先开始拆分与 ci_* 相关的数据库表。为了保持当前的应用程序开发体验,我们在代码库中添加了工具和静态分析器,以确保正确的数据访问和数据修改方法。通过使用正确的数据库事务定义形式,我们可以为未来的重构工作节省大量时间。
事务块
ActiveRecord 库提供了一种便捷的方式,将数据库语句组合到事务中:
issue = Issue.find(10)
project = issue.project
ApplicationRecord.transaction do
issue.update!(title: 'updated title')
project.update!(last_update_at: Time.now)
end这个事务涉及两个数据库表。如果发生错误,每个 UPDATE 语句都会回滚到之前的一致状态。
避免引用 ActiveRecord::Base 类,改用 ApplicationRecord。
事务和数据库锁
当打开事务块时,数据库会尝试获取资源的必要锁。锁的类型取决于实际的数据库语句。
考虑一个并发更新场景,以下代码同时从两个不同进程执行:
issue = Issue.find(10)
project = issue.project
ApplicationRecord.transaction do
issue.update!(title: 'updated title')
project.update!(last_update_at: Time.now)
end数据库尝试为引用的 issue 和 project 记录获取 FOR UPDATE 锁。在这种情况下,我们有两个事务竞争这些锁,只有其中一个能成功获取。另一个事务必须在锁队列中等待,直到第一个事务完成。此时第二个事务的执行被阻塞。
事务速度
为了防止锁竞争并保持稳定的应用程序性能,事务块应尽快完成。当事务获取锁时,它会一直持有这些锁直到事务结束。
除了应用程序性能外,长时间运行的事务还可能通过阻塞数据库迁移来影响应用程序升级过程。
危险示例:第三方 API 调用
考虑以下示例:
member = Member.find(5)
Member.transaction do
member.update!(notification_email_sent: true)
member.send_notification_email
end在这里,我们确保只有在 send_notification_email 方法成功时才更新 notification_email_sent 列。send_notification_email 方法执行向邮件发送服务的网络请求。如果底层基础设施没有指定超时时间或网络调用耗时过长,数据库事务将保持打开状态。
理想情况下,事务应只包含数据库语句。
避免在 transaction 块中执行以下操作:
- 外部网络请求,例如:
- 触发 Sidekiq 作业。
- 发送邮件。
- HTTP API 调用。
- 使用不同连接运行数据库语句。
- 文件系统操作。
- 长时间的 CPU 密集型计算。
- 调用
sleep(n)。
显式模型引用
如果事务修改来自同一数据库表的记录,我们建议使用 Model.transaction 块:
build_1 = Ci::Build.find(1)
build_2 = Ci::Build.find(2)
Ci::Build.transaction do
build_1.touch
build_2.touch
end上述事务使用与 transaction 块中模型相同的数据库连接。在多数据库环境中,以下示例是危险的:
# `ci_builds` 表位于另一个数据库上
class Ci::Build < CiDatabase
end
build_1 = Ci::Build.find(1)
build_2 = Ci::Build.find(2)
ApplicationRecord.transaction do
build_1.touch
build_2.touch
endApplicationRecord 类使用与 Ci::Build 记录不同的数据库连接。事务块中的两个语句不是事务的一部分,如果出现问题也不会回滚。它们充当第三方调用。