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