MULTI/EXEC
Предпосылки: Атомарность одной команды.
← Атомарность одной команды | Lua-скрипты →
Однопоточность Redis делает каждую отдельную команду атомарной — между GET и ответом ничто не вклинится. Но когда бизнес-операция состоит из нескольких команд — прочитать баланс, проверить лимит, списать сумму — между ними может вклиниться команда другого клиента, и итоговое состояние окажется некорректным.
Зачем нужны транзакции
Одна команда атомарна, но иногда нужно выполнить несколько команд как единое целое. Без транзакций между командами клиента A могут вклиниться команды клиента B:
Клиент A: GET balance → 100
Клиент B: GET balance → 100
Клиент A: SET balance 80 (списал 20)
Клиент B: SET balance 70 (списал 30, но от старого 100)
-- Результат: 70. Должно быть 50.MULTI/EXEC: сначала собрать, потом выполнить
Транзакция в Redis работает в два этапа: сначала клиент наполняет очередь команд, потом Redis выполняет всю очередь атомарно за один проход.
Наполнение очереди. Клиент отправляет MULTI — Redis переводит соединение в режим транзакции и отвечает OK. Теперь любая команда, которую клиент отправит через это соединение, не выполняется — Redis проверяет синтаксис, сохраняет команду в очередь и отвечает QUEUED. Именно поэтому вместо привычных OK или значений клиент видит QUEUED на каждую команду: это подтверждение, что команда принята в очередь и ждёт исполнения.
Исполнение. Когда клиент отправляет EXEC, Redis берёт всю накопленную очередь и выполняет команды последовательно за один проход event loop, без переключения на команды от других клиентов. Результат — массив ответов, по одному на каждую команду в порядке постановки в очередь. Если вместо EXEC клиент отправит DISCARD, очередь очищается и транзакция отменяется без выполнения.
MULTI -- → OK (режим транзакции)
SET balance 80 -- → QUEUED (в очередь, не выполнена)
INCR login_count -- → QUEUED
SET status "active" -- → QUEUED
EXEC -- выполнить всю очередь атомарно
-- → ["OK", 1, "OK"]Каждая команда между MULTI и EXEC — отдельный сетевой round-trip: клиент отправляет команду, ждёт QUEUED, отправляет следующую. Но очередь привязана к конкретному соединению, и пока клиент A наполняет свою очередь, Redis свободно обрабатывает запросы от клиентов B, C, D — никто не заблокирован. Блокировка event loop для других клиентов происходит только на время EXEC, и ровно столько, сколько занимает выполнение накопленных команд. Чтобы не платить отдельный round-trip за каждую команду, MULTI/EXEC комбинируют с pipelining: клиент отправляет MULTI, все команды и EXEC одним пакетом, получая и атомарность, и один round-trip на всю транзакцию.
Нет rollback
MULTI/EXEC гарантирует атомарность выполнения, но не целостность в случае ошибки. Принципиальное отличие от SQL-транзакций: Redis не откатывает транзакцию при ошибке в одной из команд. Если INCR применена к строковому значению — эта команда вернёт ошибку, но остальные команды в транзакции выполнятся успешно. Redis-авторы объясняют это тем, что ошибки в транзакции — это ошибки программирования (неправильный тип данных), а не конкурентные конфликты, и rollback усложнил бы реализацию без практической пользы.
Если при постановке в очередь Redis обнаружил ошибку синтаксиса (несуществующая команда, неправильное количество аргументов), клиент получит ошибку вместо QUEUED. В этом случае EXEC откажется выполнять транзакцию целиком.
Ограничение: read → decide → write
Множество реальных задач следует одному паттерну: прочитать текущее состояние, принять решение на его основе и записать результат. Проверить баланс перед списанием, прочитать счётчик перед инкрементом с условием, получить TTL перед обновлением — всё это варианты «прочитать → решить → записать».
Интуитивное ожидание: внутри транзакции можно прочитать значение, принять решение на его основе и записать результат. Так работают интерактивные транзакции в реляционных базах данных — каждый запрос на чтение немедленно возвращает результат.
MULTI/EXEC работает иначе. Каждая команда между MULTI и EXEC возвращает QUEUED, а не реальное значение:
MULTI
GET balance -- → QUEUED (не 100!)
-- Здесь клиент хочет проверить: balance > 50?
-- Но данных нет — только подтверждение, что GET в очереди
-- Условную команду поставить в очередь невозможно
EXEC
-- → ["100"] (значение появляется только здесь)Клиент не может прочитать balance внутри MULTI/EXEC и поставить в очередь условную команду — значение появится только после EXEC, когда очередь уже зафиксирована.
Для паттерна «прочитать → решить → записать» в Redis есть два пути. WATCH (следующий раздел) позволяет вынести чтение за пределы очереди и повторить транзакцию при конфликте. Lua-скрипт выполняется целиком на сервере и имеет доступ к значениям ключей в момент исполнения — он подходит для условной логики внутри атомарной операции.
WATCH: проверить, потом записать
WATCH выносит чтение за пределы очереди MULTI/EXEC и гарантирует, что прочитанные данные не устарели к моменту записи. Клиент начинает «наблюдать» за ключами — если между WATCH и EXEC другой клиент изменил наблюдаемый ключ, EXEC возвращает nil и транзакция не выполняется. Клиент может повторить попытку.
WATCH balance
-- прочитать текущее значение
GET balance -- → "100"
-- подготовить транзакцию на основе прочитанного
MULTI
SET balance 80
EXEC
-- → ["OK"] если balance не изменился
-- → (nil) если другой клиент изменил balance между WATCH и EXECТакой подход называется оптимистичной блокировкой: WATCH не мешает другим клиентам читать или писать — он лишь отклоняет транзакцию, если обнаружен конфликт. При высокой конкуренции за один ключ количество повторных попыток может расти. В таких случаях Lua-скрипт может быть эффективнее — он выполняется атомарно без повторов.
UNWATCH снимает наблюдение за всеми ключами без выполнения транзакции. После EXEC или DISCARD наблюдение снимается автоматически — явный UNWATCH не нужен.
См. также
- EXEC в Rails — WATCH, оптимистичная блокировка, retry
Sources
- Redis Documentation: Transactions. https://redis.io/docs/interact/transactions/