Назад в блог

Engineering31 авг. 2026 г.

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

ZenbooxОфициальный блог

В нашей базе данных нет колонки, в которой лежал бы ваш баланс.

Это кажется странным решением ровно до того момента, когда вы увидите, как сохранённый баланс и история операций расходятся. Когда это происходит, понять, что из них верно, невозможно. При достаточном времени и достаточном числе одновременных записей они расходятся всегда.

Поэтому Zenboox ведёт книгу двойной записи, а баланс — это запрос к ней: сумма проводок по вашему счёту.

ОДНО ПРАВИЛО

В каждой записи не меньше двух проводок, и сумма по дебету должна равняться сумме по кредиту. Если они не равны, запись не создаётся вовсе: операция целиком завершается ошибкой, вместо того чтобы оставить половину движения. Записи на нулевую сумму тоже отклоняются.

Это не бухгалтерская эстетика. В несбалансированной книге у вопроса «куда делись деньги» нет ответа, и чем старше расхождение, тем труднее найти запись, которая его вызвала.

Раз баланс вычисляется, направление имеет значение. Ваш баланс — это обязательство платформы: зачисление на ваш счёт увеличивает наш долг перед вами. Резерв казначейства — это актив. Нормальная сторона каждого счёта объявлена в одном месте, потому что как только две части кода начнут понимать её по-разному, на экране появится отрицательный баланс.

ЧЕГО ДВОЙНАЯ ЗАПИСЬ НЕ ЛОВИТ

Вот часть, которую в подобных текстах обычно опускают.

Допустим, две заявки на вывод по одному счёту приходят в один и тот же момент, а средств хватает только на одну. Обе читают баланс, обе видят достаточную сумму, обе пишут сбалансированную запись. Проверка целостности остаётся зелёной, потому что с книгой всё в порядке: каждая запись сходится идеально.

Нарушено другое правило — пользователь не может вывести деньги, которых у него нет. Двойной записи об этом сказать нечего.

Решение лежит не в книге учёта, а в уровне изоляции. Денежный путь вывода выполняется в сериализуемой транзакции, и баланс читается внутри неё. Нужны обе половины. Прочитайте баланс вне транзакции — и эти строки не попадут в её область видимости, так что даже сериализуемый уровень не обнаружит конфликт. Возьмите уровень слабее — и две транзакции ещё не видят дебет друг друга, ведь проводки добавляются вставкой и общей строки для блокировки нет, поэтому проверку проходят обе.

Мы знаем это, потому что наш первый тест на эту гонку оказался бесполезным. Он шёл через HTTP-эндпоинт, где сначала расходуется одноразовый код, а это фактически выстраивало две заявки в очередь. Тест измерял защиту кода, а не гонку: удаление проверки баланса не роняло его. Поэтому денежный путь вынесли в отдельный модуль, где действительно можно сделать два одновременных вызова, и теперь защита проверяется напрямую.

Гарантия, которую вы ни разу не пытались сломать, — не гарантия. Это комментарий.