Facendo eco al commento di @GarryWelding:l'aggiornamento del database non è un posto appropriato nel codice per gestire il caso d'uso descritto. Bloccare una riga nella tabella utente non è la soluzione giusta.
Eseguire il backup di un passaggio. Sembra che vogliamo un controllo granulare sugli acquisti degli utenti. Sembra che abbiamo bisogno di un posto dove archiviare un record degli acquisti degli utenti e quindi possiamo verificarlo.
Senza immergermi nella progettazione di un database, ho intenzione di lanciare alcune idee qui...
Oltre all'entità "utente"
user
username
account_balance
Sembra che siamo interessati ad alcune informazioni sugli acquisti effettuati da un utente. Sto lanciando alcune idee sulle informazioni/attributi che potrebbero interessarci, senza affermare che sono tutti necessari per il tuo caso d'uso:
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"
Non vogliamo provare a tenere traccia di tutte queste informazioni nel "saldo dell'account" di un utente, soprattutto perché possono esserci più acquisti da un utente.
Se il nostro caso d'uso è molto più semplice di quello, e dobbiamo solo tenere traccia dell'acquisto più recente da parte di un utente, potremmo registrarlo nell'entità utente.
user
username
account_balance ("money")
most_recent_purchase
_datetime
_item_service
_amount ("money")
_from_computer/session
E poi, ad ogni acquisto, potremmo registrare il nuovo account_balance e sovrascrivere le informazioni precedenti sull'"acquisto più recente"
Se tutto ciò che ci interessa è prevenire più acquisti "contemporaneamente", dobbiamo definire che... significa entro lo stesso esatto microsecondo? entro 10 millisecondi?
Vogliamo solo impedire acquisti "duplicati" da computer/sessioni differenti? Che ne dici di due richieste duplicate nella stessa sessione?
Questo non come risolverei il problema. Ma per rispondere alla domanda che hai posto, se andiamo con un semplice caso d'uso:"impedire due acquisti entro un millisecondo l'uno dall'altro", e vogliamo farlo in un UPDATE
di user
tabella
Data una definizione di tabella come questa:
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)
con la data e ora (fino al microsecondo) dell'ultimo acquisto registrato nella tabella utenti (utilizzando l'ora restituita dal database)
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
)
Possiamo quindi rilevare il numero di righe interessate dall'istruzione.
Se otteniamo zero righe interessate, allora o :user
non è stato trovato o :money2
era maggiore del saldo del conto o most_recent_purchase_dt
era entro un intervallo di +/- 1 millisecondo al momento. Non possiamo dire quale.
Se sono interessate più di zero righe, allora sappiamo che si è verificato un aggiornamento.
MODIFICA
Per sottolineare alcuni punti chiave che potrebbero essere stati trascurati...
L'esempio SQL prevede il supporto per una frazione di secondo, che richiede MySQL 5.7 o successivo. In 5.6 e precedenti, la risoluzione DATETIME era ridotta solo al secondo. (Nota la definizione della colonna nella tabella di esempio e SQL specifica la risoluzione fino al microsecondo... DATETIME(6)
e NOW(6)
.
L'istruzione SQL di esempio prevede username
essere la CHIAVE PRIMARIA o una chiave UNICA nel user
tavolo. Questo è notato (ma non evidenziato) nella definizione della tabella di esempio.
L'istruzione SQL di esempio sovrascrive l'aggiornamento di user
per due istruzioni eseguite entro un millisecondo di ciascun altro. Per il test, cambia la risoluzione in millisecondi con un intervallo più lungo. ad esempio, cambialo in un minuto.
Cioè, cambia le due occorrenze di 1000 MICROSECOND
a 60 SECOND
.
Qualche altra nota:usa bindValue
al posto di bindParam
(dal momento che stiamo fornendo valori all'istruzione, non restituendo valori dall'istruzione.
Assicurati anche che PDO sia impostato per generare un'eccezione quando si verifica un errore (se non controlleremo il ritorno dalle funzioni PDO nel codice) in modo che il codice non metta il suo mignolo (figurativo) nell'angolo di la nostra bocca in stile Dr.Evil "Presumo solo che andrà tutto secondo i piani. Cosa?")
# 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
}
E per sottolineare un punto che ho sottolineato in precedenza, "bloccare la riga" non è l'approccio giusto per risolvere il problema. E facendo il controllo come ho mostrato nell'esempio, non ci dice il motivo per cui l'acquisto non è andato a buon fine (fondi insufficienti o entro il periodo di tempo specificato dall'acquisto precedente.)