Почему UDP, а не TCP
TCP — надёжный протокол. Он гарантирует доставку, порядок пакетов и контроль перегрузок. Но за эту надёжность приходится платить:
- Three-way handshake — установка соединения требует 1.5 RTT (round-trip time)
- ACK-пакеты — каждый полученный пакет должен быть подтверждён
- Retransmission — потерянные пакеты переотправляются автоматически
- Head-of-line blocking — потеря одного пакета блокирует доставку всех последующих
- Состояние соединения — сервер должен хранить состояние для каждого клиента
Для real-time приложений (платежи, игры, телеметрия) эти накладные расходы критичны. Если пакет потерялся — клиент просто отправит запрос заново. Нам не нужна гарантия доставки на уровне транспорта, потому что у нас есть криптографические снапшоты на уровне приложения.
UDP даёт нам минимальную задержку: клиент отправляет пакет — сервер его получает. Никаких handshake, никаких ACK, никаких состояний. Если пакет потерялся — клиент повторит запрос. Это trade-off: мы меняем надёжность транспорта на скорость, а надёжность обеспечиваем на уровне приложения через снапшоты с BLAKE3-подписями.
Бинарный vs текстовый протокол
Большинство API используют JSON или protobuf. Это удобно для разработки, но создаёт накладные расходы:
- Парсинг: ~500 ns
- Сериализация: ~200 ns
- Размер: 200-500 байт
- Аллокации: 5-10 на запрос
- Валидация: ~100 ns
- Парсинг: ~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
Что происходит:
- Создаём массив из 256 сообщений и буферов по 1400 байт каждый
- Вызываем
ReadBatch— один системный вызовrecvmmsg()в Linux - Получаем
nпакетов (от 1 до 256) - Обрабатываем каждый пакет в цикле
При загрузке 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 автоматически распределяет входящие пакеты между сокетами:
Количество воркеров равно количеству 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:
- TCP handshake: 1.5 RTT
- HTTP парсинг: ~500 ns
- JSON десериализация: ~200 ns
- Бизнес-логика: ~1000 ns
- JSON сериализация: ~200 ns
- TCP ACK: 1 RTT
- Итого: ~5-10 μs
- 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.