提问人:Chris Malek 提问时间:4/5/2023 最后编辑:Chris Malek 更新时间:4/12/2023 访问量:525
在 Postgresql 中环绕后,将主键与循环序列一起使用会导致“重复键违反唯一约束”错误
Using a primary key with a sequence that cycles results in "duplicate key violates unique constraint" errors after wrap around in Postgresql
问:
背景/背景
如果我定义一个序列和一个表,如下所示:
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() 在包装时与现有条目发生冲突?
显然,此问题有几种可能的解决方法:
- 迁移到更大的数据类型,例如 bigint。
- 改用 UUID 值。
- 将 ON CONFLICT DO NOTHING 子句引入 INSERT。
- 将 ON CONFLICT DO UPDATE 子句引入 INSERT。
- 添加一个 BEFORE INSERT 触发器以递增序列的 nextval,直到我们到达一个空闲的 nextval。
- 等。?
对于那些感兴趣的人,以下文章探讨了上述几个选项之间的差异: 选择 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%。因此,在选择这种特定方法时,并没有像我最初想象的那样节省空间,我可以建议不要使用这种解决方法。特别是考虑到它引入的数据损坏的可能性。
答:
就我个人而言,我一直并将始终使用 / 解决这个问题。bigint
bigserial
根据文档,为您提供 9,223,372,036,854,775,807 个值。当然,它可能会在某个时候结束,但让我们现实一点。bigserial
让我们大胆想象一下,您的数据库每秒保留 1M 个值。使用它们需要超过 292,271 年的时间。即使你设法每秒使用10亿个值,它仍然有更多的时间把它变成你的曾曾-...-曾曾孙的问题,而不是你的问题。
我看不出数据库可以支持在近 300 年的时间里每秒保存 10 亿条记录。在序列上保留这么多值的唯一可能方法是丢弃其中的 99% 以上,在这种情况下,我会说您的设计存在大问题。
恕我直言,您可以放心地假设一个序列永远不会回绕(只要您不丢弃大量生成的值)。bigint
编辑:
正如我所感觉的那样,从问题和这个答案下留下的评论来看,我没有通过,让我评论一下提出的 5 种解决方案:
与问题中提到的相反,正如我上面解释的那样,序列需要在循环之前插入如此多的记录,以至于它实际上永远不会发生。
BIGINT
UUID 可以成为拥有唯一 ID 的解决方案,而无需关心骑自行车。拥有分片数据库是首选的解决方案(如果向用户显示主键)。
然而,在关于避免主键冲突的 SQL 问题中,数据安全性是偏离主题的,从某种意义上说,即使它比其他解决方案产生更多的冲突,它仍然需要被选择,并且从某种意义上说,使用 UUID 创建的表将“永远不会”有 PK 冲突(实际概率已经在那里得到了回答), 因此不需要改进。
将答案限制为仅解决冲突并搁置安全性,解决方案 1 也不会循环,更简单,更快。
->UUID 应该被排除在外(出于安全原因,这里是题外话)。的唯一目的是避免收到错误消息。但是,除了在手动键入查询时获取之外,错误消息只是数据库通知客户端应用程序的一种方式,应被视为一件好事。无需测试插入的记录数,只需根据文档中提供的表捕获并解释错误消息编号即可。
-> 被排除在外。ON CONFLICT DO NOTHING
syntax error
ON CONFLICT DO UPDATE
:与上述评论相同 + 它将破坏可能应该保持不变的现有数据。
-> 被排除在外。该解决方案可以很容易地进行测试。下面的脚本模拟包含 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 NOTICE
INSERT INTO A VALUES(default)
冒着重复我的观点的风险:解决方案 1 和解决方案 2 是唯一有效的解决方案。由于解决方案 1 也恰好是最简单、最快的,并且将我的答案限制在 id 冲突(而不是安全性)上,这绝对是正确的方法。
解决方案 5:
- 尽管最终也解决了冲突,
- 与解决方案 1 相比,它以处理器时间和从磁盘读取的形式使用多几个数量级的资源,使其速度慢了几个数量级,以至于它觉得查询根本不起作用。
- 除了序列循环之外,对任何事情都没用:
- 对于 UUID,即使你不幸地产生了冲突,你最好让万亿次中的 1 次插入失败,而不是在所有插入上执行触发器,
- 对于自定义生成的值,应改进值的生成方式,以避免在值到达数据库之前发生冲突。
这不是要走的路。
评论
bigserial