История об асинхронном серверном EAV‑движке, epoll'е, битовых полях и шардинге, который не смог.
Это должен был быть очередной вялотекущий рутинный проект TCP сервера, listen socket, пул потоков, пул соединений, СУБД и синхронизация всего этого добра. Сказать, что скучно — ничего не сказать.
Но у меня было несколько интересных мыслей и желание реализовать что‑то достойное. Писать чистый и изящный код, чтобы каждая деталь была на своём месте, все структурировано и ничего лишнего. Ресурсы позволяли писать код для удовольствия.
Этот сервер не стоит миллион рублей. Это обычная облачная виртуалка: 4 vCPU, 4.5 ГБ RAM, AlmaLinux 8. И она выдала 1М+ RPS на пакетах по 4 байта. Сервер стоял на 40% CPU.
Я прогнал тесты ещё несколько раз — результат не менялся. Сравнил счетчик сервера со счетчиками эмуляторов. Сервер стабильно держал 1М с копейками. Запросил информацию по производительности NGINX. Да ладно!?
Технические детали
Вот скажите мне, кому нравится синхронизация? Никому не нравится, это боль, что‑то типа неизбежного зла. Сначала наплодить потоков, а потом на каждый чих придумывать хитрые способы: мьютексы, семафоры, барьеры памяти, разделяемую память, атомарные операции, лишь бы развести их, чтобы они не натворили всякого. Редкий разработчик не мечтал обойтись без синхронизации.
Ну так можно! Много потоков нужны только для блокирующих вызовов, они используются в основном, для того, чтобы висеть. Ничего не должно блокировать поток, вот строго ни одного блокирующего вызова. И не надо изображать из себя диспетчер задач. Вся магия происходит ниже: оптимизатор компилятора и планировщик операционной системы сами разберутся, как распределить нагрузку по ядрам. Они сделаны умнее. Ну или по крайней мере опытнее.
Итак, всё просто: один поток, один epoll, несколько listen сокетов. Все клиентские сокеты — неблокирующие. epoll_wait ждёт события на всех сразу. Пришёл EPOLLIN на listen сокете — принимаем новое соединение. EPOLLIN на клиентском сокете — читаем. EPOLLOUT — пишем. EPOLLERR или EPOLLHUP — закрываем. Синхронизация? Нет, не слышали.
// Инициализация listen_fd = socket(...); epoll_fd = epoll_create(...); // Главный цикл while (true) { events = epoll_wait(epoll_fd, ...); for (event : events) { if (event.fd == listen_fd) { client_fd = accept4(...); add_to_epoll(client_fd); } else if (event.events & EPOLLIN) { read_data(event.fd); } else if (event.events & EPOLLOUT) { write_data(event.fd); } else if (event.events & (EPOLLERR | EPOLLHUP)) { close_connection(event.fd); } } }
А как же СУБД? Опять же несложно. У libpg и libmariadb есть неблокирующие режимы. После подключения забрать дескриптор сокета, добавить его в epoll. Когда придут данные — epoll_wait вернёт управление. Проверить готовность, работа с ответом. Никаких потоков, никаких блокировок. Профит.
Далее, код не должен зависеть от данных. Наборов данных может быть сколько угодно — EAV помогает структурировать и унифицировать их. Сущности одного типа различаются номером, а атрибуты — своим двойным ключом. Гибкость без потери контроля. У каждого атрибута есть тип значения и само значение. Реализовано восемь типов значений: от int8 до binary.
А теперь — протокол. Бинарный, конечно. HTTP и JSON я оставил тем, кто не считает байты, тем более биты.
Каждый пакет начинается с заголовка — 4 байта с битовыми полями. Всё, что нужно, упаковано в биты: длины ID и данных, CRC16, команда. Никаких пустых мест.
Дальше — ID, размер и ключи с переменной длиной, от 0 байт. Заголовок сущности или атрибута — ещё 2 байта, тоже с битовыми полями.
Почему не Protobuf? Нет, спасибо. Я не хочу тащить схему, теги и метаданные туда, где достаточно четырёх байтов.
Да, Protobuf крут. Для сложных систем с эволюцией схемы — да. Но у меня EAV. Там всё просто и статично. И мой протокол оказался на 15–20% компактнее и на 30–40% быстрее. Иногда самописный велосипед быстрее заводского.
А теперь — разочарования
300K RPS на кэше? Ладно, простим. Но 20K RPS на PostgreSQL? Серьёзно? Настройки СУБД сколько‑нибудь заметного ускорения не дали.
PING 1M — это предел сети (800 Мбит) для 4-байтовых пакетов. READ 300K — предел сети для 21-байтовых пакетов. Сервер может больше, но сеть не пускает. А вот БД... БД — это как к гоночному болиду прицепить асфальтоукладчик.
Тип нагрузки | Размер пакета (in/out) | RPS | Ограничение |
|---|---|---|---|
PING | 4 / 4 байта | 1М+ | Сеть (800 Мбит/с) |
READ из кэша | 21 / 21 байт | ~300,000 | Сеть (забит канал) |
READ из PostgreSQL | 21 / 21 байт | ~20,000 | База данных |
Последняя надежда — шардинг. Два шарда — два независимых процесса, в теории линейное ускорение. На практике: +10%.
Я проверил всё: транзакций нет, блокировок нет, диски не синхронные. А прироста нет.
Промежуточный итог
Движок не чувствует нагрузки от слова совсем. На хорошем железе и ethernet способен выдать многое. В общем‑то, результаты итак превзошли самые смелые ожидания.
Узкое место с СУБД — подозрение на libpq. Это единственный чёрный ящик в прозрачной архитектуре проекта. Не верю, что в этом комбайне обошлись без синхронизации и блокировок.
И это стало отправной точкой для разработки FastPgClient — собственный драйвер PostgreSQL. Без libpq, без GSSAPI, без лишних движений. Только сокет, epoll и асинхронный парсинг. Пока прототип, позже наверное будет статья.
C++ живёт и здравствует. Проект родился не из бюджета, а из желания писать чистый код. Никаких лишних сущностей, только качественный прозрачный код.
Проект живёт здесь: https://github.com/kamaleksandr/PeeledOrange. Тут исходники, готовые к сборке примеры сервера, есть клиент на Java. Приходите, смотрите, пробуйте, задавайте вопросы. Лицензия MIT.

