Блокчейн — инновационная технология, обещающая улучшить многие сферы человеческой жизни. Она переносит реальные процессы и продукты в цифровое пространство, обеспечивает скорость и надежность финансовых операций, снижает их стоимость, а также позволяет создавать современные 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
  • Etherium
  • Soild

Тестирование


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

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

Тесты проводились на 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 сабмит 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 (d3b), #89(214)
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 хорошо оптимизирован на данные операции.

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

Запись в базу


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

Для высокой производительности мы настроили Redis более тонко:

  • Установили unix socket соединение.
  • Отключили сохранения состояния на диск (для надежности можно настроить реплику и уже в отдельном Redis делать сохранение на диск).

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

При использовании стандартной NodeJS библиотеки Redis получили производительность в 18к транзакций в секунду. Cкорость упала в 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).slice();` и ` BD.decode(buffer, Protocol)` мы преобразовываем данные в `Buffer` для сохранения в Redis или пересылки другой ноде и извлечения данных обратно.

Также у нас есть 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)
  }
  ```

где:

  • `code` — код сообщение, например 6 — PREPARE_NEW_BLOCK, 7 — BLOCK_VALID, 8 — BLOCK_COMMIT;
  • `versionProtocol` — версия протокола, так как в сети могут быть подняты ноды с разными версиями и они могут работать по-разному;
  • `seq` — идентификатор сообщения;
  • `countChunk` и `chunkNumber` необходимы для дробления больших сообщений;
  • `length` и `payload` длина и сами данные.

Так как мы заранее типизировали данные, конечная система работает намного быстрее, чем `rlp` библиотека от Эфириума. К сожалению, нам пока не удалось от нее отказался, так как необходимо доработать смарт-контракт, что мы и планируем сделать в будущем.

Если у нас получилось достигнуть скорости 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.

Комментарии (1)


  1. JekaMas
    12.10.2019 13:17

    Любопытно. Вы, как я понимаю, автор оригинальной статьи?
    На прошедшем devcon присутствовали? Есть ещё материалы?