Redis держит набор данных целиком в памяти, и DBA, который его принял, сталкивается с двумя загадками: hash на 512 полей занимает 5 955 байт, а на 513 — 23 442, и четыре потока ввода-вывода ускоряют сервер в 2,1–2,3 раза, а не в четыре. Причины в устройстве: объект хранится в одной из кодировок и меняет её на порогах конфигурации, а цикл событий ae исполняет команды в одном потоке, и другим потокам можно отдать только чтение запросов и запись ответов. Как Redis пишет RDB и AOF и как реплика догоняет мастера — в соседних статьях; здесь собрана сама машина. Измерено в двух контейнерах на объёмах до тысячи полей и четырёх миллионов запросов.
Redis и Valkey: поток команд, кодировка, потоки I/O
Цикл событий: один поток команд
Контейнеры с лимитом памяти 1 ГБ каждый; образы скачиваются при первом запуске:
P=dba-redis-valkey-architecture-overview
docker run -d --name $P-redis --memory 1g redis:8
docker run -d --name $P-valkey --memory 1g valkey/valkey:8
for n in redis valkey; do docker exec $P-$n redis-cli INFO server | grep -E '^(redis_version|valkey_version|multiplexing_api)'; done
docker exec $P-redis sh -c 'cat /sys/fs/cgroup/cpu.max; nproc'
вывод
redis_version:8.10.3
multiplexing_api:epoll
redis_version:7.2.4
valkey_version:8.1.11
multiplexing_api:epoll
max 100000
20
Valkey отвечает redis_version:7.2.4 для совместимости клиентов, его собственная версия — valkey_version; код ниже взят по тегам 8.10.3 и 8.1.11. cpu.max равен max: лимита процессора у контейнера нет, он делит 20 логических ядер хоста с чужими процессами — к этому вернётся бенчмарк.
Сервер крутится в цикле, который ждёт готовых сокетов через epoll и обрабатывает события по одному. Файл src/ae.c Redis 8.10.3, aeMain:
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS|
AE_CALL_BEFORE_SLEEP|
AE_CALL_AFTER_SLEEP);
}
}
Источник: src/ae.c L497-L504. Цикл вызывает aeProcessEvents (внутри — aeApiPoll, на Linux это epoll), и команды клиентов исполняются в этом цикле по очереди: долгая команда задерживает остальных. Список потоков процесса по /proc/<pid>/task/*/comm, для сервера с io-threads 1 (порт 6379) и с io-threads 4 (порт 6380, второй redis-server в том же контейнере: docker exec -d $P-redis redis-server --port 6380 --io-threads 4 --save "" --appendonly no):
вывод
:6379
1 bio_aof
1 bio_close_file
1 bio_lazy_free
2 jemalloc_bg_thd
1 redis-server
:6380
1 bio_aof
1 bio_close_file
1 bio_lazy_free
1 io_thd_1
1 io_thd_2
1 io_thd_3
4 jemalloc_bg_thd
1 redis-server
Команды исполняет redis-server; bio_* и jemalloc_bg_thd — фоновые потоки с отдельными задачами. io-threads 4 добавил три потока io_thd_N: вместе с главным их четыре. Тот же подсчёт для обоих серверов и обоих портов:
вывод
redis :6379 io-threads=1 threads=6 io_thd=0
redis :6380 io-threads=4 threads=11 io_thd=3
valkey :6379 io-threads=1 threads=6 io_thd=0
valkey :6380 io-threads=4 threads=11 io_thd=3
Valkey ведёт себя так же: шесть потоков и 11 с тремя io_thd.
Кодировки: 35 байт на ключ и скачок на пороге
OBJECT ENCODING показывает, как хранится значение, MEMORY USAGE — сколько байт занимает ключ вместе со значением. Hash наполняется по одному полю f<i> → v<i>; пороги по умолчанию читаются из конфигурации (hash-max-listpack-entries 512, hash-max-listpack-value 64, одинаково в обоих серверах):
for n in redis valkey; do
c=$P-$n; docker exec $c redis-cli FLUSHALL >/dev/null; prev=0
for k in 1 100 511 512 513 514 1000; do
docker exec $c sh -c "seq $((prev+1)) $k | sed 's/.*/HSET h f& v&/' | redis-cli >/dev/null"
prev=$k
echo "$n fields=$k $(docker exec $c redis-cli OBJECT ENCODING h) usage=$(docker exec $c redis-cli MEMORY USAGE h)"
done
done
вывод
-- dba-redis-valkey-architecture-overview-redis
fields=1 listpack usage=35
fields=100 listpack usage=1011
fields=511 listpack usage=5943
fields=512 listpack usage=5955
fields=513 hashtable usage=23442
fields=514 hashtable usage=23472
fields=1000 hashtable usage=38054
-- dba-redis-valkey-architecture-overview-valkey
fields=1 listpack usage=40
fields=100 listpack usage=1048
fields=511 listpack usage=6168
fields=512 listpack usage=6168
fields=513 hashtable usage=16944
fields=514 hashtable usage=16960
fields=1000 hashtable usage=42080
Hash из одного поля весит 35 байт в Redis и 40 в Valkey; 512 полей — 5 955 и 6 168 байт, то есть около 12 байт на поле. 513-е поле меняет кодировку на hashtable, и Redis подскакивает до 23 442 байт: плюс 17 487 байт за одно поле, рост в 3,9 раза. Valkey на том же шаге: 6 168 → 16 944 байта, в 2,7 раза. Условие в коде Redis 8.10.3, hashTypeSet, src/t_hash.c:
/* Check if the listpack needs to be converted to a hash table */
if (hashTypeLength(o, 0) > server.hash_max_listpack_entries)
hashTypeConvert(db, o, OBJ_ENCODING_HT);
Источник: src/t_hash.c L1931-L1933, конец ветки OBJ_ENCODING_LISTPACK в hashTypeSet: условие «больше 512», поэтому 512 полей ещё listpack. Второй порог — по длине значения:
for n in redis valkey; do c=$P-$n; docker exec $c redis-cli FLUSHALL >/dev/null
docker exec $c redis-cli HSET v a $(printf 'x%.0s' $(seq 64)) >/dev/null; echo "$n value=64 $(docker exec $c redis-cli OBJECT ENCODING v) $(docker exec $c redis-cli MEMORY USAGE v)"
docker exec $c redis-cli HSET v b $(printf 'x%.0s' $(seq 65)) >/dev/null; echo "$n value=65 $(docker exec $c redis-cli OBJECT ENCODING v) $(docker exec $c redis-cli MEMORY USAGE v)"
done
вывод
redis value=64 listpack 97
redis value=65 hashtable 283
valkey value=64 listpack 104
valkey value=65 hashtable 320
Одно значение в 65 байт превращает hash из двух полей в hashtable: 97 → 283 байта в Redis, 104 → 320 в Valkey. Из этого не следует, что Valkey «тяжелее»: на 513 полях он легче (16 944 против 23 442), на 1 000 — тяжелее (42 080 против 38 054 байт). В Valkey 8.1.11 hash-таблица описана как «hash table with cache-line sized buckets» с инкрементальной перестройкой на двух таблицах (src/hashtable.c), Redis 8.10.3 строит hash на dict (dictExpand в hashTypeTryConversion, перестройка — dictRehash в src/dict.c). Почему размеры расходятся именно так, опыт не выяснял: измерено только само расхождение.
Память как главный ресурс: вытеснение по приближённому LRU
Когда maxmemory исчерпан, а политика allkeys-lru или volatile-lru, сервер не ведёт точный список: каждый объект хранит 24 бита времени последнего доступа (LRU_BITS 24, разрешение LRU_CLOCK_RESOLUTION 1000 мс, src/object.h), а кандидатов на вытеснение сервер берёт выборкой ключей, а не со всего набора (документация). Возраст объекта в миллисекундах считает estimateObjectIdleTime в src/evict.c:
unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
if (lruclock >= o->lru) {
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
LRU_CLOCK_RESOLUTION;
}
}
Источник: src/evict.c L75-L83 и LRU_BITS в src/object.h на том же теге. Часы переполняются через 2²⁴ секунд, это 194 суток, и функция учитывает оборот. Размер выборки задаёт maxmemory-samples; само вытеснение опыт здесь не мерил.
Потоки I/O: два раза, а не четыре
Сервер с io-threads 4 на порту 6380 и сервер с одним потоком на 6379, один клиент: redis-benchmark с четырьмя собственными потоками, 200 соединений, значение 64 байта, 100 000 разных ключей, 4 000 000 запросов, два прогона на каждый порт:
docker exec $P-redis redis-benchmark -p 6379 -t set,get -n 4000000 -c 200 --threads 4 -d 64 -r 100000 -q
вывод
redis port=6379 run=1 SET: 301772.91 requests per second, p50=0.591 msec GET: 313479.62 requests per second, p50=0.575 msec
redis port=6379 run=2 SET: 313479.62 requests per second, p50=0.583 msec GET: 307455.81 requests per second, p50=0.575 msec
redis port=6380 run=1 SET: 639488.38 requests per second, p50=0.199 msec GET: 639488.38 requests per second, p50=0.199 msec
redis port=6380 run=2 SET: 639488.38 requests per second, p50=0.207 msec GET: 639590.62 requests per second, p50=0.207 msec
valkey port=6379 run=1 SET: 285449.25 requests per second, p50=0.607 msec GET: 301795.69 requests per second, p50=0.583 msec
valkey port=6379 run=2 SET: 290634.31 requests per second, p50=0.599 msec GET: 301636.38 requests per second, p50=0.575 msec
valkey port=6380 run=1 SET: 639795.25 requests per second, p50=0.167 msec GET: 695410.31 requests per second, p50=0.159 msec
valkey port=6380 run=2 SET: 639795.25 requests per second, p50=0.167 msec GET: 695410.31 requests per second, p50=0.159 msec
Один поток: 285–313 тысяч запросов в секунду у обоих серверов; четыре потока: 639–695 тысяч, то есть в 2,1–2,3 раза, а медиана задержки упала с 0,58–0,61 до 0,16–0,21 мс. Четыре потока не дали четырёх раз: команды по-прежнему идут в одном потоке (раздел выше), остальные снимают с него разбор запроса и запись ответа (описание в блоге Valkey).
Ограничение опыта. Клиент и оба сервера делят один контейнер и 20 логических ядер хоста с чужими процессами (в момент прогона load average хоста был 9,59), лимита CPU у контейнера нет. Поэтому сравнивай только отношение «1 поток к 4» внутри одного опыта, а абсолютные числа не переноси на свой сервер: как цифры поведут себя на отдельной машине и с клиентом на другом хосте, не измерено. Одинаковые до сотых значения в нескольких строках (639488,38; 695410,31) — не совпадение: redis-benchmark делит число запросов на время в целых миллисекундах (config.totlatency = mstime()-config.start в src/redis-benchmark.c), и 4 000 000 / 6,255 с = 639 488. Миллисекунда на шестисекундном прогоне — ступень около 100 запросов в секунду; прогонов по два, поэтому разброс не оценён.
Лицензии
Три периода записаны в README репозитория Redis на теге 8.10.3 (строки 461–467): версии 7.2.x и раньше — BSDv3; 7.4.x–7.8.x — на выбор RSALv2 или SSPLv1; 8.0.x и позже — на выбор RSALv2, SSPLv1 или AGPLv3. Valkey, в INFO объявляющий совместимость с 7.2.4, распространяется под BSD-3-Clause (COPYING на теге 8.1.11: SPDX-License-Identifier: BSD-3-Clause). Для DBA это практический вопрос: при обновлении с Redis 7.2 выбор между Redis 8 и Valkey включает выбор лицензии, а условия AGPLv3 и BSD разные; их толкование за юристом, а не за этой статьёй.
Что из этого следует
- Ключи с десятками полей держи в пределах порогов. Hash на 512 полей — 5 955 байт, на 513 — 23 442 (раздел про кодировки): если приложение складывает в hash «чуть больше 512» полей, ключ стоит в 3,9 раза дороже, чем на 512.
- Одно большое значение переводит весь hash в
hashtable. 65 байт вместо 64 дают 97 → 283 байта (раздел про кодировки): проверьOBJECT ENCODINGна репрезентативных ключах до оценки памяти. - Потоки I/O ускоряют сеть, а не команды.
io-threads 4дал 2,1–2,3 раза; долгая команда по-прежнему задерживает остальных (разделы про цикл событий и потоки I/O). - Выбор Redis или Valkey — выбор лицензии. Пороги и кодировки одинаковы, размеры отличаются (раздел про кодировки), поэтому ёмкость считай на том сервере, который поставишь.
Чек-лист
- Выполни
INFO server | grep -E 'redis_version|valkey_version|multiplexing_api': известна точная версия сервера и мультиплексор. - Возьми 3–5 самых больших hash/set/zset и сравни
OBJECT ENCODINGиMEMORY USAGE; рядом с порогом*-max-listpack-entriesсчитай запас. - Проверь
CONFIG GET io-threadsиCONFIG GET maxmemory-policy: нужны ли потоки I/O и какая политика вытеснения включена. - Запиши лицензию установленной версии (README репозитория на нужном теге) и сверь с тем, как сервер предоставляется пользователям.
Убери контейнеры: docker rm -f -v dba-redis-valkey-architecture-overview-redis dba-redis-valkey-architecture-overview-valkey.