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


Атомарность одной команды | Lua-скрипты