GBase 8c序列刷新实战:存储过程解决主键重复冲突 📅 发布时间:2026/9/11 9:43:05 👁 浏览次数: 上周三晚上十一点多测试环境接口突然开始报主键重复日志里一片duplicate key value violates unique constraint。我看了一眼用户表就明白问题了下午刚从生产库同步了一份用户数据到测试环境表里已经躺了 8000 多行记录但序列还停留在建表时的初始值。应用一插入数据序列从 1 开始发号和表里已有主键撞个满怀。这场景做数据库的人应该都不陌生数据导进去了序列没跟上后续 INSERT 全在踩地雷。GBase 8c 作为南大通用基于 openGauss 内核打造的数据库产品序列的底层逻辑和 PostgreSQL 非常接近同时又兼容了一批 Oracle 迁移过来的老用户习惯。我在处理这个问题的过程中把“用存储过程刷新序列值”这套方案完整梳理了一遍也踩了几个不太容易察觉的坑。这篇就记录一下整个过程给同样在使用 GBase 8c 或者其他 openGauss 系数据库的同学做个参考。1. 为什么需要刷新序列迁移后主键撞车是最常见的现场序列值刷新这件事表面看是个很边缘的小操作但真遇到的时候往往都是线上问题在催。根据我自己的经验触发这个需求最频繁的是下面几类场景。1.1 数据迁移和测试库重建表和序列各走各的最典型的就是我开头提到的场景把生产库的数据导出后导入测试库。如果用的工具是直接把数据 INSERT 进去并且目标表设置了显式主键值那么表里的实际数据最大值和序列记录的最新值就脱节了。GBase 8c 里的序列是一个独立数据库对象它不会因为表里插入了大 ID 就自动跟进。只要你没有在数据导入后顺手重置序列下一次应用插入时就会拿到一个已经存在的主键值。我见过不少团队在测试环境反复踩这个坑因为测试环境经常要“灌数”时间一长几乎每个表都会遇到主键冲突。有些人选择手动删除冲突记录有些人让应用捕获唯一键冲突后重试这些方案都有用但都属于亡羊补牢。1.2 批量清理和数据订正序列值被“掏空”了一块还有一种常见情况是表的数据被删掉了一部分。假设原表 ID 已经到 10000删掉了后面 5000 行再插入时序列会从 10001 继续。这本身没问题ID 存在空洞不影响业务。但如果你删的是中间一段、尾部一段而业务上又希望后续插入的 ID 不再和曾经存在的某些数据冲突就需要重新对齐。更麻烦的是数据订正场景手工 UPDATE 或 INSERT 了一些大 ID 的记录直接把序列当前值“甩”在了后面。等到业务程序再插入数据序列还是从旧位置开始发号很快就撞上手工插入的那几条。1.3 为什么选择存储过程而不是手写 SQL有人可能会说刷新序列不就是一句setval(seq, max_id)的事为什么非要写存储过程我最初也是手动执行 SQL 解决的但连续处理几张表之后发现这里面有几个现实问题每张表都要单独算最大值单独写一行 setval步骤重复、容易漏。线上规范要求数据库变更要留痕手动 SQL 很难复用和审计。很多团队有自动化的数据初始化任务希望迁移流程里能自动把序列对齐这就需要把“找最大值 刷新序列”封装成一个可调用的数据库对象。动态拼接 SQL 的参数安全也要处理存储过程里可以用 quote_ident 对标识符做规范化比直接在客户端拼字符串靠谱。所以从第二次遇到这个问题起我就开始写存储过程了。2. GBase 8c 序列的底层行为setval 的第三个参数决定一切写存储过程之前先把 GBase 8c 里序列的基本行为理清楚。这个数据库的序列在语法上兼容 PostgreSQL所以下文这些函数和用法在关系上是一脉相承的但真到生产环境使用时还是有不少细节值得注意。2.1 创建序列与获取值的基本函数GBase 8c 里创建一个常规序列的语法类似这样CREATE SEQUENCE t_user_id_seq INCREMENT BY 1 START WITH 1 NO MINVALUE NO MAXVALUE CACHE 1;取序列值用SELECT nextval(t_user_id_seq); SELECT currval(t_user_id_seq); -- 注意仅限当前会话如果仅想查看序列当前分配位置也可以直接查序列对象的last_value字段SELECT last_value FROM t_user_id_seq;这些语法和 PostgreSQL 一致用过 PG 的人不会有陌生感。但 GBase 8c 也兼容了很多 Oracle 的习惯比如存储过程支持IS/AS写法这就给原来做 Oracle 的人提供了平滑过渡的可能。2.2 setval 的语义is_called 是 true 还是 false 差别很大刷新序列最核心的函数是setval它的完整签名是setval(regclass, bigint, is_called)第三个参数is_called非常重要直接决定下一次nextval返回什么如果设置为true表示“这一次设置的值已经被用掉了”那么下一次nextval返回的是设置值 increment。如果设置为false表示“设置的值还没有被用掉”下一次nextval直接返回设置值本身。我们的目标是把序列对齐到表里的最大 ID 上让下一次插入取到max_id 1所以应该这样调用SELECT setval(t_user_id_seq, 8000, true);调用之后下一次nextval返回 8001。如果把第三个参数漏掉或者写成false下一次nextval直接返回 8000而这个 ID 在表里已经存在了主键冲突立刻复现。这个细节我在第一次测试时就翻过车当时只图省事写了两参数版本结果刷新完成后插入仍然报错。2.3 ALTER SEQUENCE RESTART 与 setval 的差异除了setvalGBase 8c 也支持用ALTER SEQUENCE来重置序列ALTER SEQUENCE t_user_id_seq RESTART WITH 8001;这里要注意和setval的语义区别RESTART WITH n之后下一次nextval返回的就是 n 本身而不是 n1。所以在对齐表内最大 ID 的场景下如果用 RESTART 方法就得写成max_id 1比setval(seq, max_id, true)多一步换算。而且ALTER SEQUENCE在存储过程里做动态执行时会多一层拼接逻辑你需要先算出 max_id再把它加 1 拼到 SQL 里而setval可以直接把 max_id 作为变量传进去。所以我的存储过程方案里首选是setvalRESTART 我更倾向于在手工排查问题时使用。下面这张表可以直观看出两者的差别操作方式设置值下一次 nextval 结果适合场景setval(seq, 8000, true)80008001对齐表内最大 IDsetval(seq, 8000, false)80008000需要精确指定下一个 IDALTER SEQUENCE ... RESTART WITH 800180018001手动重置便于理解和检查2.4 CACHE 参数带来的隐蔽影响序列可以配置缓存比如CREATE SEQUENCE t_user_id_seq CACHE 20;这意味着一个会话首次调用nextval时会一次性从系统表里取 20 个序列值缓存到本地之后直接在内存里分配减少对系统表的访问压力。代价是如果你刷新了序列值但某些会话的本地缓存里还攥着一批旧的大 ID那么这些缓存值依然会被继续用掉。举个例子序列刷新前已经发到 10000会话 A 缓存了 9981 到 10000。现在你把序列刷新到 8000理论上后续应该是 8001 起步但会话 A 下一次nextval还会从本地缓存里吐出一个旧的 9982 之类的值。如果你的表里没有这个 ID它会被正常插入但如果表里有就直接撞冲突。所以刷新序列虽然是一条 SQL 的事但在并发较高的环境中最好选择在业务低峰期操作或者刷新后让应用重新建立数据库连接池尽量让旧的缓存失效。这一点使用 CACHE 较大值的系统尤其要注意。3. 存储过程实战从动态 SQL 到异常兜底的完整实现理解了setval的语义接下来就是封装存储过程。我一开始的想法比较简单传一个表名和一个序列名进去里面算一下最大值再 setval。但真正使用后发现要做成一个“能用、敢用、复用”的过程还得考虑参数设计、标识符安全、空表处理、异常兜底这些层面。3.1 过程签名设计该传哪些参数我最后设计的过程签名是CREATE OR REPLACE PROCEDURE refresh_sequence_for_table( p_table_name TEXT, p_column_name TEXT, p_sequence_name TEXT, p_schema_name TEXT DEFAULT NULL )四个参数各有讲究p_table_name要统计最大值的表名。p_column_name关联序列的主键列名通常是 id。p_sequence_name要刷新的序列名。p_schema_name表所在的 schema。允许为空为空时用当前会话的current_schema()。为什么要单独传p_schema_name因为 GBase 8c 里搜索路径search_path如果没设置好动态 SQL 里不加 schema 前缀很可能会找到错误的表。数据库里同名的表在不同 schema 下完全合法让调用方显式指定 schema 最保险。3.2 动态 SQL 构建quote_ident 不是可选项表名、列名、序列名在 SQL 里都是标识符identifier不能像普通变量那样用占位符绑定只能拼接到 SQL 语句中。拼接字符串就引出了 SQL 注入和标识符大小写两重风险。GBase 8c 提供的quote_ident函数可以把字符串转成带双引号的合法标识符比如quote_ident(t_user)返回t_userquote_ident(T_User)返回T_User。这样有两个好处特殊字符、保留字不会破坏 SQL 结构。大小写严格按传入字符串来避免 GBase 8c 把未加引号的标识符自动转成小写导致动态 SQL 里查不到真实表名。序列名是另一个问题。setval的第一个参数类型是regclass它接受一个文本形式的序列名也可以带 schema 前缀。对于序列名我是先拼好带 schema 的完整名称然后再进行::regclass转换v_full_seq : v_schema || . || p_sequence_name; v_set_result : setval(v_full_seq::regclass, v_max_id, true);注意我不对v_full_seq使用quote_ident。如果传入的序列名本身带 schema 前缀比如other_schema.t_user_id_seq用quote_ident会把整个字符串当成一个标识符来加双引号反而会破坏 schema 与对象名的层级关系。序列名应该用::regclass这种形式按数据库对象名去解析。3.3 完整代码与逐段说明下面是完整的过程定义我加了比较详细的注释。CREATE OR REPLACE PROCEDURE refresh_sequence_for_table( p_table_name TEXT, p_column_name TEXT, p_sequence_name TEXT, p_schema_name TEXT DEFAULT NULL ) AS $$ DECLARE v_max_id BIGINT : 0; v_schema TEXT; v_full_seq TEXT; v_sql TEXT; v_set_result BIGINT; BEGIN -- 1. schema 兜底没有显式传入就用当前默认 schema IF p_schema_name IS NULL THEN v_schema : current_schema(); ELSE v_schema : p_schema_name; END IF; -- 2. 序列名兼容两种写法带 schema 前缀和不带 IF strpos(p_sequence_name, .) 0 THEN v_full_seq : p_sequence_name; ELSE v_full_seq : v_schema || . || p_sequence_name; END IF; -- 3. 查询表当前最大 ID空表时用 COALESCE 兜底为 0 v_sql : SELECT COALESCE(MAX( || quote_ident(p_column_name) || ), 0) FROM || quote_ident(v_schema) || . || quote_ident(p_table_name); EXECUTE v_sql INTO v_max_id; -- 4. 刷新序列第三个参数 true 表示下一次 nextval 从 max_id 1 开始 v_set_result : setval(v_full_seq::regclass, v_max_id, true); RAISE NOTICE 刷新完成: %.% max%, sequence%, 下一次 nextval%, v_schema, p_table_name, v_max_id, v_full_seq, v_set_result 1; END; $$ LANGUAGE plpgsql;几个值得单独说明的地方为什么用COALESCE(MAX(col), 0)如果表是空的MAX返回NULL直接赋值给BIGINT变量会让整体变成 NULL后面 setval 也会报错。用COALESCE兜底成 0空表刷新后序列从 1 开始和序列默认起始值一致逻辑上没问题。为什么v_max_id初始化为 0双重保险。即使 EXECUTE 结果异常也不会出现变量为 NULL 的情况。为什么用RAISE NOTICE做输出存储过程不像查询语句可以直接返回结果集RAISE NOTICE可以把执行结果输出到客户端日志里在 GBase 8c 的 gsql 客户端、第三方工具里都能看到。我特意把“下一次 nextval”的值也打印出来方便调用方直接核对。3.4 调用方式在 gsql 里直接调用CALL refresh_sequence_for_table(t_user, id, t_user_id_seq);如果你所在的 schema 不是当前 schema就显式传第五个参数CALL refresh_sequence_for_table(t_user, id, t_user_id_seq, app);执行成功后服务端会返回类似下面这样的提示NOTICE: 刷新完成: app.t_user max8241, sequenceapp.t_user_id_seq, 下一次 nextval8242看到这个输出基本可以确认刷新已经生效。4. 边界场景的处理策略空表、跨 Schema、权限与并发存储过程能跑通简单的场景不算什么真正考验人的是各种边界情况。我在实际使用中主要遇到过下面几类问题。4.1 空表刷新COALESCE 兜底后的行为确认空表刷新是我测试时第一个想到的边界场景。空表意味着表里没有任何数据序列理论上应该回到初始位置。我的过程里COALESCE(MAX(id), 0)返回 0然后setval(seq, 0, true)下一次nextval返回 1。如果序列的起始值就是 1这是完全正确的。但如果序列定义时用了START WITH 100空表刷新后下一次nextval返回的就是 1而不是 100。这算是一个隐藏问题。对于极少数自定义起始值的序列我的建议是在刷新前先查序列定义或者单独把这种情况作为特例处理不要一概而论。不过绝大多数业务表的主键序列都是从 1 开始的所以这个边界在实际中发生概率很低。4.2 跨 Schema 和大小写问题GBase 8c 的标识符处理沿用了 PostgreSQL 的习惯不加引号的标识符默认转成小写存储。如果你的表和序列创建时用了大写或者驼峰命名在动态 SQL 里必须非常小心。我的过程强制quote_ident如果调用时传入的p_table_name是T_User那么查询语句会变成SELECT COALESCE(MAX(id), 0) FROM app.T_User这样严格匹配了小写列名id和原始驼峰表名T_User。但如果你传的是t_user而实际表名是T_User拼接出来的 SQL 就是SELECT COALESCE(MAX(id), 0) FROM app.t_user这种写法会因为找不到表而报错。所以这类动态 SQL 对外部调用方有一个要求参数必须和数据库对象创建时的原始名称一致。如果你不确定建议先查询系统表确认。4.3 权限要求不只是 EXECUTE 权限存储过程的执行权限只是一层过程体内部的 SQL 同样需要目标对象的底层权限。具体到我们这个场景调用者必须同时满足在目标表上有SELECT权限否则统计MAX(id)会报错。在目标序列上有USAGE或UPDATE权限否则setval执行不了。一个常见问题是存储过程的属主有权限但业务账号只有EXECUTE权限。这取决于数据库的权限模型设置。如果 GBase 8c 的存储过程默认以调用者权限执行类似 Oracle 的 invoker rights那么业务账号自己也必须拥有上述底层权限。如果是以定义者权限执行PG 里常见的 SECURITY DEFINER那么只有过程属主有权限即可。我建议在授权时两条路都打通避免后面排查半天发现是权限不够。更隐蔽的一个权限点是如果你的连接用户使用的是只读账号setval会被拒绝。因为刷新序列本质上是在修改数据库对象状态不是普通 DML只读事务里执行会直接报错。数据迁移后的序列刷新通常应该由具备写权限的账号执行。4.4 并发环境下的刷新风险刷新序列涉及到一个事实把序列的当前值往回拨。如果此时有并发事务正在执行nextval可能会拿到比你设置值更大的旧值导致你刷新后的新值“没有生效”。这背后的机制在 2.4 节提到过CACHE 会让会话缓存一批序列值。即使没有 CACHE高并发下多个事务同时调用nextval也可能在你执行setval的瞬间已经取了旧的大值。对这个问题目前最稳妥的操作建议是在业务低峰期执行序列刷新。对于关键表可以配合业务侧短暂停写。如果应用有连接池刷新后尽量让连接池重建避免旧缓存值残留。我曾经在一个日活较高的系统上直接执行刷新结果发现虽然把序列拨到了最大 ID但随后一小段时间内仍然有应用连接用了旧的缓存序列值导致表中插入了断层 ID。虽然没有造成主键冲突但这种“序列回拨”的现象在审计时很不好看。4.5 事务内执行setval 是可以回滚的很多初学者不知道setval在 GBase 8c 里是事务性的。也就是说如果你在一个事务里执行了setval但事务最终回滚序列的值不会保持刷新后的状态。我记得在 PostgreSQL 早期版本里序列操作是不受事务回滚影响的但现在的主流版本已经能够保证序列变更的事务一致性。这意味着如果你的存储过程会在异常时自动回滚整个调用事务刷新动作也会一起回滚。反过来如果你希望在刷新后立刻确认效果请确保调用方执行 COMMIT而不是把 CALL 放在一个长事务里迟迟不提交。5. 实测复盘三种典型场景的关键验证存储过程写完后我做了三组典型验证分别覆盖正常迁移、空表重置和带 CACHE 的序列。5.1 场景一删除部分数据后刷新我建了一张测试表并插入 100 行数据CREATE TABLE t_user ( id BIGINT PRIMARY KEY, name TEXT ); INSERT INTO t_user SELECT generate_series(1, 100), user_ || generate_series(1, 100);然后手动删除尾部数据模拟真实业务中的“末尾空洞”DELETE FROM t_user WHERE id BETWEEN 50 AND 100;此时表内最大 ID 是 49但序列已经走到 101。调用存储过程CALL refresh_sequence_for_table(t_user, id, t_user_id_seq);执行结果NOTICE: 刷新完成: public.t_user max49, sequencepublic.t_user_id_seq, 下一次 nextval50接着插入一条新数据INSERT INTO t_user(name) VALUES (before_refresh_check); SELECT id FROM t_user ORDER BY id DESC LIMIT 1;结果返回 50说明序列和表数据已经对齐。5.2 场景二空表重置继续在空表上执行TRUNCATE t_user; CALL refresh_sequence_for_table(t_user, id, t_user_id_seq);结果NOTICE: 刷新完成: public.t_user max0, sequencepublic.t_user_id_seq, 下一次 nextval1插入一条数据后 ID 是 1。这个结果符合预期也验证了空表下 COALESCE 兜底的正确性。5.3 场景三带 CACHE 的序列刷新我另外创建了一个带 CACHE 20 的序列并模拟并发连接缓存值的情况。刷新后个别连接的旧缓存值仍然会被使用这个现象我前面已经解释过。对于这种场景我的建议反而是不要在生产环境抱有侥幸心理直接做一个维护窗口处理比事后去查数据断层轻松得多。6. 从这段经历总结的几条数据库操作心得写到这里我的经验已经基本完整了。最后分享几点个人体会不是泛泛的建议都是这次实际操作中真切感受到的。序列刷新不是测试环境的专属需求生产环境同样需要。只要存在数据订正、数据回填、批量导入就一定存在序列落后于表数据的情况。与其每次发生冲突时手忙脚乱地修数据不如提前把这个存储过程沉淀成公共对象哪个表出问题就调用哪一个。永远要把 setval 的第三个参数写清楚。这是整个方案里最容易出错的地方。我见过身边同事在两参数 setval 和三参数 setval 之间反复横跳就是因为语义没吃透。只要记住“true 代表已用掉下一次 1false 代表未用掉下一次原样返回”就不会再混淆。动态 SQL 里的标识符必须做规范化处理。表名、列名这些标识符不能直接字符串拼接quote_ident 是我在这个项目里重点依赖的安全防线。虽然过程看起来多包了一层但在面对不规范命名和潜在注入风险时收益远大于成本。刷新序列前先确认业务低峰期。这一点再怎么强调都不过分。序列回拨带来的并发问题是数据库行为层面的不是写一段代码能绕开的。维护窗口配合短暂停写比事后排查重复键轻松得多。另外我觉得这个存储过程还有进一步扩展的空间比如增加对不同序列的批量处理把多个表和序列的对应关系放进一张配置表让过程按配置批量刷新再比如在刷新前自动对比序列last_value和表MAX值只在差异超过阈值时才执行刷新。如果你也在 GBase 8c 或者 openGauss 系数据库上频繁处理数据迁移不妨试试这个方案再根据自己业务的特点把它改造成更顺手的样子。