Команды, блокирующие Redis

Предпосылки: Event loop, Клиенты и соединения, Структуры данных на практике.

Структуры данных на практике

Предыдущие заметки показали, как Rails-приложение использует разные структуры Redis для конкретных задач. Но любая из этих команд, даже корректно выбранная, может заблокировать весь Redis, если работает с большим объёмом данных.

Redis обрабатывает все команды в одном потоке — O. Пока одна команда выполняется, все Puma-процессы, Sidekiq-воркеры и другие клиенты ждут. Для типичных команд (GET, SET, INCR) это незаметно — они занимают микросекунды. Проблемы начинаются, когда команда за один вызов обходит большой объём данных.

Здесь важно не перепутать уровень проблемы. Пул соединений помогает от очереди на стороне Ruby-процесса, но не спасает от тяжёлой команды на стороне самого Redis. Если админка запустила KEYS *, ждать будут все соединения, потому что сервер занят одной длинной командой. Поэтому отдельные пулы защищают только клиентскую сторону, а разделение Redis по инстансам остаётся отдельной мерой изоляции.

KEYS в админке

В Rails-приложениях KEYS иногда используется для отладки или в админ-панелях — например, чтобы найти все сессии пользователя:

# ❌ Блокирует Redis на время полного обхода таблицы ключей
sessions = REDIS.with { |r| r.keys("session:user:#{user_id}:*") }

KEYS обходит каждый ключ в Redis и проверяет его на соответствие маске. При миллионе ключей это занимает десятки миллисекунд. В это время ни один другой клиент не может выполнить даже простой GET — проверка лимитов запросов, кеш, Sidekiq, ActionCable ждут.

SCAN решает проблему, возвращая ключи порциями. Между порциями Redis обрабатывает команды других клиентов:

# ✅ SCAN возвращает ключи пачками, не блокируя event loop надолго
def find_user_sessions(user_id)
  pattern = "session:user:#{user_id}:*"
  keys = []
 
  REDIS.with do |r|
    cursor = "0"
    loop do
      cursor, batch = r.scan(cursor, match: pattern, count: 100)
      keys.concat(batch)
      break if cursor == "0"
    end
  end
 
  keys
end

count: 100 — подсказка Redis, сколько ключей просматривать за одну итерацию. Это не гарантия размера результата, а ориентир для внутреннего обхода.

Чтение больших структур целиком

HGETALL, SMEMBERS, LRANGE 0 -1 читают всё содержимое структуры за один вызов. На маленьких структурах (десятки–сотни элементов) они быстры и безопасны. Проблема возникает, когда структура незаметно вырастает.

Типичный сценарий: приложение хранит действия пользователя в Redis SET и периодически проверяет, какие действия уже выполнены:

# ❌ Безобидно при 50 элементах, блокирует при 500 000
def completed_actions(user_id)
  REDIS.with { |r| r.smembers("user:#{user_id}:actions") }
end

Если структура может расти неограниченно, лучше проверять принадлежность конкретного элемента через SISMEMBER (O(1)) или использовать SSCAN для порционного чтения:

# ✅ Проверка конкретного элемента — O(1), не зависит от размера множества
def action_completed?(user_id, action)
  REDIS.with { |r| r.sismember("user:#{user_id}:actions", action) }
end

То же касается HGETALL: если нужно одно-два поля из хеша, HGET или HMGET не блокируют event loop, даже если в хеше тысячи полей.

Удаление больших объектов

DEL списка или множества из миллионов элементов освобождает память синхронно — Redis обходит все элементы и освобождает каждый. В Rails это встречается при очистке данных пользователя или истёкших очередей:

# ❌ Если в списке миллион элементов, DEL блокирует event loop
REDIS.with { |r| r.del("user:#{user_id}:notifications") }

Начиная с Redis 4.0 команда UNLINK передаёт освобождение памяти фоновому потоку:

# ✅ UNLINK удаляет ключ из пространства имён мгновенно,
#    память освобождается в фоне
REDIS.with { |r| r.unlink("user:#{user_id}:notifications") }

После UNLINK ключ сразу перестаёт существовать для других клиентов, но физическое освобождение памяти происходит в отдельном потоке lazyfree.

Lua-скрипты

Lua-скрипты

Lua-скрипты выполняются атомарно: пока скрипт работает, другие команды ждут. Для коротких скриптов (проверка + удаление блокировки, атомарный инкремент с условием) это не проблема. Но если скрипт обходит большой объём данных или содержит тяжёлую логику, он блокирует Redis так же, как KEYS *:

# ❌ Lua-скрипт, который обходит все ключи по маске — та же проблема, что KEYS
INVALIDATE_SCRIPT = <<~LUA
  local keys = redis.call("KEYS", ARGV[1])
  for _, key in ipairs(keys) do
    redis.call("DEL", key)
  end
  return #keys
LUA
 
REDIS.with { |r| r.eval(INVALIDATE_SCRIPT, argv: ["cache:products:*"]) }

Если нужна массовая инвалидация, лучше вынести её из Lua в Ruby-код с SCAN + UNLINK:

# ✅ Порционное удаление — event loop свободен между итерациями
def invalidate_cache(pattern)
  REDIS.with do |r|
    cursor = "0"
    loop do
      cursor, keys = r.scan(cursor, match: pattern, count: 100)
      r.unlink(*keys) unless keys.empty?
      break if cursor == "0"
    end
  end
end

Как обнаружить блокирующие команды

Redis пишет в лог команды, выполнение которых заняло больше порога (по умолчанию 10 мс). Последние записи доступны через SLOWLOG:

# Последние 10 медленных команд
REDIS.with { |r| r.slowlog("GET", 10) }

Каждая запись содержит время выполнения в микросекундах, имя команды и аргументы. Если в slowlog регулярно появляются KEYS, HGETALL, SMEMBERS или EVAL — стоит проверить размер структур, с которыми они работают.


Структуры данных на практике