Мини-учебнег по распределенным системам/протоколам. Stateless+Shared DB+Cache

Sep 01, 2017 21:40

Дополним Stateless схему кэшированием. Вообще, кэширование применяется везде, есть много их всяких видов, в частности есть целая куча библиотек кэширования. Нужно уточнить о чем идет речь. В данном посте я буду вести речь прежде всего о кэшировании, которое разгружает Shared DB в контексте стейтлесс сервера. Т.е. вообще говоря, это паттерн не только для веп-систем. Хотя есть еще специфические для веба кэши, которые вобщем тоже разгружают веб-сервер, а косвенно и БД. Но пока именно о кэшах типа memcached, ибо это есть популярная схема.

Итак, для упрощения масштабирования http layer'а (или еще из каких соображений) мы сделали web сервера стейтлесс, и все состояние храним в БД. И мы столкнулись с тем, что схема перестала масштабироваться - БД стала боттлнеком. И мы хотим ввести кэширование, чтобы разгрузить БД.

Отметим, что стейтлесс подход может диктоваться выбором языка программирования. У многих языков есть проблемы с многопоточностью внутри одного процесса - они там любят захватывать Global Interpreter Lock и все такое. Т.е. для того чтобы воспользоваться преимуществом многоядерности, нужно по сути запускать несколько копий процесса. Но тогда у каждого процесса будет своя память. Если процессам нужно коммуницировать через общую память, то у нас будут проблемы. Таким образом, нужно использовать какой-то внешний процесс для хранения разделяемой памяти. И в языках с GIL этого не избежать, ибо если мы используем потоки в рамках одного процесса/интепретатора, то из-за GIL это мало что дает.

Итак, в стейтлесс подходе нам нужно использовать внешнюю память для коммуникации. И это может быть БД, либо внешняя память, либо какие-то кэши, либо комбинация оных. Отметим разницу между кэшем и внешней памятью. Тут стоить отметить, что для организации внешней разделяемой память может использоваться библиотека кэширования (какой-нить там in memory data grid). Посему важно понимать различие. При кэшировании мы держим копию части "истинных данных" в памяти, чтобы ускорить к ним доступ. Т.е. данных в кэше может не быть, и тогда нам нужно лезть на диск или еще куда-то (может посчитать). При этом данные из кэша могут пропадать - вытесняться другими данными, или там по таймауту. Т.е. кэш - это частичная копия чего-то. В то же время, память не может просто так взять, да и исчезнуть (конечно может накрыться комп, или там даже все реплики, но в случае дисковой памяти такое тоже может произойти). По сути, если мы используем библиотеку кэширования, и запрещаяем вытеснение данных из кэша, ну и возможно настраиваем репликацию, персистенс, то получаем уже типа внешнюю разделяемую память.

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

Рассмотрим типовый паттерн доступа к кэшу при чтении данных

value = memcahed_fetch(key)
if !value
value = db_select(...)
memcached_set(key, value, timeout)

При таком подходе, запросы на чтение будут получать данные из кэша, пока не истечет timeout. Если эту схему не дополнять обновлением/инвалидацией кэша при записи состояния в БД, то у нас может быть рассогласование кэша и БД, пока не истечет timeout и данные будут выкинуты из кэша. Часто такое рассогласование не принципиально, т.е. какие-то вещи в пользовательском интерфейсе могут обновляться с задержкой, раз в сколько-то минут. А в среднем будет ускорение, ибо запросы будут часто обслуживаться из более быстрой памяти. Отметим также, что тут могут быть несколько конкурентных записей в кэш, если два и более процесса одновременно обнаружили отсутствие записи в кэше, полезли в БД, ну и потом обновили кэш. Если результат исполнения запросов в БД одинаков, то это не проблема. А вот если запросы в БД разные, то может быть рассогласование, ибо в кэше будет результат только одного апдейта, но не факт, что он будет соответствовать последнему результату из БД.

К примеру, первый процесс обнаруживает отсутствие записи в кэше, запрашивает БД и получает версию 1, второй так же обнаруживает отсутствие записи, запрашивает БД и получает версию 2 (более позднюю) ибо кто-то успел поменять данные. Соответственно, оба процесса пытаются записать данные в кэш, но порядок не гарантирован, т.е. второй процесс может записать версию 2, а потом первый перезапишет, и в кэше будет версия 1. Т.е. код исполняется не атомарно со всеми вытекающими последствиями. В принципе, это не всегда есть проблема, но это может представлять проблему.

Рассмотрим теперь код для апдейта

if db_update(...)
value = db_select(...)
memcached_set(key,value)

Таким образом, мы обновляем кэш, так что обновленное значение данных там появится раньше нежели данные выкинуться из кэша по таймауту. Однако, тут также возможна проблема с конкурентным апдейтом в БД/кэш, т.е. между апдейтом БД и апдейтом кэша может вклиниться другой writer и данные в кэше могут оказаться рассогласованными с БД. Даже если writer всего один, то write может вклиниться между чтением из БД и апдейтом кэша, которое делает reader процесс (смотри код выше).

Чтобы избежать этих проблем, нам нужно либо организовывать распределенные критические секции, блокировки, ну или там использовать версии данных. Т.е. код заметно (иногда сильно) усложняется. Например, в случае memcached API мы можем использовать операции add вместо set, в случае add у нас будет ошибка, если данные в кэше уже есть. К примеру

value = memcached_fetch(key)
if !value
# cache miss
value = db_select(...)
if !memcached_add(key, value, timeout)
# oops, someone else has been faster
value = memcached_fetch(key) # can fail too actually in some circumstances

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

Для апдейта БД можно написать похожий код используя CAS. Т.е. типа сперва смотрим текущую версию в кэше и обновляем, только если версия не изменилась. Иначе повторяем попытку (если в кэше ничего нет, то добавляем с помощью add). Код получается достаточно громоздким для кэша.

Вобщем проблемы можно решать, но это выходит за рамки данного поста. Сделаем вывод в целом - кэши помогают разгрузить БД в случае stateless/shared DB подхода, ибо операции на чтение будут часто брать данные из памяти. Но за это надо платить рассогласованием значений в кэше и БД, или более сложными замороками по реализации критических секций. Отмечу, что это может быть сильно сложнее нежели в случае с многопоточностью, ибо кэширующий сервер может отвалиться. Что может быть особенно проблематично в случае с реализацией блокировок через кэш, ибо означает невозможность получить блокировку.

Вобщем основной вывод такой - кэш надо использовать как кэш, и крайне осторожно использовать как распределенную память. Если нужна именно распределенная разделяемая память (например, если мы хотим координировать процессы), то лучше использовать соответствующие библиотеки, которые позволяют настроить репликацию/восстановление после сбоев. Но это опять-таки отдельная тема.
Previous post Next post
Up