Pull to refresh

Comments 13

Поздравляю, вы переизобрели спринг-батч :)
К сожалению, Java я не знаю, поэтому если где-то неправильно понял особенности фреймворка, извиняюсь заранее.

На первый взгляд выглядит так, что оба фреймворка решают похожие по смыслу задачи, но немного в разных областях (мы больше интегрируемся с базой данных и Liquibase, в то время как Spring Batch ближе к коду приложения). Поэтому оба имеют право на жизнь и процветание :)

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

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

А как вы думаете, что случится при миграции а то и откате (после ошибки) одной большой, нет "гиганской" транзакцией, т.е. если без штук типа ихней "многопоточки"…
Даже умолчим про autovacuum, immutable states, пересбор индексов/статистик и т.п. прелести и сопутствующие эффекты на такой длинной транзакции и огромной базе...


Так мигрировать на горячую не есть камильфо...


Хотя если нужно таки всё неразрывно под DDL, то обычно морозят states (PITR, снапшоты и т.п.), многопоточно сливают изменения в новые таблички, а потом апдейтят/сливают bulk-ом в одной транзакции, и если все без остановки (aka hot), то сверху накатывают изменения из states(point_after — point_before), WAL по PITR и т.п… А так вообще очень сильно зависит от ситуации/требований (например, я утрирую, всё много сильно сложнее, если миграция базы длится неделю и размер diff-а для последующего обновления настолько огромен, что время накатывания оного снова приближается к той же неделе).


Ну и бэкапы никто еще не отменял...

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

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

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

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

Тут еще могут быть и другие варианты проведения миграции и восстановления, зависит от сценариев. На проде у нас критичных случаев, когда бы понадобились варианты 2 или 3 не было.
Ясно. Есть еще один вопрос если можно. Какая у Вас пиковая нагрузка по запросам в секунду select/insert на один инстанс PostgresSQL и какие ресурсы на один инстанс (количество ядер и оперативная память)?
Ядер более ста, оперативки не очень много (хватает, чтобы все индексы при построении влезали в maintenance_work_mem, например), данные распределены между SSD и SAS дисками в зависимости от профиля нагрузки. Подробно детали по нагрузке и конфигурации раскрыть не могу, к сожалению.

Немного оффтоп: а зачем так мигрировать вообще? В смысле, вы про "прозрачную миграцию" что-нибудь слышали?
Т.е. собственно процесс "миграции" происходит постепенно (on-the-fly, application-assisted), используя новые классы данных и небольшой background сценарий вызовов сверху:
типа while not ready: get(oldset) + set/alter-update(newset) + del(oldset).


Т.е. чтение реализуется в виде:


dataset GetSomeEntry(...) {
  // read dataset (from new-table)
  ...
  // if does not exists - try to read from old-table
  GetSomeEntry_MIG(...)
  ...
  // read child items:
  if (dataset.version < version.current) {
  //... use old api ...
  } else {
  //... use new api ...
  }
  ...
}

Если язык реализации приложения — скриптовый, то оно инжектами/обёртками на стадии загрузки приложения легко реализуется...


Запись только новым API, в новом формате (в новые таблицы/структуры).


А остальное (типа выборки и т.д.) оборачивается view (если необходимо) и/или сливом bulk-ом в materialized/temporary-table в новом формате.


А так всё последовательно в два шага, например:


  1. правкой всех forign constraint/trigger, переименованием таблицы sometable в sometable_mig, созданием новой sometable_new, c начальным identity равным max(sometable_mig.id) и вьюхой sometable:


    create view sometable as 
    -- simulate new table using old table:
    select ... from sometable_mig
    union all
    select * from sometable_new

    Ну или наоборот табличкой sometable и view sometable_migview, зависит от условий, например сколько в кодовой базе обращений к sometable не "обернутых" переменными, и/или соотношение insert/delete к select/join (хотя постгрес умеет updatable view и подобные конструкции позволяющие прозрачную миграцию)...


  2. по полному завершению процесса "миграции" (хоть через неделю — месяц), вторым шагом, удалением view sometable, пустой таблицы sometable_mig, и конечным переименованием таблицы sometable_new в sometable.
    И накатывания "чистой" кодовой базы, уже без инъекций типа
    if (dataset.version < version.current) {...}

Transparent migration позволяет выкатить обновление приложения без миграции, полностью перелопатить просто гигантские базы, практически совсем без отрыва (т.е. hot), без длинных транзакций, с одним WAL (без плясок с бубном вокруг репликации, не трогая ее вовсе) и на 90% используя новый (готовый) API приложения (ну и обернутый практически теми же тестами, что и основной функционал).

Если есть логика в базе с джойнами/вьюхами/запросами сложнее, чем чтение атомарного значения по PK, при такой схеме миграции рано или поздно где-нибудь в нагруженном месте выстрелит запрос, у которого после очередного юниона съедет план настолько, что он не только сам перестанет возвращать данные за требуемое время, но ещё и всё остальное положит при достаточной нагрузке.

Да запросто… было так… лет десять назад.


Кстати, насчет "съедет план", у union как правило он много стабильнее (даже через вью).
Я даже ранее какой-нибудь "поехавший" на OR-IN план union-ами лечил.
Т.е. когда вот это вот:


select * from MT t ...
where t.somefield in (select x.field1 from XT x where $criteria1)
or t.otherfield in (select x.field2 from XT x where $criteria2)
-- ready, 5172 ms

заменялось на:


select * from MT t ...
where t.somefield in (select x.field1 from XT x where $criteria1)
union all
select * from MT t ...
where t.otherfield in (select x.field2 from XT x where $criteria2)
-- ready, 31 ms

Оно еще как-то понятно.


Но когда такое же случалось, просто на большой выборке с OR по нескольким полям (1 к 1, без full-scan, сложных join и т.д.)…
Какого спрашивается.

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

Спасибо за статью. Читал её ещё в 2018-м. С тех пор пор прошло много времени. У меня есть альтернативная реализация "многопоточки". Там немного bash для распараллеливания и одна хранимая процедура на PL/pgSQL, чтобы скрыть все сложности внутри.

Sign up to leave a comment.