Страницы

Поиск по вопросам

четверг, 1 ноября 2018 г.

Redis, как кэш для SQL запросов в веб-приложении

Я впервые использую редис в веб-приложении. Изначально, цель была - кэшировать результаты сложных запросов, которых было не очень много, это не так сложно, и я подумал, почему бы не кэшировать всё? Ну или почти всё.
И так, я написал конструктор mysql-запросов, который всегда перед тем как выполнить запрос к бд, принимает необязательный параметр - ключ, по которому хранятся данные в redis. В итоге если такой ключ был передан, конструктор сначала посмотрит, есть ли данные по ключу в редисе, и если есть - то он вернет данные из редиса и к мускулу обращаться не будет, а если нет - то выполнит sql-запрос, вернет данные в контроллер и в соседнем потоке запишет их в редис (используя тот самый, переданный ключ). С запросами на выборку понятно, а вот если мы делаем insert/update, то мы удаляем из редиса все данные которые попали под маску переданного ключа, и тогда при новой выборке кэш обновится.
Все что попадает в редис, в основном хранится в виде JSON. И, вот настал тот ужасный момент, когда количество хранимых данных стало большим (а на проде, будет еще в over 9999 раз больше), и самое важное - много дублирующихся данных. Приведу простой пример. Мы заходим в раздел articles, загружаются посты которые находятся на первой странице (пусть их по умолчанию отображается по 50). В этот момент, контроллер сформировал ключ articles:list:page_1 по которому будет искать данные в редисе. Дальше походили по страницам, сформировали кэш для articles:list:page_2 articles:list:page_5 articles:list:page_19 articles:list:page_28 .... После чего, нажали на кнопку "Показать все статьи", тем обратились по ключу articles:list:all - понятно, что тут хранятся все записи (и я думаю - это ужасно!). Потом мы открыли несколько статей articles:post:id1234 articles:post:id567 articles:post:id890. Вы представьте сколько уже есть дублирующих данных в редисе, а если мы дадим пользователю выбирать кол-во выводимых записей, то тогда появятся такие ключи: articles:list:page_1_55 articles:list:page_3_55 и тд (_55 записей на странице). Это приведет к большой беде. Теперь дальше, если в админке мы отредактируем какой-то пост, а заголовок этого поста выводится в листинге, тогда мы удалим из кэша старые данные так articles:post:id890 и очистим весь листинг articles:list:* - что есть тоже очень плохо.
А что касается сложных запросов к бд, есть еще один случай, когда сам пост хранится в одной таблице, комменты к нему в другой, лайки в третьей и еще 5 таких зависимостей. Сейчас (без редиса) это вытягивается с помощью сложного запроса с JOIN'ами (допустим их 7 шт.). Это все можно сохранить в редис, но тогда если кто-то лайкнет пост, нужно инвалидировать кэш, и заново придется выполнять сложный запрос. Есть вариант разбить это на 7 простых запросов и сохранить каждый в редисе, и тогда если кто-то оставит коммент или лайкнет, то нужно будет только один простой запрос выполнить. Вот, как в такой ситуации было бы правильно поступить? (не будем говорить о тестах, будем рассуждать примерно, берем какие-то средние значения и среднестатичные случаи).
Что в этом случае вы посоветуете делать? Как правильно реализовать хранение данных в кэше? Возможно стоит пересмотреть способ хранения, может не хранить коллекции :list: целиком, а извлекать всегда какие-то части (элементы)? Я вот не могу в этом разобраться.
p.s. буду благодарен за ссылки на интересные статьи по теме (желат. на русском), и по настройке редис на сервере, да и вообще как можно больше про редис хочется узнать (не просто из постов на хабре, а что-то более подробное и доходчивое для человека который впервые с ним работает)


Ответ

Вы столкнулись с некоторыми вещами, которые не очень хорошо помещаются в тот мир, в котором вы работали раньше. В какой-то степени можно это назвать просто NoSQL, но это не совсем так, в любом случае могу поздравить с важным шагом в карьере.
Дублирование данных
Первое, о чем хотелось бы сказать:
самое важное - много дублирующихся данных
Дублирующиеся данные сами по себе нормальны. Нормализация базы данных SQL невольно учит нас тому, чтобы данные были в единичном экземпляре, но это, на самом деле, не аксиома. Дублирующиеся данные сложнее поддерживать, но само их наличие в целях улучшения работы приложения - это абсолютно нормально. Кроме этого, стоит вставить мою любимую ремарку про модель работы приложения: если сейчас у вас это де-факто pull-on-change - сформировать данные по запросу, то есть гораздо более интересная модель push-on-change - когда данные для запросов подготавливаются при добавлении, и в этом дублирование данных подразумевается само по себе. Представьте себе некую социальную сеть с лентой новостей. При стандартном подходе (и SQL-бэкенде) придется формировать гигантский SQL-запрос, который будет проверять необходимость показа каждой записи, наличие доступа к ней (мы же не хотим показывать приватные записи, верно?) и прочие атрибуты. Push-on-change же предлагает в этом случае в момент обновления записи в ленте вычислять, кому она должна быть показана, и формировать таблицу вида 'id пользователя | id записи | datetime', чтобы просто доставать из нее N первых записей, по которым уже формировать запрос по новостям. Данные в этом случае де-факто будут продублированы, но это необходимо, чтобы ускорить многократные read-операции за счет однократной write-операции. С переходом в хайлоад это становится особенно актуально, потому что один сервер не в состоянии хранить все данные и/или выдержать нагрузку, а поэтому необходимо разделять ответственность между серверами (что может поставить запрет на джойны, например). Однако,
Отказ от подготовки ответов на все запросы
геометрическая прогрессия, конечно, не позволяет подготовить заранее готовые ответы на все возможные запросы:
Вы представьте сколько уже есть дублирующих данных в редисе, а если мы дадим пользователю выбирать кол-во выводимых записей, то тогда появятся такие ключи: articles:list:page_1_55
поэтому от вышеприведенного примера действительно стоит отказаться - у Redis нет столько оперативки (как и от :all - тут проблема не в оперативке, а в количестве данных, которые надо будет передать по сети и затем разобрать). Конкретно в вашем случае есть две вещи, которые я считаю необходимым отметить
Текущий подход работает, де-факто, на кэширование конкретных запросов Redis используется как KeyValue-кэш
Первое подразумевает тот самый рост данных в геометрической прогрессии со степенью, зависящей от количества параметров, по которым может проводиться выборка. Вам не обязательно получать из Redis уже готовый ответ - вы можете получать его либо по частям, либо бОльшим куском. В случае со страницами по 55 записей вы можете просто хранить "верхушку" таблицы в редисе:
# пусть верхушка состоит из 500 записей from = 63 to = 63 + 55 if to < 500: top_entries = redis.get 'articles:top' # в редисе может не оказаться ничего или оказаться устаревший и слишком короткий список if top_entries && top_entries.length > to: return top_entries.slice from, to return article_repository.get_slice from, to
В этом случае приложение знает, что у него (возможно) есть кусман на 500 записей в редисе, и запрос, скорее всего, проще обслужить оттуда, поэтому пытается сделать именно это.
В случае со сложной иерархической структурой проще наоборот, разбить сущность на составляющие:
article = redis.get 'article:' + id if not article: article = article_repository.get id if not article: throw new ResourceNotFoundException comments = redis.get 'comments:by-article:' + id if not comments: comments = comment_repository.get_by_article id likes = redis.get '
В этом случае вы делаете из сущностей некоторое подобие строительного материала, который не слишком сильно дублируется и позволяет собирать различные результаты на основе одних и тех же источников (например, поиск по статьям в любом случае пойдет через БД, но лайки для них можно вытащить из Redis без особых затрат по времени). Это не самый богатый арсенал, но он поможет избежать избыточного дублирования, которое может возникнуть, если иерархические сущности кэшируются целиком, и, заодно, уменьшит развесистость операции, необходимой для обновления одной атомарной сущности (т.е. не придется сбрасывать половину кэша из-за одного проставленного лайка).
Закешировать всё
Кроме всего вышеописанного, я бы относился немного более практично к идее закешировать всё. Сейчас вы пытаетесь уменьшить нагрузку на сервис за счет кэширования, но переносите в Redis буквально вообще все, но это вам не нужно. До десятой страницы новостей доберется один читатель из сотни - вам действительно нужно оптимизировать ее быстродействие? В том случае, если она срендерится за 100 мс, а не за 10, пользователь это не почувствует, и сервер тоже не почувствует, потому что основная нагрузка у него идет на получение первых записей.
LazyLoad
Все вышеописанное только вскользь говорило о том, как данные попадают в Redis - что в случае добавления записи необходимо пересчитать специально подготовленную выборку. Однако мы знаем, что записи в любом in-memory кэш-сервисе (будь то Redis, Memcache, Aerospike) вечны максимум вплоть до первой перезагрузки машины, и даже подготовленные выборки в этом случае умрут. В этом случае поможет механизм ленивой загрузки - если грубо, то он пытается получить данные из некоего источника данных А, и, если не находит, загружает их из источника Б, кладет в А, и возвращает. В программировании этот подход часто применяется для инициализации тяжелых объектов и вызовов из БД, которые могут быть не нужны:
private heavy_object private settings
get_heavy_object(): if not heavy_object: heavy_object = new HeavyObject return heavy_object
get_settings(): if not settings: settings = read_settings_from_database() return settings
В случае с кэшем хранилищем А является не переменная, а кэш, а хранилищем Б - БД:
get_article(id): redis_id = 'article:' + id article = redis.get redis_id if not article: # не обнаружен в Redis - ищем в БД article = repository.get id if not article: # значит, он вообще не существует return null redis.put id, article # кладем в Redis для последующих вызовов return article
Если вы будете применять эту парадигму везде, то приложению можно будет хоть живьем подменить Redis, оно все равно будет работать. Кроме того - для меня это самый важный пункт - в этом случае можно спокойно инвалидировать существующий кэш, не боясь за само приложение (но, конечно, стоит иметь в виду резко возрастающую нагрузку на БД).
Инвалидация (и немного про CAP)
Следующий вопрос, который у вас так же возникает - это инвалидация данных, т.е., когда они должны исчезнуть из кэша или обновиться. Самый напроломный вариант - это обновление данных in-place, т.е., как только обновилась сущность, обновились и все связи в кэше. Однако, это плохой вариант - точнее, он отличный, но его невероятно сложно реализовать, не забыв про что-то и не раздув код до невероятных размеров. В случае, если кэш условно-бесконечен, и какой-то тип записей не обновляется, то все пользователи будут видеть устаревшие данных и спрашивать у вас, почему на разных страницах у одной и той же статьи разные комментарии. Тут на помощь приходят две стратегии инвалидации, которые есть в Redis:
LRU (Least Recently Used). При переполнении указанного в параметре max-memory размера Redis начнет удалять записи, которые не запрашивались дольше всех. Это гарантирует некоторую ротацию записей (при добавлении новых записей некоторые из старых будут удаляться, чтобы, при повторном запросе, быть обновленными из БД) TTL (Time To Live). Запись с указанным TTL может быть удалена по истечении этого TTL (может быть - в связи с проблемами в выполнении задачи не могу сказать, насколько вовремя это сделает Redis, но вряд ли задержка составит больше трех секунд). Именно это я и хочу предложить в качестве серебряной пули - делайте всем записям TTL в пределах 1-60 секунд, и устаревшие данные у вас гарантированно не продержатся дольше минуты, а благодаря lazy load воскреснут вновь.
Резюмируя, проще всего задавать небольшое время жизни записям, и сильно не беспокоиться об инвалидации, пока продукт не вышел на поддержку. В этом случае у вас страдает целостность данных (consistency), но в большинстве случаев она на самом деле не является критичной, более того, существует применимая к распределенным системам теорема CAP (NB: обсуждаемое в этом ответе приложение - это не распределенная система), которая (если очень грубо) говорит о том, что невозможно одновременно поддерживать доступность и согласованность данных - это просто некоторое свойство нашей вселенной, и небольшая задержка в обновлении данных чаще всего не только остается незамеченной, но и не всегда может быть замеченной (если у вас есть задержка в обновлении выдачи статей, то пользователь не ожидает получить новую в тот же момент, когда она была написана - он не знает момента написания).
Dogpile effect
В связи с параграфом про TTL и lazyload нельзя пропустить так называемый эффект собачьей стаи. С момента запроса обнаружения пустоты в кэше первым клиентом до момента его обновления проходит некоторое время X, за которое могут прийти еще N клиентов. Приложение в этом случае добросовестно попытается реконструировать кэш еще N раз. Единого решения у этой проблемы нет, но вы можете либо ставить лок на время обновления кэша (но если у вас приложение стоит более, чем на одном сервере, то придется использовать сервис, поддерживающий распределенные блокировки - например, consul, etcd или даже реализация с помощью самого Redis - см. Redlock), либо просто считать, что в SLA приложения добавлена возможность работать без кэша вообще (если за кэшем у вас скрывается один запрос для одной записи в БД, то почему бы и нет).
Поиск
И, наконец, самое веселое. Как объединить сам поиск и подготовку данных для него? В общем случае - никак, ищите по базе, вы не сможете запихнуть все необходимое для запроса в KV-хранилище. Но если вас интересует, как это решается в серьезных случаях (имеется в виду поиск по базе данных, а не уровня яндекса/гугла, конечно), то есть специализированные решения, например, Lucene (и построенный на его основе ElasticSearch), Solr, Sphinx, YoctoDB. В общем случае поиск сводится к построению индексов из полей документа, поиску по этим индексам и агрегированию результата.
Микросервисная архитектура
Предложенные мной решения по разбиению на building blocks так или иначе приведут к появлению в приложении менеджеров каждой сущности, каждый из которых заведует отдельной сущностью. Хочется сказать, что это разбиение может продолжиться и вне приложения - приложение можно разбить на отдельные приложения, каждое из которых будет заниматься своим доменом. Это так же убьет всякую возможность джойнов, но, на самом деле, она и не нужна. Если пользователи у вас лежат в одном приложении на одном кластере, а статьи, в которых автор указан в виде идентификатор - в другом приложении на другом кластере, то вряд ли вам потребуется искать все статьи, в имени автора которого стоит "Андрей".
Как обо всем позаботиться?
Скорее всего, через весь ответ так и сквозят вопросы вроде "какой TTL ставить", "нужно ли кэшировать Х" и "какйо прирост производительности я получу". Ответ простой - это вообще никак не узнать, это выясняется только на практике. Деплойте приложение, анализируйте статистику, и через некоторое время вы просто поймете, где оно подтормаживает. Если оно не подтормаживает в месте, где вы забыли поставить кэш - возможно, там и нет смертельной необходимости в нем, и к этому месту нужно будет вернуться, если вообще не останется других задач?
Просто пара слов про Redis
Во-первых просто хотелось сказать, что кроме Redis есть еще сервисы, выполняющие те же функции (мой любимый - Aerospike). Redis довольно прост в использовании, но не умеет, например, шардиться, у него были проблемы с фрагментацией памяти (порог в 100 мб теоретически имел право забрать до 200 мб оперативки), он однопоточный и в мире NoSQL довольно похож на MySQL в мире баз данных. Тем не менее, с небольшим приложением по меркам интернет-гигантов он наверняка справится без особых проблем.

Комментариев нет:

Отправить комментарий