Команды, блокирующие 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
endcount: 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 — стоит проверить размер структур, с которыми они работают.