随着 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:理解 deletedestroy 的区别

ActiveRecord 提供了两种主要的数据删除方式:deletedestroy,以及它们的批量操作版本:delete_alldestroy_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 提供了几种解决方案。你可以通过仪表板立即回滚错误的模式更改,而不会丢失任何数据。此外,如果你尝试删除最近被查询的表,我们会在仪表板中发出警告,希望能帮你在事故发生前捕捉到问题。



Ruby on Rails: 大规模数据删除的3个技巧插图

关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台

除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接

本文链接:http://www.choupangxia.com/2025/09/07/ruby-on-rails/