Пяць студэнтаў і тры размеркаваныя key-value сховішчы

Або як мы пісалі кліенцкую C++ бібліятэку для ZooKeeper, etcd і Consul KV

У свеце размеркаваных сістэм існуе шэраг тыпавых задач: захоўванне інфармацыі аб складзе кластара, кіраванне канфігурацыяй вузлоў, дэтэкцыя збойных вузлоў, выбар лідэра і іншыя. Для вырашэння гэтых задач створаны спецыяльныя размеркаваныя сістэмы - сэрвісы каардынацыі. Цяпер нас будуць цікавіць тры з іх: ZooKeeper, etcd і Consul. З усёй багатай функцыянальнасці Consul мы засяродзімся на Consul KV.

Пяць студэнтаў і тры размеркаваныя key-value сховішчы

Па сутнасці ўсе гэтыя сістэмы ўяўляюць сабой адмоваўстойлівыя лінеарызуецца key-value сховішчы. Хоць іх мадэлі дадзеных і маюць істотныя адрозненні, пра што мы пагаворым пазней, яны дазваляюць вырашаць адны і тыя ж практычныя праблемы. Відавочна, кожнае прыкладанне, якое выкарыстоўвае сэрвіс каардынацыі, завязваецца на адзін з іх, што можа прыводзіць да неабходнасці падтрымліваць у адным датацэнтры некалькі сістэм, якія вырашаюць аднолькавыя задачы, для розных прыкладанняў.

Ідэя, закліканая вырашыць гэтую праблему, зарадзілася ў адным аўстралійскім кансалтынгавым агенцтве, а нам - невялікай камандзе студэнтаў - выпала яе рэалізоўваць, пра што я і збіраюся расказаць.

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

Мадэлі дадзеных

Каб распрацаваць агульны інтэрфейс для трох розных сістэм, трэба зразумець, што ж у іх агульнага, і чым яны адрозніваюцца. Давайце разбірацца.

Зоопарк

Пяць студэнтаў і тры размеркаваныя key-value сховішчы

Ключы арганізаваны ў дрэва і называюцца вузламі (znodes). Адпаведна, для вузла можна атрымаць спіс ягоных дзяцей. Аперацыі стварэння znode (create) і змены значэння (setData) падзеленыя: чытаць і змяняць значэнні можна толькі ў існых ключоў. Да аперацый праверкі існавання вузла, чытання значэння і атрыманні дзяцей можна прывязваць watches. Watch - гэта аднаразовы трыгер, які спрацоўвае, калі версія адпаведных дадзеных на серверы змяняецца. Для выяўлення адмоў служаць эфемерныя вузлы. Яны прывязваюцца да сесіі які стварыў іх кліента. Калі кліент закрывае сесію або перастае апавяшчаць ZooKeeper аб сваім існаванні, гэтыя вузлы аўтаматычна выдаляюцца. Падтрымліваюцца простыя транзакцыі - набор аперацый, якія або ўсё паспяхова выконваюцца, або не выконваюцца, калі хаця б для адной з іх гэта немагчыма.

і г.д.

Пяць студэнтаў і тры размеркаваныя key-value сховішчы

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

У etcd няма стандартнай аперацыі compare-and-set, але ёсць сёе-тое лепей – транзакцыі. Вядома, яны ёсць ва ўсіх трох сістэмах, але ў etcd транзакцыі асабліва добрыя. Яны складаюцца з трох блокаў: check, success, failure. Першы блок змяшчае набор умоў, другі і трэці - аперацыі. Транзакцыя выконваецца атамарна. Калі ўсе ўмовы дакладныя, то выконваецца блок success, інакш - 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). Пры гэтым усе метады маюць строгія перадумовы: create верне памылку, калі вузел ужо створаны, delete ці 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, які прымяняе перададзеную функцыю да выніку future, калі ён становіцца даступны. У нашым выпадку такі метад неабходны для пераўтварэння выніку ў фармат нашай бібліятэкі. Каб абыйсці гэтую праблему, прыйшлося рэалізаваць свой просты пул патокаў, паколькі па патрабаванні заказчыка мы не маглі выкарыстоўваць цяжкія іншыя бібліятэкі, такія як 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

Дадаць каментар