Skip to content

Обзор P2P-сети

VIZ Ledger использует пользовательский DLT P2P-протокол, который заменил устаревший сетевой уровень graphene на основе synopsis. Новая конструкция оптимизирована для DLT-режима (узлы на основе снимков со скользящими block log) и устраняет сложный synopsis предков graphene в пользу более простого обмена блоками на основе диапазонов.


Архитектура

┌─────────────────────────────────────────────────────────┐
│ p2p_plugin (плагин AppBase)                             │
│   └─ dlt_delegate (реализует dlt_p2p_delegate)          │
│        └─ мост к состоянию цепочки: db(), fork_db,      │
│           block_log                                     │
├─────────────────────────────────────────────────────────┤
│ dlt_p2p_node                                            │
│   ├─ цикл принятия (входящие TCP-соединения)            │
│   ├─ периодическая задача (тик 5с: переподключение,     │
│   │  статистика, разрывы)                               │
│   └─ dlt_peer_state × N (по одному на подключённый пир) │
├─────────────────────────────────────────────────────────┤
│ Формат провода: raw TCP, заголовок (тип + длина) + данные│
│ Модель волокон: весь I/O в одном fc::thread, кооп.      │
└─────────────────────────────────────────────────────────┘

Решения по проектированию

РешениеОбоснование
Паттерн делегатаdlt_p2p_node связывает только fc + graphene_protocol. Прямой доступ к цепочке предоставляется через dlt_p2p_delegate во избежание циклических зависимостей.
Raw TCP (без STCP-шифрования)DLT-экстренный режим переключает всех валидаторов одновременно — совместимое шифрование не требуется. Более простой протокол провода.
Кооперативные волокна (fc::thread)Весь I/O использует readsome()/writesome(), которые уступают волокно. Несколько пиров в одном потоке без мьютексов.
Отдельный P2P mempoolЦепочечный _pending_tx вступает в силу только после принятия. P2P mempool фильтрует по сроку действия, TaPoS и размеру до отправки в цепочку, снижая нагрузку на evaluator.
Встроенная замена плагинаИмя плагина по-прежнему "p2p", порт по-прежнему 2001/4243, публичный API не изменился. Старый и новый протоколы несовместимы; двойной режим создаёт изолированные подсети.

Жизненный цикл пира

Каждое соединение пира проходит через следующие состояния:

CONNECTING ──(TCP установлен)──► HANDSHAKING
              таймаут 5с ↓           ↓ таймаут 10с
              DISCONNECTED       hello/hello_reply
                    ▲                ↓
                    │           SYNCING ──(догнал)──► ACTIVE
                    │                                    │
                    └──(отключение/ошибка)───────────────┘

                    BANNED ◄──(spam_strikes ≥ 10)────────┘

Значения таймаутов:

  • Connecting → DISCONNECTED: 5 секунд
  • Handshaking → DISCONNECTED: 10 секунд

Откат переподключения: 30 с → 60 с → … → 3600 с с ±25% джиттером. Откат сбрасывается после стабильного соединения >5 минут. Пиры без ответа в течение 8 часов удаляются навсегда.

Экстренный сброс пиров: Если все пиры изолированы (ноль активных соединений) в течение 60 секунд, emergency_peer_reset() очищает все мягкие баны и сбрасывает все откаты до начального значения с немедленными попытками переподключения.


Рукопожатие Hello

При соединении инициирующий пир отправляет dlt_hello_message, содержащий:

  • head_block_num / head_block_id
  • lib_block_num / lib_block_id
  • dlt_earliest_block_num — старейший блок, доступный в скользящем DLT block log пира
  • node_status — SYNC или FORWARD

Принимающий пир отвечает dlt_hello_reply_message, содержащим:

  • fork_alignment — перекрываются ли блоки на одном форке
  • exchange_enabled — считает ли отвечающий пир отправителя обновлённым

Проверка выравнивания форков (с учётом DLT-диапазона)

Поскольку DLT-узлы обрезают старые блоки, наивное сравнение head-ID ложно помечало бы однофорковые пиры как "другой форк". Проверка многоуровневая:

СлучайПроверка
Пир не имеет блоков (head_num == 0)Выровнен
Голова пира в нашем DLT-диапазонеis_block_known(peer.head_id)
head пира + 1 == наш ранний блокЧитаем наш ранний блок, проверяем previous == peer.head_id
Запасной вариантis_block_known(peer.lib_id)

Режимы синхронизации

Каждый узел в любой момент находится в одном из двух режимов:

SYNC-режим (вытягивание)

Используется, когда узел отстаёт от сети. Узел запрашивает блоки диапазонами до 200 блоков от пира:

мы                          пир
 │──dlt_get_block_range──►│
 │◄──dlt_block_range_reply─│
 │   (до 200 блоков)       │
 │──применить каждый блок──►chain│
 │                          │
 │  (когда is_last=true)    │
 │──переход в forward       │

Обнаружение разрывов: Если our_head + 1 < peer.dlt_earliest (недостающие блоки больше не в скользящем логе пира), узел ищет другой пир, способный устранить разрыв. Если ни один пир не может обслужить разрыв, рекомендуется импорт снимка.

Защита от стагнации: Если в течение 30 секунд не получено ни одного блока, узел повторяет попытки до 3 раз, затем переходит в FORWARD-режим с предупреждением.

FORWARD-режим (проталкивание)

Используется, когда узел обновлён. Блоки рассылаются через dlt_block_message. Каждый блок транслируется всем exchange-enabled пирам, разделяющим тот же форк.

Откат FORWARD → SYNC: Если голова узла не продвигается 30 секунд (check_forward_stagnation) и хотя бы один пир опережает, узел повторно входит в SYNC-режим.

Переходы SYNC ↔ FORWARD

ПереходТриггер
SYNC → FORWARDОтвет диапазона блоков с is_last=true
SYNC → FORWARDcheck_sync_catchup(): наша голова ≥ все пиры
SYNC → FORWARDСтагнация после 3 попыток
FORWARD → SYNCcheck_forward_stagnation(): голова застряла 30с и пир опережает
FORWARD → SYNCЗаполнение разрыва не удалось и нет доступного пира

При переходе SYNC → FORWARD узел транслирует dlt_fork_status_message со статусом node_status=FORWARD всем подключённым пирам, позволяя им переоценить exchange_enabled для этого узла.


Заполнение разрывов

Заполнение разрывов — лёгкий механизм для получения небольшого числа конкретных блоков без входа в полный SYNC-режим. Использует два специальных типа сообщений (dlt_gap_fill_request / dlt_gap_fill_reply) и срабатывает в трёх местах:

  1. Когда прибывает блок вне порядка (on_dlt_block_reply)
  2. Каждые 5 секунд из periodic_task()
  3. После завершения паузы снимка (resume_block_processing())

Правила:

  • Максимум 100 блоков на запрос (GAP_FILL_MAX_BLOCKS); более крупные разрывы используют разбитые на части запросы.
  • 5-секундное охлаждение между запросами заполнения разрывов.
  • Запрашивающий пир выбирает активный пир с наибольшим номером головного блока.
  • Обслуживающий пир читает блоки из своего DLT block log; запросы вне диапазона лога отклоняются.
  • Пиры жизненного цикла SYNCING являются допустимыми кандидатами (не только ACTIVE).
  • Если подходящий пир не найден, узел немедленно переходит в SYNC-режим.

Mempool

DLT P2P уровень поддерживает собственный mempool, отдельный от цепочечного _pending_tx. Это позволяет ранней фильтрации до отправки транзакций в chain evaluator.

Проверки при приёме:

  • Дубликат по tx_id — дедупликация при получении
  • Истечение срока — отклонять уже истёкшие
  • TaPoS (tapos_block_num) — отклонять если блок-ссылка неизвестен
  • Размер — отклонять если tx.size > dlt-mempool-max-tx-size (по умолчанию 64 КБ)
  • Горизонт истечения — отклонять если срок действия более чем dlt-mempool-max-expiration-hours (по умолчанию 24 ч) в будущем

Вытеснение: Когда mempool превышает dlt-mempool-max-tx (по умолчанию 10 000) или dlt-mempool-max-bytes (по умолчанию 100 МБ), первым вытесняется запись с ближайшим сроком истечения.

Жизненный цикл:

  • Транзакции, полученные во время SYNC, помечаются как provisional и повторно проверяются при переходе в FORWARD (блоки TaPoS теперь могут быть известны).
  • При применении блока включённые транзакции обрезаются (remove_transactions_in_block).
  • При переключении форка записи с недействительным TaPoS обрезаются (prune_mempool_on_fork_switch).
  • periodic_mempool_cleanup() удаляет истёкшие и TaPoS-недействительные записи в каждом цикле.

Разрешение форков

DLT P2P уровень отслеживает состояние форка с порогом 42 блока (2 полных раунда валидаторов = CHAIN_MAX_VALIDATORS × 2).

track_fork_state() вызывается после каждого применения блока. При обнаружении конкурирующего форка, удерживаемого ≥ 42 блока, resolve_fork() вычисляет самую весомую ветвь по общему весу голосов. Ветвь-кандидат должна набрать 6 последовательных блоков подтверждения (dlt_fork_resolution_state::CONFIRMATION_BLOCKS) до переключения узла на неё (гистерезис).

Текущий статус форка предоставляется через is_on_majority_fork(), который плагин Validator использует для решения о производстве блоков.


Защита от спама

Каждый пир имеет единый счётчик spam_strikes:

  • Увеличивается при: недействительном блоке, недействительной транзакции, нарушении протокола
  • Сбрасывается при: любом допустимом пакете
  • Порог мягкого бана: 10 страйков

Мягко забаненный пир получает dlt_soft_ban_message (содержащий ban_duration_sec и понятную человеку причину) до закрытия соединения. Забаненный пир входит в состояние BANNED на указанную длительность и не будет переподключаться до её истечения.

Дедупликация соединений по IP предотвращает множественные соединения от одного узла:

  • accept_loop() отклоняет входящие соединения от IP с существующей активной записью.
  • connect_to_peer() пропускает исходящие соединения если целевой IP уже имеет активную запись.
  • Трансляция (send_to_all_our_fork_peers) отслеживает set<ip::address> и пропускает IP, уже отправленные в этой трансляции.

Толерантность к дублирующимся / внеочерёдным блокам:

  • Уже применённые блоки тихо пропускаются (не считаются спамом).
  • Внеочерёдные блоки в ответах диапазонов попадают в fork_db вместо запуска мягкого бана.
  • Ошибки десериализации не увеличивают spam strikes.
  • Слишком крупные сообщения от пиров старого протокола запускают отключение без увеличения откатного времени.

Обмен пирами

Узлы обмениваются адресами пиров для помощи в обнаружении.

Ограничение скорости: 3 запроса за 5-минутное окно на пир.

Фильтры, применяемые перед разделением адреса пира:

  • Минимальное время работы: 600 секунд
  • Разнообразие подсетей: максимум 2 пира на /24-подсеть
  • Исключение эфемерных портов: пиры с is_incoming никогда не разделяются (их порт временный)

Лимиты на ответ: dlt-peer-exchange-max-per-reply (по умолчанию 10).


Пауза/возобновление обработки блоков

Плагин snapshot (и другие плагины, требующие эксклюзивного доступа) может приостановить приём P2P-блоков через pause_block_processing(). Во время паузы:

  • periodic_task() пропускает операции, требующие read lock базы данных: sync_stagnation_check(), periodic_peer_exchange(), log_peer_stats().
  • Таймеры зависшей синхронизации и форвард-стагнации сбрасываются, чтобы узел не входил в ненужные переходы режима.
  • Продолжается ведение хозяйства без DB: переподключение, управление жизненным циклом, очистка mempool, разбанивание пиров.

При resume_block_processing() узел сначала пытается заполнить разрыв, прежде чем откатиться к SYNC-режиму.


Конфигурация

ОпцияПо умолчаниюОписание
p2p-endpoint0.0.0.0:2001Адрес и порт прослушивания
seed-nodeАдрес(а) статических сид-пиров
p2p-max-connectionsМаксимальное количество одновременных соединений пиров
dlt-block-log-max-blocks100000Ёмкость скользящего DLT block log
dlt-peer-max-disconnect-hours8Удалять не отвечающий пир через N часов
dlt-mempool-max-tx10000Жёсткий лимит на количество записей в mempool
dlt-mempool-max-bytes104857600Жёсткий лимит на общую память mempool (100 МБ)
dlt-mempool-max-tx-size65536Отклонять транзакции крупнее этого (64 КБ)
dlt-mempool-max-expiration-hours24Отклонять транзакции истекающие более чем через N часов
dlt-peer-exchange-max-per-reply10Максимум адресов на ответ обмена пирами
dlt-peer-exchange-max-per-subnet2Максимум пиров, разделяемых на /24-подсеть
dlt-peer-exchange-min-uptime-sec600Минимальное время работы пира перед разделением адреса
dlt-stats-interval-sec300Интервал между выводом статистики пиров в лог (мин. 30 с)

Лог статистики пиров

Каждые dlt-stats-interval-sec (по умолчанию 5 минут) узел выводит сводку статистики пиров:

[DLT-P2P] node=FORWARD head=#79274318 lib=#79274297 fork=MAJORITY
  peer 192.168.1.10:2001 ACTIVE  head=#79274318 exch=YES  dlt=[79174319..79274318] strikes=0
  peer 192.168.1.11:2001 SYNCING head=#79274100 exch=no   dlt=[79174319..79274100] strikes=0
  peer 192.168.1.12:2001 BANNED  ban_remaining=3540s

Поля:

  • exch=YES/no — включён ли обмен блоками/транзакциями с этим пиром
  • dlt=[min..max] — диапазон DLT block log, который пир может обслуживать
  • strikes — текущее количество spam strikes (сбрасывается при любом допустимом пакете)
  • ban_remaining — секунды до истечения мягкого бана

Интервал статистики может обновляться во время работы через set_stats_log_interval().


Диагностическая сводка

СимптомВероятная причина
Узел застрял в SYNC, голова не продвигаетсяРазрыв между нашей головой и DLT-диапазоном пира — пир не может устранить; рассмотрите импорт снимка
Быстрые SYNC ↔ FORWARD колебанияНет пира опережающего, или все пиры изолированы — проверьте записи журнала emergency_peer_reset
Все пиры показывают exch=noПереход в FORWARD не уведомил пиров; должно самоисправиться в следующем цикле broadcast_chain_status
spam_strikes растёт на всех пирахВероятно расхождение форка — проверьте выравнивание форков в логах hello
unlinked_size растёт в fork_dbРодительские блоки не поступают; заполнение разрыва должно восстановиться в течение 5с
peer_head_num выглядит устаревшим в статистикеОжидаемо — peer_head_num является снимком из последнего обмена hello/fork_status, не реального времени

См. также: Сообщения, Сценарии синхронизации, Форвард-режим, Справочник статистики, Снимок, Разрешение форков.