Почему сложно сделать правильное кэширование

13 мая 2015

Вы, наверняка, знаете, как это бывает. Ой, у нас тут такие тяжелые вычисления / так долго тянутся данные из базы. А давайте просто прикрутим кэшик. Что может пойти не так? Так вот, опыт показывает, что пойти не так может очень и очень многое.

Постановка задачи

Рассмотрим типичный сценарий. Есть больше одного бэкенда. Есть база данных. На практике баз часто несколько и у них еще бывают реплики. В рамках этой задачи представим, что база одна, так как тут многое зависит от специфики БД. Есть несколько серверов Memcached или Redis. Для определенности далее будем говорить про Memcached. Некоторые запросы выполняются слишком долго, чтобы постоянно слать их в базу, поэтому их нужно кэшировать. На каком кэш-сервере какой ключ искать определяется, например, алгоритмом Ketama. Следует отметить, что определять сервер просто по хэшу от ключа очень плохо, потому что такое решение приводит к практически полной инвалидации кэша при решардинге.

Распределенные кэши в Akka Cluster, которые мы недавно рассматривали, как оказывается, в ряде граничных случаев сводятся к описанной выше проблеме. Во-первых, после перераскладки серверов возникает проблема холодных кэшей. В связи с этим возникает желание дублировать содержимое in-memory кэшей во внешнем Memcached и считывать это содержимое при старте. Во-вторых, при изменении состава кластера какое-то время разные узлы ищут один и тот же кэш в разных местах. В итоге получаем те же несколько бэкендов, которые читают и пишут в Memcached по одному ключу.

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

Почему не получается все сделать правильно

Ниже представлен не претендующий на полноту список возникающих проблем:

  1. Параллельная запись. Несколько бэкендов одновременно пишут значение по одному ключу. Получаем состояние гонки и испорченные данные. К счастью, эта проблема легко решается, если кэш-сервер поддерживает CAS. Кроме того, у вас нет такой проблемы, если данные в кэше всегда неизменяемы.
  2. Нетсплиты и падения серверов. Падение одного или нескольких серверов можно с большой точностью считать частным случаем нетсплита. Вы можете сделать возникновение нетсплита очень маловероятными, например, если будете физически дублировать все сетевые соединения. Но это сложно, дорого, и требует своего ДЦ. PACELC, более известный, как «CAP-теорема», говорит нам, что в случае нетсплита мы должны выбирать между консистентностью и доступностью. То есть, если вы не можете достучаться до кэш-сервера, то либо отдаете пользователю ерунду (скажем, последнее значение из памяти или какой-то дэфолт), либо говорите «простите, сервис недоступен». В случае, например, с котировками в системе торговли ценными бумагами, строго говоря, ничто из этого не является допустимым. Конкретно в случае с кэшами можно еще и сходить в базу напрямую, но это медленно и может положить базу под увеличенной нагрузкой.
  3. Поднятие и опускание кэш-серверов. В этом случае какое-то время разные бэкенды ходят по одному ключу на разные кэш-сервера. Вы можете получать список кэш-серверов перед каждой операцией из ZooKeeper, Consul или etcd, но это не устранит гонку полностью. Можно дождаться минимальной нагрузки, зафаерволить все кэш-сервера, сведя проблему к предыдущей, обновить список, затем сделать кэш-сервера доступными. Но не в каждом приложении такое возможно. Плюс при частом изменении состава кэш-кластера есть шанс считать по ключу очень-очень старое значение, записанное еще до изменения состава. На данный момент мне неизвестно на 100% рабочее решение этой проблемы.
  4. Разъезжание базы и кэшей. Допустим, вы реализовали следующий алгоритм, который предусматривает многое. При чтении (1) проверяем кэш, если что-то есть — возвращаем, иначе запоминаем токен для CAS, (2) читаем из базы (3) пишем в кэш, и если CAS не проходит, goto 1. При записи (1) инвалидируем кэш и запоминаем токен, иначе если приложение упадет после записи в БД, все разъедется (2) пишем в БД (3) обновляем кэш, если конфликт, goto 4, иначе ОК (4) считать данные из БД, goto 3. Но даже тут есть гонка. Допустим, мы делаем запись и падаем после шага 2. Параллельно с записью в базу данных происходило чтение. В результате в кэше оказались старые данные, которые будут там до следующей записи или очистки кэша по TTL. Для полноценного решения проблемы, насколько я понимаю, нужна некая разновидность распределенных транзакций.
  5. Самая главная проблема. Все это должны поддерживать люди, понимая, как все работает, вдумчиво тестируя и не плодя багов при внесении в код изменений. Разумеется, все это в условиях авралов и дэдлайнов. К тому же, вдумчивых и понимающих людей нужно еще где-то найти и убедить работать именно у вас.

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

Варианты тупых, но рабочих решений

На ум приходят следующие возможные варианты решений:

  1. Никогда не хранить данные в кэше дольше 1-2 минут. Тогда по любому ключу можно записать любую ерунду, через какое-то время все само себя восстановит. Плюс таймауты на чтение из кэша. Если не удалось считать, идем в базу или возвращаем ошибку, по вкусу. Такая схема должна отлично работать в вебе и подобных системах.
  2. Не использовать кэши. Использовать БД, которая умеет кэшировать горячие данные в памяти и выделить этой самой памяти побольше. Те же PostgreSQL, MySQL и MongoDB вполне это умеют. В теории должно быть хорошо. На практике строить системы совсем без кэшей я лично не пробовал. Если у кого-нибудь есть опыт, поделитесь!
  3. Какая-нибудь Lambda или Kappa архитектура, в которых все события пишутся только в лог, из которого потом фактически строиться materialized view. Увы, этот подход довльно трудно внедрить, когда проект разрабатывается не с нуля.

Если есть варианты получше, предлагайте.

Вердикт

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

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

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

Дополнение: Как я поднимал Couchbase-кластер в Амазоне

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.