Як уникнути стрибків по часу відгуку і споживання пам'яті при знятті знімків стану в СУБД в оперативній пам'яті


Пам'ятайте мою недавню статтю «Що таке СУБД в оперативній пам'яті і як вона ефективно зберігає дані»? У ній я навів короткий огляд механізмів, які використовуються в СУБД в оперативній пам'яті для забезпечення збереження даних. Мова йшла про два основних механізми: запис в журнал транзакцій і зняття знімків стану. Я дав загальний опис принципів роботи з журналом транзакцій і лише зачепила тему знімків. Тому в цій статті про знімках я розповім більш докладно: почну з найпростішого способу робити знімки стану в СУБД в оперативній пам'яті, виділю кілька пов'язаних з цим способом проблем і докладно зупинюся на тому, як даний механізм реалізовано у Tarantool.

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

Як же домогтися стану консистентного? Найпростіший (і грубий) спосіб — попередньо заморозити всю базу даних, зробити знімок стану і знову розморозити її. І це спрацює. База даних може перебувати в заморожуванні досить тривалий час. Наприклад, при розмірі даних 256 Гбайт і максимальної продуктивності жорсткого диска 100 Мбайт/с зняття знімка займе 256 Гбайт/(100 Мбайт/с) — приблизно 2560 секунд, або (знову ж приблизно) 40 хвилин. СУБД може обробляти запити на читання, але не зможе виконувати запити на зміну даних. «Що, серйозно?» — воскликнете ви. Давайте порахуємо: за 40 хвилин простою, скажімо, в день СУБД знаходиться в повністю робочому стані 97% часу в найкращому випадку (на ділі, звичайно, цей відсоток буде нижче, тому що на тривалість простою буде впливати безліч інших факторів).

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

  1. Нам необхідно заморожувати всі дані. Припустимо, ми копіюємо дані в область пам'яті зі швидкістю 1 Гбайт/с (що знову ж занадто оптимістично, тому що насправді швидкість може становити 200-500 Мбайт/с для більш-менш просунутих структур даних). 256 Гбайт/(1 Гбайт/с) — це 256 секунд, або близько 4 хвилин. Отримуємо 4 хвилини простою в день, або 99.7% часу доступності системи. Це, звичайно, краще, ніж 97%, але ненабагато.
  2. Як тільки ми скопіювали дані в окремий буфер в оперативній пам'яті, потрібно записати їх на диск. Поки йде запис копії, вихідні дані в пам'яті продовжують змінюватися. Ці зміни як-то потрібно відстежувати, наприклад, зберігати ідентифікатор транзакції разом зі знімком, щоб було зрозуміло, яка транзакція потрапила в останній знімок. В цьому немає нічого складного, але тим не менш робити це необхідно.
  3. Подвоюються вимоги до обсягу оперативної пам'яті. Насправді нам постійно треба пам'яті удвічі більше, ніж розмір даних; підкреслю: не тільки для зняття знімків стану, а постійно, тому що не можна просто збільшити обсяг пам'яті на сервері, зробити знімок, а потім знову витягти планку пам'яті.
Один із способів вирішити цю проблему — використовувати механізм копіювання при записі (далі для стислості я буду використовувати англійську абревіатуру COW, copy-on-write), що надається системним викликом fork. В результаті виконання цього виклику створюється окремий процес зі своїм віртуальним адресним простором і використовується тільки для читання копією всіх даних. Тільки для читання копія використовується тому, що всі зміни відбуваються в батьківському процесі. Отже, ми створюємо копію процесу і не поспішаючи записуємо дані на диск. Залишається питання: а в чому тут відміну від попереднього алгоритму копіювання? Відповідь лежить в самому механізмі COW, який використовується в Linux. Як згадувалося трохи вище, COW — це абревіатура, що означає copy-on-write, тобто копіювання при записі. Суть механізму в тому, що дочірній процес спочатку використовує сторінкову пам'ять спільно з батьківським процесом. Як тільки один з процесів змінює будь-які дані в оперативній пам'яті, створюється копія відповідної сторінки.

Природно, копіювання сторінки призводить до збільшення часу відгуку, тому що, крім власне операції копіювання, відбувається ще кілька речей. Зазвичай розмір сторінки дорівнює 4 Кбайт. Припустимо, ви змінили невелике значення в базі даних. Спершу відбувається переривання за відсутності сторінки, т. к. після виконання виклику fork всі сторінки батьківського і дочірнього процесів доступні тільки для читання. Після цього система переходить у режим ядра, виділяє нову сторінку, копіює 4 Кбайт зі старої сторінки і знову повертається в режим користувача. Це дуже спрощений опис, детальніше про те, що відбувається насправді, можна почитати посилання.

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

Ще одна проблема з fork в тому, що цей системний виклик копіює таблицю дескрипторів сторінок. Скажімо, при використанні 256 Гбайт пам'яті розмір цієї таблиці може досягати сотень мегабайтів, тому ваш процес може зависнути на секунду-другу, що знову ж таки збільшить час відгуку.

Використання fork, звичайно, не панацея, але це поки що краще, що у нас є. Насправді деякі популярні СУБД в оперативній пам'яті досі застосовують fork для зняття знімків стану — наприклад, Redis.

Чи можна тут щось покращити? Давайте придивимося до механізму COW. Копіювання там відбувається по 4 Кбайт. Якщо змінюється лише один байт, копіюється все одно вся сторінка повністю (у випадку з деревами — багато сторінок, навіть якщо перебалансування не потрібно). А що якщо нам реалізувати власний механізм COW, який буде копіювати лише фактично змінені ділянки пам'яті, точніше — змінені значення? Природно, така реалізація не стане повноцінною заміною системного механізму, а буде використовуватися лише для зняття знімків стану.

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




Як ви можете бачити, у елементів можуть бути як старі, так і нові версії. Приміром, на останньому зображенні в табличному просторі значень 3, 4, 5 і 8 за дві версії (стара і відповідна їй нова), у решти ж (1, 2, 6, 7, 9) по одній.

Зміни відбуваються тільки в більш нових версіях. Версії ж постарее використовуються для операцій читання при знятті знімка стану. Основна відмінність нашої реалізації механізму COW від механізму системного у тому, що ми не копіюємо всю сторінку розміром 4 Кбайт, а лише невелику частину дійсно змінених даних. Скажімо, якщо ви оновіть ціле четырехбайтное число, наш механізм створить копію цього числа, і скопійовані будуть тільки ці 4 байти (плюс ще кілька байтів як плата за підтримку двох версій одного елемента). А тепер для порівняння подивіться на те, як веде себе системний COW: копіюється 4096 байтів, відбувається переривання за відсутності сторінки, перемикання контексту (кожне таке перемикання еквівалентно копіювання приблизно 1 Кбайт пам'яті) — і все це повторюється кілька разів. Чи Не занадто багато клопоту для оновлення всього-навсього одне цілого четырехбайтного числа при знятті знімка стану?

Ми застосовуємо власну реалізацію механізму COW для зняття знімків в Tarantool починаючи з версії 1.6.6 (до цього ми використовували fork).

На підході нові статті з цієї теми з цікавими подробицями і графіками з робочих серверів Mail.Ru Group. Стежте за новинами.

Всі питання, пов'язані із змістом статті, можна адресувати автору оригіналу danikin, технічному директору поштових і хмарних сервісів Mail.Ru Group.
Джерело: Хабрахабр

0 коментарів

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