Транзакции: 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 не создаёт: изоляция достигается однопоточностью и флагом «грязности» ключа.