Горизонтальное масштабирование небольших Web-приложений на Java (вопросы собеседований)
Эта тема была поднята в ходе нескольких (3+) собеседований который я прошёл за последние полтора месяца — в разных вариациях, но примерно об одном. Казалось бы, известные вещи —, но собрав все ответы и объяснения какие я давал (и кое-что что нашёл позже в гугле), решил сохранить их не у себя в гугл-драйве, а написать краткий обзор.Речь шла о небольших и типовых приложениях Enterprise / Web на Java, каких пишется множество (ну такие, на 10–100 тысяч клиентов, миллион посещений и т.п.). Пусть это будет обобщённый диалог в виде вопросов и ответов.
В: Допустим, у вас есть приложение (самое обычное — JSP, Spring, Hibernate например) развернутое на томкате (Apache Tomcat) и вы однажды замечаете что сервер с томкатом загружен на 80% в среднем. Что делать?
О: Поставим несколько томкатов на отдельных серверах в паралель. Они будут по-прежнему использовать одну базу на одном сервере.
В: Но как же пользователь будет заходить на ваши несколько серверов?
О: Используем load-balancer, например перед томкатами может стоять апач (Apache httpd) с mod_proxy — он будет распределять (проксировать) запросы приходящие к нему между всеми нашими томкатами.
В: Но ведь может получиться что пользователь залогинится на одном томкате, а следующий запрос load-balancer пошлёт на другой, где пользователь не залогинен!
О: Это мы говорим о том как организовать сессию. Например, делаем sticky sessions (например когда load-balancer добавляет к запросу куку с указанием на какой томкат он этот запрос проксирует —, а все последующие запросы с этой кукой направляет обязательно на тот же самый сервер. Таким образом каждый отдельный пользователь будет работать только с одним сервером.
В: А если этот конкретный сервер упадёт?
О: Сессия пользователя потеряется. Поэтому лучше использовать хранение сессий в кэше. Томкат «из коробки» умеет хранить их в memcached например. То есть мы дописываем строчку в конфиг и на отдельном сервере запускаем memcached — теперь все томкаты хранят сессии на нём и если пользователь попал с очередным запросом на другой сервер, он этого не заметит — сессия будет работать все равно.
В: Какие ещё преимущества у кэша для сессий?
О: Например, можно деплоить новую версию приложения только на один из нескольких томкатов, так что скажем 25% пользователей видят новую страницу входа и успеют высказать нам пожелания если им это не нравится, т.е. они невольно поработают бета-тестерами :)
В: Но если версии приложения по-разному используют базу?
О: Мы можем проектировать изменения базы так чтобы поддерживать обратную совместимость между двумя соседними версиями. Это нетрудно. Добавлять колонки, например, нужно вместе с новой версией, а вот удалять ненужные только при следующем релизе.
В: Хорошо, теперь у нас узким местом становится база. Что мы будем делать при возрастании нагрузки на неё?
О: В первую очередь между базой и томкатами полезно сделать кэш. Ещё раньше вероятно мы используем кэш на уровне ORM (например, второй уровень кеша в Hibernate). Общий смысл в том что в течение сессии пользователь использует ограниченный набор данных, поэтому их удобно кэшировать.
В: Ну, а всё-таки, допустим даже кэш нас не спасает. Как можно уменьшить нагрузку на базу?
О: У нас есть несколько путей. Например можно часть базы (какую-нибудь особо насосную таблицу допустим) выделить в другую базу на отдельном сервере, может даже в NoSQL хранилище или какой-нибудь специальный кэш. Конечно, лучше это разделение сделать ещё при проектировании :)
В: А какие есть другие пути? Какие решения на уровне самой базы данных?
О: Можно использовать шардинг — при этом таблицы разбиваются на несколько серверов и обращение к нужному происходит, например, по части id-шника. В некоторых случаях можно разделить сразу, допустим, сделки, транзакции, электронные документы и т.п. касающиеся одного пользователя поскольку обычно пользователь не работает с чужими документами —, а значит все его данные можно удобно хранить на одном сервере.
В: Какой недостаток этого подхода?
О: С такими таблицами впоследствии будет сложнее работать — join с таблицей лежащей на нескольких серверах очевидно будет менее эффективен — вообще усложняется индексирование, запросы по критериям и т.п. Вообще само проектирование ощутимо усложняется.
В: Хорошо, знаете ли вы ещё варианты?
О: Самый простой это настроить репликацию, например так что база содержит копии на нескольких серверах, из них один используется для записи, а остальные для чтения. Эти последние быстро синхронизируют своё содержимое при апдейтах. Получается что общее количетсво запросов к базе теперь распределяется по нескольким машинам. Конечно это полезно именно когда чтения больше чем записи.
В: Какие дальнейшие пути масштабирования вы могли бы предложить?
О: Например, очереди сообщений. Скажем, пользователь сохраняет новую транзакцию —, но мы не пишем её в базу сами. Вместо этого отсылаем сообщение в очередь (скажем RabbitMQ) что такие-то данные должны быть сохранены. Это сообщение будет выдано одному из нескольких серверов осуществляющих обработку и сохранение в базу. Наращивать количество таких серверов (при использовании распределённой / реплицированной базы или кеша) вообще очень легко. Однако сама по себе архитектура на таком уровне уже требует больше внимания и размышлений — возможно даже это тот момент когда приложение стоит переписать целиком :)
В: Ладно, с этим ясно, давайте поговорим о другом… (и тут могут начать про гарбаж-коллекторы, или попросить написать двоичный поиск в массиве — проверка на вшивость —, но это уже не важно)
Поделившись своими «наблюдениями» по собеседованиям я буду рад конечно дополнениям, исправлениям и т.п. которые могут оказаться полезными и мне и другим коллегам :)