Python: будуємо розподілену систему c PySyncObj

Уявіть, що у вас є клас:
class MyCounter(object):
def __init__(self):
self.__counter = 0
def incCounter(self):
self.__counter += 1
def getCounter(self):
return self.__counter

І ви хочете зробити його розподіленим. Просто наслідуєте його від SyncObj (передавши йому список серверів, з якими потрібно синхронізуватися) і відзначаєте декоратором @replicated всі методи, які змінюють внутрішній стан класу:
class MyCounter(SyncObj):
def __init__(self):
super(MyCounter, self).__init__('serverA:4321', ['serverB:4321', 'serverC:4321'])
self.__counter = 0
@replicated
def incCounter(self):
self.__counter += 1
def getCounter(self):
return self.__counter

PySyncObj автоматично забезпечить реплікацію вашого класу між серверами, відмовостійкість (все буде працювати до тих пір, поки живе більше половини серверів), а також (при необхідності) асинхронний дамп вмісту на диск.
На базі PySyncObj можна будувати різні розподілені системи, наприклад розподілений м'ютекс, децентралізовані бази даних, білінгові системи та інші подібні штуки. Всі ті, де на першому місці стоїть надійність і відмовостійкість.

Загальна інформація

Для реплікації PySyncObj використовує алгоритм Raft. Raft — це простий алгоритм досягнення консенсусу в розподіленій системі. Raft розроблявся в якості більш простої заміни алгоритму Paxos. Коротко алгоритм raft працює наступним чином. Серед всіх вузлів вибирається лідер, який пінг інші вузли через певний проміжок часу. Кожен вузол вибирає випадковий проміжок часу, який він буде чекати отримання пінгу від лідера. Коли час очікування закінчується, а пінг від лідера не прийшов — вузол вважає, що лідер впав і посилає іншим вузлам повідомлення, в якому говорить, що він сам став лідером. При вдалому збігу обставин на цьому все і закінчується (інші вузли погоджуються). А у випадку, якщо два вузла захотіли стати лідерами одночасно, процедура вибору лідера повторюється (але вже з іншими випадковими значеннями часу очікування). Докладніше про вибір лідера ви можете дізнатися подивившись візуалізацію, або почитавши наукову статтю.

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

Щоб не втратити вміст (наприклад, при виключенні взагалі всіх серверів), його потрібно періодично зберігати на диск. Так як кількість даних може бути дуже великим, вміст зберігається асинхронно. Щоб мати можливість працювати з даними і паралельно зберігати їх на диск, PySyncObj використовує CopyOnWrite через fork процесу. Після fork-а процес батько і дочірній процес мають загальну пам'ять. Копіювання даних здійснюється операційною системою лише в разі спроби перезапису цих даних.

PySyncObj реалізований повністю на Python (підтримується Python 2 і Python 3) і не використовує будь-яких зовнішніх бібліотек. Робота з мережею відбувається за допомогою select або poll, в залежності від платформи.

Приклади

А тепер кілька прикладів.

Key-value storage
class KVStorage(SyncObj):
def __init__(self, selfAddress, partnerAddrs, dumpFile):
conf = SyncObjConf(
fullDumpFile=dumpFile,
)
super(KVStorage, self).__init__(selfAddress, partnerAddrs, conf)
self.__data = {}
@replicated
def set(self, key, value):
self.__data[key] = value
@replicated
def pop(self, key):
self.__data.pop(key, None)
def get(self, key):
return self.__data.get(key, None)

Вообщем-то все те ж саме що і з лічильником. Для того щоб зберігати дані на диск створюємо SyncObjConf і передаємо йому fullDumpFile.

Callback
PySyncObj підтримує callback-і — ви можете створювати методи, які повертають значення, вони автоматично будуть прокинуты в callback:
class Counter(SyncObj):
def __init__(self):
super(Counter, self).__init__('localhost:1234', ['localhost:1235', 'localhost:1236'])
self.__counter = 0
@replicated
def incCounter(self):
self.__counter += 1
return self.__counter

def onAdd(res, err):
print 'OnAdd: counter = %d:' % res

counter = Counter()
counter.incCounter(callback=onAdd)


Distributed lock
Приклад трохи складніше — розподілений лок. Весь код можете подивитися на github, а тут я просто опишу основні аспекти його роботи.

Почнемо з інтерфейсу. Лок підтримує наступні операції:
  • tryAcquireLock — спроба взяти лок
  • isAcquired — перевірка, узятий чи лок або відпущений
  • release — відпустити лок
Перший можливий варіант реалізації лока — аналогічний key-value сховища. Якщо по ключу lockA щось є, значить лок узятий, інакше він вільний, і ми можемо самі його взяти. Але не все так просто.

По-перше, якщо ми просто скористаємося kv-сховищем з прикладу вище без усяких модифікацій, то операції перевірки наявності елемента (перевірки взято чи лок) і запису елемента (взяття лока) будуть не атомарны (тобто ми можемо переписати чийсь інший лок). Тому перевірка і взяття лока повинні бути однією операцією, що реалізується всередині реплицируемого класу (в даному випадку в tryAcquireLock).

По-друге, у разі, якщо якийсь із клієнтів, які взяли лок впаде, лок залишиться висіти назавжди (ну або поки клієнт не переподнимется і не відпустить його). У більшості випадків це небажану поведінку. Тому ми введемо тайм-аут, після якого lock буде вважатися вільною. Також доведеться додати операцію, що підтверджує взяття лока (назвемо її ping), яка буде викликатися з інтервалом timeout / 4, і яка буде продовжувати життя взятим лок.

Третя особливість — репліковані класи повинні забезпечувати ідентичну поведінку на всіх серверах. Це означає, що вони не повинні використовувати усередині себе ніяких даних, які можуть відрізнятися. Наприклад, список процесів на сервері, значення random-а чи час. Тому якщо ми все ж хочемо використовувати час — доведеться передавати його в якості параметра всіх методів класу, в яких воно використовується.

З урахуванням цього, отримана реалізації складається з двох класів — LockImpl, є реплицируемым об'єктом а так само Lock, обгортка над ним. Всередині Lock ми автоматично додаємо поточний час до всіх операцій над LockImpl а так само здійснюємо періодичний ping з метою підтвердити взяті локі. Одержаний лок — всього лише мінімальний приклад, який можна допрацьовувати з урахуванням необхідної функціональності. Наприклад, додати колбэки, що інформують нас про взяття та відпусканні лока.

Висновок

Ми використовуємо PySyncObj в проекті WOT Blitz для синхронізації даних між серверами в різних регіонах. Наприклад, для лічильника залишилися танках під час івенту ИС-3 Захисник. PySyncObj є непоганий альтернативної існуючим механізмам зберігання даних в розподілених системах. Основні аналоги – різні розподілені БД, наприклад, Apache Zookeeper, etcd та інші. На відміну від них PySyncObj не є БД. Він є інструментом більш низького рівня і дозволяє відтворити складні кінцеві автомати. Крім того, він не вимагає зовнішніх серверів і легко інтегрується в python програми. З недоліків поточної версії – потенційно не найвища продуктивність (зараз це повністю python код, є плани спробувати переписати у вигляді c++ экстеншена) а так само відсутність поділ на серверну / клієнтську частину – іноді може виникнути необхідність мати велику кількість нод-клієнтів (часто підключаються / відключаються) і лише кілька постійно працюючих серверів.

Посилання

  • github.com/bakwc/PySyncObj — вихідні коди проекту
  • pip install pysyncobj — установка через pypi
  • raft.github.io — сайт протоколу raft (опис та візуалізація)
  • ramcloud.stanford.edu/raft.pdf — оригінальна публікація raft (з докладним описом деталей реалізації)
  • habrahabr.ru/post/222825 — Консенсус в розподілених системах. Paxos
Джерело: Хабрахабр

0 коментарів

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