Engineering31 авг. 2026 г.
Ваш баланс не хранится. Он вычисляется.

В нашей базе данных нет колонки, в которой лежал бы ваш баланс.
Это кажется странным решением ровно до того момента, когда вы увидите, как сохранённый баланс и история операций расходятся. Когда это происходит, понять, что из них верно, невозможно. При достаточном времени и достаточном числе одновременных записей они расходятся всегда.
Поэтому Zenboox ведёт книгу двойной записи, а баланс — это запрос к ней: сумма проводок по вашему счёту.
ОДНО ПРАВИЛО
В каждой записи не меньше двух проводок, и сумма по дебету должна равняться сумме по кредиту. Если они не равны, запись не создаётся вовсе: операция целиком завершается ошибкой, вместо того чтобы оставить половину движения. Записи на нулевую сумму тоже отклоняются.
Это не бухгалтерская эстетика. В несбалансированной книге у вопроса «куда делись деньги» нет ответа, и чем старше расхождение, тем труднее найти запись, которая его вызвала.
Раз баланс вычисляется, направление имеет значение. Ваш баланс — это обязательство платформы: зачисление на ваш счёт увеличивает наш долг перед вами. Резерв казначейства — это актив. Нормальная сторона каждого счёта объявлена в одном месте, потому что как только две части кода начнут понимать её по-разному, на экране появится отрицательный баланс.
ЧЕГО ДВОЙНАЯ ЗАПИСЬ НЕ ЛОВИТ
Вот часть, которую в подобных текстах обычно опускают.
Допустим, две заявки на вывод по одному счёту приходят в один и тот же момент, а средств хватает только на одну. Обе читают баланс, обе видят достаточную сумму, обе пишут сбалансированную запись. Проверка целостности остаётся зелёной, потому что с книгой всё в порядке: каждая запись сходится идеально.
Нарушено другое правило — пользователь не может вывести деньги, которых у него нет. Двойной записи об этом сказать нечего.
Решение лежит не в книге учёта, а в уровне изоляции. Денежный путь вывода выполняется в сериализуемой транзакции, и баланс читается внутри неё. Нужны обе половины. Прочитайте баланс вне транзакции — и эти строки не попадут в её область видимости, так что даже сериализуемый уровень не обнаружит конфликт. Возьмите уровень слабее — и две транзакции ещё не видят дебет друг друга, ведь проводки добавляются вставкой и общей строки для блокировки нет, поэтому проверку проходят обе.
Мы знаем это, потому что наш первый тест на эту гонку оказался бесполезным. Он шёл через HTTP-эндпоинт, где сначала расходуется одноразовый код, а это фактически выстраивало две заявки в очередь. Тест измерял защиту кода, а не гонку: удаление проверки баланса не роняло его. Поэтому денежный путь вынесли в отдельный модуль, где действительно можно сделать два одновременных вызова, и теперь защита проверяется напрямую.
Гарантия, которую вы ни разу не пытались сломать, — не гарантия. Это комментарий.