Powtórzenie komentarza z @GarryWelding:aktualizacja bazy danych nie jest odpowiednim miejscem w kodzie do obsługi opisanego przypadku użycia. Zablokowanie wiersza w tabeli użytkowników nie jest dobrym rozwiązaniem.
Cofnij się o krok. Wygląda na to, że chcemy mieć szczegółową kontrolę nad zakupami użytkowników. Wygląda na to, że potrzebujemy miejsca do przechowywania rejestru zakupów użytkowników, a następnie możemy to sprawdzić.
Bez zagłębiania się w projektowanie bazy danych, przedstawię tutaj kilka pomysłów...
Oprócz jednostki „użytkownik”
user
username
account_balance
Wygląda na to, że interesują nas informacje o zakupach dokonanych przez użytkownika. Przedstawiam kilka pomysłów na temat informacji/atrybutów, które mogą nas zainteresować, nie twierdząc, że są one potrzebne w Twoim przypadku użycia:
user_purchase
username that made the purchase
items/services purchased
datetime the purchase was originated
money_amount of the purchase
computer/session the purchase was made from
status (completed, rejected, ...)
reason (e.g. purchase is rejected, "insufficient funds", "duplicate item"
Nie chcemy próbować śledzić wszystkich tych informacji w „saldzie konta” użytkownika, zwłaszcza że użytkownik może dokonać wielu zakupów.
Jeśli nasz przypadek użycia jest znacznie prostszy i śledzimy tylko najnowszy zakup dokonany przez użytkownika, możemy to zarejestrować w encji użytkownika.
user
username
account_balance ("money")
most_recent_purchase
_datetime
_item_service
_amount ("money")
_from_computer/session
A potem przy każdym zakupie moglibyśmy zarejestrować nowe saldo konta i nadpisać poprzednie informacje o „najnowszym zakupie”
Jeśli wszystko, na czym nam zależy, to zapobieganie wielokrotnym zakupom „w tym samym czasie”, musimy to zdefiniować… czy to oznacza dokładnie tę samą mikrosekundę? w ciągu 10 milisekund?
Czy chcemy tylko zapobiec „zduplikowanym” zakupom z różnych komputerów/sesji? A co z dwoma zduplikowanymi żądaniami w tej samej sesji?
To nie jak rozwiązałbym problem. Ale odpowiadając na pytanie, które zadałeś, jeśli pójdziemy z prostym przypadkiem użycia - "zapobiegnij dwóm zakupom w ciągu milisekundy od siebie", i chcemy to zrobić w UPDATE
user
tabela
Mając taką definicję tabeli:
user
username datatype NOT NULL PRIMARY KEY
account_balance datatype NOT NULL
most_recent_purchase_dt DATETIME(6) NOT NULL COMMENT 'most recent purchase dt)
z datą i godziną (z dokładnością do mikrosekundy) ostatniego zakupu zapisanego w tabeli użytkownika (przy użyciu czasu zwróconego przez bazę danych)
UPDATE user u
SET u.most_recent_purchase_dt = NOW(6)
, u.account_balance = u.account_balance - :money1
WHERE u.username = :user
AND u.account_balance >= :money2
AND NOT ( u.most_recent_purchase_dt >= NOW(6) + INTERVAL -1000 MICROSECOND
AND u.most_recent_purchase_dt < NOW(6) + INTERVAL +1001 MICROSECOND
)
Możemy wtedy wykryć liczbę wierszy, których dotyczy instrukcja.
Jeśli dostaniemy zero wierszy, wtedy albo :user
nie znaleziono lub :money2
była większa niż saldo konta, czyli most_recent_purchase_dt
znajdował się w zakresie +/- 1 milisekundy. Nie możemy powiedzieć, które.
Jeśli dotyczy to więcej niż zera wierszy, wiemy, że nastąpiła aktualizacja.
EDYTUJ
Aby podkreślić niektóre kluczowe punkty, które mogły zostać przeoczone...
Przykładowy SQL oczekuje obsługi ułamków sekund, co wymaga MySQL w wersji 5.7 lub nowszej. W wersji 5.6 i wcześniejszych rozdzielczość DATETIME spadła tylko do sekundy. (Zwróć uwagę na definicję kolumny w przykładowej tabeli, a SQL określa rozdzielczość z dokładnością do mikrosekundy... DATETIME(6)
i NOW(6)
.
Przykładowa instrukcja SQL oczekuje username
być KLUCZEM PODSTAWOWYM lub UNIKATOWYM w user
stół. Jest to odnotowane (ale nie wyróżnione) w przykładowej definicji tabeli.
Przykładowa instrukcja SQL zastępuje aktualizację user
dla dwóch instrukcji wykonanych w ciągu jednej milisekundy siebie nawzajem. Na potrzeby testowania zmień tę rozdzielczość milisekundową na dłuższy interwał. na przykład zmień go na jedną minutę.
Oznacza to, że zmień dwa wystąpienia 1000 MICROSECOND
do 60 SECOND
.
Kilka innych uwag:użyj bindValue
zamiast bindParam
(ponieważ dostarczamy wartości do instrukcji, a nie zwracamy wartości z instrukcji.
Upewnij się również, że PDO jest ustawione tak, aby rzucało wyjątek, gdy wystąpi błąd (jeśli nie zamierzamy sprawdzać powrotu z funkcji PDO w kodzie), aby kod nie umieszczał swojego (obrazowego) małego palca w rogu nasze usta w stylu Dr.Evil „Po prostu zakładam, że wszystko pójdzie zgodnie z planem. Co?”)
# enable PDO exceptions
$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$sql = "
UPDATE user u
SET u.most_recent_purchase_dt = NOW(6)
, u.account_balance = u.account_balance - :money1
WHERE u.username = :user
AND u.account_balance >= :money2
AND NOT ( u.most_recent_purchase_dt >= NOW(6) + INTERVAL -60 SECOND
AND u.most_recent_purchase_dt < NOW(6) + INTERVAL +60 SECOND
)";
$sth = $dbh->prepare($sql)
$sth->bindValue(':money1', $amount, PDO::PARAM_STR);
$sth->bindValue(':money2', $amount, PDO::PARAM_STR);
$sth->bindValue(':user', $user, PDO::PARAM_STR);
$sth->execute();
# check if row was updated, and take appropriate action
$nrows = $sth->rowCount();
if( $nrows > 0 ) {
// row was updated, purchase successful
} else {
// row was not updated, purchase unsuccessful
}
A żeby podkreślić moją wcześniejszą uwagę, „zamknij rząd” nie jest właściwym podejściem do rozwiązania problemu. A wykonanie czeku w sposób, który zademonstrowałem w przykładzie, nie mówi nam, dlaczego zakup się nie powiódł (brak wystarczających środków lub w określonym przedziale czasowym od poprzedniego zakupu).