← Назад

Бинарный UDP протокол для high-load систем

Пакетная обработка через ipv4.ReadBatch, SO_REUSEPORT для масштабирования на все ядра CPU и бинарный протокол без сериализации. Как MEMORIA обрабатывает миллионы пакетов в секунду с минимальными задержками.

256
пакетов/batch
1400
байт макс
N
воркеров
16MB
read buffer
Содержание
  1. Почему UDP, а не TCP
  2. Бинарный vs текстовый протокол
  3. Формат пакетов MEMORIA
  4. Пакетная обработка через ReadBatch
  5. SO_REUSEPORT и масштабирование
  6. Архитектура воркера
  7. Диспетчеризация пакетов
  8. Бенчмарки и цифры
  9. Компромиссы UDP

Почему UDP, а не TCP

TCP — надёжный протокол. Он гарантирует доставку, порядок пакетов и контроль перегрузок. Но за эту надёжность приходится платить:

Для real-time приложений (платежи, игры, телеметрия) эти накладные расходы критичны. Если пакет потерялся — клиент просто отправит запрос заново. Нам не нужна гарантия доставки на уровне транспорта, потому что у нас есть криптографические снапшоты на уровне приложения.

Ключевая идея

UDP даёт нам минимальную задержку: клиент отправляет пакет — сервер его получает. Никаких handshake, никаких ACK, никаких состояний. Если пакет потерялся — клиент повторит запрос. Это trade-off: мы меняем надёжность транспорта на скорость, а надёжность обеспечиваем на уровне приложения через снапшоты с BLAKE3-подписями.

Бинарный vs текстовый протокол

Большинство API используют JSON или protobuf. Это удобно для разработки, но создаёт накладные расходы:

✗ JSON/HTTP
  • Парсинг: ~500 ns
  • Сериализация: ~200 ns
  • Размер: 200-500 байт
  • Аллокации: 5-10 на запрос
  • Валидация: ~100 ns
✓ Бинарный UDP
  • Парсинг: ~5 ns
  • Сериализация: ~0 ns
  • Размер: 53-1400 байт
  • Аллокации: 0
  • Валидация: ~10 ns

В бинарном протоколе MEMORIA каждый байт имеет фиксированное значение. Нет парсинга, нет сериализации — просто memcpy и приведение типов через unsafe.Pointer.

Формат пакетов MEMORIA

Протокол использует три типа пакетов, различаемых по размеру и первым байтам:

1. Снапшот (128 байт + данные транзакций)

Начинается с магической строки "SNAP" (4 байта). Используется для регистрации, восстановления состояния и запроса баланса.

─────────────────────────────────────────────────────────┐
│  Offset  │  Size  │  Field                              │
├──────────┼────────┼─────────────────────────────────────┤
│  0-3     │  4     │  Magic: "SNAP"                      │
│  4-23    │  20    │  PeerID (20 цифр)                   │
│  24-31   │  8     │  Balance (uint64, little-endian)    │
│  32-63   │  32    │  UserKey (BLAKE3 хэш)               │
│  64-95   │  32    │  Padding (нули)                     │
│  96-127  │  32    │  Signature (BLAKE3 подпись)         │
│  128+    │  var   │  Transaction log (опционально)      │
└──────────┴────────┴─────────────────────────────────────┘Формат снапшота

2. Команды с подписью (53 байта)

Используются для запроса баланса (0x02) и клейма (0x01 или 0x99).

┌────────────────────────────────────────┐
│  Offset  │  Size  │  Field             │
├──────────┼────────┼────────────────────
│  0       │  1     │  Command type      │
│  1-20    │  20    │  PeerID            │
│  21-52   │  32    │  Signature (BLAKE3)│
──────────┴────────┴────────────────────┘Формат команды

3. Транзакция (89 байт)

P2P-перевод от одного пользователя к другому.

┌─────────────────────────────────────────────────────┐
│  Offset  │  Size  │  Field                          │
├──────────┼────────┼─────────────────────────────────┤
│  0       │  1     │  Command type (0x03)            │
│  1-20    │  20    │  From PeerID                    │
│  21-40   │  20    │  To PeerID                      │
│  41-48   │  8     │  Amount (int64)                 │
│  49-56   │  8     │  ReqID (уникальный ID)          │
│  57-88   │  32    │  Signature (BLAKE3)             │
└──────────┴─────────────────────────────────────────┘Формат транзакции

Обратите внимание: все числа в little-endian формате. Это нативный формат для x86/x64 процессоров, что позволяет читать их напрямую через binary.LittleEndian.Uint64() без конвертации.

Пакетная обработка через ReadBatch

Классический подход — читать по одному пакету за раз через ReadFromUDP(). Но каждый системный вызов стоит ~500 ns. При миллионе пакетов в секунду это 500 ms только на системные вызовы.

Решение: ipv4.ReadBatch() из пакета golang.org/x/net/ipv4. Эта функция читает до 256 пакетов за один системный вызов:

var msgs [BATCH_SIZE]ipv4.Message
var buffers [BATCH_SIZE][MAX_PACKET_SIZE]byte

for i := range msgs {
    msgs[i].Buffers = [][]byte{buffers[i][:]}
}

// Читаем до 256 пакетов за раз
n, err := p.ReadBatch(msgs[:], 0)

for i := 0; i < n; i++ {
    if msgs[i].N == 0 || msgs[i].N > MAX_PACKET_SIZE {
        continue
    }
    buf := buffers[i][:msgs[i].N]
    addr := msgs[i].Addr.(*net.UDPAddr)
    w.handlePacket(buf, addr)
}Go

Что происходит:

  1. Создаём массив из 256 сообщений и буферов по 1400 байт каждый
  2. Вызываем ReadBatch — один системный вызов recvmmsg() в Linux
  3. Получаем n пакетов (от 1 до 256)
  4. Обрабатываем каждый пакет в цикле
─────────────────────────────────────────────────────┐ │ Один системный вызов recvmmsg() │ │ │ │ ┌──────┐ ┌──────┐ ──────┐ ┌──────┐ │ │ │Pkt 1 │ │Pkt 2 │ │Pkt 3 │ ... │Pkt N │ │ │ │53B │ │89B │ │128B │ │1400B │ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │ │ N = 1..256 пакетов за раз │ │ Экономия: ~500 ns × (N-1) системных вызовов │ └─────────────────────────────────────────────────────┘

При загрузке 100 000 пакетов в секунду и среднем batch size 50, мы делаем всего 2000 системных вызовов в секунду вместо 100 000. Экономия: ~50 ms в секунду только на syscall overhead.

SO_REUSEPORT и масштабирование

По умолчанию один UDP-сокет может обрабатываться только одной горутиной. Для масштабирования на все ядра CPU используется SO_REUSEPORT — опция ядра Linux, позволяющая нескольким сокетам слушать один порт:

func listenUDPReusePort(port int) (*net.UDPConn, error) {
    config := &net.ListenConfig{
        Control: func(network, address string, c syscall.RawConn) error {
            return c.Control(func(fd uintptr) {
                // Разрешаем нескольким сокетам слушать один порт
                _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, 
                    unix.SO_REUSEPORT, 1)
                
                // Увеличиваем буферы чтения/записи до 16 MB
                _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, 
                    syscall.SO_RCVBUF, SOCKET_READ_BUF)
                _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, 
                    syscall.SO_SNDBUF, SOCKET_WRITE_BUF)
            })
        },
    }
    conn, err := config.ListenPacket(context.Background(), 
        "udp", fmt.Sprintf(":%d", port))
    if err != nil {
        return nil, err
    }
    return conn.(*net.UDPConn), nil
}Go

Теперь каждый воркер создаёт свой сокет на том же порту 9093. Ядро Linux автоматически распределяет входящие пакеты между сокетами:

Входящие UDP пакеты │ ▼ ┌───────────────────────┐ │ Ядро Linux │ │ (SO_REUSEPORT) │ ───┬───┬───┬───┬─────── │ │ │ │ ▼ ▼ ▼ ▼ ┌────┐┌────┐┌────┐┌────┐ │W 0 ││W 1 ││W 2 ││W 3 │ ... N воркеров │:9093││:9093││:9093││:9093│ └────┘└────┘└────┘└────┘

Количество воркеров равно количеству CPU-ядер:

numWorkers := runtime.NumCPU()
var wg sync.WaitGroup
wg.Add(numWorkers)
for i := 0; i < numWorkers; i++ {
    go workerLoop(i, &wg)
}Go

На сервере с 8 ядрами — 8 воркеров, каждый обрабатывает ~12.5% трафика. Никакой координации между воркерами не требуется — ядро само балансирует нагрузку.

Архитектура воркера

Каждый воркер — независимая горутина со своим сокетом и пулом буферов:

type Worker struct {
    id              int
    arena           [2][]byte       // Двойной буфер для аллокаций
    active          int             // 0 или 1
    off             [2]int          // Смещения в буферах
    udpConn         *net.UDPConn    // Свой сокет
    msgBuf          [128]byte       // Временный буфер
    verifyBuf       [256]byte       // Буфер для верификации
    transferMsgBuf  [256]byte       // Буфер для транзакций
    base64DecodeBuf [64]byte        // Буфер для base64
    errBuf          [64]byte        // Буфер для ошибок
}Go

Обратите внимание: все буферы предвыделены в структуре воркера. Никаких аллокаций в runtime. Метод alloc() использует двойной буфер arena[2] для временных данных:

//go:nosplit
func (w *Worker) alloc(n int) []byte {
    if n <= 0 || n > MAX_PACKET_SIZE {
        return nil
    }
    if w.off[w.active]+n > len(w.arena[w.active]) {
        w.active = 1 - w.active  // Переключаемся на другой буфер
        w.off[w.active] = 0
    }
    if n > len(w.arena[w.active]) {
        return nil
    }
    buf := w.arena[w.active][w.off[w.active] : w.off[w.active]+n]
    w.off[w.active] += n
    return buf
}Go

Это bump-allocator: просто двигаем указатель вперёд. Когда буфер заканчивается — переключаемся на второй. После обработки пакета оба буфера сбрасываются.

Диспетчеризация пакетов

Функция handlePacket определяет тип пакета по размеру и первым байтам:

func (w *Worker) handlePacket(buf []byte, addr *net.UDPAddr) {
    // Снапшот: начинается с "SNAP"
    if bytes.Equal(buf[0:4], []byte(SNAPSHOT_MAGIC)) {
        w.handleSnapshot(buf, addr)
        return
    }
    
    // Команды: 53 байта (cmd + peerID + sig)
    if len(buf) == 53 {
        cmdType := buf[0]
        if cmdType == 0x01 || cmdType == 0x02 {
            w.handleCommandWithSig(buf, addr)
            return
        }
    }
    
    // Транзакция: 89 байт
    if len(buf) == 89 && buf[0] == 0x03 {
        w.handleTransfer(buf, addr)
        return
    }
    
    // Неизвестный пакет — игнорируем
}Go

Диспетчеризация занимает ~5 ns — просто проверка длины и первых байт. Никакого парсинга JSON, никакой валидации схемы.

Бенчмарки и цифры

Реальные цифры обработки пакетов на Intel Core i7-4790:

Обработка пакета (handlePacket):
  - Диспетчеризация:     ~5 ns
  - Чтение баланса:      ~0.35 ns
  - Верификация подписи: ~100 ns (BLAKE3)
  - Запись ответа:       ~50 ns
  
Итого на пакет: ~155 ns
Пропускная способность: ~6.5M пакетов/сек на ядроОценка

Сравним с типичным HTTP/JSON API:

HTTP/JSON API
  • TCP handshake: 1.5 RTT
  • HTTP парсинг: ~500 ns
  • JSON десериализация: ~200 ns
  • Бизнес-логика: ~1000 ns
  • JSON сериализация: ~200 ns
  • TCP ACK: 1 RTT
  • Итого: ~5-10 μs
✓ MEMORIA UDP
  • ReadBatch: ~500 ns / 256 пакетов
  • Диспетчеризация: ~5 ns
  • Обработка: ~155 ns
  • WriteToUDP: ~50 ns
  • Итого: ~210 ns

Разница: 25-50 раз быстрее. И это без учёта того, что HTTP создаёт аллокации (GC нагрузка), а UDP-обработка в MEMORIA — zero-allocation.

Компромиссы UDP

UDP не идеален. Вот какие проблемы приходится решать на уровне приложения:

1. Потеря пакетов

UDP не гарантирует доставку. Если пакет потерялся — клиент не получит ответ. Решение: таймаут и повтор. Клиент ждёт ответ 100 ms, если не получил — повторяет запрос.

2. Дубликаты пакетов

Повторные запросы могут привести к двойным транзакциям. Решение: ReqID cache (разбирали в предыдущей статье). Каждый запрос имеет уникальный 8-байтовый ID, который запоминается на 10 секунд.

3. Порядок пакетов

UDP не гарантирует порядок доставки. Решение: timestamp в каждой транзакции. Если приходит транзакция с более старым timestamp, чем последняя обработанная — она игнорируется.

4. Размер пакета

Максимальный размер UDP-пакета — 65 535 байт, но на практике MTU Ethernet = 1500 байт. С заголовками IP (20) + UDP (8) остаётся 1472 байта полезной нагрузки. MEMORIA использует лимит 1400 байт для запаса.

5. DDoS-атаки

UDP легко спуфить — отправитель может подделать IP-адрес. Решение: IP rate limiter (64 шарда, 10 запросов в секунду на IP) + криптографические подписи. Даже если злоумышленник отправит миллион пакетов, без валидной подписи они будут отброшены.

Главный урок

UDP — это не «ненадёжный TCP». Это другой инструмент для других задач. Если вам нужна минимальная задержка и вы готовы решать проблемы надёжности на уровне приложения — UDP даёт преимущество в 25-50 раз. Если вам нужна гарантия доставки и порядок — используйте TCP. MEMORIA выбирает UDP, потому что для real-time платежей каждая миллисекунда на счету, а надёжность обеспечивается криптографическими снапшотами.

В следующей статье мы разберём, как BLAKE3 используется для подписей снапшотов и почему он быстрее SHA-256.