英文:
Concurrent requests transaction to prevent unwanted persistence
问题
我正试图弄清楚如何处理起初看起来是一个“简单”问题的方法。
我有一些 UserAccounts
,它们可以拥有多个 Purchase
,但是业务逻辑规定在 PurchaseState.IDLE
状态下只能有一个 Purchase
(实体上的一个字段)。当首次创建时,purchase
处于 IDLE 状态。
我有一个存储库(repo),其中有一个方法可以确定用户是否已经存在具有给定状态的购买:
boolean existsByPurchaseStateInAndUserAccount_Id(List<PurchaseState> purchaseState, long userAccountId);
经过一些测试和思考,我注意到当两个请求在紧密相邻/同时传递时(即并发问题和/或竞态条件),我可以创建多个购买。
这导致用户账户有两个购买,两者都处于 IDLE 状态。
现在,是否有一种使用 @Transactional 的方法可以导致第二个持久化/事务回滚?
我不确定是否简单地在 @Transactional(isolation=REPEATED_READ)
中包装服务方法会解决问题?
也就是说,SQL 是否会以事务方式处理这个?
我只能猜想这实际上不会有帮助,因为 existsBy 不受 SQL 事务跟踪,因此不会回滚?
唯一真正的解决方案是在方法末尾运行第二个 countBy
查询,以便在符合条件的实体数大于 1 时回滚事务吗?
我仍然觉得这不是一个“完美”的解决办法,也无法完全解决竞态条件/事务问题...
所以服务将看到在两个事务中提交了 2 个实体(尚未提交),但对于 T2,服务可以抛出 RuntimeException 来触发回滚?
抱歉,我一直在阅读有关事务隔离的一些内容,但似乎只适用于如果我正在检查实体的字段值/列,而不是基于“count(*)”查询的返回值的逻辑...
谢谢您的任何启示。
英文:
I am trying to get my head around how to approach what initially seems a "simple" problem.
I have UserAccounts
that can have MANY Purcahse
s BUT business logic dictates can only have one Purchase
in a PurchaseState.IDLE
state (a field on the entity). A purchase
is IDLE when first created.
I have a repo with a method to determine if the user has a purchase with the given states already existing:
boolean existsByPurchaseStateInAndUserAccount_Id(List<PurchaseState> purchaseState, long userAccountId);
I noticed with a bit of testing and thinking I can create more than one purchase when two requests are passed in close proximity/at the same time (i.e. a concurrency issue and/or race condition).
This leads to the user account having two purchases with both having an IDLE state.
I have drawn up a quick diagram to show what I think is happening:
Now, is there a way using @Transactional that would cause the second persistence/transaction to rollback?
I am unsure if simply wrapping the service method in @Transcational(isolation=REPEATED_READ)
would relieve the issue? I.e. is there a way SQL will handle this transactionally?
I can only guess this wouldn't actually help as the existsBy is not tracked by the SQL transaction and therefore wont rollback?
Is the only real solution to run a second countBy
query at the end of the method to rollback the transaction if there is >1 entity fitting the condition? I still don't feel this is "perfect" and fully solve the race condition/TX issue...
So the service will see there are 2 entities being committed across the two transactions (not yet committed) but for T2 the service can throw a RuntimeException to trigger the rollback?
Sorry, I have been reading bits about Transaction isolation but it seems to only be applicable to say if I am checking a field value/column of an entity rather than using logic based on say the return of a "count(*)" query...
Thank you for any enlightenment.
答案1
得分: 4
一个“清晰”的解决方案是创建一个专用的表user_order_pending
,其中包含两列:user_id
和 order_id
(最好都带有外键约束),并在 user_id
上设置唯一约束。然后,在一个事务中,将订单插入到orders
表中,并在users_order_pending
中插入相应的条目。如果两个并发事务尝试同时插入新的待处理订单,只有一个事务会成功,另一个事务会回滚。
如果这种改变过于复杂,还有另一种涉及到mysql
特定解决方案,涉及到一个GENERATED
列。我们创建一个名为is_pending
的新列,它是一个可为空的BOOLEAN
列。然后,我们只在status
列为pending
时将该列的值设置为true
。最后,在user_id
和is_pending
列上设置UNIQUE
约束。一个大致的草图如下所示:
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)
);
在上面的示例中,status
为0
表示pending
。现在让我们测试一下我们的解决方案。首先,在表中插入一条新的记录:
INSERT INTO orders(user_id) VALUES(1);
然后检查结果:
SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
| 1 | 1 | 0 | 1 |
+----+---------+--------+------------+
1 row in set (0.00 sec)
到目前为止都很好。现在让我们尝试为此用户添加另一个订单:
INSERT INTO orders(user_id) VALUES(1);
ERROR 1062 (23000): Duplicate entry '1-1' for key 'orders.unique_user_id_is_pending'
这次插入被拒绝,非常好!现在让我们更新现有的条目,并给它一个不同的状态:
UPDATE orders SET status = 1 WHERE id = 1;
然后再次检查结果:
SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
| 1 | 1 | 1 | NULL |
+----+---------+--------+------------+
1 row in set (0.00 sec)
生成的列已经更新,很棒!现在最后,让我们为user_id 1
插入一个新的条目:
INSERT INTO orders(user_id) VALUES(1);
确实如预期,我们的数据库中为用户有了第二个订单:
SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
| 1 | 1 | 1 | NULL |
| 3 | 1 | 0 | 1 |
+----+---------+--------+------------+
2 rows in set (0.00 sec)
由于约束是基于user_id
和is_pending
,我们可以为其他user_id 2
之类的用户添加新的待处理订单:
INSERT INTO orders(user_id) VALUES(2);
最后一段:
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)
最后:由于约束忽略NULL
值,我们可以将user_id 1
的第二个订单移至非待处理状态:
UPDATE orders SET status=1 WHERE id = 3;
最后再次检查结果:
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)
这种解决方案的好处是,如果数据库处于合法状态(即每个用户最多只有一个“待处理”订单),它可以添加到现有数据库中。新列和约束可以添加到表中,而不会破坏现有代码(除非在上述情况下某些进程无法插入数据,这正是所期望的行为)。
英文:
A "clean" solution would be to create a dedicated table user_order_pending
with two columns: user_id
and order_id
(preferably both with a foreign key constraint) and set a unique constraint on the user_id
. Then, in one transaction, insert both the order into orders
and the corresponding entry in users_order_pending
. If two concurrent transactions would try to insert new pending orders concurrently, only one transaction would succeed, the other one would rollback.
If this change is too complex, there is another mysql
-specific solution involving a GENERATED
column. We create a new column is_pending
, that is a BOOLEAN
and nullable. Then, we set the value of this column to true
if and only if the status
column is pending
. Finally, we set a UNIQUE
constraint on columns user_id
and is_pending
. A rough sketch would look like this:
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)
);
In the example above, a status
of 0
represents pending
. Now let us test our solution. First, we insert a new row in our table:
INSERT INTO orders(user_id) VALUES(1);
and check the results:
SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
| 1 | 1 | 0 | 1 |
+----+---------+--------+------------+
1 row in set (0.00 sec)
So far, so good. Let us try to add another order for this user:
INSERT INTO orders(user_id) VALUES(1);
ERROR 1062 (23000): Duplicate entry '1-1' for key 'orders.unique_user_id_is_pending'
This insert gets rightfully rejected, great! Now let us update the existing entry and give it another status:
UPDATE orders SET status = 1 WHERE id = 1;
Query OK, 1 row affected (0.02 sec)
Rows matched: 1 Changed: 1 Warnings: 0
And again check the result:
SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
| 1 | 1 | 1 | NULL |
+----+---------+--------+------------+
1 row in set (0.00 sec)
The generated column has updated, neat! Now finally, let us insert a new entry for the user with user_id 1
:
INSERT INTO orders(user_id) VALUES(1);
Query OK, 1 row affected (0.01 sec)
And sure enough, we have a second order for our user in database:
SELECT * FROM orders;
+----+---------+--------+------------+
| id | user_id | status | is_pending |
+----+---------+--------+------------+
| 1 | 1 | 1 | NULL |
| 3 | 1 | 0 | 1 |
+----+---------+--------+------------+
2 rows in set (0.00 sec)
Since the constraint is on user_id
and is_pending
, we can add new pending orders for, e.g., 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)
And finally: since the constraint ignores NULL
-values, we can move the second order for user_id 1
into a not-pending state:
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)
The nice thing about this solution is that it can be added to an existing database if the databse is in a legal state, i.e. if there at most one pending
order per user. The new column and the constraint can be added to the table without breaking existing code (save for the fact that some processes may not be able to insert data in the scenario described above, which is the desired behaviour).
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论