Обзор 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_idlib_block_num/lib_block_iddlt_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 → FORWARD | check_sync_catchup(): наша голова ≥ все пиры |
| SYNC → FORWARD | Стагнация после 3 попыток |
| FORWARD → SYNC | check_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) и срабатывает в трёх местах:
- Когда прибывает блок вне порядка (
on_dlt_block_reply) - Каждые 5 секунд из
periodic_task() - После завершения паузы снимка (
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-endpoint | 0.0.0.0:2001 | Адрес и порт прослушивания |
seed-node | — | Адрес(а) статических сид-пиров |
p2p-max-connections | — | Максимальное количество одновременных соединений пиров |
dlt-block-log-max-blocks | 100000 | Ёмкость скользящего DLT block log |
dlt-peer-max-disconnect-hours | 8 | Удалять не отвечающий пир через N часов |
dlt-mempool-max-tx | 10000 | Жёсткий лимит на количество записей в mempool |
dlt-mempool-max-bytes | 104857600 | Жёсткий лимит на общую память mempool (100 МБ) |
dlt-mempool-max-tx-size | 65536 | Отклонять транзакции крупнее этого (64 КБ) |
dlt-mempool-max-expiration-hours | 24 | Отклонять транзакции истекающие более чем через N часов |
dlt-peer-exchange-max-per-reply | 10 | Максимум адресов на ответ обмена пирами |
dlt-peer-exchange-max-per-subnet | 2 | Максимум пиров, разделяемых на /24-подсеть |
dlt-peer-exchange-min-uptime-sec | 600 | Минимальное время работы пира перед разделением адреса |
dlt-stats-interval-sec | 300 | Интервал между выводом статистики пиров в лог (мин. 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, не реального времени |
См. также: Сообщения, Сценарии синхронизации, Форвард-режим, Справочник статистики, Снимок, Разрешение форков.