Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~18 мин +20 XP

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

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

Содержание урока · 8 разделов

Сценарий из жизни бэкенда: списание средств. Хендлер читает текущий баланс командой Get, вычитает сумму и пишет результат обратно через Set. Между Get и Set второго инстанса успевает пройти та же операция с тем же ключом — оба прочитали сто, оба записали девяносто, а списаний было два. Деньги возникают из воздуха, и на локальной машине баг не воспроизводится: гонка живёт только под нагрузкой и на нескольких инстансах. Примитивов тут три: атомарная команда вроде DecrBy, которая решает частные случаи без чтения; распределённая блокировка SET NX из урока про блокировки, когда критическая секция длинная; и транзакции 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.

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

ПодходЧитает внутриГарантияЦена при конфликтах
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 из урока про ограничение запросов держит окно на связке INCR плюс EXPIRE: если Expire уедет из группы, счётчик останется навсегда и забанит всех — TxPipeline здесь дешевле всяких блокировок. Отзыв JWT-токенов из курса по JWT добавляет jti в denylist и одновременно двигает версию пользователя: две записи, которые обязаны примениться вместе, иначе проверяющий сервис увидит половину факта. В обоих случаях запись связанная, чтение внутри группы не нужно, и TxPipeline закрывает вопрос одной конструкцией. А вот перенос элемента между списками с проверкой содержимого или перераспределение бюджета между счетами — уже схемы «прочитал — решил — записал», их место в redis.Tx с WATCH.

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

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

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

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

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

Прочитайте код и выберите, что он выведет:

// Клиент 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))

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

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

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) { // гонка: перечитываем и повторяем попытку }

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

MULTI и EXEC

нажмите, чтобы перевернуть
Определение

MULTI ставит команды соединения в очередь, EXEC применяет их атомарно: без вклинивания чужих команд. Атомарность есть, отката нет.

1 / 6

Транзакции заняли своё место: TxPipeline для связанных записей, redis.Tx с WATCH для схем с чтением и решением, ретраи на TxFailedErr с потолком попыток. Соседние темы по курсу: урок про пайплайны объяснил, откуда TxPipeline берёт сжатие сети, а урок про ошибки go-redis покажет, как отличить TxFailedErr от настоящих аварий и не логировать проигранные гонки как инциденты. Для сравнения ментальных моделей полезно заглянуть в урок про транзакции GORM: там есть откат и уровни изоляции, здесь их нет — и это меняет дизайн кода сильнее, чем кажется. Полный план — на странице курса по Redis. Если транзакция защищает запись в базе рядом с кэшем Redis — например, GORM пишет строку, а Redis двигает счётчик, — проговорите с командой, что между этими двумя хранилищами атомарности нет, и выбирайте, какая половина может отстать. Домашнее задание ниже устроит управляемую гонку: два воркера списывают с одного баланса параллельно, и без WATCH вы потеряете операцию — увидите это своими глазами в выводе.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • 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 · Транзакции MULTI/EXEC и Watch)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

JWT25 уроков~12 чпрактика25 уроков уже доступны

JWT-аутентификация в Go: golang-jwt и bcrypt

Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.

  • Понимать анатомию JWT и почему payload не зашифрован
  • Выпускать и проверять токены через golang-jwt v5
  • Хешировать пароли bcrypt и сравнивать безопасно
  • Писать middleware аутентификации для chi и Gin