Программа курса

Модуль 01 / Урок 4 из 27

Сложность операций и SCAN

Теория и практика

Из-за однопоточной модели сложность команды равна времени, на которое вы блокируете весь сервер. Учимся читать сложность и заменять опасные команды на безопасные итераторы.

Таблица сложности, которую стоит держать в голове

КомандаСложностьКомментарий
GET, SET, DEL (маленький), INCR, HGET, HMGET, LPUSH, RPUSH, SADD, SISMEMBER, ZADD, ZSCOREO(1)безопасно всегда
ZRANGEBYSCORE, ZRANK, ZCOUNTO(log N)безопасно
LINDEX, LSETO(N)N — индекс; для больших списков аккуратно
LRANGE, HGETALL, HKEYS, HVALS, SMEMBERS, ZRANGE (весь)O(N)N — размер коллекции; опасно при N > ~10k
LREM, LINSERTO(N)сканируют список с края
SINTER, SUNION, SDIFFO(N·M)проверяйте размеры сетов заранее (SCARD)
SINTERSTOREO(N·M)та же цена, но результат — в ключ
SORTO(N+M·log M)почти всегда можно заменить ZSet'ом
KEYS patternO(вся БД)запрещено в проде
SCAN / HSCAN / SSCAN / ZSCANO(1) за вызов + O(бакетов)итерация без блокировки
DEL большого ключаO(N)блокирует; используйте UNLINK
FLUSHDB / FLUSHALLO(вся БД)есть 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_setSSCAN big_set 0 COUNT 500
HGETALL big_hashHSCAN или точечные HMGET нужных полей
DEL big_keyUNLINK big_key (фоновое освобождение)
FLUSHALLFLUSHALL ASYNC
SORTZSet: 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

Практика

  1. Напишите на своём языке функцию «удалить все ключи по префиксу» через SCAN+UNLINK и протестируйте на 1 млн ключей, замеряя латентность PING во время обхода — она не должна деградировать.
  2. Сравните стоимость: KEYS * против полного обхода SCAN'ом по 1000 вызовов (замерьте wall-time обеих операций).
  3. Создайте сет на 1 млн элементов и попробуйте SMEMBERS (запаситесь терпением), затем SSCAN порциями. Сравните.
Мини-квиз

1. SCAN вернул курсор ≠ 0 и пустой список ключей. Это баг? — Нет: COUNT — про бакеты, MATCH отфильтровал; продолжайте с того же курсора.

2. Почему DEL большого ключа опаснее UNLINK? — DEL освобождает память синхронно в основном потоке (блокировка), UNLINK — в фоновом.

3. Нужен ли «список всех ключей с TTL>3600»? Как получить безопасно? — SCAN итерациями + PEXPIRETIME/TTL на каждом ключе, в фоне; на лету — никак O(1) не получится.