Это ещё один текст по мотивам доклада на Ruby Russia 2022. Он посвящён консистентности схемы базы данных на примере библиотеки database_consistency. Автор — Евгений Демин, Principal Engineer и Ruby-разработчик Toptal.

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

Консистенции между ActiveRecord и базой данных

В 2018 году где-то в глубинах нашей ActiveRecord произошло падение со странной ошибкой.

Что происходит? Трудно понять, почему при обновлении одного поля у нас падает валидация по другому. 

Оказывается, валидация была добавлена не так давно, то есть модель и таблица уже существовали. Почему упала валидация? Потому что в базе данных есть старые записи, которые забыли обновить.

Как можно было этого избежать? Одно из очевидных решений — при добавлении валидации убедиться, что все записи в нашей таблице готовы к этой валидации.

Как это сделать? С помощью null constraint.

К сожалению, любой может забыть добавить null constraint в процессе добавления валидации из-за большого объема работы. Поэтому мне стало интересно, как часто случаются такие ситуации, когда разработчик добавил валидацию, но не убедился, что в базе данных существует null constraint

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

v0.1.0 ColumnPresenceChecker 

v0.1.0 ColumnPresenceChecker — это первая проверка, которая появилась в библиотеке.

Допустим, у нас есть таблица users, а также класс User с валидацией над полем name. В данном случае библиотека покажет, что не хватает null constraint над полем name нашей таблицы.

Соответственно, нужна следующая схема баз данных:

Сделать это можно с помощью простой миграции. 

ПРИМЕЧАНИЕ. Эта миграция не подходит для тех, кто следует Zero Downtime Deployment Policy.

В нашем монолите оказалось более 500 кейсов, что много даже для нашей базы данных. Но, как это часто бывает в больших компаниях, эта хорошая идея пришла в голову не только мне. Проблему решил коллега из другой команды вручную с помощью небольшого скрипта. Однако я решил продолжить писать гем, чтобы в дальнейшем его можно было расширять и добавлять другие проверки, а также выложить в Open Source, чтобы им могли пользоваться другие компании и индивидуальные разработчики. Спустя несколько месяцев произошел релиз гема, и он стал дополняться новыми проверками. 

v0.2.0 NullConstraintChecker

NullConstraintChecker — это вторая проверка после ColumnPresenceChecker, которая делает то же самое, но в обратную сторону. Предположим, у нас есть null constraint, но нет валидации на модели. Это проблема. Например, пользователь использует один из наших интерфейсов, API или страницу и пытается заполнить какую-то форму, но не заполняет определенные значения. Если бы у нас была валидация, выпала бы ошибка 422 Unprocessable Entity, и на фронтенде мы могли бы ее соответствующим образом обработать. Однако, если мы не добавляем валидацию, сервер будет падать с 500-ой ошибкой, что не очень хорошо.

v.0.4.0 BelongsToPresenceChecker

BelongsToPresenceChecker отслеживает, чтобы все неполиморфные ассоциации BelongsTo имели соответствующие foreign key constraint в базе данных.

Это очень полезная проверка, чтобы гарантированно иметь данные, связанные с нашей таблицей в связанных таблицах, чего без foreign key constraint мы гарантировать не можем.

v.0.5.0 MissingUniqueIndexChecker

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

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

В этот момент в нашей компании произошла новая неочевидная проблема.

v0.6.0 MissingIndexChecker

Предположим, у нас есть простая таблица users, таблица accounts с user_id, модель Users с has_one :account и модель Accounts с belongs_to

Казалось бы, ничего не предвещает беды. ActiveRecord работает как часы, для каждого пользователя возвращаются аккаунты.

Однако для конкретного пользователя, когда мы выполняем joins, почему-то отображается два аккаунта, то есть всего две записи.

Чего же не хватает? Это не очевидно, и не все знают об этой проблеме по умолчанию. Все очень просто. has_one не гарантирует, что будет 0 или 1 связь с нашей базой данных. Поэтому желательно иметь соответствующий уникальный индекс над нашим связующим вторичным ключом, что не сделано в первоначальной схеме.

Отсутствие индекса может привести к очень странным последствиям. Например, часть нашей программы может отлично работать, но в конкретных ситуациях все может посыпаться и потребовать усилий, чтобы все исправить.

Индекс в схеме должен быть уникальным.

Добиться этого можно простой миграцией.

Пролетел еще один год и вышли новые проверки.

v0.7.0 LengthConstraintChecker

Данная проверка смотрит, чтобы все наши типы данных, которые имеют определенные лимиты, например, varchar(128), в нашей базе данных, имели соответствующие валидации на длину в наших моделях.

v.0.8.0 PrimaryKeyTypeChecker

Начиная с пятой версии Rails, выходит обновление, предусматривающее, что все первичные ключи по умолчанию имеют большой тип данных, например, BIGINT или BIG SERIAL. К сожалению, в каких-то проектах первичные ключи имеют меньший тип данных. Это нормально, поскольку не так-то просто переполнить 2 миллиарда первичных ключей. Однако это можно сделать, и после переполнения это приведет к неординарным проблемам.

В конце 2020 года Дэвид Хенсон, создатель Rails, больше известный, как DHH, рассказал, как все рассыпалось в Basecamp. Они столкнулись с переполнением типа для вторичного ключа, то есть первичный ключ имел больший тип данных, чем вторичный. Соответственно, когда были добавлены новые записи для таблицы, все было хорошо, но в связывающей таблице вторичный ключ переполнился. Связь была утеряна или же указывала на другую запись в первичной таблице (что, возможно, еще хуже). 

Поэтому я решил написать валидатор для DHH, чтобы находить такие ситуации автоматически. Мы это сделали в рамках v0.8.5 ForeignKeyTypeChecker.

v0.8.5 ForeignKeyTypeChecker

Предположим, у нас есть таблица users, таблица account с полем user_id, модель User с ассоциацией с has_one :account и модель Accounts с belongs_to :user.

В данном случае ошибка библиотеки выдаст ошибку, что наш тип данных для вторичного ключа недостаточен для покрытия типа данных первичного ключа.

Исправить это можно примерно следующим образом:

Необходимо перейти от .integer к .bigint для user_id в таблице accounts. Сделать это можно следующий миграцией:

Комментарии (2)


  1. aelaa
    12.12.2022 11:53

    Все хорошо, но где взять потерянные 1000 имен то? Кроме констрейнта еще миграция на seedинг хотя бы дефолтного noname ожидается, но про нее даже не сказано


    1. djezzzl Автор
      13.12.2022 22:15

      Спасибо за валидный поинт! Да, к сожалению, не всегда это просто взять и добавить constraint, сначала нужно правильно "починить" данные. К сожалению, шаблоного подхода в этом быть не может, у всех свои ситуации, но что подходит в любом случае, так это не забыть добавить constraint после очередной починки данных, чтобы избежать повторной ситуации. Здесь же, как раз о таком больше и рассказано.