Проверка 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
Наш код для создания новых тегов выглядел примерно так:
|
Состояние в базе данных
Проверка нашей базы данных показала, что было 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
но индекс не был уникален.
Чтобы сделать этот индекс уникальным без простоев, необходимо выполнить следующие шаги:
- Убедитесь, что новые дублированные записи не могут быть созданы.
- Очистка базы данных.
- Удалить старый индекс.
- Создайте новый уникальный индекс.
Предотвращение создания неуникальных записей между очисткой и миграцией индекса
Добавление уникального индекса в таблицу, которая содержит неуникальные записи, вызовет исключение. Поэтому нам нужно будет очистить базу данных перед добавлением индекса. Если между очисткой и добавлением индекса будут введены новые дублированные записи, миграция завершится неудачно. Поэтому сначала мы должны убедиться, что новые дублированные записи не могут быть созданы. Вы можете решить эту проблему с помощью временных таблиц, но мы выбрали другой подход. Поскольку 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.