Из-за однопоточной модели сложность команды равна времени, на которое вы блокируете весь сервер. Учимся читать сложность и заменять опасные команды на безопасные итераторы.
Таблица сложности, которую стоит держать в голове
| Команда | Сложность | Комментарий |
|---|---|---|
| GET, SET, DEL (маленький), INCR, HGET, HMGET, LPUSH, RPUSH, SADD, SISMEMBER, ZADD, ZSCORE | O(1) | безопасно всегда |
| ZRANGEBYSCORE, ZRANK, ZCOUNT | O(log N) | безопасно |
| LINDEX, LSET | O(N) | N — индекс; для больших списков аккуратно |
| LRANGE, HGETALL, HKEYS, HVALS, SMEMBERS, ZRANGE (весь) | O(N) | N — размер коллекции; опасно при N > ~10k |
| LREM, LINSERT | O(N) | сканируют список с края |
| SINTER, SUNION, SDIFF | O(N·M) | проверяйте размеры сетов заранее (SCARD) |
| SINTERSTORE | O(N·M) | та же цена, но результат — в ключ |
| SORT | O(N+M·log M) | почти всегда можно заменить ZSet'ом |
| KEYS pattern | O(вся БД) | запрещено в проде |
| SCAN / HSCAN / SSCAN / ZSCAN | O(1) за вызов + O(бакетов) | итерация без блокировки |
| DEL большого ключа | O(N) | блокирует; используйте UNLINK |
| FLUSHDB / FLUSHALL | O(вся БД) | есть async-вариант: FLUSHALL ASYNC |
SCAN: как правильно обходить всё пространство ключей
SCAN — курсорный итератор: вы передаёте курсор, получаете порцию ключей и новый
курсор; цикл продолжается, пока курсор не станет «0».
SCAN 0 MATCH user:* COUNT 1000
1) "174" # следующий курсор
2) 1) "user:1001" 2) "user:1002" ...
SCAN 174 MATCH user:* COUNT 1000
...
Что важно понимать:
- COUNT — это подсказка количества просматриваемых бакетов, а не возвращаемых ключей: с MATCH может вернуться и 0 ключей за вызов при непустом курсоре;
- гарантии полноты, но не моментального снимка: ключи, существовавшие весь цикл обхода, будут возвращены обязательно; добавленные/удалённые во время обхода — не факт (нет ни дубликатов гарантированных, ни потерь гарантированных);
- сканирование идёт по бакетам (обратный бинарный порядок), поэтому оно terminates и не зацикливается даже при рехешинге;
- каждый вызов — дешёвый O(лог-бакетов); можно «размазать» обход по времени: по N вызовов в секунду из cron'а приложения;
- для коллекций то же самое:
HSCAN hash 0,SSCAN set 0,ZSCAN zset 0— безопасные итераторы по элементам вместо HGETALL/SMEMBERS.
Типовые заменители опасных команд
| Вместо | Используйте |
|---|---|
| KEYS user:* | SCAN MATCH user:* в цикле (или ведите собственный сет индексов) |
| SMEMBERS big_set | SSCAN big_set 0 COUNT 500 |
| HGETALL big_hash | HSCAN или точечные HMGET нужных полей |
| DEL big_key | UNLINK big_key (фоновое освобождение) |
| FLUSHALL | FLUSHALL ASYNC |
| SORT | ZSet: score — критерий сортировки, ZRANGE по диапазону |
# Ленивое освобождение (Redis 4+): unlink убирает ключ из словаря мгновенно,
# память освобождает фоновый поток
UNLINK huge:hash
# Автоматизировать для eviction/expire/rename/flush:
# CONFIG SET lazyfree-lazy-eviction yes
# CONFIG SET lazyfree-lazy-expire yes
# CONFIG SET lazyfree-lazy-server-del yes
Практика
- Напишите на своём языке функцию «удалить все ключи по префиксу» через SCAN+UNLINK и протестируйте на 1 млн ключей, замеряя латентность PING во время обхода — она не должна деградировать.
- Сравните стоимость:
KEYS *против полного обхода SCAN'ом по 1000 вызовов (замерьте wall-time обеих операций). - Создайте сет на 1 млн элементов и попробуйте
SMEMBERS(запаситесь терпением), затемSSCANпорциями. Сравните.
Мини-квиз
1. SCAN вернул курсор ≠ 0 и пустой список ключей. Это баг? — Нет: COUNT — про бакеты, MATCH отфильтровал; продолжайте с того же курсора.
2. Почему DEL большого ключа опаснее UNLINK? — DEL освобождает память синхронно в основном потоке (блокировка), UNLINK — в фоновом.
3. Нужен ли «список всех ключей с TTL>3600»? Как получить безопасно? — SCAN итерациями + PEXPIRETIME/TTL на каждом ключе, в фоне; на лету — никак O(1) не получится.