Удаляем записи из базы данных

December 18, 2014

Если база маленькая её можно и даже, думаю, нужно, считать той штукой, которая выполняет Active Record запросы. Когда записей уже миллионы, начинаются разные интересные задачи.

Как то раз я расщеплял базу на две и нужно было удалить примерно 10 миллионов детей, которые остались без родителей (имена таблиц - выдуманные).

Иван Евтухович (я всегда звоню ему с техническими вопросами, Иван знает все, особенно про PostgreSQL) сказал, что можно конечно удалить в лоб, в одной транзакции, но она будет работать долго и молча. Лучше положить id во временную таблицу и удалять записи небольшими порциями, не теряя лица.

Играем с тестовой базой

Заполняем базу крайне синтетическими данными на 20M записей :

# ...
10_000.times do
  parent = Parent.create!
  values = ["(#{parent.id})"]*1000
  sql = "INSERT INTO children (parent_id) VALUES #{values.join(", ")};"
  ActiveRecord::Base.connection.execute(sql)
  puts "Parent ##{parent.id}"
end
10_000.times do |i|
  values = ["(0)"]*1000
  sql = "INSERT INTO children (parent_id) VALUES #{values.join(", ")};"
  ActiveRecord::Base.connection.execute(sql)
  puts "Chunk ##{i}"
end

Удаляем одним запросом:

DELETE FROM children
WHERE id IN (
  SELECT children.id
  FROM children LEFT JOIN parents ON children.parent_id = parents.id
  WHERE parents.id IS NULL
);
-- => DELETE 10000000
-- => Time: 7538596.724 ms (~ 2 hours)

Удаляем порциями по 1000 записей :

# ...
unless ActiveRecord::Base.connection.table_exists?("deleting_ids")
  m1 = Benchmark.measure {
    ActiveRecord::Base.connection.execute <<-SQL
      BEGIN;

      CREATE TABLE deleting_ids(id integer);
      CREATE INDEX ON deleting_ids(id);

      INSERT INTO deleting_ids
      SELECT children.id
      FROM children LEFT JOIN parents ON children.parent_id = parents.id
      WHERE parents.id IS NULL;

      COMMIT;
    SQL
  }
end

m2 = Benchmark.measure {
  index = 0
  while DeletingId.first
    ids = DeletingId.limit(1000).pluck(:id)
    Child.delete_all(id: ids)
    DeletingId.delete_all(id: ids)
    puts "Chunk ##{index += 1} processed"
  end
}
# => (112.227890)   (~ 2 mins)
# => (1052.880441)  (~ 17 mins)

Выводы

В одной транзакции 10М записей удалялось 2 часа, по частям - 20 минут (эти времена некорректно сравнивать, так как многое зависит от настроек базы, данных, нагрузки и так далее).

Даже не обращая внимание на время исполнения, второй способ все равно гораздо лучше, так как он явно показывает прогресс и изменяет базу небольшими, контролируемыми шагами. Более того, скрипт можно прервать в любом месте и запустить заново, он просто продолжит удалять, что в случае одной транзакции сделать невозможно.

comments powered by Disqus