Версионирование и деплой кода PostgreSQL
Сотни баз данных и тысячи хранимых процедур. Как это всё писать, тестировать и деплоить на множество серверов с возможностью быстрого отката в условиях хайлоад 24×7 и не умереть? Интересно? Добро пожаловать под кат!
Как вы уже знаете, все ваши объявления на 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.
Спасибо за внимание!