Версионирование и деплой кода PostgreSQL

    Сотни баз данных и тысячи хранимых процедур. Как это всё писать, тестировать и деплоить на множество серверов с возможностью быстрого отката в условиях хайлоад 24х7 и не умереть? Интересно? Добро пожаловать под кат!

    image

    Как вы уже знаете, все ваши объявления на 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


    Хеш-сумма
    (md5) кода
    хранимой
    процедуры


    0539f31fee4efd845a24c9878cd721b2


    ver_id


    Номер версии,
    увеличивается
    на 1 при
    изменении
    хеша,
    default: 0


    2


    create_txtime


    Время
    создания


    2016-12-11 10:16:10


    update_txtime


    Время
    последнего
    обновления
    версии
    (увеличения
    ver_id)


    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.

    Деплой хранимых процедур осуществляется на первых шагах сборки проекта, перед сборкой словарей.

    Для каждого файла хранимой процедуры в проекте:


    1. Подсчитывается хеш-сумма от содержимого файла, далее выполняется поиск минимальной версии хранимой процедуры с новой хеш-суммой в таблице stored_procedures.
    2. Если ничего не нашлось (ранее такая процедура не деплоилась ни в одной ветке), то инкрементируется версия для новой процедуры и разрешается деплой данной процедуры в базу.
    3. Если данная хранимая процедура с новой хеш-суммой уже использовалась ранее в других ветках, то текущая ветка также будет использовать данную процедуру с минимальной версией без нового деплоя в базу.
    4. Если данная хранимая процедура использовалась ранее в данной ветке и  новая хеш-сумма отличается от хеш-суммы в таблице stored_procedures для текущей записи и данная хранимая процедура с новой хеш-суммой...
      — не использовалась в других ветках и минимальная версия не известна, то для новой процедуры инкрементируется версия и разрешается деплой в базу;
      — использовалась в других ветках и известна минимальная версия, то текущая ветка будет использовать существующую хранимую процедуру с минимальной версией без нового деплоя кода в базу.
    5. В случае первичной регистрации в таблице 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 останется нетронутым.
    6. Далее происходит сборка словаря, который на данном этапе содержит актуальные версии хранимых процедур для данной ветки.

    Плюсы данного способа версионирования кода:


    • деплоятся только измененные хранимые процедуры;
    • можно хранить несколько версий хранимых процедур в одной базе;
    • лёгкий «откат».

    Минусы:


    • трудности при деплое и использовании внутренних хранимых процедур (вызов одной хранимой процедуры из другой);
    • необходим инструмент для очищения старых версий хранимых процедур;
    • информация о том, какие версии на какой базе созданы, не централизована (в случае подключения второй копии базы (доступной на запись) к деплою необходимо синхронизировать таблицу 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%.

    Процесс боевого деплоя при сборке проекта:


    1. Когда запускается сборка, в таблице build_history регистрируется информация о новой сборке, назначается уникальная схема и пользователь для подключения к серверам баз данных.
    2. Происходит запись пользователя в конфиг, который деплоится вместе с кодом проекта.
    3. Происходит подключение к серверам баз данных под специальным пользователем для деплоя.
    4. В базах данных создается (пересоздается, если есть) назначенная схема с хранимыми процедурами.
    5. После того, как код проекта разложен на все серверы приложений перед подменой симлинка на новый код проекта, на одном из этих серверов происходит обращение к главному серверу, на котором:
      — устанавливается время переключения на новый код проекта в таблице 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.


    Спасибо за внимание!

    Avito 317,97
    Avito – сайт объявлений №1 в России
    Поделиться публикацией
    Комментарии 35
    • +1
      Почему нельзя вынести логику из бд вообще?
    • +5
      Для нас хранимые процедуры удобны, в первую очередь тем, что это безопасно, плюс не надо передавать гигабайты данных между базой и приложением. Удобно сделать несколько действий с разными таблицами в базе, а в приложение только отчитаться о том, что всё было выполнено успешно. Это действительно удобно.
      • –3
        Удобно сделать несколько действий с разными таблицами в базе, а в приложение только отчитаться о том, что всё было выполнено успешно. Это действительно удобно.


        Мне как-то больше по вкусу, когда приложение явно контролирует что происходит в бд. И гораздо меньше гемора с деплоем и поддержкой всего этого добра =)
        • 0
          Здравствуйте!

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

          При использовании соотвутствующих инструментов появляется возможность проводить тестирование этой самой бизнес-логики на стороне БД. Может быть это излишне или даже не нужно (я не специалист но разработке БД), но, как показывает мой не слишком богатый опыт, позволяет меньше усилий тратить при разработке и тестировании кода приложения, использующего БД.
      • 0
        А зачем с одной БД вообще работать из разных версий кода? Можно же сделать легковесный бекап для разработчиков и тестовых серверов, и разворачивать по схеме «каждому инстансу — своя БД». Ну и выкатывать все тоже вместе — и миграции БД, и хранимки, и код, который это все использует.
        • 0
          На сколько я понял, второй метод чем-то похож, только решение через схемы, а не БД. Но все равно попахивает одним большим костылем…
          • +2
            1. Атомарно нельзя выкатить несколько серверов с кодом приложения. Таким образом одновременно может вызываться старый и новый код на базе.
            2. Быстрый деплой и особенно откат.
            3. Проведение нагрузочного тестирования на staging (для сотен разработчиков * n веток потребуется очень много ресурсов для создания индивидуального staging окружения)
          • 0
            и как это все увязывается с DDL?
            • 0
              Мы используем мигратор и обратно совместимые миграции.
              • 0
                А как вы работаете с миграциями? Свой инструмент или сторонний? Просто на моей прошлой работе миграции складировались по папкам(именовали папки датами и вели учет в версионность вели в табличке Excel) и вручную заливались на каждый сервер БД(а их было где-то 15 штук). Как вспомню, так вздрогну.
                • +1
                  Добрый вечер!

                  Мы создали свой инструмент, который очень похож на продукты с открытым исходным кодом.
                  Информация о примененных миграциях хранится в той же базе, на которую они накатываются.
                  • 0

                    Расскажите, как в вашем случае с вашим инструментом реализуется возможность посмотреть структуру базы данных на определенном коммите в SCM?


                    Часть существующих решений предлагает хранить только миграции и нулевую версию схемы, часть предлагает свой вариант DDL и генерацию миграций из его диффа.

                    • +2
                      Добрый день!

                      Мы тоже храним файлы с миграциями в sql-формате.
                      Поэтому на определённом коммите можем посмотреть только сами миграции.

                      Свои варианты DDL (отличные от sql) не рассматривались в принципе, так как требуют разработки (что очень непросто) и изучения теми, кто будет их использовать.

                      Если говорить простым языком, то:
                      1. Миграции в sql-формате разложены по подпапкам с именами баз данных (их много) в репозитории с проектом;
                      2. Мигратор накатывает неприменённые миграции последовательно на каждую базу данных и заносит информацию о применении миграции в служебную таблицу в той же базе;
                      3. Миграции делятся на два вида: линейные (простые) и повторяемые. Линейные выполняются один раз. Повторяемые выполняются каждый раз, как только меняется контрольная сумма sql-файла.


                      Понимаете, всё предельно просто и не требует никаких специальных знаний.
            • 0
              А в случае изменения структуры таблиц?
              Проходится всегда делать обратно совместимые изменения и чистить устаревшее?
            • –3
              dbprojector.net — Позволяет делать сравнение декларативной схемы в проекте и реальной схемы на существующей произвольной БД и автоматически генерирует DDL для апдейта к новой версии схемы БД.

              Заморочился в свое время, только сейчас пиарить времени нет. Умеет в том числе и генерить DDL для альтера таблиц. Хоть PG и кривой в этом плане. Только это не сильно кому нужно т.к. действительно логика в БД — зло.
              • 0
                Продолжая вашу категоричную мысль:… а писать одинаковую логику на нескольких языках – добро.

                Положим n – количество портов вашего приложения. Тогда в случае изменения логики, менять придётся не одну хранимую процедуру/триггер/constraint, а n исходных текстов. Имея процент ошибок, при написании текста программ, например, 0.5% от написанного, вы этот процент, опять же, увеличиваете в n раз.
                • 0
                  Предположим я сделаю бекенд и n клиентов под разные платформы.
                  • 0
                    Для этого давно изобретена сервисная архитектура, где мы закрываем прямой доступ к БД и где у нас намного больше свободы и возможностей для формирования нормального, явно выраженого слоя API. Для доступа от разных платформ можно например сделать HTTP REST сервис. Нет наверно таких платформ которые не могут использовать такие сервисы. Заодно и возможностей для маневра в случае чего остается намного больше.

                    И я ни в коем случае не пытаюсь утверждать, что делать так всегда плохо, а вот так всегда хорошо. Все зависит от задачи, исходных условий и т.д. Я только говорю что формировать слой API в БД на хранимках обычно не самая лучшая идея.
                • 0

                  Спасибо за статью, Николай, я правильно понимаю, что для каждой тестовой сборки тоже потребуется создать свой search_path.
                  И у вас есть пул из N доступных search_path.


                  Не получается ли такое, что при N+1 тестовых сборок, старые слишком быстро "протухают"? Что вообще происходит тогда с тестовой сборкой?

                  • +2
                    У нас есть боевой пул пользователей и аналогично тестовый, только тестовый пул намного больше. Вы правы, так как схемы пересоздаются циклично и размер пула ограничен, то через какое-то время более новая сборка получает более ранний порядковый номер, тем самым обновляя информацию в журнале сборок. Ветка, которая «протухла» (отдала свою схему более новой сборке) лечится путем пересборки.
                    • 0

                      Не думали её просто инвалидировать?


                      А то QA может не сразу об этом понять. Или там сразу сыпятся фатальные ошибки, прямо на главной?

                      • +1
                        Инвалидировать и пересобирать заранее чужую ветку смысла нет, возможно эту ветку уже выкатили в прод. При нашем размере тестового пула пользователей такая ситуация возможна очень редко, так как ветка быстрее уходит в прод, чем «протухает».
                  • 0
                    я работал одно время в разработке noSQL базы данных — Tarantool.

                    так вот там запилили мы такую фичу: сессионный сторадж, могущий хранить в т.ч. ссылки на функции.

                    то есть если говорить о «больших БД» у которых есть CREATE TEMPORARY TABLE — таблицы которые убьются при дисконнекте, то мы там реализовали CREATE TEMPORARY FUNCTION.

                    так вот идея в чем: каждый приконнектившийся в момент коннекта создает нужный ему набор функций в сессионном сторадже.
                    ну а дальше получается что все эти функции хранятся в Git рядышком с кодом им пользующимся.

                    очень удобно.

                    я все собираюсь собираюсь покопать посмотреть Pg на тему реализовать и в нем эту фичу, но пока руки не дошли :(

                    конечно для работы через баунсер видимо это будут дополнительные какие-то костыли. надо подумать
                    • 0
                      Сериализовать передаваемые параметры при этом не требуется?
                      • 0
                        сериализация и версионирование при таком подходе — вещи не пересекающиеся друг с другом

                        :)
                    • 0
                      Подскажите, а как вы решили, и решали ли следующую ситуацию: имеем команды А и Б пилящие соотв продукты, мигратор то общий — как им быть с деплоем своих фич? В нашем случае мы пришли к выводу что проще каждой команде развернуть свой тестовый сервер
                      • 0
                        У всех наших разработчиков есть возможность развернуть локально dev окружение в контейнере, в котором они могуть вести разработку и деплоить код всеми доступными способами. Миграции проверяются командой DBA и мержатся для дальнейшего нагрузочного тестирования.
                        • 0
                          Вероятно вы держите некий слепок «мини» базы, повторяющий продуктовую но имеющий ограниченный набор данных? Подскажите как вы его строите — забираете бекап и урезаете данные или у вас изначально была пустая база и скрипты для ее наполнения?
                          • 0
                            У нас есть собственный инструмент для семплирования и фикстуры для того, чтобы проводить тестирование нашего приложения. После семплирования данных мы готовим докер-образ, который пушим в наше облако. Из образа поднимаются базы, dev и test среда и другая инфраструктура (внешние индексы, различные кэши, очереди). На семпловых данных поднимается вся связанная инфраструктура. Таким образом, применение семпловых данных приводит к инициализации около десятка баз данных.
                            • 0
                              Если не серкерт, каков объем тестовой базы и как долго строиться ее образ?
                              • 0
                                30 минут, 5 GB.
                                • 0
                                  Если мигратор общий на все комманды, то возможна ситуация когда изменения в миграторе обоих комманд уже приехали на прод, но еще не приехали сами приложеньки эти изменения использующие, интересно как вы решили подобную штуку, просто коммуникацией между командами, или быть может приложения выкатывают вообще в переди мигратора или быть може еще какой вариант, интересно как в целом вы построили процесс деплоя
                                  • 0
                                    В вашем случае несколько сервисов работают с одной базой, так делать неправильно. Необходимо делать вызовы по API к сервису — владельцу базы.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое