Доброго времени. Меня зовут Дмитрий и я веб‑разработчик. На данный момент работаю в группе компаний по экспорту автомобилей и техники из Японии, Китая и Кореи. Но, сейчас поговорим не об основной работе, а о «подработке».
>= 2 лет назад, на меня вышел директор достаточно крупной автошколы нашего города. На тот момент у них имелось порядка 3-х филиалов, и приблизительно 4,5 тыс. учеников (как актуальных, так и те — которые уже получили свои ВУ). Директор предложил мне поработать с их CRM системой. Данное ПО было написано какими‑то фрилансерами, и на протяжении нескольких лет они же и обеспечивали поддержку. Но, со слов директора, они начали забивать на свою работу, затягивали с выполнением задач или во все игнорировали пожелания по внесению изменений (все это было не бесплатно).
Что из себя представляла на тот момент данная CRM
Это было веб‑приложение, написанное на PHP 5.4, база была на MySQL (версию уже не помню) и какой‑то даже JS присутствовал. По мимо кабинета для сотрудников — реализованы личные кабинеты для каждого ученика, где они могут пополнить свой счет через YooMoney, сделать онлайн запись на вождение или изучить свою финансовую статистику.

Во‑первых, что меня тут больше всего удивляло — разметка страницы представляла собой 3 фрейма:
Верхняя навигация (1-й фрейм);
Левая навигация (2-й фрейм);
Контент (3-й фрейм);
Почему было так задумано — я не знаю и осуждать не собираюсь. Теперь, расскажу о основном функционале и возможностях данного ПО. Ключевые задачи CRM:
Учет учеников по 3-м филиалам, контроль документов;
Ведение учебных групп;
Ведение и учет групп на экзамен;
Финансовый контроль;
График вождения;
Документооборот;
Отчетность;
Первой моей задачей было: устранение дубликатов при одновременной записи на вождение с личного кабинета ученика. Запись на вождение с их стороны производилась так:
Ученик открывает график и выбирает удобное и свободное для себя время. Выбрав его — у него открывается окно подтверждения. После подтверждения записи логика работала так:
если была соответствующая ячейка по времени — делался UPDATE userid
если не было — делался INSERT
И так как у них достаточно большой поток учеников, допускался вариант — учениками одновременного открытия одинаковых окон подтверждения записи. Тем самым — если у обоих учеников был положительный баланс — условие выполнялось — делался UPDATE у одного, а у второго INSERT. И на выходе мы получаем 2 записи учеников в 1 и тоже время, в 1 и ту же дату к одному инструктору.
Я был немного в замешательстве от такой логики построения условий — это первое. Второе — зачем вообще ученикам давать возможность что‑то INSERT‑ить в базу данных, если есть возможность делать UPDATE.
Так как я был на тот момент еще недостаточно компетентен, я придумал еще более уникальное решение — сделал доп. таблицу, в которую вносилась запись при ожидании подтверждения =). Ну то есть, человек выбрал время — сделался INSERT в таблицу, и другие не смогут уже выбрать это время в течении 2–3 минут. И каков был результат? Конечно дубликаты записей сохранились. Люди так же записывались по 2–3 записи на 1 занятие.
Спустя 86400 * 365...
Требования автошколы привели к тому, что необходимо переписать весь функционал ПО, так как у них появляются частные инструктора, которые не должны иметь доступа к основному графику вождения. Так же необходимо было реализовать закрепление ученика за авто/инструктором. Переписал я данное ПО примерно за 3 месяца, так как было достаточно много свободного времени. Затронул фронт, потому что реализация его на тот момент не давала возможности сделать элементарной группировки верхней навигации. В конечном итоге, ПО приобрело новый вид и функционал:

Ничего хорошего из этого не вышло: было куча ошибок, связанных с внутренними нюансами организации, о которых мне просто никто не сказал. Были так же вопросы с генерацией номеров сертификатов о профессии водителя. В общем — написал то, что потом опять пришлось переписывать =), но сделал я это куда более быстрее: примерно за 1,5 месяца.
time();
Сегодня, данное ПО эксплуатируется уже практически около года без нареканий. Выглядит это все таким образом:

И, отгадайте что? Правильно, я опять переписываю данное ПО =) Но, не ттолько из‑за своей некомпетентности, но и еще кое почему.
Безопасность. Ранее я практически не зацикливался на этом, и сейчас я обнаружил кучу дыр в данном приложении — это и инъекции и XSS атаки.
Статика. В ПО практически не используется JS, а тем более jQuery. Так как я обрел больше опыта, необходимо его применять.
Требования организации. Появились новый функционал, который необходимо интегрировать в ПО.
Обновление версии php. Да, я решил переписать ПО на 8.2 с использованием PDO драйвера для mysql.
Оптимизация запросов. Так как я подтянул язык запросов и изучил такие понятия как LEFT JOIN, IN, COUNT — необходимо переписать все запросы.
Использования ООП. Не использовал вообще. Понял, спустя несколько лет что вещь незаменимая, теперь активно применяю.
По крайней мере, отправной точкой решения переписать ПО стало — возможность обучения одного ученика на несколько категорий. То есть, реализовать это можно только интегрируя систему договоров. Учет финансов и всего прочего будет не ученика, а договора — который привязан к самому ученику. А к самому договору уже привязывается и категория, и группа. То есть сколько у учеников будет договоров — столько и балансов. А также, реализовать тарифные планы более статичным — вплоть до статичной суммы списания за занятие у ученика. (один может платить 1200 за час, другой 1600).
В предыдущем релизе, стоимость вождения вносилась в сразу в таблицу с занятиями. Теперь все эти записи будут суммироваться и вычитаться из поступлений. Таким образом будет теперь считаться баланс у учеников — просто одним большим запросом.
На данном этапе, так как из‑за малого количества свободного времени, готовы страницы со списками учебных групп, и список договоров в группе. Ну и фронтик =) как же не без этого. Ужасно раздражает эта синяя полоска с иконками для навигации и вываливающийся левый блок.


Вся таблица на втором изображении формируется всего 1 запросом. Ранее — данная таблица формировалась несколькими, и если в не было порядка 100 элементов, страница могла открываться 10–20 секунд.
Если кому интересно, готов поделиться финальными результатами и результатами миграции и интеграции.
Комментарии (45)
fo_otman
19.02.2025 07:57Если ты бэкендер, занимайся бэком. Не лезь во фронт. Современный веб слишком усложнился со времен jQuery. Сосредоточься на формировании вменяемого REST API для фронта. Пускай заказчик наймет нормального фронта, и тот сделает все по уму. Ты удивишься, когда он тебе расскажет, насколько плохо выполнена фронтовая часть.
dustdevil
А решили-то как? ))))
Очень плохое решение. Достаточно в каждом поступлении/списании хранить еще поле остатка. Потом просто делать SELECT balance FROM table WHERE user_id = ... ORDER BY date DESC LIMIT 1. И не забывать про транзакции.
glu-dimaz Автор
Да все просто. Во первых избавился от INSERT . На моменте формирования администратором графика - все ячейки с временем вносятся в базу. Во вторых, после подтверждения, прям перед update - простая проверка, записан там кто то или нет.
Почему. Просто один запрос считает 3 суммы:
сумма поступлений
сумма списаний
сумма списаний вождения
Там всего 2 таблицы за это отвечают. И расчет суммы от туда - не тратит много времени) но я учту)
Для понимания, раньше стоимость вождения считалась для каждой записи. Ее как физической величины - не было) . Открылась карточка ученика с историей его вождения. И пока она открывается - считается сумма по каждому уроку из таблицы тарификации)
dustdevil
А зачем вы считаете 3 суммы, если можно получить одну запись? ))) А если вам нужно показать пользователю все операции по счету в хронологическом/другом порядке? Вы будете делать простыню из юнионов? В биллинговых системах это обычно хранят как хронологию изменения счета. Пользователь - Причина изменения - Дата - Сумма изменения - Актуальный остаток. Собственно, актуальный остаток последней по времени записи и есть текущий баланс. Иногда добавляются дополнительные поля, типа зарезервированных средств. Да, если у пользователей нет миллионов транзакций, можно не париться, но зачем делать плохо, если можно сделать хорошо?
glu-dimaz Автор
Возможно вы и правы =) я не буду спорить и доказывать. Я считаю, что и ваш и мой метод имеет как плюсы, так и минусы.
FanatPHP
Погодите, а откуда там хоть один юнион, не говоря уже о "простыне"? Ведь ваш список тупо строится по таблице транзакций, которая и так есть у автора? Я могу неправильно понимать структуру таблиц автора, но пока я вижу только одну, из которой всё берётся простым запросом, без всяких юнионов
dustdevil
Если я правильно понял этот сумбур, там списания хранятся отдельно от поступлений, и возможно отдельно списания от списаний вождения. Но тут без гарантии.
FanatPHP
Непонятно, почему такую проверку нельзя было сделать и для INSERT. То есть вы, кажется, так и не поняли своё же решение, выставляя его так, что ключевым является избавление от инсерта, а не проверка перед записью.
Но главное, такая проверка не гарантирует перезаписи при одновременном сохранении формы. Но это пока не горит, а потом узнаете про борьбу с race condition поподробнее.
glu-dimaz Автор
Я в статье указывал. Для чего вообще пользователям оставлять возможность делать INSERT в бд, если можно обойтись через UPDATE из соображений безопастности.
Куда более будет правильнее если пользователь подлюченный к бд со строны клиентов будет иметь право SELECT из некоторых таблиц и update только в 1
FanatPHP
Извините, но это просто чушь какая-то. Судя по всему, в части инъекций там остался весь ламерский код из предыдущей версии, и вы мечетесь, пытаясь применить дебильные советы из интернета, как уменьшить урон, и в частности не давать право на вставку. Хотя этого давно никто не делает за полной бессмысленностью.
Бороться надо с инъекциями, а не с их последствиями.
FanatPHP
Очень смешной комментарий. Звучит как "очень плохо класть зажигалку в карман. Достаточно пришить ещё один". Вам не кажется, что "хранить еще и поле остатка" - это дополнительное действие, которое никак не подходит под формулировку "достаточно"? А достаточно как раз хранить только сами транзакции?
Другое дело, что с дополнительным полем можно будет реализовать атомарный апдейт и избежать двойных списаний без блокировки всей таблицы. Но тогда это должно быть поле в отдельной таблице с уникальным user_id. И делать транзакцию, где сначала идёт апдейт этой таблицы с условием, и роллбек если условие не выполнилось.
dustdevil
Странно... Вообще я не карман новый пришивать предложил, а не выпускать зажигалку из рук, если следовать Вашей терминологии. И нет, хранить поле остатка - это достаточное условие для избавления от дополнительных агрегаций, но не необходимое в данном конкретном проекте. Да, это отход от третьей нормальной формы через добавление избыточности данных, но почему-то делают именно так.
В данной ситуации атомарный апдейт - это отсутствующая величина, по той простой причине, что хронологически связанные данные (как и графы состояний) не апдейтят. Изменение данных происходит только инсертами, и защита от двойных списаний реализуется обычной транзакцией, у которой по умолчанию невозможны грязные чтения, потерянные обновления, а фантомные чтения, при правильной реализации даже местами полезны, поскольку последний выбранный датасет всегда правильный. Что позволяет еще и уровень изоляции снизить для производительности. Если же вы говорите о банальном дабл-клике - достаточно в форму оплаты скрытым полем добавить "код операции" (генерится заранее), добавить это поле в указанную таблицу, повесить на него unique. Но это уже совсем другая история.
FanatPHP
Насколько я понимаю, под "обычной транзакцией" вы имеете в виду полную блокировку таблицы. Поскольку другого варианта залочить под вставку нет. И если сравнивать её с хранением баланса в отдельной таблице (и блокировкой только одной строки в этой таблице), то эта величина хоть и "отсутствующая" но куда более приемлемая в реальности.
dustdevil
Ээээ... А зачем нам блокировать таблицу? Мы же про InnoDB? Почитайте плиз: https://habr.com/ru/articles/238513/
И у нас READ COMMITED идет, напоминаю.
FanatPHP
Мне не очень нравится, что ваша аргументация сводится к общим словам уровня RTFM и ссылке на статью, в которой запрос insert не упоминается ни разу. Если будете трудиться отвечать на этот комментарий, то большая просьба писать только конкретные схемы.
В итоге мне приходится самому от себя получать адекватный фидбек. Получается что да, мы можем, наверное, залочить на чтение не всю таблицу, а только записи данного пользователя, прочитать верхнюю строчку, посмотреть в РНР, не превысит ли новое списание лимит, и если нет - то добавить новую запись и разблокировать остальные. Параллельная вставка не сможет прочитать максимальный баланс, и будет ждать вставки, после которой прочтёт в РНР новый баланс и увидит, что выходит за рамки лимита. То есть опять же, ваше лишнее поле не нужно, кстати. "Необходимость" которого вы, в своей обычной манере, постулировали, но не аргументировали.
При этом "отсутствующая величина" в виде атомарного апдейта позволяет перенести контроль выхода за пределы лимита целиком на уровень базы данных, причём вообще без блокировок. И заодно даёт ваш любимый дополнительный карман.
В целом же мне кажется, что вы путаете транзакции с блокировками. И полагаете, что транзакция - это такая волшебная палочка, которая как-то там сама по себе решает все проблемы - достаточно просто сказать волшебное слово "транзакция". А меня интересует именно конкретика.
dustdevil
Зачем вы собираетесь лочить пользователя, если вам нужно прочитать только одно поле его последней записи (мой вариант)? Причем не сканом по всей таблице, а по индексу? Без агрегаций. При этом, сама запись остается открытой для чтения, и в случае параллельного чтения другой, не пишущей транзакцией, в случае отката нашей, та вернет актуальные данные! Если вы еще не поняли, нам тут блокировки вообще не нужны. И их НЕТ, что не получится при агрегации (ваш вариант)! Нам нужна очередь из транзакций с insert. И если ее организовать другим способом, нам тут вообще и сами транзакции будут не нужны.
Надеюсь так более понятно.
FanatPHP
Вот! Теперь есть
к чему придратьсяконкретика.Ну так об этом и речь. А в случае успешного завершения транзакции прочитанное значение станет невалидным, и позволит выйти из лимита.
А предотвратить это можно только блокировкой на чтение. Причём у нас только два варианта - либо лочить только записи пользователя, что, по вашему "не нужно" - и тогда остаётся лочить всю таблицу. Уровнем ли изоляции, явной ли блокировкой - но суть одна.
Опять у вас общие фразы, не имеющие ничего общего с реальностью. У нас даже близко нет ничего похожего на "просто очередь транзакций с insert". У нас очередь запросов insert с условием. И состояние гонки. Напоминаю - это не просто "хронологически связанные данные", которые льются потоком. А данные, которые добавляются или не добавляются в зависимости от текущего состояния БД.
Опять же, в порядке конструктивного диалога с самим собой: в теории, наверное можно делать и по вашему принципу: валить всё, что пришло, а после вставки считать результат, и если меньше лимита, то откатывать вставку. Но этот вариант мне нравится меньше всех остальных. Да, вероятность получить пополнение между снятиями практически нулевая, но в жизни всякое бывает. И, главное, такой подход исходно выглядит недетерминированным, то есть могут быть и другие проблемы, которых я сейчас не могу сообразить.
dustdevil
А вот теперь от вас чуть больше конкретики: как вы выйдете из лимита, при том что параллельное списание невозможно? Подробно, плиз. Пока это что-то у вас общие слова про "состояние гонки". Непонятно кого с кем и за какой приз.
FanatPHP
А зачем здесь параллельное-то?
Первая транзакция прочитала сумму, и убедилась, что списание не выходит из лимита.
Вторая транзакция по вашему сценарию прочитала сумму, и убедилась, что списание не выходит из лимита.
Первая транзакция добавила строку и закоммитилась.
Вторая транзакция добавила строку и закоммитилась.
Сумма ушла в минус.
Всё строго последовательно.
dustdevil
Отлично. Вы сами себе что-то придумали, а потом начали с этим спорить, требуя конкретики. Ну или просто немного не в курсе темы про транзакции...
Наша транзакция изменения баланса состоит из двух операций - чтения (чтобы проверить возможность списания), и записи (актуализация баланса). На все остальные чтения, параллельные и перпендикулярные, нам плевать.
Операции чтения-записи не коммутативны (non permutable), планировщик никогда не запустит 2 такие транзакции в параллельные процессы. Тем более, профайлер прекрасно видит, что транзакции зависят от одних и тех же данных (даже план выполнения для получения этих данных не будет переделывать, а возьмет из кэша). Вторая транзакция начнет выполнение, только когда произойдет коммит или роллбек первой.
А теперь огромная просьба рассказать, как вы собираетесь добиваться вот этого:
при том, что в данной ситуации я даже не представляю как это технически возможно. У вас либо ПОЛНОСТЬЮ выполняется первая транзакция, и денег на вторую не хватает, либо первая НЕ выполняется, выполняется вторая, первую не перевыполнить. Все списания правомерны, в минус не ушли. И не надо придумывать поведения систем под свои хотелки. Это так не работает, насколько подробно не объясняй первоначальный сценарий.
FanatPHP
Моя невнимательность. Я зевнул слово "не пишушей". можете объяснить, как транзакция в момент открытия понимает, пишущая она, или нет? И в целом, прошу пояснить термин "не пишущая транзакция".
В случае, если такого термина нет, то это и есть ответ на ваш вопрос.
dustdevil
Понимает это не транзакция, а профайлер, который под транзакцию готовит планы выполнения, и отправляет их в кэш. Что делает и с обычными SELECT-запросами. Не пишущая транзакция - транзакция, не изменяющая состояния данных. Да, в мускуле их особо не особо, а вот в Оракле, с его гетерогенными службами, кластерами и остальным богатством - дофига. Как вариант: создание материализованных представлений из удаленных источников, потом селект.
FanatPHP
При чем здесь какой-то проафйлер и кэш? Мы с вами тут говори о транзакциях. И вы явно говорили про транзакции, а не про профайлер.
Вот мы запустили базу.
Вот у нас пошла первая транзакция со времени старта, по совпадению - наша транзакция по списанию денег со счёта. Как ваш профайлер понимает, что она "пишущая"?
Никак не понимает. То есть эти два ваших утверждения прямо противоречат друг другу:
Вам надо сначала определиться, видит вторая транзакция данные, которые прочитала первая, или нет.
И далее, если не видит - то пояснить механизм этого "не видит". Если вторая не видит только одну строку - то почему, за счёт какого механизма БД.
В меня начинает закрадываться очень печальное подозрение, что я общаюсь с ботом.
dustdevil
Читать научитесь собственные сообщения, чтобы не задавать глупых вопросов. Вы задали вопрос - я на него ответил. Потом перечитайте чуть выше, про коммутативные транзакции. Потом вообще почитайте про транзакции, в том числе коммутативные. Последний ответ в рамках сегодняшнего ликбеза: транзакция пишущая, если по результатам есть запись в transaction log. При этом, ничего не мешает завернуть в транзакцию обычный SELECT. Транзакция откроется? Да. Транзакция либо закоммитится, либо роллбек? Да, к примеру - нет такой таблицы. Транзакция была? Была. Изменения есть? Нет. Запись в transaction log есть? Нет. Транзакция не пишущая. И да, в некоторых базах это действительно используется, в том числе для избегания конкурентных запросов.
FanatPHP
Жаль, что вы опять скатились в поучающий тон, но это было закономерно. После того, как вы не смогли объяснить, как транзакция ещё только при открытии узнаёт, что она "пишушая" и не должна давать другим транзакциям читать те данные, которые читает она (что само по себе противоречит другому вашему же утверждению) выбор у вас был невелик - или признать, что вы всё время писали ерунду, или вернуться к надуванию щёк.
dustdevil
Жаль, что вы, вместо того, чтобы попросить посоветовать литературу по архитектуре баз данных, если вам это действительно интересно, начали попытки поймать меня на незнании темы. Причем, задавая вопросы, ответы на которые уже были даны, и оправдываясь "промаргиванием". Ни запрос, ни транзакция - не обладают самосознанием. Они о себе знают приблизительно ничего. Это просто текст. Во что-то осмысленное, такое как план выполнения, который вы можете посмотреть эксплейном, они превращаются после работы профайлера. Не того, который показывает результат SHOW PROFILE FOR QUERY, а того, кто этот результат готовит, и использует на уровне движка. Причем он определяет и порядок выполнения подзапросов, и использование индексов, и первичные ограничения, если страничные данные. И много чего еще, включая оптимизацию запроса. И он сразу поймет, что запрос типа UPDATE Table SET Table.col = Table.col FROM Table - чушь собачья, которая поменяет данные ровно никогда, вернет только результат ОК. И выполнять он его будет... Хотя, лучше посмотрите его explain-он сами. И, если это единственный запрос транзакции - он благополучно поставит этой транзакции статус "коммутативна", что означает "данная транзакция никогда, никак и ни про каких обстоятельствах не будет влиять на результаты других транзакций, можно выполнять параллельно с ними". В Оракле это строго так, в мускуле - не знаю точно, нужно проверять (поскольку UPDATE). Транзакция на чтение-запись - всегда "НЕ коммутативна". По определению. А значит - строго последовательно, если есть хоть малейшее подозрение на влияние на уже выполняемую. Чтение в представленной мной схеме, где данные не изменяются апдейтом никогда, а уровень изоляции стоит в READ COMMITED - всегда коммутативная операция. Результат чтения никак не зависит от выполняющихся транзакций с записью, он всегда возвращает актуальный датасет.
А вот это - просто ваша откровенная ложь. Такого я нигде не утверждал.
FanatPHP
Знаете, вы правы. Я действительно человек тёмный, от сохи. Не в теме про транзакции. Не читал литературу и архитектуру. Мне проще написать скрипт в 10 строчек, чем два экрана умных слов, не имеющих отношения к теме разговора.
Вот, написал. Первый
И второй
Запускаем
(php 1.php &) && php 2.php
Вторая транзакция тут не то что чтения - она даже коммита первой не ждёт.
А вы продолжайте расказывать про профайлер. Это очень, очень познавательно.