Масштабирование баз данных: эволюция архитектуры Nextdoor

Материал основан на разборе blog.bytebytego.com. Ниже — главное и выводы, которые стоит учитывать в SEO и маркетинге.


Когда я впервые разбирал архитектуру Nextdoor, меня поразило не то, насколько она сложна сейчас, — а то, насколько дисциплинированно команда двигалась от простого к сложному. Каждый новый уровень появлялся ровно тогда, когда предыдущий переставал справляться. Это редкость в индустрии, где принято строить сразу «на вырост».

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

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

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

Дисклеймер: этот материал основан на публично опубликованных сведениях от инженерной команды Nextdoor.

Вертикальное масштабирование и его пределы: один PostgreSQL на всё

В первые дни Nextdoor полагался на единственный экземпляр PostgreSQL для обработки каждого поста, комментария и обновления района.

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

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

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

Чтобы решить эту проблему, Nextdoor ввёл промежуточный слой под названием PgBouncer. Это пулер соединений (connection pooler), который располагается между приложением и базой данных. Вместо того чтобы каждый рабочий процесс приложения поддерживал собственную выделенную линию к базе данных, все они общаются с PgBouncer.

Процесс работает в четыре шага:

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

Это позволяет тысячам рабочих процессов приложения совместно использовать несколько сотен «тёплых» соединений с базой данных. Такой подход эффективно устранил узкое место соединений и позволил основной базе данных полностью сосредоточиться на обработке данных.

Горизонтальное масштабирование чтения и лаг репликации

Как только управление соединениями стабилизировалось, следующее узкое место проявилось в виде трафика на чтение.

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

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

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

Чтобы решить проблему, при которой сосед публикует пост, а затем видит, что он исчез после обновления страницы, Nextdoor использует динамическую маршрутизацию на основе времени (Time-Based Dynamic Routing). Это умная логика маршрутизации, гарантирующая, что пользователи всегда видят результаты своих собственных действий. Вот как это работает:

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

Это гарантирует, что пока общий район видит данные с итоговой согласованностью (eventual consistency), человек, внёсший изменение, всегда видит строго согласованные данные.

Высокоскоростная библиотека: слой кэширования на Valkey

Даже при наличии нескольких реплик обращение к базе данных при каждой загрузке страницы является дорогостоящей операцией.

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

Команда использует паттерн Look-aside Cache. Когда приложению нужны данные, оно следует определённой последовательности:

  • Проверка кэша: приложение ищет данные в Valkey по уникальному ключу.
  • Попадание в кэш (cache hit): если данные найдены, они мгновенно возвращаются пользователю без обращения к базе данных.
  • Промах кэша (cache miss): если данные отсутствуют, приложение запрашивает базу данных PostgreSQL для получения актуальной информации.
  • Шаг заполнения: приложение берёт результат из базы данных, сохраняет его копию в Valkey для будущих запросов и затем возвращает пользователю.

Эффективность критически важна при управлении кэшем такого масштаба. Оперативная память значительно дороже дискового хранилища, поэтому данные должны занимать как можно меньше места

Nextdoor использует бинарный формат сериализации под названием MessagePack. Вместо хранения данных в громоздком текстовом формате наподобие JSON они преобразуют их в сильно сжатый бинарный формат, который компьютер разбирает значительно быстрее. MessagePack особенно полезен для Nextdoor, поскольку поддерживает эволюцию схемы (schema evolution): если инженерная команда добавляет новое поле в профиль пользователя, старые кэшированные данные по-прежнему можно читать без сбоев приложения. Для ещё более крупных фрагментов данных используется сжатие Zstd. Комбинируя эти два инструмента, Nextdoor сокращает объём памяти, занимаемый серверами кэша.

Версионирование и атомарные обновления кэша

Кэширование может создавать серьёзную проблему, когда начинает «лгать» в определённых сценариях. Если база данных обновлена, а кэш не обновлён, пользователи видят устаревшую, некорректную информацию. Большинство простых стратегий кэширования опираются на TTL (time-to-live, время жизни) — таймер, который указывает кэшу удалить запись через несколько минут. Для социальной сети реального времени ждать несколько минут обновления публикации — неприемлемое решение

Nextdoor создал сложный движок версионирования, чтобы кэш оставался актуальным. В таблицы базы данных был добавлен специальный столбец system_version, а для управления этим числом использовались триггеры PostgreSQL. Триггер — это небольшой скрипт, который автоматически запускается внутри базы данных при каждом изменении строки. Каждый раз, когда публикация обновляется, триггер увеличивает номер версии. Это гарантирует, что база данных остаётся единственным источником истины относительно того, какая версия публикации является новейшей.

Когда приложение пытается обновить кэш, оно не просто перезаписывает старые данные. Оно использует Lua-скрипт, выполняемый внутри Valkey. Этот скрипт выполняет атомарную операцию сравнения и записи (Compare and Set), которая работает следующим образом:

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

Это предотвращает состояния гонки (race conditions). Представьте, что два разных сервера пытаются обновить одну и ту же публикацию одновременно. Без этой логики более старое обновление могло бы прийти на миллисекунду позже и перезаписать более новое — кэш навсегда вышел бы из синхронизации с базой данных. Благодаря использованию Lua весь процесс проверки версии и обновления данных происходит как единый, неделимый шаг, который невозможно прервать

CDC и согласование: страховочная сеть системы

Даже при наличии версионирования и Lua-скриптов могут возникать ошибки.

Сетевой раздел может помешать обновлению кэша достичь Valkey, или процесс приложения может аварийно завершиться до окончания шага заполнения. Nextdoor потребовалась финальная страховочная сеть для обнаружения таких расхождений. Была реализована технология захвата изменений данных, также известная как CDC (Change Data Capture, захват изменений данных), с использованием инструмента Debezium.

CDC работает, «прослушивая» внутренние журналы базы данных PostgreSQL. В частности, он отслеживает журнал упреждающей записи (Write-Ahead Log, WAL), где каждое изменение фиксируется до его фиксации. Каждый раз, когда в базе данных происходит изменение, Debezium захватывает это событие и превращает его в сообщение в потоке данных. Фоновый сервис, известный как Reconciler (согласователь), отслеживает этот поток.

Процесс согласования обеспечивает механизм «самовосстановления» для всей системы:

  • Обновление базы данных: пользователь обновляет описание своего района в основной базе данных PostgreSQL.
  • Захват журнала: Debezium обнаруживает новую запись в журнале и публикует сообщение о событии изменения.
  • Действие Reconciler: фоновый сервис получает это сообщение и определяет, какой ключ кэша необходимо исправить.
  • Инвалидация: сервис указывает кэшу удалить старую запись. В следующий раз, когда пользователь запросит это описание, приложение столкнётся с промахом кэша и получит полностью актуальные данные из базы данных.

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

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

Шардирование баз данных: когда репликации уже недостаточно

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

Шардирование — это процесс разбиения одной огромной таблицы на меньшие части и их распределения по совершенно разным кластерам баз данных. Nextdoor, как правило, шардирует данные по уникальному идентификатору, например по Neighborhood ID (идентификатору района).

  • Разделение кластера: все данные для районов с 1 по 500 могут находиться в кластере A, тогда как районы с 501 по 1000 — в кластере B.
  • Ключ шарда: приложение использует neighborhood_id, чтобы точно знать, к какому кластеру базы данных обращаться для любого конкретного запроса.

Шардирование позволяет значительно масштабироваться, поскольку мы можем продолжать добавлять кластеры по мере роста. Однако это сопряжено с высокой ценой в виде сложности. После шардирования базы данных мы больше не можем легко выполнять операцию Join между данными на двух разных шардах — это принципиальный trade-off, который команда принимает осознанно.

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

Типичные ошибки при масштабировании баз данных

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

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

Кэш без версионирования. Простой TTL-кэш работает до тех пор, пока данные не начинают меняться часто. Без механизма версионирования, аналогичного system_version в Nextdoor, кэш превращается в источник трудноотлаживаемых ошибок согласованности.

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

Отсутствие страховочной сети. Версионирование и атомарные операции снижают вероятность расхождений, но не исключают их полностью. CDC с Debezium и фоновый Reconciler — это именно та страховочная сеть, которую большинство команд добавляют слишком поздно, уже после инцидента в продакшене.

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

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

Главный вывод: сложность должна быть заслужена. Каждый уровень лестницы масштабирования решает одну проблему, одновременно порождая новый вызов в области согласованности данных. Выстраивая надёжные механизмы защиты — такие как версионирование и сверка данных — инженерная команда Nextdoor обеспечила рост системы без потери доверия со стороны сообществ, которым она служит.

Ответы на эти вопросы могут быть для вас полезными

Почему Nextdoor выбрал PgBouncer, а не встроенный пулинг соединений PostgreSQL?

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

Что такое лаг репликации и как он влияет на пользовательский опыт?

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

Зачем использовать Lua-скрипты внутри Valkey для обновления кэша?

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

Когда стоит переходить к шардированию базы данных?

Шардирование оправдано только тогда, когда исчерпаны более простые инструменты: пулинг соединений, реплики для чтения и кэширование. Преждевременное шардирование добавляет значительную сложность — в частности, невозможность выполнять Join между шардами — без соразмерного выигрыша в производительности.

Как CDC с Debezium помогает поддерживать согласованность кэша?

Debezium отслеживает журнал упреждающей записи (WAL) PostgreSQL и публикует событие при каждом изменении данных. Фоновый Reconciler получает эти события и инвалидирует соответствующие записи в Valkey. Это обеспечивает итоговую согласованность даже в случаях, когда основное обновление кэша завершилось неудачей из-за сетевой ошибки или сбоя процесса.

Оцените статью
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x