golang

От полной выгрузки к S3 и PostgreSQL: как мы доставляем гигабайты данных в память подов

  • вторник, 7 июля 2026 г. в 00:00:07
https://habr.com/ru/companies/ozontech/articles/1052132/

Представьте себе высоконагруженный сервис, который решает, с какого из множества складов нужно отправить товар покупателю. В пике через него проходит около 600 000 RPS, а строгий SLA требует ответа в пределах 50 мс. Для расчёта нужно за минимальное время выбрать оптимальный склад с учётом остатков, доступности и других данных, которые хранятся в разных микросервисах и их базах данных.

Первое, что приходит на ум, — обратиться к этим сервисам по API во время запроса и получить всё необходимое для расчёта. Но один запрос может затрагивать сотни и даже тысячи складов, не считая связанных сущностей. Такое число сетевых вызовов быстро превысит SLA и приведёт к клиентским таймаутам. Сетевые запросы к мастер-системам в нашем случае — непозволительная роскошь, поэтому мы вынуждены держать слепок данных прямо в памяти подов. А значит, появляется новая проблема: как быстро и надёжно доставлять постоянно меняющиеся данные из мастер-систем в оперативную память сотен подов.

Если говорить о конкретных цифрах, в каждом из 500 подов мы держим около 10 ГБ справочных данных. Суммарный поток изменений по всем этим сущностям (не считая остатков) достигает 3000 событий в секунду. Отдельно стоит упомянуть остатки (стоки) — их состояние занимает около 300 ГБ в мастер-системе, и эти данные тоже загружаются в память подов, но это тема для отдельной статьи. Здесь я сосредоточусь на эволюции доставки именно справочных данных по остальным сущностям.

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

  1. Полная выгрузка по расписанию.

  2. Версионирование состояния и pull-обновления.

  3. Отдельный сервис — хранитель состояния.

  4. Снапшоты в S3 и дельта из PostgreSQL.

Периодическая полная выгрузка

Самое прямолинейное, что можно придумать, — каждый раз получать полный слепок всех нужных данных и складывать его в память каждого пода. Никакой сложной инфраструктуры, никаких очередей и инкрементов. Просто берём всё и заливаем в локальный кеш.

Как это работает

В каждом экземпляре сервиса работает фоновый процесс. По сути, обычная горутина (goroutine) с тикером (time.Ticker). Раз в заданный интервал — например, раз в 5 минут — он:

  1. Идёт в БД или в мастер-систему по API.

  2. Выполняет запросы на получение данных.

  3. Собирает полученные данные в in-memory-структуры: плоские map, иногда чуть более сложные индексы для быстрого поиска по нескольким ключам.

  4. Когда новое состояние полностью готово, атомарно подменяет старый указатель на новую структуру. Запросы в этот момент продолжают обслуживаться из старого кеша, без блокировок.

Периодическая полная выгрузка
Периодическая полная выгрузка

Приведу упрощённый код фонового загрузчика:

func (s *Scheduler) Run(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case <-time.After(s.interval):
			newState, err := s.loadNewState(ctx)
			if err != nil {
				logError()
				continue
			}
			s.storage.StoreNewState(newState)
		}
	}
}

А вот пример хранения с атомарной заменой:

type Storage struct {
	data atomic.Pointer[map[Key]Data]
}

func (s *Storage) Get(k Key) Data {
	m := s.data.Load()
	return (*m)[k]
}

func (s *Storage) StoreNewState(m map[Key]Data) {
	s.data.Store(&m)
}

Что хорошего в таком решении

Реализация элементарна. Не нужны дополнительные хранилища, брокеры сообщений, механизмы версионирования. Код — одна горутина с бесконечным циклом и atomic.Pointer. Для данных, где объём измеряется до тысяч записей, а изменения происходят редко, это отличный выбор. 

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

Какие есть ограничения

  1. Задержка актуализации. Задержка в худшем случае равна интервалу выгрузки. Если данные обновляются раз в 5 минут, то до 5 минут сервис может отдавать уже устаревшие значения.

  2. Избыточная нагрузка на источник. Каждый раз выгружается полный слепок данных, хотя изменений могло не быть. Отсюда дополнительная нагрузка на источник данных, например БД, и дополнительная нагрузка на сеть.

  3. Двойное потребление памяти. В момент замены в памяти одновременно находятся и старый, и новый слепок. Старый будет висеть до тех пор, пока все читатели не закончат с ним работу и не отработает сборщик мусора. На время обновления потребление памяти подскакивает в два раза.

  4. Несогласованность данных между подами. Каждый под выгружает полный слепок независимо, по собственному таймеру. Это приводит к тому, что в один момент поды могут хранить разное состояние и ответ сервиса будет расходиться в зависимости от того, в какой под придёт запрос. Степень расхождения зависит от разброса фаз загрузки. Её можно уменьшить, если запускать выгрузку синхронно на всех подах (привязавшись к единому расписанию), но полностью избежать нельзя, так как скорость загрузки всё равно будет немного различаться.

Эти ограничения становятся критичными для данных, где объём превышает миллионы записей, а частота изменений — сотни событий в секунду. Нам потребовался способ передавать только изменившиеся данные, и мы пришли к версионированию.

Версионирование состояния и pull изменений

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

Дальше нужно было понять, как поды будут узнавать об изменениях. По сути, есть два пути: push и pull.

Push vs Pull: первый выбор

Push-модель — это когда источник данных сам рассылает изменения всем потребителям. Типичные реализации: брокеры сообщений (Kafka, RabbitMQ), websocket-стримы, gRPC server-side streaming. Главный плюс — минимальная задержка: событие доставляется почти мгновенно после возникновения. Минусы — сложность на стороне источника (нужно знать всех подписчиков, управлять подключениями, переживать перезапуски потребителей) и риск каскадных сбоев, если один из получателей завис или упал.

Pull-модель — потребитель сам ходит к источнику и спрашивает: «Какие были изменения с момента X?» Источник не знает о потребителях ничего, кроме факта запроса. Плюсы: простота реализации на стороне источника, изоляция (проблемы у одного потребителя не затрагивают остальных), естественная поддержка backpressure (потребитель забирает данные в своём темпе). Минус — задержка доставки определяется интервалом опроса.

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

Почему не push

В нашей инфраструктуре есть несколько вариантов, как реализовать пуш-модель: Kafka topic, Redis pub-sub, gRPC-streaming. Разберём по порядку, почему не подошёл ни один из этих вариантов.

  1. Kafka topic. На первый взгляд, Kafka — то что нужно: гарантии at least once, высокая пропускная способность, масштабируемость. Но в нашем сценарии проявилась фундаментальная проблема. У нас сотни подов, и каждому нужна полная копия всех изменений, а не своя порция данных.

    В Kafka для этого есть два пути. Первый — создать на каждый под отдельную consumer group. Тогда брокер вынужден дублировать одни и те же сообщения для каждой группы и хранить её offset, что создаёт избыточную нагрузку на брокер и сеть. Второй путь — читать без consumer group, вручную назначая партиции. Это избавляет от лишних offset на брокере, но заставляет нас самих думать, где хранить смещение и как восстанавливаться после сбоя.

    По сути, мы заставляем Kafka делать то, для чего она не предназначена: работать как широковещательный fan-out на сотни получателей с полным дублированием данных. Обычно Kafka масштабируется за счёт того, что потребители внутри одной группы делят партиции между собой, обрабатывая разные подмножества сообщений, а у нас каждый под читает всё.

  2. Redis pub-sub — это fire-and-forget. Если потребитель неактивен, сообщение теряется безвозвратно. Нет ни подтверждений, ни повторов. В нашем случае потеря даже одного события может привести к тому, что под будет вечно работать с устаревшими данными, пока не случится рестарт с полной выгрузкой. К тому же Redis использует асинхронную репликацию, и при отказе целого дата-центра (ДЦ) часть сообщений может быть потеряна — а система должна оставаться устойчивой к потере ДЦ. Даже если взять Redis Streams (который умеет хранить сообщения), это потребует на каждый под по consumer group со своим курсором, что вызывает те же проблемы, что и при использовании Kafka topic. 

  3. gRPC streaming. Можно самостоятельно запрограммировать pub-sub-модель поверх стриминга, но это превращает простую задачу «передать изменения» в отдельный инфраструктурный проект. Именно это мы и хотели обойти. Ряд трудностей, которые пришлось бы решать:

    1. Управление подписчиками. Сервер должен знать всех подключившихся потребителей, отслеживать их состояние, корректно обрабатывать обрывы соединений и переподключения.

    2. Гарантии доставки. Чтобы обеспечить at least once, придётся реализовать подтверждения (ack) и повторную отправку непринятых сообщений. Это значит хранить для каждого подписчика курсор и буфер неподтверждённых событий.

    3. Backpressure. Если потребитель тормозит, сервер должен либо сбрасывать сообщения (теряем гарантии), либо накапливать их в памяти, рискуя упасть по OOM. 

Версионирование в БД

Итак, мы выбрали pull-модель и решили строить лог изменений прямо в PostgreSQL, в котором мы уже храним слепок данных. Идея — присвоить каждой записи монотонно возрастающий номер версии, а потребителям дать возможность запрашивать «все изменения, случившиеся после версии X».

На стороне нашего сервиса, который отслеживает изменения из мастер-систем и пишет в БД, схема выглядит так:

  1. При любом изменении данных (INSERT/UPDATE/DELETE) мы открываем транзакцию.

  2. Резервируем версии для конкретной сущности (например, «остатки складов»). Число зарезервированных версий равно количеству изменяемых строк.

  3. Добавляем к записям зарезервированные версии и пишем их в БД.

  4. Фиксируем транзакцию.

Ключевой элемент — таблица versions, где для каждой логической сущности хранится текущий максимальный номер версии. Атомарный инкремент счётчика в той же транзакции, что и изменение данных, гарантирует непрерывный, монотонно возрастающий номер для каждой строки без дырок. Даже при параллельных транзакциях версии сериализуются: UPDATE ... RETURNING захватывает эксклюзивную блокировку на строке счётчика до конца транзакции. Пока одна транзакция удерживает блокировку, другая будет ждать и получит свой диапазон версий только после коммита первой. Это важно: порядок коммитов совпадает с порядком выданных диапазонов, поэтому поллер никогда не увидит ситуации, когда транзакция с большим диапазоном уже зафиксировалась, а с меньшим — ещё нет. Полученный номер версии используем как последнюю версию, а предыдущие вычисляем арифметически и присваиваем изменённым строкам, то есть имеем диапазон версий [returning_version - len_changes + 1, returning_version]. Упрощённый SQL:

BEGIN;
UPDATE versions SET version = version + @len_changes WHERE entity = @entity RETURNING version;
INSERT INTO ... ON CONFLICT (id) DO UPDATE version = EXCLUDED.version, data = EXCLUDED.data, ...;
COMMIT;

В отличие от классического event sourcing, мы не храним отдельный лог изменений. Колонка version добавлена непосредственно в таблицы с данными. Каждая запись — это актуальное и полное состояние сущности, а version показывает, когда оно последний раз менялось. При обновлении мы перезаписываем строку и инкрементируем ей версию; при удалении ставим флаг is_deleted (soft-delete), чтобы не терять информацию и дать потребителю возможность обработать удаление. Потребитель запрашивает все строки с версией больше своей — это и есть дельта.

Потребитель: как мы забираем изменения

Работа потребителя состоит из двух фаз: начальной загрузки и поллинга дельты.

При старте пода мы должны получить полный актуальный срез. Для этого выполняем SELECT * FROM entity ORDER BY version с разбиением на пачки (по N записей, чтобы не забить память и не держать долгую транзакцию). После загрузки всех страниц мы запоминаем last_version — максимальную версию среди загруженных записей. С этого момента запускается фоновый поллер:

  • Раз в заданный интервал (например, 1 секунду) выполняем запрос SELECT * FROM entity WHERE version > @last_version ORDER BY version LIMIT @limit.

  • Полученные изменения атомарно применяем к in-memory-кешу — для каждой строки либо вставляем новую запись, либо обновляем существующую по первичному ключу, либо удаляем (если флаг is_deleted).

  • Обновляем last_version до версии последней обработанной строки.

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

func (p *Poller) Run(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case <-time.After(p.interval):
            for {
				changes, err := p.loadChanges(ctx, p.lastVersion) // SELECT * FROM entity WHERE version > @last_version LIMIT @limit ORDER BY version;
				if err != nil {
					logError()
                    break
                }
				if len(changes) == 0 {
                    break
                }
				p.storage.StoreChanges(changes)
				p.lastVersion = changes[len(changes)-1].Version
				if len(changes) < p.limit {
        			break
    			}
            }
		}
	}
}

Такой механизм даёт нам максимальную задержку актуализации, равную интервалу опроса в установившемся режиме (секунды), а при всплесках изменений под автоматически навёрстывает упущенное.

Схематично взаимодействие выглядит следующим образом:

Версионирование состояния в БД
Версионирование состояния в БД

Какие преимущества и недостатки

Что мы получили:

  • Дедупликация на стороне БД. Мы не храним историю всех изменений — только последнее состояние каждой сущности с версией. В отличие от событийного лога, где одно и то же поле может обновляться десятки раз и плодить записи, у нас одна строка на сущность. Это экономит дисковое пространство и упрощает потребление: не нужно накатывать цепочку событий, достаточно перезаписать значение в кеше.

  • По сети передаётся только дельта. Даже при миллионах записей в таблице поллинг вытаскивает только изменившиеся строки. В спокойные периоды это единицы записей в секунду, а не гигабайты. Нагрузка на БД и сеть падает на порядки.

  • Управляемая задержка. Интервал опроса определяет верхнюю границу устаревания данных. Мы можем выбирать компромисс между нагрузкой и актуальностью: секунда, две, пять — всё под нашим контролем.

  • Простота отладки. Версия — это сквозной идентификатор состояния. Зная last_version на конкретном поде, мы всегда можем понять, насколько он отстаёт от источника, и найти причину.

Какие недостатки пришлось принять:

  • Паразитная нагрузка пустых запросов. При отсутствии изменений каждый под продолжает с заданной частотой опрашивать БД. Тысячи запросов в минуту с сотен подов, возвращающих пустой результат, создают фоновый шум. Это плата за простоту pull-модели, и на практике она оказалась незначительной: индекс по version делает такие запросы почти бесплатными.

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

Сервис с раздачей версионированного состояния

Версионирование в БД и pull дельты решили проблему постоянной избыточной передачи данных. В получившейся системе всё работало отлично: сотни подов опрашивали PostgreSQL, получали единицы изменившихся строк в секунду и держали актуальный слепок в памяти. Решение долгое время нас устраивало. Но со временем нагрузка на систему росла, мы горизонтально масштабировались и число подов увеличивалось. Чем больше становилось подов, тем острее ощущалась проблема холодного старта.

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

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

Как это устроено

Вместо того чтобы каждый под ходил в БД, мы ввели отдельный сервис с минимальным числом подов (по одному-два в каждом ДЦ), который:

  • При старте сам загружает полный слепок данных из PostgreSQL (используя ту же механику версионирования и начальной выгрузки, что мы описали ранее).

  • Подписывается на дельту — точно так же, как обычный под, поллит БД на предмет новых версий и сохраняет изменения себе в память.

  • Держит in-memory-состояние: полную копию всех актуальных записей, упорядоченных по версии для быстрого доступа.

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

Остальные поды (назовём их клиентскими) больше не ходят в БД ни при старте, ни в процессе работы. Код поллера данных остался точно таким же, как и ранее, только вместо селектов в БД вызывались запросы по API к сервису-хранителю.

Центральная идея: БД теперь обслуживает единицы потребителей полной выгрузки, а не сотни. Все клиенты получают данные из быстрой памяти специализированного сервиса, который оптимизирован именно под эту задачу.

Сервис с раздачей версионированного состояния
Сервис с раздачей версионированного состояния

Почему это лучше

  • Разгрузка БД. Вместо сотен тяжеловесных сканирований при старте — единицы. 

  • Быстрый старт клиентов. Данные передаются из памяти по внутренней сети без затрат на дисковые операции. Гигабайт состояния может быть передан за секунды вместо минут.

  • Централизованное управление версиями. Сервис-хранитель становится естественным «источником истины» для клиентов, упрощая мониторинг и диагностику: мы всегда знаем, какая версия у хранителя, и можем сравнивать с версиями клиентов.

  • Возможности оптимизации. Можно сжимать данные, использовать эффективную сериализацию, кешировать горячие срезы — всё это в одном месте, а не в каждом поде отдельно.

Что стало сложнее

  • Появился дополнительный сервис. Это ещё один компонент инфраструктуры, который нужно разворачивать, мониторить, обновлять.

  • Дополнительная точка отказа. Если под сервиса-хранителя выходит из строя, клиенты переключаются на другие (их по 1–2 в каждом из четырёх ДЦ). Полный отказ всего кластера хранителей маловероятен, но теоретически возможен, и тогда новые поды не смогут инициализироваться, а текущие перестанут получать обновления. С другой стороны, при отказе БД сервис-хранитель продолжит отдавать последнее известное состояние, и клиенты смогут инициализироваться, просто данные начнут устаревать.

  • Потребление памяти. Хранитель держит полное состояние и историю изменений в оперативной памяти, что требует значительных ресурсов. Но это ожидаемая цена за скорость.

Реализация сервиса-хранителя

В основе сервиса лежит эффективная структура в памяти. Все актуальные записи хранятся в одном непрерывном слайсе, который всегда отсортирован по версии. Это позволяет по запросу «отдай всё, что новее версии X» выполнять быстрый бинарный поиск — и отдавать только изменившиеся строки, не просматривая весь массив.

Обновления из БД приходят через тот же самый pull-механизм, который мы использовали раньше. Сервис-хранитель поллит PostgreSQL и все полученные изменения просто дописывает в хвост слайса. Поскольку база гарантирует монотонное возрастание версий, слайс остаётся упорядоченным автоматически, без дополнительных сортировок.

Но здесь возникает нюанс: данные регулярно обновляются, и по одному и тому же ключу может прийти новая запись. Чтобы не терять старые и не нарушать порядок, мы не перезаписываем старую строку в слайсе, а только помечаем её как неактуальную. Новая же запись дописывается в конец, получая свежую версию. Для быстрого поиска старой записи по ключу служит отдельная map — индекс, который указывает на текущий элемент в слайсе. Так мы совмещаем упорядоченность по версии с возможностью мгновенно найти и инвалидировать предыдущее состояние.

У такого подхода есть предсказуемое следствие: со временем в памяти накапливаются «мёртвые» записи — строки, помеченные удалёнными. Чтобы держать потребление памяти под контролем, в фоне запускается горутина, которая создаёт новый слайс, пропуская все неактуальные элементы, и атомарно подменяет им старый.

Код описанной структуры представлен ниже:

type Service[T Object[K, P], K Key, P Proto] struct {
    elems []*element[T, K, P]     // отсортированные по версии для бинарного поиска
    index map[K]*element[T, K, P] // индекс для удаления/вставки
    mx    *sync.RWMutex
    name  string
}

// Get получение данных после переданной версии с заданным лимитом
func (s *Service[T, K, P]) Get(version, limit int) []P {
    return s.Filter(version, limit, func(P) bool {
       return true
    })
}

func (s *Service[T, K, P]) Filter(version, limit int, pred func(P) bool) []P {
    s.mx.RLock()
    defer s.mx.RUnlock()

    idx := sort.Search(len(s.elems), func(i int) bool {
       return int(s.elems[i].object.GetVersion()) > version
    })

    out := make([]P, 0, limit)
    for i := idx; i < len(s.elems) && len(out) != limit; i++ {
       elem := s.elems[i]
       if elem.isDeleted || !pred(elem.object) {
          continue
       }
       out = append(out, s.elems[i].object)
    }

    return out
}

// Push добавляет элементы в слайс, упорядоченный по версии
func (s *Service[T, K, P]) Push(newElems []T) {
    s.mx.Lock()
    defer s.mx.Unlock()

    if len(newElems) == 0 {
       return
    }

    toAppend := lo.Map(newElems, func(item T, _ int) *element[T, K, P] {
       return &element[T, K, P]{
          key:       item.GetKey(),
          object:    item.GetProto(),
          isDeleted: false,
       }
    })

    for _, newEl := range toAppend {
       oldEl, ok := s.index[newEl.key]
       if ok {
          oldEl.isDeleted = true
       }
       s.index[newEl.key] = newEl
    }

    if len(s.elems) > 0 {
       prevMaxVersion := s.elems[len(s.elems)-1].object.GetVersion()
       newMinVersion := toAppend[0].object.GetVersion()
       if newMinVersion < prevMaxVersion {
          panic(fmt.Sprintf("invalid service Push call: %d < %d", newMinVersion, prevMaxVersion))
       }
    }

    s.elems = append(s.elems, toAppend...)
}

// Compress убирает из данных мёртвые записи
func (s *Service[T, K, P]) Compress(ctx context.Context) {
    s.mx.Lock()
    defer s.mx.Unlock()

    compressed := make([]*element[T, K, P], 0, len(s.index))
    for _, el := range s.elems {
       if el.isDeleted {
          continue
       }
       compressed = append(compressed, el)
    }

    s.elems = compressed
}

Сервис-хранитель блестяще справился с холодным стартом клиентов, но принёс и новые проблемы. Когда объём данных вырос до десятков гигабайтов, рестарт хранителя стал занимать 5–10 минут и всё так же вызывал лавинные чтения при полном сканировании БД. Стало ясно, что нужно освободить хранитель от бремени полного состояния.

Первичная загрузка из S3, обновления напрямую из БД

Если посмотреть на задачу холодного старта непредвзято, становится ясно: поду в момент инициализации нужен не сам сервис-хранитель, а максимально быстрый способ получить полный слепок данных. Таким способом вполне может быть обычный файл в S3 — снапшот, сформированный заранее. А для получения дельты после старта сервис-хранитель может и не держать всё в памяти, а просто проксировать запросы в ту же самую PostgreSQL, которая и так хранит актуальные данные и, что важнее, почти всегда держит их в буферном кеше.

Так мы пришли к схеме, которая выглядит следующим образом:

  • Сервис-хранитель регулярно, раз в несколько минут, создаёт снапшот полного состояния прямо из PostgreSQL и загружает его в S3. Вместе со снапшотом сохраняется метаинформация — максимальная версия данных на момент его создания.

  • Клиентский под при старте скачивает актуальный снапшот из S3, загружает его в свой локальный кеш и запоминает версию, с которой этот снапшот был сделан.

  • Сразу после этого под начинает поллинг дельты, но теперь API сервиса-хранителя не отдаёт данные из памяти, а ходит напрямую в PostgreSQL: SELECT * FROM entity WHERE version > @last_version ORDER BY version LIMIT @limit.

  • Сервис-хранитель в этой схеме выступает просто как слой абстракции: он принимает запрос от клиента, выполняет лёгкий запрос к БД и возвращает результат. Никакого хранения гигабайтов в собственной памяти ему больше не требуется.

Для создания снапшотов мы реализовали два режима. Первый — полный: сервис просто вычитывает всю таблицу и потоково пишет её в сжатый файл в S3. Это надёжно, но требует полного сканирования БД. Второй — инкрементальный: скачивается последний снапшот, копируется, и к копии применяются все изменения, накопившиеся с момента его создания (то есть догружается дельта). Затем обновлённый файл заливается в S3 как новый снапшот. Такой подход ещё меньше нагружает базу данных и быстрее формирует следующий слепок, что позволяет делать снапшоты чаще без ущерба для основной нагрузки.

Первичная выгрузка из S3 и поллинг PostgreSQL
Первичная выгрузка из S3 и поллинг PostgreSQL

Почему это работает

На первый взгляд кажется, что мы откатились назад: данные снова читаются из БД, а не из оперативной памяти. Но дьявол в деталях.

Снапшот забирает на себя всю тяжёлую работу. Полное сканирование таблиц выполняется один раз при создании снапшота (и то асинхронно, с планированием нагрузки), а не при запуске каждого пода. Снапшот можно дополнительно сжать потоковым алгоритмом (например, zstd) — тогда по сети передаётся меньше данных, а сеть в сценариях массовой выгрузки часто оказывается узким горлышком. Клиенты получают гигабайт данных из дешёвого объектного хранилища, вообще не трогая базу данных.

Дельта почти всегда в памяти. PostgreSQL активно использует буферный кеш. Горячие страницы — а это именно те данные, которые недавно менялись, — практически гарантированно находятся в оперативной памяти. Все клиенты запрашивают одни и те же данные (последние изменившееся), поэтому запросы вида WHERE version > X постоянно обращаются к одним и тем же страницам. Чтение идёт из shared buffers, а не с диска. Разница с хранением в памяти сервиса-хранителя лишь в том, что данные лежат не в куче Go-приложения, а в буферном кеше PostgreSQL.

Экономия памяти. Сервис-хранитель теперь потребляет минимум оперативной памяти: ему достаточно обслуживать сетевые соединения. Сотни гигабайтов RAM, занятых дубликатом данных, освобождаются.

Ускорение холодного старта. Снапшот создаётся часто (раз в несколько минут), поэтому «хвост» изменений, который нужно догнать при инициализации, всегда мал. Клиент загружает снапшот из S3, несколькими лёгкими запросами добирает последние изменения по API и готов к работе. Полное восстановление после отказа сервиса-хранителя перестало быть катастрофой: он просто перезапускается, не загружая гигабайт данных в себя, и начинает обслуживать запросы клиентов, проксируя их в БД.

Сжатие данных и оптимизация снапшотов

При подготовке снапшотов мы использовали стандартную связку: protobuf-сериализация + zstd. Такой выбор неслучаен: protobuf даёт компактное бинарное представление, а zstd сочетает высокую скорость и отличную степень сжатия. Мы применяли реализацию пакета github.com/klauspost/compress/zstd с настройками по умолчанию — она обеспечивает хороший баланс между нагрузкой на CPU и размером выходного файла.

Чтобы честно оценить выигрыш, мы сравнили физический размер данных в PostgreSQL (чистые данные, без индексов) и размер полученного снапшота для каждой таблицы. Результаты оказались следующими:

Таблица

Размер в БД

Размер снапшота (S3)

Коэффициент сжатия

points (информация по складам)

2 793 MB

140 MB

20×

fbs_warehouse_priorities (селлерские приоритеты)

894 MB

7,6 MB

118×

warehouse_locations (доступные локации для доставки)

1 052 MB

27,5 MB

38×

delivery_methods (методы доставки)

3 512 MB

332 MB

10,6×

delivery_method_polygons (полигоны доставки по методам)

1 543 MB

717 MB

2,15×

Всего

~10,5 GB

~1,3 GB

~8×

Общий объём данных, участвующих в холодном старте, сократился с ~10,5 ГБ до ~1,3 ГБ — более чем в 8 раз. Для большинства таблиц сжатие составляло 10–100×, что полностью снимало вопрос о сетевой нагрузке при запуске сотен подов.

Однако одна строка в таблице выбивалась из общей картины: delivery_method_polygons сжималась всего в 2,15 раза. Исходный размер в БД — 1,5 ГБ, а снапшот занимал 717 МБ. Для объектного хранилища это всё равно лучше, чем полное сканирование базы, но выглядело как явная аномалия, которую стоило понять.

Мы проанализировали структуру данных. Типичная запись в delivery_method_polygons содержит несколько атрибутов, и главное «тяжёлое» поле — массив идентификаторов полигонов доставки (это целые числа). Полигоны разных методов доставки часто ссылаются на один и тот же набор точек, однако в исходных данных массивы не были упорядочены. Из-за этого одинаковые с точки зрения бизнес-логики наборы ID превращались в разные байтовые последовательности, и zstd не мог обнаружить повторяющиеся блоки. Решение напрашивалось само: перед сериализацией мы стали сортировать массив идентификаторов. Это привело данные к каноническому виду: одинаковые множества ID стали порождать одинаковые цепочки байтов, которые zstd эффективно находит и заменяет ссылками на предыдущие вхождения.

Результат превзошёл ожидания:

  • Размер снапшота delivery_method_polygons уменьшился с 717 МБ до 20 МБ.

  • Коэффициент сжатия вырос с 2,15× до 77× (1 543 МБ в БД → 20 МБ в S3).

  • Общий размер всех снапшотов сократился примерно до 600 МБ, а суммарный коэффициент сжатия поднялся до ~17×.

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

Что изменилось для клиента

С точки зрения клиентского пода изменилось немного. Логика поллинга осталась прежней: та же горутина с тикером, тот же запрос «дай изменения после версии X». Только теперь перед вызовом этой горутины появился ещё один этап: выгружается состояние из S3, и уже с версией из метаданных запускается поллинг.

Новые компромиссы

Зависимость от свежести снапшотов. Если создание снапшотов задерживается (например, из-за всплеска нагрузки на БД), «хвост» для новых подов становится длиннее, и старт замедляется. Это решается мониторингом и адаптивным интервалом создания снапшотов, но в нашем случае даже получасовой интервал между снапшотами не создаёт проблем для БД.

Нагрузка на S3. Гигабайтные снапшоты, скачиваемые сотнями подов, создают трафик в объектное хранилище. На практике это недорого и хорошо масштабируется. Благодаря высокой пропускной способности S3 и сжатию данных пиковые нагрузки на старте подов проходят без задержек.

Так мы пришли к окончательной архитектуре: холодный старт полностью вынесен в S3, а текущая работа с дельтой идёт напрямую из PostgreSQL, буферный кеш которого обслуживает запросы напрямую из памяти. Оказалось, что для быстрой доставки данных не нужно городить сложные хранилища в оперативной памяти собственных сервисов — достаточно грамотно использовать механизмы, уже встроенные в базу данных и объектное хранилище. Эта схема позволила забыть о проблемах холодного старта, радикально снизить потребление памяти и при этом сохранить контролируемую задержку обновлений.

Заключение

Пройдя путь от простейшей загрузки по крону до сервиса-прокси со снапшотами в S3 и поллингом PostgreSQL, мы вынесли несколько уроков, которые, надеюсь, пригодятся тем, кто столкнётся с похожей задачей.

Эволюция архитектуры — это нормально. Каждое решение было правильным для своего времени. Полная выгрузка по крону работает, пока данных мало. Версионирование в БД позволило перейти на дельту. Сервис-хранитель в памяти разгрузил БД от холодных стартов. Снапшоты в S3 освободили память и ускорили запуск. Не нужно сразу проектировать систему под нагрузку, которой ещё нет, — лучше последовательно усложнять архитектуру по мере роста проблем.

Pull часто проще и надёжнее Push. Когда множеству потребителей нужна полная копия одних и тех же данных, pull-модель с опросом БД или API оказывается значительно проще, чем push-решения с брокерами. Мы честно рассматривали Kafka, Redis pub-sub, gRPC-стриминг и каждый раз упирались в умножение трафика, хранение смещений и дополнительные точки отказа. Поллинг с версионированием дал нам контролируемую задержку, изоляцию и простоту.

Холодный старт заслуживает отдельного решения. Массовый запуск подов после деплоя — особая проблема, которая не решается простым ускорением запросов. Снапшоты в S3 оказались простым и мощным инструментом: дёшево, масштабируемо, без нагрузки на БД. А в сочетании со сжатием и инкрементальным обновлением снапшотов мы вообще забыли, что запуск сотен подов — это проблема.

Не бойтесь возвращаться к истокам. Мы начинали с чтения из PostgreSQL, потом ушли в in-memory хранилище, а затем снова вернулись к PostgreSQL — но на другом уровне понимания. Иногда улучшение — это не добавление новой технологии, а переосмысление роли существующих.

Этот путь научил нас, что не существует единственного правильного решения. Есть компромиссы, которые вы принимаете в зависимости от масштаба, требований к задержке, стоимости инфраструктуры и готовности команды поддерживать сложность. Выбирайте самое простое решение, которое закрывает текущую проблему, и не стесняйтесь его усложнять, когда оно перестаёт справляться.