Транзакции: MULTI, EXEC, WATCH

Как в Redis собрать несколько команд в одну неделимую пачку и безопасно обновлять данные при конкуренции — без блокировок и без отката, к которым вы привыкли в SQL.

Транзакция в Redis — это очередь команд между MULTI и EXEC, которые сервер выполняет подряд, не прерываясь на чужие команды. Это атомарность изоляции («никто не вклинится»), но не атомарность отката: если одна команда внутри упадёт, остальные всё равно выполнятся.

Зачем это нужно на практике

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

Транзакция решает именно это: команды между MULTI и EXEC буферизуются на сервере и выполняются разом, как единый блок. Пока блок исполняется, ни один другой клиент не увидит промежуточное состояние и не вмешается.

MULTI и EXEC: пачка команд

Внутри сессии redis-cli вы открываете транзакцию командой MULTI. После неё каждая команда не выполняется сразу, а ставится в очередь — сервер отвечает QUEUED. Команда EXEC выполняет всю очередь одним неделимым блоком и возвращает массив ответов.

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR orders:count
QUEUED
127.0.0.1:6379> SET orders:last 2026-06-27
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 1
2) OK

До EXEC ни INCR, ни SET не применялись — они просто стояли в очереди. EXEC выполнил обе подряд, и никакой другой клиент не смог прочитать счётчик «между» этими двумя командами. Если передумали — DISCARD очищает очередь и ничего не выполняет.

Нет отката: важнейшее отличие от SQL

В SQL транзакция — это «всё или ничего»: ошибка откатывает уже сделанное (ROLLBACK). В Redis отката нет. Если команда в очереди корректна синтаксически, но упадёт во время выполнения (например, INCR по строке, которая не число), Redis выполнит остальные команды как ни в чём не бывало, а ошибочную вернёт как ошибку в массиве ответов.

127.0.0.1:6379> SET name "Аня"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR name
QUEUED
127.0.0.1:6379> INCR visits
QUEUED
127.0.0.1:6379> EXEC
1) (error) ERR value is not an integer or out of range
2) (integer) 1

Обратите внимание: INCR visits отработал (вернул 1), хотя INCR name рядом упал. Никакого отката второй команды не произошло. Поэтому в Redis ответственность за корректность лежит на вас: проверяйте типы и данные заранее.

Есть и второй класс ошибок — синтаксические (несуществующая команда, неверное число аргументов). Их Redis ловит ещё на этапе постановки в очередь и тогда отклоняет всю транзакцию целиком при EXEC — это единственный случай, когда «не выполнится ничего».

WATCH: оптимистичная блокировка

Часто новое значение зависит от текущего: «спиши со счёта 100, только если на нём не меньше 100». В Redis это решается без блокировок — через оптимистичную модель. Команда WATCH key помечает ключ; если до вашего EXEC этот ключ изменит кто-то другой, EXEC ничего не выполнит и вернёт nil — это сигнал «данные под нами поменялись, начни заново».

WATCH balance
val = GET balance          # читаем текущее значение в приложение
if val >= 100:
    MULTI
    DECRBY balance 100
    EXEC                   # выполнится, ТОЛЬКО если balance не менялся после WATCH
else:
    UNWATCH                # денег мало — снимаем наблюдение

Это классический паттерн «check-and-set». Вы не держите замок и не блокируете других — вы лишь просите Redis отменить транзакцию, если наблюдаемый ключ кто-то тронул. Если EXEC вернул nil, цикл повторяется: снова WATCH, снова чтение, снова попытка.

Смоделируем эту логику «проверь-и-замени» на чистом Python, чтобы увидеть, почему второй параллельный клиент проигрывает.

balance = 100

def cas(current, expected, new):
    # выполнить замену, только если значение не изменилось
    return new if current == expected else None

a = cas(balance, 100, 80)   # клиент A: списать 20 (ожидал 100)
balance = a                 # A зафиксировал -> 80
b = cas(balance, 100, 70)   # клиент B: ожидал 100, а уже 80 -> отказ

print("После A:", a)
print("Попытка B (ожидала 100):", b)
print("Итоговый баланс:", balance)

Вывод:

После A: 80
Попытка B (ожидала 100): None
Итоговый баланс: 80

Клиент B обнаружил, что значение под ним изменилось (None), и обязан повторить попытку с новым значением. Ровно так ведёт себя WATCH + EXEC: вместо «затереть чужое изменение» — «отмени и попробуй снова».

Как это работает под капотом

При MULTI сервер переводит соединение в режим транзакции и просто складывает входящие команды в список, отвечая QUEUED. На EXEC он проходит этот список и выполняет команды одну за другой в своём единственном потоке обработки — поэтому между ними физически не может вклиниться чужая команда. WATCH же навешивает на ключ «флаг грязности»: любая запись в этот ключ (хоть из другого соединения) помечает все наблюдающие транзакции как «грязные». На EXEC Redis проверяет флаг — если ключ был тронут, вся очередь отбрасывается и возвращается nil, не выполнив ни одной команды. Никаких реальных замков при этом не создаётся: отсюда и название «оптимистичная блокировка».

Частые ошибки

  • Ждать SQL-отката. Ошибка одной команды внутри EXEC не откатывает соседние — проверяйте корректность данных заранее.
  • Логика внутри MULTI. Между MULTI и EXEC нельзя «прочитать и решить»: команды лишь ставятся в очередь, их результаты ещё неизвестны. Все GET и условия — до MULTI, под WATCH.
  • Забыть про повтор после WATCH. Если EXEC вернул nil, операцию нужно повторить целиком, а не считать выполненной.
  • Долго держать WATCH. Чем дольше между WATCH и EXEC, тем выше шанс, что ключ кто-то изменит и попытка сорвётся. Делайте окно коротким.
  • Считать транзакцию заменой Lua. Если нужна именно условная логика на сервере («если..., то...»), часто чище и быстрее Lua-скрипт — о нём следующий урок.

Итоги

  • MULTI ... EXEC выполняют пачку команд неделимо: никто не вклинится между ними.
  • Отката, как в SQL, нет: упавшая команда не отменяет соседние — следите за корректностью данных сами.
  • WATCH даёт оптимистичную блокировку: EXEC вернёт nil, если наблюдаемый ключ кто-то изменил.
  • Паттерн check-and-set — это цикл «WATCH → чтение → MULTI/EXEC → при nil повторить».
  • Блокировок Redis не создаёт: изоляция достигается однопоточностью и флагом «грязности» ключа.
Проверьте себя
1. Чем атомарность транзакции Redis принципиально отличается от транзакции в SQL?
AВ Redis нет отката: если одна команда внутри EXEC упадёт, остальные всё равно выполнятся
BВ Redis транзакция автоматически откатывает все команды при любой ошибке, как ROLLBACK
CВ Redis транзакция блокирует всю базу до завершения
DВ Redis команды внутри MULTI выполняются сразу, а не по EXEC
2. Что произойдёт, если ключ, помеченный WATCH, изменит другой клиент до вашего EXEC?
AEXEC выполнит команды, но вернёт предупреждение
BEXEC ничего не выполнит и вернёт nil — транзакцию нужно повторить с самого начала
CRedis автоматически повторит транзакцию за вас
DEXEC заблокирует другого клиента, пока транзакция не завершится