Страх и ненависть в распределённых системах

54d4292f59864b5e9c85826d68d590ab.jpg

Роман Гребенников объясняет сложность построения распределённых систем. Это — доклад Highload++ 2016.

Всем привет, меня зовут Гребенников Роман. Я работаю в компании Findify. Мы делаем поиск для онлайн-магазинов. Но разговор не об этом. В компании Findify я занимаюсь распределенными системами.

Что же такое распределённые системы?

7432044d52b343d8bb921a1d2b7c720b.png

Из слайдов понятно, что «страх и ненависть» — это обычное дело в IT, а распределённые системы — не совсем обычное. Мы попытаемся понять вначале, что такое распределённые системы, почему тут такая боль, и почему нужен этот доклад.

Представим, что у вас есть какое-то приложение, которое где-то там работает. Допустим, оно серверное, но отличается оно от обычных приложений тем, что у него есть какое-то внутреннее состояние. Например, у вас есть игра, внутреннее состояние — это мир, где человечки набегают и прочее. Рано или поздно вы растёте, и ваше внутреннее состояние всё пухнет, оно изменяется и перестаёт помещаться на один сервер.

040b6cea49e24d8990e59ba0ebfc813a.png

Тут на картинке Винни-Пух застрял в норе.

Если вы начали расти и перестали помещаться в один сервер, то надо что-то делать.

У вас есть несколько вариантов.

Вы можете взять сервер помощнее, но это может быть тупиковый путь, потому что вы возможно уже работаете на самом быстром сервере, который есть.

Мы можете оптимизировать, но что — непонятно, по щелчку всё ускорить в два раза тяжело.

Вы можете встать на очень шаткую дорожку — создание распределенных систем.

Эта дорожка шаткая и страшная.

О чём я буду сегодня рассказывать


Сначала мы поговорим, что такое распределенные системы. Немного матчасти, почему это важно, что такое целостность, как эту целостность причёсывать, какие есть подходы к проектированию распределённых систем, которые данные не теряют или теряют их совсем чуть-чуть, какие есть инструменты для проверки ваших распределенных систем на то, что у вас всё хорошо с данными или почти всё хорошо.

Поскольку одна теория — это скучно, мы поговорим немножко по делу и немножко практики. Мы возьмём вот этот ноутбук и напишем свою маленькую простенькую распределенную базу данных. Не совсем базу данных, там будет не key value хранилище, а value хранилище. Потом попробуем доказать, что она не теряет данные, причем неоднократно и в особо страшных позах.

Дальше немного философии «как с этим жить».

Мы представляем, что распределённые системы — это такие штуки, которые работают где-то далеко, а мы сидим все здесь. Это могут быть сервера, это могут быть мобильные приложения, которые друг с другом общаются, у них есть какое-то внутреннее состояние. Если вы со своими друзьями переписываетесь по смс, то вы тоже часть распределенной системы, у вас есть общее состояние, о чём вы пытаетесь договориться. В общем случае распределённая система — это такая шутка, которая состоит из нескольких частей, и эти части общаются друг с другом. Но всё усложняется тем, что они общаются с задержками и с ошибками. Это всё сильно усложняет.

Небольшой пример из жизни.

Мы однажды писали веб-паук, который ходил по Интернету, скачивал всякие разные странички. У него была большая очередь задач, мы туда наваливали все. У нас есть несколько базовых операций у очереди. Это что-то взять из очереди, что-то положить в очередь. Также у нас была третья операция для очереди: проверить есть ли объект в очереди, чтобы два раза одно и тоже туда не класть.

a7f8a625010d474590b59778c8df4020.png

Проблема была в том, что очередь была довольно большая, и в память она не помещалась. Мы подумали: что здесь сложного? Мы же умные, на HighLoad ходим. Поэтому давайте эту очередь разрежем на кусочки, раскатаем на разные сервера. Каждый сервер будет заниматься своим кусочком очереди. Да, мы немножечко потеряем в целостности, в том плане что мы не сможем взять самый первый элемент из очереди, но мы сможем взять почти самый первый. Просто выбрав случай шард, взяв из него, и всё хорошо. То есть если взять из очереди, почти всё хорошо, чуть-чуть стала сложнее логика. Положить в очередь — тоже всё просто, мы смотрим, в какой шард класть, и кладём. Проверить, есть ли в очередь, тоже никаких проблем. Да, бизнес-логика стала чуть-чуть сложнее, но, по крайней мере, она стала некритичной, и крови здесь вроде как никакой нет.

Какие могут быть проблемы


Мы понимаем, что если у нас добавилось какое-то сетевое взаимодействие, и у нас стало больше компонентов, то у системы стала меньше надежность. Если меньше надёжность, то обязательно что-нибудь пойдёт не так. Если с софтом и железом, всё понятно. С железом можно взять сервера побрендовее, с софтом — не деплоить по пьяни в пятницу. А с сетью, такая штука, ломается независимо от того, что вы с ней делаете. У Microsoft есть отличная статья со статистикой отказа сетевого оборудования в зависимости от типа коммутатора в Windows Azure. Вероятность того, что порт load balancer крякнет в течение года, порядка 17%. То есть если вы не предусматриваете что делать в случае отказа, то вы рано или поздно хлебнёте кого-нибудь добра.

Самая популярная проблема, которая случается с сетью — это NETSPLIT. Когда ваша сеть развалилась пополам, либо она постоянно развалилась, либо у вас потеря пакетов. В результате этого она то развалилась, то не развалилась.

Что же будет у нас с очередью на шардинге, если у нас проблемы с сетью?

Если что-то взять из очереди, если мы готовы взять не самый первый элемент из очереди, то мы можем просто брать из тех шардов, которые нам доступны, и как-то дальше жить.

Если нам нужно проверить, есть ли компонент в очереди, то тут всё усложняется тем, что если нам нужно зайти на тот шард, который нам недоступен, то мы ничего не можем сделать.

Но всё усложняется с тем, когда нам нужно что-то в очередь положить, а шард недоступен. Нам деть это некуда, потому что тот шард где-то далеко. Ничего не остается, кроме как это потерять, либо куда-то отложить и потом что-то с этим делать. Потому что мы не заложили отказы в дизайн системы.

fcb6a7d9d19d45768941b1bdba5ebfca.png

На картине дядечка грустит. Потому что он не заложил отказы в дизайн системы. Мы-то умные, мы-то знаем, что наверняка есть другие дядечки, которые очень умные и придумали много разных вариантов, как с этой проблемой жить.

95dda64a80974b8f86c04072bfb2b424.png

Тут на сцене появляется CAP-теорема.

CAP-теорема — это краеугольный камень проектирования распределённых систем. Это теорема, которая формально не теорема, а эмпирическое правило, но все её называют теоремой.

Звучит она следующим образом. У нас есть три кита создания распределённых систем. Это целостность, доступность и устойчивость к сетевым проблемам. У нас есть три кита, мы можем выбрать любые два. Причем не совсем любые два, почти любые два. Чуть позже мы об этом поговорим.

По порядку — что такое целостность, доступность и устойчивость. Это же вроде теорема, должны быть формальные описания.

Доступность


Это доступность (availability), и она подразумевает под собой постоянную доступность. Каждый запрос к системе, любой запрос к системе к любому живому узлу должен быть успешно обработан. То есть если мы часть запросов куда-то на потом откладываем, либо мы записи в сторонку откладываем, потому что у нас что-то пошло не так — это непостоянная доступность. Если не все узлы отвечают на запросы, или не на все запросы все узлы отвечают — это тоже непостоянная доступность с точки зрения CAP-теоремы.

Если оно у вас отвечает вот так:

9313e62aecf04af1a8827d93dee9b8ab.png

Это тоже непостоянная доступность.

Целостность


Следующий пункт — это целостность. С точки зрения целостности вы можете сказать, что существует вот столько разных типов целостности в распределённых системах:

56e2440b03ad413f97e55751165ae6d7.png

Их порядка 50-ти. Какой их из них в CAP-теореме?

В CAP-теореме самый строгий тип. Называется линеаризуемость.

Целостность, линеаризуемость. Звучит очень просто, но под собой имеет большие последствия. Если операция B началась после операции A, то B должна увидеть систему на момент окончания A или в более новом состоянии. То есть если A завершилась, то следующая операция не может видеть то, что было до A. Вроде бы всё логично, ничего сложного нет. Если переформулировать это другими словами: «Существует непротиворечивая история последовательных операций».

Сейчас мы поподробнее поговорим об этих историях.

52ba7fa5b5c74123a017ed73f25e7129.png

Представим, у нас есть какой-то регистр. Это то что мы можем прочитать, только то что мы до этого туда записали. Просто одна дырочка для переменной. У нас есть один читатель-писатель. Мы всё туда читаем-пишем, ничего сложного. Даже если у нас несколько читателей и писателей, тоже ничего сложного.

a4bd7dd4067b44ae8ee699f634ce3cd9.png

Но как только мы перемещаем из слайда в реальный мир, эта диаграмма выглядит немножко по-другому, потому что у нас появляются сетевые задержки. Мы точно не знаем, когда. Запись случилась между w и w1, где именно она случилась, мы не знаем. То же самое с чтениями. С точки зрения истории у нас, допустим, можно записать три таких простеньких истории.

ab519a4f2ef248c8a77e7367a6735909.png

Сначала мы прочитали a, потом записали b, потом прочитали b, четко как на картинке нарисовано. В принципе возможна и другая история, когда мы прочитали a, мы снова прочитали a, а потом мы записали b, если у нас всё как на картинке.

Третья история. Если мы прочитали a, а потом внезапно прочитали b, а потом записали b, она противоречит сама себе, потому что мы прочитали b до того, как его записали. С точки зрения линеаризуемости такая история не линеаризуема, но CAP-теорема требует, чтобы была хотя бы одна история, которая не противоречит сама себе. Тогда ваша система линеаризуема. Их может быть несколько.

Устойчивость


Последний пункт — это буква P. Partition tolerance, по-русски можно сказать, что это «устойчивость к сбоям в сети». Выглядит она следующим образом:

597653238cdc443291b8f56f123d1c0e.png

Представим, что у вас есть несколько серверов, и рано утром тут проехал экскаватор и перерубил между ними провода. У вас есть два выхода, если ваш кластер развалился пополам. Первый выход: большая половинка живет, а меньшая отвалилась. Вы потеряли доступность, потому что меньшая отвалилась. Зато большая живет. Зато не потеряли целостность. Либо работают обе половинки, мы и тут и там принимаем записи, всё принимаем, всё хорошо. Только потом, когда провод спаяют, мы поймем, что у нас была одна система, а стало две, и они живут своей жизнью.

С точки зрения CAP-теоремы у нас есть три возможных подхода к проектированию системы. Это системы CP / AP / AC в зависимости от двух комбинаций из трёх.

С AC-системами есть проблема. С одной стороны, они гарантируют, что у нас есть высокая доступность и целостность. Всё классно, пока у нас не поломалась сеть. А поскольку такое часто бывает, в реальном мире AC-системы можно использовать, но только если вы понимаете те компромиссы, на которые вы идёте, когда вы используете AC-системы.

В реальном мире есть два выхода. Вы можете либо сместиться в целостности и потерять доступность, либо сместиться к доступности, но потерять целостность. Третьего не дано.

В реальной жизни есть много алгоритмов, которые реализуют различные системы CP / AP / AC. Двухфазный коммит, Paxos, кворум и прочие рафты, Gossip и прочие кучи алгоритмов.

Мы сейчас попробуем некоторые из них реализовать и посмотреть, что получится. Вы можете сказать:»10 минут прошло, у нас уже голова взорвалась, а мы только пришли». Поэтому мы попытаемся на практике что-нибудь сделать.

Что мы сделаем на практике


Мы напишем простенькую master-slave распределенную систему. Для этого мы возьмем Scala, Docker, всё запакуем. У нас будет master-slave распределенная система, которая с асинхронной/синхронной репликацией. Потом мы достанем Jepsen и покажем, что на самом деле мы всё написали правильно или неправильно. Попытаемся объяснить результат после того, как мы запустим Jepsen. Что такое Jepsen? Я чуть позже расскажу, наверняка многие из вас об этом слышали, но глазами не видели.

Итак, master-slave. В общем случае это выглядит как базовая вещь. Клиент отправляет запрос на запись мастеру. Мастер пишет на диск. Мастер синхронно или асинхронно раскидывает это всё по слейвам. Cинхронно или асинхронно отвечает клиенту, либо до того, как он как записи раскидал, либо после того.

Мы попробуем понять с точки зрения CAP-теоремы, как дела обстоят с целостностью, доступностью и прочим.

Попробуем что-нибудь накодить.

fd477e5d49f84a1eb9778e941277043a.png

У здесь есть небольшая заготовка, дальше я буду писать больше под караоке. У нас есть две функции, которые помогут нам работать с другими серверами. То есть мы будем использовать HTTP как самый простой способ взаимодействия между нодами в распределенной системе. Почему бы и нет?

У нас есть две функции. Одна пишет вот в эту ноду вот эти данные:

d2a753f4460c4183be53d8368e489309.png

Другая функция, которая читает из вот этой ноды какие-то данные, причем делает всё это асинхронно:

dcb3c5f84aef4f68b54451c0f0dcd6a9.png

Тоже полезная функция, которая парсит Response. Берёт Response, возвращает String:

e68af05b83b04ab2b022510ba6e322e7.png

Ничего сложного.

Для начала мы напишем простенький сервер нашей распределённой системы.

3892c4f5881a4d719c1446843e319399.png

Для начала, у нас тут есть домашние заготовки. Данные внутри нашей системы, весь stat мы будем хранить в одной переменной, потому что у нас тут live demo, а не настоящая база данных.

cb8dce7c6d6743bbb7005fea830065f1.png

Поэтому мы просто храним строку, её же мы будем реплицировать и прочее. Тут всякие полезные шутки, типа — прочитать из переменного окружения HOSTNAME, NODES соседние и прочее.

dd0a5d5d3d224de8a27a1a67e4dd898c.png

Мы тут напишем затычки для двух функций.

24cea0674cba464b80b8a9edea4a1e6a.png

Чтение с нашей распределенной системы и записи из нашей распределенной системы. Мы их, конечно же, сейчас реализовывать не будем, а пойдем дальше.

Тут мы запускаем нашу балалайку:

e73e74d2bd8e4ae2b0583c50d3b4edbd.png

Всё очень просто. У нас есть функция route, которая пока ещё не реализована, но она что-то делает. Она описывает rest route, которыми мы будем пользоваться.

c5f8c431962b4365ae783a3b14285fb9.png

Всё это мы на 8000-ый порт натягиваем:

592473c44b444c55937f144b68c568b6.png

Какие route у нас будут? У нас будет два route. Первый это db.

39e2403b968e4eed9a233efea0eb668a.png

Этот route для нас, для клиентов базы данных. Мы с ним работаем, а она под капотом что-то делает.

Если мы туда делаем get, то мы вызываем нашу волшебную функцию read, которую мы ещё не реализовали.

0c62a33e4490475aa6632fc84fabde1e.png

Если мы туда постим какие-то данные, то мы вызываем нашу функцию write. Вроде ничего сложного.

2ea3bb1412f3466eb7877dbf45d1a3fa.png

Помимо этого, у нас будет ещё один route, под названием local.

6aeae60b2779430abf5b9aebd6792c0c.png

Он сделан не для нас, а для того, чтобы члены распределённой системы, разные ноды могли друг с другом общаться. Одна у другой могла прочитать, что у неё там записано.

Если мы туда делаем get, то мы читаем нашу волшебную переменную value.

944aef4e85914dceb6c05fc77bced25f.png

Если мы туда делаем post, значит мы пишем в эту переменную то, что мы туда запостили.

5d2d93fd2cc5460fbcbde35b52a56e7b.png

Ничего сложного. Немножко мозг взрывается от Scala, наверное, но ничего страшного.

Мы вроде написали наш сервер. Осталось сделать логику для нашего MasterSlave. Мы сейчас будем делать отдельный класс, который будет реализовывать MasterSlave. Чтение из асинхронного MasterSlave, по факту — просто локальное чтение того, что у нас там записано в нашу переменную.

38c16e61ef264ba9ab07e140bc236275.png

Запись немного сложнее.

5dc45dc4c22a45db850070fa38d878f3.png

Мы сначала пишем в себя.

73076287338e4f7394622e9a5423310a.png

Потом пробегаемся по всем slaves, которые у нас есть, и пишем во все slaves.

df4b571fd22341edbea1681f64c3ecc2.png

Но у этой функции есть особенность, она возвращает Future.

16ed2888685a4f6aa0d1d5d01b048c75.png

Здесь мы выпаливаем асинхронно все запросы на запись к slaves, а клиенту говорим: «Всё ок, мы записали, можно идти дальше». Типичная асинхронная репликация, возможно с подводными камнями, сейчас мы это всё увидим.

Теперь мы попробуем всё это скомпилить. Это Scala, она делает это долго. Вообще должно скомпилиться, я репетировал. Скомпилилось.

Что же делать дальше


Мы написали, но нам же нужно всё это запустить, а у меня один ноутбук. А мы же делаем распределённую систему. Распределённая система с одной нодой — это не совсем распределённая система. Поэтому мы будем использовать Docker.

a6860a3e939449aa96a3364b24874c56.png

Docker — это система контейнеризации приложений, все, наверное, про неё слышали. Не все, вероятно, использовали в продакшене. Мы попробуем использовать его. Это лайтовая система виртуализации, если совсем всё упростить. У Docker есть богатая экосистема вокруг, мы не будем использовать всё из этой экосистемы. Но поскольку нам нужно запускать не один контейнер, а сразу группу, то мы будем использовать Docker Compose, чтобы их скопом сразу все накатить.

У нас есть простенький Dockerfile, но он не совсем простенький.

af5ada80769f4bc8bb228730074940bc.png

Тут Dockerfile, который ставит Java, засовывает туда SSH. Не спрашивайте, зачем он нужен. Наше приложение это всё запускает.

И у нас есть Compose файл, который описывает все 5 нод.

bac3ba4d376845b98eeaaee127c81c2a.png

У нас тут есть описание нескольких нод. Мы попробуем сейчас это всё задеплоить. У меня есть скриптик для этого.

73db9addf1044149a75ca4128ce548c0.png

Пока оно деплоится, я поменяю цвета.

6025a9bee59c47e58524fb52a1fd9f8f.png

Сейчас оно создает Docker-контейнер. Сейчас оно его запустит. Все наши 5 нод запустились.

290f7b79c2b14e6dadb8417349790d7d.png

Сейчас ждем, пока наша распределённая система стукнет о том, что она жива. На это уходит некоторое время, это же Java. Наш MasterSlave сказал, что он запустился.

ffbb950e72f94e26ad959e7bde65ba30.png

Помимо этого, всего у нас есть простенькие скриптики, которые позволят нам что-то из этой распределённой системы прочитать.

Посмотрим, что у нас в ноде n1.

4a7921cdcc3a4e02b999f43404718c59.png

Там записан 0.

Поскольку тут не было защиты от того, кто здесь master, кто slave, мы будем брать на веру, что n1 у нас всегда master. Мы будем только в него всегда писать, чтобы всё упростить.

Давайте попробуем в этот master что-нибудь записать.

put n1 1

Туда записалась единица. Посмотрим, что у нас тут в логах.

ef83f192cffc430ea86cb7b162e05ce1.png

Вот у нас пришла наша единичка здесь в записи, мы эту запись раскатали по всем slave, вот здесь она записалась.

681b1834780f4e9290e5f2af583b621d.png

Можем даже зайти на ноду n3, посмотрим, что там.

833ae5491d914a31a2d2a87eb25b618e.png

Там записана единичка.

Мы можем идти за премией, мы написали свою распределённую систему на коленке, которая даже работает. Но она работает, пока всё хорошо.

Сейчас мы попробуем сделать ей плохо. Для того, чтобы сделать ей плохо, мы возьмем такой фреймворк под называнием Jepsen. Jepsen — ое-фреймворк для тестирования распределённых систем, с одной тонкостью: он написан на Clojure. Clojure — это такой лисп, кто не в курсе. Это набор готовых тестов для уже существующих баз данных, очередей и прочего. Вы всегда можете написать свой. Помимо этого есть еще куча статей о найденных проблемах, наверное, во всех базах данных, кроме экзотики. Пожалуй, не досталось только ZooKeeper и RethinkDB. Им досталось, но чуть-чуть по сравнению с остальными. Можете почитать об этом.

Каким образом работает Jepsen


Он имитирует сетевые ошибки, потом генерирует случайные операции к вашей распределённой системе. Потом смотрит каким образом эти операции были применены к вашей распределённой системе и к эталонному поведению, к модели этой распределённой системы, и есть ли с этим проблемы.

Если Jepsen у вас нашел такую проблему:

4fd9fe77f08d419fb7abbdf15cc8145d.png

Это я тут хотел пошутить.

Если Jepsen нашел у вас какую-то проблему, то он нашел для вашей распределённой системы контрпримеры, и там действительно есть какой-то косяк. Но поскольку Jepsen-тесты носят вероятностный характер, если он ничего не нашёл, возможно, он недостаточно хорошо искал. Но если он хорошо поищет, он что-нибудь найдет. В случае, например, RethinkDB они гоняли тесты около двух недель перед релизом, чтобы доказать, что более-менее как-то работает.

Мы тут две недели тесты гонять не будем, будем секунд по пять. Наша задача с тестом Jepsen — это писать в master, читать с MasterSlave и понять, как обстоят дела с целостностью и правильно ли мы написали нашу мастер/слейв репликацию или, может быть, нет.

Jepsen-тест состоит из нескольких важных частей.

98e97a44d91e473fb9e67a7eb527f8fa.png

У нас есть генератор, который генерирует случайные операции, которые мы применяем к нашей распределённой системе. Сама распределённая система, которую мы где-то там запустили. Она может быть в Docker, может быть на реальных железных серверах, почему нет. И у нас есть эталонная модель, которая описывает поведение распределённой системы. В нашем случае это регистр, то, что мы туда записали, мы это и должны прочитать. Ничего сложного нет. В Jepsen есть огромное количество моделей на все случаи жизни, но мы будем использовать только регистр и Checker, который проверяет соответствие истории операции, примененной к распределённой системе на соответствие их к модели.

Проблема в том, что Jepsen написан на Clojure, и тесты нужно писать тоже Clojure. Если бы была возможность писать их на чём-нибудь другом, было бы классно. Но беда, беда. Clojure — это такой язык, где всегда есть список. Если, например, вы хотите сложить два числа, то вы делаете список, в котором первый элемент — это сложение, а потом два числа, в конце получится три.

e18f2de50753400a9ee4a6469bc25620.png

Вы можете задать свою функцию вызова в другую функцию под названием defn и сказать, что первый аргумент — это название функции, дальше аргумент — это функции, дальше тело функции. Если вы вызовете её таким образом, она скажет «hello, highload!». Это такой курс Clojure для начинающих.

Вы можете сказать, что этот курс Clojure выглядит вот так:

f709365d9ec048ae91e647e4a76531c6.png

И сейчас наверняка будет, то что там справа. На самом деле, да. Но я рассказал, вам достаточно, чтобы хотя бы примерно понимать, что сейчас будет проходить на кадрах, потому что Clojure немного специфичный.

Итак, Jepsen. Для начала мы опишем наш тест.

2ab9e96188074f89977b74a7ad138db6.png

Эту портянку правильно читать не слева направо, сверху вниз, потому что это лисп, лучше читать изнутри и наружу. У нас есть какая-то функция, которая возвращает описание нашего теста, который мы ещё не реализовали. Мы это описание суём в другую функцию, которая этот тест запускает, и она возвращает справочник. По этому справочнику мы выковыриваем что-то по ключу results и смотрим, есть ли в этом справочнике ключ под названием valid. Если он там есть, то тест пройден. Clojure читает вот так. Нужно сначала мозг сломать, но потом всё становится понятно.

Теперь мы опишем наш тест.

7ed8d763c52f48058c5109d221187b0b.png

Наш тест — это тоже функция, в которой нет аргументов. Она расширяет другой тест, который вообще ничего не делает, и дополняет его некоторыми штучками. Например, названием, клиентом, который он будет использовать для общения с нашей базой данных, потому Jepsen не знает ни про что, ни про какие HTTP. Checker, который мы ещё не написали, но напишем. Модель, которую мы будем использовать как эталон нашей распределённой системы. Генератор, который берет случайные операции чтения и записи, вставляет между ними задержку в 10 миллисекунд, запускает их с клиентом и пускает это всё на протяжении 5 секунд. Дальше там для работы с SSH.

Теперь мы опишем наши чтение и запись. Это тоже функции. Это же Clojure, тут всё функции, тут ничего другого нет.

0ffc950eaaf040f7961c4dd50565dea3.png

Также опишем наш клиент, но сначала HTTP-клиент.

eaabd38cda924d14b858c7592dd4a2b4.png

У нас тут несколько функций для записи в HTTP, для чтения в HTTP. Мы не будем вдаваться, как это всё делается. Но по факту если нам вернулось 200, то всё окей. Если 409 — это HTTP 409 Conflict, очень полезный код, значит, что-то не так. Мы будем его немного дальше использовать.

Дальше мы опишем наш клиент.

d3301c4b32c44bd48ee2d76bc9bf7a0f.png

Тоже функция, которая берёт хост, который мы сейчас будем писать, и расширяет интерфейс под названием client. У него есть три функции. Setup которая возвращает саму себя. Teardown которой не надо удалять клиента, там и так ничего нет внутри. invoke которая нашу операцию, которую мы туда передаём, применяет к этому хосту. У нас тут есть пятисекундный таймаут, то есть если за 5 секунд наша распределённая система не заработала, то можно сказать, что она и не заработает. Если у нас это чтение, мы выполняем HTTP-read, делаем GET-запрос. Если запись, то мы делаем HTTP-write, то есть POST-запрос. Поскольку у нас MasterSlave и у нас только одни Master, мы тут немного поменяем, мы пишем всё время в Master.

2900219413e046c59856014efeeb9dd2.png

Последний пункт, который у нас остался — это Checker. Можно было бы использовать, который внутри Jepsen под названием linearizability checker, но мы не будем это использовать, потому что я на это наступил.

631e4af0ddbf46a3bbff28f931a3a435.png

Я не знаю, что с этим делать. Поэтому я написал свой, который не падает. По факту он использует то же самое, только не пытается в конце сгенерировать красивую картинку, где он обычно и падает.

555913e99ea44f4792876786b763cbe2.png

Мы написали наш мега-тест, давайте попробуем его запустить.

5c4fd0fd250a4af2956cebde886d28a3.png

У нас есть наша распределённая система, тест, мы ждём, пока он запустится.

lein — это такая система сборки для Clojure.

Оно начало писать разные хосты, разные чтение/запись, в логах тоже магия творится — чтение/запись, репликация. В конце оно нам говорит «fail». Что-то вы здесь забыли. Тут есть список историй, который не линеаризуемый, и что-то здесь явно напутали.

Давайте посмотрим на такую ситуацию:

e4b8d23ece054214a02f8f32abfd3c8d.png

Все знают, что если мы делаем асинхронную репликацию в Master/slave, то slave у нас всегда запаздывает. Что значит запаздывание slave с точки зрения линеаризуемости? Это значит то, что здесь случилась запись, через некоторые время запись отреплицировалась, а здесь случилось чтение. В результате мы прочитали то что не должны были прочитать. Мы должны были прочитать B, а прочитали A. То есть вернулись во времени назад, когда операция вроде как уже закончилась, а мы вернулись назад и прочитали то, что было с неё. Потому что Master при асинхронной репликации говорит «ок» слишком рано, потому что еще не все slaves догнали поток репликации. Поэтому slave всегда запаздывает за master.

Вы конечно скажете, что это детский сад, давайте сделаем всё по-взрослому. Напишем синхронную репликацию, мы же умные. Она будет почти такая же, как и асинхронная, только синхронная.

05d016b884ca4331b14ad0e6de2bcdfc.png

Мы переопределим одну функцию, которая пишет в нашу распределённую систему. Она теперь делает это по-умному: она дожидается того момента, когда все slaves скажут «ок». Только потом она говорит, что хорошо, всё записалось. Если один из slaves сказал, что не «ок», то значит у нас пошла беда, и мы туда не идём.

Теперь попробуем всю эту балалайку запустить. Наверное, чувствуете, что сейчас будет какая-то подстава, не может всё так хорошо быть. 25 минут прошло, а тут человек все налайвкодил, и всё заработало, поэтому мы сейчас подождём, пока оно скомпилится. Сейчас оно всё собирает, это вам не динамически языки, когда всё сразу работает, тут всё долго и мучительно. Есть время подумать, то ли ты написал, не то написал, переписать всё, пока деплоится.

Master/slave sync запустился, отлично. Давайте теперь запустим нам Jepsen-тест снова в надежде, что всё будет хорошо. Мы же синхронную репликацию сделали, что может пойти не так. Он запускается, начало писать, но уже не так быстро. Опять пишет, опять читает, что-то там происходит. В результате опять что-то плохое происходит.

bd1483fd12ad4395ae3fe44f2db58a5e.png

У нас опять случился fail: она нам выдало огромное количество историй, которые не линеаризуемы с точки зрения линеаризуемости.

В чём же проблема?

Мы думали, что всё сделали правильно. Как-то получилось всё плохо.

ced20aa298bb4b758ddc5e6a9164a7b0.png

На картинке видно, что всё стало ещё хуже, чем было.

Представим такую ситуацию.

8bb66ec4866548e6a5555e83653f5504.png

У нас есть наша мастер-нода 1. Мы туда записали операции B и C. У нас здесь C, отреплицировали сюда. Мы же по HTTP асинхронно всё делаем, что может пойти не так. В ноду 2 они записались именно в этой последовательности. А на ноду 3 они перепутались, мы же их случайно отправили. Мы же забыли, что есть журналы и прочие штуки. Они применились не в той последовательности, в которой должны были примениться. У нас в результате вместо C получилось B, и мы тут читаем непонятно что. То ли B, то ли C, хотя должны читать C. Поэтому такая вот беда.

С точки зрения CAP-теоремы master/slave и целостности репликация зависит от того, синхронная репликация или нет. Если асинхронная, то понятно, что никакой линеаризуемости нет, потому что slaves запаздывают. С точки зрения синхронной это зависит от того, насколько вы криворукий и правильно всё реализуете. Если повезет, то хорошо. Если такие же как я, то плохо. С точки зрения доступности проблема в том, что у нас slave не умеет писать, он умеет только читать. С точки зрения высокой доступности мы не можем исполнить все те запросы, которые к нам приходят. Мы можем делать только чтение. Поэтому мы нифига не доступы.

Поэтому CAP-теорема — это очень специфичная штука. Применять её к базам данных надо очень осторожно, потому что определения её строгие, они не всегда описывают то, что есть в реальных базах данных. Потому что она описывает 1 регистр. Если вы можете все ваши транзакции и прочее свести к операциям на одном регистре, то это хорошо. Но обычно это сложно или даже невозможно. Доступность она такая доступность, но latency ничего не говорит. Если ваша распределённая система офигенно консистентна, но отвечает на запрос раз в сутки, то это тоже очень тяжело в продакшне использовать. Есть куча разных практических аспектов, которые в CAP-теореме никак не рассматриваются. Та же потеря пакетов partitions, то есть если у нас partition сетевой непостоянный, а переменный, раз в 5 секунд несколько пакетов теряются.

Поэтому по личному опыту, когда люди начинают писать распределённые системы с неокрепшим умом, у них рано или поздно знания кристаллизуются, все костыли набивают новые шишки, и получается что-то похоже на кривой-косой алгоритм консенсуса. Когда все ваши ноды пытаются договориться о общем состоянии, но этот алгоритм не очень тестирован в экстремальных случаях, они же бывают редко, зачем об этом волноваться. Но консенсус в общем случае — это соглашение об общем состоянии с возможностью ещё-то как пережить сбои, если они случились.

Такой пример:

b8a886617ae044e4b4e7ca0259761bf1.png

У нас есть клиент, который пишет в нашу распределён

© Habrahabr.ru