Для чего используются базы данных, ведь есть старые добрые файлы? Чем они хуже базы данных или чем база данных лучше файлов? БД — более структурированное хранилище. Она позволяет делать транзакции, запросы и так далее. Самый простой случай: есть сервер с базой данных и несколько приложений, которые делают запросы к серверу. База данных отвечает, меняет что-то внутри себя, и всё хорошо ровно до того момента, пока нагрузка на неё не вырастает настолько, что база данных перестаёт справляться.
Если допустить, что это только нагрузка на чтение, то проблема решается репликацией. Вы можете ставить к базе данных столько реплик, сколько нужно, и все чтения пускать на реплику, а все записи — на мастер. Если же на базу данных идёт нагрузка на запись, то репликация эту проблему не решает, ведь запись должна осуществляться на все реплики. Таким образом, сколько бы вы их ни ставили, вы не уменьшите нагрузку на запись из расчёта на одну машину. Тут на помощь приходит шардинг.
Если база не держит нагрузку на запись, то шарды можно добавлять до бесконечности. Шард устроен сложнее, чем реплика, потому что нужно как-то распределить данные по таблицам или внутри таблицы, по хэшу, по range — есть множество разных вариантов. Таким образом, добавляя реплики и шарды, вы можете делить любую нагрузку на базу данных. Казалось бы, больше желать нечего, о чём дальше говорить?
Но есть проблема
…которая лежит уже не в плоскости технологий. Ваш босс, видя постоянно растущий парк серверов, начинает негодовать, потому что на это уходит много денег. Нагрузка растёт, количество запросов от пользователей растёт, и вы всё добавляете и добавляете серверы. Вы же технарь, про деньги не думаете — пусть этим занимаются финансисты. И вы говорите своему боссу: «Всё нормально. У нас бесконечно масштабируемая система. Мы добавляем серверы, и всё круто работает». А босс отвечает: «Отлично, но мы теряем деньги. Нужно что-то с этим сделать. И если мы не решим проблему, то придётся закрыть весь бизнес. Поскольку, несмотря на рост бизнеса, по базам данных и серверам мы растём с опережающей скоростью». И эту проблему должны решить именно вы, а не финансисты, потому что она лежит, возможно, в технологической плоскости. Что делать дальше? Amazon гораздо дороже. Оптимизировать? Вы уже оптимизировали все запросы.
Выходом может стать кэширование данных, которые часто селектятся. Их можно держать в каком-то кэше и постоянно возвращать оттуда, не обращаясь к многочисленным репликам и шардам.
Проблемы кэша
Отлично, проблема решена: один memcached заменяет нам целую стойку серверов-реплик. Но за всё приходится платить.
- Приложение пишет и в кэш, и в базу, которые между собой никак не реплицируются. Таким образом возникает несогласованность данных. Например, вы пишете сначала в кэш, потом в базу. По какой-то причине запись в базу не получилась: приложение упало, сеть моргнула. Затем приложение вернуло пользователю ошибку, но в кэше уже находятся другие данные. То есть в кэше одни данные, а в базе другие. Про это никто не знает, приложение продолжает работать с кэшем. И когда он перезагружается, данные теряются, потому что в базе находится другая копия.
Самое смешное, что если писать в обратном порядке, то будет происходить то же самое. В базу записали, а в кэш запись не прошла. Мы работаем со старыми данными из кэша, в базе новые данные, но про это никто не знает. Кэш перезагрузился — опять данные потеряны. То есть в обоих случаях теряется апдейт. А это говорит о том, что вы теряете некое свойство базы данных, а именно — гарантию, что записанные данные сохранены в ней навсегда, то есть коммит уже не коммит. Справиться с несогласованностью данных можно, написав умный кэш, чтобы приложение работало только с ним. Он может быть и write through, лишь бы приложение не работало с базой. Сначала кэш должен записывать полученные данные в базу, а потом в себя. Если по каким-то причинам данные в базу не записались, то и в кэш они не должны записываться. Таким образом данные будут всегда синхронны. Данные не могут не записаться в кэш, потому что кэш — это память, а в память запись всегда проходит, кроме ситуации, когда память побилась, но и в этом случае умный кэш «свалится», унеся все закэшированные данные в небытие, что плохо, но не приведет к рассинхрону данных.
Но всё равно остаётся один редкий случай, при котором данные оказываются несинхронны: приложение пишет в кэш, кэш пишет в базу, база внутри себя сделала коммит. Дальше она подтверждает кэшу успешное завершение операции, но в этот момент рвётся сеть, и кэш этого подтверждения не получает. Он считает, что данные в базу не записались, и не применяет их у себя. Но в базе они всё-таки применились. Приложение работает со старыми данными, потом кэш перезагружается — данные опять другие. Это очень редкий случай, но он возможен.
И самое главное — умный кэш никак не решает проблему шардинга. А ваш босс не любит шардинг, потому что это очень дорого, ведь нужно покупать много-много серверов.
- Помимо прочего, внедрение кэша не избавляет нас от шардинга, потому что запись не ускоряется. Каждый коммит должен куда-то коммититься, причём не в кэш.
- Следующая проблема: кэш — это не база данных, а обычное key/value-хранилище. У вас теряются запросы и транзакции. Индексы и таблицы тоже теряются, но их можно с грехом пополам соорудить поверх key-value кэша. Поэтому приложение приходится упрощать и кардинально переделывать.
- Четвёртая проблема — «холодный старт». Когда кэш только поднимается, он пустой, в нём нет данных. Далее все селекты идут напрямую в базу мимо кэша, потому что в нём ещё ничего нет. Соответственно, приходится снова добавлять реплики, хотя бы не в полном объёме. Нам же нужно как-то прогревать кэш. А когда он прогревается, то в базу идёт много селектов. Соответственно, приходится держать целый ряд реплик только для прогрева кэша. Не правда ли, это выглядит достаточно расточительно? Но без этих реплик вы не сможете нормально стартовать. Рассмотрим подробнее решение этой проблемы.
Холодный старт
В своё время возникла такая идея: чтобы данные были всегда «теплыми», нужно просто не «охлаждать» их. Для этого кэш должен быть персистентным (persistence), то есть нужно хранить данные где-то на диске, и тогда всё будет нормально. Кэш будет стартовать и подгружать данные. Но тут возникло сомнение: кэш — это ОЗУ, он должен быть быстрым, а когда ему в пару даётся диск, то не будет ли он таким же медленным, как база данных? На самом деле — не будет.
Проще всего «персистить» кэш раз в N минут, целиком дампить его на диск. Этот дамп можно делать асинхронно, в фоне. Он не замедляет никакие операции, не нагружает процессор. Это позволяет многократно ускорить прогрев: когда кэш поднимается, у него уже под рукой собственный дамп с данными, он их читает линейно и очень быстро. Получается быстрее, чем с любым количеством реплик баз данных. Но не может же быть всё так легко, верно? Допустим, мы делаем дамп каждые пять минут. А если в промежутке происходит сбой, то накопившиеся с момента предыдущего дампа изменения будут потеряны. Для каких-то приложений это неважно, например для статистики, а для каких-то важно.
Вторая проблема — мы хорошо нагружаем диск, который, возможно, требуется для чего-то ещё, например для логов. Во время дампа диск будет тормозить, и происходить это будет бесконечно. Избежать этого можно, если вместо регулярных сбросов дампа вести журнал. Сразу должен возникнуть вопрос: «Как же так? Это кэш, он быстрый, а мы тут всё журналируем». На самом деле это не проблема. Если писать журнал в файл последовательно, на обычном винчестере, то скорость записи будет достигать 100 Мб/с. Допустим, средний размер транзакции 100 байт — это миллион транзакций в секунду. Очевидно, что мы никогда не упрёмся в производительность диска при журналировании кэша. Благодаря этому решается и проблема IOPS: мы нагружаем диск ровно настолько, насколько это необходимо, чтобы все данные персистились. Данные всегда свежие, мы их не теряем, при этом прогрев осуществляется быстро.
Но у журналирования есть свои недостатки. При ведении лога апдейты, которые обновляют один и тот же элемент, не группируются в одну запись. Их получается много, и при старте кэшу приходится «проигрывать» все эти записи, что может занять больше времени, чем старт с дампа. Кроме того, сам по себе лог может занимать очень много места, даже не уместиться на диск.
Для решения проблемы можно объединить оба подхода — сброс дампа и ведение лога. Почему бы нет? Мы можем дампить, то есть создавать снапшот, раз в сутки, и при этом всегда писать в лог. В снапшоте мы сохраняем ID последнего изменения. А когда нужно запустить кэш, читаем снапшот, сразу применяем его в память, дальше читаем лог, начиная с последнего изменения в снапшоте, и применяем его поверх снапшота. Всё, кэш прогрет. Это делается так же быстро, как если бы мы читали из дампа. Итак, с холодным стартом мы разобрались, давайте теперь решать остальные проблемы в нашем списке.
Остальные три проблемы
У базы данных есть такое свойство, как durability, которое обеспечивается с помощью транзакций. В БД обычно хранятся горячие и холодные данные. По крайней мере, раз мы дошли до кэша, то у нас данные точно делятся на горячие и холодные. Обычно холодных данных очень много, а горячих очень мало. Так устроена жизнь. Мы реплицируем и шардируем базу данных на много-много копий и шардов только для того, чтобы обслуживать горячие данные. Мы можем сказать себе: «Зачем мы всё это копируем? Давайте шардировать только горячие данные». Но это никак не поможет, потому что мы должны использовать ровно такое же количество серверов, ибо шардируем и реплицируем не из-за того, что данные не помещаются в память или на диск, а из-за того, что мы упираемся в CPU. То есть база не успевает обрабатывать. Таким образом, шардинг и репликация только горячих данных не решает эту проблему. И босс всё ещё злится, потому что нужно платить за всё новые серверы.
Что можно сделать? У нас есть кэш, но горячие данные в базе не дают спокойно жить, мы доставляем их реплики и шарды. Однако кэш тоже хранит данные, как и база. При желании можно сделать в нём репликацию. Что нам мешает использовать кэш как первичный источник данных? Отсутствие такой фичи, как транзакции? Можем сделать и транзакции. Тем самым мы решаем остальные три проблемы, поскольку горячие данные можно будет вообще не хранить в базе, только в кэше. Шардинг тоже становится не нужен, ведь нам не придётся резать базу данных на много серверов, кэш успешно справляется с нагрузкой, в том числе и на запись. А на запись он справляется потому, что у кэша запись работает с журналом так же быстро, как и без журнала.
Итак, в кэш можно внедрить все свойства, которые присущи базе данных. Мы так и сделали, а получившееся в результате детище назвали Tarantool. По скорости работы на чтение и запись он сопоставим с кэшем, при этом имеет все свойства базы данных, которые нам необходимы. Таким образом, мы можем отказаться от базы для хранения горячих данных. Все проблемы решены.
Возможности и особенности Tarantool
Итак, эти многочисленные холодные данные мы реплицировали и шардировали только для того, чтобы обрабатывать малочисленные горячие. Теперь холодные данные, редко запрашиваемые и изменяемые, лежат в SQL, а горячие отправляются в Tarantool. То есть Tarantool — это база для горячих данных. В итоге для большинства задач двух инстансов (мастера и реплики) более чем достаточно. Хотя можно обойтись и одним, потому что паттерн доступа к нему и RPS такой же, как у обычного кэша, несмотря на то что это база данных. Для кого-то это проблема психологического свойства: как можно отказаться от базы как от авторитетного источника хранения данных с её уютным durable с транзакциями и уйти в кэш? На самом деле, начав использовать memcached или любой другой кэш, вы уже отказались от преимуществ базы данных. Вспомните про inconsistency и потерю апдейтов. И с этой точки зрения Tarantool не только ускоряет работу и позволяет экономить деньги, он возвращает вас обратно в мир баз данных с транзакциями, запросами, индексами и прочим.
Пара слов про параллельную работу транзакций. Когда в Tarantool используется Lua, он это рассматривает как одну транзакцию: все чтения делает из памяти, а все записи передаёт во временный буфер и в самом конце одним куском пишет на диск. И пока данные пишутся, другая транзакция уже может читать из памяти старые, незакомиченные данные без всяких блокировок! Очередь из транзакций может возникнуть лишь в том случае, если будет превышена пропускная способность последовательной записи на диск.
Как мы перекладываем с горячих на холодные
Этот процесс у нас пока не автоматизирован. Анализируем логи и определяем, что данные с таким-то паттерном можно считать горячими. Например, профили пользователей — горячие, значит, мы их перекладываем в Tarantool. Понятно, что попутно зацепим и холодные, потому что часть юзеров, например, на Почту уже не ходит. Но, несмотря на перерасход, это получается эффективнее, чем при использовании MySQL. Хотя бы потому, что у Tarantool очень сильно оптимизирован memory footprint. Очень интересный факт: БД SQL всё хранит на диске, но 10–20% должно кэшироваться в памяти. Но при этом у традиционных SQL БД footprint в три-пять раз хуже чем у Tarantool, поэтому эти 20% превращаются в 100%. Получается, что при аналогичной нагрузке SQL-сервер не выигрывает даже по памяти, хотя и нагрузку эту он не держит.
Tarantool против Redis
С нашей точки зрения, есть два ключевых отличия между Tarantool и Redis.
- По нашим тестам, Tarantool быстрее процентов на 30%. Результаты тестирования представлены на сайте Tarantool и в этой статье.
- Tarantool — это база данных. Там можно писать server side скрипты на Lua. У Redis тоже есть Lua, но он однопоточный, блокирующийся, писать свои скрипты можно, но область применения их весьма ограниченна. К тому же Lua в Redis не транзакционен. В этом смысле Tarantool идеален. Он и быстрее, и позволяет использовать транзакции везде, где нужно. Нет необходимости доставать key из кэша, апдейтить и класть обратно, когда параллельно кто-то ещё может менять.
Один миллион долларов
Эта сумма — не выдумка для привлекательного заголовка, а реально сэкономленные деньги в одном из проектов Mail.Ru Group. Нам нужно было где-то хранить профили пользователей. До этого они лежали в старом хранилище, и мы размышляли, куда бы их перенести. Изначально мы рассматривали MySQL. Развернули 16 реплик и шардов MySQL, начали потихоньку дублировать в них нагрузку от профилей на чтение и запись. Профили — это маленькие порции информации (от 500 байт до килобайта), хранящие ФИО, количество отправленных писем, различные флаги и сервисные данные, которые обычно нужны на каждой странице. Эти данные часто запрашиваются и обновляются. При 1/8 от всей нашей нагрузки ферма из 16 MySQL сломалась. И это после всех оптимизаций, которые мы там сделали. После этого мы решили попробовать Tarantool. Оказалось, что он на четырёх серверах спокойно держал нагрузку, которая до этого была распределена по 128 серверам. На самом деле даже на одном сервере держал, мы поставили четыре для подстраховки. А экономия в виде 128 серверов и снижения расходов на
И это лишь один случай. Tarantool нашёл применение во многих наших проектах. Например, в Почте и Облаке трудится 120 инстансов Tarantool. Если бы при имеющемся уровне нагрузок там использовался MySQL, то пришлось бы ставить десятки тысяч серверов или других SQL — PostgreSQL, Oracle, чего угодно. Даже трудно оценить, в какие миллионы долларов это бы вылилось. Мораль сей басни такова — для каждой задачи нужно подбирать правильный инструмент, это позволяет экономить огромные деньги на простейшей фиче. Холодные данные надо хранить в предназначенной для этого базе данных SQL, а горячие данные, которые часто запрашиваются и часто обновляются, нужно хранить в хранилище, адаптированном для этого, коим является Tarantool.
В версии 1.7, которая сейчас находится в разработке, мы хотим сделать полностью автоматическое кластерное решение с шардингом и с репликацией типа RAFT. Следите за обновлениями!
Автор: Mail.Ru Group