← назад к разделу

Redis чаще всего появляется в проекте как кэш. Но те же самые структуры данных и атомарные операции решают ещё несколько классических задач бэкенда — без подключения отдельных сервисов.

один ключ в Redis решает, какая копия приложения работает app-1 app-2 lock:invoice:42 критическая секция обе копии шлют SET … NX EX 30 ключа нет owner = app-1TTL 30 c owner = app-2TTL 30 c OK — замок захвачен (nil) — ждём и повторяем app-1 app-2OK — замок достался app-2 EVAL: удалить ключ, если владелец — app-1 повтор SET … NX EX 30

Замок — это обычный ключ со сроком жизни. Команда с флагом NX выполняется атомарно, поэтому из двух одновременных попыток создать ключ успешна ровно одна. Срок жизни освобождает замок, если владелец умер и уже ничего не снимет; проверка владельца при удалении не даёт снять чужой замок.

Обязательно

Зачем вообще идти дальше кэша

Когда приложение работает в нескольких экземплярах, привычные инструменты начинают отказывать. Synchronized-блок защищает только один JVM-процесс. AtomicLong живёт только в памяти одного Pod'а. EventBus из Guava не знает о соседних серверах.

Redis работает как единая точка координации: все экземпляры приложения видят одно и то же состояние, а операции над ним атомарны.

Распределённая блокировка

Несколько экземпляров приложения одновременно запускают один и тот же джоб или обрабатывают одно и то же событие. Нужно гарантировать, что только один экземпляр выполнит критическую секцию.

SETNX + TTL: простой вариант

SETNX (SET if Not eXists) — атомарная команда: ключ создаётся, только если его нет. Её считают устаревшей с версии 2.6.12: на смену пришёл SET с флагом NX, который задаёт срок жизни тем же запросом. У SETNX срок пришлось бы ставить вторым шагом — а между двумя шагами процесс может умереть и оставить замок навсегда.

живой пример

# Попытка захватить блокировку на 30 секунд
SET lock:invoice:42 owner-uuid-1234 NX EX 30
# OK — блокировка захвачена
# (nil) — кто-то другой уже держит блокировку
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

NX — «только если не существует», EX 30 — TTL в секундах. TTL обязателен: если процесс упадёт, блокировка освободится автоматически.

Освобождать нужно только свою блокировку — поэтому в значение пишем уникальный идентификатор владельца и проверяем его перед удалением:

# Проверить владельца и удалить — атомарно через Lua
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end" 1 lock:invoice:42 owner-uuid-1234

Механика замка видна на маленькой программе: ключ — запись в карте, NX — проверка перед вставкой, срок жизни — поле записи.

живой пример

import java.util.HashMap;
import java.util.Map;

public class LockDemo {
    record Lock(String owner, long expiresAt) {}

    static final String KEY = "lock:invoice:42";
    static final Map<String, Lock> redis = new HashMap<>();
    static long now = 0;

    public static void main(String[] args) {
        System.out.println("app-1 берёт замок: " + setNx("app-1"));
        System.out.println("app-2 берёт замок: " + setNx("app-2"));
        now = 31;
        System.out.println("срок жизни истёк, app-2 берёт: " + setNx("app-2"));
        System.out.println("app-1 снимает замок: " + delIfOwner("app-1"));
        System.out.println("замок у: " + redis.get(KEY).owner());
    }

    static boolean setNx(String owner) {
        Lock lock = redis.get(KEY);
        if (lock != null && lock.expiresAt() > now) {
            return false;
        }
        redis.put(KEY, new Lock(owner, now + 30));
        return true;
    }

    static boolean delIfOwner(String owner) {
        Lock lock = redis.get(KEY);
        if (lock == null || !lock.owner().equals(owner)) {
            return false;
        }
        redis.remove(KEY);
        return true;
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Fencing-токен

Простой SETNX-замок имеет слабость: если процесс завис, TTL истечёт, другой экземпляр захватит блокировку — а первый «очнётся» и решит, что всё ещё держит её. Для критичных операций добавляют fencing-токен — монотонно возрастающее число, которое передаётся в каждый запрос к защищаемому ресурсу. Ресурс отклоняет запросы со старым токеном.

В Redis такой счётчик можно хранить командой INCR — но тогда честно будет сказать и вторую половину. Счётчик, который обязан расти строго по одному и никогда не откатываться назад, живёт на одном конкретном узле. Узел недоступен — маркеры выдавать некому. Реплики тут не спасают: они догоняют первичный узел с запозданием, и после переключения счётчик может откатиться к старому значению, а неубывающим он быть обязан. Поэтому там, где маркер действительно нужен, его берут не у Redis, а у того, кто умеет строгий порядок: у последовательности в базе данных, у ZooKeeper или etcd.

Redlock и его критика

Redlock — алгоритм распределённой блокировки поверх нескольких независимых Redis-узлов. Задуман он как раз против той беды, о которой шла речь абзацем выше: единственный Redis-узел недоступен — и замков нет ни у кого.

Спорят о нём с 2016 года, и спор стоит знать. Мартин Клеппман, автор «Designing Data-Intensive Applications», разобрал алгоритм и показал: на паузах — сборке мусора, подвисшей сети, замершем диске — Redlock ломается ровно так же, как обычный замок со сроком жизни. Процесс может считать, что держит блокировку, когда её уже перехватил другой, и никакое число узлов этого не меняет. Спасает не алгоритм, а тот самый маркер-ограждение — а выдать монотонный маркер Redlock как раз не умеет. Сальваторе Санфилиппо, автор Redis, ответил, что для задач вроде «не запускать фоновую задачу дважды» строгий маркер и не нужен, а там, где нужен, никакой замок на сроке жизни не годится в принципе.

Вывод из этого спора практический: если речь о деньгах и абсолютной корректности — нужен маркер-ограждение из строго упорядоченного источника или специализированный сервис (ZooKeeper, etcd). Для задач вроде «не запускать фоновую задачу дважды» хватает и Redlock, и одиночного замка с уникальным владельцем.

Готовая реализация для Java — библиотека Redisson (RLock).

Продление аренды и почему берут готовую библиотеку

У самодельного замка на SET NX EX есть дыра, которая проявляется ровно тогда, когда больно: работа заняла дольше срока аренды. Замок истёк, его подобрал второй обработчик, а первый всё ещё работает — и в какой-то момент оба считают себя владельцами. Дальше первый завершается и снимает чужой замок (если снимает без проверки владельца), и владельцев становится трое.

Два приёма против этого. Первый уже описан: снимать замок только своим маркером, сравнив его в скрипте на Lua, — тогда чужой замок не снимется. Второй закрывает саму причину: продление аренды. Владелец запускает фоновую задачу, которая, пока работа идёт, каждые несколько секунд продлевает срок замка (PEXPIRE со своим маркером в том же скрипте). Тогда короткая аренда (скажем, тридцать секунд) не мешает долгой работе, а при падении владельца продление прекращается и замок освобождается сам.

Именно это и делает Redisson: у его замка есть сторожевой таймер, который продлевает аренду, пока жив владелец, — поэтому его и рекомендуют вместо самописного варианта. Плюс он умеет отпускать замок по идентификатору потока и предлагает «честный» вариант с очередью ожидающих.

Ради чего замок чаще всего берут

Самая частая задача — не выполнить фоновое задание дважды, когда экземпляров сервиса несколько. Каждый экземпляр по расписанию просыпается одновременно, и без внешнего замка задание отрабатывает на всех сразу: письма уходят трижды, отчёт считается трижды.

Готовое решение для Spring — ShedLock: он оборачивает метод с расписанием и держит замок в общем хранилище (Redis, база, Zookeeper), с явным минимальным и максимальным временем удержания.

@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "nightlyReport", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M")
public void buildNightlyReport() { ... }

lockAtMostFor защищает от зависшего экземпляра (замок всё равно освободится), lockAtLeastFor — от повторного запуска на соседнем узле, если задание отработало мгновенно и часы разошлись. Писать такой замок руками стоит только тогда, когда нужен замок на данные (обработку конкретного клиента, конкретного файла), а не на задание целиком.

Ограничение запросов (rate limiting)

Счётчик с TTL

Простейший rate limiter: считать запросы за период и блокировать, когда лимит превышен.

живой пример

# Каждый запрос пользователя user:42
SET rate:user:42:2026060312 0 EX 3600 NX   # завести счётчик со сроком, если его ещё нет
INCR rate:user:42:2026060312               # ключ = пользователь + час
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Если значение счётчика превысило лимит — возвращаем 429. Срок жизни ключа гарантирует, что через час счётчик обнулится сам.

Порядок команд здесь не случайный. Напрашивается более короткое «INCR, потом EXPIRE» — и это ровно та ловушка, о которой шла речь в разделе про блокировки: процесс, умерший между двумя командами, оставит ключ без срока жизни. Счётчик застынет на превышенном значении и никогда не обнулится, а пользователь останется заблокированным навсегда. SET ... EX ... NX заводит счётчик сразу со сроком и не трогает уже существующий. Тот же эффект дают Lua-скрипт из двух команд или EXPIRE только тогда, когда INCR вернул единицу.

Минус: fixed window — в конце и начале окна можно «удвоить» лимит. В последнюю секунду окна и первую следующего пройдёт в два раза больше запросов.

12:00:00 окно А открылось 12:00:59 окно А: 100 запросов 12:01:00 окно Б, счётчик 0 12:01:01 окно Б: 100 запросов

Лимит сто запросов в минуту, а на стыке окон за две секунды проходит двести: смотрите на отметки 12:00:59 и 12:01:01.

Скользящее окно (sliding window)

Точнее, но требует больше памяти. Метки времени всех запросов лежат в Sorted Set: перед проверкой выбрасываем всё, что вывалилось из окна (ZREMRANGEBYSCORE), считаем остаток (ZCARD) и добавляем новый запрос (ZADD) только если лимит не выбран:

живой пример

# ts = текущая метка в миллисекундах
ZREMRANGEBYSCORE rate:user:42 0 1780617540000        # выкинуть всё старше окна: ts - 60 сек
ZCARD rate:user:42                                   # сколько запросов осталось в окне
# и только если полученное число меньше лимита:
ZADD rate:user:42 1780617600000 "req-uuid"
EXPIRE rate:user:42 60
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Если ZCARD уже даёт лимит — отказ, и запрос в множество не попадает. Порядок тут решает всё: стоит сначала добавить метку, а потом считать — и отклонённый запрос всё равно останется в окне и будет занимать в нём место следующую минуту. Отказы начнут продлевать сами себя, и пользователь выберется из блокировки, только если замолчит на целое окно. Вся цепочка команд выполняется атомарно через MULTI/EXEC или Lua-скрипт.

В Spring Boot встроенная поддержка — через RedisTemplate или библиотеку Bucket4j с Redis-бэкендом.

Ведро с токенами: то, что стоит в шлюзах

Скользящее окно считает события, а самый распространённый в шлюзах и библиотеках алгоритм устроен иначе — он считает токены.

Идея: у клиента есть ведро ёмкостью, скажем, сто токенов, которое пополняется с постоянной скоростью (десять токенов в секунду). Каждый запрос забирает токен; нет токенов — отказ. Пока клиент обращается реже скорости пополнения, ведро полное и он ничего не замечает; если он молчал минуту и вдруг выдал шестьдесят запросов разом, они пройдут за счёт накопленных токенов.

Это и есть главное отличие: ведро разрешает всплеск в пределах ёмкости, а скользящее окно — нет. Для публичного API это обычно то, что нужно: пользователь, открывший страницу с десятью запросами сразу, не должен получать отказ, если в среднем он вписывается в лимит.

Два параметра задают поведение: ёмкость (какой всплеск допустим) и скорость пополнения (средний разрешённый темп). Реализуют его либо скриптом на Lua в Redis (хранится пара «сколько токенов» и «когда пополняли последний раз», пополнение считается по прошедшему времени), либо готовой библиотекой: в Java это Bucket4j, который умеет держать состояние ведра в Redis и потому работает на нескольких экземплярах сервиса.

Родственный алгоритм — дырявое ведро: он, наоборот, сглаживает поток, выпуская запросы с постоянной скоростью и ставя лишние в очередь. Его берут там, где нужно защитить медленного получателя (внешний API с жёстким лимитом), а не ограничить клиента.

Pub/Sub: публикация и подписка

Pub/Sub — канал для отправки сообщений всем подписчикам в реальном времени.

# Подписчик
SUBSCRIBE channel:notifications

# Издатель (из другого клиента)
PUBLISH channel:notifications "user:42 logged in"

В Spring Data Redis — RedisMessageListenerContainer с MessageListener.

Когда Pub/Sub подходит, а когда нет

Pub/Sub подходит для задач, где потеря одного сообщения некритична: обновление UI в реальном времени, сброс кэша на нескольких серверах, рассылка уведомлений «best-effort».

Pub/Sub не подходит, если нужно:

  • гарантировать доставку (подписчик, не слушавший в момент публикации, сообщение не получит);
  • переиграть историю событий;
  • обработать сообщение ровно один раз.

Для этих случаев — Redis Streams.

Pub/Sub PUBLISH подписчик офлайн сообщение исчезло Streams XADD подписчик офлайн запись ждёт XACK

Один и тот же отключённый обработчик: публикация проходит мимо него безвозвратно, а запись в потоке дожидается подтверждения.

Отдельная ловушка ждёт в кластере. Обычная публикация рассылается всем узлам кластера, потому что Redis не знает, где сидят подписчики: сообщение о сбросе одного ключа заставляет работать весь кластер, и на большом потоке событий это заметная нагрузка. Поэтому в Redis 7 появился шардированный вариант (SPUBLISH и SSUBSCRIBE): канал привязывается к слоту по своему имени, как обычный ключ, и сообщение уходит только на тот узел, который этим слотом владеет.

Практически это значит, что для сброса кэша на нескольких серверах в кластере лучше использовать шардированный вариант и включить его поддержку в клиенте (в Lettuce это отдельный режим). И общая оговорка остаётся в силе: публикация ничего не гарантирует — подписчик, который был отключён в момент отправки, сообщение не получит. Для сброса кэша это приемлемо (ключ протухнет по сроку), для чего-то важного — нет, там нужен поток с подтверждениями.

Redis Streams: надёжная очередь

Streams появились в Redis 5.0 как append-only журнал — аналог Kafka, но без отдельного кластера.

# Создание группы потребителей (один раз)
XGROUP CREATE orders order-processor $ MKSTREAM

# Публикация события
XADD orders * order_id 1001 status created

# Чтение группой потребителей
XREADGROUP GROUP order-processor consumer-1 COUNT 10 BLOCK 2000 STREAMS orders >

# Подтверждение обработки
XACK orders order-processor 1717425600000-0

> означает «дай мне новые сообщения, которые ещё не взял никто в группе». После обработки вызываем XACK — без этого сообщение останется в состоянии «в обработке» и будет видно через XPENDING.

Streams дают:

  • сохранность сообщений (сообщение не пропадает при отключении потребителя);
  • группы потребителей с балансировкой нагрузки;
  • повторную обработку недоставленных сообщений.

В Java/Spring — StreamMessageListenerContainer из Spring Data Redis.

Streams vs Kafka: когда что выбрать

СитуацияRedis StreamsKafka
Redis уже есть в стекеподходитизбыточен
Нужно партиционирование на сотни топиковне оптимальноподходит
Объём — миллионы сообщений/секограничено RAMподходит
Replay за несколько дней/недельограничено политикойподходит
Простота эксплуатации важнее масштабаподходитсложнее

Три вещи, без которых очередь на потоке не работает в проде.

Поток надо подрезать. Записи остаются в нём после подтверждения — XACK помечает сообщение как обработанное, но из потока не удаляет. Без ограничения длины (XADD … MAXLEN ~ 100000 или периодический XTRIM) поток растёт линейно нагрузке, пока не упрётся в память. Ориентир выбирают по времени: сколько записей нужно, чтобы упавший обработчик догнал отставание, плюс запас.

Зависшие сообщения забирают руками. Сообщение, выданное обработчику и не подтверждённое (он упал), остаётся в списке «на обработке» у этого потребителя — и само никуда не уйдёт. XPENDING показывает такие сообщения с возрастом и владельцем, а забирают их XCLAIM (по конкретным идентификаторам) или XAUTOCLAIM (пачкой всё, что висит дольше заданного времени). Отсюда обязательный элемент рабочего обработчика — периодический вызов автоматического перехвата:

XAUTOCLAIM orders workers worker-2 60000 0 COUNT 100

Читается так: в потоке orders, в группе workers, от имени worker-2 забрать всё, что висит дольше шестидесяти секунд, начиная с начала, порциями по сто. Без этого шага сообщения упавшего обработчика не обработает никто.

Счётчик доставок и «ядовитые» сообщения. У каждого висящего сообщения есть число попыток доставки; сообщение, которое уже пять раз перехватывали и оно снова падает, обрабатывать бессмысленно — его отправляют в отдельный поток «разобрать руками» и подтверждают. Без этого один плохой документ останавливает очередь навсегда.

Дополнительно: при первом чтении можно пропустить

Глубже: ведро с токенами и дырявое ведрорасширенное

Счётчик в окне из основной части отвечает на вопрос «сколько запросов за минуту», но не умеет главного, что нужно продуктовому лимиту: разрешить короткий всплеск и при этом держать среднюю скорость. Пользователь открывает страницу, и она делает двадцать запросов за секунду; лимит «десять в секунду» её сломает, а лимит «шестьсот в минуту» пропустит и шестьсот за одну секунду. Для этого есть два классических алгоритма, и оба про ведро.

Ведро с токенами (token bucket). В ведре лежат токены, максимум capacity штук, и каждую секунду в него доливают rate новых, пока не полное. Запрос забирает токен; нет токена, отказ. Полное ведро разрешает всплеск размером capacity, а дальше пропускает ровно rate в секунду. Хранить в Redis нужно всего два числа на ключ, остаток и время последнего пополнения, а долив считают лениво при запросе, умножая прошедшее время на rate. Проверка и списание обязаны быть одной атомарной операцией, поэтому это Lua:

EVAL "local t = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local cap, rate, now = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local tokens = tonumber(t[1]) or cap
local ts = tonumber(t[2]) or now
tokens = math.min(cap, tokens + (now - ts) * rate)
local ok = 0
if tokens >= 1 then tokens = tokens - 1; ok = 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) * 2)
return ok" 1 rate:user:42 20 10 1780617600

Здесь ёмкость двадцать, скорость десять в секунду: страница с двадцатью запросами пройдёт, а бот, который шлёт сто в секунду, получит десять.

Дырявое ведро (leaky bucket) смотрит с другой стороны: запросы падают в ведро, а вытекают из него с постоянной скоростью; переполнилось, лишнее отбрасывается. На выходе получается ровный поток без всплесков, и это нужно там, где всплеск вреден получателю, например перед внешним API с жёстким лимитом или перед отправкой писем. Реализация та же пара чисел, только считают не остаток токенов, а уровень воды. В строгой форме дырявое ведро это очередь, из которой берут по расписанию, и тогда запросы не отклоняются, а ждут.

Что выбрать. Лимит для пользователей и клиентов API: ведро с токенами, потому что всплески у людей нормальны. Выравнивание потока к внешней системе: дырявое ведро или очередь. В Java всё это уже написано в Bucket4j, где ведро с токенами настраивается пределами и умеет хранить состояние в Redis через тот же Lua; писать свой скрипт стоит, когда нужно понять, что происходит, или когда правил больше, чем библиотека выражает.

Коротко

  • Распределённая блокировка — SET key value NX EX ttl; владелец идентифицируется уникальным значением; освобождение через Lua-скрипт.
  • Fencing-токен — монотонный счётчик (INCR) как защита от «проснувшегося» старого замка.
  • Redlock подходит для большинства задач координации, но не для операций, где нужна строгая корректность при GC-паузах.
  • Rate limiting: простой счётчик с TTL (fixed window) или Sorted Set (sliding window) для точного скользящего окна.
  • Pub/Sub — доставка «best-effort»: нет гарантии доставки, нет истории. Хорош для сброса кэша и real-time уведомлений.
  • Streams — надёжная очередь с группами потребителей и подтверждением; выбирайте, когда Redis уже есть и Kafka избыточен.
  • В шлюзах чаще всего стоит ведро с токенами: оно разрешает всплеск до ёмкости и держит среднюю скорость, в отличие от скользящего окна; дырявое ведро выравнивает поток. Оба это два числа на ключ и один Lua-скрипт, в Java готово в Bucket4j.
  • Самодельный замок надо продлевать, пока работа идёт (сторожевой таймер), иначе владельцев становится двое; Redisson делает это сам, а «не запустить задание дважды» проще закрыть ShedLock.
  • В кластере обычная публикация рассылается по всем узлам — для сброса кэша берут шардированный вариант (SPUBLISH/SSUBSCRIBE).
  • Очередь на потоке требует трёх вещей: подрезать MAXLEN ~, забирать зависшее XAUTOCLAIM и уводить «ядовитые» сообщения по счётчику попыток.

Что почитать дальше