01 — Масштабирование переноса данных
Миллион записей. Простой скрипт.
Нужно скопировать таблицу из одной базы в другую. Первый скрипт работает. Потом таблица растёт, и каждое исправление переносит проблему в другое место.
↓ Прокрутка меняет ситуацию. Управление позволяет её запустить.
Начнём с самого простого копирования.
Прочитаем все строки одним запросом и вставим их по одной. Тысяча записей переносится примерно за две секунды, а весь скрипт помещается на экран.
Запустите и посмотрите на число обращений: один запрос на чтение, затем по одному обращению на каждую запись. Каждое ждёт ответа по сети.
Управление переносомТеперь — миллион.
Тот же скрипт, в тысячу раз больше данных. Единственный запрос должен держать весь результат в памяти до первой вставки, а у процесса 512 МБ.
Запустите миллион записей: копирование падает, ничего не записав. Попробуйте 100 000: в память помещается, но вставки по одной уже занимают минуты.
Управление переносомЧитаем лениво.
Будем читать потоком. Курсор отдаёт страницу из 1 000 строк, скрипт записывает их и просит следующую. Сколько бы ни выросла таблица, память не растёт.
Снова запустите миллион. Перенос завершается — примерно за полчаса. И всё это время курсор в исходной базе и его соединение остаются открытыми.
Управление переносомПишем пакетами.
Большая часть этого получаса — ожидание: миллион обращений ради вставок. Отправляйте по тысяче строк за раз, и обращений станет в тысячу раз меньше.
Меняйте размер пакета. Крупные пакеты экономят время, пока не начинает преобладать работа целевой базы над каждой записью, а память под буфер растёт. И появляется новый вопрос: если пакет упал на середине, что успело записаться?
Управление переносомСеть иногда рвётся.
Перенос миллиона записей пакетами идёт около минуты, а сеть и базы иногда отвечают ошибкой. На 121-м пакете соединение обрывается дважды подряд. Без повторов скрипт падает, и всё приходится начинать заново.
Запустите без повторов, затем включите повтор с растущей задержкой: 100 мс, 200 мс… Перенос доходит до конца. Но посмотрите на пакет 371: приёмник его записал, подтверждение потерялось, и повтор вставил те же строки ещё раз.
Управление переносомПовтор не должен дублировать.
Если у строк есть уникальный ключ, вставку можно заменить на upsert: строка с тем же ключом перезаписывается, а не добавляется. Такую запись безопасно повторять. Добавим ещё один сбой: на пакете 641 падает сам скрипт, и его перезапускают.
Сравните INSERT и UPSERT. С upsert дублей нет, но после перезапуска скрипт начинает с нуля и заново переписывает 641 пакет. И каждая запись upsert немного дороже обычной вставки.
Управление переносомЗапомним, где остановились.
После каждого пакета скрипт сохраняет checkpoint — номер следующего пакета — и после перезапуска продолжает с этого места. Но запись в приёмник и сохранение checkpoint — две отдельные операции, и скрипт может упасть между ними.
Сохраняйте checkpoint после записи: при падении один пакет запишется повторно, а upsert сделает это безопасным. Переключите на «до записи»: перенос завершится, но 1 000 записей молча пропадут. Если хранить checkpoint в той же транзакции, что и данные, не будет ни пропусков, ни повторов.
Управление переносомНесколько читателей.
Один скрипт переносит пакеты по очереди. Разделим ключи на диапазоны и запустим читателей параллельно. У приёмника пул из 8 соединений, и без замедления он выдерживает около четырёх пишущих одновременно.
Добавляйте читателей: 2 и 4 ускоряют перенос почти пропорционально, 8 почти ничего не добавляют, а 16 ждут свободных соединений. Включите неравномерные данные — половина строк в первом диапазоне — и весь перенос ждёт одного читателя. Общий порядок вставки строк при этом больше не сохраняется.
Управление переносомЗагрузить всё, вставлять по одной
Готово к запуску. Запустите перенос и посмотрите, как он идёт.
Все числа — оценки модели. Анимация сжимает расчётную длительность до нескольких секунд.
Другая модель: push
Когда данные приходят сами
Здесь скрипт сам просил данные. Если источник сам присылает события, работает push — со своими сбоями: лаг, повторная доставка, ядовитые сообщения.
Когда данные приходят самиЧто осталось за пределами модели
Сцены 1–4: один последовательный скрипт переноса, один источник и один приёмник. Каждый запрос или оператор вставки стоит одного сетевого обращения в 2 мс. Работа базы — 0,005 мс на чтение записи и 0,05 мс на запись. Запись занимает 1 КБ памяти, страница содержит 1 000 записей, скрипт может держать в буфере 512 МБ. Чтение и запись не перекрываются. Длительность вычисляется из этих констант, ничего не измеряется. Не моделируются: индексы, блокировки и конкуренция, разброс сетевых задержек, установка соединений, ограничения на размер транзакций, накладные расходы драйвера и протокола, повторы и сбои — о них сцены 5–7. Модель помогает сравнивать решения, а не предсказывать длительность вашей миграции.
Сцены 5–7: 1 000 пакетов по 1 000 записей, ленивое чтение. Сбои заданы заранее и повторяются при каждом запуске: два обрыва соединения на пакете 121, потерянное подтверждение на пакете 371, падение скрипта на пакете 641. Ожидание ответа — 1 с, задержки повторов — 100, 200, 400, 800 мс без случайного разброса, перезапуск скрипта — 10 с. UPSERT стоит 60 мс работы приёмника на пакет против 50 мс у INSERT, сохранение checkpoint — одно обращение в 2 мс. У приёмника нет уникального ключа для INSERT, поэтому повтор создаёт дубли. Сбой самого приёмника, частичная запись пакета и бесконечные ошибки не моделируются.
Сцена 8: те же 1 000 пакетов делятся на диапазоны ключей поровну, а при неравномерных данных половина строк попадает в первый диапазон. Пул приёмника — 8 соединений; при более чем четырёх одновременных писателях работа приёмника над пакетом замедляется пропорционально их числу. Нагрузка на источник, блокировки и порядок строк не моделируются.
Глубже в механизм
Как реальные системы читают лениво и пишут пакетами:
PostgreSQL — DECLARE, курсоры ↗PostgreSQL JDBC — чтение через курсор ↗PostgreSQL — массовая загрузка данных ↗Заметки, на которых основано исследование ↗