Один поток и событийный цикл: откуда у Redis скорость

Redis обрабатывает команды в одном потоке — и при этом отдаёт сотни тысяч операций в секунду. Разбираем, почему single-threaded не значит медленно: событийный цикл, I/O-мультиплексирование, threaded I/O в новых версиях, пайплайнинг. И честно про обратную сторону: где один поток становится узким местом и откуда берётся хвостовая latency.

«Redis однопоточный» — фраза, которая одновременно верна и вводит в заблуждение. Обработка команд действительно идёт в одном потоке, и это осознанный выбор архитектуры, а не ограничение. Именно однопоточность даёт Redis атомарность команд без блокировок и предсказуемость, а высокую пропускную способность обеспечивает не параллелизм, а событийный цикл поверх эффективного I/O-мультиплексирования. При этом «один поток» — не вся правда: в новых версиях часть работы (сетевой I/O) вынесена в отдельные потоки.

Эта статья — вторая в серии «Redis: глубокое погружение». Она объясняет, за счёт чего Redis быстрый, и, что важнее, где эта модель упирается в потолок. Понимание событийного цикла — ключ к диагностике latency-инцидентов, о которых пойдёт речь в статье про эксплуатацию, и к трезвой оценке границы «кэш vs источник истины»: под источник истины важна не только скорость, но и предсказуемость хвостов.

Машинный зал: красный маскот Redis с одной парой рук молниеносно обслуживает очередь клиентов-маскотов через пневмопочту. Слева табло «одиночные запросы 2520 ops/s» и подпись «каждый — туда и обратно»; справа «пакет из 100 запросов — 154005 ops/s», «×61», «полный туда и обратно одним выстрелом». Сверху отдельные механические руки помечены «io-threads» с табличкой «упаковка — да, исполнение — нет». Внизу справа учёный разводит руками у графика «эффект io-threads — в пределах шума». На верстаке табличка «один за одним, но молниеносно»

В статье

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

Redis/Valkey обрабатывают все команды, поступающие от всех клиентов, в одном потоке: пока сервер выполняет одну команду, ни одна другая не может начаться. Это не техническое ограничение, которое разработчики просто не успели снять, — это осознанный архитектурный выбор, и у него есть прямое следствие, которым пользуется вся остальная модель Redis.

Следствие первое: атомарность каждой отдельной команды достаётся бесплатно, без единой явной блокировки данных. Пока INCR читает значение счётчика, увеличивает его и пишет обратно, ни один другой клиент физически не может вклиниться между этими шагами — в этот момент в процессе вообще не выполняется ничего другого. В многопоточной СУБД та же гарантия потребовала бы блокировок на структуру данных (или lock-free-алгоритмов) — источника и оверхеда, и целого класса гонок и дедлоков, которых у Redis попросту нет по конструкции. Это не побочный эффект, а фундамент: та же гарантия «выполняется от начала до конца без чужого вмешательства» — причина, по которой Lua-скрипты в Redis атомарны (об этом — в статье про Streams и Lua дальше в серии) и по которой MULTI/EXEC вообще имеет смысл как транзакционный примитив (механика — в статье про транзакции в KV и документных БД).

Следствие второе: раз поток один, высокая пропускная способность Redis не может браться из параллельного исполнения команд на разных ядрах. Она берётся из другого источника — событийного цикла (event loop) поверх эффективного I/O-мультиплексирования. Устройство простое: сервер держит один цикл, который на каждой итерации спрашивает у операционной системы, какие из открытых сокетов готовы к чтению или записи. Системные вызовы epoll (Linux) и kqueue (BSD/macOS) устроены так, что возвращают именно готовые дескрипторы, а не заставляют перебирать все соединения по кругу в поисках работы — это и есть мультиплексирование: тысячи соединений обслуживаются одним потоком без активного опроса каждого. Готовые сокеты обрабатываются один за другим: прочитать команду, выполнить её до конца, записать ответ, перейти к следующему готовому сокету. Ключевое — «до конца»: внутри одной итерации цикл синхронный, команда не может приостановиться на середине и уступить очередь другой.

flowchart LR CL(["Клиенты"]) -->|TCP| MUX["epoll / kqueue
кто из сокетов готов"] MUX --> R["Чтение + разбор протокола
потоки, если io-threads больше 1"] R --> EXEC["Выполнение команды
главный поток — всегда один"] EXEC --> W["Запись ответа
потоки, если io-threads больше 1"] W --> CL

flowchart LR
    CL(["Клиенты"]) -->|TCP| MUX["epoll / kqueue
кто из сокетов готов"] MUX --> R["Чтение + разбор протокола
потоки, если io-threads больше 1"] R --> EXEC["Выполнение команды
главный поток — всегда один"] EXEC --> W["Запись ответа
потоки, если io-threads больше 1"] W --> CL

Цена такой модели видна сразу, и она прямая: одно ядро CPU обрабатывает вообще все команды сервера. Дорогая по CPU или по объёму данных команда не «зависает» асинхронно где-то на фоне — она занимает единственный поток целиком, и всё остальное ждёт своей очереди. Это не абстрактная угроза, а конкретный и предсказуемый эффект, ему посвящён отдельный раздел ниже. А то, что́ на самом деле вынесено из этого единственного потока в новых версиях (сетевой I/O на схеме выше) и что всё равно остаётся в нём независимо от версии (выполнение команды), — тема следующих двух разделов.

Пайплайнинг: как убрать сетевой round-trip

Даже самая дешёвая по CPU команда — SET короткого значения — платит за сетевой round-trip: клиент отправляет запрос и ждёт ответа, прежде чем отправить следующую команду. Если сама команда исполняется за микросекунды, а сеть между клиентом и сервером — за десятки или сотни микросекунд, то время съедает именно сеть, а не работа сервера. Пайплайнинг решает эту проблему без изменения протокола: клиент отправляет пачку команд подряд, не дожидаясь ответа на каждую по отдельности, и получает пачку ответов одним потоком байт — round-trip платится один раз на всю пачку, а не на каждую команду.

pipe := rdb.Pipeline()
for i := 0; i < 100; i++ {
    pipe.Set(ctx, key(i), "v", 0)
}
_, err := pipe.Exec(ctx) // один сетевой round-trip на все 100 команд

Разница между командой без пайплайна и той же командой в пайплайне батчами по 100 измерена на живых redis:8.8 и valkey/valkey:8.1, один клиент, без конкуренции, N=10000 команд SET. Оговорка, важная для чтения абсолютных чисел ниже: стенд поднят с хост-машины Windows через Docker Desktop, и путь клиент→контейнер идёт через виртуализованный сетевой стек, а не голый TCP-loopback на Linux — p50≈500мкс на одиночный SET без пайплайна заметно выше типичных цифр bare-metal Linux и отражает оверхед платформы измерения, а не свойство Redis/Valkey. Относительные сравнения внутри одного и того же прогона (пайплайн vs без, io-threads=1 vs io-threads=4, Redis vs Valkey) остаются валидными. Отдельно стоит отметить: прогрев соединения перед замером ни в одном сценарии не делался — при N=10000 разовая стоимость установки соединения на результат не влияет, но методологически это стоит держать в уме.

Метрика Redis 8.8 без пайплайна Redis 8.8 пайплайн (batch=100) Ускорение Valkey 8.1 без пайплайна Valkey 8.1 пайплайн (batch=100) Ускорение
elapsed 3.968s 64.933ms ×61.1 4.034s 57.124ms ×70.6
p50 499.6мкс 5.013мкс ×99.7 499.5мкс 5.622мкс ×88.8
p95 1.021мс 10.464мкс ×97.6 1.0271мс 10.661мкс ×96.3
p99 1.1215мс 15.282мкс ×73.4 1.1205мс 11.177мкс ×100.3
throughput 2520.3 ops/s 154005.1 ops/s ×61.1 2479.0 ops/s 175057.5 ops/s ×70.6

Пайплайнинг батчами по 100 даёт throughput на два порядка выше на обоих образах — ожидаемо, потому что без пайплайна каждая команда платит полный round-trip, а с пайплайном round-trip один на все сто. Важная деталь про то, как читать колонку p50/p95/p99 с пайплайном: латентность здесь — это время всего батча, делённое на 100, то есть средняя латентность на «слот» внутри батча, а не latency отдельной сетевой операции — с одним запросом на сотню команд отдельной сетевой операции для одной команды просто не существует. Redis и Valkey здесь практически неотличимы: p50 без пайплайна совпадает до десятых микросекунды, p95 — в пределах единиц микросекунд, а разница в throughput с пайплайном (154005 против 175057 ops/s, Valkey выше примерно на 14%) — в пределах шума одного прогона, повтора для этой пары сценариев не делалось.

Абсолютные числа выше не стоит запоминать как эталон: тот же самый SET без пайплайна, один клиент, тот же хост и та же топология, но в другой сессии измерения (при проверке персистентности дальше в серии, в режиме aof-everysec) дал не 2520.3 ops/s (p50≈499.6мкс), а около 3053 ops/s (p50≈316мкс) — на 20–25% быстрее. Ни одно из двух чисел не «правильнее» другого, оба корректны в рамках оговорки про Docker Desktop/Windows — это просто разные сессии измерения на разделяемом хосте, и хороший повод не относиться к абсолютной микросекунде как к константе, даже в пределах одного и того же стенда.

Пайплайнинг стоит держать отдельно от транзакций и от Lua, хотя все три экономят на количестве обращений к серверу. Пачка команд в пайплайне выполняется сервером всё так же по одной, в том же однопоточном цикле из раздела выше, и без какой-либо гарантии, что между ними не вклинится команда другого клиента — пайплайн ускоряет доставку, а не изолирует пачку от остального мира. Изоляцию такого рода даёт MULTI/EXEC, механика которых разбирается в статье про транзакции в KV и документных БД.

Threaded I/O: что вынесено в потоки, а что — нет

Начиная с 6-й ветки Redis (и унаследовано Valkey) часть сетевой работы — чтение сырых байт из сокета, разбор протокола RESP, запись байт ответа обратно в сокет — можно вынести в настраиваемое число потоков ввода-вывода (io-threads). Само выполнение команды над структурой данных при этом остаётся на главном потоке независимо от значения io-threads — именно эта часть, а не сетевой I/O, была источником атомарности в первом разделе, и её распараллеливание сломало бы всю модель. Threaded I/O — компромисс: распараллелить упаковку/распаковку байт, оставив нетронутой однопоточность там, где она даёт гарантии.

Эффект измерен под конкурентной нагрузкой: N=10000 команд SET, поделённых на 8 параллельных горутин одного клиента (пул соединений ≥16), io-threads=1 (дефолт) против io-threads=4 с --io-threads-do-reads yes, каждая конфигурация прогнана дважды — проверка разброса между запусками, а не только между образами. Оговорка про платформу измерения действует и здесь: абсолютные ops/s в таблицах ниже — числа Docker Desktop на Windows, не bare-metal; значимо только сравнение конфигураций внутри одного прогона.

Redis 8.8

Конфигурация Прогон throughput без пайплайна throughput с пайплайном (batch=100)
io-threads=1 (дефолт) #1 12910.5 ops/s 500999.5 ops/s
io-threads=1 (дефолт) #2 12149.9 ops/s 472806.5 ops/s
io-threads=4, do-reads yes #1 13119.5 ops/s 605759.6 ops/s
io-threads=4, do-reads yes #2 10856.6 ops/s 367647.1 ops/s

Valkey 8.1

Конфигурация Прогон throughput без пайплайна throughput с пайплайном (batch=100)
io-threads=1 (дефолт) #1 13227.9 ops/s 387718.6 ops/s
io-threads=1 (дефолт) #2 13031.5 ops/s 357490.1 ops/s
io-threads=4, do-reads yes #1 14502.4 ops/s 681236.0 ops/s
io-threads=4, do-reads yes #2 13457.3 ops/s 529607.7 ops/s

Честный вывод здесь не в пользу красивого заголовка. На Redis 8.8 разброс между двумя повторами одной и той же конфигурации (io-threads=4: 605759.6 против 367647.1 ops/s на пайплайне — разброс около 40%) больше, чем разница средних между конфигурациями (io-threads=1 в среднем ≈486903 ops/s на пайплайне, io-threads=4 — ≈486703 ops/s, то есть практически идентично). При этой нагрузке (localhost, N=10000, concurrency=8, короткие значения) io-threads не даёт на Redis измеримого эффекта — шум перекрывает сигнал.

На Valkey 8.1 картина направленно другая: оба повтора с io-threads=4 дали более высокий throughput, чем оба повтора с io-threads=1, и на операциях без пайплайна (13979.9 против 13129.7 ops/s в среднем, +6.5%), и особенно с пайплайном (605421.9 против 372604.4 ops/s в среднем, +62%). Направление устойчиво в обоих повторах — но при двух повторах на конфигурацию это наблюдение, а не статистически надёжный результат. Здесь стоит удержаться от соблазна написать «Valkey быстрее с threaded I/O на N%»: на этой нагрузке видна направленная тенденция, а не подтверждённая цифра, и честная формулировка — именно такая, без округления в сторону эффектного заголовка.

Побочная находка, не связанная напрямую с throughput, но полезная как диагностическая тонкость: у обоих образов CONFIG GET "io-threads*" возвращает только сам параметр io-threadsio-threads-do-reads в выдаче нет, хотя передача --io-threads-do-reads yes аргументом командной строки при старте не вызывает ошибку и не мешает запуску. Похоже, что в этих версиях (Redis 8.8, Valkey 8.1) чтения потоками ввода-вывода включаются неявно, когда io-threads > 1, а отдельный переключатель для этого не задокументирован и не виден через CONFIG GET — если ожидать увидеть его там при диагностике, он просто не найдётся, и это не баг стенда, а особенность конкретных версий.

Откуда берётся хвостовая latency

Модель из первого раздела — один поток, команда выполняется до конца, прежде чем начнётся следующая — работает в обе стороны. Она даёт атомарность бесплатно, но она же значит, что дорогая по времени команда не «подвисает» где-то в фоне: она удерживает единственный поток на всё время своего выполнения, и каждая другая команда от каждого другого клиента — сколь угодно дешёвая сама по себе — стоит в очереди позади неё. Это не побочный дефект, который можно пропатчить, а прямое, механическое следствие однопоточной модели: если поток занят, он занят для всех.

Дороже всего в эту очередь встают команды, которые вместо O(1) или O(log N) делают O(N) по всей структуре: KEYS без ограничения по количеству ключей проходит по всему keyspace целиком, большой LRANGE, SMEMBERS или HGETALL на значении из тысяч элементов — по всей структуре целиком. Здесь смыкается с материалом первой статьи серии про кодировки: во что переключилась структура (hashtable, skiplist, quicklist вместо компактных listpack/intset) — вопрос не только памяти, но и того, сколько времени займёт операция над ней, когда это время физически станет временем простоя для всех остальных клиентов сервера, а не только для того, кто вызвал дорогую команду. Ключ, который незаметно вырос за порог компактной кодировки и превратился в hashtable на сотнях тысяч полей, — не просто занимает больше байт, он превращает любую O(N)-операцию над собой в паузу, которую почувствует весь сервер целиком.

На этом стенде такой эффект намеренно не измерялся количественно — числа выше про пайплайнинг и threaded I/O относятся к дешёвым операциям без конкурентной нагрузки в момент выполнения дорогой команды, и приписывать им вывод про блокировку было бы нечестной экстраполяцией. Механизм здесь однозначен: одна дорогая команда занимает поток на всё время своего исполнения, и в это время сервер физически не может начать обработку ничего другого — это прямое следствие модели из первого раздела, а не отдельно измеренный на этой нагрузке факт. На практике за такими командами следят инструментами вроде SLOWLOG (журнал команд, превысивших порог по времени исполнения) и LATENCY DOCTOR/LATENCY HISTORY (агрегированная картина по секундам) — то, как эти инструменты реально ведут себя под нагрузкой и в чём между ними тонкая, но важная разница, разобрано на живых цифрах в статье про эксплуатацию и принятие решений дальше в серии.

У хвостовой latency есть и внешние по отношению к однопоточной модели источники — форк процесса под перезапись RDB/AOF с паузами на copy-on-write (подробности — в статье про персистентность), своп, THP, сетевые всплески. Но именно медленная команда на большой структуре — самый прямой и самый частый практический случай, потому что он целиком объясняется однопоточной моделью из первого раздела статьи, без привлечения ОС или диска.

Тем же однопоточным циклом объясняется и ещё один факт, который пригодится дальше в серии: Lua-скрипты выполняются не в отдельном интерпретаторе где-то сбоку, а в том же самом главном потоке, что и обычные команды. Именно поэтому скрипт целиком атомарен — он не может быть прерван другой командой между шагами внутри себя ровно по той же причине, по которой атомарна каждая отдельная команда в первом разделе. Механика вызова (EVAL против FUNCTION) и то, для чего эта атомарность нужна на практике, — тема отдельной статьи про Streams, Lua и Functions дальше в серии.

Что дальше

Событийный цикл и его цена — не последняя остановка в понимании производительности Redis, а фундамент, на который опираются несколько следующих тем серии. Пайплайнинг убирает сетевой round-trip, но не даёт изоляции между командами одного клиента и командами других — за изоляцией нужно идти в MULTI/EXEC, разобранные в статье «KV и документные: транзакций почти нет». Threaded I/O параллелит упаковку байт, но не выполнение — та же однопоточность, что даёт атомарность обычным командам, лежит в основе Lua-скриптов, разобранных дальше в серии в статье про Streams, Lua и Functions.

Один поток = одно ядро под фактическое выполнение команд — это вертикальный потолок, и он не решается настройкой io-threads (она про сетевой I/O, не про исполнение). Когда однопоточной модели одного инстанса перестаёт хватать по пропускной способности, ответ горизонтальный — шардирование данных между независимыми инстансами через Redis Cluster, — этому посвящена статья «Redis: репликация, Cluster, Sentinel» дальше в серии.

То, как клиентские библиотеки на Go и Java оборачивают пайплайнинг, пулы соединений и повторные попытки при недоступности сервера, — за рамками этой статьи и разобрано отдельно в «Redis: клиенты на Go, Java, Rust». А диагностика хвостовой latency вживую — под конкурентной нагрузкой, с реальными SLOWLOG/LATENCY-сигналами и тем, как их не читать наивно, — в завершающей статье серии «Redis: эксплуатация и принятие решений».

Серия продолжает трактовать Redis и Valkey как равноправную, API-совместимую пару (что изменилось после форка и что из этого следует при выборе — в отдельной статье про ValkeyСкоро): там, где поведение практически неотличимо (латентность и throughput без пайплайна и с ним — раздел выше), оба движка разбираются вместе; там, где прогоны разошлись по направлению (направленный, но статистически не подтверждённый эффект io-threads на Valkey против честного нуля на Redis), это отмечается явно, без сглаживания в сторону эффектного вывода. Двух не-результатов недостаточно, чтобы утверждать, что движки здесь расходятся, — на этой нагрузке про io-threads честно сказать нечего ни за, ни против.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии