KV и документные: «транзакций» почти нет — Redis, MongoDB, ScyllaDB

Что означает «транзакция» там, где нет классического ACID-движка: Redis MULTI/EXEC без rollback и WATCH-CAS, multi-document транзакции MongoDB поверх snapshot, LWT на Paxos в ScyllaDB и почему BATCH — это не транзакция. Примеры на Go и Java

За пределами реляционного мира слово «транзакция» начинает означать совсем другое — или почти ничего. Где-то это батч команд, который исполняется атомарно и без чужих вклиниваний, но не умеет откатываться и ничего не обещает про чтение, сделанное до него. Где-то — опциональная и не бесплатная надстройка поверх snapshot isolation, которую нужно явно попросить. А где-то — консенсус на одном ключе, ценой нескольких сетевых раундов. Разберём три системы — Redis, MongoDB, ScyllaDB — и зафиксируем, что там реально гарантируется, а что только похоже на гарантию.

Это третья статья серии «Транзакции и изоляция». Опираемся на аномалии и уровни из первой статьи: здесь важно понимать, каких гарантий эти системы не дают.

Три хранилища — три идеи «транзакции»: Redis с оптимистичным WATCH, MongoDB с атомарным одним документом, ScyllaDB с Paxos-консенсусом (LWT)

В статье

Redis: MULTI/EXEC — это не то, что вы думаете

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

Во-первых, нет rollback. Если одна из команд в очереди падает во время выполнения (например, INCR на строке, которая на самом деле не число), остальные команды всё равно выполнятся — Redis не откатывает уже применённые эффекты. DISCARD работает только до EXEC — это отмена самой очереди, а не откат результата.

Во-вторых, и это ключевое: сама изоляция EXEC (очередь исполняется без того, чтобы в неё вклинилась чужая команда) не спасает классический read-modify-write. Паттерн «GET → посчитать в приложении → SET» не защищён MULTI/EXEC вообще — не потому, что внутри EXEC что-то прерывается (не прерывается), а потому что GET происходит до открытия транзакции. Между этим GET и последующим SET (даже завёрнутым в MULTI/EXEC) свободно вклинивается любое количество других клиентов, и решение о новом значении принимается по устаревшему снимку, прочитанному вне транзакции.

Живой пример: 16 воркеров, каждый делает 100 инкрементов общего счётчика (ожидаемый итог — 1600) через наивный GET, обработку в приложении и SET. На Redis 8.6.1 итог — 100 из 1600, потеряно 1500 инкрементов. Почти все обновления потеряны — не потому что MULTI/EXEC где-то не сработал, а потому что его здесь и не было: SET просто перезаписывает значение, прочитанное задолго до этого.

Встроенный механизм Redis для read-modify-write, который нельзя свести к одной атомарной команде, — WATCH. Это оптимистичная блокировка на уровне ключа: WATCH key запоминает текущее состояние ключа, а EXEC откажется выполнять очередь (вернёт nil), если ключ изменился кем-то другим между WATCH и EXEC. Получается CAS (compare-and-swap) — приложение обязано само проверить результат EXEC и, если он nil, повторить весь цикл WATCH → GET → MULTI → SET → EXEC заново.

incr := func() error {
    return rdb.Watch(ctx, func(tx *redis.Tx) error {
        v, err := tx.Get(ctx, "counter").Int()
        if err != nil && !errors.Is(err, redis.Nil) {
            return err
        }
        _, err = tx.TxPipelined(ctx, func(p redis.Pipeliner) error {
            p.Set(ctx, "counter", v+1, 0)
            return nil
        })
        return err
    }, "counter")
}
for {
    err := incr()
    if errors.Is(err, redis.TxFailedErr) { // ключ изменился между WATCH и EXEC
        atomic.AddInt64(&retries, 1)
        continue
    }
    break
}

На том же стенде (16 × 100, Redis 8.6.1) WATCH+MULTI/EXEC даёт корректный итог 1600, но ценой 10075 retry — то есть в среднем больше 6 повторов на каждый успешный инкремент под такой конкуренцией на одном ключе.

Важная оговорка: конкретно счётчик здесь — учебный пример, чтобы показать гонку и CAS. В реальном коде инкремент делают атомарной серверной командой INCR/INCRBY — она выполняется целиком на сервере, без чтения значения в приложение, поэтому read-modify-write-гонки нет вовсе и retry не нужны. WATCH показан как общий механизм для случая, когда результат зависит от прочитанного значения и одной командой не выражается («списать с A, если баланса хватает»). А когда логика ещё сложнее — есть третий путь, серверные скрипты (ниже). Java (Jedis) — то же самое API один в один: watch/multi/exec(), только exec() возвращает null вместо ошибки:

while (true) {
    j.watch("counter");
    int v = Integer.parseInt(j.get("counter"));
    Transaction t = j.multi();
    t.set("counter", String.valueOf(v + 1));
    if (t.exec() == null) { // ключ изменился между WATCH и EXEC
        retries.incrementAndGet();
        continue;
    }
    break;
}

На идентичном сценарии Jedis даёт тот же корректный итог 1600, retries 8957 — того же порядка, что и в Go, разница объясняется таймингом планировщика потоков, а не разницей в модели.

Если логика read-modify-write сложнее одного SET — есть более прямой путь: Lua-скрипты (EVAL) и их именованная форма FUNCTION. Скрипт выполняется на сервере как единая атомарная единица — Redis не выполнит ни одной другой команды, пока скрипт не закончится, поэтому внутри скрипта можно свободно читать и писать несколько ключей без гонки. Плата — тот же однопоточный контекст: долгий скрипт блокирует весь сервер на время своего выполнения, поэтому Lua хорош для короткой атомарной логики (условный инкремент, rate limiter, лотерея на паре ключей), а не для произвольно тяжёлых транзакций.

Практический вывод: MULTI/EXEC честно делает ровно то, что обещает — прогоняет очередь команд атомарно, не впуская в неё чужие. Данные теряет не он, а паттерн: чтение, вынесенное за пределы транзакции, и расчёт нового значения по снимку, который к моменту записи уже устарел — ровно там, где транзакция обычно и нужна (счётчики, балансы, инвентарь). Ошибка — ждать от MULTI/EXEC реляционного BEGIN/COMMIT/ROLLBACK: отката у него нет, и сам по себе он read-modify-write не закрывает. Закрывают это WATCH — условный CAS, отменяющий EXEC, если ключ изменился, — или Lua/FUNCTION, когда логику целиком уносят на сервер (а для простого инкремента хватит атомарной INCR). Любой из этих вариантов выбирают явно, а не получают бесплатно вместе с MULTI.

Полный стенд — Go и Java по всем трём хранилищам — в digital-cookbook, transactions/kv-document/ (проверено на Redis 8.6.1, MongoDB 8.2.11, ScyllaDB 6.2.3).

MongoDB: атомарность документа бесплатно, транзакции — опция

В MongoDB атомарность одного документа есть всегда и не требует никакой явной транзакции: любое обновление, затрагивающее произвольное число полей внутри одного документа ($inc, $set, обновление вложенного массива), применяется целиком или не применяется вовсе. Для очень большого класса задач этого достаточно — если весь инвариант умещается в один документ (счётчик с историей, агрегат с вложенным списком), отдельная транзакция просто не нужна.

Проблема начинается там, где инвариант связывает несколько документов — классический перевод между счетами: balance счёта A и balance счёта B меняются одновременно, и сумма A + B не должна нарушаться ни в какой момент, видимый снаружи. Для этого в MongoDB есть multi-document транзакции — доступны на replica set с версии 4.0, на sharded-кластерах с версии 4.2. Это отдельная, явно запрашиваемая конструкция (session.withTransaction), а не побочный эффект обычных вызовов, и она работает поверх snapshot isolation: транзакция видит согласованный снимок на момент своего старта и коммитится атомарно для внешнего наблюдателя.

readConcern и writeConcern — это то, чем настраивается сила гарантии. readConcern: snapshot даёт транзакции согласованный снимок данных, writeConcern: majority требует подтверждения записи большинством реплик перед тем, как транзакция считается закоммиченной, — то есть данные не потеряются даже при падении primary сразу после коммита. Комбинация snapshot + majority — это то, что делает multi-document транзакцию в MongoDB одновременно консистентной и durable, но именно эта комбинация и стоит дороже всего по latency: транзакция не может закрыться, пока majority не подтвердит запись.

withTransaction (и в Go, и в Java-драйвере) сам оборачивает вызов в retry-логику для транзитных ошибок конкуренции (TransientTransactionError, WriteConflict при столкновении с другой параллельной транзакцией на тех же документах) — приложению не нужно писать свой цикл повтора, только передать функцию с бизнес-логикой внутри.

txnOpts := options.Transaction().
    SetReadConcern(readconcern.Snapshot()).
    SetWriteConcern(writeconcern.Majority())

transfer := func(sess mongo.Session) error {
    _, err := sess.WithTransaction(ctx, func(sc mongo.SessionContext) (any, error) {
        if _, e := coll.UpdateOne(sc, bson.M{"_id": "A"}, bson.M{"$inc": bson.M{"balance": -1}}); e != nil {
            return nil, e
        }
        if _, e := coll.UpdateOne(sc, bson.M{"_id": "B"}, bson.M{"$inc": bson.M{"balance": 1}}); e != nil {
            return nil, e
        }
        return nil, nil
    }, txnOpts)
    return err
}

Живой прогон: 16 воркеров × 50 переводов A→B по 1 (всего 800 переводов) на MongoDB 8.2.11. Итог — A=200, B=800, сумма 1000 сохранена под конкуренцией — ни один перевод не потерялся и не задвоился, инвариант между двумя разными документами держится именно потому, что каждый перевод завёрнут в явную транзакцию, а не в два независимых UpdateOne.

Java (mongodb-driver-sync) — тот же паттерн через ClientSession.withTransaction:

try (ClientSession s = client.startSession()) {
    for (int i = 0; i < iters; i++) {
        s.withTransaction(() -> {
            coll.updateOne(s, eq("_id", "A"), inc("balance", -1));
            coll.updateOne(s, eq("_id", "B"), inc("balance", 1));
            return null;
        });
    }
}

На идентичном сценарии — тот же результат: A=200, B=800, sum=1000. Отдельная деталь, которая ловит на локальном стенде: directConnection=true в строке подключения нужен для single-node replica set в Docker — иначе драйвер пытается резолвить имена членов RS из его собственной конфигурации и упирается во внутреннее имя контейнера, недоступное снаружи; на проде с нормальным DNS между узлами реплика-сета этот флаг не нужен.

Итог по MongoDB: транзакции здесь — не «бесплатный ACID из коробки», а точечный инструмент для случаев, где инвариант выходит за пределы одного документа. Модель по умолчанию — атомарность документа; multi-document транзакция — осознанный выбор с осознанной ценой в latency и пропускной способности.

ScyllaDB/Cassandra: BATCH — не транзакция, LWT — консенсус

В мире wide-column хранилищ (ScyllaDB, Cassandra) слово «транзакция» ближе всего подходит к BATCH — и это тот случай, когда сходство обманчиво. Logged BATCH даёт гарантию не отката, а eventual completion: координатор сначала записывает батч в служебный batchlog (на нескольких узлах), и если он падает, не доведя все мутации, батч доигрывается (реплеится из batchlog), пока все записи не применятся. Отката (rollback уже применённых мутаций) здесь нет вовсе — гарантируется, что в итоге применятся все, а не «все или ни одной» в момент времени. И это не изоляция: пока батч доигрывается, другой клиент, читающий те же партиции, может увидеть частичный результат — часть записей уже видна, часть ещё нет; между разными партициями изоляции нет тем более. BATCH — механизм гарантированной доставки набора мутаций, а не ACID-транзакция: он ничего не говорит о том, что видят конкурентные читатели в процессе.

Настоящая линеаризуемость в этом классе баз — только через LWT (lightweight transactions): INSERT ... IF NOT EXISTS, UPDATE ... IF column = ?. Механизм — Paxos: перед применением записи узлы договариваются между собой о том, чья версия «выигрывает», и это несколько раундов сетевого консенсуса вместо одной обычной записи — LWT в разы медленнее plain-записи именно из-за этой цены. Важное ограничение: консенсус LWT работает только в пределах одного раздела (partition) — мульти-раздельных транзакций в Cassandra/ScyllaDB не существует в принципе, LWT не поможет, если инвариант связывает строки из разных партиций.

Живой пример показывает разницу предельно наглядно: 16 воркеров одновременно пытаются забронировать одно и то же место.

1 из 16LWT (IF NOT EXISTS)эксклюзивно ✓16 из 16plain INSERTбез взаимного исключения

С INSERT ... IF NOT EXISTS (LWT, ScyllaDB 6.2.3) применяется 1 из 16 попыток — Paxos выбирает ровно одного победителя, остальные 15 получают applied=false и видят, кто именно занял место. С обычным INSERT (без IF) — «успешны» 16 из 16: взаимного исключения нет вообще, каждая запись просто перезаписывает предыдущую (last-write-wins), и последняя по времени попытка молча становится «победителем» — без единого сигнала о конфликте кому бы то ни было.

var exSeat, exBy string
applied, err := sess.Query(
    `INSERT INTO seats (seat_id, booked_by) VALUES (?, ?) IF NOT EXISTS`,
    "A1", fmt.Sprintf("worker-%d", id)).ScanCAS(&exSeat, &exBy)
if err == nil && applied {
    atomic.AddInt64(&lwtWon, 1)
}

Java (DataStax driver) — то же самое через wasApplied() на результате:

PreparedStatement ps = s.prepare(
    "INSERT INTO demo.seats (seat_id, booked_by) VALUES ('A1', ?) IF NOT EXISTS");
ResultSet rs = s.execute(ps.bind("worker-" + id));
if (rs.wasApplied()) {
    won.incrementAndGet();
}

На идентичном сценарии Java даёт тот же результат — применяется 1 из 16, эксклюзивно.

Когда LWT оправдан: уникальность на вставке (бронирование места, резервация логина/email, идемпотентная запись «эта операция уже выполнена»), лидер-элекшн на одном ключе (кто держит блокировку прямо сейчас). Во всех этих случаях конкуренция за конкретный раздел разовая или редкая, и несколько раундов Paxos — приемлемая цена за гарантированное взаимное исключение. Когда LWT не оправдан: горячий счётчик или любая запись с высокой частотой конкурентных попыток на один и тот же раздел — Paxos не тянет такой throughput, и каждая LWT-операция там будет стоить на порядок дороже обычной записи, вытесняя пропускную способность в цену корректности, которую можно получить дешевле другим способом (например, шардированием счётчика).

Карта: что значит «транзакция» в каждой

Система «Транзакция» — это… Изоляция? Rollback? Цена
Redis MULTI/EXEC — атомарный батч команд; реальная защита от гонки — WATCH (CAS) или Lua/FUNCTION Нет между WATCH и внешними клиентами до EXEC; сама очередь исполняется без прерывания Нет — ошибка команды не откатывает уже применённые CAS: retry-цикл под конкуренцией (10075 retry на 1600 успешных); Lua: блокировка всего сервера на время скрипта
MongoDB Явная multi-document транзакция (withTransaction) поверх snapshot isolation; атомарность одного документа — бесплатно и всегда Да, snapshot isolation, настраивается readConcern/writeConcern Да, аборт транзакции откатывает все её операции Latency majority-подтверждения + ретраи транзитных конфликтов
ScyllaDB/Cassandra logged BATCH — гарантия доставки (eventual completion) без изоляции; LWT — линеаризуемость через Paxos в пределах одного раздела BATCH: нет (в т.ч. между партициями). LWT: да, но только внутри раздела BATCH: не откат — batchlog доигрывает недоприменённые мутации до конца (eventual completion); LWT — отдельного отката нет, есть только «не применилось» LWT — несколько раундов консенсуса, в разы дороже обычной записи

Общее для всех трёх систем: транзакционность — не встроенное фоновое свойство, как в реляционных БД, а точечный инструмент (CAS, явная multi-document транзакция, LWT), который нужно осознанно выбрать и за который нужно осознанно заплатить. А вот область действия у них разная, и здесь важно не смешивать. В Redis инструмент работает в границах ключей, к которым применён WATCH (или тела Lua-скрипта), в ScyllaDB LWT — в границах одного раздела; произвольных мульти-объектных транзакций реляционного типа у этих двух нет и не предполагается архитектурой. MongoDB — исключение: её multi-document транзакции покрывают несколько документов, коллекций, баз и даже шардов (distributed transactions с 4.2) — это настоящая мульти-объектная транзакция, просто не бесплатная и не режим по умолчанию, а осознанно запрашиваемая конструкция с ценой в latency и пропускной способности.

Что дальше

Дальше в серии — брокеры и стриминг: в RabbitMQ и Kafka слово «транзакция» снова означает нечто своё — гарантии доставки сообщений и атомарность публикации, а не изоляция конкурентных читателей.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии