Читай реальный код и конфиг из кейсов данных-и-денег, затем выбери senior-починку: лейбл метрики, идемпотентное списание, дебет кошелька с потерянным обновлением и атомарную многоночную бронь.
SDCSenior◷ 14 min
Уровень
ОсновыJuniorMiddleSenior
Денежные баги живут в коде, не в прозе: лейбл на метрике, неатомарное чтение-затем-запись, списание без ключа идемпотентности, поночёвый декремент вне транзакции. Читай каждый сниппет, проследи отказ и выбери починку, что закоммитил бы senior-инженер.
Практикуй петлю, что гоняешь в дизайн-ревью по деньгам или критичным к корректности данным: засеки угрозу в коде, назови её и выбери изменение, что механизм реально требует.
Что не так с этой инструментацией и какова починка?
Heads-up Лейбл на пользователя форкает ряд на пользователя; число рядов — ПРОИЗВЕДЕНИЕ кардинальностей лейблов, так что user_id (миллионы значений) умножает его в миллионы и исчерпывает память/индекс TSDB. Богатые разбивки по пользователю — в логи/трейсы, не в лейблы метрик.
Heads-up Counter против gauge — неверная ось; счётчик запросов корректно является counter. Баг — неограниченный лейбл user_id, взрывающий кардинальность; тип метрики в порядке.
Heads-up Больше лейблов УМНОЖАЮТ число рядов (это произведение), делая кардинальность хуже, не лучше. Починка — УБРАТЬ неограниченный лейбл (user_id), не добавить больше.
Сниппет 2 — вызов списания
def checkout(order): # клиент повторяет этот вызов при таймауте resp = psp.charge(amount=order.total, source=order.card) ledger.record(order.id, resp) return resp
Викторина
Completed
Клиент повторяет checkout() после таймаута. Что произойдёт и какова починка?
Heads-up PSP дедупят по КЛЮЧУ ИДЕМПОТЕНТНОСТИ, не по осмотру суммы/карты (два законных списания одной суммы валидны). Без клиентского ключа ретрай — свежее списание. Надо передать ключ идемпотентности, что клиент отчеканил до первой попытки.
Heads-up Локальная транзакция не охватит внешний вызов psp.charge и не предотвратит второе списание, когда первый ответ потерян. Починка — ключ идемпотентности, не транзакция БД.
Heads-up Проглатывание ошибки рискует реально пропущенным платежом (таймаут двусмыслен — он мог удаться или нет). Повторяй БЕЗОПАСНО с ключом идемпотентности, чтобы дубликаты распознавались, а пропущенные списания завершались.
Сниппет 3 — дебет кошелька
def debit(wallet_id, amount): bal = db.query("SELECT balance FROM wallets WHERE id = ?", wallet_id) if bal >= amount: db.execute("UPDATE wallets SET balance = ? WHERE id = ?", bal - amount, wallet_id) return "ok" return "insufficient"
Викторина
Completed
Два конкурентных дебета бьют по тому же кошельку. Каков баг и однооператорная починка?
Heads-up Каждый оператор атомарен, но ЧТЕНИЕ и ЗАПИСЬ раздельны, оставляя зазор, где другой дебет читает тот же устаревший баланс. Два конкурентных дебета оба проходят проверку — классическое потерянное обновление. Схлопни в один условный UPDATE.
Heads-up Индекс ускоряет поиск, но ничего не делает с гонкой read-modify-write. Починка — контроль конкуренции: атомарный условный UPDATE (или SELECT … FOR UPDATE), чтобы check-and-decrement был неделим.
Heads-up Кэширование делает устаревание ХУЖЕ и всё равно оставляет зазор между чтением и записью. Починка — сделать декремент условным и атомарным в БД, не добавить кэш.
Сниппет 4 — многоночная бронь
def reserve(room_type, nights): # nights = [Dec31, Jan1, Jan2] for night in nights: db.execute( "UPDATE inventory SET booked = booked + 1 " "WHERE room_type = ? AND date = ? AND booked < total", room_type, night) return "reserved" # каждая ночь обновлена независимо, без транзакции
Викторина
Completed
Поночёвый UPDATE условный (хорошо), но у цикла нет транзакции. Что может пойти не так и какова починка?
Heads-up Поночёвая атомарность предотвращает овербукинг ОДНОЙ ночи, но без транзакции через диапазон 3-ночное проживание бронирует 2 ночи, затем валится на 3-й, оставляя сломанную частичную бронь. Весь диапазон должен быть всё-или-ничего.
Heads-up Ретрай перезапускает уже забронированные ночи, дважды декрементя инвентарь. Починка — одна транзакция над всеми ночами с откатом, если любая ночь провалится — не ретрай над частичными записями.
Heads-up Отдельные транзакции — ровно баг: они допускают частичную бронь (часть ночей забронирована, одна провалилась). Диапазон должен коммититься атомарно как одна транзакция, всё-или-ничего.
Вспомните перед уходом
01
Как читать сниппет дебета кошелька или брони на угрозу потерянного обновления / частичной записи?
02
Как засечь угрозы идемпотентности и кардинальности в коде?
Итог
Каждый баг данных-и-денег читается прямо с кода. Неограниченный лейбл метрики (user_id) форкает ряд на значение и OOM’ит TSDB — кардинальность рядов, не темп запросов, ограничение, так что убери его из лейблов. Списание, что клиент повторяет без ключа идемпотентности, списывает дважды, когда первый ответ потерян — передай клиентом отчеканенный ключ с атомарным check-and-store. Дебет кошелька, что читает баланс, затем пишет обратно, имеет зазор чтения-записи, что эксплуатируют два конкурентных дебета (потерянное обновление) — схлопни в один атомарный условный UPDATE. И многоночная бронь, что декрементит каждую ночь вне транзакции, может оставить частичную бронь — оберни весь диапазон в одну транзакцию всё-или-ничего. Senior-привычка одна для всех четырёх: найди угрозу, назови её и сделай операцию ограниченной (кардинальность) или атомарной (идемпотентность, потерянное обновление, частичная запись) — починку, что требует механизм, не ту, что её прячет.