«Redis однопоточный» — фраза, которая одновременно верна и вводит в заблуждение. Обработка команд действительно идёт в одном потоке, и это осознанный выбор архитектуры, а не ограничение. Именно однопоточность даёт Redis атомарность команд без блокировок и предсказуемость, а высокую пропускную способность обеспечивает не параллелизм, а событийный цикл поверх эффективного I/O-мультиплексирования. При этом «один поток» — не вся правда: в новых версиях часть работы (сетевой I/O) вынесена в отдельные потоки.
Эта статья — вторая в серии «Redis: глубокое погружение». Она объясняет, за счёт чего Redis быстрый, и, что важнее, где эта модель упирается в потолок. Понимание событийного цикла — ключ к диагностике latency-инцидентов, о которых пойдёт речь в статье про эксплуатацию, и к трезвой оценке границы «кэш vs источник истины»: под источник истины важна не только скорость, но и предсказуемость хвостов.
В статье
- Почему один поток: атомарность без блокировок и событийный цикл
- Пайплайнинг: как убрать сетевой round-trip
- Threaded I/O: что вынесено в потоки, а что — нет
- Откуда берётся хвостовая latency
- Что дальше
- Источники
Почему один поток: атомарность без блокировок и событийный цикл
Redis/Valkey обрабатывают все команды, поступающие от всех клиентов, в одном потоке: пока сервер выполняет одну команду, ни одна другая не может начаться. Это не техническое ограничение, которое разработчики просто не успели снять, — это осознанный архитектурный выбор, и у него есть прямое следствие, которым пользуется вся остальная модель Redis.
Следствие первое: атомарность каждой отдельной команды достаётся бесплатно, без единой явной блокировки данных. Пока INCR читает значение счётчика, увеличивает его и пишет обратно, ни один другой клиент физически не может вклиниться между этими шагами — в этот момент в процессе вообще не выполняется ничего другого. В многопоточной СУБД та же гарантия потребовала бы блокировок на структуру данных (или lock-free-алгоритмов) — источника и оверхеда, и целого класса гонок и дедлоков, которых у Redis попросту нет по конструкции. Это не побочный эффект, а фундамент: та же гарантия «выполняется от начала до конца без чужого вмешательства» — причина, по которой Lua-скрипты в Redis атомарны (об этом — в статье про Streams и Lua дальше в серии) и по которой MULTI/EXEC вообще имеет смысл как транзакционный примитив (механика — в статье про транзакции в KV и документных БД).
Следствие второе: раз поток один, высокая пропускная способность Redis не может браться из параллельного исполнения команд на разных ядрах. Она берётся из другого источника — событийного цикла (event loop) поверх эффективного I/O-мультиплексирования. Устройство простое: сервер держит один цикл, который на каждой итерации спрашивает у операционной системы, какие из открытых сокетов готовы к чтению или записи. Системные вызовы epoll (Linux) и kqueue (BSD/macOS) устроены так, что возвращают именно готовые дескрипторы, а не заставляют перебирать все соединения по кругу в поисках работы — это и есть мультиплексирование: тысячи соединений обслуживаются одним потоком без активного опроса каждого. Готовые сокеты обрабатываются один за другим: прочитать команду, выполнить её до конца, записать ответ, перейти к следующему готовому сокету. Ключевое — «до конца»: внутри одной итерации цикл синхронный, команда не может приостановиться на середине и уступить очередь другой.
кто из сокетов готов"] 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-threads — io-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 честно сказать нечего ни за, ни против.
Источники
- Официальная документация: redis.io, раздел redis.io/docs.
- Пайплайнинг: redis.io/docs — Pipelining.
- Диагностика latency: redis.io/docs — Latency.
- Справочник команд (
CONFIG,SLOWLOG,LATENCY): redis.io/commands. - Стенд: digital-cookbook/databases/redis/deep-dive (
redis:8.8,valkey/valkey:8.1, модульeventloop: сценарииno-pipeline,pipeline,threaded-io).
Комментарии