Створення in-memory кешу першого рівня .NET-клієнтів StackExchange.Redis

Джонатан Карді написав .NET бібліотеку StackRedis.L1 з відкритим вихідним кодом, яка дозволяє створювати кеш першого рівня для Redis. Іншими словами, використовуючи бібліотеку StackExchange.Redis в .NET-додатку, ви можете підключити до неї StackRedis.L1 для прискорення роботи за рахунок локального кешування даних в оперативній пам'яті. Це дозволяє уникнути зайвих звернень до Redis в тих випадках, коли дані не піддавалися змінам. Бібліотека доступна на GitHub і NuGet.
У цій статті розповідається про те, як і чому вона була створена.



Передісторія

Останні пару років я працював над додатком Repsor custodian під управлінням SharePoint. Якщо ви знайомі з SharePoint, то знаєте, що її модель вимагає запускати програми на окремому сервері. Це дозволяє підвищити стабільність і спростити архітектуру. Тим не менш, дана модель працює на шкоду продуктивності, так як вам доводиться щоразу звертатися за відомостями до сервера SharePoint, і затримки в мережі в даному випадку грають не на вашу користь.

З цієї причини ми вирішили додати в додаток Redis-кешування, використовуючи StackExchange.Redis в якості .NET-клієнта. Для повного кешування всіх моделей даних програми ми використовуємо безлічі, впорядковані множини і хеш-таблиці.
Як і слід було очікувати, це значно прискорило роботу додатка. Тепер сторінки поверталися приблизно за 500 мс замість 2, як раніше. Але аналіз показав, що навіть з цих 500 мс чимала частина часу йшла на надсилання або отримання даних від Redis. Більше того, основна частина даних, одержуваних кожен раз за запитом зі сторінки, не піддавалася змінам.

Кешування кешу

Redis – це не тільки неймовірно швидкий засіб кешування, але і набір інструментів для управління кэшированными даними. Ця система добре розвинена і дуже широко підтримується. Бібліотека StackExchange.Redis безкоштовна і має відкритий вихідний код, а її співтовариство активно зростає. Stack Exchange агресивно кешує всі дані Redis, притому що це один з найбільш завантажених сайтів в інтернеті. Але головним козирем є те, що вона виконує in-memory кешування всіх доступних даних на сервері Redis і, таким чином, їй зазвичай навіть не доводиться звертатися до Redis.

Наступна цитата в деякій мірі пояснює принцип роботи механізму кешування в Stack Exchange:
http://meta.stackexchange.com/questions/69164/does-stack-exchange-use-caching-and-if-so-how

«Безсумнівно, самий оптимальний обсяг даних, який можна швидше відправити по мережі, – це 0 байт».


Коли ви кэшируете дані перш, ніж вони потрапляють в інший кеш, ви створюєте кілька рівнів кеша. Якщо ви виконуєте in-memory кешування даних перед їх кешуванням в Redis, in-memory кешу присвоюється перший рівень L1.

Тому, якщо Redis – сама повільна частина вашої програми, ви на правильному шляху і безумовно зможете прискорити цей недолік надалі.

In-memory кешування

В найпростішій ситуації при використанні Redis ваш код буде виглядати приблизно так:

//Try and retrieve from Redis
RedisValue redisValue = _cacheDatabase.StringGet(key);
if(redisValue.HasValue)
{
return redisValue; //it's in Redis - return it
}
else
{
string strValue = GetValueFromDataSource(); //Get the value from eg. SharePoint or Database etc
_cacheDatabase.StringSet(key, strValue); //Add to Redis
return strValue;
}


А якщо ви вирішите застосувати in-memory кешування (тобто L1-кеш), код трохи ускладниться:

//Try and retrieve from memory
if (_memoryCache.ContainsKey(key))
{
return key;
}
else
{
//It isn't in memory. Try and retrieve from Redis
RedisValue redisValue = _cacheDatabase.StringGet(key);
if (redisValue.HasValue)
{
//Add to cache memory
_memoryCache.Add(key, redisValue);

return redisValue; //it's in redis - return it
}
else
{
string strValue = GetValueFromDataSource(); //Get the value from eg. SharePoint or Database etc
_cacheDatabase.StringSet(key, strValue); //Add to Redis
_memoryCache.Add(key, strValue); //Add to memory
return strValue;
}
}


І хоча це не так важко реалізувати, все стає набагато складніше, варто вам спробувати проробити ті ж дії для інших моделей даних в Redis. До того ж виникнуть такі проблеми:

• Redis дозволяє нам керувати даними допомогою функцій на зразок StringAppend. В такому разі нам доведеться оголосити елемент in-memory недійсним.
• Якщо видалити ключ через KeyDelete, його також необхідно видалити з in-memory кеша.
• Якщо інший клієнт змінить або видалить яке-небудь значення, воно стане застарілим в in-memory кеші нашого клієнта.
• Коли ключ старіє, його необхідно видалити з in-memory кеша.

Методи доступу до даних і оновлення даних бібліотеки StackExchange.Redis визначені в інтерфейсі IDatabase. Виходить, ми можемо переписати реалізацію IDatabase так, щоб вирішити всі наведені вище проблеми. Ось як це можна зробити:

• StringAppend – додамо дані in-memory рядок, а потім передамо ту ж операцію в Redis. При більш складних операціях з даними необхідно буде видалити in-memory ключ.
• KeyDelete, KeyExpire, і т. д. – видаляє in-memory дані.
• Операції через інший клієнт – повідомлення простору ключів в Redis зроблені так, щоб виявляти зміни даних і відповідним чином оголошувати їх недійсним.
Вся краса цього підходу в тому, що ви продовжуєте використовувати той же інтерфейс, що і раніше. Виходить, впровадження L1 кешу не вимагає ніяких змін в коді.

Архітектура



Я вибрав таке рішення з такими основними елементами:

Статичний регістр MemoryCache

Ви можете створювати нові сутності RedisL1Database, передаючи в Redis IDatabase готову сутність і вона продовжить використовувати будь-in-memory кеш, який раніше створила для цієї бази даних.

Рівень повідомлень

NotificationDatabase – публікує спеціальні події простору ключів, необхідні для підтримки актуальності in-memory кеша. Стандартні повідомлення простору ключів в Redis не дозволяють досягти того ж результату, оскільки вони не надають достатньо інформації, щоб оголосити недійсним потрібну частину кеш-пам'яті. Наприклад, якщо ви видаляєте хеш-ключ, ви отримуєте HDEL-повідомлення з інформацією про те, з якого хеша він був видалений. Але воно не вказує, яким елементом хеша він був. У свою чергу, спеціальні події також містять інформацію про самому елементі хеша.
NotificationListener – підписується на спеціальні події простору ключів і звертається до статичної кеш-пам'яті, щоб оголосити недійсним необхідний ключ. Він також підписується на вбудоване подія простору ключів Redis під назвою expire. Це дозволяє оперативно видаляти з пам'яті всі минулі ключі Redis.

Далі ми розглянемо способи кешування різних моделей даних в Redis.

Рядок

Працювати з рядком відносно просто. Метод IDatabase StringSet виглядає так:

public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry = default(TimeSpan?), When when = When.Always, CommandFlags flags =
CommandFlags.None)


• Key і Value – назви говорять самі за себе.
• Expiry – довільний проміжок часу, а тому нам потрібно використовувати in-memory кеш зі строком дії.
• When – дозволяє визначити умови створення рядки: задати рядок тільки якщо вона вже існує або ще не існує.
• Flags – дозволяє уточнити деталі кластера Redis (не є актуальним).

Для зберігання даних ми використовуємо System.Runtime.Caching.MemoryCache, що дозволяє реалізувати автоматичне закінчення ключів. Метод StringSet виглядає так:

public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry = default(TimeSpan?), When when = When.Always, CommandFlags flags = CommandFlags.None)
{
if (when == When.Exists && !_cache.Contains(key))
{
//We're only supposed to cache when the key already exists.
return;
}

if (when == When.NotExists && _cache.Contains(key))
{
//We're only supposed to cache when the key doesn't already exist.
return;
}

//Remove it from the memorycache before re-adding it (the expiry may have changed)
_memCache.Remove(key);

CacheItemPolicy policy = new CacheItemPolicy()
{
AbsoluteExpiration = DateTime.UtcNow.Add(expiry.Value)
};

_memCache.Add(key, o, policy);

//Forward on the request to set the string in Redis
return _redisDb.StringSet(key, value, expiry, when, flags);


Потім StringGet може читати in-memory кеш перед спробою звернутися до Redis:

public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None)
{
var cachedItem = _memCache.Get<redisvalue>(key);
if (cachedItem.HasValue)
{
return cachedItem;
}
else
{
var redisResult = _redisDb.StringGet(key, flags);
//Cache this key for next time
_memCache.Add(key, redisResult);
return redisResult;
}
}
</redisvalue>


Redis підтримує безліч операцій для рядків. В кожному випадку їх застосування пов'язано або з оновленням in-memory значення, або з оголошенням його недійсним. В цілому другий варіант підходить краще, оскільки в іншому випадку ви ризикуєте даремно ускладнити багато операції Redis.

• StringAppend – дуже проста операція. До in-memory рядку, якщо така існує і не оголошена неактивній, що додаються.
• StringBitCount, StringBitOperation, StringBitPosition – операція здійснюється в Redis, задіяння in-memory не потрібно.
• StringIncrement, StringDecrement, StringSetBit, StringSetRange – in-memory рядок оголошується недійсною до перенаправлення операції в Redis.
• StringLength – повертає довжину рядка, якщо вона знаходиться в in-memory кеші. Якщо ні, операція отримує її з Redis.

Багато

Безлічі трохи складніше у зверненні. Метод SetAdd виконується за наступною схемою:

1. Перевірити MemoryCache на HashSet з даним ключем
• Якщо такого не існує, створити його.
2. Додати кожне значення Redis у безліч.

Додавати і видаляти значення з множин досить просто. Метод SetMove – це SetRemove, за яким слід SetAdd.
Більшість інших запитів множин можуть кешуватися. Наприклад:

SetMembers – повертає всі елементи множини, щоб результат зберігся в пам'яті.
SetContains, SetLength – перевіряє in-memory безліч перед зверненням до Redis.
SetPop – виштовхує елемент даних з безлічі Redis і потім видаляє елемент з in-memory множини, якщо такої там є.
SetRandomMember – отримує від Redis випадковий елемент множини, потім виконує його in-memory кешування і повертає.
SetCombine, SetCombineAndStore – задіяння in-memory не потрібно.
SetMove – видаляє елемент даних з in-memory безлічі, додає його в інше in-memory безліч і перенаправляє в Redis.

Хеш-таблиці

Хеш-таблиці теж відносно прості, так як їх in-memory реалізація – це всього лише Dictionary<string,RedisValue>, що, в принципі, дуже схоже на рядок.

Базова операція:

1. Якщо хеш-таблиця не доступна в in-memory, потрібно створити Словник<string,RedisValue> і зберегти його.
2. Здійснити операції над in-memory словником, якщо це можливо.
3. Перенаправити запит в Redis, якщо це необхідно.
• Кешувати результат.

Для хеш-таблиць доступні такі операції:

• HashSet – зберігає значення в словнику, зберігається у ключі, і потім перенаправляє запит в Redis.
• HashValues, HashKeys, HashLength – in-memory застосування відсутній.
• HashDecrement, HashIncrement, HashDelete – видаляє значення зі словника і перенаправляє в Redis.
• HashExists – повертає true, якщо значення знаходиться в in-memory кеші. В іншому випадку перенаправляє запит в Redis.
• HashGet – запитує дані з in-memory кеша. В іншому випадку перенаправляє запит в Redis.
• HashScan – отримує результати з Redis і додає їх в in-memory кеш.

Впорядкована множина

Впорядковані множини – це, поза сумнівом, найскладніша модель даних по частині створення in-memory кеша. В даному випадку процес in-memory кешування передбачає використання так званих «непересічних впорядкованих множин». Тобто кожен раз, коли локальний кеш бачить невеликий фрагмент упорядкованого безлічі Redis, цей фрагмент додається в «непересічний» впорядкована множина. Якщо надалі буде запитуватися підсекція упорядкованого безлічі, в першу чергу буде перевірятися непересічне впорядкована множина. Якщо воно цілком містить необхідну секцію, її можна повернути з повною упевненістю, що там немає відсутніх компонентів.

Впорядковані in-memory безлічі сортуються не за значенням, а по особливому параметру score. Теоретично можна було б розширити реалізацію непересічних впорядкованих множин, щоб була можливість сортувати їх за значенням, але на даний момент це поки що не реалізовано.

Операції використовують непересічні множини наступним чином:

SortedSetAdd – значення додаються в in-memory безліч дискретно, а значить ми не знаємо, чи пов'язані вони з точки зору score.
SortedSetRemove – значення видаляється з пам'яті, так і з Redis.
SortedSetRemoveRangeByRank – всі in-memory безліч оголошується недійсним.
SortedSetCombineAndStore, SortedSetLength, SortedSetLengthByValue, SortedSetRangeByRank, SortedSetRangeByValue, SortedSetRank – запит надсилається безпосередньо в Redis.
SortedSetRangeByRankWithScores, SortedSetScan – з Redis запитуються і потім дискретно кешовані дані.
SortedSetRangeByScoreWithScores – найбільш кэшируемая функція, так як scores повертаються по порядку. Кеш перевіряється, і якщо він може обробити запит, то повертається. В іншому разі надсилається запит до Redis, після чого scores зберігається в пам'яті як безперервне безліч даних.
SortedSetRangeByScore – дані беруться з кеша, якщо це можливо. В іншому випадку вони беруться з Redis і не кешуються, так як scores не повертаються.
SortedSetIncrement, SortedSetDecrement – in-memory дані оновлюються, і запит перенаправляється в Redis.
SortedSetScore – значення береться з пам'яті, якщо це можливо. В іншому разі надсилається запит до Redis.

Складність роботи з впорядкованими множинами обумовлена двома причинами: по-перше, характерна складність побудови in-memory реалізації наявних підмножин впорядкованих множин (тобто побудови розривних множин). А по-друге, зважаючи числа доступних операцій в Redis, які вимагають виконання. В деякій мірі складність зменшується за рахунок можливості реалізувати цілеспрямований кешування запитів, що включають Score. Так чи інакше, необхідно серйозне модульне тестування всіх компонентів.

Список

Списки не так-то легко кешувати в оперативній пам'яті. Причина в тому, що операції з ними, як правило, передбачають роботу або з головою, або з хвостом списку. І це не так просто, як може здатися на перший погляд, тому що у нас немає стовідсоткової можливості переконатися, що in-memory список має такі ж дані на початку і в кінці, як і список Redis. Частково цю складність можна було б вирішити за допомогою повідомлень простору ключів, але поки що ця можливість не реалізована.

Оновлення з боку інших клієнтів

До цього моменту ми виходили з міркувань, що маємо всього один клієнт бази даних Redis. Насправді клієнтів може бути багато. У таких умовах цілком імовірна ситуація, коли у одного клієнта є дані in-memory кеші, а інший їх оновлює, що робить дані першого клієнта недійсними. Існує лише один спосіб вирішити цю проблему – налагодити комунікацію між усіма клієнтами. На щастя, Redis надає механізм обміну повідомленнями у вигляді шаблону «видавець–читач». Цей шаблон має 2 типу повідомлень.

Повідомлення простору ключів

Ці повідомлення публікуються в Redis автоматично, коли змінюється ключ.

Спеціально опубліковані повідомлення

Публікуються на рівні повідомлень, реалізований як частина кешуючого механізму для клієнта. Їх мета – підкріплювати повідомлення простору ключів додатковою інформацією, необхідної, щоб оголошувати недійсними окремі дрібні частини кеша.
Робота з безліччю клієнтів – основна причина виникнення проблем кешування.

Ризики і проблеми

Загублені повідомлення

Redis не гарантує доставку повідомлень ключового простору. Відповідно повідомлення можуть загубитися, а в кеші залишаться недійсні дані. Це один з основних ризиків, але, на щастя, такі ситуації трапляються рідко. Тим не менш, при роботі з двома і більше клієнтами, цю небезпеку потрібно враховувати.

Рішення: використовувати надійний механізм обміну повідомленнями між клієнтами з гарантованою доставкою. На жаль, такого поки що немає, тому дивіться альтернативне рішення.
Альтернативне рішення: виконувати in-memory кешування тільки на короткий час, скажімо, на одну годину. В такому випадку всі дані, до яких ми звертаємося протягом години, будуть завантажуватися швидше. А якщо яке-небудь повідомлення загубиться, це упущення незабаром восполнится. Викличте метод Dispose для бази даних, а потім инстанцируйте її повторно, щоб очистити.

Використовує один – використовують всі
Якщо один клієнт використовує цей рівень кешування (L1), його повинні використовувати і всі інші клієнти, щоб повідомляти одна одну у випадку зміни даних.

Відсутність ліміту для in-memory даних
На даний момент все, що може кешуватися, буде відправлено в кеш. У такій ситуації у вас може швидко закінчитися пам'ять. В моєму випадку це не проблема, а просто майте це на увазі.

Висновок
Кажуть, у програмуванні всього 10 складних проблем: инвалидация кешу, привласнення імен і двійкова система… Упевнений, цей проект може стати в нагоді в багатьох ситуаціях. Вихідний код бібліотеки доступний на GitHub. Слідкуйте за проектом і приймайте в ньому участь!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.