Основная проблема IT-отрасли, на мой непросвещенный взгляд, заключается в том, что жизнь обучает нас профессии примерно так же, как учителя́ начальной школы — арифметике. Сначала нам говорят: делить на ноль нельзя. А потом оказывается, что ещё в XVII веке один маркиз по имени Гийом Франсуа Лопиталь научился. Нам говорят: квадратный корень можно извлекать только из положительных чисел. А потом — хоба — оказывается комплексными бывают не только обеды. И так далее.

С чего начинается обучение компьютерным наукам? — С некоторого количества теории, которая скучная и непонятная, как и любая полностью оторванная от практики теория, — а потом — с примеров. Мы открываем REPL и некоторое время забавляемся с ней, как с калькулятором.

Python 3.13.3 (main, Jun 16 2025, 18:15:32) [GCC 14.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 1 + 2
3
>>> 0.1 + 0.2
0.30000000000000004

Серьёзно? 0.30000000000000004? Это квантовомеханическая, или гравитационная поправка? Нет, это число с плавающей точкой так работает. Мантисса — это слово звучало на лекциях. Но кто про него вспоминает спустя несколько лет, устроившись стажёром в финтеховский стартап? Так в проектах и появляются деньги, хранящиеся во флоатах, и приводящие в уныние всех бухгалтеров при подбивке баланса.

Скажете, это смешной пример, про который все знают? — Ладно, вот вам пример повзрослее. Опросите 10 синьёров бигтеха с мегаопытом в пять и более лет, на предмет: какого рода гарантии предоставляет инкапсуляция в джаве? — И они вам хором ответят: скрывает внутреннюю кухню, не позволяя получить к ней доступ напрямую. Ха-ха-ха. Проект AspectJ появился 25 лет назад. С тех пор аспектное программирование затащили даже в Spring. Кроме того, если чужой код упал внутри чего-нибудь инкапсулированного в другом потоке — объект может остаться в любом неопределенном состоянии.

Скажете, это хак, а хаки использовать не нужно? — Ладно, давайте уже перейдем к хрестоматийному примеру: типы. Как часто вы слышали аргумент, мол, типы устраняют баги, заменяют тесты и всячески прочищают карбюратор? Некоторые ошибки разработчика типы действительно помогут выявить на ранней стадии: например, если вы попробуете передать объект «продукт» в качестве параметра в функцию «updateUserInfo», тип вам помешает. Но типичная трудноотлавливаемая ошибка часто бывает связана с ситуациями типа «off-be-one», или «неверный знак в арифметической операции», или даже «забыли про заглушку». Тут типы не помогут (ну, в off-by-one и смежных — помогут зависимые типы, реализованные в Идрисе, который с каждым годом уходит всё дальше от готовности к продакшену).

И вот, наконец, мой любимый пример: транзакции в СУБД. Якобы они предоставляют гарантии консистентности. Но на всех уровнях изоляции, отличных от Serializable (которая во-первых не умолчательная, а во-вторых — приносит кучу своих проблем, которые придется разруливать отдельно) — это не так. Причем, это бы еще и ладно; но ведь работа с СУБД обычно ведется не из консоли со скоростью печати и атомарностью каждой команды — а из высоконагруженного приложения, расположенного где-то на другой машине в сети. Наличие в этом уравнении сущности «сеть» — сразу ломает все гарантии, потому что не существует способа синхронизировать состояние в базе там и в приложении здесь. Простейший пример, с которым сталкивался лично я: мы стартуем транзакцию из приложения, и тут отваливается сеть. Вызов падает по таймауту. Если в этот момент просто отловить ошибку и попытаться транзакцию повторить (что, смею предположить, делает примерно 90% всего клиентского кода в мире) — транзакция будет применена неизвестное количество раз в диапазоне [0, N+1) — где N — количество попыток повтора.

Гарантии — зло

К чему я это всё? — Да к тому, что ожидание гарантий от какого-то стороннего кода — исключительно пагубная привычка, к которой нас старательно приучивают всю жизнь. Нам подсовывают механизм «try/catch», говоря: вот если что-то там снаружи (или внутри) поломается — перехвати исключительную ситуацию и ты снова на коне в дамках. Нам говорят: «Не хочешь потерять данные — положи в базу». Не хочешь, чтобы всё сломалось — перехвати исключение. Что-то не получилось? — Попробуй снова. Добейся, чтобы твой код никогда не ломался, не падал, не приводил к сегфолту.

Проблема в том, что на самом деле нет никаких гарантий, что «положил в базу — значит не потерял», «перехватил исключение — значит, продолжил нормальное выполнение», «получилось со второй попытки — ну и отлично». Я уже выслушал кучу наставлений «синьёров» про то, что «всю Молдаванку устраивает, а его видите ли нет» — когда попытался рассказать, почему просто засунуть медленный код в жобу — очень плохая идея. Красной нитью проходит всё то же непонимание, что в IT нет и не может быть никаких гарантий. И есть всего два варианта работать в такой ситуации: ① смириться, что каждая сотая (тысячная, миллионная) операция выполнится с неожиданным результатом, или ② принять постулат об отсутствии гарантий и научиться писать код, которому гарантии не нужны.

На самом деле, перед разработчиком никогда не стоит задача добиться гарантий успеха на промежуточном этапе. Гарантии нужны только при идемпотентном терминировании условного конечного автомата жизни данных. Результат — должен быть сохранён, для потомков и аудиторов, тут спору нет, но это несложно: просто долбитесь в базу, пока она не ответит согласием, очевидная идемпотентность этого вызова позволяет. А вот промежуточные шаги — совсем не требуют никаких гарантий выполнения, нужна лишь гарантия «перехода в следующее состояние».

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

Асинхронные сообщения

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

Такое (на поверхностный и неверный взгляд) недружелюбие со стороны окружения — заставляет разработчика мыслить немного иначе. Не полагаться слепо на какие-то там гарантии, предоставляемые на бумаге чужим кодом, — но четко понимать, что и зачем он делает.

Взять вот хотя бы набивший лично мне оскомину пример с расплатой за товар. Что должно произойти в результате этого действия? — Если у вас в голове появились мысли про проверку наличия товара, денег на балансе, еще какие-то детали реализации — ответ неверный. Должно произойти следующее:

  • процесс покупки должен быть тотальным (завершиться при любых входных данных с ожидаемым результатом за конечное время)

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

  • магазин должен оказаться в одном из двух возможных состояний: либо без товара и бо́льшим количеством денег на балансе, либо с товаром и с той же суммой, что у него была до начала этой возни

Всё. Вы видите где-то тут что-то про базу, транзакции, и прочие детали имплементации? — Я да: тотальность, которую я не случайно поставил во главе угла, недостижима, если полагаться только на базу и транзакции (и жобу, конечно, которая будет повторять попытку провести оплату даже в ситуации «по банку вдарила болванка»). Заметьте, кстати, любопытную деталь: не имеет никакого смысла удостоверяться, что количество денег, заплаченных покупателем, не отличается от количества денег, полученных магазином. Или что это один и тот же товар. Операционные пространства покупателя и магазина — совершенно, абсолютно не связаны по умолчанию (их можно связать пото́м, если надо): покупателю важна только транзакция деньги→товар, а магазину — только товар→деньги. Если товар аннигилировал, а деньги пришли в результате сжигания квантово-связанной со счетом магазина банкноты в Зимбабве — всё хорошо.

Я знаю, как правильно реализовать этот сценарий с базой. Простите за болд, но смешные человечки, спешащие мне сообщить, что я просто не умею программировать, — неимоверно надоели. Еще я знаю, что если опираться в этом решении на базу — решение получится либо не тотальным, либо неимоверно окостыленным. Большинство, конечно, либо просто не подумает о крайних случаях, либо забьёт на них болт на тридцать два с правой резьбой.

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

Disclaimer

Поскольку этот текст (перестаньте уже называть эти текстики высокопарным «статья», этот термин подразумевает рецензирование, как минимум) написан на русском языке и опубликован тут, считаю своим долгом сообщить для людей среднего умишка и атрофированной способностью воспринимать печатный текст: я не отрицаю ценность базы, сам иногда пользуюсь синхронными сообщениями и полагаюсь на гарантии третьесторонних библиотек/софтин в некритичных случаях. Просто более десяти лет работы с критичными сущностями (типа транзакций на $50M) — заставили меня думать не о девяностодевятипроцентных гарантиях, а о том, как эту цифру довести до максимума. А это невозможно, если верить в Деда Мороза, Золотых Единорогов, Зубных Фей и Гарантии ПО.

Удачного конструктивного недоверия!

Комментарии (16)


  1. 8bityeti
    28.06.2025 09:37

    • процесс покупки должен быть тотальным (завершиться при любых входных данных с ожидаемым результатом за конечное время)

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

    • магазин должен оказаться в одном из двух возможных состояний: либо без товара и бо́льшим количеством денег на балансе, либо с товаром и с той же суммой, что у него была до начала этой возни

      По сути произошел Event Storming (на мой взгляд). Сейчас углубляюсь в эту тематику (bounded context, domain, coupling & etc), очень нравится и верю что в этом есть смысл)



    1. cupraer Автор
      28.06.2025 09:37

      Event Storming

      Ох, и минуты не проходит, чтобы кто-то не переназвал какую-нибудь штуку, которой триста лет в обед, — новым модным словом.

      Я просто бизнес-требования перечислил :) Думая, как нормальный человек, а не как программист. Если это теперь принято называть EventStorming — то в моё время это было принято называть просто Постановка задачи.

      :)


      1. 8bityeti
        28.06.2025 09:37

        Так же произошло с accounting) Хотя double entry ledgers придумали еще в 15 веке. Лука Пачоли


        1. LavaLava
          28.06.2025 09:37

          Ну не зря День Бухгалтера с ним и связан.


  1. oracle_schwerpunkte
    28.06.2025 09:37

    Вот это самомнение!
    Упомянуты какие-то крайние абстрактные случаи без единого примера.
    Без них создается устойчивое впечатление что автор получил психологическую травму и действительно не умеет работать с базой.


    1. cupraer Автор
      28.06.2025 09:37

      Я не ставил перед собой задачу создать положительное впечатление у всех вокруг, так что всё в порядке.


  1. michael_v89
    28.06.2025 09:37

    Наличие в этом уравнении сущности «сеть» — сразу ломает все гарантии, потому что не существует способа синхронизировать состояние в базе там и в приложении здесь.

    А должен быть? Как это связано с транзакциями?
    Если нужно приостановить параллельные изменения в базе пока приложение обрабатывает загруженное состояние, для этого используются мьютексы. Внутри транзакции можно использовать FOR UPDATE.

    Если в этот момент просто отловить ошибку и попытаться транзакцию повторить (что, смею предположить, делает примерно 90% всего клиентского кода в мире)

    Не знаю, как в других языках, но в веб-проектах на PHP такое встречается крайне редко. База недоступна, процесс пишет в лог и падает с исключением. Fail-fast и всё такое. Я такое встречал только пару раз в фоновых консольных командах, которые обрабатывают много записей, чтобы переподключиться заново.

    мы стартуем транзакцию из приложения, и тут отваливается сеть
    транзакция будет применена неизвестное количество раз в диапазоне

    Что значит "применена"? Если коммита не было, то изменения не записаны.
    Если с точки зрения приложения база недоступна, то оно должно считать подключение недействительным и переподключиться, а не пытаться отправлять данные в тот же сокет. Тогда для базы это будет означать новую транзакцию, а не продолжение той же.


  1. Dhwtj
    28.06.2025 09:37

    Я знаю, как правильно реализовать этот сценарий с базой

    Распределенная транзакция

    Eventual consistency и задача в поставленной формулировке не решается принципиально

    То есть будет потенциально бесконечный ряд уточнений как там в кошельке, как там на прилавке. С быстро растущей вероятностью выполнения условий, но никогда не 100%


  1. Dhwtj
    28.06.2025 09:37

    Кстати, куда потерялось условие И?

    Магазин продал И покупатель купил?

    Вижу тут только по отдельности:

    1. Магазин продал. То есть отдал товар и получил деньги

    2. Покупатель купил. То есть отдал деньги и получил товар.

    А товар физически один и это посильнее всех этих ваших программ. В общем, проблема в том что процесс покупки не чистый, а взаимодействует с контекстом, который может товар сломать, а деньги украсть

    А если магазин отвечает только за свой инвариант, а покупатель только за свой, то возможен случай когда покупатель скажет что товар не получил, верните деньги, а магазин скажет что деньги не получил и товар не отдаст


    1. cupraer Автор
      28.06.2025 09:37

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


  1. olegsafonov
    28.06.2025 09:37

    Сначала нам говорят: делить на ноль нельзя. А потом оказывается, что ещё в XVII веке один маркиз по имени Гийом Франсуа Лопиталь научился.

    Делить на ноль действительно нельзя, а операции с пределами - это операции с пределами. Дальше поток мысли читать не стал.


    1. geher
      28.06.2025 09:37

      На самом деле возможность делить на ноль зависит от ситуации. Точнее от того, как мы определим операцию деления на некотором множестве.

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

      Но в некоторых случаях в качестве результата автоматически берется некое искусственное значение - бесконечность.

      А в C++ деление числа с плавающей точкой на ноль даст вполне определенный результат - NaN.


  1. sepulkary
    28.06.2025 09:37

    Посмотрел - у автора все статьи довольно эмоциональные, интересный подход. Может и правда, так до кого-то лучше доходит и заставляет задуматься о привычных вещах; плюсую, как говорится.

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

    Не, не получится. Я вам буду оппонировать книжкой Мартина Клеппмана "Высоконагруженные приложения" (она довольно увесистая и ей удобно оппонировать) - не получится. Будет тяжело, долго, дорого и всё время придётся думать, чем бы пожертвовать.


    1. cupraer Автор
      28.06.2025 09:37

      Да оперируйте хоть Брокгаузом, то, что кто-то не осилил — так себе аргумент.


  1. Kerman
    28.06.2025 09:37

    Тут типы не помогут

    Тут вы, споря с виртуальным собеседником, приводите аргумент, что типы не всё решают. И делаете вывод, что они не спасают от багов. Это манипуляция.

    Типы гарантируют, что в этой переменной может быть значение только такого типа. Не больше и не меньше.

    И вот, наконец, мой любимый пример: транзакции в СУБД. Якобы они предоставляют гарантии консистентности.

    Вообще ни разу. Транзакция даёт гарантию, что она либо выполнится целиком, либо не выполнится никак. Не больше и не меньше. Это инструмент, а обеспечить с помощью этого инструмента консистентность - это твоя задача, программист. Данные же твои. Схема данных твоя. Это ты определяешь, что является консистентностью, а что нет.


  1. cordarryl_juormer
    28.06.2025 09:37

    дели на ноль - не бойся: потом всё сойдётся