Костыль на костыле: как 20 лет лечил остатки вместо поиска причины

Костыль на костыле: как я больше двадцати лет лечил остатки вместо того, чтобы найти причину

В любой системе, где есть остаток, счётчик или итоговая сумма, рано или поздно приходится выбирать: пересчитывать показатель заново при каждом запросе или хранить готовое значение и поддерживать его при изменении данных. Первый вариант обычно медленнее, зато честнее: если исходные данные верны - верен и результат. Второй - быстрый и удобный, но только до момента, пока хотя бы одно изменение пройдёт "мимо кассы". Тогда аккуратно накопленный итог начинает жить своей жизнью.

Я сделал этот выбор ещё в 1999 году. И выбрал хранить - из лучших побуждений: скорость, отзывчивость интерфейса, нормальная работа пользователей. Вместе с этим решением я на годы вперёд получил проблему, которую долго принимал за неизбежную "плату за производительность".

Три термина, без которых дальше не разобраться

Валютная позиция - это сколько валюты у банка остаётся сверх обязательств. Купили доллары - позиция выросла, продали - упала. Пока позиция не ноль, банк выигрывает или проигрывает на каждом движении курса, поэтому точная цифра нужна не "к закрытию месяца", а прямо сегодня.

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

Подтверждённая операция - ключевое понятие всей истории. Платёж может быть заведён заранее (чтобы видеть план), но фактически ещё не прошёл. В остатках должны учитываться только те движения, которые реально состоялись. В базе это маркировалось признаком `confirmed`.

Версия первая: пересчитываем каждый раз - и всё хорошо... до поры

В конце 90-х никакой автоматизированной системы позиции у нас вообще не было: цифры жили на бумаге и в головах. Я работал в казначействе банка дилером, позже руководил, программистом быть не собирался - просто не было ни бюджета, ни команды, ни времени "заказать разработку". В итоге написал сам: Visual Basic 6 поверх Oracle, который стоял у меня под столом.

Схема была простая: есть таблица движений `SUM_MOVEMENT` - счёт, дата валютирования, сумма, направление (плюс/минус) и флаг подтверждения. Открываешь счёт - приложение собирает SQL строкой и суммирует все движения по нему. Условие `confirmed=-1` фиксировало бизнес-правило: считаем только подтверждённые операции. Это был первый раз, когда правило появилось в коде.

Первые годы всё работало идеально. Сделки дилинга попадали в таблицу сами, платежи заводили вручную, поштучно - агрегаторов тогда не существовало. Но к 2002 году таблица разрослась до десятков тысяч строк, и каждый просмотр остатка превращался в суммирование тысяч операций. А главное - хвост постоянно удлинялся, потому что история не заканчивается никогда.

Система при этом не падала. Не было ни ошибок, ни аварий. Она просто стала мучительно медленной: обновление формы - до полуминуты. Пользоваться стало невозможно.

Версия вторая: храним остаток и правим дельтой - быстро, но хрупко

Решение казалось очевидным: завёл таблицу `ACCOUNT_BALANCE`, где хранится готовый остаток по счёту и дате. Поддержку поручил триггерам на таблице движений. Вставили операцию - остаток увеличился. Удалили - уменьшился. Исправили задним числом - разница разошлась по всем последующим дням этого счёта.

То есть остаток не пересчитывался заново, а "подкручивался" на дельту: вычли старое значение операции, прибавили новое. Формы снова "полетели", отклик стал почти мгновенным. Заодно мы переехали с домашнего сервера на нормальный.

И вот здесь началась история длиной больше двадцати лет.

Симптом: остатки иногда "не сходятся" - от копеек до миллионов

С какого-то момента хранимые остатки начали периодически расходиться с операциями, которые их формируют. Иногда на мелочь, иногда на огромные суммы. Парадокс в том, что проблема была плавающей: сегодня всё хорошо, завтра на части счетов - ерунда, послезавтра вроде "само прошло".

Я поступал как поступают многие, когда горит и надо быстро закрыть дыру:

1) написал утилиту, которая находила счета с расхождениями и исправляла цифры;
2) встроил её в запуск приложения - чтобы "подлечивать" систему каждый день;
3) добавил кнопку в интерфейс - потому что остатки успевали "поплыть" уже в течение рабочего дня.

Костыль на костыле, зато быстро. И главное - внешне логично: раз итог "иногда" неверный, значит, надо периодически его выравнивать.

Что я подозревал - и почему промахнулся

Долгое время я думал в двух направлениях.

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

И это выглядело правдоподобно, потому что хранимые итоги действительно часто ломаются из-за технических мелочей. Но правда оказалась неприятнее - и проще.

Корень проблемы: одно правило написали шесть раз, а в седьмой - забыли

Причину я нашёл только сейчас, когда сел переписывать систему заново. В новой архитектуре каждый кейс приходится описывать явно и покрывать тестами. И как только начинаешь формализовывать логику, сразу становится видно, где в системе "разные истины".

Бизнес-правило было одно: в остатках участвуют только подтверждённые операции (`confirmed`). Но это правило оказалось размазано по системе:

- в одном запросе оно было;
- в триггере на вставку - было;
- в триггере на удаление - было;
- в триггере на обновление - было (или почти было);
- в нескольких местах в приложении - было;
- а в одном критическом сценарии - его не написали вообще.

И вот именно эта "дырка" и порождала расхождения. Остаток обновлялся дельтой по операции, которая по смыслу не должна была влиять на остаток, либо, наоборот, не обновлялся там, где должен. Если система живёт достаточно долго, такие случаи гарантированно накапливаются, а итог постепенно превращается в сумму случайностей.

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

Почему я не находил это двадцать лет

Потому что я боролся не с причиной, а с последствиями.

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

Второй фактор - архитектурный. Когда бизнес-правила размазаны по триггерам, формам, ручным утилитам и "особым обработчикам", становится почти невозможно доказать, что правило применяется всегда и везде одинаково. Система превращается в набор исключений, где "так исторически сложилось".

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

Возражения, которые я слышу чаще всего - и что с ними не так

"Триггеры надёжнее, они в базе, значит, централизованно".
Централизованно - да. Надёжнее - не всегда. Триггер не спасает, если логика неполная или продублирована где-то ещё. Он лишь ускоряет выполнение ошибок.

"Хранить итоги - нормально, пересчитывать дорого".
Хранить можно и нужно, если выстроены инварианты: единый источник истины, однозначные правила, строгая дисциплина изменений. Но если итог правится дельтой без гарантий, вы не ускоряете систему - вы ускоряете накопление расхождений.

"Мы будем периодически сверять и чинить".
Сверка - это контроль, а не лечение. Если вы регулярно "подкручиваете" цифры, вы признаёте, что система не умеет быть точной. Это допустимо для второстепенных метрик, но крайне опасно для финансовых остатков, лимитов и риск-показателей.

Что я делаю иначе сейчас

1) Одна формулировка правила - один модуль ответственности.
Логика "что влияет на остаток" живёт в одном месте, а не в семи. Остальные слои не повторяют правило, а пользуются им.

2) Явные события вместо "магии" триггеров.
Там, где важна предсказуемость, лучше иметь понятный поток событий: операция создана, подтверждена, отменена, исправлена. Тогда видно, где и почему меняется итог.

3) Тесты на бизнес-инварианты.
Главная проверка простая: "остаток равен сумме подтверждённых движений". Если система хранит итог - тесты обязаны ловить любой сценарий, где это перестаёт быть правдой.

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

5) Минимум дублирования правил в SQL.
Если в одном месте условие `confirmed` написано как `=1`, в другом как `=-1`, в третьем как `IS NOT NULL`, а в четвёртом забыто - вопрос не "когда сломается", а "когда заметят".

Главный итог

Я больше двадцати лет исправлял "остатки", потому что верил, что проблема в неизбежной сложности: триггеры, производительность, базы, транзакции. На деле всё упёрлось в банальную вещь: правило было продублировано много раз и применялось не везде. А когда истина распадается на копии, любая система со временем начинает врать - просто потому, что её части расходятся в понимании реальности.

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

Прокрутить вверх