事务中原子 SELECT 和 UPDATE 或 INSERT 的 Postgres / SQL 模式

问题描述 投票:0回答:1

我有一个简单的场景,我想自动读取和修改一行的

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
    列不可为空且属于枚举类型(邀请、加入、离开、禁止)。引入一个新的枚举值,永远不应该在这个锁定机制之外使用它,这感觉是错误的。但我需要一些值来创建和锁定该行。有什么想法吗?
sql postgresql transactions locking upsert
1个回答
0
投票

如何正确处理虚拟值?状态列不可为空

不要使用它们。如果这样做,请将其设为可为空并表示缺少

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);
© www.soinside.com 2019 - 2024. All rights reserved.