Кешування даних у веб-додатках. Використання memcached



Юрій Краснощок (Delphi LLC, Dell)
Я трохи розповім вам про кешування. Кешування, загалом-то, не сильно цікаво, береш і кэшируешь, тому я ще розповім про memcached, досить інтимні подробиці.



Про кешування почнемо з того, що просять вас розробити фабрику по виробництву омнониевых торсиометров. Це стандартна задача, головне робити нудне обличчя і говорити: «Ну, ми застосуємо типову схему для розробки фабрики».

Взагалі, це ближче до фабричного виробництва, тобто звідки проблема пішла? Фабрика працює дуже швидко, виробляє наші торсиометры. А для калібрування кожного приладу потрібен чистий омноний, за яким треба літати кудись далеко і, відповідно, поки ми в процесі видобутку цього омнония, прилади лежать некалібровані, і, по суті справи, все виробництво зупиняється. Тому ми будуємо поруч з фабрикою склад. Але це не безкоштовно.



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



Джерело називається origin; об'єм складу, тобто розмір кешу   cache size; коли ми йдемо на склад за зразком потрібної форми, і комірник видає те, що ми просили — це називається cache hit, а якщо він говорить: «Немає такого», це називається cache miss.



У даних — у нашого омнониума   freshness, буквально — це свіжість. Fresheness використовується скрізь. Як тільки дані втрачають свою природну свіжість, вони стають stale data.



Процес перевірки даних на придатність називається validation, а момент, коли ми говоримо, що дані негідні, і викидаємо їх зі складу — це називається invalidation.



Іноді трапляється така прикра ситуація, коли у нас не залишається місця, але нам краще тримати свіжий омнониум, тому ми знаходимо по якимось критеріям самий старий і викидаємо. Це називається eviction.



Схема така, що від нашого браузера до бекенду є маса ланок в ланцюгу. Питання: де кешувати? Насправді, кешувати треба абсолютно скрізь. І, хочете ви цього чи ні, дані будуть кешуватися, тобто питання, швидше, — як ми можемо на це вплинути?



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

Немає сенсу кешувати дані, які часто змінюються. Потрібно кешувати дані, які часто використовуються. Розмір даних має значення, тобто якщо ми вирішуємо кешувати в пам'яті blu-ray фільм — класно, ми його дуже швидко дістанемо з пам'яті, але, швидше за все, нам потім його доведеться перекачати куди-небудь по мережі, і це буде дуже повільно. Такий великий обсяг даних не можна порівняти зі швидкістю доставки, тобто нам немає сенсу тримати такі дані в пам'яті. Можемо на диску тримати, але треба порівнювати по швидкості.

До речі, можете погуглити «programming latencies». Там на сайті дуже добре дано всі стандартні затримки, наприклад, швидкість доступу до CPU cache, швидкість відправки Round Trip пакету в дата-центрі. І коли ви проектуєте, щось прикидаєте, добре дивитися, там дуже наочно показано, що скільки часу і в порівнянні з чим займає.

Це готові рецепти для кешування:



Це релевантний HTTP Headers:



Я трохи розповім про деякі, але, взагалі-то, про це треба читати. В принципі, в вебі єдине, як ми можемо впливати на кешування — це правильно встановлюючи ось ці header'и.



Expires використовувався раніше. Ми встановлюємо freshness для наших даних, ми буквально говоримо: «Все, цей вміст придатний до такого числа». І зараз цей header потрібно використовувати, але тільки як fallback, т. к. є більш новий header. Знову-таки, ця розмова дуже довга, ви можете потрапити на якусь проксі, яка розуміє тільки ось цей header — Expires.

Новий header, який зараз відповідає за кешування, — це Cache-Control:



Тут ви можете вказати відразу ж і freshness, і validation механізм, і invalidation механізм, вказати, це public дані або private, як їх кешувати…

До речі, no-cache — це дуже цікаво. За назвою очевидно, що ми говоримо: кэшируйте скрізь, будь ласка, як завгодно кэшируйте, якщо ми говоримо «no-cache». Але кожен раз, коли ми використовуємо якісь дані з цього контенту, наприклад, у нас є формочка, і ми в цій формочці робимо submit, то ми говоримо, що в будь-якому випадку всі ваші закэшированные дані не актуальні, вам треба їх перевірити.

Якщо ми хочемо, взагалі, вимкнути кешування для контенту, то говоримо «no-store»:



Ці «no-cache», «no-store» дуже часто застосовуються для форм аутентифікації, тобто ми не хочемо кешувати неаутентифицированных користувачів, щоб не вийшло дивного, щоб вони не побачили зайвого або не було непорозуміння. І, до речі, про цей Cache-Control: no-cache… Якщо, припустимо, Cache-Control header не підтримується, то його поведінку можна симулювати. Ми можемо взяти header Expires і встановити дату яку-небудь у минулому.

Ці всі header'и, включаючи навіть Content-Length, для кеша актуальні. Деякі кэшируюшие проксі можуть просто навіть не кешувати, якщо немає Content-Length.

Власне, ми приходимо до memcached, кешу на стороні бекенду.



Знову ж таки ми можемо кешувати по-різному, тобто ми дістали якісь дані з бази, що в коді з ними робимо, але, по суті справи, це кеш, — ми один раз їх дістали, щоб багато разів переиспользовать. Ми можемо використовувати у коді якийсь компонент, фреймворк. Цей компонент для кешування потрібен, тому що у нас повинні бути розумні лимитэйшны на наш продукт. Все починається з того, що приходить якийсь інженер з експлуатації і каже: «Поясни мені вимоги на свій продукт». І ви повинні йому сказати, що це буде стільки-то оперативної пам'яті, стільки-то місця на диску, такий-то прогнозований обсяг зростання у додатки… Тому, якщо ми щось кешуємо, ми хочемо мати обмеження. Припустимо, перше обмеження, яке ми можемо легко забезпечити, — по числу елементів у кеші. Але якщо у нас елементи різного розміру, тоді ми хочемо закрити рамками фіксованого обсягу пам'яті. Тобто ми говоримо який-небудь розмір кешу — це самий головний ліміт, найголовніший boundary. Ми використовуємо бібліотеку, яка може таку штуку робити.



Ну, або ж ми використовуємо окремий кешуючий сервіс, взагалі stand-alone. Навіщо нам потрібен якийсь окремий кешуючий сервіс? Найчастіше бекенд — це не щось таке монолітне, один процес. У нас є якісь розрізнені процеси, якісь скрипти, і якщо у нас є окремий кешуючий сервіс, тобто можливість у всієї інфраструктури бекенду бачити цей кеш, використовувати дані з нього. Це здорово.

Другий момент — у нас є можливість рости. Наприклад, ми ставимо один сервіс, у нас закінчується кэшсайз, ми ставимо ще один сервіс. Природно, це не безкоштовно, тобто «а сьогодні ми вирішили отмасштабироваться» не може статися. Потрібно планувати такі речі заздалегідь, але окремий кешуючий сервіс таку можливість дає.



Кеш ще дає нам availability практично за безкоштовно. Припустимо, у нас є якісь дані в кеші, і ми намагаємося дістати дані з кешу. У нас щось десь падає, а ми робимо вигляд, що нічого не впало, віддаємо дані з кеша. Воно, може, в цей час переподнимется як-то, і буде навіть availability.

Власне, ми підібралися до memcached. Memcached — це типовий noSQL.



Чому noSQL для кешування — це добре?

За структурою. У нас є звичайна хеш-таблиця, тобто ми отримуємо низький latency. У випадку з memcached і аналогічними key-value storage'ами — це не просто низький latency, а в нотації big-O у нас складність більшості операцій — це константа від одиниці. І тому ми можемо говорити, що у нас якийсь тимчасової constraint. У нас, наприклад, запит займає не більше 10 мс., тобто можна навіть домовлятися про якийсь контракті на підставі цих latency. Це добре.

Найчастіше ми кешуємо абищо — картинки впереміш з CCS'ом з JS'ом, якісь фрагменти форм прирендеренных, щось ще. Це незрозуміло, які дані, і структура key-value дозволяє їх зберігати досить легко. Ми можемо завести нотацію, що у нас account.300.avatar — це картинка, і воно там працює. 300 — це ID аккаунта в нашому випадку.

Важливий момент — те, що спрощується код самого storage'а, якщо у нас key-value noSQL, тому що найстрашніше, що може бути — це ми зіпсуємо або втратимо дані. Чим менше коду працює з даними, тим менше шансів зіпсувати, поетом простий кеш з простою структурою — це добре.



Про memcached key-value. Можна вказувати разом з даними expiration. Підтримується робота у фіксованому обсязі пам'яті. Можна встановлювати 16-бітові прапори зі значенням довільно — вони для memcached прозорі, але найчастіше ви будете з memcached працювати і з якогось клієнта, і швидше за все, цей клієнт вже загреб ці 16 біт під себе, тобто він їх використовує. Така можливість є.

memcached може працювати з підтримкою викшинов, тобто коли у нас закінчується місце, ми самі старі дані выпихиваем, самі нові додаємо. Або ж ми можемо сказати: «Не видаляй ніякі дані», тоді при додаванні нових даних вона буде повертати помилку out of memory — це прапорець-М».



Структурованої єдиної документації по memcached немає, найкраще читати опис протоколу. В принципі, якщо ви наберете в Google «memcached протокол», це буде перша посилання. У протоколі описані не тільки формати команд — відправлення, що ми відправляємо, що приходить у відповідь… Там описано, що ось ця команда, вона буде вести себе ось так і так, т. е. там якісь корнер кейси.

Коротенько по командам:



get   отримати дані;

set/ add/ delete/ replace — як ми сторим ці дані, тобто:

  • set — це зберегти, додати нові, або замінити,
  • add — це додати тільки, якщо такого ключа немає,
  • delete   видалити;
  • replace   замінити, лише якщо такий ключ є, інакше — помилка.
Це в середовищі, коли у нас є шард. Коли у нас є кластер, це ніякий консистентности нам не гарантує. Але з одним инстансом консистанси можна підтримувати цими командами. Більш або менш можна такі constraint'и вибудовувати.

prepend/ append — це ми беремо і перед нашими даними вставляємо якийсь шматочок або після наших даних вставляємо якийсь шматочок. Вони не дуже ефективно реалізовані всередині memcached, тобто у вас все одно буде виділятися новий шматок пам'яті, різниці між ними і set функціонально немає.

Ми можемо даними, які зберігаємо, вказати якийсь expiration і потім ми можемо ці дані чіпати командою touch, і ми продляем життя конкретно ось цьому ключу, тобто він не відійде.

Є команди инкремента і декремента   incr/decr. Працює вона наступним чином: ви сторите якесь число у вигляді рядка, потім говорить incr і даєте значення якесь. Воно підсумовує. Декремент — те ж саме, але віднімає. Там є цікавий момент, наприклад, 2 — 3 = 0, з точки зору memcached, тобто вона автоматично хэндлит андерфлоу, але вона не дає нам зробити від'ємне число, в будь-якому випадку повернеться нуль.



Єдина команда, за допомогою якої можна зробити якусь консистентним, — це cas (це атомарна операція compare and swap). Ми порівнюємо два якихось значення, якщо ці значення збігаються, то ми замінюємо дані на нові. Значення, яке ми порівнюємо, — це глобальний лічильник всередині
memcached, і кожен раз, коли ми додаємо туди дані, цей лічильник инкрементится, і наша пара key-value отримує якесь значення. З допомогою команд gets ми отримуємо значення і потім у команді cas ми можемо його використовувати. У цієї команди є всі ті ж проблеми, які є у звичайних атомік, тобто можна наробити купу raise condition'ів цікавих, тим більше, що у memcached немає жодних гарантій на порядок виконання команд.

Є у memcached ключик «-З» — він вимикає cas. Тобто що відбувається? Цей лічильник пропадає key-value pair, якщо ви додаєте ключик «-З», то ви заощаджуєте 8 байт, тому що це 64-хбитный лічильник на кожному значенні. Якщо у вас невеликі значення, ключі невеликі, то це може бути істотна економія.

Як працювати з memcached ефективно?



Вона задизайнена, щоб працювати з безліччю сесій. Безлічі — це сотні. Тобто починається від сотень. І справа в тому, що в термінах RPS — request per second — ви не выжмете з memcached багато чого, використовуючи 2-3 сесії, тобто для того, щоб її розгойдати, треба багато з'єднань. Сесії повинні бути довгограючі, тому що створення сесії всередині memcached — досить дорогий процес, тому ви один раз причепилися і все, цю сесію треба тримати.

Запити треба batch'ить, тобто ми повинні надсилати запити пачками. Для get-команди у нас є можливість передати кілька ключів, цим треба користуватися. Тобто ми говоримо get і ключ-ключ-ключ. Для решти команд такої можливості немає, але ми все одно можемо робити batch, тобто ми можемо формувати запит у себе, локально, на стороні клієнта з використанням декількох команд, а потім цей запит відправляти.

memcached многопоточна, але вона не дуже добре многопоточна. У неї всередині багато блокувань, досить ad-hoc, тому більше чотирьох потоків викликають дуже сильний контеншн всередині. Мені вірити не треба, треба все перевіряти самому, треба з живими даними, на живій системі робити якісь експерименти, але дуже велике число потоків працювати не буде. Треба погратися, підібрати якесь оптимальне число ключиком «-t».

memcached підтримує UDP. Це патч, який був доданий в memcached facebook'ом. Те, як використовує facebook memcached — вони роблять все сети, т.тобто всю модифікацію даних TCP, а get'и вони роблять по UDP. І виходить, коли обсяг даних істотно великий, то UDP дає серйозний виграш за рахунок того, що менше розмір пакету. Вони примудряються більше даних прокачати через сітку.

Я вам розповідав про incr/decr — ці команди ідеально підходять для того, щоб зберігати статистику бекенду.



Статистика HighLoad'e — це річ незамінна, тобто ви не зможете зрозуміти, що, як, звідки відбувається конкретна проблема, якщо у вас не буде статистики, тому що після півгодини роботи «система веде себе дивно» і все… Щоб додати конкретики, наприклад, кожен тисячний запит фейлится, нам потрібна якась статистика. Чим більше статистики буде, тим краще. І навіть, в принципі, щоб зрозуміти, що у нас є проблема, нам потрібна якась статистика. Наприклад, бекенд віддавав за 30 мс сторінку, почав за 40, поглядом відрізнити неможливо, але у нас перформанс просів на чверть — це жахливо.

Memcached теж сама по собі підтримує статистику, і якщо ви вже використовуєте memcached у своїй інфраструктурі, то статистика memcached — це частина вашої статистики, тому туди заглядати треба, туди треба дивитися, щоб розуміти, правильно бекенд використовує кеш, добре він дані кешує.

Перше — по кожній команді є hits та misses. Коли ми звернулися до кешу, і нам віддали дані, поинкрементился hit по цій команді. Наприклад, зробили delete ключ, у нас буде delete hits 1, так по кожній команді. Природно, треба, щоб hits була 100%, misses не було взагалі. Треба дивитися. Припустимо, у нас може бути дуже високий miss ratio. Сама банальна причина — ми просто не ліземо за тими даними. Може бути такий варіант, що ми виділили під кеш мало пам'яті, і ми постійно переиспользуем кеш, тобто ми дані якісь додали-додали-додали на якомусь моменті там перші дані випали з кешу, ми за ними полізли, їх там уже немає. Ми полізли за іншими, їх там теж немає. І воно ось так все крутиться. Тобто треба або з боку бекенду зменшити навантаження на memcached, або можна збільшити параметром «-m» обсяг пам'яті, який ми дозволяємо використовувати.

Evictions — це дуже важливий момент. Ситуація, про яку я розповідаю, вона буде видно з того, що evictions rate буде дуже високий. Це кількість, коли придатні дані не експайред, тобто вони свіжі, гарні викидаються з кешу, тоді у нас зростає кількість evictions.

Я говорив, що треба використовувати batch'і. Як підібрати розмір batch'a? Срібної кулі немає, треба експериментально це все підбирати. Залежить все від вашої інфраструктури, від мережі, яку ви використовуєте, від числа инстансов та інших факторів. Але коли у нас batch дуже великий… Уявіть ситуацію, що ми виконуємо batch, і всі інші конекншны стоять і чекають, поки batch виконається. Це називається starvation — голодування, тобто коли інші конекшны голодують і чекають, поки виконається один жирний. Щоб цього уникнути, всередині memcached є механізм, який перериває виконання batch'a насильно. Реалізовано це досить грубо, є ключик «-R», який говорить скільки команд може виконати один конекшн поспіль. За замовчуванням це значення 20. І ви, коли подивіться на статистику, якщо у вас conn_yields stat буде якимось дуже високим, це означає, що ви використовуєте batch більше, ніж може memcached прожувати, і йому доводиться насильно часто перемикати контекст цього конекшна. Тут можна або збільшити розмір batch'а ключиком «-R», або не використовувати з боку бекенду такі batch'в.



Ще я говорив, що memcached викидає з пам'яті найбільш старі дані. Так от, я збрехав. Насправді це не так. Всередині memcached є свій memory менеджер, щоб ефективно працювати з цією пам'яттю, щоб викидати ці атоми. Він влаштований таким чином, що у нас є slabs (буквально «огризок»). Це усталений термін в програмуванні memory менеджерів для якогось шматка пам'яті, тобто у нас є просто якийсь великий шматок пам'яті, який, у свою чергу, ділиться на pages. Pages всередині memcached за Мб, тому ви не зможете створити там дані «ключ-значення» більше одного Мб. Це фізичне обмеження — memcached не може створити дані більше, ніж одна сторінка. І, в підсумку, всі сторінки побиті на чанкі, це те, що ви бачите на картинці по 96, 120 — вони визначеного розміру. Тобто йдуть шматки по 96 Мб, потім шматки по 120, з коефіцієнтом 1.25, від 32-х до 1 Мб. В межах цього шматка є двусвязный список. Коли ми додаємо якесь нове значення, memcached дивиться на розмір цього значення (це ключ + значення + экспирейшн + прапори + системна інформація, яка memcached потрібна (близько 24-50 байт)), вибирає розмір цього чанка і додає в двусвязный список наші дані. Вона завжди додає дані head. Коли ми до якихось даними звертаємося, то memcached виймає їх з двусвязного списку і знову кидає в head. Т. о., ті дані, які мало використовуються, переповзають в хвіст, і в підсумку вони видаляються.

Якщо пам'яті нам не вистачає, то memcached починає видаляти пам'ять з кінця. Механізм list recently used працює в межах одного чанка, тобто ці списки виділені для якогось розміру, це не фіксований розмір — це діапазон від 96 до 120 потраплять до 120-ий чанк і т. д. Впливати на цей механізм з боку memcached ми ніяк не можемо, тільки з боку бекенду треба підбирати ці дані.



Можна подивитися статистику по цим slab'ам. Дивитися статистику по memcached найпростіше — протокол повністю текстовий, і ми можемо Telnet'ом під'єднатися, набрати stats, Enter, і вона вивалить «простирадло». Точно так само ми можемо набрати stats slabs, stats items — це, в принципі, схожа інформація, але stats slabs дає картину, більше розмазана в часі, там такі stat's — що було, що відбувалося за весь той період поки memcached працював, а stat items — там більше про те, що у нас є зараз, скільки є чого. В принципі, обидві ці речі треба дивитися, треба враховувати.



Ось ми дібралися до масштабування. Природно, ми поставили ще один сервер memcached — здорово. Що будемо робити? Як-то треба вибирати. Або ми на стороні клієнта вирішуємо, до якого з серверів будемо приєднуватися і чому. Якщо у нас availability, то все просто — записали туди, записали сюди, читаємо звідки-небудь, не важливо, можна Round Robin'ом, як завгодно. Або ми ставимо якийсь брокер, і для бекенду у нас виходить, що це виглядає як один інстанси memcached, але насправді за цим брокером ховається кластер.

Для чого використовується брокер? Щоб спростити інфраструктуру бекенду. Наприклад, нам треба з дата-центру в дата-центр поперевозить сервера, і всі клієнти повинні про це знати. Або ми можемо хак за цим брокером зробити, і для бекенду все прозоро пройде.

Але виростає latency. 90% запитів — це мережевий round trip, тобто memcached всередині себе обробляє запит за мкс — це дуже швидко, а по мережі дані ходять довго. Коли у нас є брокер, у нас з'являється ще одна ланка, тобто все ще довше виконується. Якщо клієнт відразу знає, на який кластер memcached йому йти, то він дані дістане швидко. А, власне, як клієнт дізнається, на який кластер memcached йому йти? Ми беремо, вважаємо хеш від нашого ключа, беремо залишок від ділення цього хеша на кількість инстанса в memcached і йдемо на цей кластер — найпростіший солюшн.

Але у нас додався ще один кластер в інфраструктуру, значить, зараз нам треба грохнути весь кеш, тому що він став неконсистентным, не валідним, і заново все перерахувати — це погано.



Для цього є механізм — consistent hashing ring. Тобто що ми робимо? Ми беремо хеш-значення, всі можливі хеш-значення, наприклад int32, беремо всі можливі значення і маємо ніби на циферблаті годинника. Так. ми можемо налаштувати — припустимо, хеші з такого-то по такий-то йдуть на цей кластер. Ми конфігуруємо ренджі і конфігуруємо кластери, які відповідають за ці ренджі. Таким чином, ми можемо тасувати сервера як завгодно, тобто нам треба буде поміняти в одному місці це кільце, перегенерувати, і сервера, клієнти або роутер, брокер — у них буде консистентное уявлення про те, де лежать дані.

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

memcached погано підходить для consistancy якихось рішень, тобто це більше рішення на availability, але в той же час, є якісь можливості з cas'ом щось наколхозить.

Контакти
» cachelot@cachelot.io
» http://cachelot.io/

Ця доповідь — розшифровка одного з кращих виступів на навчальній конференції розробників високонавантажених систем HighLoad++ Junior.

Також деякі з цих матеріалів використовуються нами в навчальному онлайн-курс по розробці високонавантажених систем HighLoad.Guide — це ланцюжок спеціально підібраних листів, статей, матеріалів, відео. Вже зараз у нашому підручнику понад 30 унікальних матеріалів. Підключайтеся!

Ну і головна новина — ми почали підготовку весняного фестивалю "Російські інтернет-технології", в який входить вісім конференцій, включаючи HighLoad++ Junior.


Джерело: Хабрахабр

0 коментарів

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