caching-is-hard/

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

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

Рассмотрим типичный сценарий. Есть больше одного бэкенда. Есть база данных. На практике баз часто несколько и у них еще бывают реплики. В рамках этой задачи представим, что база одна, так как тут многое зависит от специфики БД. Есть несколько серверов 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-кластер в Амазоне

EnglishRussianUkrainian