Масштабирование БД в Java: Replication и Eventual Consistency
Пока приложение небольшое, база данных обычно живёт на одном сервере: приложение отправляет запросы, база их обрабатывает. Всё просто. Но с ростом проекта увеличивается количество пользователей, запросов и данных. Сервер начинает упираться в CPU, память, диск или количество соединений.
Можно поставить более мощный сервер — это вертикальное масштабирование. Но у него есть предел. Другой вариант — добавить несколько экземпляров базы и распределить между ними нагрузку. Это уже горизонтальное масштабирование, и один из самых распространённых способов его реализовать — репликация.
Primary и Replica
При репликации один экземпляр базы становится Primary, а другие — Replica. Primary принимает изменения, Replica получает их копии и может использоваться для чтения.
Например, в интернет-магазине пользователи постоянно просматривают каталог, но относительно редко меняют данные:
Если чтений становится очень много, можно добавить несколько Replica и распределять запросы между ними. Это позволяет масштабировать read-нагрузку: вместо того чтобы один сервер обрабатывал все SELECT, работу можно распределить между несколькими.
Но здесь появляется важный нюанс.
Replica может отставать
В типичной асинхронной репликации данные не появляются на Replica в ту же миллисекунду, когда они были записаны на Primary.
Представим:
Primary успешно сохранил изменение. Сразу после этого приложение делает:
Но запрос попадает на Replica, которая ещё не получила новую версию данных. В результате пользователь может увидеть старое имя. Эта задержка называется replication lag. А архитектура, в которой разные экземпляры некоторое время могут содержать разные версии данных, приводит нас к понятию Eventual Consistency.
Идея здесь простая: данные не обязаны стать одинаковыми мгновенно, но при нормальной работе системы реплики в итоге синхронизируются. Для каталога товаров такая задержка может быть практически незаметной. А вот для некоторых сценариев она становится проблемой.
Read-after-write: когда старые данные недопустимы
Представим, что пользователь только что изменил пароль или оформил заказ. Запись успешно завершилась, и следующим запросом приложение получает данные пользователя. Если этот запрос уйдёт на отстающую Replica, пользователь может увидеть старое состояние.
Получается ситуация:
Поэтому приложение должно понимать, какие данные можно читать с Replica, а какие требуют гарантированно свежего состояния.
Для этого можно направлять критичные чтения на Primary, а обычные — на Replica. В более сложных системах используются специальные механизмы маршрутизации и контроля задержки репликации. Именно здесь репликация перестаёт быть исключительно задачей базы данных. Архитектура приложения тоже должна учитывать, что данные теперь существуют в нескольких экземплярах.
Как это связано с Java
В обычном Java-приложении разработчик может вообще не знать, на каком сервере выполняется запрос:
После появления репликации появляется дополнительный слой маршрутизации:
Условно можно договориться: операции записи идут на Primary, а большая часть чтений — на Replica.
В Spring-приложениях подобную маршрутизацию можно реализовать на уровне DataSource или инфраструктуры доступа к базе, не заставляя бизнес-логику самостоятельно выбирать сервер. Главное — не превратить код в набор условий вроде if (read) useReplica(). Решение о маршрутизации лучше держать в отдельном инфраструктурном слое.
А что с отказоустойчивостью?
Replica может быть полезна не только для чтений. Если Primary выходит из строя, одна из Replica может стать новым Primary — это failover.
Но при асинхронной репликации возможна потеря последних изменений, которые Primary уже успел принять, а Replica ещё не получила. Поэтому репликация повышает отказоустойчивость, но не означает автоматически нулевой риск потери данных. Более строгие гарантии можно получить с помощью синхронной репликации, однако за них приходится платить дополнительной задержкой и сложностью.
И снова получается компромисс: чем сильнее требования к согласованности, тем дороже их обеспечить.
Когда репликация действительно нужна?
Если база нормально работает на одном сервере, добавлять Replica просто «на будущее» обычно не стоит. Это усложняет архитектуру и добавляет новые сценарии, которые нужно тестировать.
Репликация становится особенно полезной, когда основная проблема — большое количество чтений или требования к отказоустойчивости. При этом подход к согласованности данных во многом зависит от выбранной модели базы. Для традиционных SQL-систем строгая согласованность и транзакционность обычно являются базовыми свойствами, поэтому переход к модели с задержкой между репликами приходится осознанно учитывать на уровне архитектуры. В NoSQL-системах вроде Cassandra или DynamoDB горизонтальное масштабирование и распределённость изначально заложены в архитектуру, поэтому Eventual Consistency может быть естественной частью модели работы с данными. В таких системах нет необходимости строить всё вокруг единственного Primary: разные узлы могут принимать операции, а согласованное состояние достигается постепенно. Такой подход часто описывают через модель BASE — противопоставляя её классическому ACID.
Но репликация не решает другую важную проблему: что делать, если самих данных становится слишком много для одного сервера. Представим таблицу событий с миллиардами строк. Мы можем добавить десять Replica, но весь объём данных всё равно должен храниться на Primary. В такой ситуации нужно уже не просто копировать данные, а разделять их.
Но это часть отдельной темы, Partitioning и Sharding.
Replication позволяет иметь несколько экземпляров базы и распределять между ними нагрузку, прежде всего чтение. Но вместе с этим появляется replication lag и необходимость учитывать Eventual Consistency.
Для Java-разработчика это означает, что недостаточно просто знать, как выполнить SQL-запрос. Нужно понимать, куда он попадёт, насколько свежие данные нужны конкретной операции и что произойдёт, если основной сервер станет недоступен.
Хотите узнать больше? Изучите другие статьи из разделов: