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


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

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


  1. oxidmod
    23.11.2017 14:43

    Почему нельзя вынести логику из бд вообще?


    1. nvorobev Автор
      23.11.2017 14:59

      Ответ ниже :)


  1. nvorobev Автор
    23.11.2017 14:55

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


    1. oxidmod
      23.11.2017 15:19
      -1

      Удобно сделать несколько действий с разными таблицами в базе, а в приложение только отчитаться о том, что всё было выполнено успешно. Это действительно удобно.


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


      1. Wilk
        26.11.2017 14:15

        Здравствуйте!

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

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


  1. jakobz
    23.11.2017 14:56

    А зачем с одной БД вообще работать из разных версий кода? Можно же сделать легковесный бекап для разработчиков и тестовых серверов, и разворачивать по схеме «каждому инстансу — своя БД». Ну и выкатывать все тоже вместе — и миграции БД, и хранимки, и код, который это все использует.


    1. SLASH_CyberPunk
      23.11.2017 15:10

      На сколько я понял, второй метод чем-то похож, только решение через схемы, а не БД. Но все равно попахивает одним большим костылем…


    1. nvorobev Автор
      23.11.2017 15:12

      1. Атомарно нельзя выкатить несколько серверов с кодом приложения. Таким образом одновременно может вызываться старый и новый код на базе.
      2. Быстрый деплой и особенно откат.
      3. Проведение нагрузочного тестирования на staging (для сотен разработчиков * n веток потребуется очень много ресурсов для создания индивидуального staging окружения)


  1. Yo1
    23.11.2017 15:07

    и как это все увязывается с DDL?


    1. nvorobev Автор
      23.11.2017 15:15

      Мы используем мигратор и обратно совместимые миграции.


      1. ls18
        23.11.2017 17:43

        А как вы работаете с миграциями? Свой инструмент или сторонний? Просто на моей прошлой работе миграции складировались по папкам(именовали папки датами и вели учет в версионность вели в табличке Excel) и вручную заливались на каждый сервер БД(а их было где-то 15 штук). Как вспомню, так вздрогну.


        1. pkorobeinikov
          23.11.2017 20:36

          Добрый вечер!

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


          1. kaverdo
            23.11.2017 22:47

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


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


            1. pkorobeinikov
              24.11.2017 13:20

              Добрый день!

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

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

              Если говорить простым языком, то:

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


              Понимаете, всё предельно просто и не требует никаких специальных знаний.


  1. AGS13
    23.11.2017 15:16

    А в случае изменения структуры таблиц?
    Проходится всегда делать обратно совместимые изменения и чистить устаревшее?


    1. nvorobev Автор
      23.11.2017 15:16

      Ответил выше.


  1. VVit1
    23.11.2017 18:03

    dbprojector.net — Позволяет делать сравнение декларативной схемы в проекте и реальной схемы на существующей произвольной БД и автоматически генерирует DDL для апдейта к новой версии схемы БД.

    Заморочился в свое время, только сейчас пиарить времени нет. Умеет в том числе и генерить DDL для альтера таблиц. Хоть PG и кривой в этом плане. Только это не сильно кому нужно т.к. действительно логика в БД — зло.


    1. QuickJoey
      24.11.2017 12:48

      Продолжая вашу категоричную мысль:… а писать одинаковую логику на нескольких языках – добро.

      Положим n – количество портов вашего приложения. Тогда в случае изменения логики, менять придётся не одну хранимую процедуру/триггер/constraint, а n исходных текстов. Имея процент ошибок, при написании текста программ, например, 0.5% от написанного, вы этот процент, опять же, увеличиваете в n раз.


      1. oxidmod
        24.11.2017 13:33

        Предположим я сделаю бекенд и n клиентов под разные платформы.


      1. VVit1
        24.11.2017 21:06

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

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


  1. Cubist
    23.11.2017 19:48

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


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


    1. nvorobev Автор
      23.11.2017 20:04

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


      1. Cubist
        23.11.2017 20:09

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


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


        1. nvorobev Автор
          23.11.2017 20:14

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


  1. linuxover
    25.11.2017 16:44

    я работал одно время в разработке noSQL базы данных — Tarantool.

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

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

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

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

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

    конечно для работы через баунсер видимо это будут дополнительные какие-то костыли. надо подумать