postgres中的候补名单,僵局

waitlist in postgres, deadlock

我正在尝试在 Postgres 中创建等待列表。最小代码:

CREATE TABLE IF NOT EXISTS applies(
    created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    user_id SMALLINT REFERENCES users(id) ON DELETE CASCADE,
    service_id SMALLINT REFERENCES services(id) ON DELETE CASCADE,
    status SMALLINT DEFAULT 0, --0: new 1: waitlisted, 2: applied
    PRIMARY KEY (user_id, service_id)
    -- ...
);

CREATE INDEX ON applies (service_id);

状态很重要,因为我希望能够在用户进入候补名单后通知他们。但是,如果他们根本没有被列入候补名单,就不想根据他们处于第一个 n 位置来通知他们。 服务仅限于给定数量的用户。系统如何决定哪个用户获得服务有多种选择。最简单的是先到先得。这就是它有两个阶段的原因(但它不应该以任何方式改变用例)。

可能的要求是:

  1. 插入:用户 applies/enrolls 服务
    1. 正在插入(user_idservice_idCURRENT TIMESTAMPstate: 0 (new)
    2. a 根据具有相同 service_id
    3. 的 COUNT(*) 将状态更新为 1(等待)或 2(已申请)
  2. 删除:用户取消his/her服务申请
    1. 正在删除给定的应用程序
    2. 如果有人处于候补状态,请移至已申请,并发送相关通知

我的第一次尝试是一个天真的实现。假设服务限制为 10。

1/2 添加:

UPDATE applies
SET status = (
    SELECT CASE WHEN COUNT(*) <= 10 THEN 2 ELSE 1 END
    FROM applies
    WHERE service_id = 7918
    AND created <= '2021-08-16 16:20:34.161274+00:00:00'
)
WHERE user_id = 5070
AND service_id = 7918
RETURNING status

2/2 OnRemove:

SELECT user_id
FROM applies
WHERE status = 1
AND service_id = 7915
ORDER BY created
LIMIT 1

然后(我知道他们可以加入)

UPDATE applies
SET status = 2
WHERE status = 1
AND user_id = 5063
AND service_id = 7915

它适用于顺序测试,但多线程测试显示应用状态超过 10 行的情况。

所以我把他们放在一个以SET TRANSACTION ISOLATION LEVEL SERIALIZABLE开始的交易中,然后是REPEATABLE READ,他们给了我很多ERROR #40001 could not serialize access due to concurrent update。然后与 READ COMMITTED 相同。它比原始版本好多了,但它也以过度应用而告终。

然后我开始在 selects 中使用 FOR NO KEY UPDATE,但它们总是很快就陷入僵局。我在死锁上搜索了很多,但找不到任何有用的东西。

所以我想出了一个版本,其中 OnAdd 和 OnRemove 有非常相似的查询,只是选择 user_id 不同,而我没有添加 FOR UPDATE。我不得不更改 1/1 Insert,因此默认状态已列入候补名单。

添加时:

UPDATE applies
SET status = 2
WHERE service_id = 7860
AND 10 > (
    SELECT COUNT(*)
    FROM (
        SELECT service_id, user_id
        FROM applies
        WHERE service_id = 7860
        AND status = 2
        FOR NO KEY UPDATE
    ) as newstate)
AND user_id = 5012 RETURNING status

删除时:

UPDATE applies
SET status = 2
WHERE service_id = 7863
AND 10 > (
    SELECT COUNT(*)
    FROM (
        SELECT service_id, user_id
        FROM applies
        WHERE service_id = 7863
        AND status = 2
        FOR NO KEY UPDATE
    ) as newstate)
AND user_id = (
    SELECT user_id
    FROM applies
    WHERE service_id = 7863
    And status = 1
    ORDER BY created
    LIMIT 1
)
RETURNING user_id

但是在多线程测试中也死锁了

编辑:

按照下面 的建议,我添加了一列而不是计数。不是单独的 table,而是 services.

里面

我向 OnAdd 和 OnRemove 添加了一个以

开头的事务
SELECT *
FROM services
WHERE id = ?
FOR NO KEY UPDATE

有时在多线程测试中应用不足。所以我加入 Remove 与 OnRemove 在同一个事务中,终于成功了。

根据我对您尝试执行的操作以及数据库工作方式的理解 - 您将需要一个已锁定 OnAdd 的共享资源。

原因是两个同时尝试 'add' 的线程必须竞争共享资源,这样只有一个线程获胜而其他线程出错/失败。您无法使用行数来实现您的目标。

一个解决方案是锁 table:

CREATE TABLE IF NOT EXISTS applies(
    service_id SMALLINT REFERENCES services(id) ON DELETE CASCADE,
    applied_count SMALLINT
);

然后:

  1. 打开事务(如果在过程之外)
  2. 获取 exclusive/write 锁到锁中的服务行 table
  3. 如果 limit/constraint 满足(例如 applied_count < 10)则...
  4. 将“应用”标记为已应用UPDATE applies SET status = 2
  5. 更新锁定table(例如SET applied_count = applied_count + 1
  6. 提交交易