П'ять студентів та три розподілені key-value сховища

Або тому що ми писали клієнтську C++ бібліотеку для ZooKeeper, etcd та Consul KV

У світі розподілених систем існує низка типових завдань: зберігання інформації про склад кластера, управління конфігурацією вузлів, детекція збійних вузлів, вибір лідера та інших. Для розв'язання цих завдань створено спеціальні розподілені системи — послуги координації. Зараз нас цікавитимуть три з них: ZooKeeper, etcd та Consul. З усієї багатої функціональності Consul ми зосередимося на Consul KV.

П'ять студентів та три розподілені key-value сховища

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

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

Нам вдалося створити бібліотеку, яка надає спільний інтерфейс для роботи з ZooKeeper, etcd та Consul KV. Бібліотека написана на C++, але є плани портування іншими мовами.

Моделі даних

Щоб розробити загальний інтерфейс для трьох різних систем, треба зрозуміти, що ж у них спільного і чим вони відрізняються. Давайте розумітися.

ZooKeeper

П'ять студентів та три розподілені key-value сховища

Ключі організовані в дерево та називаються вузлами (znodes). Відповідно, для вузла можна отримати перелік його дітей. Операції створення znode (create) та зміни значення (setData) розділені: читати та змінювати значення можна лише у існуючих ключів. До операцій перевірки існування вузла, читання значення та отримання дітей можна прив'язувати watches. Watch – це одноразовий тригер, який спрацьовує, коли версія відповідних даних на сервері змінюється. Для виявлення відмов служать ефемерні вузли. Вони прив'язуються до сесії клієнта, який їх створив. Коли клієнт закриває сесію або перестає сповіщати ZooKeeper про своє існування, ці вузли автоматично видаляються. Підтримуються прості транзакції - набір операцій, які або всі успішно виконуються, або не виконуються, якщо хоча б для однієї з них це неможливо.

тощо

П'ять студентів та три розподілені key-value сховища

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

У etcd немає стандартної операції compare-and-set, але є дещо краще транзакції. Звичайно, вони є у всіх трьох системах, але в транзакції etcd особливо хороші. Вони складаються із трьох блоків: check, success, failure. Перший блок містить набір умов, другий та третій – операції. Транзакція виконується атомарно. Якщо всі умови вірні, то виконується блок успіху, інакше – failure. У версії API 3.3 блоки success і failure можуть містити вкладені транзакції. Тобто атомарно можна виконувати умовні конструкції майже довільного рівня вкладеності. Детальніше дізнатися про те, які існують перевірки та операції, можна з документації.

Watches тут теж існують, хоча вони влаштовані трохи складніше і багаторазові. Тобто після встановлення watch'а на діапазон ключів ви отримуватимете всі оновлення в цьому діапазоні, поки не скасуйте watch, а не тільки перше. У etcd аналогом клієнтських сесій ZooKeeper є leases.

Consul KV

Тут теж немає суворої ієрархічної структури, але Consul вміє створювати видимість, що вона є: можна отримувати та видаляти всі ключі із зазначеним префіксом, тобто працювати з «піддеревом» ключа. Такі запити називають рекурсивними. Крім цього Consul вміє вибирати лише ключі, які містять зазначений символ після префікса, що відповідає отриманню безпосередніх «дітей». Але варто пам'ятати, що це саме видимість ієрархічної структури: можна створити ключ, якщо його батько не існує або видалити ключ, у якого є діти, при цьому діти продовжать зберігатися в системі.

П'ять студентів та три розподілені key-value сховища
Замість watch'ів у Consul існують блокуючі HTTP запити. По суті, це звичайні виклики методу читання даних, для яких поряд з іншими параметрами вказується остання відома версія даних. Якщо поточна версія відповідних даних на сервері більша за вказану, відповідь повертається відразу, інакше – коли значення зміниться. Тут також є сесії, які будь-якої миті можна прикріплювати до ключів. Варто зауважити, що на відміну від etcd та ZooKeeper, де видалення сесій призводить до видалення пов'язаних ключів, тут є режим, при якому сесія просто від них відкріплюється. Доступні транзакції, без розгалужень, але з усілякими перевірками.

Зводимо все докупи

Найсуворішою моделлю даних має ZooKeeper. Виразні запити на діапазони, доступні в etcd, неможливо ефективно емулювати ні ZooKeeper, ні Consul. Намагаючись увібрати все найкраще від усіх сервісів, ми отримали інтерфейс майже еквівалентний інтерфейсу ZooKeeper з наступними суттєвими винятками:

  • sequence, container та TTL nodes не підтримуються
  • ACL не підтримуються
  • метод set створює ключ, якщо він не існував (у ZK setData повертає помилку в цьому випадку)
  • методи set і cas розділені (у ZK це по суті те саме)
  • метод erase видаляє вершину разом із піддеревом (у ZK delete повертає помилку, якщо у вершини є діти)
  • для кожного ключа існує тільки одна версія – версія значення (ZK їх три)

Відмова від sequential вузлів пов'язаний з тим, що в etcd і Consul для них немає вбудованої підтримки, а поверх інтерфейсу бібліотеки, що вийшов, вони можуть бути легко реалізовані користувачем.

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

Тонкощі реалізації

Розглянемо докладніше деякі аспекти реалізації інтерфейсу бібліотеки у різних системах.

Ієрархія в etcd

Підтримка ієрархічного вигляду в etcd виявилося одним із найцікавіших завдань. Запити на діапазони дозволяють легко отримати список ключів із зазначеним префіксом. Наприклад, якщо вам потрібно все, що починається з "/foo", ви запитуєте діапазон ["/foo", "/fop"). Але це повертало б повністю все поддерево ключа, що може бути неприйнятно, якщо поддерево велике. Спочатку ми планували використовувати механізм перетворення ключів, реалізований у zetcd. Він передбачає додавання на початок ключа одного байта, що дорівнює глибині вузла в дереві. Наведу приклад.

"/foo" -> "u01/foo"
"/foo/bar" -> "u02/foo/bar"

Тоді отримати всіх безпосередніх дітей ключа "/foo" можна, запитавши діапазон ["u02/foo/", "u02/foo0"). Так, в ASCII "0" стоїть відразу після "/".

Але як у такому разі реалізувати вилучення вершини? Виходить, потрібно видалити всі діапазони виду ["uXX/foo/", "uXX/foo0") для XX від 01 до FF. І тут ми уперлися в ліміт числа операції усередині однієї транзакції.

У результаті було придумано просту систему перетворення ключів, яка дозволила ефективно реалізувати і видалення ключа, і отримання списку дітей. Достатньо дописувати спецсимвол перед останнім токеном. Наприклад:

"/very" -> "/u00very"
"/very/long" -> "/very/u00long"
"/very/long/path" -> "/very/long/u00path"

Тоді видалення ключа "/very" перетворюється на видалення "/u00very" та діапазону ["/very/", "/very0"), а отримання всіх дітей – на запит ключів з діапазону ["/very/u00", "/very/u01").

Видалення ключа в ZooKeeper

Як я вже згадав, в ZooKeeper не можна видалити вузол, якщо він має дітей. Ми ж хочемо видаляти ключ разом із піддеревом. Як бути? Ми робимо це оптимістично. Спочатку рекурсивно обходимо піддерево, отримуючи дітей кожної вершини окремим запитом. Потім будуємо транзакцію, яка намагається видалити всі вузли піддерева у правильному порядку. Зрозуміло, між читанням піддерева та видаленням у ньому можуть відбутися зміни. У такому разі транзакція провалиться. Більше того, піддерево може змінитись ще в процесі читання. Запит дітей чергового вузла може повернути помилку, якщо, наприклад, ця вершина вже видалено. В обох випадках ми повторюємо весь процес наново.

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

set в ZooKeeper

У ZooKeeper окремо існують методи, які працюють зі структурою дерева (create, delete, getChildren) і які працюють з даними у вузлах (setData, getData). setData - якщо його ще не існує. Нам був потрібен метод set, який можна викликати, не замислюючись про наявність ключа.

Один із варіантів – застосувати оптимістичний підхід, як із видалення. Перевірити, чи існує вузол. Якщо існує, викликати setData, інакше create. Якщо останній спосіб повернув помилку, повторити все спочатку. Перше, що варто помітити – це безглуздість перевірки на існування. Можна одразу викликати create. Успішне завершення означатиме, що вузла не існувало і було створено. В іншому випадку create поверне відповідну помилку, після чого потрібно викликати setData. Звичайно, між викликами вершина може бути видалена конкуруючим викликом, і setData теж поверне помилку. У цьому випадку можна повторити все заново, але чи варто?

Якщо обидва методи повернули помилку, ми точно знаємо, що мало місце конкуруюче видалення. Припустимо, що це видалення відбулося після виклику set. Тоді яке б значення ми не намагалися встановити, воно вже стерте. Значить, можна вважати, що set виконався успішно, навіть якщо насправді нічого записати не вдалося.

Більш технічні тонкощі

У цьому розділі ми відвернемося від розподілених систем і поговоримо про кодинг.
Однією з основних вимог замовника була кросплатформність: у Linux, MacOS та Windows повинен підтримуватися хоча б один із сервісів. Спочатку ми вели розробку лише під Linux, а в решті систем почали тестувати пізніше. Це викликало масу проблем, яких деякий час було зовсім незрозуміло як підступитися. У результаті зараз у Linux та MacOS підтримуються всі три сервіси координації, а у Windows – лише Consul KV.

З самого початку для доступу до сервісів ми намагалися використати готові бібліотеки. У випадку з ZooKeeper вибір ліг на ZooKeeper C++, який у результаті так і не вдалося скомпілювати у Windows. Втім, це не дивно: бібліотека позиціонується як linux-only. Для Consul єдиним варіантом виявився ppconsul. До нього довелося додати підтримку сесій и транзакцій. Для etcd повноцінної бібліотеки, яка підтримує останню версію протоколу, так і не знайшлося, тому ми просто згенерували grpc клієнт.

Надихнувшись асинхронним інтерфейсом бібліотеки ZooKeeper C++, ми вирішили також реалізувати асинхронний інтерфейс. У ZooKeeper C++ при цьому використовуються примітиви future/promise. У STL вони, на жаль, реалізовані дуже скромно. Наприклад, ні методу thenщо застосовує передану функцію до результату майбутнього, коли він стає доступним. У нашому випадку такий метод необхідний для перетворення результату у формат нашої бібліотеки. Щоб уникнути цієї проблеми, довелося реалізувати свій простий пул потоків, оскільки на вимогу замовника ми не могли використовувати важкі сторонні бібліотеки, такі як Boost.

Наша реалізація then працює в такий спосіб. Під час виклику створюється додаткова пара promise/future. Новий future повертається, а переданий розміщується разом з відповідною функцією та додатковим promise у чергу. Потік із пулу вибирає з черги кілька futures та опитує їх, використовуючи wait_for. Коли результат стає доступним, викликається відповідна функція, і її значення, що повертається, передається в promise.

Цей же пул потоків ми використовували для виконання запитів до etcd та Consul. Це означає, що з бібліотеками, що знаходяться нижче, можуть працювати кілька різних потоків. ppconsul не є потокобезпечним, тому звернення до нього захищено блокуванням.
З grpc можна працювати з кількох потоків, але є тонкощі. У etcd watches реалізовані через grpc streams. Це такі двонаправлені канали для повідомлень певного типу. Бібліотека створює єдиний stream для всіх watches і єдиний потік, який обробляє повідомлення, що надходять. Так от grpc забороняє робити паралельні записи в stream. Це означає, що при ініціалізації або видаленні watch'а потрібно дочекатися, поки завершиться відправка попереднього запиту, перш ніж надсилати наступний. Ми використовуємо для синхронізації умовні змінні.

Підсумок

Дивіться самі: liboffkv.

Наша команда: Раєд Романов, Іван Глушенков, Дмитро Камальдінов, Віктор Крапивенський, Віталій Іванін.

Джерело: habr.com

Додати коментар або відгук