open atlas
↑ К треку
Разборы System Design SDC · 05 · 04

Спроектируй цифровой кошелёк

Цифровой кошелёк: корректность баланса под конкуренцией, переводы как ACID против распределённой транзакции, идемпотентность, проблема потерянного обновления, шардированные балансы, event-sourced ledger и консистентность, которой требуют деньги.

SDC Senior ◷ 36 min
Уровень
ОсновыJuniorMiddleSenior

У пользователя было $100 в кошельке. Два запроса пришли в одну миллисекунду: покупка на $70 и покупка на $60. Оба бэкенд-воркера прочитали баланс — каждый увидел $100 — оба проверили «хватает ли $100?», оба сказали да, оба вычли и записали обратно. Кошелёк остался на $40 (чья запись легла последней), пользователь потратил $130, которых у него не было, а компания тихо потеряла $30. Ничто не упало. Ни одной ошибки в лог. Это проблема потерянного обновления (lost update): две транзакции читают одно значение, каждая вычисляет новое из устаревшего чтения, и одна перезаписывает другую. Для счётчика лайков это ошибка округления, которой никто не замечает; для баланса кошелька это деньги из ниоткуда, и это главная определяющая угроза дизайна кошелька.

Требования

Цифровой кошелёк держит баланс и даёт пользователям пополнять, тратить и переводить в другие кошельки. Числа малы, но планка корректности абсолютна.

Функциональные. Показать баланс. Кредитовать (пополнение) и дебетовать (трата) кошелёк. Переводить между двумя кошельками атомарно — дебет одного, кредит другого, никогда одно без другого. Отклонять дебет, что уводит в минус (если овердрафт явно не разрешён). Выдавать полную историю транзакций. Быть идемпотентным: повторённый перевод не должен двигать деньги дважды.

Нефункциональные. Корректность баланса необсуждаема — баланс всегда обязан равняться сумме движений кошелька, без потерянных обновлений и без создания или уничтожения денег. Переводы должны быть атомарными (обе ноги или ни одной). Операции должны быть идемпотентными под ретраями. Система должна масштабироваться до миллионов кошельков, что вынуждает шардинг — и вот тут переводы между шардами становятся трудными.

Пропускная способность скромна (кошелёк — не высокий-QPS фид), так что, ровно как с платежами, трудность — корректность под конкуренцией, а не сырой масштаб. Проблема потерянного обновления из вступления — враг, ради победы над которым существует весь дизайн.

Оценка

кошельков         = 100 000 000 кошельков
транзакций        = 50 000 000 переводов/день
средний QPS       = 5×10^7 / 86 400        ≈ 580 переводов/с  (скромно)
пик QPS           = ~5× средн.             ≈ 3 000 переводов/с
записей в ledger  = 2 на перевод (дебет + кредит) → ~10^8 неизменяемых строк/день
горячие кошельки  = пара кошельков продавцов могут забрать огромную долю записей

Совокупный QPS (~сотни до пары тысяч переводов/с) мал для БД. Дизайн на деле формируют две вещи. Первое — конкуренция за запись на кошелёк: большинство кошельков тихи, но популярный продавец или выплатной кошелёк может стать горячей точкой записи, и сериализация того единственного кошелька — реальное узкое место, а не общий итог. Второе — ledger растёт вечно (~10^8 записей/день, append-only, хранится годы), так что балансы выводятся из ledger, и нужен способ читать текущий баланс, не пересуммируя всю историю — снимок или материализованный баланс.

Высокоуровневый дизайн

Хребет — event sourcing: источник истины — append-only лог событий, меняющих баланс (кредитован, дебетован, переведён), а текущий баланс — это fold (свёртка) по этому логу, никогда не поле, что мутируешь на месте. Идемпотентность дедупит ретраи; перевод — единое атомарное применение (две записи ledger); балансы материализуются для быстрых чтений; а шардинг по wallet id держит большинство переводов одношардовыми, пока saga обрабатывает межшардовое меньшинство.

Глубокое погружение

Проблема потерянного обновления и контроль конкуренции

Вступление — каноничный баг конкуренции, и у него три стандартные починки — надо знать все три и когда какую.

Пессимистичная блокировка (SELECT … FOR UPDATE). Транзакция блокирует строку кошелька до чтения, так что второй перевод ждёт до коммита первого, затем читает свежий баланс ($30, не $100) и корректно отклоняет овердрафт. Просто и в точности корректно; цена — конкурентные операции на том же кошельке сериализуются, так что горячий кошелёк становится очередью.

Оптимистичная конкуренция (версия/CAS — Compare-And-Swap, сравни-и-запиши). Прочитай баланс и его версию, вычисли новый баланс, затем запиши только если версия не изменилась (UPDATE … WHERE id=? AND version=?). Если кто-то записал первым, версия сдвинулась, твоё обновление затрагивает ноль строк, и ты повторяешь со свежим значением. Хорошо, когда конкуренция редка (большинство кошельков); расточительно при высокой (шторм ретраев на горячем кошельке).

Атомарная условная запись. Затолкай весь check-and-decrement в один оператор, что БД исполняет атомарно: UPDATE wallets SET balance = balance - 70 WHERE id = ? AND balance >= 70. Гонки read-modify-write нет, потому что БД оценивает условие и обновление как одну операцию; ноль затронутых строк значит недостаточно средств. Это чистейший одношардовый дебет.

ПОТЕРЯННОЕ ОБНОВЛЕНИЕ (баг):
  T1 чит. 100 ─┐                    ┌─ T2 чит. 100
  T1 проверка  │  (оба видели 100)  │  T2 проверка
  T1 зап. 30 ──┘                    └─ T2 зап. 40   ← перезапис.; баланс = 40, потрачено $130

ПОЧИНЕНО (атомарная условная):
  T1: UPDATE … balance = balance-70 WHERE balance >= 70  → 1 строка, баланс = 30
  T2: UPDATE … balance = balance-60 WHERE balance >= 60  → 0 строк (30 < 60) → отклонён
Почему это работает

Почему атомарная условная запись побеждает потерянное обновление, а наивный код нет? Потому что баг — это временной зазор между чтением баланса и записью нового — в этом зазоре другая транзакция читает то же устаревшее значение, и обе считают от $100. Наивный код принимает решение в приложении, по значению, что может устареть до того, как запись ляжет. Атомарный оператор закрывает зазор: БД оценивает balance >= 70 и выполняет balance = balance - 70 как одну неделимую операцию под блокировкой строки, так что никакая другая транзакция не вклинится между проверкой и вычитанием. Нет устаревшего чтения, от которого считать, потому что чтение, проверка и запись — один атомарный шаг. Это общий урок: не решай в приложении по значению, что потом записываешь обратно; пусть БД решает-и-пишет за один присест, или держи блокировку через зазор.

Атомарные переводы и проблема межшарда

Перевод — это два движения — дебет отправителя, кредит получателя — что должны быть всё-или-ничего. Если оба кошелька на одном шарде/БД, это тривиально: оберни оба в одну локальную ACID-транзакцию; либо оба коммитятся, либо оба откатываются. Поэтому шардируешь по wallet id и стараешься держать связанные кошельки вместе — большинство переводов остаются одношардовыми и получают настоящую ACID-атомарность бесплатно.

Трудный случай — межшардовый перевод: отправитель на шарде A, получатель на шарде B, ни одна транзакция не охватывает оба. Нельзя просто дебетовать A, а затем кредитовать B — если кредит отказал после коммита дебета, деньги исчезли. Варианты:

  • Saga с компенсацией (урок про event-driven): дебет A (локальный коммит) → кредит B (локальный коммит); если кредит B отказал, запусти компенсацию, кредитующую A назад. Между двумя шагами система кратко неконсистентна (деньги «в полёте»), что явно моделируется pending-состоянием.
  • Двухфазный коммит (2PC): координатор готовит оба шарда, затем коммитит оба — сильная атомарность, но блокирует, если координатор умрёт посреди коммита, и связывает доступность со всеми участниками.

Большинство кошельков выбирают saga (доступна, eventually consistent, с явным in-flight состоянием) вместо 2PC (консистентен, но блокирует) и опираются на ledger плюс сверку, чтобы обнаружить и исцелить любой застрявший перевод.

Event sourcing: баланс как свёртка по событиям

Хранение баланса как изменяемого числа — первородный грех (это то, что включает потерянное обновление и стирает историю). Event sourcing переворачивает это: события — истина, баланс — производное. Каждое изменение — неизменяемое событие, добавленное в лог; текущий баланс вычисляется свёрткой событий:

события:  +100 (пополнение)  −70 (покупка)  +20 (возврат)
баланс = fold(0, события) = 0 + 100 − 70 + 20 = 50

Это покупает три вещи, которых изменяемый баланс не может. Проверяемость: полная история — это хранилище, так что всегда можешь объяснить баланс и переиграть его. Проверки корректности: баланс проверяем (пересвернуть и сравнить), так что порча обнаружима. Темпоральные запросы: «какой был баланс в прошлый вторник?» — это свёртка до той точки. Практическая цена — свёртка всей истории на каждом чтении слишком медленна, так что держишь снимок (материализованный баланс на чекпойнте) и сворачиваешь лишь события с тех пор — быстрые чтения, полная история сохранена.

Узкие места и компромиссы

Определяющее узкое место — сериализация записи на кошелёк: единственный горячий кошелёк (крупный продавец) — точка конкуренции, которую никакой шардинг не чинит, потому что нельзя разделить обновления баланса одного кошелька, не потеряв атомарность одной строки, что предотвращает потерянные обновления — смягчения это батчинг его обновлений или разбиение на суб-счета, что сверяются. Стратегия блокировки — компромисс по конкуренции: пессимистичные блокировки просты и безопасны, но сериализуют горячие кошельки; оптимистичная/CAS хороша при низкой конкуренции, но штормит ретраями при высокой; выбирай по профилю конкуренции кошелька. Межшардовые переводы меняют атомарность на доступность: одношардовый — настоящий ACID, межшардовый — saga с кратким in-flight расхождением и сверкой для исцеления опоздавших. И event sourcing меняет простоту чтения на целостность записи: чтениям нужны снимок-плюс-хвост, чтобы быть быстрыми, а хранилище растёт вечно, но взамен баланс всегда — проверяемое, аудитируемое следствие лога — компромисс, что денежные системы делают каждый раз.

Викторина

Два конкурентных дебета ($70 и $60) оба читают баланс $100, оба проходят проверку, оба пишут обратно — и кошелёк уходит в минус. Что это, и какая однооператорная починка это предотвращает?

Викторина

Перевод двигает деньги из кошелька на шарде A в кошелёк на шарде B. Ни одна транзакция не охватывает оба шарда. Как правильно держать его атомарным, и какова цена?

Закончи аналогию

В event-sourced кошельке баланс никогда не хранится как изменяемое число; это _______ по append-only логу событий — так что он всегда может быть пересчитан и проверен, а история, что его объясняет, никогда не теряется.

Вспомните перед уходом
  1. 01
    Что такое проблема потерянного обновления и каковы три способа её предотвратить?
  2. 02
    Как держать перевод атомарным, одношард против межшарда?
  3. 03
    Почему event-source баланс, а не хранить изменяемое число?
Итог

Цифровой кошелёк — это задача корректности под конкуренцией, а не масштаба: пропускная способность скромна, но баг потерянного обновления из вступления — два дебета, оба читающие тот же баланс, и один перезаписывает другой — создаёт деньги из ниоткуда без ошибки в логе, и победа над ним — вся работа. Три инструмента закрывают зазор устаревшее-чтение-затем-запись: пессимистичная блокировка (проста, сериализует горячие кошельки), оптимистичная/CAS (хороша при низкой конкуренции, штормит при высокой) и чистейший одношардовый дебет — атомарная условная запись (UPDATE … balance = balance - X WHERE balance >= X), делающая check-and-decrement одной неделимой операцией. Переводы должны быть атомарными: одношардовые получают настоящий ACID в одной локальной транзакции (поэтому шардируешь по wallet id, чтобы большинство переводов было одношардовыми), пока межшардовые переводы используют saga с компенсацией и кратким in-flight состоянием вместо блокирующего 2PC. Под капотом баланс event-sourced — append-only ledger событий с балансом как проверяемой, аудитируемой свёрткой, держимой быстрой снимком-плюс-хвост. Постоянные компромиссы — сериализация записи на горячих кошельках продавцов (узкое место, что шардинг не чинит), стратегия блокировки по профилю конкуренции, атомарность-против-доступности между шардами и простота-чтения-против-целостности-записи в event sourcing — каждый разрешён в пользу того, чтобы баланс всегда был доказуемым следствием лога. Теперь, когда увидишь дебет, реализованный как чтение-в-приложении-затем-запись, ты распознаешь потерянное обновление ещё до того, как оно случится — и сразу потянешься за атомарным условным UPDATE, а не за циклом ретраев.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.