7.2Redis: устройство и почему он быстрый
Пойми, как Redis обрабатывает команды и почему сложность команды имеет значение при общей последовательной обработке. Используй SCAN для обслуживания ключей, обходи большие значения порциями и отделяй блокирующую работу от обычных соединений.
Что такое Redis
Redis - сервер структур данных в памяти. Ключ в нём всегда бинарно-безопасная строка, а значение - одна из встроенных структур: строка, хеш, список, множество, упорядоченное множество, битовая карта, HyperLogLog, поток, geo-индекс, а с Redis 8 ещё vector set, JSON и time series. От «мапы по сети» его отличает главное: сервер выполняет операции над структурой на своей стороне (инкремент, добавление в множество, выборку топ-10 по рангу), поэтому значение не надо тащить к клиенту, менять и класть обратно. Отсюда и производительность, и бесплатная атомарность.
Redis используют как кэш, брокер (pub/sub, Streams), хранилище сессий, счётчик, распределённый лок, лидерборд и очередь. Есть у него и персистентность (два механизма, RDB и AOF), только гарантий уровня СУБД она не даёт, подробнее об этом в главе 7.6.
Почему быстрый: пять причин, а не одна
- Всё в оперативной памяти. На пути запроса нет ни одного обращения к диску. Даже AOF пишется в буфер и сбрасывается фоново (по умолчанию раз в секунду).
- Команды выполняются в одном потоке. Ни мьютексов, ни атомиков, ни переключений контекста, ни борьбы за строки кэша между ядрами. Каждая команда атомарна просто потому, что во время её выполнения ничего другого не происходит. Заодно исчезает целый класс багов, а такты не уходят на синхронизацию.
- Мультиплексирование ввода-вывода. Один поток обслуживает десятки тысяч соединений через
epoll(Linux),kqueue(BSD/macOS),evport(Solaris) илиselectкак запасной вариант. Никакого «поток на клиента». - Дешёвый протокол RESP. Текстово-бинарный, длины передаются явно, парсер линейный, без разбора грамматики и без рефлексии.
- Специализированные компактные кодировки. Маленький хеш хранится не хеш-таблицей, а
listpack: это сплошной кусок памяти, который влезает в кэш процессора. Множество целых чисел лежит отсортированнымintset, а короткая строка ложится вembstrодним куском вместе с заголовком объекта.
И отдельно: у Redis нет планировщика запросов, нет MVCC, нет журнала транзакций на пути записи, нет проверки ограничений целостности. Он не делает почти ничего из того, на что тратит время СУБД.
«В Redis время уходит не на работу с данными, а на сеть. Сама команда GET занимает
доли микросекунды: хеш от ключа и разыменование указателя. Round-trip по сети внутри дата-центра
стоит сотни микросекунд. Поэтому, чтобы ускорить работу с Redis, надо не оптимизировать команды,
а сокращать число round-trip: пайплайнинг, MGET, Lua.»
Что именно значит «однопоточный»
Формулировка, которую стоит держать наготове: Redis выполняет команды в один поток; всё остальное может быть многопоточным. По версиям:
| Версия | Что вынесли из главного потока |
|---|---|
| всегда | BGSAVE и BGREWRITEAOF - через fork(), то есть отдельный процесс, а не поток |
| 2.4+ | отдельные потоки под fsync AOF и медленное закрытие файлов |
| 4.0 | UNLINK и lazyfree-lazy-*: освобождение памяти больших ключей уходит в фоновый поток |
| 6.0 | io-threads: чтение из сокетов, парсинг RESP и запись ответов можно распараллелить (по умолчанию выключено; до 8.0 чтение включали отдельным флагом). Выполнение команд - нет |
| 7.0 | sharded pub/sub, Functions; модель исполнения та же |
Отсюда вывод: чтобы загрузить 16 ядер, одного процесса Redis мало. Масштабируются либо несколькими инстансами на машине (по одному на ядро, разные порты), либо через Redis Cluster. Вертикально Redis растёт в основном по памяти: лишние ядра займут разве что потоки ввода-вывода.
- Head-of-line blocking. Любая долгая команда останавливает весь сервер, а не только того клиента, который её послал.
- Один большой ключ = один большой стоп.
DELмножества на миллион элементов освобождает миллион аллокаций синхронно. - Lua-скрипт и
MULTI/EXEC- тоже атомарные блокировки. Скрипт на секунду = сервер стоит секунду. По умолчаниюbusy-reply-threshold(раньшеlua-time-limit) равен 5 с, после чего Redis начинает отвечатьBUSYостальным, а скрипт всё равно продолжает выполняться. - Fork на BGSAVE. Сам fork на инстансе с 40 ГБ занимает сотни миллисекунд, и всё это время процесс стоит: копируются таблицы страниц.
- Дефрагментация и вытеснение тоже отъедают такты того же потока.
Протокол RESP
RESP выглядит текстовым, но по сути бинарно-безопасен. Первый байт
задаёт тип, \r\n завершает элемент, у строк длина передаётся явно, поэтому в значении
могут быть любые байты, включая нули и переводы строк. Клиентские библиотеки всегда шлют
команду массивом bulk-строк.
> из RESP3 держатся уведомления
клиентского кэша (CLIENT TRACKING) и pub/sub на том же соединении.# отправить кадр руками: ровно те байты, что уходят на провод
$ printf '*3\r\n$3\r\nSET\r\n$6\r\nuser:1\r\n$4\r\nIvan\r\n' | nc localhost 6379
+OK
$ printf 'PING\r\n' | nc localhost 6379 # inline-команды тоже принимаются
+PONG
# переключить соединение в RESP3
$ redis-cli HELLO 3
Сложность команд и запрещённые в проде
| Команда | Сложность | Комментарий |
|---|---|---|
GET, SET, INCR, EXPIRE, HGET, HSET, SADD, SISMEMBER, LPUSH, RPOP | O(1) | рабочая лошадка, можно всё |
ZADD, ZSCORE, ZRANK, ZINCRBY | O(log N), у ZSCORE O(1) | skiplist; практически бесплатно |
ZRANGE, LRANGE, SRANDMEMBER count | O(log N + M), у LRANGE O(S + M), у SRANDMEMBER O(M) | M - сколько вернули, а не размер ключа |
MGET k1..kN, DEL k1..kN | O(N) по числу ключей | нормально, если N разумный |
HGETALL, SMEMBERS, LRANGE key 0 -1 | O(N) по размеру ключа | опасно на большом ключе: и блокировка, и трафик |
KEYS pattern | O(N) по всему keyspace | запрещено в проде |
FLUSHALL / FLUSHDB | O(N) | без ASYNC до Redis 7.4 освобождал всё синхронно; с 7.4 память освобождает фоновый поток, ждёт только вызвавший клиент |
SORT | O(N log N) | плюс возможные лукапы BY/GET |
SUNION, SINTER, ZUNIONSTORE | O(N) / O(N*M) | считать заранее, не в горячем пути |
DEBUG SLEEP, DEBUG OBJECT | - | с Redis 7.0 выключены по умолчанию; если включали, ставить в ACL-запрет вместе с KEYS |
KEYS * проходит всю таблицу ключей, собирает совпадения в один ответ и
делает это в главном потоке. На 10 млн ключей это секунды полной остановки: не отвечают
health-check, копятся таймауты у всех клиентов, sentinel может решить, что мастер умер, и
устроить failover (механика в главе 7.6).
Вдобавок ответ на сотни мегабайт раздувает выходной буфер, и Redis
может отключить клиента по client-output-buffer-limit, если лимит для обычных клиентов
настроен: по умолчанию он у них выключен. То же касается
SMEMBERS на множестве из миллиона элементов и HGETALL на большом хеше.
SCAN: как обходить ключи правильно
SCAN обходит ключи по курсору. Каждый вызов возвращает новый курсор и порцию ключей;
обход закончен, когда курсор снова стал 0. Работа идёт маленькими кусками, и
между вызовами сервер спокойно обслуживает других клиентов.
# плохо: блокирует сервер целиком
KEYS session:*
# плохо: то же самое из кода
rdb.Keys(ctx, "session:*")
# хорошо: курсор, порциями
SCAN 0 MATCH session:* COUNT 500 # ответ: курсор 17408 и порция ключей
SCAN 17408 MATCH session:* COUNT 500 # ответ: курсор 0, обход завершён
// Итератор go-redis сам крутит курсор и проверяет ctx.
iter := rdb.Scan(ctx, 0, "session:*", 500).Iterator()
var batch []string
for iter.Next(ctx) {
batch = append(batch, iter.Val())
if len(batch) == 500 {
// Удаляем пачками через UNLINK: большие значения освободит фоновый поток.
if err := rdb.Unlink(ctx, batch...).Err(); err != nil {
return err
}
batch = batch[:0]
}
}
if err := iter.Err(); err != nil {
return err
}
if len(batch) > 0 {
return rdb.Unlink(ctx, batch...).Err()
}
return nil
- Ключ, который жил от начала до конца итерации, вернётся хотя бы один раз.
- Ключ может вернуться несколько раз, так что обработчик обязан быть идемпотентным.
- Ключи, добавленные или удалённые во время обхода, могут вернуться, а могут нет.
COUNTлишь подсказывает, сколько слотов просмотреть, а не задаёт размер ответа. Порция может прийти пустой, и это не значит, что обход закончился: закончен он, только когда вернулся курсор0.MATCHфильтрует после выборки, так что скорость он не повышает, а только уменьшает трафик.- Курсор устойчив к рехешу таблицы: он обходит бакеты «реверсивным двоичным инкрементом», поэтому и рост, и сжатие хеш-таблицы во время итерации не ломают гарантию.
- Есть
HSCAN,SSCAN,ZSCANдля обхода внутри одного большого ключа, иSCAN ... TYPE hashдля фильтра по типу.
Блокирующие команды: кого они блокируют
BLPOP, BRPOP, BLMOVE, BLMPOP,
BZPOPMIN, XREAD BLOCK и WAIT
не блокируют сервер. Redis снимает клиента с обработки, записывает его в список
ожидающих на конкретный ключ и продолжает крутить цикл событий. Когда кто-то делает
LPUSH, сервер будит ожидающих в порядке очереди сразу после выполнения этого
LPUSH. Подвох популярный: «блокирующая команда» звучит страшно, но блокируется
только соединение клиента.
// Воркер очереди: висим на BRPOP до 5 секунд, потом повторяем.
// Таймаут нужен, чтобы горутина реагировала на отмену контекста.
for {
res, err := rdb.BRPop(ctx, 5*time.Second, "queue:jobs").Result()
if err == redis.Nil {
continue // по таймауту просто повторяем
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err() // сервис останавливается
}
time.Sleep(200 * time.Millisecond)
continue
}
handle(res[1]) // res[0] - имя ключа, res[1] - значение
}
Соединение, висящее в BRPOP, занято целиком и не может обслуживать другие запросы.
Если пул соединений маленький, десяток воркеров с BRPOP исчерпает его, и обычные
GET начнут ждать свободного соединения. Поэтому под блокирующие команды и под
подписку pub/sub заводят отдельный клиент со своим пулом. В go-redis для
подписки это происходит само (Subscribe берёт выделенное соединение), а вот
BRPOP берёт коннект из общего пула, и за этим надо следить.
Redis против Memcached
| Redis | Memcached | |
|---|---|---|
| Модель данных | строки, хеши, списки, множества, zset, битмапы, HLL, streams, geo | только строка (opaque blob) |
| Потоки | один поток на команды | честно многопоточный, масштабируется по ядрам |
| Персистентность | RDB + AOF | нет вообще, только память |
| Репликация / HA | асинхронная репликация, Sentinel, Cluster | нет; шардирование делает клиент |
| Атомарные операции | любые, плюс Lua и транзакции | incr/decr, cas, add, append: простые операции над одним ключом |
| Вытеснение | 8 политик maxmemory-policy, с Redis 8.6 - 10 | slab-аллокатор + LRU внутри класса |
| Фрагментация памяти | jemalloc, есть активная дефрагментация | slab-классы: почти нет фрагментации, но есть внутренние потери |
| Pub/Sub, очереди, локи | есть | нет |
| Когда лучше | почти всегда: нужны структуры, атомарность, персистентность, HA | чистый кэш HTML-фрагментов или сериализованных объектов, где хочется линейно масштабироваться по ядрам одной большой машины |
Короткий ответ на собеседовании: Memcached - это распределённая мапа, Redis - сервер структур данных. Memcached выигрывает ровно в одном сценарии: когда нужна многопоточность на одной машине при предельно простом доступе. Во всех остальных берут Redis, потому что структуры и атомарные операции на сервере закрывают целые классы задач (счётчики, лидерборды, локи, рейт-лимиты).
У каждого значения есть encoding, видимый через OBJECT ENCODING key.
Строка бывает int (число до 20 знаков хранится как long, без аллокации буфера),
embstr (до 44 байт: заголовок и данные лежат одним куском памяти, аллокация одна;
с Redis 8.2 в тот же кусок в 64 байта ложится и ключ, так что ключ и значение вместе должны
уложиться примерно в 41 байт) и
raw. Хеш и zset начинают жизнь как listpack, плоский массив байтов:
поиск в нём линейный, но данные лежат одним сплошным куском памяти, процессор читает их подряд, и
на малых размерах это быстрее хеш-таблицы. В «настоящую» структуру хеш переходит, когда превышен
hash-max-listpack-entries (512) или hash-max-listpack-value (64 байта);
для zset - zset-max-listpack-entries (128), для списка - list-max-listpack-size
(по умолчанию 8 КБ на узел). Обратно хеш не возвращается: если он один раз разросся, то
останется хеш-таблицей, даже если удалить из него почти всё. Список с Redis 7.2 умеет и назад:
quicklist, снова ставший маленьким, превращается в listpack. Множество целых до set-max-intset-entries лежит
отсортированным intset, и поиск по нему бинарный. Если спросят, почему Redis
экономно расходует память, listpack будет очень выигрышной деталью.
$ redis-cli SET n 12345
$ redis-cli OBJECT ENCODING n # "int"
$ redis-cli SET s "короткая строка"
$ redis-cli OBJECT ENCODING s # "embstr"
$ redis-cli HSET h f1 v1
$ redis-cli OBJECT ENCODING h # "listpack"
$ redis-cli MEMORY USAGE h # сколько байт реально занимает ключ
# инструменты диагностики, которые стоит назвать на собесе
$ redis-cli --latency-history # латентность в динамике
$ redis-cli --bigkeys # самые большие ключи каждого типа (через SCAN)
$ redis-cli --hotkeys # нужна LFU-политика: allkeys-lfu или volatile-lfu
$ redis-cli SLOWLOG GET 10 # команды дольше slowlog-log-slower-than (10 000 мкс)
$ redis-cli INFO commandstats # усреднённое время на каждую команду
Вопросы
6Что это
Сервер структур данных, в отличие от «мапы по сети»: ключ - бинарная строка, значение - строка, хеш, список, множество, sorted set, битмап, HyperLogLog, stream или geo-индекс. Операции над структурой сервер выполняет у себя: инкремент, добавление в множество, выборку топ-N. Значит, не надо тащить значение к клиенту и класть обратно, отсюда и скорость, и атомарность.
Почему быстрый
- Всё в памяти. На пути запроса нет диска. AOF по умолчанию сбрасывается раз в секунду фоновым потоком, а не на каждой записи.
- Один поток на выполнение команд. Нет мьютексов, атомиков, переключений контекста, нет конкуренции за строки кэша между ядрами. Атомарность каждой команды достаётся бесплатно: во время её выполнения ничего другого не происходит.
- Мультиплексирование. С
epollна Linux (kqueueна BSD) один поток следит за десятками тысяч сокетов одним системным вызовом, и «поток на клиента» не нужен. - Протокол RESP. Длины передаются явно, парсер линейный, разбора грамматики нет.
- Компактные кодировки. Маленький хеш хранится как
listpack, сплошной кусок памяти, который процессор читает подряд; множество целых лежит вintset, короткая строка - вembstr, в одной аллокации с заголовком. - И то, чего Redis не делает: нет планировщика запросов, MVCC, журнала транзакций на пути записи, проверок целостности.
«Дорого обходится не работа с данными, а сетевой round-trip. Сама команда занимает доли
микросекунды, сеть - сотни. Поэтому работу с Redis почти всегда ускоряют, сокращая число
round-trip: MGET вместо цикла GET, пайплайнинг, Lua-скрипт
вместо read-modify-write.»
Где потоки есть
BGSAVEиBGREWRITEAOFработают черезfork(): отдельный процесс с копированием при записи.- С 2.4 появились отдельные потоки под
fsyncAOF и закрытие файлов. - С 4.0
UNLINKи семействоlazyfree-lazy-*отдают освобождение памяти больших ключей фоновому потоку. - С 6.0 есть
io-threads(по умолчанию выключены): чтение из сокетов, парсинг RESP и запись ответов можно распараллелить. Логика команд по-прежнему идёт в одном потоке.
Чем это хорошо
Каждая команда атомарна по построению; нет ни одной блокировки на пути данных; поведение предсказуемо, а профиль латентности очень узкий, пока все команды O(1).
Чем плохо
- Head-of-line blocking:
KEYS *,SMEMBERSна миллионе элементов,DELбольшого ключа, тяжёлый Lua-скрипт - и весь сервер стоит. Причём страдают все клиенты, а не автор команды. - Не масштабируется по ядрам. Потоки ввода-вывода снимают с главного потока сеть, но команды всё равно идут в одном, и 32 ядра один инстанс не загрузит. Масштабируются несколькими инстансами на разных портах или через Redis Cluster.
- Fork на снапшоте на инстансе в десятки гигабайт занимает сотни миллисекунд полной остановки: копируются таблицы страниц.
«Если Redis однопоточный, зачем ему MULTI/EXEC?» Ответ: отдельная команда
атомарна и так, а транзакция нужна, чтобы группа команд выполнилась подряд без
чужих команд между ними. При этом транзакция Redis устроена иначе, чем в СУБД: отката нет,
ошибка в середине не отменяет уже выполненное, а условную логику делают через
WATCH (оптимистическая блокировка) или Lua.
\r\n разделяет элементы, длины строк передаются явно. Парсится за один линейный
проход и при этом читаем глазами.
Клиентская библиотека шлёт команду массивом bulk-строк. SET user:1 Ivan уходит на
провод как *3, затем три пары «длина + данные». Явная длина делает протокол
бинарно-безопасным: в значении могут быть нули, переводы строк, что угодно. Парсер не
ищет разделитель, он просто читает N байт.
Типы RESP2
+простая строка (+OK),-ошибка (-WRONGTYPE ...),:целое (:1000),$bulk string ($-1- nil),*массив.
Что добавил RESP3 (Redis 6, включается через HELLO 3)
_- настоящий null вместо$-1/*-1.%map,~set,,double, big number, verbatim string: клиент теперь понимает семантику ответа, а не «плоский массив, который надо разбирать по соглашению».>push: сервер шлёт сообщения сам, не в ответ на команду. Отсюда два следствия: pub/sub теперь живёт на том же соединении, что и обычные команды, и клиентский кэш (CLIENT TRACKING) получает от Redis уведомления, что закэшированный ключ изменился, прямо в рабочем соединении (в RESP2 для них нужно отдельное соединение с подпиской).
Почему не protobuf? Протокол задумывали так, чтобы он тривиально разбирался на любом языке,
отлаживался через telnet и не требовал схемы. Вдобавок сервер принимает
inline-команды: можно послать PING\r\n текстом и получить +PONG.
KEYS - курсорный
SCAN, вместо DEL больших ключей - UNLINK.Что именно происходит при KEYS
Redis проходит всю таблицу ключей, накапливает совпадения в один ответ и делает это
синхронно. На 10 млн ключей это занимает от одной до трёх секунд. За это время не отвечают
health-check, у всех клиентов копятся таймауты, а Sentinel вполне может решить, что мастер
мёртв, и запустить failover. Плюс сам ответ на сотни мегабайт раздувает выходной буфер, и
клиента могут отключить по client-output-buffer-limit, если для обычных клиентов
этот лимит включён (по умолчанию нет).
До Redis 7.4 FLUSHALL и FLUSHDB без опций синхронно освобождали всю
память, и это была та же блокировка; с Redis 4 от неё спасал FLUSHALL ASYNC,
который отдаёт освобождение фоновому потоку. С 7.4 так работает и обычный вызов, только
клиент ждёт окончания, а сервер обслуживает остальных (кроме вызова внутри транзакции или
Lua-скрипта). Сервер так же останавливают: SMEMBERS на большом множестве,
HGETALL на большом хеше, LRANGE key 0 -1, SORT,
DEBUG SLEEP.
Как правильно
SCAN 0 MATCH "session:*" COUNT 500
SCAN 17408 MATCH "session:*" COUNT 500
# ... пока не вернётся курсор 0
И гарантии, о которых надо знать: ключ, живший всю итерацию, вернётся хотя бы раз; ключ может
вернуться несколько раз; добавленное или удалённое во время обхода вернётся как повезёт.
COUNT служит подсказкой, а не размером ответа: порция может прийти пустой, и это не
признак конца. Конец обозначает только курсор 0. MATCH фильтрует после
выборки, и скорость от него не растёт.
Опасные команды запрещают на сервере, а не договорённостями:
ACL SETUSER app on >pass ~app:* +@all -@dangerous или переименовывают через
rename-command KEYS "". А лучше вообще не строить приложение вокруг
поиска по шаблону: если нужно перечислять ключи, значит, нужен индекс - множество или
sorted set, где эти ключи лежат явно.
Когда другой клиент делает LPUSH, сервер сразу после выполнения этой команды (а в
транзакции - после всей транзакции) находит ожидающих на этом ключе и раздаёт им добавленные
элементы: кто дольше ждёт, тот получает первым.
Никакого опроса, никакого таймера. Для сервера ожидающий клиент почти бесплатен, это просто
запись в списке.
Что действительно блокирует сервер
- Долгие O(N)-команды:
KEYS,SMEMBERS,SORT. - Lua-скрипты и
MULTI/EXEC: выполняются целиком, не прерываясь. - Освобождение большого ключа без lazy free.
fork()приBGSAVEна большом датасете.
Практика на стороне Go
Соединение, висящее в BRPOP, занято целиком, поэтому под блокирующие команды и
pub/sub заводят отдельный клиент со своим пулом - иначе десяток воркеров выест общий пул, и
обычные GET начнут ждать свободного коннекта. Таймаут в BRPOP
делают обязательно конечным (например, 5 с), иначе горутина не отреагирует на отмену контекста
при остановке сервиса. И держи в голове, что BRPOP даёт at-most-once: если
воркер упал между извлечением и обработкой, задача потеряна. Для at-least-once нужен
BLMOVE в processing-список или Streams с consumer group и XACK.
Ключевые различия
- Модель данных. В Memcached значение хранится непрозрачным блобом, и любое изменение
превращается в «прочитать, поменять у себя, записать обратно», то есть в гонку. В Redis есть
INCR,SADD,ZADD,HSET: операции выполняются на сервере и атомарны. - Потоки. Memcached по-настоящему многопоточный и линейно масштабируется по ядрам одной машины. У Redis один поток на команды, растёт он шардированием.
- Персистентность и HA. У Memcached их нет вовсе: перезапустил - пусто, реплик нет, шардирование целиком на клиенте. У Redis есть RDB/AOF, репликация, Sentinel, Cluster.
- Память. Memcached использует slab-аллокатор: почти нет внешней фрагментации, но есть внутренние потери на округление до класса размера, и данные одного класса вытесняют только друг друга. Redis работает на jemalloc с активной дефрагментацией и восемью политиками вытеснения (с Redis 8.6 - десятью).
- Дополнительно у Redis: pub/sub, Streams, Lua, транзакции, распределённые локи, rate limiting, TTL на отдельные поля хеша (Redis 7.4), геозапросы, вероятностные структуры.
Как отвечать
«В подавляющем большинстве задач берут Redis: структуры и атомарные операции снимают целые классы проблем, а персистентность и репликация нужны почти всегда. Memcached осмыслен, если нужен тупой кэш блобов, объём большой, машина одна и хочется загрузить все её ядра без шардирования. Есть и ещё довод: Memcached предельно прост, и неправильно эксплуатировать его почти невозможно.»
В марте 2024 Redis Ltd. сменила лицензию с BSD на двойную RSALv2/SSPL, и Linux
Foundation в ответ форкнула проект в Valkey (его поддерживают AWS, Google, Oracle). В мае
2025 Redis 8 добавил третий вариант, AGPLv3. На практике разработчик пока разницы не
заметит - протокол и команды совместимы, go-redis работает с обоими, - но
знать про форк полезно: в облаках РФ и за рубежом всё чаще предлагают именно Valkey.
Что стоит запомнить
- Обычная обработка команд Redis однопоточна, поэтому долгая команда задерживает другие команды на том же event loop.
- SCAN возвращает курсорный обход с возможными повторами и не обещает снимок множества ключей.
- Большое удаление UNLINK может освободить память асинхронно, тогда как DEL способен выполнять больше работы синхронно.
Частые ошибки
- Не запускай KEYS на рабочем наборе: он может надолго занять обработку команд.
- Не считай SCAN курсором ровно-однократной доставки; повторная обработка ключа должна быть допустимой.
- Не дели один пул соединений между большим числом блокирующих операций и latency-чувствительными командами без оценки ожидания.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Прочитай курсорный обход
Команда SCAN вернула курсор 17 и список ключей. Можно ли считать обход законченным и гарантировать, что ключи не повторятся? Что означает курсор 0?
iter := rdb.Scan(ctx, 0, "session:*", 500).Iterator()
for iter.Next(ctx) {
batch = append(batch, iter.Val())
if len(batch) == 500 { _ = rdb.Unlink(ctx, batch...).Err(); batch = batch[:0] }
}
if err := iter.Err(); err != nil { return err }
Ответ и разбор
Нет. Продолжай SCAN с курсором 17, пока ответ не вернёт курсор 0. Во время обхода ключ может встретиться более одного раза; набор может меняться параллельно, поэтому обработка должна быть идемпотентной. Курсор 0 означает конец текущего обхода, а не атомарный снимок.
Код-ревью
2. Найди блокирующую команду в общем пуле
Worker владеет отдельным ctx, который отменяется при shutdown. Он использует общий go-redis client с HTTP handlers и блокируется на BRPOP без серверного таймаута. Найди риск для пула и покажи, как цикл завершится по отмене.
func runWorker(ctx context.Context, rdb *redis.Client) error {
for {
res, err := rdb.BRPop(ctx, 0, "queue:jobs").Result()
if err != nil {
if ctx.Err() != nil { return ctx.Err() }
continue
}
if err := handle(res[1]); err != nil { return err }
}
}
Ответ и разбор
Нулевой timeout у BRPOP означает ждать до элемента или отмены ctx; каждый заблокированный worker занимает соединение из общего пула, поэтому число таких workers может лишить HTTP handlers свободных соединений. Держи число workers ограниченным и выдели им отдельный Redis client/pool, если пул общий не выдерживает блокирующие команды. При shutdown отмени worker-owned ctx: go-redis прервёт ожидающий вызов, ветка ctx.Err() != nil завершит цикл. Для сетевых ошибок добавь задержку перед новой попыткой, иначе получится горячий цикл.
Применение
3. Составь безопасную очистку session:*
Нужно удалить все ключи session:* без остановки сервиса. Опиши цикл сканирования и удаление пачками, включая ошибку посередине.
Ответ и разбор
Иди по SCAN курсору с MATCH session:* и ограниченным COUNT, накапливай небольшой batch и удаляй его через UNLINK. Проверяй ошибку каждой команды и ошибку итератора; при ошибке запиши курсор/метрики и повтори безопасно, потому что обработка ключа может повториться. Не собирай весь набор ключей в память и не вызывай KEYS.
Применение
4. Оцени блокировку большой команды
Один worker вызывает HGETALL на hash размером 8 МБ. В это время p99 GET на Redis вырос с 1 мс до 300 мс. Объясни связь и предложи изменение модели доступа.
Ответ и разбор
Redis не может обслуживать обычные команды, пока синхронно сериализует большой ответ и отправляет его клиенту; это создаёт очередь. Не загружай весь hash, если нужны отдельные поля: дели данные на меньшие ключи/части, запрашивай известные поля или обрабатывай объём порциями. Проверь размер ответа и число вызовов; перенос в другую структуру должен учитывать паттерн доступа.