---
title: "Транзакции MULTI/EXEC и Watch"
description: "Транзакции Redis на Go: TxPipeline и redis.Tx с WATCH. Атомарные группы команд, оптимистичная блокировка, TxFailedErr и честный рассказ про откат."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/17-transakcii/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Транзакции MULTI/EXEC и Watch

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 17 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/17-transakcii/*

Транзакции в Redis — команды MULTI и EXEC — выполняют группу операций атомарно: либо все команды применятся подряд без вклинивания чужих, либо, если watched-ключ изменился, группа не применится вовсе. Классическая гонка, которую они лечат: два инстанса читают баланс, каждый считает новое значение и пишет — одно списание теряется. Разбираем TxPipeline для простых атомарных групп, redis.Tx с WATCH для схемы «прочитал — решил — записал» и честно проговариваем, почему у транзакций Redis нет отката.

Сценарий из жизни бэкенда: списание средств. Хендлер читает текущий баланс командой Get, вычитает сумму и пишет результат обратно через Set. Между Get и Set второго инстанса успевает пройти та же операция с тем же ключом — оба прочитали сто, оба записали девяносто, а списаний было два. Деньги возникают из воздуха, и на локальной машине баг не воспроизводится: гонка живёт только под нагрузкой и на нескольких инстансах. Примитивов тут три: атомарная команда вроде DecrBy, которая решает частные случаи без чтения; распределённая блокировка SET NX из урока про [блокировки](/courses/go-redis/lessons/10-raspredelennye-blokirovki/), когда критическая секция длинная; и транзакции Redis — про них этот урок. Транзакция объединяет чтение и запись в одну группу, которую сервер выполняет целиком, не подмешивая команды других клиентов.

### Транзакции Redis: MULTI и EXEC под капотом

На уровне протокола всё выглядит так: MULTI переводит соединение в режим очереди, каждая следующая команда не выполняется, а ставится в буфер, и сервер отвечает на неё коротким QUEUED. EXEC отправляет буфер на исполнение — все команды применяются подряд, без пауз и без вклинивания чужих операций: однопоточное ядро гарантирует это физически. Теперь честная часть, о которой молчат туториалы. Ошибки делятся на два вида, и судьба у них разная. Если команда не встала в очередь — опечатка в имени, неверное число аргументов, — сервер сообщит об этом сразу, и EXEC вернёт ошибку, не выполнив ничего. Если же команда встала в очередь, но упала при исполнении — например, Get по ключу-списку с WRONGTYPE, — сервер сообщит об ошибке этой команды, но остальные применит, и отката не будет. Redis документирует это прямо: транзакции дают атомарность и очередность, но не rollback.

### TxPipeline: группа команд в MULTI/EXEC

```go
func saveProfile(ctx context.Context, id int64, data string) error {
        key := "profile:" + strconv.FormatInt(id, 10)

        pipe := rdb.TxPipeline()
        pipe.Set(ctx, key, data, 0)
        pipe.Del(ctx, "cache:"+key)
        pipe.Publish(ctx, "events:profiles", "updated:"+key)

        _, err := pipe.Exec(ctx)
        return err
}
```

TxPipeline оборачивает все запланированные команды в MULTI и EXEC: сервер применит их одной группой, и ни один клиент не вклинится между Set и Del. Наблюдателю из Pub/Sub событие придёт только после успешного EXEC — это убирает класс гонок «уведомление ушло, а запись ещё нет». Форма планирования та же, что у обычного пайплайна из прошлого урока, отличается только создание: TxPipeline вместо Pipeline.

### TxPipeline: атомарная группа без WATCH

TxPipeline закрывает сценарии, где читать ничего не нужно: связанная запись должна примениться целиком или не примениться вообще. Сохранить профиль и сбросить его кэш одной группой — иначе между записью и сбросом другой инстанс успеет закэшировать старое. Двигать счётчик и обновлять лимит, записывать событие в поток и увеличивать метрику — любые пары «основное действие плюс побочное», которые обязаны быть согласованы. Внутри группы одна команда может упать с WRONGTYPE, и остальные всё равно применятся — TxPipeline здесь ничего не меняет, атомарность Redis не включает откат. Но вклинивание чужих команд исключено, и именно этого хватает для большинства сбоев согласованности. Когда внутри группы есть чтение, на котором строится решение, — Get и потом условная запись — TxPipeline уже не спасает: читать нужно внутри транзакционного контекста с WATCH, и это следующий блок.

### redis.Tx и WATCH: списание без потерянных операций

```go
const balanceKey = "balance:42"

func withdraw(ctx context.Context, amount int64) error {
        return rdb.Watch(ctx, func(tx *redis.Tx) error {
                current, err := tx.Get(ctx, balanceKey).Int()
                if err != nil && !errors.Is(err, redis.Nil) {
                        return err
                }

                if current < amount {
                        return ErrInsufficientFunds
                }

                _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
                        pipe.Set(ctx, balanceKey, current-amount, 0)
                        return nil
                })
                return err
        }, balanceKey)
}
```

Watch берёт соединение, ставит WATCH на ключи и вызывает замыкание. Чтение tx.Get идёт внутри транзакционного контекста, TxPipelined планирует запись и выполняет EXEC. Если между WATCH и EXEC ключ изменил другой клиент, EXEC вернёт пустой ответ, и go-redis превратит его в redis.TxFailedErr: запись по устаревшим данным не применится. redis.Nil проверяем отдельно — первый запуск с пустым балансом это норма, а не авария. ErrInsufficientFunds здесь пакетная ошибка, объявленная через errors.New.

### WATCH и redis.Tx: оптимистичная блокировка по шагам

WATCH запоминает состояние watched-ключей; EXEC проверяет, изменились ли они с момента WATCH, и отклоняет всю группу при первом же несовпадении. Это оптимистичная блокировка: мы не запираем ключ, а делаем ставку, что конфликтов не будет, и проигравшего отправляем на повтор. Повтор — обычный цикл: вызываете транзакцию снова, и при ошибке redis.TxFailedErr идёте на следующую попытку, потому что TxFailedErr означает не поломку данных, а проигранную гонку. Отсюда и выбор между примитивами. Конфликты редкие — WATCH дешёв и честен: проигравший просто перечитает и пересчитает. Конфликт постоянный, сто писателей на одном горячем ключе, — оптимистичная схема превращается в шторм ретраев, где половина попыток выбрасывается, и тогда выгоднее pessimistic-путь: блокировка SET NX из урока про блокировки или Lua-скрипт, который выполнится целиком без всяких гонок.

### Цикл ретраев на TxFailedErr

```go
func withdrawWithRetry(ctx context.Context, amount int64) error {
        const maxAttempts = 5

        for i := 0; i < maxAttempts; i++ {
                err := withdraw(ctx, amount)
                if !errors.Is(err, redis.TxFailedErr) {
                        return err // успех или другая ошибка — выходим
                }
                // ключ изменили под нами: перечитаем и попробуем снова
        }
        return ErrTooMuchContention
}
```

Ретрай на TxFailedErr — единственный способ дожать оптимистичную транзакцию: неудача означает проигранную гонку, а не испорченные данные. Ограничение попыток обязательно: бесконечный цикл на горячем ключе сожжёт процессор инстансов, которые перезаписывают друг друга по кругу. ErrTooMuchContention после пяти неудач — сигнал менять стратегию на блокировку или Lua. Проверить гонку можно локально: два go run в разных терминалах и Redis в Docker.

> **Отката в Redis нет — привычки из SQL здесь ломаются.** Первая команда группы применилась, вторая упала с WRONGTYPE — и всё: первая останется в данных, EXEC ничего не отменит. В документации Redis сказано прямо: атомарность и очередность есть, rollback нет. Типичная ошибка — проектировать транзакцию как «всё или ничего» с отменой на клиенте. Правила выживания: проверяйте типы и условия до записи, делайте команды идемпотентными, а когда атомарная логика с ветвлением обязательна — Lua-скрипт, который целиком исполняется внутри сервера.

### Четыре способа сделать запись безопасной

| Подход | Читает внутри | Гарантия | Цена при конфликтах |
| --- | --- | --- | --- |
| TxPipeline | Нет | Атомарная запись группы | Практически нулевая |
| redis.Tx + WATCH | Да | Запись валидна для прочитанного | Ретрай на каждую проигранную гонку |
| Блокировка SET NX | Да | Эксклюзивный доступ к секции | Ожидание освобождения, TTL на случай падения владельца |
| Lua-скрипт | Да | Атомарная логика на сервере | Нет ретраев: скрипт выполняется целиком |

### Отладка в redis-cli: QUEUED, DISCARD и nil после EXEC

Прежде чем разбирать гонки кодом, потрогайте транзакции руками — redis-cli показывает всё то же самое без обёрток клиента. Первое окно: MULTI, затем пара команд — сервер отвечает QUEUED на каждую, это и есть буфер очереди. EXEC применяет группу, DISCARD её же выбрасывает целиком — единственный механизм отмены, который существует в Redis. Теперь сама гонка: в первом окне WATCH balance:42, во втором окне SET balance:42 77 — и в первом MULTI плюс Set плюс EXEC. Ответом будет nil: транзакция отклонена, ключ перезаписали между WATCH и EXEC. Эти три минуты в терминале дают больше, чем чтение документации: видно, что очередь живёт на соединении, что WATCH срабатывает только до EXEC и что nil — это не ошибка соединения, а штатный сигнал проигранной гонки, который go-redis просто упаковывает в TxFailedErr.

Пара сценариев из практики, где транзакции всплывают неочевидным образом. Rate limiter из урока про [ограничение запросов](/courses/go-redis/lessons/22-rate-limiter/) держит окно на связке INCR плюс EXPIRE: если Expire уедет из группы, счётчик останется навсегда и забанит всех — TxPipeline здесь дешевле всяких блокировок. Отзыв JWT-токенов из курса по [JWT](/courses/jwt-go/lessons/15-otzyv-tokenov/) добавляет jti в denylist и одновременно двигает версию пользователя: две записи, которые обязаны примениться вместе, иначе проверяющий сервис увидит половину факта. В обоих случаях запись связанная, чтение внутри группы не нужно, и TxPipeline закрывает вопрос одной конструкцией. А вот перенос элемента между списками с проверкой содержимого или перераспределение бюджета между счетами — уже схемы «прочитал — решил — записал», их место в redis.Tx с WATCH.

> **Ошибки очереди ловятся тестами.** Опечатка в имени команды не компилируется в Go, но операции, собираемые из данных — динамический список шагов из конфига, — могут уйти в очередь невалидными, и EXEC вернёт ошибку, не выполнив ничего. Такие группы ловятся тестами с фейковым Redis: как подключить miniredis и прогнать транзакционный код без Docker, разобрано в уроке про [тесты с miniredis](/courses/go-redis/lessons/24-testy-miniredis/).

### Проверка: судьба первой команды

**Вопрос:** Внутри EXEC первая команда применилась, вторая упала с WRONGTYPE. Что увидит клиент?

- ✅ Первая применена, отката не будет: Redis не поддерживает rollback
- Обе отменены: транзакция откатилась целиком
- Первая применена и автоматически отменена сервером
- EXEC вернёт успех: ошибки исполнения игнорируются

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

### Проверка: когда возникает TxFailedErr

**Вопрос:** При каких условиях go-redis возвращает redis.TxFailedErr из rdb.Watch?

- ✅ Watched-ключ изменился между WATCH и EXEC
- Любая команда внутри EXEC вернула ошибку
- Истёк дедлайн контекста
- Watched-ключа не существует в Redis

TxFailedErr — сигнал проигранной оптимистичной гонки: кто-то перезаписал ключ, за которым вы следили, до вашего EXEC. Отсутствие ключа, ошибки команд и таймауты возвращаются другими ошибками и к ретраю через TxFailedErr отношения не имеют.

### Пройдёт ли транзакция?

```go
// Клиент A: rdb.Watch(..., "k"), внутри TxPipelined Set k=1
// Клиент B в этот момент успевает: rdb.Set(ctx, "k", "2", 0)

err := rdb.Watch(ctx, func(tx *redis.Tx) error {
        tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
                pipe.Set(ctx, "k", "1", 0)
                return nil
        })
        return nil
}, "k")

fmt.Println(errors.Is(err, redis.TxFailedErr))
```

**Что выведет программа?**

- ✅ true: ключ k изменили между WATCH и EXEC
- false: транзакция прошла, k равен 1
- true: из-за таймаута контекста
- панику: вложенный TxPipelined запрещён

Клиент B перезаписал watched-ключ до EXEC, поэтому EXEC отклонён, и go-redis вернёт TxFailedErr. Замыкание A могло уже прочитать старое значение — именно для этого случая отказ сделан целиком: запись по устаревшим данным не применится никогда.

### Соберите каркас WATCH-транзакции

Допишите запуск транзакции с наблюдением, вложенный TxPipelined и проверку проигранной гонки.

```go
err := rdb.___(ctx, func(tx *redis.Tx) error {
        _, err := tx.___(ctx, func(pipe redis.Pipeliner) error {
                pipe.Incr(ctx, "hits:page:1")
                return nil
        })
        return err
}, "hits:page:1")

if errors.___(err, redis.TxFailedErr) {
        // гонка: перечитываем и повторяем попытку
}
```

Подсказка: Наблюдение ставит rdb.Watch, внутри транзакции пишут через tx.TxPipelined, а ошибки сверяют через errors.Is.

Правильные ответы в пропусках: `Watch`, `TxPipelined`, `Is`

### Списание с оптимистичной блокировкой

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

Правильный порядок строк:

1. `err := rdb.Watch(ctx, func(tx *redis.Tx) error {`
2. `    return tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {`
3. `        pipe.Set(ctx, "balance:42", 90, 0)`
4. `        return nil`
5. `    })`
6. `}, "balance:42")`

### Карточки: транзакции

- **MULTI и EXEC** — MULTI ставит команды соединения в очередь, EXEC применяет их атомарно: без вклинивания чужих команд. Атомарность есть, отката нет.
- **TxPipeline** — Обёртка пайплайна в MULTI/EXEC: группа команд применяется целиком. Выбор для связанных записей, где читать внутри группы не нужно.
- **redis.Tx и WATCH** — Оптимистичная блокировка: WATCH следит за ключами, EXEC отклоняет группу, если они изменились. Чтение и решение — внутри замыкания.
- **redis.TxFailedErr** — Признак проигранной гонки: watched-ключ изменился до EXEC. Обрабатывается ретраем, а не алертом — данные не испорчены.
- **Откат транзакции** — В Redis его нет: команда, упавшая при исполнении, не отменяет соседей. Ошибки очереди (опечатки, неверные аргументы) отменяют весь EXEC.
- **Когда брать Lua вместо WATCH** — Когда конфликтов много: горячий ключ и постоянные ретраи съедают процессор. Скрипт выполняется атомарно без гонок и повторов.

Транзакции заняли своё место: TxPipeline для связанных записей, redis.Tx с WATCH для схем с чтением и решением, ретраи на TxFailedErr с потолком попыток. Соседние темы по курсу: урок про [пайплайны](/courses/go-redis/lessons/16-pipy-lineinye/) объяснил, откуда TxPipeline берёт сжатие сети, а урок про [ошибки go-redis](/courses/go-redis/lessons/19-oshibki-go-redis/) покажет, как отличить TxFailedErr от настоящих аварий и не логировать проигранные гонки как инциденты. Для сравнения ментальных моделей полезно заглянуть в урок про [транзакции GORM](/courses/gorm/lessons/15-tranzakcii/): там есть откат и уровни изоляции, здесь их нет — и это меняет дизайн кода сильнее, чем кажется. Полный план — на странице [курса по Redis](/courses/go-redis/). Если транзакция защищает запись в базе рядом с кэшем Redis — например, GORM пишет строку, а Redis двигает счётчик, — проговорите с командой, что между этими двумя хранилищами атомарности нет, и выбирайте, какая половина может отстать. Домашнее задание ниже устроит управляемую гонку: два воркера списывают с одного баланса параллельно, и без WATCH вы потеряете операцию — увидите это своими глазами в выводе.

## Коротко: что запомнить

- MULTI/EXEC применяет группу команд атомарно и без вклинивания чужих: TxPipeline в go-redis — обёртка над этим механизмом.
- Отката в Redis нет: команда, упавшая при исполнении, не отменяет соседей; ошибки очереди отменяют весь EXEC.
- redis.Tx с WATCH закрывает схему «прочитал — решил — записал»: EXEC отклоняется, если watched-ключ изменился.
- TxFailedErr — проигранная гонка, а не авария: обрабатывается ретраем с потолком попыток, обычно пяти.
- Горячий ключ и постоянные ретраи — сигнал переходить на блокировку SET NX или Lua-скрипт.
- Связанные записи без чтения — TxPipeline: сброс кэша рядом с записью, счётчик рядом с лимитом, событие рядом с фактом.

## Домашнее задание

Соберите withdraw с WATCH и withdrawWithRetry, запустите два воркера локальным Go с Redis в Docker и устройте параллельные списания с одного баланса: без WATCH одно из них пропадёт, с WATCH — баланс сойдётся. Затем перепишите saveProfile на TxPipeline и убедитесь через MONITOR, что MULTI и EXEC окружают группу. В конце выведите число неудачных попыток в метрику или лог: если на тестовом ключе ретраев больше трёх на успех — попробуйте тот же сценарий через Lua-скрипт.

## Вопросы и ответы по уроку

### Транзакции Redis или SQL-транзакции — это одно и то же?

Название общее, семантика разная. В SQL есть откат и уровни изоляции между сессиями; Redis даёт только атомарность и очередность, без rollback и без изоляции по снимкам. Дизайн кода под это меняется: проверки до записи, идемпотентность, Lua для ветвления.

### Чем TxPipeline отличается от обычного Pipelined?

Планирование и результат идентичны, но TxPipeline отправляет буфер в обёртке MULTI и EXEC — группа применяется атомарно. Обычный пайплайн только сжимает сеть, и чужие команды могут вклиниться между вашими. Когда группа обязана примениться целиком, выбор всегда TxPipeline.

### Нужен ли WATCH, если операция сводится к одному Incr?

Нет: сам Incr атомарен, чтение с решением не нужно. WATCH оправдан, когда новое значение зависит от прочитанного и не выражается одной командой. Если выражается — DecrBy, IncrByFloat или Set с Lua-проверкой дешевле транзакции.
