sql >> Base de Datos >  >> RDS >> Mysql

Transacción PHP PDO Duplicación

Haciéndose eco del comentario de @GarryWelding:la actualización de la base de datos no es un lugar apropiado en el código para manejar el caso de uso que se describe. Bloquear una fila en la tabla de usuarios no es la solución correcta.

Retrocede un paso. Parece que queremos un control detallado sobre las compras de los usuarios. Parece que necesitamos un lugar para almacenar un registro de las compras de los usuarios, y luego podemos comprobarlo.

Sin sumergirme en el diseño de una base de datos, voy a lanzar algunas ideas aquí...

Además de la entidad "usuario"

user
   username
   account_balance

Parece que estamos interesados ​​en cierta información sobre las compras que ha realizado un usuario. Estoy lanzando algunas ideas sobre la información/atributos que podrían ser de nuestro interés, sin afirmar que todos estos sean necesarios para su 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"

No queremos intentar rastrear toda esa información en el "saldo de la cuenta" de un usuario, especialmente porque puede haber varias compras de un usuario.

Si nuestro caso de uso es mucho más simple que eso, y solo hacemos un seguimiento de la compra más reciente de un usuario, entonces podríamos registrar eso en la entidad del usuario.

user
  username 
  account_balance ("money")
  most_recent_purchase
     _datetime
     _item_service
     _amount ("money")
     _from_computer/session

Y luego, con cada compra, podríamos registrar el nuevo saldo de cuenta y sobrescribir la información anterior de "compra más reciente"

Si lo único que nos importa es evitar compras múltiples "al mismo tiempo", debemos definir que... ¿eso significa exactamente dentro del mismo microsegundo? dentro de 10 milisegundos?

¿Solo queremos evitar compras "duplicadas" desde diferentes computadoras/sesiones? ¿Qué pasa con dos solicitudes duplicadas en la misma sesión?

Esto es no como solucionaria el problema. Pero para responder a la pregunta que hizo, si vamos con un caso de uso simple:"evitar dos compras con una diferencia de un milisegundo", y queremos hacer esto en un UPDATE de user mesa

Dada una definición de tabla 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)

con la fecha y hora (hasta el microsegundo) de la compra más reciente registrada en la tabla de usuarios (utilizando la hora devuelta por la base de datos)

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 
           )

Entonces podemos detectar el número de filas afectadas por la declaración.

Si tenemos cero filas afectadas, entonces :user no se encontró, o :money2 era mayor que el saldo de la cuenta, o most_recent_purchase_dt estaba dentro de un rango de +/- 1 milisegundo de ahora. No podemos decir cuál.

Si se ven afectadas más de cero filas, sabemos que se produjo una actualización.

EDITAR

Para enfatizar algunos puntos clave que podrían haberse pasado por alto...

El SQL de ejemplo espera soporte para fracciones de segundo, lo que requiere MySQL 5.7 o posterior. En 5.6 y versiones anteriores, la resolución de DATETIME solo se reducía al segundo. (Observe la definición de la columna en la tabla de ejemplo y SQL especifica la resolución en microsegundos... DATETIME(6) y NOW(6) .

La instrucción SQL de ejemplo espera username ser la CLAVE PRIMARIA o una clave ÚNICA en el user mesa. Esto se indica (pero no se resalta) en la definición de la tabla de ejemplo.

La instrucción SQL de ejemplo anula la actualización de user para dos declaraciones ejecutadas dentro de un milisegundo el uno del otro. Para probar, cambie esa resolución de milisegundos a un intervalo más largo. por ejemplo, cámbielo a un minuto.

Es decir, cambie las dos apariciones de 1000 MICROSECOND a 60 SECOND .

Algunas otras notas:use bindValue en lugar de bindParam (ya que estamos proporcionando valores a la declaración, no devolviendo valores de la declaración.

También asegúrese de que PDO esté configurado para lanzar una excepción cuando ocurra un error (si no vamos a verificar el retorno de las funciones de PDO en el código) para que el código no ponga su dedo meñique (figurativo) en la esquina de nuestra boca estilo Dr.Evil "Simplemente asumo que todo irá según lo planeado. ¿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
}

Y para enfatizar un punto que mencioné anteriormente, "bloquear la fila" no es el enfoque correcto para resolver el problema. Y hacer la verificación de la manera que demostré en el ejemplo, no nos dice la razón por la cual la compra no tuvo éxito (fondos insuficientes o dentro del plazo especificado de la compra anterior).