Estoy tratando de entender cómo abordar lo que inicialmente parece un problema "simple".

Tengo UserAccounts que puede tener MUCHOS Purcahse PERO los dictados de la lógica empresarial solo pueden tener un Purchase en un estado PurchaseState.IDLE (un campo en la entidad). Un purchase está INACTIVO cuando se crea por primera vez.

Tengo un repositorio con un método para determinar si el usuario tiene una compra con los estados dados ya existentes:

boolean existsByPurchaseStateInAndUserAccount_Id(List<PurchaseState> purchaseState, long userAccountId);

Me di cuenta de que con un poco de prueba y pensando que puedo crear más de una compra cuando se pasan dos solicitudes muy cerca / al mismo tiempo (es decir, un problema de concurrencia y / o condición de carrera).

Esto lleva a que la cuenta de usuario tenga dos compras y ambas tengan un estado IDLE.

He elaborado un diagrama rápido para mostrar lo que creo que está sucediendo: TX

Ahora, ¿hay alguna forma de usar @Transactional que haría que la segunda persistencia / transacción se revierta? No estoy seguro de si simplemente envolver el método de servicio en @Transcational(isolation=REPEATED_READ) aliviaría el problema? Es decir. ¿Hay alguna forma de que SQL maneje esto transaccionalmente?

Solo puedo suponer que esto en realidad no ayudaría, ya que la transacción SQL no rastrea el existBy y, por lo tanto, ¿no se revertirá?

¿Es la única solución real ejecutar una segunda consulta countBy al final del método para revertir la transacción si hay> 1 entidad que se ajusta a la condición? Sigo sin sentir que esto es "perfecto" y resuelvo completamente el problema de condición de carrera / TX ...

TX2

Entonces, el servicio verá que hay 2 entidades comprometidas en las dos transacciones (aún no comprometidas), pero para T2, ¿el servicio puede lanzar una RuntimeException para activar la reversión?

Lo siento, he estado leyendo bits sobre el aislamiento de transacciones, pero parece que solo es aplicable decir si estoy verificando un valor de campo / columna de una entidad en lugar de usar la lógica basada en, digamos, el retorno de una consulta de "recuento (*)". ..

Gracias por cualquier esclarecimiento.

6
Jcov 23 ago. 2020 a las 00:05

1 respuesta

La mejor respuesta

Una solución "limpia" sería crear una tabla dedicada user_order_pending con dos columnas: user_id y order_id (preferiblemente ambas con una restricción de clave externa) y establecer una restricción única en user_id. Luego, en una transacción, inserte tanto el pedido en orders como la entrada correspondiente en users_order_pending. Si dos transacciones simultáneas intentaran insertar nuevas órdenes pendientes al mismo tiempo, solo una transacción tendría éxito y la otra se revertiría.

Si este cambio es demasiado complejo, hay otra mysql - solución específica que involucra una columna GENERATED. Creamos una nueva columna is_pending, que es BOOLEAN y acepta valores NULL. Luego, establecemos el valor de esta columna en true si y solo si la columna status es pending. Finalmente, establecemos una restricción UNIQUE en las columnas user_id y is_pending. Un boceto aproximado se vería así:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    status SMALLINT NOT NULL DEFAULT 0,
    is_pending BOOLEAN GENERATED ALWAYS AS (
        CASE
            WHEN status = 0 THEN 1
        END
    ),
    CONSTRAINT unique_user_id_is_pending UNIQUE (user_id, is_pending)
);

En el ejemplo anterior, un status de 0 representa pending. Ahora probemos nuestra solución. Primero, insertamos una nueva fila en nuestra tabla:

INSERT INTO orders(user_id) VALUES(1);

Y comprobar los resultados:

SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
|  1 |       1 |      0 |          1 |
+----+---------+--------+------------+
1 row in set (0.00 sec)

Hasta aquí todo bien. Intentemos agregar otro pedido para este usuario:

INSERT INTO orders(user_id) VALUES(1);
ERROR 1062 (23000): Duplicate entry '1-1' for key 'orders.unique_user_id_is_pending'

Este inserto es legítimamente rechazado, ¡genial! Ahora vamos a actualizar la entrada existente y darle otro estado:

UPDATE orders SET status = 1 WHERE id = 1;
Query OK, 1 row affected (0.02 sec)
Rows matched: 1  Changed: 1  Warnings: 0

Y nuevamente verifique el resultado:

SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
|  1 |       1 |      1 |       NULL |
+----+---------+--------+------------+
1 row in set (0.00 sec)

La columna generada se ha actualizado, ¡ordenada! Ahora, finalmente, insertemos una nueva entrada para el usuario con user_id 1:

INSERT INTO orders(user_id) VALUES(1);
Query OK, 1 row affected (0.01 sec)

Y efectivamente, tenemos un segundo pedido para nuestro usuario en la base de datos:

SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
|  1 |       1 |      1 |       NULL |
|  3 |       1 |      0 |          1 |
+----+---------+--------+------------+
2 rows in set (0.00 sec)

Dado que la restricción está en user_id y is_pending, podemos agregar nuevos pedidos pendientes para, por ejemplo, user_id 2:

INSERT INTO orders(user_id) VALUES(2);
Query OK, 1 row affected (0.01 sec)

SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
|  1 |       1 |      1 |       NULL |
|  3 |       1 |      0 |          1 |
|  4 |       2 |      0 |          1 |
+----+---------+--------+------------+
3 rows in set (0.00 sec)

Y finalmente: dado que la restricción ignora los valores de NULL, podemos mover el segundo orden para user_id 1 a un estado no pendiente:

UPDATE orders SET status=1 WHERE id = 3;
Query OK, 1 row affected (0.02 sec)
Rows matched: 1  Changed: 1  Warnings: 0

SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
|  1 |       1 |      1 |       NULL |
|  3 |       1 |      1 |       NULL |
|  4 |       2 |      0 |          1 |
+----+---------+--------+------------+
3 rows in set (0.00 sec)

Lo bueno de esta solución es que se puede agregar a una base de datos existente si la base de datos está en un estado legal, es decir, si hay como máximo un pedido pending por usuario. La nueva columna y la restricción se pueden agregar a la tabla sin romper el código existente (salvo por el hecho de que es posible que algunos procesos no puedan insertar datos en el escenario descrito anteriormente, que es el comportamiento deseado).

4
Turing85 23 ago. 2020 a las 07:31