Ruby on Rails: 大规模数据删除的3个技巧
随着 Rails 应用的规模增长,团队在删除数据时会遇到一些常见问题。
在本文中,你将学习几种策略,用于降低在高规模 Rails 应用中清理数据的风险。
Rails 删除关联数据的方式
在大规模 Rails 应用中,当一次性删除大量记录时通常会遇到问题。这种情况最常发生在存在许多关联关系的模型中。
在 Rails 中删除关联数据的标准方式是通过 dependent: :destroy
由 ActiveRecord 处理。例如,在以下代码中,当父模型(作者)被删除时,所有依赖模型的数据(比如书籍)也会被 ActiveRecord 一并删除:
class Author < ApplicationRecord has_many :books, dependent: :destroy end class Book < ApplicationRecord belongs_to :author end
数据库架构如下所示:
ActiveRecord::Schema[7.1].define(version: 2022_06_06_171750) do create_table "authors", force: true do |t| t.string "name" t.datetime "created_at" t.datetime "updated_at" end create_table "books", force: true do |t| t.string "name" t.text "description" t.bigint "author_id", null: false t.datetime "created_at" t.datetime "updated_at" t.index ["author_id"], name: "index_books_on_author_id" end end
以上数据库架构定义了一个索引外键,但没有外键约束。ActiveRecord 将负责删除关联数据。
在介绍了标准数据删除方式后,让我们看一些改善的技巧。
技巧1:使用 ActiveRecord 的 destroy_async
从 Rails 6.1 起,可以使用 dependent: :destroy_async
。它的工作原理与 dependent: :destroy
类似,区别在于它会通过后台任务异步运行数据删除,而不是在请求处理过程中完成。
这种方式可以保护你免于在单个事务中触发大量删除。随着 Rails 应用的规模增长,可能会不小心删除一个父记录,从而触发数千次级联删除。如果这些删除都发生在请求处理中,会导致超时并给数据库增加很大压力。
替换使用 ON DELETE CASCADE
的外键约束
外键约束用于维护表之间的引用完整性。具体来说,开发者可以使用 ON DELETE CASCADE
,当父记录被删除时同时删除所有关联记录。这是一些 Rails 应用会使用的替代标准 dependent: :destroy
的方法。
这种方式在子数据量较少时效果良好。但当删除大量子记录时就会成为问题。一条简单的 DELETE
请求可能会突然变成一个跨多个表删除数千条记录的大规模操作。这会导致用户的 DELETE
请求花费数秒完成或超时。在数据库层面上,这可能导致锁的使用过度、复制延迟增加,同时还会影响应用程序的其他部分。
我们推荐用 :destroy_async
替换任何使用外键约束的场景,以实现更安全的大规模删除操作。
留意验证失败问题
使用 destroy_async
时需要注意一个问题,即子模型的删除操作可能因验证失败而影响数据清理。由于是异步操作,用户不会意识到错误,任务会最终进入错误队列。如果任何子记录在删除时需要进行验证,我们建议从父模型发起这些验证。这将阻止删除,并通知用户问题所在。这是一个需要添加测试覆盖的关键区域,以防止回归问题。
技巧2:理解 delete
与 destroy
的区别
ActiveRecord 提供了两种主要的数据删除方式:delete
和 destroy
,以及它们的批量操作版本:delete_all
和 destroy_all
。
destroy
:删除记录,同时触发模型的回调(callbacks)。delete
:跳过回调,直接从数据库删除记录。
如果已经设置了回调,通常应该使用 destroy
以确保它们被触发。然而,在删除大量记录时,需谨慎考虑这些回调可能导致的额外操作。例如,一个用于清理旧数据的定时任务更适合使用 delete_all
以跳过回调。
技巧3:安全地批量删除旧数据
当某些数据不再需要时,归档或删除它是一种常见做法。
对于大的繁忙表,一次性删除大量记录可能会锁定表,并对应用的其他部分产生意外影响。更安全的做法是以小批量持续删除。
以下是一个 Sidekiq 的任务示例,可以通过 cron 定时每小时运行一次:
# frozen_string_literal: true class DeleteOldDataJob < BaseJob # 我们只希望此任务的一个实例在同一时间运行 sidekiq_options unique_for: 1.hour, unique_until: :start, queue: :background, retry: false def perform(limit: 500) # 删除 500 条记录 deleted = Model.where("created_at < ?", 3.months.ago).limit(limit).delete_all # 如果还有更多记录要删除,重新入队自己并再次运行 if deleted == limit self.class.perform_async end end end
此示例利用了 Sidekiq 的独占任务,避免了多个任务并发运行导致的死锁问题。如果你使用的任务系统没有独占特性,可以设置一个并发为1的队列,并在该队列中运行清理任务。
如何测试
为任务操作添加测试覆盖是确保删除正确数据的好方法。以下是一个可以使用的测试模式:
# frozen_string_literal: true require "test_helper" class DeleteOldDataJobTest < ActiveJob::TestCase test "deletes data over 3 months old" do expired = create(:data, minute: 3.months.ago - 1.hour) retained = create(:data, minute: 3.months.ago + 1.hour) DeleteOldDataJob.new.perform assert Data.where(id: expired.id).empty? assert Data.where(id: retained.id).exists? end test "requeues if more to delete" do create(:data, minute: 3.months.ago - 1.hour) create(:data, minute: 3.months.ago - 1.hour) assert_enqueued_sidekiq_jobs(1, only: DeleteOldDataJob) do DeleteOldDataJob.new.perform(limit: 1) end end end
Rails 与 PlanetScale
如果你在删除数据或更改模式时犯了错误,PlanetScale 提供了几种解决方案。你可以通过仪表板立即回滚错误的模式更改,而不会丢失任何数据。此外,如果你尝试删除最近被查询的表,我们会在仪表板中发出警告,希望能帮你在事故发生前捕捉到问题。
关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台
除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接