TGViewer
Сергей Озеранский Сергей Озеранский @sergeiozeranskii · 2.35K subscribers
Post #600 1.35K
Леджер: журнал, которому можно верить

Баланс говорит, сколько у клиента денег. Леджер объясняет, откуда взялась эта цифра. Сегодня про него: как устроена таблица, почему в неё нельзя записать неверную строку и что происходит, когда одну и ту же операцию присылают дважды.

Только добавление

Леджер — таблица, в которую можно только вставлять. У репозитория нет методов update и delete для неё. Ошибку в леджере не исправляют, её объясняют новой записью.

В каждой строке: тип операции, сумма, баланс до, баланс после, ключ идемпотентности, ссылка на источник (джоба, платёж, возврат) и кто это сделал.

Семь типов операций

Три уменьшают баланс:
debit — списание за джобу,
refund — возврат клиенту через платёжного провайдера,
reversal — зарезервирован для внутренних отмен.

Три увеличивают:
credit — пополнение,
bonus — начисление сверх оплаты,
free_grant — стартовый грант.

Седьмой, adjustment, единственный со знаковой суммой: его пишет сверка, о которой ниже.

Направление операции задаётся типом, а не знаком. Сумма всегда положительная. Так по одной колонке видно, что это за операция, и нельзя случайно записать "списание на минус пять долларов".

Postgres проверяет арифметику

На таблице стоит CHECK:

(type IN ('debit','refund','reversal')
AND balance_after = balance_before - amount)
OR (type IN ('credit','bonus','free_grant')
AND balance_after = balance_before + amount)
OR type = 'adjustment'


Строку, в которой числа не сходятся, база отклонит, даже если в коде баг. Это последняя линия обороны, и она не зависит от того, кто и откуда пишет.

Откуда берутся баланс до и после? В прошлом посте списание делалось одним UPDATE с RETURNING, и именно возвращённое значение попадает в леджер. Одна транзакция: изменили баланс, получили новый остаток, вставили строку с ним. Разойтись эти два числа не могут.

Одна операция — одна запись

Мир вокруг нашего кода ненадёжен. Платежный провайдер присылает webhook об оплате, не получает ответ вовремя и присылает ещё раз. Воркер списания падает после UPDATE, но до commit, и повторяет цикл. Ретрай после сетевой ошибки. Во всех случаях операция одна, а попыток несколько.

Каждая операция несёт уникальный ключ: payment:<id платежа>, job:<id>:period:<начало периода>, refund:<id возврата>, grant:user:<id>. На колонке UNIQUE-индекс. Защита от дублирования на уровне БД.

Схема записи:

SAVEPOINT → UPDATE баланса → INSERT в леджер с ON CONFLICT DO NOTHING.


Если INSERT вернул строку, savepoint фиксируется. Если не вернул, ключ уже есть: savepoint откатывается, UPDATE баланса отменяется вместе с ним, а наружу возвращается существующая запись с пометкой "уже применено".

Почему не проверить ключ до UPDATE? Потому что между проверкой и вставкой второй процесс успеет сделать то же самое. Арбитром должен быть уникальный индекс, а не код. Savepoint нужен, чтобы откатить только эту операцию, не трогая внешнюю транзакцию: воркер списания держит блокировку на строке джобы, и терять её из-за дубликата нельзя.

Леджер это не двойная запись

В бухгалтерии каждая операция затрагивает два счёта, и сумма дебетов всегда равна сумме кредитов. У нас single-entry: одна строка, один аккаунт, направление в типе. Платформа ведёт только один вид счёта — обязательства перед клиентами. Выручка, комиссии провайдера и деньги на расчётном счёте живут в бухгалтерии. Если появятся внутренние взаиморасчёты между несколькими видами счетов, double-entry окупится. Пока это усложнение без выгоды.

Сверка

Баланс — денормализованная сумма леджера. Это ускоряет горячий путь, но два числа могут разойтись, если где-то есть баг. Раз в десять минут воркер считает по леджеру сумму со знаками для каждого аккаунта и сравнивает с хранимым остатком.

При расхождении: ERROR в лог и одна строка adjustment, где баланс до — сумма леджера, баланс после — хранимый остаток. Сам остаток не меняется: он источник истины для того, сколько клиенту можно тратить, и сверка не должна молча менять эту сумму. Она фиксирует разницу, чтобы леджер снова объяснял остаток целиком.

В норме таких строк ноль. Каждая — повод искать баг.
  • ❤ 5
  • 👍 3
  • 🔥 3
More from @sergeiozeranskii
  1. Sep 24, 2026Еще немного про ускорения. На этот раз про тесты, а точнее про coverage (Python). Библиоте…
  2. Sep 24, 2026Обнаружил, что агенты Claude Code в разных сессиях умеют общаться между собой. У меня сейч…
  3. Sep 23, 2026Возьмем Python-сервис в Kubernetes. Как правило на старте контейнера может проявляться спа…
  4. Sep 23, 2026Сегодня случилось страшное. То, чего я никак не ожидал. За все годы на MacBook со мной так…
  5. Sep 17, 2026Я вот не понимаю, зачем люди идут в OSS и контрибьютят на отвали. Ценности в этом ноль для…
  6. Sep 17, 2026Помните про https://github.com/ozeranskii/httptap? Я писал о нем давно еще - > тут. Наклеп…
Threads Profile ViewerView any public Threads profile without an account.Open ThreadLook →Writing with AI? Make it sound human.Metric37 rewrites AI drafts so they read naturally. Free AI detector, 1,500 words free.Try Metric37 →