Содержание урока · 8 разделов
Сценарий из жизни бэкенда: списание средств. Хендлер читает текущий баланс командой Get, вычитает сумму и пишет результат обратно через Set. Между Get и Set второго инстанса успевает пройти та же операция с тем же ключом — оба прочитали сто, оба записали девяносто, а списаний было два. Деньги возникают из воздуха, и на локальной машине баг не воспроизводится: гонка живёт только под нагрузкой и на нескольких инстансах. Примитивов тут три: атомарная команда вроде DecrBy, которая решает частные случаи без чтения; распределённая блокировка SET NX из урока про блокировки, когда критическая секция длинная; и транзакции Redis — про них этот урок. Транзакция объединяет чтение и запись в одну группу, которую сервер выполняет целиком, не подмешивая команды других клиентов.
Транзакции Redis: MULTI и EXEC под капотом#
На уровне протокола всё выглядит так: MULTI переводит соединение в режим очереди, каждая следующая команда не выполняется, а ставится в буфер, и сервер отвечает на неё коротким QUEUED. EXEC отправляет буфер на исполнение — все команды применяются подряд, без пауз и без вклинивания чужих операций: однопоточное ядро гарантирует это физически. Теперь честная часть, о которой молчат туториалы. Ошибки делятся на два вида, и судьба у них разная. Если команда не встала в очередь — опечатка в имени, неверное число аргументов, — сервер сообщит об этом сразу, и EXEC вернёт ошибку, не выполнив ничего. Если же команда встала в очередь, но упала при исполнении — например, Get по ключу-списку с WRONGTYPE, — сервер сообщит об ошибке этой команды, но остальные применит, и отката не будет. Redis документирует это прямо: транзакции дают атомарность и очередность, но не rollback.
TxPipeline: группа команд в MULTI/EXEC
GOfunc 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: списание без потерянных операций
GOconst 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
GOfunc 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.
Первая команда группы применилась, вторая упала с 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 из урока про ограничение запросов держит окно на связке INCR плюс EXPIRE: если Expire уедет из группы, счётчик останется навсегда и забанит всех — TxPipeline здесь дешевле всяких блокировок. Отзыв JWT-токенов из курса по JWT добавляет jti в denylist и одновременно двигает версию пользователя: две записи, которые обязаны примениться вместе, иначе проверяющий сервис увидит половину факта. В обоих случаях запись связанная, чтение внутри группы не нужно, и TxPipeline закрывает вопрос одной конструкцией. А вот перенос элемента между списками с проверкой содержимого или перераспределение бюджета между счетами — уже схемы «прочитал — решил — записал», их место в redis.Tx с WATCH.
Опечатка в имени команды не компилируется в Go, но операции, собираемые из данных — динамический список шагов из конфига, — могут уйти в очередь невалидными, и EXEC вернёт ошибку, не выполнив ничего. Такие группы ловятся тестами с фейковым Redis: как подключить miniredis и прогнать транзакционный код без Docker, разобрано в уроке про тесты с miniredis.
Проверка: судьба первой команды
Внутри 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 и проверку проигранной гонки.
Списание с оптимистичной блокировкой
Расставьте строки по порядку: ключ, запуск транзакции с наблюдением, вложенный EXEC и запись.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: транзакции
Транзакции заняли своё место: 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 или SQL-транзакции — это одно и то же?
Название общее, семантика разная. В SQL есть откат и уровни изоляции между сессиями; Redis даёт только атомарность и очередность, без rollback и без изоляции по снимкам. Дизайн кода под это меняется: проверки до записи, идемпотентность, Lua для ветвления.
Чем TxPipeline отличается от обычного Pipelined?
Планирование и результат идентичны, но TxPipeline отправляет буфер в обёртке MULTI и EXEC — группа применяется атомарно. Обычный пайплайн только сжимает сеть, и чужие команды могут вклиниться между вашими. Когда группа обязана примениться целиком, выбор всегда TxPipeline.
Нужен ли WATCH, если операция сводится к одному Incr?
Нет: сам Incr атомарен, чтение с решением не нужно. WATCH оправдан, когда новое значение зависит от прочитанного и не выражается одной командой. Если выражается — DecrBy, IncrByFloat или Set с Lua-проверкой дешевле транзакции.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Контекст и таймауты в go-redis
- Ошибки go-redis: Nil, таймауты и ретраи
- Пул соединений: настройка и мониторинг
- Сессии веб-приложения в Redis
- Пайплайны: много команд за один рейс
- Streams: журнал событий вместо очереди
- Pub/Sub: рассылка событий в реальном времени
- Отсортированные множества: лидерборды
- Все курсы по Go
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin