我有一个简单的场景,我想自动读取和修改一行的
state
。但该行可能还不存在。
在此示例中,我使用
user_group_membership
表:
user_id (pk) | group_id (pk) | state
-------------------------------------
1 | 3 | joined
1
是组3
的成员,状态为joined
(也可以是invited
或left
或banned
)。2
不是组3
的成员。由于表中没有行,因此从未成为该组的成员state
值的工作原理类似于状态机。有一组有限的转换:
null (no row present) -> invited, banned
invited -> joined, banned
joined -> left, banned
left -> invited, banned
banned -> invited, left
如果一行已经存在,我可以使用
SELECT ... FOR UPDATE
来获取当前的 state
,验证转换,更新 state
并提交事务。所有其他并发事务将“等待”锁被释放。没关系。在这种情况下,所有 state
转换都按顺序运行。
但是如果表中没有行,则没有任何内容可以锁定。因此所有并发事务都会尝试执行
INSERT
。第一个会成功,其余的会因为重复的主键而失败。
此时我可以“重新运行”整个代码,因为现在我知道该行存在并且它将使用
SELECT ... FOR UPDATE
进行锁定/等待。但我不想两次执行相同的代码。我正在寻找更优雅的解决方案。
这是
SELECT ... FOR UPDATE
的替代品:
INSERT INTO user_group_membership (user_id, group_id, state)
VALUES (2, 3, 'DUMMY_FOR_THE_ROW_LOCK')
ON CONFLICT (user_id, group_id) DO UPDATE
SET user_id = EXCLUDED.user_id
RETURNING *;
-- application code for validating state transition
UPDATE user_group_membership
SET state = 'INVITED'
WHERE user_id = 2 AND group_id = 3;
这应该可以防止多个并发事务尝试
INSERT
并会遇到重复键错误的情况。
DO UPDATE
部分基本上是无操作的,但似乎有必要让RETURNING
正常工作。这有效地取代了 SELECT
。
state
列不可为空且属于枚举类型(邀请、加入、离开、禁止)。引入一个新的枚举值,永远不应该在这个锁定机制之外使用它,这感觉是错误的。但我需要一些值来创建和锁定该行。有什么想法吗?如何正确处理虚拟值?状态列不可为空
不要使用它们。如果这样做,请将其设为可为空并表示缺少
user
-group
与 state is null
关系。
这是处理这种情况的正确方法吗?
“安全”吗?
有更好/更简单的解决方案吗?
如果有效,那就有效。这不是不安全,而是魔法/虚拟/标志/行程/流氓/信号/哨兵值不是很优雅。
使选择、验证和更新插入成为一个操作:
prepare find_validate_apply(int,int,text) as
with find as(
select state
from user_group_membership as f
where $1=user_id and $2=group_id
for update of f
--limit 1--unncessary given the uniqueness and non-nullability
),empty_as_null as(--`coalesce()` for rows
(select state from find)
union(select null)
order by 1 nulls last limit 1
),validate as(
select exists(select from allowed_transitions as t
where t.source_state is not distinct from found.state
and t.target_state is not distinct from $3)
as is_transition_allowed
from empty_as_null as found
),apply as(
insert into user_group_membership
select $1,$2,$3
from validate
where is_transition_allowed
on conflict(user_id,group_id)do update
set state=$3
returning *)
select*from apply;
现在每个工人都可以等待其他人完成他们的整个操作。
allowed_transitions
的想法是不言自明的:
create table allowed_transitions(source_state,target_state)
as values
(null,'invited'),(null, 'banned')
,('invited','joined'),('invited','banned')
,('joined','left'),('joined','banned')
,('left','invited'),('left','banned')
,('banned','invited'),('banned','left');
alter table allowed_transitions
add constraint uniq unique(source_state,target_state);