在 Postgresql 中环绕后,将主键与循环序列一起使用会导致“重复键违反唯一约束”错误

Using a primary key with a sequence that cycles results in "duplicate key violates unique constraint" errors after wrap around in Postgresql

提问人:Chris Malek 提问时间:4/5/2023 最后编辑:Chris Malek 更新时间:4/12/2023 访问量:525

问:

背景/背景

如果我定义一个序列和一个表,如下所示:

CREATE SEQUENCE public.test_sequence
    CYCLE
    MINVALUE 1
    MAXVALUE 2147483647;

CREATE TABLE public.test_table
(
    id integer NOT NULL DEFAULT nextval('test_sequence'::regclass),
    name text,
    CONSTRAINT test_table_pkey PRIMARY KEY (id)
);

并插入大量值。最终,我将到达序列的末尾,并且由于 CYCLE 关键字,nextval 将返回序列的开头并再次返回 1、2 等。不幸的是,如果我正在插入并且旧记录仍然存在相同的 id,那么我最终会遇到冲突,插入调用将导致“重复键违反唯一约束”错误。我对这个结果感到有些惊讶,因为我做出了明显错误的假设,认为“nextval”稍微更聪明一些,意味着下一个自由的 id 值,这在这种情况下显然更有用。

根据以下线程,似乎我可能不是唯一一个做出这种假设的人,其他人可能会遇到同样的问题:如何避免 nextval() 在包装时与现有条目发生冲突?

显然,此问题有几种可能的解决方法:

  1. 迁移到更大的数据类型,例如 bigint。
  2. 改用 UUID 值。
  3. 将 ON CONFLICT DO NOTHING 子句引入 INSERT。
  4. 将 ON CONFLICT DO UPDATE 子句引入 INSERT。
  5. 添加一个 BEFORE INSERT 触发器以递增序列的 nextval,直到我们到达一个空闲的 nextval。
  6. 等。?

对于那些感兴趣的人,以下文章探讨了上述几个选项之间的差异: 选择 Postgres 主键

出于各种原因,无论好坏,我选择了选项 5 来解决这个问题。如果我们能避免争论哪个是更好的解决方案,我将不胜感激。特别是考虑到最佳选择可能取决于一个人的特殊要求。但是,如果还有其他我上面没有考虑的独特解决方法,我会很感兴趣?

进一步明确我们的要求。我们在嵌入式设备上运行,空间是有限的。我们最近空间用完了,我们才刚刚开始填充我们的主要桌子。因此,任何增加空间使用率的选择都不一定是我们的最佳选择。我们目前对我们的时间没有任何限制,所以我们目前可以在这里承受性能损失。因此,为了满足我们的需要,我们已经选择了备选方案5。对于其他人的要求,上述任何其他选项可能确实是更好的选择。

问题

考虑到我们已经选择了选项 5。实现这一点的最佳方法是什么?

我有兴趣获得有关我为处理这种情况而编写的数据库触发器的反馈。具体说来:

  • 如果有人注意到我使用的锁定解决方案存在并发问题?

  • 如果有更好的方法来编写此触发器?

  • 等。

  • 这种方法有哪些陷阱/脚枪?

    A1 - @Ry- 提出了一个很好的观点,即密钥重用可能会导致一些竞争条件(如果我在这里正确解释的话)。例如,当您有 3 个事务时,一个特定场景是:第一个事务对 id 5 执行 DELETE,第二个事务稍后对 id 5 执行 INSERT,第三个事务尝试对 id 5 执行 UPDATE。根据第三笔交易到达的时间(在其他两笔交易之前或之后),它将更新旧记录或新记录,其中一项可能不是预期的更新。因此,种族和可能的数据损坏。我想这种特殊情况可以通过在执行“更新”(在同一事务中)之前始终执行“SELECT FOR UPDATE”来检查以前的状态来缓解。但是,这显然会产生进一步的性能开销,并有可能使用户忘记这样做。

A2 - @Atmo提出了一个很好的观点,即此函数的性能会受到损害,具体取决于换行后的值的满度(或稀疏程度)。如果没有更多的免费 ID,这将在最坏的情况下达到高潮,如果您正在锁定,您将死锁,或者如果您不锁定,则会无限循环(至少在其他人删除记录之前)。应该注意的是,这个时间惩罚是线性时间,因为它只需要检查每条记录一次,并且只会击中索引而不是记录。此方案的缓解技术包括定期清理(如果可能)以维护表中的条目较少,并检查触发器中的换行以避免死锁/循环。

A3 - 循环/换行 id 值意味着您不能再依赖 id 值进行按时间顺序排序/排序(如果您碰巧这样做的话)。

以下是相关的触发器代码:

CREATE OR REPLACE FUNCTION fix_id_collision()
RETURNS TRIGGER AS $$
DECLARE
  found_id integer;
  column_default text;
BEGIN
  -- Loop until we find a free id value.
  LOOP
    -- Check if the id already exists in the table.
    -- Use row level "FOR UPDATE" locking to hopefully ensure
    -- that concurrent INSERT queries don't receive the same id
    -- and collide.
    EXECUTE format('SELECT id FROM %I.%I WHERE id = %L FOR UPDATE', TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id) INTO found_id;
    IF found_id IS NULL THEN
      RETURN NEW;
    END IF;
    EXECUTE format('SELECT column_default FROM
information_schema.columns WHERE table_schema=%L AND table_name=%L', TG_TABLE_SCHEMA, TG_TABLE_NAME) INTO column_default;
    EXECUTE format('SELECT %s', column_default) INTO NEW.id;
  END LOOP;
END;
$$
LANGUAGE plpgsql;

还有一些代码来为我的数据库中的所有表安装它:

CREATE OR REPLACE FUNCTION install_id_collision_triggers()
RETURNS VOID
AS $$
DECLARE
  tbl_schema text;
  tbl_name text;
BEGIN
  FOR tbl_schema, tbl_name IN SELECT table_schema, table_name FROM information_schema.columns WHERE column_name='id'
  LOOP
    EXECUTE format('DROP TRIGGER IF EXISTS id_collision_trigger_%s ON %I.%I', tbl_name, tbl_schema, tbl_name);
    EXECUTE format('CREATE TRIGGER id_collision_trigger_%s BEFORE INSERT ON %I.%I FOR EACH ROW EXECUTE FUNCTION fix_id_collision()', tbl_name, tbl_schema, tbl_name);
  END LOOP;
END;
$$
LANGUAGE plpgsql;

SELECT install_id_collision_triggers();

PS 我已经知道我可以在最近的 PostgreSQL 版本中执行“CREATE OR REPLACE TRIGGER”,但我碰巧使用旧版本进行测试,所以请原谅额外的步骤。

跟进

@klin在这里提出了一个有趣的观点,即由于数据类型对齐问题,使用 integer 和 bigint/bigserial 数据类型(在 64 位系统上)之间可能没有空间差异,如他的以下帖子中所述: 基准测试:PostgreSQL 上的 bigint vs int 。我运行了一个非常非常基本的测试,并确认在选择整数与 bigint 之间空间使用量只有非常小的增加。在我的情况下,bigint 的空间使用量增加了大约 4%。因此,在选择这种特定方法时,并没有像我最初想象的那样节省空间,我可以建议不要使用这种解决方法。特别是考虑到它引入的数据损坏的可能性。

PostgreSQL 序列 循环 唯一约束 nextval

评论

0赞 Atmo 4/5/2023
不是你问题的答案,但我碰巧写了一个简短的解释,说明为什么一个序列会以这种方式工作,其中包括为什么它不检查表中的现有值。
2赞 klin 4/5/2023
迁移到更大的数据类型,例如 bigint,并希望您永远不会到达终点。一个每秒输入一百万行的大串行 PK 表将在 292471 年后循环。
0赞 Chris Malek 4/5/2023
谢谢@Atmo。这是一个很好的解释。不幸的是,他们的要求与 CYCLE 关键字的使用相冲突。使序列要求与CYCLE要求兼容显然可能会以降低性能为代价。然而,也许有点可悲的是,这两个概念不是“只是一起工作”,而是在用户的脸上爆炸。我认为一些用户可能愿意牺牲额外的性能来换取开箱即用的东西。无论哪种方式都有选择,那就太好了。
0赞 Atmo 4/5/2023
谁的要求有冲突?无论如何,老实说,如果你插入了太多的记录,以至于你做了一个序列循环,那么你所说的这种额外的性能本身就成为一种主要功能。请参阅下面的回答。bigserial
1赞 Ry- 4/7/2023
你无法避免脚枪,这就是重点。当您在重用其 ID 的同时删除一行时(可能会发生这种情况 - 否则您一开始就不会遇到冲突问题),可能会出现一些并发查询,这些查询旨在对已删除的内容进行操作,以对新行进行操作。你可以通过让每个运行查询的人始终牢记这个问题来避免错误,但这很难,后果很糟糕(非常罕见和不确定的静默数据损坏),这就是它成为脚枪的原因。

答:

3赞 Atmo 4/5/2023 #1

就我个人而言,我一直并将始终使用 / 解决这个问题。bigintbigserial

根据文档,为您提供 9,223,372,036,854,775,807 个值。当然,它可能会在某个时候结束,但让我们现实一点。bigserial

让我们大胆想象一下,您的数据库每秒保留 1M 个值。使用它们需要超过 292,271 年的时间。即使你设法每秒使用10亿个值,它仍然有更多的时间把它变成你的曾曾-...-曾曾孙的问题,而不是你的问题。

我看不出数据库可以支持在近 300 年的时间里每秒保存 10 亿条记录。在序列上保留这么多值的唯一可能方法是丢弃其中的 99% 以上,在这种情况下,我会说您的设计存在大问题。

恕我直言,您可以放心地假设一个序列永远不会回绕(只要您不丢弃大量生成的值)。bigint


编辑

正如我所感觉的那样,从问题和这个答案下留下的评论来看,我没有通过,让我评论一下提出的 5 种解决方案:

  1. 与问题中提到的相反,正如我上面解释的那样,序列需要在循环之前插入如此多的记录,以至于它实际上永远不会发生。BIGINT

  2. UUID 可以成为拥有唯一 ID 的解决方案,而无需关心骑自行车。拥有分片数据库是首选的解决方案(如果向用户显示主键)。
    然而,在关于避免主键冲突的 SQL 问题中,数据安全性是偏离主题的,从某种意义上说,即使它比其他解决方案产生更多的冲突,它仍然需要被选择,并且从某种意义上说,使用 UUID 创建的表将“永远不会”有 PK 冲突(实际概率已经在那里得到了回答), 因此不需要改进。
    将答案限制为仅解决冲突并搁置安全性,解决方案 1 也不会循环,更简单,更快。
    ->UUID 应该被排除在外(出于安全原因,这里是题外话)。

  3. 的唯一目的是避免收到错误消息。但是,除了在手动键入查询时获取之外,错误消息只是数据库通知客户端应用程序的一种方式,应被视为一件好事。无需测试插入的记录数,只需根据文档中提供的表捕获并解释错误消息编号即可。
    -> 被排除在外。
    ON CONFLICT DO NOTHINGsyntax error

  4. ON CONFLICT DO UPDATE:与上述评论相同 + 它将破坏可能应该保持不变的现有数据。
    -> 被排除在外。

  5. 该解决方案可以很容易地进行测试。下面的脚本模拟包含 10k 个值的表上的序列冲突。TRIGGER

    CREATE TABLE A (
        ID BIGSERIAL PRIMARY KEY
    );
    INSERT INTO A SELECT generate_series(1,10000);
    /* The sequence cycling back to 1 is simulated here: netval() will return 1 but the table is already filled */
    CREATE OR REPLACE FUNCTION fix_id_collision()
    RETURNS TRIGGER AS $$
    DECLARE
      found_id integer;
      column_default text;
    BEGIN
      -- Loop until we find a free id value.
      LOOP
      -- Check if the id already exists in the table.
      -- Use row level "FOR UPDATE" locking to hopefully ensure
      -- that concurrent INSERT queries don't receive the same id
      -- and collide.
      EXECUTE format('SELECT id FROM %I.%I WHERE id = %L FOR UPDATE', TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id) INTO found_id;
      IF found_id IS NULL THEN
        RETURN NEW;
      END IF;
      RAISE NOTICE 'Id taken %', NEW.id;
      EXECUTE format('SELECT column_default FROM
    information_schema.columns WHERE table_schema=%L AND table_name=%L', TG_TABLE_SCHEMA, TG_TABLE_NAME) INTO column_default;
      EXECUTE format('SELECT %s', column_default) INTO NEW.id;
      RAISE NOTICE 'Trying with id %', NEW.id;
    END LOOP;
    END;
    $$
    LANGUAGE plpgsql;
    CREATE TRIGGER id_collision_trigger_A BEFORE INSERT ON A FOR EACH ROW EXECUTE FUNCTION fix_id_collision();
    

    我添加了一些可以证明并发查询有效的方法。
    第一条评论:我认为锁定记录没有意义,因为它们不会发生任何事情。如果有的话,这样做会阻止同时更新/删除锁定的记录,而其他客户端插入新记录则毫无用处。
    其次,我再说一遍,表中只有 10k 个值(还有 10k 个冲突需要解决),大约需要 30 秒才能完成。在该测试示例上,下一个查询会更快,因为不会再有冲突需要解决,但在这种情况下,没有触发器的序列也同样有效。
    在我上面构建的测试用例之外,在你预计经常发生冲突的情况下,性能非常糟糕,我无法想象表中的更多记录或主键中更长的连续值系列会发生什么。
    -> 被排除在外。
    RAISE NOTICEINSERT INTO A VALUES(default)

冒着重复我的观点的风险:解决方案 1 和解决方案 2 是唯一有效的解决方案。由于解决方案 1 也恰好是最简单、最快的,并且将我的答案限制在 id 冲突(而不是安全性)上,这绝对是正确的方法。

解决方案 5:

  • 尽管最终也解决了冲突,
  • 与解决方案 1 相比,它以处理器时间和从磁盘读取的形式使用多几个数量级的资源,使其速度慢了几个数量级,以至于它觉得查询根本不起作用。
  • 除了序列循环之外,对任何事情都没用:
    • 对于 UUID,即使你不幸地产生了冲突,你最好让万亿次中的 1 次插入失败,而不是在所有插入上执行触发器,
    • 对于自定义生成的值,应改进值的生成方式,以避免在值到达数据库之前发生冲突。

这不是要走的路。

评论

0赞 Chris Malek 4/6/2023
正如我的问题中所描述的。我不想讨论各种变通办法。切换到更大的数据类型显然是一种选择,但这也以增加存储需求为代价。一般来说,存储可能很便宜,但根据开发人员的限制,他们可能仍然会感受到存储限制的紧缩。例如,如果他们在小型嵌入式设备上实现某些东西。;)这取决于他们的要求。
0赞 Atmo 4/6/2023
然后我对解决方案 5 的反馈是:它非常慢。如果你谈论嵌入式设备,你还必须考虑到有限的计算能力和可能有限的电池自主性。恕我直言,使解决方案 5 也不适合他们的事情。向每条记录添加 4 个字节通常意味着仅将表大小增加几个百分点。触发器(尤其是在大型表上)可能会使性能下降 2 倍以上(>慢 100%)。
0赞 Atmo 4/6/2023
如果你有不使用序列的要求,也许你不应该写一个包含序列的表,说你想管理一个序列的周期。您的问题中的所有内容都围绕着您可以为该表使用序列这一事实,因此解决方案是增加其大小,使其超出其循环的任何可能性。我的回答清楚地表明选项 2 是一个有效的解决方案,但它没有提供比选项 1 更好的好处(在您的问题的上下文中),而选项 1 确实提供了比选项 2 额外的好处,所以应该是首选。
0赞 Chris Malek 4/6/2023
你似乎认为速度是唯一重要的要求。如果速度是您的主要要求,那么是的,选项 1 绝对是您最好的选择。如果您更关心安全性,那么选项 2 可能是更好的选择(请参阅我问题中的第 2 个链接以获取他们的解释)。或者,如果您更关心空间,那么选项 5 可能是更好的选择。正如我从一开始就主张的那样,您的选择应该取决于您的个人要求和测试。我的问题不在于哪个是更好的选择
2赞 klin 4/6/2023
@ChrisMalek - 切换到更大的数据类型 (...) 也是以增加存储需求为代价的。没有必要,请参阅 PostgreSQL 上的基准测试:bigint 与 int