Статьи

Подводные камни в валидации уникальности с использованием Rails ActiveRecord

Название изображения

Проверка ActiveRecord в Rails обеспечивает простой способ проверки записей на уникальность. На примере реального мира мы показываем, почему использование только валидации Rails может стать проблемой, и демонстрируем способ очистки базы данных без простоев.

Эта проблема

Маркировка ключей перевода была добавлена ​​в качестве функции продукта на самых ранних этапах разработки фразы . Хотя эта функция постоянно совершенствовалась и производительность была очень надежной, однажды клиент связался с нами из-за ошибки проверки. Этот клиент довольно широко использовал функцию тегирования для автоматической маркировки всех загруженных ключей с помощью запроса извлечения GitHub, что приводило к ошибке проверки существующего тега. Таким образом, мы исследовали проблему и обнаружили, что ошибка была вызвана именем тега, которое не было уникальным для проекта. Так как это могло произойти? Мы использовали проверку уникальности ActiveRecord и использовали только Rails без пропуска проверки.

Код государства

Последнее состояние было то , что  Tagдолжно быть имя поля и принадлежат к проекту, а также иметь подтверждение ActiveRecord так, чтобы метка была уникальной в  project_idи name

Состояние кодовой базы

class Tag < ActiveRecord::Base
 has_and_belongs_to_many :translation_keys   
  ...   
  belongs_to :project   
  belongs_to :account   
  ...   
  validates_uniqueness_of :name, scope: :project_id   
  ... 
end

Наш код для создания новых тегов выглядел примерно так:

 
class Keys::TagService
  def self.create_or_find_tag_by_name_for_project(project, tag_name, user, ...)
    ...
    tag = TagRepository.tags_for_project(project).find_or_initialize_by(name: tag_name)
    if tag.new_record? && tag.save
      ...
    end
 
    tag
  end
end

Состояние в базе данных

Проверка нашей базы данных показала, что было 40 тыс. Записей, которые не были уникальными.

SELECT COUNT(*) FROM
  (SELECT COUNT(*) 
   FROM tags
   GROUP BY project_id, name
   HAVING COUNT(*) > 1) AS g

Кроме того, мы увидели, что все дублированные записи были сгенерированными системой тегами, созданными нашей функцией загрузки. Загрузки обрабатываются одновременно асинхронно работниками. Используя команду push нашего клиента, запускается загрузка для каждой локали. При любой загрузке, где пользователь может выделить тег, ключи также должны быть помечены. Эта комбинация значительно увеличивает вероятность того, что два процесса попытаются создать тег одновременно.

Что произошло?

Если проверка уникальности включена,  Rails будет искать существующие записи перед выполнением  Tag.create, Tag.save, Tag.update ...операций. Если запись была найдена, проверка завершится неудачно, и транзакция будет отменена, если запись не будет сохранена.

Пример проверки не пройден:

pry(main)> Tag.create(name: "test", project_id: 1)
   (0.2ms)  BEGIN
  ...
  Tag Exists (0.2ms)  SELECT  1 AS one FROM `tags` WHERE (`tags`.`name` = BINARY 'test' AND `tags`.`project_id` = 1) LIMIT 1
   (0.1ms)  ROLLBACK

Пример успеха:

pry(main)> Tag.create(name: "test", project_id: 1)
   (0.2ms)  BEGIN
  ### Some other validations ###
  ..
  Tag Exists (0.3ms)  SELECT  1 AS one FROM `tags` WHERE (`tags`.`name` = BINARY 'test' AND `tags`.`project_id` = 1) LIMIT 1
  ..
  ### some before_save and before_create hooks ###
  ..
  SQL (0.2ms)  INSERT INTO `tags` (`name`, `project_id`, `slug`, `created_at`, `updated_at`) VALUES ('test', 1, 'test', '2017-02-13 12:56:07', '2017-02-13 12:56:07')
   (14.7ms)  COMMIT

В этом примере вы можете видеть, что между таблицей SELECTи  INSERTоператором нет блокировки таблицы  . В этот промежуток времени другой параллельный процесс может также создать запись с тем же именем и тем же идентификатором проекта без какой-либо неудачной проверки. Как видите, перехватчики также запускаются между проверкой и оператором INSERT. Увеличение количества и сложности хуков также приведет к увеличению вероятности создания неуникальных записей.

Решение и извлеченные уроки

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

Чтобы сделать этот индекс уникальным без простоев, необходимо выполнить следующие шаги:

  1. Убедитесь, что новые дублированные записи не могут быть созданы.
  2. Очистка базы данных.
  3. Удалить старый индекс.
  4. Создайте новый уникальный индекс.

Предотвращение создания неуникальных записей между очисткой и миграцией индекса

Добавление уникального индекса в таблицу, которая содержит неуникальные записи, вызовет исключение. Поэтому нам нужно будет очистить базу данных перед добавлением индекса. Если между очисткой и добавлением индекса будут введены новые дублированные записи, миграция завершится неудачно. Поэтому сначала мы должны убедиться, что новые дублированные записи не могут быть созданы. Вы можете решить эту проблему с помощью временных таблиц, но мы выбрали другой подход. Поскольку MySQL игнорирует  NULLзначения для уникальных индексов, мы добавили новый столбец, для которого мы установили project_id, и имя, объединенное с новыми записями. Значение по умолчанию этого столбца должно быть  NULL. В этом поле мы можем добавить уникальный индекс.

class AddTmpFieldToTags < ActiveRecord::Migration
  def change
    add_column :tags, :tmp_field, :string, null: true, default: nil

    add_index :tags, [:tmp_field], unique: true
  end
end

Когда мы не создаем никаких ловушек пропуска записей тегов, мы можем добавить  before_saveловушку, которая устанавливает  tmp_fieldконкатенацию  project_idи  name. Этот индекс предотвратит создание неуникальных записей тегов.

Теперь приложение будет вызывать исключение каждый раз, когда оно пытается создать дублированную запись. Чтобы избежать этого, мы должны изменить наш код для создания тегов, чтобы он решал эту проблему. Rails вызовет исключение RecordNotUnique в этом случае, поэтому мы можем перехватить это, выбрать и вернуть существующий тег. Здесь вы должны обязательно перезагрузить компьютер, чтобы избежать странных проблем с кэшированием запросов.

...
  def self.create_or_find_tag_by_name_for_project(project, tag_name, user)
    tag = TagRepository.tags_for_project(project).find_or_initialize_by(name: tag_name)
    begin
      if tag.new_record? && tag.save
        ...
      end 
    rescue ActiveRecord::RecordNotUnique
      tag = TagRepository.tags_for_project(project).reload.find_by(name: tag_name)
    end 

    tag
  end
...

Очистка и изменение индекса

После того, как изменения развернуты, мы можем очистить базу данных. Здесь вы можете перейти на миграцию или скрипт. Мы решили сделать это путем миграции. Во время очистки важно установить правильные ассоциации, потому что мы не хотим терять связь между тегами и ключами перевода. После завершения очистки индекс можно изменить на уникальный.

class AddUniqueIndexOnProjectIdAndNameToTags < ActiveRecord::Migration def change remove_index :tags, [:project_id, :name] cleanup add_index :tags, [:project_id, :name], unique: true end private def cleanup dub_tags = ActiveRecord::Base.connection.exec_query("SELECT name, project_id, COUNT(*) FROM tags GROUP BY name, project_id HAVING COUNT(*) > 1").to_hash

    dub_tags.each do |tag|
      unify_tag(tag["name"], tag["project_id"])
    end
  end

  def unify_tag(name, project_id)
    tags = Tag.where(project_id: project_id, name: name).order(:id)
    return if tags.count < 2

    Tag.transaction do
      keys_to_tag = []
      tag_to_keep = tags.to_a.first
      tags.each_with_index do |tag, index|
        keys_to_tag = keys_to_tag.concat(tag.translation_keys.to_a)
        tag.destroy if tag.id != tag_to_keep.id
      end

      tag_to_keep.translation_keys = keys_to_tag.uniq { |key| key.id }
      tag_to_keep.save
    end
  end
end

После выполнения этой миграции временное поле и крюк для его заполнения можно удалить.

Уроки выучены

  • Когда использовать уникальный индекс с проверкой уникальности Rails.
  • Как провести очистку и добавить уникальный индекс с нулевым временем простоя на MySQL.