Четыре статьи серии разбирали транзакционность по частям: аномалии и уровни изоляции в теории (#1), реляционная практика на PostgreSQL — lost update, write skew, SERIALIZABLE (#2), «транзакция» в KV и документных хранилищах — Redis, MongoDB, ScyllaDB (#3), и наконец брокеры — RabbitMQ, Kafka, outbox как мост к БД (#4). Сквозная идея всей серии была не в том, чтобы составить рейтинг «какая БД лучше», а в том, чтобы построить карту компромиссов: у каждой системы своя модель того, что она называет транзакцией, и своя цена за корректность под конкуренцией.
Эта статья — финал. Вместо ещё одной системы разберём один прикладной сценарий сразу на четырёх хранилищах, на общем стенде, с одинаковым нагрузчиком и одинаковой конкуренцией. Не «какая система быстрее» вообще — а какой ценой каждая держит один и тот же инвариант, и какой класс задач какому хранилищу подходит в итоге.
В статье
Сценарий и стенд
Сценарий предельно простой и от этого показательный: один счёт с балансом budget = 1000, 16 воркеров, каждый выполняет 100 попыток списать 1 — итого 1600 конкурентных попыток на 1000 единиц баланса. Попыток заведомо больше, чем баланс способен покрыть, поэтому часть должна быть отклонена. Инвариант один: баланс никогда не должен уйти в минус, и после прогона он должен сойтись бухгалтерски — списано ровно столько, на сколько хватило бюджета, а не «примерно».
Это тот же вопрос, что лежал в основе lost update из статьи #2 и WATCH-CAS/multi-document апдейтов из статьи #3, только теперь один и тот же вопрос задан четырём системам одновременно, на одном и том же профиле нагрузки — общий Go-нагрузчик и по адаптеру на каждое хранилище за единым интерфейсом Store (Reset, Decrement, Final). У каждого хранилища — свой идиоматичный атомарный механизм для этого инварианта, не «один и тот же код на четырёх языках», а то, как это принято делать правильно именно в этой системе:
- PostgreSQL — атомарный условный
UPDATEс проверкой вWHERE, под защитой строчной блокировки СУБД; - Redis — Lua-скрипт: проверка и
DECRвыполняются на сервере как единая неделимая операция; - MongoDB —
findOneAndUpdateс условием прямо в фильтре: атомарность одного документа без явной транзакции; - ScyllaDB — LWT (
IF balance = ?), compare-and-set поверх Paxos-консенсуса.
func (s *pgStore) Decrement(ctx context.Context) (bool, int64) {
ct, err := s.pool.Exec(ctx, "UPDATE accounts SET balance = balance - 1 WHERE id = 1 AND balance > 0")
if err != nil {
return false, 0
}
return ct.RowsAffected() == 1, 0
}Ни SERIALIZABLE, ни retry сверху не нужны: проверка условия и запись происходят в одном statement, СУБД сама берёт строчную блокировку на время апдейта — ровно тот случай из статьи #2, где не нужно тащить полную изоляцию, потому что весь инвариант умещается в один атомарный оператор.
// если баланс > 0 — уменьшить и вернуть 1, иначе вернуть 0
script := redis.NewScript(
`local v = tonumber(redis.call('GET', KEYS[1])); if v and v > 0 then redis.call('DECR', KEYS[1]); return 1 else return 0 end`)
func (s *redisStore) Decrement(ctx context.Context) (bool, int64) {
n, err := s.script.Run(ctx, s.rdb, []string{"balance"}).Int()
if err != nil {
return false, 0
}
return n == 1, 0
}Redis выполняет Lua-скрипт целиком, без вклинивания других команд между чтением и декрементом — не ACID-транзакция в классическом смысле, но для этого инварианта её и не требуется: сервер однопоточный на исполнение команд, и скрипт гарантирует то же самое, что дал бы WATCH/MULTI из статьи #3, только без raundtrip клиент-сервер на CAS.
func (s *mongoStore) Decrement(ctx context.Context) (bool, int64) {
res := s.coll.FindOneAndUpdate(ctx,
bson.M{"_id": "acct", "balance": bson.M{"$gt": 0}},
bson.M{"$inc": bson.M{"balance": -1}})
if err := res.Err(); err != nil {
if errors.Is(err, mongo.ErrNoDocuments) { // условие balance>0 не выполнено
return false, 0
}
return false, 0
}
return true, 0
}Инвариант целиком живёт в границах одного документа, поэтому multi-document транзакция из статьи #3 здесь не нужна вовсе — атомарность операции над одним документом в MongoDB даётся бесплатно, без явного session.WithTransaction.
func (s *scyllaStore) Decrement(ctx context.Context) (bool, int64) {
var cur int64
if err := s.sess.Query(`SELECT balance FROM accounts WHERE id = 'acct'`).Scan(&cur); err != nil {
return false, 0
}
var conflicts int64
for {
if cur <= 0 {
return false, conflicts
}
// ScanCAS: при неудаче cur получает актуальное значение колонки balance
applied, err := s.sess.Query(
`UPDATE accounts SET balance = ? WHERE id = 'acct' IF balance = ?`, cur-1, cur).ScanCAS(&cur)
if err != nil {
return false, conflicts
}
if applied {
return true, conflicts
}
conflicts++ // кто-то изменил balance между чтением и CAS — повторяем с новым cur
}
}balance = balance - 1 нельзя выразить одним LWT-выражением — это не counter-колонка, поэтому механизм тут вынужденно другой: читаем текущее значение, затем UPDATE ... IF balance = <прочитанное>, и при неудаче CAS повторяем цикл с обновлённым значением, которое возвращает ScanCAS. Каждая успешная попытка — раунд Paxos-консенсуса; под конкуренцией на одном ключе неудачные попытки CAS накапливаются в конфликты, и именно это число окажется решающим в следующем разделе.
Полный стенд — общий нагрузчик и все четыре адаптера — в digital-cookbook, transactions/multistore/ (docker compose up + go run . -store all; проверено на PostgreSQL 18, Redis 8.6, MongoDB 8.2, ScyllaDB 6.2).
Результаты: цена корректности
Начнём с главного: все четыре хранилища держат инвариант корректно. После прогона final = 0 — весь баланс исчерпан ровно теми 1000 списаниями, на которые его хватило, ни один воркер не увидел баланс отрицательным, лишние 600 попыток из 1600 были честно отклонены. Корректность здесь — не различитель; правильным атомарным примитивом её добивается каждая из систем.
Различается — и различается радикально — цена этой корректности.
| Хранилище | Механизм | throughput (типичный прогон) | conflicts |
|---|---|---|---|
| Redis | Lua: атомарная проверка + DECR |
~15000 tx/s | 0 |
| MongoDB | findOneAndUpdate({balance:{$gt:0}}, {$inc:-1}) |
~2500 tx/s | 0 |
| PostgreSQL | атомарный UPDATE ... WHERE balance>0 |
~800 tx/s | 0 |
| ScyllaDB | LWT compare-and-set (Paxos) | ~17 tx/s | ~10500 |
Абсолютные числа зависят от машины и сетевого пути до контейнеров — важен не сам десяток тысяч, а порядок и разрыв: между самым быстрым и самым медленным вариантом — примерно 880×, при том что все четыре одинаково держат инвариант.
Важно: throughput меряет цену корректности для этого инварианта, а не «силу транзакций». Числа не эквивалентны по гарантиям, и лидер по скорости — не лидер по транзакционной модели.
~15000 tx/sу Redis получены без durability уровня денежной транзакции: по умолчанию Redis полагается на RDB-снапшоты, а AOF выключен — при краше теряется всё, что накопилось с последнего снапшота; даже когда AOF включают, его типичный режимappendfsync=everysecвсё равно теряет до секунды подтверждённых списаний. Поставьтеappendfsync=always— fsync на каждую запись, как WAL сfsyncна коммите у PostgreSQL, — и throughput Redis сместится в сторону дисковых систем, а разрыв во многом схлопнется. Ровно за это PostgreSQL и платит свои~800 tx/s: чтобы «списано» пережило падение процесса. Сюда же — отсутствие rollback у Redis (ниMULTI/EXEC, ни Lua-скрипт не откатывают уже сделанные мутации при ошибке на полпути) и то, что вся скорость держится на укладывании инварианта в один ключ в памяти: мульти-объектной изоляции,SERIALIZABLEи защиты от write skew из статьи #2 этот примитив не даёт. Так что «Redis впереди» на этом графике читается строго как «дешевле всех держит один горячий счётчик», а не как «Redis — лучший транзакционный движок».
Redis даёт максимальный throughput (~15000 tx/s) — операции целиком в памяти, без обращения к диску и без консенсуса между узлами: атомарная проверка-и-декремент внутри Lua-скрипта на сервере занимает микросекунды. Redis исполняет команды однопоточно, но именно потому, что каждая операция настолько дешёвая, эта единственная очередь пропускает больше всех — однопоточность здесь не узкое место, а отсутствие лишней координации.
MongoDB — уверенный второй (~2500 tx/s): весь инвариант укладывается в атомарность одного документа, findOneAndUpdate — ровно одна операция над одной записью, которую движок обслуживает без явной транзакции и без консенсуса между узлами. Медленнее Redis, потому что данные всё-таки на диске, а не только в памяти.
PostgreSQL — предсказуемый середняк (~800 tx/s) с нулём конфликтов на атомарном условном апдейте. Он честно платит за то, чего нет у Redis: долговечность на диске (WAL с fsync на коммите) и строчную блокировку на горячей строке, из-за которой конкурентные транзакции выстраиваются в очередь. За эту цену получаешь ACID, SQL-запросы поверх и мульти-объектные транзакции — то, чего атомарный INCR в памяти не даёт.
ScyllaDB на LWT — примерно в 880 раз медленнее Redis (~17 tx/s) и с лавиной retry — около 10500 неудачных попыток CAS на 1000 успешных списаний. Причина не в том, что ScyllaDB «медленная» — это одна из самых быстрых систем в мире по чистой записи. Причина в том, что LWT — это Paxos-консенсус на каждую попытку, а весь сценарий бьёт в один и тот же ключ (id = 'acct'). Каждый воркер, прежде чем применить свой CAS, вынужден договориться с репликами заново, и почти все 16 воркеров соревнуются за один и тот же раунд консенсуса одновременно — отсюда и лавина конфликтов. Это ровно тот анти-паттерн, о котором предупреждала статья #3: LWT в Scylla/Cassandra создан для случаев вроде «зарезервировать уникальное имя» или «выбрать лидера», где на конкретный ключ приходится редкая операция, а не горячий счётчик, который дёргают 1600 раз подряд.
Ключевой вывод этого раздела: вопрос «может ли система обеспечить корректность» почти всегда решается положительно — правильным примитивом её добивается и PostgreSQL, и Redis, и MongoDB, и ScyllaDB. Настоящий вопрос, который решает архитектуру, — «какой ценой на вашем профиле нагрузки». А профиль нагрузки — это в первую очередь то, где именно живёт инвариант: в одной строке, в одном ключе в памяти, в одном документе или в консенсусе между репликами.
Карта выбора
Синтез всей серии — не рейтинг хранилищ, а сопоставление класса задачи месту, где физически живёт инвариант:
| Класс задачи | Где живёт инвариант | Хранилище и стратегия |
|---|---|---|
| Горячий счётчик / лимит на одном ключе (списание баланса, rate limit, инвентарь одной позиции) | Один ключ/строка, высокая конкуренция | Redis (Lua-скрипт или атомарные INCR/DECR) — минимальная задержка; либо атомарный условный UPDATE в PostgreSQL, если нужна долговечность на диске и SQL-запросы поверх |
| Сложные мульти-объектные инварианты, произвольные бизнес-транзакции | Несколько строк/таблиц одновременно | Реляционная БД (PostgreSQL), с SERIALIZABLE и retry там, где snapshot isolation не спасает от write skew — см. статью #2 |
| Документо-центричные данные с инвариантом внутри одной записи (профиль, заказ, корзина как документ) | Один документ | MongoDB — атомарность одного документа без явной транзакции; multi-document транзакция — только если инвариант реально пересекает границы документа |
| Уникальность, резервация, лидер-элекшн на конкретном ключе (не горячем) | Один ключ, редкие конкурентные попытки | LWT в ScyllaDB/Cassandra — но не на горячем ключе, который дёргают тысячи раз за секунды: там Paxos-консенсус на каждую попытку захлебнётся ровно так, как в этом стенде |
| Гарантированная доставка события в связке с изменением в БД | Событие + бизнес-данные, разные системы | Брокер (Kafka/RabbitMQ) + outbox-паттерн: атомарность обеспечивает БД-транзакция, брокер — только надёжная доставка поверх, см. статью #4 |
Общий принцип, который стоит унести из всей серии: выбирай хранилище и транзакционную стратегию по тому, где именно живёт инвариант — один ключ в памяти, одна строка на диске, один документ, несколько объектов сразу или поток событий между системами, — а не по бренду, популярности или личным предпочтениям. Один и тот же примитив (LWT, атомарный UPDATE, Lua-скрипт, транзакция) на разных профилях нагрузки даёт то отличный, то катастрофический результат, и разница видна именно на границе, где этот примитив встречает конкуренцию.
За рамками серии
Серия намеренно не покрывала распределённые транзакции между разными системами — 2PC и Saga. Координация двух независимых хранилищ так, чтобы изменение либо применилось целиком в обеих, либо не применилось ни в одной, — это отдельный большой пласт: оркестрация шагов, компенсирующие действия при откате, идемпотентность на каждом шаге. Мы коснулись только пограничного случая — outbox-паттерна как моста между БД и брокером в статье #4, — но полноценная тема распределённых транзакций заслуживает отдельной статьи, а не абзаца в финале серии.
Итог серии
Путь серии: изоляция и каталог аномалий как общий язык описания проблемы (#1) → как это работает и ломается в PostgreSQL — lost update, write skew, SERIALIZABLE (#2) → что вообще значит «транзакция» в KV и документных хранилищах — Redis, MongoDB, ScyllaDB (#3) → транзакционность как гарантия доставки и атомарность публикации в брокерах, с outbox как мостом к БД (#4) → один сценарий на всех хранилищах разом и карта выбора (#5, эта статья).
Финальная мысль, ради которой писалась вся серия: транзакционность — не галочка «есть» или «нет» в описании продукта, а спектр гарантий с конкретной, измеримой ценой. Инженерное решение не в том, чтобы взять систему с самыми сильными гарантиями «на всякий случай», и не в том, чтобы взять самую быструю «потому что бенчмарки». Оно в том, чтобы сопоставить гарантию, которая реально нужна вашему инварианту, с самым дешёвым примитивом, который эту гарантию даёт — и стенд из этой статьи наглядно показывает, насколько разной может быть эта цена, даже когда корректность на выходе одинаковая.
Источники
- Martin Kleppmann, «Designing Data-Intensive Applications» — главы 7 (Transactions) и 9 (Consistency and Consensus)
- Jepsen: анализы распределённых систем
- Документация систем и первоисточники по каждому хранилищу — см. разделы «Источники» статей #1, #2, #3 и #4
- Стенд:
digital-cookbook/databases/transactions/multistore
Комментарии