Pull to refresh

Comments 45

Так как я был на тот момент еще недостаточно компетентен, я придумал еще более уникальное решение — сделал доп. таблицу, в которую вносилась запись при ожидании подтверждения =). Ну то есть, человек выбрал время — сделался INSERT в таблицу, и другие не смогут уже выбрать это время в течении 2–3 минут. И каков был результат? Конечно дубликаты записей сохранились. Люди так же записывались по 2–3 записи на 1 занятие.

А решили-то как? ))))

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

Очень плохое решение. Достаточно в каждом поступлении/списании хранить еще поле остатка. Потом просто делать SELECT balance FROM table WHERE user_id = ... ORDER BY date DESC LIMIT 1. И не забывать про транзакции.

  1. Да все просто. Во первых избавился от INSERT . На моменте формирования администратором графика - все ячейки с временем вносятся в базу. Во вторых, после подтверждения, прям перед update - простая проверка, записан там кто то или нет.

  2. Почему. Просто один запрос считает 3 суммы:

    • сумма поступлений

    • сумма списаний

    • сумма списаний вождения

Там всего 2 таблицы за это отвечают. И расчет суммы от туда - не тратит много времени) но я учту)

Для понимания, раньше стоимость вождения считалась для каждой записи. Ее как физической величины - не было) . Открылась карточка ученика с историей его вождения. И пока она открывается - считается сумма по каждому уроку из таблицы тарификации)

А зачем вы считаете 3 суммы, если можно получить одну запись? ))) А если вам нужно показать пользователю все операции по счету в хронологическом/другом порядке? Вы будете делать простыню из юнионов? В биллинговых системах это обычно хранят как хронологию изменения счета. Пользователь - Причина изменения - Дата - Сумма изменения - Актуальный остаток. Собственно, актуальный остаток последней по времени записи и есть текущий баланс. Иногда добавляются дополнительные поля, типа зарезервированных средств. Да, если у пользователей нет миллионов транзакций, можно не париться, но зачем делать плохо, если можно сделать хорошо?

Возможно вы и правы =) я не буду спорить и доказывать. Я считаю, что и ваш и мой метод имеет как плюсы, так и минусы.

Погодите, а откуда там хоть один юнион, не говоря уже о "простыне"? Ведь ваш список тупо строится по таблице транзакций, которая и так есть у автора? Я могу неправильно понимать структуру таблиц автора, но пока я вижу только одну, из которой всё берётся простым запросом, без всяких юнионов

Там всего 2 таблицы за это отвечают.

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

Во вторых, после подтверждения, прям перед update - простая проверка, записан там кто то или нет.

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

Но главное, такая проверка не гарантирует перезаписи при одновременном сохранении формы. Но это пока не горит, а потом узнаете про борьбу с race condition поподробнее.

Я в статье указывал. Для чего вообще пользователям оставлять возможность делать INSERT в бд, если можно обойтись через UPDATE из соображений безопастности.

Куда более будет правильнее если пользователь подлюченный к бд со строны клиентов будет иметь право SELECT из некоторых таблиц и update только в 1

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

Бороться надо с инъекциями, а не с их последствиями.

Очень плохое решение. Достаточно в каждом поступлении/списании хранить еще поле остатка. 

Очень смешной комментарий. Звучит как "очень плохо класть зажигалку в карман. Достаточно пришить ещё один". Вам не кажется, что "хранить еще и поле остатка" - это дополнительное действие, которое никак не подходит под формулировку "достаточно"? А достаточно как раз хранить только сами транзакции?

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

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

В данной ситуации атомарный апдейт - это отсутствующая величина, по той простой причине, что хронологически связанные данные (как и графы состояний) не апдейтят. Изменение данных происходит только инсертами, и защита от двойных списаний реализуется обычной транзакцией, у которой по умолчанию невозможны грязные чтения, потерянные обновления, а фантомные чтения, при правильной реализации даже местами полезны, поскольку последний выбранный датасет всегда правильный. Что позволяет еще и уровень изоляции снизить для производительности. Если же вы говорите о банальном дабл-клике - достаточно в форму оплаты скрытым полем добавить "код операции" (генерится заранее), добавить это поле в указанную таблицу, повесить на него unique. Но это уже совсем другая история.

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

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

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

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

В целом же мне кажется, что вы путаете транзакции с блокировками. И полагаете, что транзакция - это такая волшебная палочка, которая как-то там сама по себе решает все проблемы - достаточно просто сказать волшебное слово "транзакция". А меня интересует именно конкретика.

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

Зачем вы собираетесь лочить пользователя, если вам нужно прочитать только одно поле его последней записи (мой вариант)? Причем не сканом по всей таблице, а по индексу? Без агрегаций. При этом, сама запись остается открытой для чтения, и в случае параллельного чтения другой, не пишущей транзакцией, в случае отката нашей, та вернет актуальные данные! Если вы еще не поняли, нам тут блокировки вообще не нужны. И их НЕТ, что не получится при агрегации (ваш вариант)! Нам нужна очередь из транзакций с insert. И если ее организовать другим способом, нам тут вообще и сами транзакции будут не нужны.

Надеюсь так более понятно.

Вот! Теперь есть к чему придраться конкретика.

При этом, сама запись остается открытой для чтения, и в случае параллельного чтения другой, не пишущей транзакцией, в случае отката нашей, та вернет актуальные данные!

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

А предотвратить это можно только блокировкой на чтение. Причём у нас только два варианта - либо лочить только записи пользователя, что, по вашему "не нужно" - и тогда остаётся лочить всю таблицу. Уровнем ли изоляции, явной ли блокировкой - но суть одна.

Нам нужна очередь из транзакций с insert. 

Опять у вас общие фразы, не имеющие ничего общего с реальностью. У нас даже близко нет ничего похожего на "просто очередь транзакций с insert". У нас очередь запросов insert с условием. И состояние гонки. Напоминаю - это не просто "хронологически связанные данные", которые льются потоком. А данные, которые добавляются или не добавляются в зависимости от текущего состояния БД.

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

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

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

А зачем здесь параллельное-то?

Первая транзакция прочитала сумму, и убедилась, что списание не выходит из лимита.
Вторая транзакция по вашему сценарию прочитала сумму, и убедилась, что списание не выходит из лимита.
Первая транзакция добавила строку и закоммитилась.
Вторая транзакция добавила строку и закоммитилась.
Сумма ушла в минус.

Всё строго последовательно.

Отлично. Вы сами себе что-то придумали, а потом начали с этим спорить, требуя конкретики. Ну или просто немного не в курсе темы про транзакции...

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

  2. Операции чтения-записи не коммутативны (non permutable), планировщик никогда не запустит 2 такие транзакции в параллельные процессы. Тем более, профайлер прекрасно видит, что транзакции зависят от одних и тех же данных (даже план выполнения для получения этих данных не будет переделывать, а возьмет из кэша). Вторая транзакция начнет выполнение, только когда произойдет коммит или роллбек первой.

  3. А теперь огромная просьба рассказать, как вы собираетесь добиваться вот этого:

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

при том, что в данной ситуации я даже не представляю как это технически возможно. У вас либо ПОЛНОСТЬЮ выполняется первая транзакция, и денег на вторую не хватает, либо первая НЕ выполняется, выполняется вторая, первую не перевыполнить. Все списания правомерны, в минус не ушли. И не надо придумывать поведения систем под свои хотелки. Это так не работает, насколько подробно не объясняй первоначальный сценарий.

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

Моя невнимательность. Я зевнул слово "не пишушей". можете объяснить, как транзакция в момент открытия понимает, пишущая она, или нет? И в целом, прошу пояснить термин "не пишущая транзакция".

В случае, если такого термина нет, то это и есть ответ на ваш вопрос.

Понимает это не транзакция, а профайлер, который под транзакцию готовит планы выполнения, и отправляет их в кэш. Что делает и с обычными SELECT-запросами. Не пишущая транзакция - транзакция, не изменяющая состояния данных. Да, в мускуле их особо не особо, а вот в Оракле, с его гетерогенными службами, кластерами и остальным богатством - дофига. Как вариант: создание материализованных представлений из удаленных источников, потом селект.

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

Вот мы запустили базу.
Вот у нас пошла первая транзакция со времени старта, по совпадению - наша транзакция по списанию денег со счёта. Как ваш профайлер понимает, что она "пишущая"?

Никак не понимает. То есть эти два ваших утверждения прямо противоречат друг другу:

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

Вторая транзакция начнет выполнение, только когда произойдет коммит или роллбек первой.

Вам надо сначала определиться, видит вторая транзакция данные, которые прочитала первая, или нет.

И далее, если не видит - то пояснить механизм этого "не видит". Если вторая не видит только одну строку - то почему, за счёт какого механизма БД.

В меня начинает закрадываться очень печальное подозрение, что я общаюсь с ботом.

как транзакция в момент открытия понимает, пишущая она, или нет?

Читать научитесь собственные сообщения, чтобы не задавать глупых вопросов. Вы задали вопрос - я на него ответил. Потом перечитайте чуть выше, про коммутативные транзакции. Потом вообще почитайте про транзакции, в том числе коммутативные. Последний ответ в рамках сегодняшнего ликбеза: транзакция пишущая, если по результатам есть запись в transaction log. При этом, ничего не мешает завернуть в транзакцию обычный SELECT. Транзакция откроется? Да. Транзакция либо закоммитится, либо роллбек? Да, к примеру - нет такой таблицы. Транзакция была? Была. Изменения есть? Нет. Запись в transaction log есть? Нет. Транзакция не пишущая. И да, в некоторых базах это действительно используется, в том числе для избегания конкурентных запросов.

Жаль, что вы опять скатились в поучающий тон, но это было закономерно. После того, как вы не смогли объяснить, как транзакция ещё только при открытии узнаёт, что она "пишушая" и не должна давать другим транзакциям читать те данные, которые читает она (что само по себе противоречит другому вашему же утверждению) выбор у вас был невелик - или признать, что вы всё время писали ерунду, или вернуться к надуванию щёк.

Жаль, что вы, вместо того, чтобы попросить посоветовать литературу по архитектуре баз данных, если вам это действительно интересно, начали попытки поймать меня на незнании темы. Причем, задавая вопросы, ответы на которые уже были даны, и оправдываясь "промаргиванием". Ни запрос, ни транзакция - не обладают самосознанием. Они о себе знают приблизительно ничего. Это просто текст. Во что-то осмысленное, такое как план выполнения, который вы можете посмотреть эксплейном, они превращаются после работы профайлера. Не того, который показывает результат SHOW PROFILE FOR QUERY, а того, кто этот результат готовит, и использует на уровне движка. Причем он определяет и порядок выполнения подзапросов, и использование индексов, и первичные ограничения, если страничные данные. И много чего еще, включая оптимизацию запроса. И он сразу поймет, что запрос типа UPDATE Table SET Table.col = Table.col FROM Table - чушь собачья, которая поменяет данные ровно никогда, вернет только результат ОК. И выполнять он его будет... Хотя, лучше посмотрите его explain-он сами. И, если это единственный запрос транзакции - он благополучно поставит этой транзакции статус "коммутативна", что означает "данная транзакция никогда, никак и ни про каких обстоятельствах не будет влиять на результаты других транзакций, можно выполнять параллельно с ними". В Оракле это строго так, в мускуле - не знаю точно, нужно проверять (поскольку UPDATE). Транзакция на чтение-запись - всегда "НЕ коммутативна". По определению. А значит - строго последовательно, если есть хоть малейшее подозрение на влияние на уже выполняемую. Чтение в представленной мной схеме, где данные не изменяются апдейтом никогда, а уровень изоляции стоит в READ COMMITED - всегда коммутативная операция. Результат чтения никак не зависит от выполняющихся транзакций с записью, он всегда возвращает актуальный датасет.

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

А вот это - просто ваша откровенная ложь. Такого я нигде не утверждал.

Знаете, вы правы. Я действительно человек тёмный, от сохи. Не в теме про транзакции. Не читал литературу и архитектуру. Мне проще написать скрипт в 10 строчек, чем два экрана умных слов, не имеющих отношения к теме разговора.

Вот, написал. Первый

<?php
require 'pdo.php';
if (!$pdo->query("show tables like 'transtest'")->fetch()) {
    $pdo->query("create table transtest (id int primary key auto_increment, user_id int, amount int)");
    $pdo->query("insert into transtest values (1, 1, 100), (2, 1, -50)");
}
$pdo->query("BEGIN");
$amount = $pdo->query("select sum(amount) from transtest where user_id=1")->fetchColumn();

sleep(10);

$sum = -30;
if ($amount + $sum >= 0) {
    $pdo->prepare("insert into transtest (user_id, amount) values (1, ?)")->execute([$sum]);
}
$pdo->query("COMMIT");

И второй

<?php
require 'pdo.php';
$pdo->query("BEGIN");
$amount = $pdo->query("select sum(amount) from transtest where user_id=1")->fetchColumn();
$sum = -40;
if ($amount + $sum >= 0) {
    $pdo->prepare("insert into transtest (user_id, amount) values (1, ?)")->execute([$sum]);
}
$pdo->query("COMMIT");

Запускаем (php 1.php &) && php 2.php

Вторая транзакция тут не то что чтения - она даже коммита первой не ждёт.

А вы продолжайте расказывать про профайлер. Это очень, очень познавательно.

Если ты бэкендер, занимайся бэком. Не лезь во фронт. Современный веб слишком усложнился со времен jQuery. Сосредоточься на формировании вменяемого REST API для фронта. Пускай заказчик наймет нормального фронта, и тот сделает все по уму. Ты удивишься, когда он тебе расскажет, насколько плохо выполнена фронтовая часть.

В ваших словах возможно и есть правда. Но, для чего использовать тот же реакт? Да, может тот же jquery устарел, но это работает. Все получается достаточно динамическим. За те деньги, что они за нее платят - считаю более чем достаточно. Да, для себя было бы неплохо разобраться в новых тенденциях построения фронта, но пока так. Спасибо за отзыв 👍🏻

Пускай заказчик наймет нормального фронта

Это совет из серии "Лучше быть здоровым и богатым, чем бедным, но больным". Давайте вы возьмёте на себя миссию объяснить заказчику, что затраты на проект надо помножить на два? А уже потом тот наймёт фронта, который расскажет автору как всё плохо.

Однажды я наслушался таких советов и затащил REST API с реактом в простой php проект, который также пилился спокойно в одного. Хотя изначально знал, что там хватит и шаблонизатора любого с jQuery. Полезно ли это было лично для меня? Однозначно да, опыт отличный. Нужно ли это было в проекте и заказчику? Однозначно нет. Сложность повысили, время на любые доработки увеличили. Если я брошу поддержку, то заказчику придётся теперь вместо одного пыхаря или фулстека искать, или всё же двоих, бэка и фронта.

Статья вызывает смешанные чувства. С одной стороны, ещё года два назад такое сочинение пятиклассника "как я провёл этим летом" выпинали бы в чулан in no time. С другой - поляна настолько обезлюдела, что надо поддерживать и такие ростки.

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

Чисто для информации:

  • активная поддержка РНР 8.2 уже завершена. Если переписывать сейчас, то под 8.4. Хотя конечно там для вас разницы, пожалуй нету, но всё равно целевой версией надо ставить текущую.

  • Переписывать на PDO смысл сомннительный. Разница между PDO и mysqli минимальная. Единственное, принципиальное отличие - в mysqli нет именованных плейсхолдеров, вида :name, а только знаки вопроса. Если это для вас принципиально - то переписывайте конечно. Хотя я бы пока сосредоточился на более важных вещах. А потом уже при следующей итерации сразу перешел на какой-нибудь ОRM

Но если говорить в целом, то главное что вы довели этот проект до конца. Это самое важное. А опыт приложится.

Благодарю за идею. Хорошая идея написать о том, какой была это CRM ранее. Если найду - займусь!

p.s. - ранее использовалась не mysqli, а mysql

А, то есть версия РНР так и осталась 5.4? Ну тогда флаг в руки, действительно придётся попотеть даже поднимая до 8.2.

Вы так ничего и не поняли. про старый говнокод писать вообще совсем не нужно. Если писать - то про свой нынешний. В котором, судя по всему, у вас там в полный ад. И вы бы получили в сто раз больше фидбека, показав реальный код, а не картинки.

Но вообще, судя по всему, вам не на хабр, а на Тостер (https://qna.habr.com/). Описывать свои идеи по улучшению и сразу получать фидбек на тему почему так делать не надо.

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

Раз переписывать, то почему бы не взять Ларавель + HTMX ? Закроет тучу проблем со всякими sql-иньекциями, упростит crud-операции.

Хороший вариант, но это будет уже следующий этап, до которого автору надо дорасти. Он только-только ООП начал осваивать. Пусть велосипедит текущую версию - и в процессе как раз дозреет до использования фреймворков. А если сейчас его посадить на Лару, то получится просто очередная обезьяна за пишмашинкой.

В вакууме учить ООП опасно - можно прийти к Фабрике Фабрик и чрезмерному СОЛИДу. А в Ларе есть и документация, и туториалы, и сниппеты, и практики хорошие.

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

На самом деле, статья - лучшая реклама всяких "страшных" TDD, DDD, SOLID и т.д. А то чуть поменялись требования - а давайте-ка перепишем всё ПО.

Указано, что по мимо требований: переход с 5.4 на 8.1, использование mysqli вместо mysql, использование ООП, jquery. С момента предидущей версии, прошло более года. Раньше знаний у меня было меньше... Если есть возможность сейчас исправить свои косяки - почему ей не воспользоваться? Чож вы все такие злые критики...

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

Вcё верно, хорошая реклама. Прямо становление программиста в прямом эфире :)

Со временем дойдёт и до всех этих ваших страшных слов. Перепрыгнуть всё равно не получится.

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

Sign up to leave a comment.

Articles