
Как вы уже знаете, все ваши объявления на Avito живут в PostgreSQL. Возможности этой базы данных предоставляют нам очень большой функционал, основанный не только на уровне данных, но и на создании собственного API для предоставления доступа к этим данным посредством хранимых процедур, триггеров, функций. При работе со всей этой структурой часто могут потребоваться какие-либо изменения. И в самом простом случае, когда разработчик имеет дело с одним клиентом и одной базой данных, процесс обновления выглядит довольно просто: изменения, скрипт миграции и всё. Но такая ситуация — редкость, чаще клиенты и базы данных для какого-либо продукта исчисляются сотнями. Таким образом, для нормального жизненного цикла базы данных крайне необходим механизм версионирования кода.
В данной статье мы поговорим о двух способах версионирования кода внутри баз данных, которые мы реализовали в Avito. Возможно, вам пригодится эта информация. Для начала немного контекста.
Avito — это:
- огромное количество серверов и еще больше баз данных;
- суммарный размер всех баз — 15 Tb;
- очень высокий TPS, в среднем 10 K;
- много разработчиков и git-веток.
Наши начальные задачи:
- деплой нескольких версий процедур на одной базе под разные git-ветки.
- удобное версионирование кода хранимых процедур.
Первый вариант версионирования
Первый вариант, который мы придумали — версионирование через словарь.
Детали
- В проекте хранятся и попадают под деплой только те хранимые процедуры, которые вызываются из php-кода.
- Хранимые процедуры, которые не имеют файлового представления в проекте, деплоятся через мигратор и/или через команду DBA.
- В каждой базе, которая деплоится, имеется таблица stored_procedures.
Название колонки |
Описание |
Пример |
branch |
Название |
location-id-fix |
fn_name |
Название |
core.location_save |
fn_md5 |
Хеш-сумма |
0539f31fee4efd845a24c9878cd721b2 |
ver_id |
Номер версии, |
2 |
create_txtime |
Время |
2016-12-11 10:16:10 |
update_txtime |
Время |
2016-12-11 11:23:14 |
- В проекте имеется php-словарь, который содержит отфильтрованные данные из таблицы по ветке (branch = '<название текущей ветки>'). В итоге словарь содержит в себе названия (включая имя базы) и ver_id всех хранимых процедур данной ветки:
1 =>
array (
'verId' => 2,
'hash' => '0539f31fee4efd845a24c9878cd721b2',
'fnFullName' => 'core.location_save@master'
)
- Версия хранимой процедуры определяется из постфикса ее имени, который имеет формат <название хранимой процедуры>_ver#, где # — номер версии.
- Благодаря колонке branch в stored_procedures, различные ветки могут вызывать одноименные хранимые процедуры, которые имеют различный код и, соответственно, версии.
- После завершения разработки в ветке, код хранимых процедур (как и php-код) вмёрживается в мастер.
- За счет того, что имя файла хранимой процедуры не содержит версии (core.location_save.sql вместо core.location_save_ver2.sql), изменения, сделанные в каждой из хранимых процедур в ветке, в процессе мёржа будут видны построчно.
- В php-коде вызов хранимых процедур осуществляется через плейсхолдер версии:
$this->db->exec(
"select core.location_save%ver%(...)"
);
- При вызове плейсхолдер заменяется на номер версии с префиксом _ver, к примеру, для версии 2 вместо %ver% будет подставлено _ver2.
Деплой хранимых процедур осуществляется на первых шагах сборки проекта, перед сборкой словарей.
Для каждого файла хранимой процедуры в проекте:
- Подсчитывается хеш-сумма от содержимого файла, далее выполняется поиск минимальной версии хранимой процедуры с новой хеш-суммой в таблице stored_procedures.
- Если ничего не нашлось (ранее такая процедура не деплоилась ни в одной ветке), то инкрементируется версия для новой процедуры и разрешается деплой данной процедуры в базу.
- Если данная хранимая процедура с новой хеш-суммой уже использовалась ранее в других ветках, то текущая ветка также будет использовать данную процедуру с минимальной версией без нового деплоя в базу.
- Если данная хранимая процедура использовалась ранее в данной ветке и новая хеш-сумма отличается от хеш-суммы в таблице stored_procedures для текущей записи и данная хранимая процедура с новой хеш-суммой...
— не использовалась в других ветках и минимальная версия не известна, то для новой процедуры инкрементируется версия и разрешается деплой в базу;
— использовалась в других ветках и известна минимальная версия, то текущая ветка будет использовать существующую хранимую процедуру с минимальной версией без нового деплоя кода в базу. - В случае первичной регистрации в таблице stored_procedures или обновления ver_id, код создания хранимой процедуры выполняется на целевой базе с предварительно подготовленной версией в заголовке SQL создания хранимой процедуры.
CREATE OR REPLACE FUNCTION core.location_save(...)
В php превратится в
CREATE OR REPLACE FUNCTION core.location_save_ver2(...)
и выполнится на базе.
Файл core.location_save.sql останется нетронутым.
- Далее происходит сборка словаря, который на данном этапе содержит актуальные версии хранимых процедур для данной ветки.
Плюсы данного способа версионирования кода:
- деплоятся только измененные хранимые процедуры;
- можно хранить несколько версий хранимых процедур в одной базе;
- лёгкий «откат».
Минусы:
- трудности при деплое и использовании внутренних хранимых процедур (вызов одной хранимой процедуры из другой);
- необходим инструмент для очищения старых версий хранимых процедур;
- информация о том, какие версии на какой базе созданы, не централизована (в случае подключения второй копии базы (доступной на запись) к деплою необходимо синхронизировать таблицу stored_procedures под общей блокировкой деплоя).
Второй вариант версионирования
Следующий вариант, как вы уже догадались, появился из минусов предыдущего. Обозначим его как версионирование через создание уникальной схемы и пользователя для каждой новой сборки проекта.
Детали
Информация о всех сборках хранится в таблице build_history в базе на главном сервере.
Название колонки |
Описание |
Пример |
build_branch |
Название собираемой ветки |
deploy_search_path |
build_tag |
Название будущего архива с проектом |
Deploy_1501247988 |
build_time |
Время сборки проекта |
28.07.17 13:19:48 |
schema_name |
Назначенная схема для проекта |
z_build_1 |
schema_user |
Назначенный пользователь БД для проекта |
user_1 |
deploy_time |
Время переключения на новый код проекта |
28.07.17 14:05:22 |
- Для каждой новой сборки проекта в разрезе ветки создается в базе своя уникальная схема.
- Для тестовой сборки схема имеет вид z_build_test_N, где N — цикличный сиквенс (от 1 до n1).
- Для тестовой сборки пользователь имеет вид user_test_N, где N — цикличный сиквенс (от 1 до n1).
- Для боевой сборки схема имеет вид z_build_N, где N — цикличный сиквенс (от 1 до n2).
- Для боевой сборки пользователь имеет вид user_N, где N — цикличный сиквенс (от 1 до n2).
- Для каждой схемы выделяется свой уникальный пользователь для подключения к серверу базы данных.
- Деплоятся все хранимые процедуры.
- Схемы пересоздаются циклично.
- В php-коде вызов хранимых процедур осуществляется без указания схемы и плейсхолдера версии %ver%.
Процесс боевого деплоя при сборке проекта:
- Когда запускается сборка, в таблице build_history регистрируется информация о новой сборке, назначается уникальная схема и пользователь для подключения к серверам баз данных.
- Происходит запись пользователя в конфиг, который деплоится вместе с кодом проекта.
- Происходит подключение к серверам баз данных под специальным пользователем для деплоя.
- В базах данных создается (пересоздается, если есть) назначенная схема с хранимыми процедурами.
- После того, как код проекта разложен на все серверы приложений перед подменой симлинка на новый код проекта, на одном из этих серверов происходит обращение к главному серверу, на котором:
— устанавливается время переключения на новый код проекта в таблице build_history;
— для выделенного пользователя назначается группа production, чтобы знать, кто в бою, и случайно не перезатереть схему с хранимыми процедурами, если будет происходить неоднократная повторная сборка проекта без дальнейшего переключения симлинка;
— на всех серверах, где создавалась схема, выставляется новый search_path вида:
search_path = public, <назначенная схема> для:
— выделенного пользователя user_N;
— разработчиков и команды DBA;
— пользователя для различных кронов и т. д.
Важное дополнение по настройке пулов в pgbouncer
Если вы используете pgbouncer, для ограничения размера пула следует использовать опцию max_db_connections равной pool_size. Без её применения у каждого пользователя пула будет свой pool_size. Данное поведение не документировано, но max_db_connections работает именно так: ограничивает число активных транзакций для всех пользователей пула.
Пример пула pgbouncer:
my_database = host=localhost pool_size=5 max_db_connections=5
В заключение хочется отметить, что представленные варианты версионирования кода отлично показали себя в режиме хайлоад 24х7 и используются у нас в гибридном режиме. Но большее предпочтение в последнее время мы отдаем второму способу на search_path.
Спасибо за внимание!
Комментарии (25)
nvorobev Автор
23.11.2017 14:55Для нас хранимые процедуры удобны, в первую очередь тем, что это безопасно, плюс не надо передавать гигабайты данных между базой и приложением. Удобно сделать несколько действий с разными таблицами в базе, а в приложение только отчитаться о том, что всё было выполнено успешно. Это действительно удобно.
oxidmod
23.11.2017 15:19-1Удобно сделать несколько действий с разными таблицами в базе, а в приложение только отчитаться о том, что всё было выполнено успешно. Это действительно удобно.
Мне как-то больше по вкусу, когда приложение явно контролирует что происходит в бд. И гораздо меньше гемора с деплоем и поддержкой всего этого добра =)Wilk
26.11.2017 14:15Здравствуйте!
Насколько я понял, именно выделение бизнес-логики в хранимые процедуры позволяет упростить разработку и поддержку приложения.
При использовании соотвутствующих инструментов появляется возможность проводить тестирование этой самой бизнес-логики на стороне БД. Может быть это излишне или даже не нужно (я не специалист но разработке БД), но, как показывает мой не слишком богатый опыт, позволяет меньше усилий тратить при разработке и тестировании кода приложения, использующего БД.
jakobz
23.11.2017 14:56А зачем с одной БД вообще работать из разных версий кода? Можно же сделать легковесный бекап для разработчиков и тестовых серверов, и разворачивать по схеме «каждому инстансу — своя БД». Ну и выкатывать все тоже вместе — и миграции БД, и хранимки, и код, который это все использует.
SLASH_CyberPunk
23.11.2017 15:10На сколько я понял, второй метод чем-то похож, только решение через схемы, а не БД. Но все равно попахивает одним большим костылем…
nvorobev Автор
23.11.2017 15:121. Атомарно нельзя выкатить несколько серверов с кодом приложения. Таким образом одновременно может вызываться старый и новый код на базе.
2. Быстрый деплой и особенно откат.
3. Проведение нагрузочного тестирования на staging (для сотен разработчиков * n веток потребуется очень много ресурсов для создания индивидуального staging окружения)
Yo1
23.11.2017 15:07и как это все увязывается с DDL?
nvorobev Автор
23.11.2017 15:15Мы используем мигратор и обратно совместимые миграции.
ls18
23.11.2017 17:43А как вы работаете с миграциями? Свой инструмент или сторонний? Просто на моей прошлой работе миграции складировались по папкам(именовали папки датами и вели учет в версионность вели в табличке Excel) и вручную заливались на каждый сервер БД(а их было где-то 15 штук). Как вспомню, так вздрогну.
pkorobeinikov
23.11.2017 20:36Добрый вечер!
Мы создали свой инструмент, который очень похож на продукты с открытым исходным кодом.
Информация о примененных миграциях хранится в той же базе, на которую они накатываются.kaverdo
23.11.2017 22:47Расскажите, как в вашем случае с вашим инструментом реализуется возможность посмотреть структуру базы данных на определенном коммите в SCM?
Часть существующих решений предлагает хранить только миграции и нулевую версию схемы, часть предлагает свой вариант DDL и генерацию миграций из его диффа.
pkorobeinikov
24.11.2017 13:20Добрый день!
Мы тоже храним файлы с миграциями в sql-формате.
Поэтому на определённом коммите можем посмотреть только сами миграции.
Свои варианты DDL (отличные от sql) не рассматривались в принципе, так как требуют разработки (что очень непросто) и изучения теми, кто будет их использовать.
Если говорить простым языком, то:
- Миграции в sql-формате разложены по подпапкам с именами баз данных (их много) в репозитории с проектом;
- Мигратор накатывает неприменённые миграции последовательно на каждую базу данных и заносит информацию о применении миграции в служебную таблицу в той же базе;
- Миграции делятся на два вида: линейные (простые) и повторяемые. Линейные выполняются один раз. Повторяемые выполняются каждый раз, как только меняется контрольная сумма sql-файла.
Понимаете, всё предельно просто и не требует никаких специальных знаний.
VVit1
23.11.2017 18:03dbprojector.net — Позволяет делать сравнение декларативной схемы в проекте и реальной схемы на существующей произвольной БД и автоматически генерирует DDL для апдейта к новой версии схемы БД.
Заморочился в свое время, только сейчас пиарить времени нет. Умеет в том числе и генерить DDL для альтера таблиц. Хоть PG и кривой в этом плане. Только это не сильно кому нужно т.к. действительно логика в БД — зло.QuickJoey
24.11.2017 12:48Продолжая вашу категоричную мысль:… а писать одинаковую логику на нескольких языках – добро.
Положим n – количество портов вашего приложения. Тогда в случае изменения логики, менять придётся не одну хранимую процедуру/триггер/constraint, а n исходных текстов. Имея процент ошибок, при написании текста программ, например, 0.5% от написанного, вы этот процент, опять же, увеличиваете в n раз.VVit1
24.11.2017 21:06Для этого давно изобретена сервисная архитектура, где мы закрываем прямой доступ к БД и где у нас намного больше свободы и возможностей для формирования нормального, явно выраженого слоя API. Заодно и возможностей для маневра в случае чего остается намного больше.
И я ни в коем случае не пытаюсь утверждать, что делать так всегда плохо, а вот так всегда хорошо. Все зависит от задачи, исходных условий и т.д. Я только говорю что формировать слой API в БД на хранимках обычно не самая лучшая идея.
Cubist
23.11.2017 19:48Спасибо за статью, Николай, я правильно понимаю, что для каждой тестовой сборки тоже потребуется создать свой search_path.
И у вас есть пул из N доступных search_path.
Не получается ли такое, что при N+1 тестовых сборок, старые слишком быстро "протухают"? Что вообще происходит тогда с тестовой сборкой?
nvorobev Автор
23.11.2017 20:04У нас есть боевой пул пользователей и аналогично тестовый, только тестовый пул намного больше. Вы правы, так как схемы пересоздаются циклично и размер пула ограничен, то через какое-то время более новая сборка получает более ранний порядковый номер, тем самым обновляя информацию в журнале сборок. Ветка, которая «протухла» (отдала свою схему более новой сборке) лечится путем пересборки.
Cubist
23.11.2017 20:09Не думали её просто инвалидировать?
А то QA может не сразу об этом понять. Или там сразу сыпятся фатальные ошибки, прямо на главной?
nvorobev Автор
23.11.2017 20:14Инвалидировать и пересобирать заранее чужую ветку смысла нет, возможно эту ветку уже выкатили в прод. При нашем размере тестового пула пользователей такая ситуация возможна очень редко, так как ветка быстрее уходит в прод, чем «протухает».
linuxover
25.11.2017 16:44я работал одно время в разработке noSQL базы данных — Tarantool.
так вот там запилили мы такую фичу: сессионный сторадж, могущий хранить в т.ч. ссылки на функции.
то есть если говорить о «больших БД» у которых есть CREATE TEMPORARY TABLE — таблицы которые убьются при дисконнекте, то мы там реализовали CREATE TEMPORARY FUNCTION.
так вот идея в чем: каждый приконнектившийся в момент коннекта создает нужный ему набор функций в сессионном сторадже.
ну а дальше получается что все эти функции хранятся в Git рядышком с кодом им пользующимся.
очень удобно.
я все собираюсь собираюсь покопать посмотреть Pg на тему реализовать и в нем эту фичу, но пока руки не дошли :(
конечно для работы через баунсер видимо это будут дополнительные какие-то костыли. надо подумать
oxidmod
Почему нельзя вынести логику из бд вообще?
nvorobev Автор
Ответ ниже :)