Кеші для «чайників»

Кеш очима «чайника»:



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

Давайте прокрутимо повний оборот ситуацій.

Tl;dr: додаючи в архітектуру кеш важливо явно усвідомлювати, що кеш може бути засобом дестабілізації системи під навантаженням. Дивіться кінець статті.

Уявімо, що у нас є доступ до бази даних, що повертає курси валют. Ми запитуємо rates.example.com/?currency1=XXX¤cy2=XXX у відповідь отримуємо plain text значення курсу. Кожні 1000 запитів до бази даних для нас, припустимо, коштують 1 євроцент.

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

Наприклад, так:

<?php // побудемо трохи ближче до народу :)
function get_current_rate($currency1, $currency2) {
$api_host = "http://rates.example.com/";
$args = ["currency1=".urlencode($currency1), "currency2=".urlencode($currency2)];
$rate = @file_get_contents($api_host."?".join("&", $args));
if ($rate === FALSE) {
return $rate;
} else {
return (float) $rate;
}
}

І в шаблонах в потрібному місці вставляємо що-небудь на зразок:

{{ get_current_rate("USD","EUR")|format(".2f") }} USD/EUR

(ну або навіть
<?=sprintf(".2f", get_current_rate("USD","EUR"))?>
, але це минуле століття).

Наївна імплементація робить найпростіше, що можна придумати: на кожен запит від користувача запитує віддалену систему і використовує відповідь безпосередньо. Це означає, що тепер кожні 1000 переглядів користувачами нашої сторінки стоять для нас на копійку більше. Здавалося б – копійки. Але ось проект зростає, у нас вже 1000 постійних користувачів, які щодня заходять на сайт і переглядають по 20 сторінок, а це вже 6 євро в місяць, що перетворює сайт безкоштовного у вже цілком порівнянний з платою за найдешевші віртуальні виділені сервери.

Ось тут на сцену виходить його величність Кеш.

Навіщо нам питати курс для кожного користувача на кожне оновлення сторінки, якщо для людей ця інформація, загалом-то, не потрібна так часто? Давайте просто обмежимо частоту оновлення до, наприклад, раз в 5 секунд. Користувачі, переходячи зі сторінки на сторінку, все одно будуть бачити нове число, а ми будемо платити в 1000 разів менше.

Сказано – зроблено! Додаємо кілька рядків:

<?php
function get_current_rate($currency1, $currency2) {
$cache_key = "rate_".$currency1."_".$currency2;
// посилання на https://cloud.google.com/appengine/docs/php/memcache/
$memcache = new Memcache;
$memcache->addServer("localhost", 11211);
$rate = $memcache->get($cache_key);
if ($rate) {
return $rate;
} else {
$api_host = "http://rates.example.com/";
$args = ["currency1=".urlencode($currency1), "currency2=".urlencode($currency2)];
$rate = @file_get_contents($api_host."?".join("&", $args));
if ($rate === FALSE) {
return $rate;
} else {
$memcache->set($cache_key, (float) $rate, 0, 5);
return (float) $rate;
}
}
}

Це найголовніший аспект кешу: зберігання останнього результату.

І вуаля! Сайт знову стає для нас майже безкоштовним… До кінця місяця, коли ми виявляємо від зовнішньої системи на рахунок 4 євро. Звичайно, не 6, але ми очікували значно більшої економії!

На щастя, зовнішня система дозволяє подивитися нарахування, де ми бачимо сплески по 100 і більше запитів кожні рівні 5 секунд протягом пікової відвідуваності.

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

У випадку з memcache це можна реалізувати, наприклад, так:

<?php
function get_current_rate($currency1, $currency2) {
$cache_key = "rate_".$currency1."_".$currency2;
$memcache = new Memcache();
$memcache->addServer("localhost", 11211);
while (true) {
$rate = $memcache->get($cache_key);
if ($rate == "?") {
sleep(0.05);
} else if ($rate) {
return $rate;
} else {
// Створюємо запис, тільки один зможе це зробити, інші підуть спати
if ($memcache->add($cache_key, "?", 0, 5)) {
$api_host = "http://rates.example.com/";
$args = ["currency1=".urlencode($currency1), "currency2=".urlencode($currency2)];
$rate = @file_get_contents($api_host."?".join("&", $args));
if ($rate === FALSE) {
return $rate;
} else {
$memcache->set($cache_key, (float) $rate, 0, 5);
return (float) $rate;
}
}
}
}
}

І ось, нарешті, споживання зрівнялася з очікуваним — 1 запит до 5 секунд, витрати скоротилися до 2 євро в місяць.

Чому 2? Було 6 кешування тисячі людей, ми ж всі закэшировали, а скоротилося всього в 3 рази? Так, варто прорахувати раніше… 1 раз в 5 секунд = 12 в хвилину = 72 годину = 576 за робочий день = 17 тисяч в місяць, а ще не всі ходять за розкладом, є дивні особистості заглядають пізньої ночі… Ось і виходить, в піку замість сотні звернень одне, а в тихий час, як і раніше, запит майже на кожне звернення проходить. Але все одно, навіть у гіршому випадку рахунок повинен бути 31×86400÷5 = 5.36 євро.

Так ми познайомилися з ще однією гранню: кеш допомагає, а не усуває навантаження.

Втім, у нашому випадку люди приходять в проект і йдуть і в якийсь момент починають скаржитися на гальма: сторінки завмирають на кілька секунд. А ще буває під ранок сайт не відповідає взагалі… Перегляд консолі сайту показує, що іноді вдень запускаються додаткові инстансы. В цей же час швидкість виконання запитів падає до 5-15 секунд на запит — із-за чого це відбувається.

Вправа для читача: подивитися уважно попередній код і знайти причину.

Так-так-так. Звичайно ж, це в гілці
if ($rate === FALSE)
. Якщо зовнішній сервіс повернув помилку, ми не звільнили блокування… В тому сенсі, що "?" так і залишився записаний, і всі чекають коли він застаріє. Що ж, це легко виправити:

if ($rate === FALSE) {
$memcache->delete($cache_key);
return $rate;
} else {

До речі, це граблі аж ніяк не тільки кешу, це загальний аспект розподілених блокувань: важливо звільняти блокування і мати таймаут, щоб уникнути дедлоков. Якщо б ми додавали "?" взагалі без часу життя, все б завмирало при першій же помилці зв'язку з зовнішньою системою. На жаль, memcache не надає хороших способів для створення розподілених блокувань, використання повноцінної БД з блокуваннями на рівні рядків краще, але це було просто ліричний відступ, необхідне просто тому, що на ці граблі наступили.

Отже, ми виправили проблему, ось тільки нічого не змінилося: все одно зрідка починалися гальма. Що примітно, вони збігалися у часі з інформаційним бюлетенем від зовнішньої системи про технічних роботах…

Ну-ка, ну-ка… Давайте зробимо коротку передишку і перерахуємо, що ми назбирали вже зараз, що повинен вміти кеш:
  1. пам'ятати останній відомий результат;
  2. дедуплицировать запити, коли результат ще або вже не відомий;
  3. забезпечувати коректну розблокування в разі помилки.


Помітили? Кеш повинен забезпечувати пункти 1-2 і для випадку помилки! Спочатку це здається очевидним: мало що сталося, відвалився один запит, наступний оновить. Ось тільки що станеться, якщо і наступного теж поверне помилку? І наступний? Ось у нас прийшло 10 запитів, перший захопив блокування, спробував отримати результат, відвалився, вийшов. Наступний перевіряє – так, блокування немає, значення немає, йдемо за результатом. Обламався, вийшов. І так для кожного. Ну ж дурість! По хорошому 10 прийшло, один спробував — все відвалилися. А вже наступного нехай спробує заново!

Звідси: кеш зобов'язаний вміти якийсь час зберігати негативний результат. Наше наївне вихідне припущення по суті передбачає зберігання негативного результату 0 секунд (але передачу цього самого заперечення всім, хто вже чекає його). На жаль, у випадку з Memcache реалізація нульового часу очікування вельми проблематична (залишу як домашнє завдання въедливому читачеві; порада: використовуйте механізм CAS; і так, в AppEngine можна використовувати і Memcache і Memcached).

Ми просто додамо збереження від'ємного значення з 1 секундою життя:

<?php
function get_current_rate($currency1, $currency2) {
$cache_key = "rate_".$currency1."_".$currency2;
$memcache = new Memcache();
$memcache->addServer("localhost", 11211);
while (true) {
$flags = FALSE;
$rate = $memcache->get($cache_key, $flags);
if ($rate == "?") {
sleep(0.05);
} else if ($flags !== FALSE) {
// Якщо ключа немає, тип не змінюється, інакше буде число,
// і ми повернемо будь-яке значення з кешу, навіть false.
return $rate;
} else {
if ($memcache->add($cache_key, "?", 0, 5)) {
$api_host = "http://rates.example.com/";
$args = ["currency1=".urlencode($currency1), "currency2=".urlencode($currency2)];
$rate = @file_get_contents($api_host."?".join("&", $args));
if ($rate === FALSE) {
// Тут ми можемо поставити окремий термін життя від'ємного значення
$memcache->set($cache_key, $rate, 0, 1);
return $rate;
} else {
$memcache->set($cache_key, (float) $rate, 0, 5);
return (float) $rate;
}
}
}
}
}

Здавалося б, " ну тепер-то вже все, і можна заспокоїтись? Як би не так. Поки ми росли, наш улюблений зовнішній сервіс теж ріс, і в якийсь момент почав іноді гальмувати і відповідати аж по секунді… І що примітно – разом з ним почав гальмувати і наш сайт! Причому знову для всіх! Але чому? Ми ж всі кешуємо, у разі помилок запам'ятовуємо помилку і тим самим відпускаємо всіх очікують відразу, хіба ні?

… А ось і ні. Уважно подивимося на код ще раз: запит до зовнішньої системі буде виконуватися стільки, скільки дозволить
file_get_contents()
. На час виконання запиту всі інші чекають, тому кожен раз, коли кеш застаріває, всі потоки чекають виконання головного, і отримають нові дані тільки, коли вони надійдуть.

Що ж, ми можемо замість очікування, додати гілку
else{}
в умови навколо
memcache->add
… Правда, варто, напевно, повернути останнє відоме значення, так? Адже ми кешуємо рівно потім, що ми згодні отримати застарілі відомості, якщо немає свіжих; отже, ще одна вимога до кешу: нехай пригальмовує не більше одного запиту.

Сказано – зроблено:

<?php
function get_current_rate($currency1, $currency2) {
$cache_key = "rate_".$currency1."_".$currency2;
$memcache = new Memcache();
$memcache->addServer("localhost", 11211);
while (true) {
$flags = FALSE;
$rate = $memcache->get($cache_key, $flags);
if ($rate == "?") {
sleep(0.05);
} else if ($flags !== FALSE) {
return $rate;
} else {
if ($memcache->add($cache_key, "?", 0, 5)) {
$api_host = "http://rates.example.com/";
$args = ["currency1=".urlencode($currency1), "currency2=".urlencode($currency2)];
$rate = @file_get_contents($api_host."?".join("&", $args));
if ($rate === FALSE) {
// Ми не змінюємо останнє успішне значення, нехай дивляться на
// застарілі відомості. При бажанні, поведінку можна змінити.
$memcache->set($cache_key, $rate, 0, 1);
return $rate;
} else {
// Ставимо термін життя нескінченним для _stale_ ключа, але можемо і поставити
// який-небудь великий: наприклад, хвилини, тим самим обмеживши
// термін життя застарілих відомостей.
$memcache->set("_stale_".$cache_key, (float) $rate);
$memcache->set($cache_key, (float) $rate, 0, 5);
return (float) $rate;
}
} else {
// Якщо немає актуальних даних, і не ми їх оновлюємо —
// повернемо значення копії даних, для яких не зазначено строк життя.
// Коли і їх немає, то повернемо false, що відповідає звичайній поведінці.
return $memcache->get("_stale_".$cache_key);
}
}
}
}

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

Примітка: звичайний PHP за замовчуванням пише сесії у файли, блокуючи паралельні запити. Щоб уникнути цього поведінки, можна передати session_start параметр read_and_close або примусово закривати через session_close сесію після здійснення всіх необхідних змін, інакше гальмувати буде не одна сторінка, а один користувач: так як скрипт, оновлюючий значення, буде блокувати відкриття сесії іншим запитом від того ж користувача. При виконанні на AppEngine за замовчуванням включено зберігання сесій в memcache, тобто без блокувань, тому проблема буде не так помітна.

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

Що ж ми можемо зробити в такій постановці питання? Ми можемо:
  1. Спробувати виконати трюки «виконання після відповіді», тобто якщо ми повинні оновити значення – реєструємо хендлер, який це зробить після виконання всього іншого скрипту. Ось тільки це сильно залежить від програми та оточення виконання; найнадійніший спосіб — використання
    <a href="http://php.net/fastcgi_finish_request">fastcgi_finish_request</a>()
    , що вимагає налаштування сервера через php-fpm (відповідно, недоступний для AppEngine).
  2. Зробити оновлення в окремому потоці (тобто виконати
    <a href="http://php.net/pcntl_fork">pcntl_fork()</a>
    або запустити скрипт через
    <a href="http://php.net/system">system()</a>
    або ще якось) – знову ж таки, може спрацювати для свого сервера, іноді навіть працює на деяких shared-хостингах, де не сильно стурбовані безпекою, але, зрозуміло, не спрацює на сервісах з параноїдальною безпекою, тобто AppEngine не підходить.
  3. Мати постійно працює фоновий процес для оновлення кешу: процес повинен з заданою періодичністю перевіряти, не старіє значення в кеші, і якщо термін життя підходить до кінця, а значення вимагалося за час життя кешу – оновлює його. Ми обговоримо цей момент трохи пізніше, коли невтомно вже від нашого бідного сайту з курсом валюти і перейдемо до більш веселим матерій.


По суті підтримання даних завжди гарячому стані – задача ледве більш складна, ніж просто кілька рядків PHP-коду, тому для нашого простого випадку нам доведеться змиритися з тим, що якийсь запит буде регулярно «замислюватися» (важливо: не випадковий, а якийсь; тобто не random, arbitrary). Застосовність цього підходу завжди важливо приміряти до задачі!

Отже, наш постачальник даних зростає, але не всі його клієнти читають хабр, а тому вони не використовують правильного кешування (якщо використовують його взагалі) і в якийсь момент починають видавати величезну кількість запитів, з-за чого сервісу стає погано, й епізодично він починає відповідати не просто повільно, а дуже повільно. До десятків секунд і більше. Користувачі, звичайно, швидко виявили, що можна натиснути F5 або інакше перезавантажте сторінку, і вона з'являється моментально – ось тільки сторінка знову почала впиратися в безкоштовні межі, так як постійно почали висіти процеси, просто очікують зовнішній відповідь, але споживають наші ресурси.

Серед інших побічних ефектів почастішали випадки показу застарілого курсу. [Мда… загалом, уявіть, що ми зараз говоримо не про наш випадок, а про щось більш складне, де старіння видно неозброєним оком :) насправді, навіть у простому випадку обов'язково знайдеться користувач, який помітить такі зовсім неочевидні косяки].
Дивіться, що виходить:
  1. Прийшов запит 1, даних в кеші немає, так що додали маркер '?' на 5 секунд і пішли за курсом.
  2. Через 1 секунду прийшов запит номер 2, побачив маркер '?', повернув дані з stale запису.
  3. Через 3 секунди прийшов запит номер 3, побачив маркер '?', повернув stale.
  4. Через 1 секунду маркер '?' застарів, незважаючи на те, що запит 1 все ще чекає відповіді.
  5. ще Через 2 секунди прийшов запит номер 4, маркера немає, додає новий маркер і відправляється за курсом.
  6. ...
  7. Запит 1 отримав відповідь, зберіг результат.
  8. Прийшов запит X, отримав актуальний відповідь кеш 1-го питання (а коли прийшов той відповідь? На момент запиту, або момент відповіді? – цього ніхто не знає...).
  9. Запит номер 4 отримав відповідь, зберіг результат – причому знову незрозуміло, чи був цей відповідь більш новий або старий...


Зрозуміло, тут ми повинні задати потрібний нам таймаут через
<a href="http://php.net/default_socket_timeout">ini_set("default_socket_timeout"</a>)
або скористатися
<a href="http://php.net/stream_context_create">stream_context_create</a>
… Так ми приходимо до ще одного важливого аспекту: кеш повинен враховувати час отримання значень. Загального рішення для поведінки немає, але, як правило, час кешування повинно бути більше ніж час обчислення. У разі якщо час обчислення перевищує час життя кешу, кеш непридатний. Це вже не кеш предвычисления, які слід зберігати в надійному сховищі.

Отже, давайте підведемо проміжний підсумок. В побутовому розумінні кеш:
  1. замінює більшість запитів на отримання вже відомого відповіді;
  2. обмежує число запитів до отримання дорогих даних;
  3. робить час запитів невидимими для користувача.


В реальності ж:
  1. замінює деякі запити з вікна життя кешу на запам'ятовування значення (кеш може загубитися в будь-який момент, наприклад, через брак пам'яті або екстравагантних запитів);
  2. намагається обмежити кількість запитів (але без спеціальної імплементації обмеження частоти вихідних запитів, реально можна забезпечити тільки характеристики типу «максимум 1 вихідний запит в один момент часу»);
  3. час виконання запиту мабуть тільки деякими користувачам (причому розподілені «щасливчики» аж ніяк не рівномірно).


Кеш передбачає «ефемерність» даних, що зберігаються, у зв'язку з чим системи кешування вільні поводитися з часом життя взагалі і з самим фактом запиту на збереження даних:
  • кеш може бути втрачений у будь-який момент часу. Навіть наші маркери блокування виконання '?' можуть бути втрачені, якщо паралельно ще 10 тисяч користувачів гуляє по сайту, всі зберігаючи щось (найчастіше час останнього звернення на сайт) у сесію, яка лежить на тому ж кеш-сервері; після того як маркер втрачено («кеш отруєний»), наступний запит знову почне процедуру оновлення значення в кеші;
  • чим швидше виконується запит до віддаленої системи, тим менше запитів буде дедуплицировано у разі отруєння кеша.


Таким чином, просто застосовуючи кеш, ми часто закладаємо міну відкладеної дії, яка обов'язково вибухне — але не зараз, а в майбутньому, коли рішення обійдеться значно дорожче. Розраховуючи продуктивність системи, важливо рахувати без урахування скорочення часу виконання від кешування позитивних відповідей, інакше ми покращуємо поведінку системи в спокійний час (коли сасһе hit ratio максимальний), а не під час пікового навантаження / перевантаженості залежностей (коли зазвичай і трапляється отруєння кеша).

Розглянемо найпростіший випадок:
  • Ми дивимося на систему в спокійному стані, і бачимо середнє час виконання 0.05 сек.
  • Висновок: 1 процес може обслужити 20 запитів в секунду, значить, для 100 запитів в секунду достатньо 5 процесів.
  • якщо час оновлення запиту зростає до 2 секунд, то виходить:
  • 1 процес зайнятий відновленням (протягом 2 секунд);
  • протягом цих 2 секунд у нас є тільки 4 процесу = 80 запитів в секунду.


І ось під великим навантаженням наш кеш отруєний, і запити не кешуються на 5 секунд, а на 1 секунду тільки, а це означає, що у нас постійно зайняті 2 запиту (один виконує перший запит, другий починає поновлювати кеш через секунду, поки перший ще працює), і залишкова ємність для обслуговування скорочується до 60 запитів в секунду. Тобто ефективна ємність від (виходячи з середнього) 6000 запитів в хвилину різко просідає до ~3600. Що означає, що якщо отруєння настало на 5000 запитах в хвилину, до тих пір, поки навантаження не впаде з 5000 до 3000 система нестабільна. Тобто будь-який (навіть піковий!) сплеск трафіку потенційно може викликати тривалу нестабільність системи.

Особливо прекрасно виглядає, коли після розсилки новин з якими-небудь новими функціями практично одночасно приходить хвиля користувачів. Такий маркетологічний хабраэффект на регулярній основі.

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

0 коментарів

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