Mysql
 sql >> Base de Dados >  >> RDS >> Mysql

Duplicação de transação PHP PDO


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).