Публічний тест: рішення для приватності та масштабованості в Ефіріумі

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

Враховуючи численні переваги та різноманітні сфери застосування блокчейн, може здатися дивним, що ця перспективна технологія ще не проникла у всі галузі. Проблема в тому, що сучасним децентралізованим блокчейнам не вистачає масштабованості. Ефіріум обробляє близько 20 транзакцій на секунду, що недостатньо задоволення потреб сучасного динамічного бізнесу. У той же час компанії, які використовують блокчейн технологію, не наважуються відмовитися від Ефіріуму через його високий ступінь захисту від злому та збоїв мережі.

Щоб забезпечити децентралізацію, безпеку та масштабованість у блокчейні, вирішуючи таким чином Трилему Масштабованості, команда розробників Opporty створила Plasma Cash — дочірній ланцюжок, що складається зі смарт-контракту та приватної мережі на основі Node.js, що періодично передає свій стан у кореневу ланцюжок (Ефіріум).

Публічний тест: рішення для приватності та масштабованості в Ефіріумі

Ключові процеси у Plasma Cash

1. Користувач викликає функцію смарт контракту `deposit`, передаючи в неї суму в ETH, яку він хоче помістити у токен Plasma Cash. Функція смарт-контракту створює токен та генерує подію про це.

2. Plasma Cash ноди, підписані на події смарт контракту, отримують подію про створення депозиту та додають до пулу транзакцію про створення токену.

3. Періодично спеціальні ноди Plasma Cash беруть усі транзакції з пулу (до 1 мільйона) і формують із них блок, вираховують дерево Меркле та, відповідно, хеш. Цей блок відправляється іншим нодам на верифікацію. Ноди перевіряють чи валідний хеш Меркле, чи валідні транзакції (наприклад, чи є відправник токена його власником). Після верифікації блоку нода викликає функцію `submitBlock` смарт-контракту, яка зберігає в кільцевому ланцюжку номер і хеш Меркле блоку. Смарт-контракт генерує подію про успішне додавання блоку. Транзакції видаляються з пулу.

4. Ноди, які отримали подію про саміт блоку, починають застосовувати транзакції, які були додані в блок.

5. Якийсь момент власник (або не власник) токена хоче вивести його з Plasma Cash. Для цього він викликає функцію startExit, передаючи в неї інформацію про останні 2 транзакції по токену, які підтверджують, що саме він є власником токена. Смарт-контракт, використовуючи хеш Меркле, перевіряє перебування транзакцій у блоках та відправляє токен на висновок, який відбудеться через два тижні.

6. Якщо операція виведення токена відбулася з порушеннями (токен був витрачений після початку процедури виведення або токен до виведення вже був чужим), власник токена може спростувати висновок протягом двох тижнів.

Публічний тест: рішення для приватності та масштабованості в Ефіріумі

Приватність досягається двома способами

1. Кореневий ланцюжок нічого не знає про транзакції, які формуються та пересилаються всередині дочірнього ланцюжка. Публічною залишається інформація про те, хто завів та вивів ETH в/с Plasma Cash.

2. Дочірній ланцюжок дозволяє організувати анонімні транзакції за допомогою zk-SNARKs.

Технологічний стек

  • NodeJS
  • Redis
  • Ефір
  • ґрунт

Тестування

Розробляючи Plasma Cash, ми протестували швидкість роботи системи та отримали наступні результати:

  • до 35 000 транзакцій за секунду додаються в пул;
  • до 1 тразакцій може зберігатися в блоці.

Тести проводились на 3 наступних серверах:

1. Intel Core i7-6700 Quad-Core Skylake Incl. NVMe SSD - 512 GB, 64 GB DDR4 RAM
Було піднято 3 валідуючі Plasma Cash ноди.

2. AMD Ryzen 7 1700X Octa-Core "Summit Ridge" (Zen), SATA SSD - 500 GB, 64 GB DDR4 RAM
Була піднята Ropsten testnet ETH нода.
Було піднято 3 валідуючі Plasma Cash ноди.

3. Intel Core i9-9900K Octa-Core Incl. NVMe SSD - 1 TB, 64 GB DDR4 RAM
Було піднято 1 sabmit Plasma Cash нода.
Було піднято 3 валідуючі Plasma Cash ноди.
Запускався тест на додавання транзакцій до Plasma Cash мережі.

Разом: 10 Plasma Cash нод у приватній мережі.

тест 1

Коштує ліміт на 1 мільйон транзакцій у блоці. Тому 1 мільйон транзакцій потрапляють у 2 блоки (оскільки система встигає взяти частину транзакцій і засабити поки вони вирушають).


Початковий стан: останній блок #7; в базі збережено 1 млн. транзакцій і токенів.

00:00 - запуск скрипту генерації транзакцій
01:37 - створено 1 млн транзакцій і почалося відправлення в ноду
01:46 - Сабміт нода взяла з пулу 240к транзакцій і формує блок #8. Також бачимо, що до пулу додається 320к транзакцій за 10 сек.
01:58 — блок #8 підписано та відправлено на валідацію
02:03 — блок #8 провалідовано та викликано функцію `submitBlock` смарт-контракту з хешем Меркле та номером блоку
02:10 - закінчив працювати демо-скрипт, який відправив 1 млн транзакцій за 32 сек.
02:33 — ноди почали отримувати інформацію про те, що блок #8 доданий до кореневого ланцюжка, і почали виконувати 240к транзакцій
02:40 - з пулу було видалено 240к транзакцій, які вже в блоці #8
02:56 — сабміт нода взяла з пулу 760к транзакцій, що залишилися, і почала вираховувати хеш Меркле і підписувати блок #9
03:20 - усі ноди містять 1млн 240к транзакцій та токенів
03:35 — блок #9 підписано та відправляється на валідацію в інші ноди
03:41 - сталася помилка мережі
04:40 - по таймууту припинилося очікування валідації блоку #9
04:54 — сабміт нода взяла з пулу 760к транзакцій, що залишилися, і почала вираховувати хеш Меркле і підписувати блок #9
05:32 — блок #9 підписано та відправляється на валідацію в інші ноди
05:53 — блок #9 провалідовано і відправлено до кореневого ланцюжка
06:17 - ноди почали отримувати інформацію про те, що блок #9 доданий до кореневого ланцюжка і почали виконувати 760к транзакцій
06:47 - пул очистився від транзакцій, які в блоці #9
09:06 — усі ноди містять 2 млн транзакцій та токенів

тест 2

Коштує ліміт 350к на блок. В результаті маємо 3 блоки.


Початковий стан: останній блок #9; в базі збережено 2 млн транзакцій та токенів

00:00 - скрипт генерації транзакцій вже запущено
00:44 - створено 1 млн транзакцій і почалося відправлення в ноду
00:56 - Сабміт нода взяла з пулу 320к транзакцій і формує блок #10. Також бачимо, що до пулу додається 320к транзакцій за 10 сек.
01:12 — блок #10 підписано та відправляється до інших нодів на валідацію
01:18 - закінчив працювати демо-скрипт, який відправив 1 млн транзакцій за 34 сек.
01:20 — блок #10 провалідовано і відправлено до кореневого ланцюжка
01:51 - всі ноди отримали з кореневого ланцюжка інформацію про те, що блок #10 доданий, і починають застосовувати 320к транзакцій
02:01 - пул очистився на 320к транзакцій, які були додані до блоку #10
02:15 - сабміт нода взяла з пулу 350к транзакцій і формує блок #11
02:34 — блок #11 підписано та відправляється іншим нодам на валідацію
02:51 — блок #11 провалідовано і відправлено до кореневого ланцюжка
02:55 - остання нода виконала транзакції з блоку #10
10:59 — дуже довго в кореневому ланцюжку виконувалася транзакція з сабмітом блоку #9, але вона виконалася і всі ноди отримали інформацію про це і почали виконувати 350к транзакцій
11:05 - пул очистився на 320к транзакцій, які були додані до блоку #11
12:10 — усі ноди містять 1 млн 670к транзакцій та токенів
12:17 - Сабміт нода взяла з pool 330к транзакцій і формує блок #12
12:32 — блок #12 підписано та відправляється іншим нодам на валідацію
12:39 — блок #12 провалідовано і відправлено до кореневого ланцюжка
13:44 - всі ноди отримали з кореневого ланцюжка інформацію про те, що блок #12 доданий і починають застосовувати 330к транзакцій
14:50 — усі ноди містять 2 млн транзакцій та токенів

тест 3

У першому і другому серверах, одна валідуюча нода була замінена на сабміт ноду.


Вихідний стан: останній блок #84; в базі збережено 0 транзакцій та токенів

00:00 — Запущено 3 скрипти, які генерують та відправляють по 1 млн транзакцій
01:38 - створено 1млн транзакцій і почалося відправлення в сабміт ноду #3
01:50 - сабміт нода #3 взяла з пулу 330к транзакцій і формує блок #85 (f21). Також бачимо, що до пулу додається 350к транзакцій за 10 сек.
01:53 - створено 1млн транзакцій і почалося відправлення в сабміт ноду #1
01:50 - сабміт нода #3 взяла з пулу 330к транзакцій і формує блок #85 (f21). Також бачимо, що до пулу додається 350к транзакцій за 10 сек.
02:01 - сабміт нода #1 взяла з пулу 250к транзакцій і формує блок #85 (65e)
02:06 — блок #85 (f21) підписано та відправляється іншим нодам на валідацію
02:08 - закінчив працювати демо-скрипт сервера #3, який відправив 1млн транзакцій за 30 секунд
02:14 — блок #85 (f21) провалідовано і відправлено до кореневого ланцюжка
02:19 — блок #85 (65e) підписано та відправляється іншим нодам на валідацію
02:22 - створено 1млн транзакцій і почалося відправлення в сабміт ноду #2
02:27 — блок #85 (65e) провалідовано і відправлено до кореневого ланцюжка
02:29 - сабміт нода #2 взяла з пулу 111855 транзакцій і формує блок #85 (256).
02:36 — блок #85 (256) підписано та відправляється іншим нодам на валідацію
02:36 - закінчив працювати демо-скрипт сервера #1, який відправив 1млн транзакцій за 42.5 секунд
02:38 — блок #85 (256) провалідовано і відправлено до кореневого ланцюжка
03:08 - закінчив працювати справа-скрипт сервера #2, який відправив 1млн транзакцій за 47 сек.
03:38 - всі ноди отримали з кореневого ланцюжка інформацію про те, що блоки # 85 (f21), # 86 (65e), # 87 (256) додані і починають застосовувати 330к, 250к, 111855 транзакцій
03:49 - пул очистився на 330к, 250к, 111855 транзакцій, які були додані до блоків #85 (f21), #86(65e), #87(256)
03:59 - сабміт нода #1 взяла з пулу 888145 транзакцій і формує блок #88 (214), сабміт нода #2 взяла з пула 750к транзакцій і формує блок #88 (50a), сабміт нода #3 взяла з пула 670 формує блок #88 (d3b)
04:44 — блок #88 (d3b) підписано та відправляється іншим нодам на валідацію
04:58 — блок #88 (214) підписано та відправляється іншим нодам на валідацію
05:11 — блок #88 (50a) підписано та надсилається іншим нодам на валідацію
05:11 — блок #85 (d3b) провалідовано і відправлено до кореневого ланцюжка
05:36 — блок #85 (214) провалідовано і відправлено до кореневого ланцюжка
05:43 — усі ноди отримали з кореневого ланцюжка інформацію про те, що блоки #88 (d3b), #89(214) додані та починають застосовувати 670к, 750к транзакцій
06:50 - через обрив зв'язку блок #85 (50a) не був провалідований
06:55 - Сабміт нода #2 взяла з pool 888145 транзакцій і формує блок #90 (50a)
08:14 — блок #90 (50a) підписано та надсилається іншим нодам на валідацію
09:04 — блок #90 (50a) провалідовано і відправлено до кореневого ланцюжка
11:23 - всі ноди отримали з кореневого ланцюжка інформацію про те, що блок #90 (50a) доданий, і починають застосовувати 888145 3 транзакцій. При цьому вже давно сервер #88 застосував транзакції із блоків #3 (d89b), #214(XNUMX)
12:11 - всі пули порожні
13:41 - всі ноди сервера #3 містять 3млн транзакцій і токенів
14:35 - всі ноди сервера #1 містять 3млн транзакцій і токенів
19:24 - всі ноди сервера #2 містять 3млн транзакцій і токенів

перешкоди

Під час розробки Plasma Cash ми зіткнулися з такими проблемами, які поступово вирішували та вирішуємо:

1. Конфлікт взаємодії різних функцій системи. Наприклад, функція додавання транзакій в пул блокувала роботу сабміта та валідації блоків, і навпаки, що вело до просідання швидкості.

2. Не відразу було зрозуміло, як відправити величезну кількість транзакцій і навіть мінімізувати витрати на передачу даних.

3. Не було ясно, як і де зберігати дані, щоб досягти високих результатів.

4. Не було зрозуміло, як організувати мережу між нодами, оскільки розмір блоку із 1 мільйоном тразакцій займає близько 100 Мб.

5. Робота в однопотоковому режимі рве з'єднання між нодами, коли відбуваються довгі обчислення (наприклад, побудова дерева Меркле та обчислення його хеша).

Як ми з усім цим упоралися?

Перша версія Plasma Cash ноди була комбаїном, який міг робити все одночасно: приймати транзакції, собмітити і валідувати блоки, надавав API для доступу до даних. Оскільки NodeJS спочатку однопотокова, важка функція розрахунку дерева Меркле блокувала функцію додавання транзакції. Ми бачили два варіанти вирішення цієї проблеми:

1. Запустіть кілька NodeJS процесів, кожен з яких виконує певні функції.

2. Використовувати worker_threads і винести виконання частини коду потоки.

У результаті ми скористалися обома варіантами одночасно: логічно розділили одну ноду на 3 частини, які можуть працювати окремо, але водночас синхронно

1. Сабміт нода, яка приймає транзакції до пулу і займається створенням блоків.

2. Валідующая нода, яка перевіряє валідність нід.

3. API нода – надає API для доступу до даних.

При цьому до кожної ноди можна підключитись через unix socket за допомогою cli.

Тяжкі операції, такі як розрахунок дерева Меркле, ми винесли в окремий потік.

Таким чином, ми досягли нормальної роботи всіх функцій Plasma Cash одночасно і без збоїв.

Як тільки система функціонально запрацювала, ми почали тестувати швидкість і, на жаль, отримали незадовільні результати: 5 000 транзакцій на секунду та до 50 000 транзакцій у блоці. Довелося з'ясовувати, що реалізовано неправильно.

Спочатку ми почали тестувати механізм спілкування з Plasma Cash, щоб дізнатися пікову можливість системи. Раніше ми писали, що Plasma Cash нода забезпечує unix socket інтерфейс. Спочатку він був текстовим. json об'єкти пересилалися, використовуючи `JSON.parse()` та `JSON.stringify()`.

```json
{
  "action": "sendTransaction",
  "payload":{
    "prevHash": "0x8a88cc4217745fd0b4eb161f6923235da10593be66b841d47da86b9cd95d93e0",
    "prevBlock": 41,
    "tokenId": "57570139642005649136210751546585740989890521125187435281313126554130572876445",
    "newOwner": "0x200eabe5b26e547446ae5821622892291632d4f4",
    "type": "pay",
    "data": "",
    "signature": "0xd1107d0c6df15e01e168e631a386363c72206cb75b233f8f3cf883134854967e1cd9b3306cc5c0ce58f0a7397ae9b2487501b56695fe3a3c90ec0f61c7ea4a721c"
  }
}
```

Ми виміряли швидкість пересилання таких об'єктів і отримали ~ 130к в секунду. Спробували замінити стандартні функції роботи з json, але продуктивність не підвищилася. Має бути двигун V8 добре оптимізований на ці операції.

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

Запис до бази

Спочатку для зберігання даних було обрано Redis як одне з найпродуктивніших рішень, які задовольняє нашою вимогою: key-value сховище, робота з hash-таблицями, безлічі. Запустили redis-benchmark та отримали ~80к операцій на секунду у режимі 1 pipelining.

Для високої продуктивності ми налаштували Redis тонше:

  • Встановили unix socket з'єднання.
  • Вимкнули збереження стану на диск (для надійності можна налаштувати репліку і вже в окремому Redis робити збереження на диск).

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

При використанні стандартної NodeJS бібліотеки Redis отримали продуктивність 18к транзакцій в секунду. Швидкість впала у 9 разів.

Оскільки benchmark показував нам можливості у 5 разів більше, почали оптимізувати. Поміняли бібліотеку на ioredis і отримали продуктивність вже 25к на секунду. Транзакції ми додавали поодинці, використовуючи команду hset. Таким чином, ми генерували багато запитів у Redis. Виникла ідея об'єднувати транзакції в пачки та відправляти їх однією командою `hmset`. Результат - 32к в сек.

З кількох причин, які опишемо нижче, з даними ми працюємо використовуючи `Buffer` і, як виявилося, якщо його перевести в текст (`buffer.toString('hex')`) перед записом, можна отримати додаткову продуктивність. Таким чином, швидкість вдалося підвищити до 35к на секунду. На даний момент вирішили призупинити подальшу оптимізацію.

Нам довелося перейти на бінарний протокол, оскільки:

1. Система часто вираховує хеші, підписи і т.п., і для цього їй потрібні дані в Buffer.

2. При пересиланні між сервісами бінарні дані важать менше ніж текст. Наприклад, при відправленні блоку з 1 млн. транзакції, дані в тексті можуть займати більше 300 мегабайт.

3. Постійне перетворення даних впливає продуктивність.

Тому за основу ми взяли власний бінарний протокол зберігання та передачі даних, розроблений на базі чудової бібліотеки binary-data.

У результаті в нас вийшли такі структури даних:

- Transaction

  ```json
  {
    prevHash: BD.types.buffer(20),
    prevBlock: BD.types.uint24le,
    tokenId: BD.types.string(null),
    type: BD.types.uint8,
    newOwner: BD.types.buffer(20),
    dataLength: BD.types.uint24le,
    data: BD.types.buffer(({current}) => current.dataLength),
    signature: BD.types.buffer(65),
    hash: BD.types.buffer(32),
    blockNumber: BD.types.uint24le,
    timestamp: BD.types.uint48le,
  }
  ```

- Token

  ```json
  {
    id: BD.types.string(null),
    owner: BD.types.buffer(20),
    block: BD.types.uint24le,
    amount: BD.types.string(null),
  }
  ```

- Block

  ```json
  {
    number: BD.types.uint24le,
    merkleRootHash: BD.types.buffer(32),
    signature: BD.types.buffer(65),
    countTx: BD.types.uint24le,
    transactions: BD.types.array(Transaction.Protocol, ({current}) => current.countTx),
    timestamp: BD.types.uint48le,
  }
  ```

Звичайними командами `BD.encode (block, Protocol).

Також у нас є 2 бінарні протоколи для передачі даних між сервісами:

— Протокол для взаємодії з Plasma Node через unix socket

  ```json
  {
    type: BD.types.uint8,
    messageId: BD.types.uint24le,
    error: BD.types.uint8,
    length: BD.types.uint24le,
    payload: BD.types.buffer(({node}) => node.length)
  }
  ```

де:

  • `type` - Дія, яку потрібно виконати, наприклад, 1 - sendTransaction, 2 - getTransaction;
  • `payload` - Дані, які потрібно передати у відповідну функцію;
  • `messageId` — іти повідомлення, щоб можна було ідентифікувати відповідь.

- Протокол взаємодії між нодами

  ```json
  {
    code: BD.types.uint8,
    versionProtocol: BD.types.uint24le,
    seq: BD.types.uint8,
    countChunk: BD.types.uint24le,
    chunkNumber: BD.types.uint24le,
    length: BD.types.uint24le,
    payload: BD.types.buffer(({node}) => node.length)
  }
  ```

де:

  • `код` код повідомлення, наприклад 6 - PREPARE_NEW_BLOCK, 7 - BLOCK_VALID, 8 - BLOCK_COMMIT;
  • `versionProtocol` — версія протоколу, оскільки у мережі може бути піднято ноди з різними версіями і вони можуть працювати по-різному;
  • `seq` - Ідентифікатор повідомлення;
  • `countChunk` и `chunkNumber` необхідні дроблення великих повідомлень;
  • `length` и `payload` довжина та самі дані.

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

Якщо нам вдалося досягти швидкості 35 000 транзакцій за секунду, нам також потрібно обробляти їх за оптимальний час. Так як зразковий час формування блоку займає 30 секунд, нам необхідно включити до блоку 1 000 000 транзакцій, що означає пересилання більше 100 Мб даних.

Спочатку ми використовували `ethereumjs-devp2p` бібліотеку для зв'язку нод, але вона не справлялася з такою кількістю даних. В результаті ми скористалися бібліотекою `ws` та налаштували пересилання бінарних даних по websocket. Звичайно, ми також зіткнулися з проблемами при надсиланні великих пакетів даних, але ми поділили їх на чанки і тепер цих проблем немає.

Також формування дерева Меркле та вирахування хешу 1 000 000 транзакцій вимагає близько 10 секунд безперервного обчислення. За цей час встигає обірватися з'єднання з усіма нодами. Вирішили перенести це обчислення в окремий потік.

Висновки:

Насправді наші висновки не нові, але чомусь багато фахівців забувають про них при розробці.

  • Використання Functional Programming замість Object-Oriented Programming збільшує продуктивність.
  • Моноліт гірший за сервісну архітектуру для продуктивної системи на NodeJS.
  • Використання `worker_threads` для важких обчислень покращує чуйність системи, особливо під час роботи з операціями i/o.
  • unix socket стабільніше і швидше, ніж http запити.
  • Якщо потрібно швидко передавати великі дані по мережі, краще скористаються websocket-ами та надсилати бінарні дані, розбиті на чанки, які можна переправити, якщо вони не дійдуть, і потім об'єднати в одне повідомлення.

Запрошуємо вас відвідати GitHub проекту: https://github.com/opporty-com/Plasma-Cash/tree/new-version

Стаття була написана у співавторстві з Олександром Нашиваном, старшим розробником Clever Solution Inc.

Джерело: habr.com

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