Ecoando o comentário de @GarryWelding:a atualização do banco de dados não é um local apropriado no código para lidar com o caso de uso descrito. Bloquear uma linha na tabela de usuário não é a solução correta.
Retroceda um passo. Parece que queremos um controle refinado sobre as compras dos usuários. Parece que precisamos de um lugar para armazenar um registro de compras do usuário, e então podemos verificar isso.
Sem mergulhar em um design de banco de dados, vou lançar algumas ideias aqui...
Além da entidade "usuário"
user
username
account_balance
Parece que estamos interessados em algumas informações sobre as compras que um usuário fez. Estou lançando algumas ideias sobre as informações/atributos que podem ser de nosso interesse, sem afirmar que tudo isso é necessário para o seu caso de 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"
Não queremos tentar rastrear todas essas informações no "saldo da conta" de um usuário, especialmente porque pode haver várias compras de um usuário.
Se nosso caso de uso for muito mais simples do que isso, e apenas acompanharmos a compra mais recente de um usuário, podemos registrar isso na entidade do usuário.
user
username
account_balance ("money")
most_recent_purchase
_datetime
_item_service
_amount ("money")
_from_computer/session
E então, com cada compra, podemos registrar o novo saldo_conta e substituir as informações anteriores de "compra mais recente"
Se tudo o que nos importa é evitar várias compras "ao mesmo tempo", precisamos definir isso... isso significa dentro do mesmo microssegundo exato? em 10 milissegundos?
Queremos apenas evitar compras "duplicadas" de computadores/sessões diferentes? E quanto a duas solicitações duplicadas na mesma sessão?
Isso não como eu resolveria o problema. Mas, para responder à pergunta que você fez, se formos com um caso de uso simples - "evitar duas compras em um milissegundo um do outro", e queremos fazer isso em um
UPDATE
de user
tabela Dada uma definição de tabela como esta:
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)
com a data e hora (até o microssegundo) da compra mais recente registrada na tabela do usuário (usando a hora retornada pelo banco de dados)
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
)
Podemos então detectar o número de linhas afetadas pela instrução.
Se obtivermos zero linhas afetadas, então
:user
não foi encontrado ou :money2
era maior que o saldo da conta ou most_recent_purchase_dt
estava dentro de um intervalo de +/- 1 milissegundo de agora. Não podemos dizer qual. Se mais de zero linhas forem afetadas, saberemos que ocorreu uma atualização.
EDITAR
Para enfatizar alguns pontos-chave que podem ter sido esquecidos...
O SQL de exemplo está esperando suporte para segundos fracionários, o que requer MySQL 5.7 ou posterior. Na versão 5.6 e anteriores, a resolução DATETIME era reduzida apenas ao segundo. (Observe a definição da coluna na tabela de exemplo e o SQL especifica a resolução até microssegundos...
DATETIME(6)
e NOW(6)
. A instrução SQL de exemplo está esperando
username
para ser a CHAVE PRIMÁRIA ou uma chave ÚNICA no user
tabela. Isso é observado (mas não destacado) na definição da tabela de exemplo. A instrução SQL de exemplo substitui a atualização de
user
para duas instruções executadas em um milissegundo de cada um. Para teste, altere essa resolução de milissegundos para um intervalo mais longo. por exemplo, altere-o para um minuto. Ou seja, altere as duas ocorrências de
1000 MICROSECOND
para 60 SECOND
. Algumas outras notas:use
bindValue
no lugar de bindParam
(já que estamos fornecendo valores para a instrução, não retornando valores da instrução. Verifique também se o PDO está configurado para lançar uma exceção quando ocorrer um erro (se não formos verificar o retorno das funções do PDO no código) para que o código não esteja colocando o dedo mindinho (figurativo) no canto do nossa boca estilo Dr.Evil "Eu apenas suponho que tudo vai sair como planejado. O quê?")
# 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 para enfatizar um ponto que mencionei anteriormente, "travar a linha" não é a abordagem correta para resolver o problema. E fazer a verificação da maneira que demonstrei no exemplo, não nos diz o motivo da compra não ter sido bem sucedida (fundos insuficientes ou dentro do prazo especificado da compra anterior).