Это вторая статья об инфраструктуре интегрированного кэша для Docstore — нашего онлайн-хранилища в Uber. В предыдущем материале мы представили CacheFront и описали его основную функциональность, принципиальные решения и архитектуру. В этой статье мы делимся улучшениями, которые реализовали с тех пор, масштабировав его применение почти в 4 раза.
Краткое напоминание о CacheFront
В предыдущей статье подробно разобрана общая архитектура Docstore. Для текущего материала достаточно помнить, что CacheFront реализован в нашем слое движка запросов без состояния, который взаимодействует с узлами движка хранения, сохраняющими состояние, чтобы обслуживать все запросы на чтение и запись.

Чтения
Запросы на чтение перехватываются на уровне движка запросов: сначала мы пытаемся получить строки из Redis™. Строки, которых нет в Redis, читаются напрямую из движка хранения и затем записываются в кэш, чтобы поддерживать его прогретым. Результаты чтения объединяются и потоково возвращаются клиенту.
Записи
В Docstore поддерживаются два типа обновлений:
Точечные записи: запросы INSERT, UPDATE, DELETE, которые изменяют заранее определённый набор строк, указанный в аргументах самого DML-запроса.
Условные обновления: запросы UPDATE или DELETE с предложением WHERE, где в зависимости от условия может быть изменена одна или несколько строк.
Записи вообще не перехватываются, в первую очередь из-за наличия условных обновлений. Не зная, какие строки будут изменены или уже изменены в результате выполнения DML-запроса, невозможно понять, какие записи кэша требуется инвалидировать.
Ждать, пока записи исчезнут из Redis по истечении времени жизни (TTL), оказалось недостаточно. Поэтому мы также полагаемся на Flux — наш сервис без состояния (stateless) захвата изменений данных (CDC), который, среди прочего, считывает MySQL-бинлоги, чтобы определить, какие строки были обновлены, и асинхронно инвалидирует либо наполняет кэш Redis, обычно с задержкой менее одной секунды. При инвалидировании Flux не удаляет данные из Redis, а записывает специальные маркеры инвалидации, заменяя любые значения, уже находящиеся в кэше.
Проблемы
По мере роста масштаба внедрения CacheFront стало очевидно, что запрос на более высокий уровень попаданий в кэш и более строгие гарантии консистентности только увеличивается. Модель конечной согласованности (eventual consistency), возникающая при использовании TTL и CDC для инвалидации кэша, в ряде случаев становилась препятствием для внедрения. Кроме того, чтобы сделать кэширование эффективнее, инженеры стремились увеличивать TTL, продлевая «жизнь» строк в кэше. В результате из кэша чаще отдавались устаревшие/неконсистентные значения, что, разумеется, не устраивало владельцев сервисов.
Эти сложности стары как само кэширование. Повторим из предыдущей статьи:
«В информатике есть только две действительно сложные вещи: инвалидация кэша и придумывание имён.»
— Фил Карлтон
Недавние улучшения CacheFront позволили нам доказать, что Фил не совсем прав — возможно преуспеть в обоих. В этой статье читайте про наш путь.
Причины неконсистентности
В CacheFront неконсистентность проявляется как «черствые» значения (stale values), когда из кэша возвращается устаревшее значение — то есть его метка времени старше, чем у значения, которое можно было бы прочитать из базовой базы данных.
Есть несколько возможных причин появления устаревших значений:
Гонки при заполнении кэша. При одновременных чтениях и записях одной и той же строки разные воркеры движка запросов могут попытаться закэшировать разные значения (в зависимости от того, как именно во времени перемежаются эти чтения и записи). В CacheFront это не проблема: мы используем Lua-скрипт, чтобы дедуплицировать новые записи в Redis с тем, что уже находится в кэше. В предыдущей статье подробно объясняется, как мы избегаем кэширования устаревших значений с помощью меток времени строк.
Задержки инвалидации кэша. Поскольку «хвостовой» компонент Flux работает асинхронно, инвалидация/наполнение кэша неизбежно выполняются с некоторой задержкой. Запись в базу данных, за которой быстро следует чтение из кэша, может вернуть предыдущее закэшированное значение, нарушив желаемую гарантию «read-your-writes» («читать свои записи»). Как только Flux «догоняет», он перезаписывает устаревшее значение и исправляет ситуацию. Неконсистентность может держаться дольше, если Flux перезапускается — из-за развёртываний, ребалансировки воркеров или повторных подключений к потокам при изменениях топологии хранилища.
Сбои инвалидации кэша. Узел Redis может на короткое время «тормозить», быть недоступным или не отвечать — тогда инвалидация не проходит даже после нескольких попыток. Кэш остаётся несогласованным с базой до тех пор, пока строка не будет окончательно удалена по TTL. Чем выше TTL, тем дольше сохраняется неконсистентность и тем дольше могут отдаваться устаревшие значения.
Наполнение кэша с ведомых узлов. Если при промахе кэша (cache miss) мы наполняем кэш, читая строку с отстающего ведомого узла, который ещё не применил последние записи ведущего, в кэш может попасть устаревшее значение. В CacheFront можно выбирать политику наполнения кэша, балансируя между:
Наполнением с ведущего узла ради более строгих гарантий консистентности,
Наполнением с ведомых узлов, разгружая ведущий, что улучшает распределение нагрузки на чтение.
Независимо от причины, неконсистентность обычно проявляется как:
Неконсистентность чтения собственных записей (read-your-writes): строка, которую прочитали, закэшировали, а затем перезаписали, на последующих чтениях может по-прежнему возвращать старое устаревшее значение — до тех пор, пока запись в кэше не будет инвалидирована или не истечёт её TTL.
Неконсистентность чтения собственных вставок (read-your-inserts): аналогично, при включённом «негативном кэшировании» (то есть кэшировании факта отсутствия строки в базовой БД) пока в кэше присутствует отрицательная запись, чтения будут возвращать «не найдено» для этой строки, что может нарушать предположения бизнес-логики сервиса. Такой тип неконсистентности обычно заметнее для владельцев сервисов.
Масштаб устаревания кэша
К этому моменту, вероятно, очевидно, что длительность неконсистентности, которую могут наблюдать вышестоящие клиенты, всегда ограничена выбранным значением TTL, а Flux лишь помогает её сократить. Поэтому по умолчанию при подключении к CacheFront мы рекомендуем TTL всего 5 минут. Исключения возможны при наличии веских оснований, и обычно мы оставляем это решение за владельцами сервисов, поскольку «черствость» напрямую влияет на бизнес. Желаемое значение TTL для пополнения кэша при промахе можно указать в необязательном заголовке при выполнении запросов чтения к Docstore.
Но в случае с отказами инвалидации кэша есть более тонкая и, вероятно, недооценённая проблема. Даже если выбран короткий TTL, можно наблюдать очень высокий уровень «черствости», если измерять его как разницу между меткой времени строки в базе данных и меткой времени соответствующей строки, хранящейся сейчас в кэше. Фактически эта разница теоретически не ограничена… Почему?
Представьте запись некоторой строки в базу данных, сделанную целый год назад. Затем в момент T (то есть сейчас) выполняется операция «чтение-модификация-запись», и, поскольку начальное чтение привело к промаху кэша, строка читается из базы и после этого попадает в кэш. Теперь предположим, что при записи модифицированной строки обратно в базу последующая инвалидация Flux не сработала. Если ту же строку снова прочитать в интервале [T, T+TTL], на каждый запрос будет возвращаться значение годичной давности. Если, стремясь повысить hit-rate кэша, владелец сервиса увеличил TTL, скажем, до одного часа, мы рискуем целый следующий час отдавать значение, которому уже год! Действительно неограниченно.

Изменения в потоке условных обновлений
Напомним: главной причиной, по которой мы не могли инвалидировать кэш синхронно вместе с запросами на запись, были условные обновления — невозможно было знать, какие строки были изменены в транзакции и требовали инвалидации. В результате постепенных и непрерывных улучшений слоя нашего движка хранения за последние годы мы модифицировали процесс обработки записей так, чтобы он мог возвращать фактический набор строк, обновлённых в рамках каждой транзакции.
Два принципа проектирования, которые позволили это сделать:
Во-первых, мы обеспечили, чтобы все удаления были «мягкими» (soft delete): на строке выставляется метка удаления (tombstone). Такие строки с tombstone затем удаляются асинхронной фоновой задачей.
Во-вторых, мы перешли на строго монотонное время для назначения сеансовых меток времени MySQL® с точностью до микросекунд, делая их уникальными в пределах узла хранения и группы Raft, к которой он относится.
Теперь, имея гарантию, что даже удалённые строки остаются внутренне видимыми, и что каждую транзакцию можно однозначно идентифицировать по её сеансовой метке времени, мы можем выбрать все строки, которые были обновлены в этой транзакции. При обновлении строк мы записываем в их столбец метки обновления текущую, уникальную и монотонную метку времени транзакции. Затем, непосредственно перед выполнением COMMIT, мы считываем все ключи строк, которые были изменены в рамках транзакции (включая удаления, которые выполняются обновлением поля tombstone, а не физическим удалением строки). Это, как правило, очень лёгкий запрос, поскольку к этому моменту данные всё равно уже закэшированы в движке хранения MySQL, и вдобавок мы всегда поддерживаем индекс по столбцу метки времени во всех наших таблицах:

Этот подход позволил нам точно определить, какие строки изменились в рамках условных обновлений, и вернуть их ключи обратно в движок запросов. Приведённый выше пример — немного упрощённая версия реального запроса. Есть немного «секретного соуса», который позволяет объединять несколько разных типов запросов на обновление и удаление в одной инициируемой на стороне клиента транзакции внутри шарда, но общий принцип тот же.
Отметим, что для точечных записей такая логика не нужна: ключи строк, которые предстоит обновить, и так известны из аргументов запроса на обновление, поэтому мы просто возвращаем их как есть.
Улучшение логики инвалидации кэша
Теперь, когда мы могли определить, какие строки были изменены в каждой транзакции записи, мы смогли улучшить логику инвалидации кэша в CacheFront. Мы перехватили каждый API записи на уровне движка запросов, зарегистрировав обратный вызов, который срабатывает при возвращении ответа от движка хранения. В состав ответа на запрос записи мы добавили набор ключей строк, затронутых транзакцией, а также связанную с ней сеансовую метку времени. Из этого обратного вызова мы теперь могли инвалидировать любые ранее закэшированные записи в Redis, перезаписывая их маркерами инвалидации.
В фоне мы по-прежнему держим запущенным Flux, который читает MySQL-бинлоги и асинхронно наполняет кэш. Наличие трёх способов пополнения и/или инвалидации кэша — через истечение TTL, через «хвост» Flux и теперь ещё напрямую по пути записи на уровне движка запросов — оказалось лучшим подходом с точки зрения консистентности.

Есть несколько моментов касательно нового потока инвалидации, о которых стоит рассказать. Инвалидация может выполняться синхронно или асинхронно. Синхронная инвалидация означает, что кэш инвалидируется в контексте самого запроса — до возврата статуса вызывающему клиенту. Это немного увеличивает задержку операций записи, но лучше подходит для сценариев чтения собственных записей и собственных вставок. Обратите внимание: если запрос записи завершился успешно, но соответствующая инвалидация кэша не удалась, мы всё равно возвращаем клиенту успех (то есть не проваливаем операцию записи).
Асинхронная инвалидация означает, что запросы на инвалидацию ставятся в очередь и выполняются вне контекста клиентского запроса. Это позволяет избежать дополнительной задержки при операциях записи, но обеспечивает немного более слабые гарантии консистентности.
Возвращая также из движка хранения сеансовую метку времени фиксации транзакции (commit timestamp), мы смогли корректно дедуплицировать записи наполнения кэша и маркеры инвалидации, которые генерируются соответственно движком запросов и «хвостовым» компонентом Flux (tailer). Кроме того, это позволило объявить устаревшим и полностью убрать специализированный API, упомянутый в предыдущей статье, который раньше рекомендовался для явной инвалидации кэша при точечных записях. Поскольку метки времени инвалидации создавались искусственно по текущим системным часам, последующие попытки наполнения кэша не проходили, вызывая больше промахов, чем ожидалось, вплоть до истечения маркеров инвалидации в кэше. Использование корректных меток времени строк, генерируемых базой данных на уровне движка хранения, оказалось критически важным для повышения доли попаданий в кэш. Вдобавок автоматическая инвалидация кэша при любом типе записи, без необходимости когда-либо делать это вручную, серьёзно повысила удобство эксплуатации.
Наконец, стоит отметить, что кэширование больше подходит для сценариев с преобладанием чтений, где соотношение чтений к записям обычно 20 и даже 100 к 1. Дополнительные инвалидации кэша, которые мы добавили, инициируются именно потоком записей (QPS — операций в секунду по записям) и потому не привели к существенному росту общей нагрузки; а там, где это было нужно, мы просто масштабировали слой движка запросов и/или кластеры Redis, чтобы поглотить дополнительную нагрузку.
Инспектор кэша (Cache Inspector)
«Нельзя улучшить то, что нельзя измерить».
— Питер Друкер
Чтобы измерять текущую «черствость» кэша, количественно оценивать улучшения, находить потенциальные баги и понимать, можно ли дальше увеличивать TTL, мы построили систему под названием Cache Inspector. Она основана на том же конвейере CDC — Flux — который читает те же MySQL-бинлоги с искусственной задержкой в одну минуту (чтобы после записей система успевала стабилизироваться). Вместо инвалидации или наполнения кэша этот «хвостовой» компонент сравнивает значения из событий бинлога с теми, что сейчас лежат в кэше. Затем он экспортирует метрики: количество проверенных записей, число обнаруженных устаревших записей, долю несовпадений по каждой таблице, гистограмму распределения степени устаревания и многое другое.

Как видно на изображении выше, число обнаруженных за неделю устаревших значений ничтожно по сравнению с общим количеством строк, записанных в таблицу или прочитанных из таблицы orderability_features_ping. Новый поток инвалидации кэша дал нам значительно более строгие гарантии консистентности, а добавление Cache Inspector позволило измерять и сравнивать эффективность. Это, в свою очередь, дало возможность повысить TTL для этой таблицы до 24 часов, подняв долю попаданий в кэш выше 99,9%.
Заключение
На сегодняшний день, в пиковые часы, CacheFront обслуживает более 150 миллионов строк в секунду. За годы непрерывных улучшений мы добавили в CacheFront множество возможностей, сделав систему лучше и надёжнее:
Адаптивные тайм-ауты, негативное кэширование, конвейерные чтения — для снижения задержек
Шардинг, межрегиональная репликация и прогрев кэша — для повышения устойчивости
Скрипты Lua, TTL и инвалидация через конвейер CDC — для улучшения консистентности
Режим сравнения кэша и Cache Inspector — для наблюдения и измерения «черствости»
Предохранители (circuit breakers) — для работы с нездоровыми узлами
Ограничители скорости установления соединений — для предотвращения «штормов» соединений
Сжатие — для уменьшения объёма памяти, сетевого трафика и загрузки CPU
С добавлением автоматической инвалидации кэша при записях, усиливающей гарантии консистентности CacheFront, мы считаем, что достигли по-настоящему передовой интегрированной инфраструктуры кэширования в Uber.

Чтобы не останавливаться на теории Uber, отработать её руками можно на курсе «Инфраструктура высоконагруженных систем». Кластеризация и оркестрация (Kubernetes, Nomad, Pacemaker), дисковые кластеры (Ceph, Gluster, Linstore), периметр и балансировка на nginx — с упором на выбор технологий под SLA и практику на стендах. Пройдите вступительный тест, чтобы понять, подойдет ли вам программа курса.
Также всех желающих приглашаем на открытые уроки, которые бесплатно проведут преподаватели курса:
28 октября: Отказ��устойчивое хранилище DRBD (Distributed Replicated Storage System). Записаться
11 ноября: PostgreSQL без простоев: создаём отказоустойчивый кластер на Patroni и etcd. Записаться
20 ноября: Настройка Nginx/Angie для высоких нагрузок и защиты от DoS-атак. Записаться