业务反馈一条简单的 INSERT 语句运行了半小时以上仍未结束,且进程未被阻塞。初步排查排除了常见的锁等待问题,通过检查活动会话,发现多个会话的等待事件为 OidGen。
SELECT pid, state, wait_event, wait_event_type, usename,
EXTRACT(EPOCH FROM (now()-query_start)),
substr(query, 0, 150)
FROM pg_stat_activity
WHERE state != 'idle'
AND EXTRACT(EPOCH FROM (now()-query_start)) > 1;
-- 输出显示关键信息:
-- wait_event: OidGen
-- wait_event_type: LWLock
-- query: INSERT INTO public.operation_log (...)
OidGen 等待事件通常与系统对象标识符(OID)的分配有关。当创建新数据库对象(如表、索引)的数量极大时,获取新OID可能变慢。但本次场景是INSERT操作,为何会涉及创建对象?
通过 pstack 抓取其中一个慢查询进程的堆栈,发现调用链清晰指向了Toast处理逻辑:
heap_insert -> heap_toast_insert_or_update -> toast_save_datum -> GetNewOidWithIndex
这表明在插入数据时,由于表存在大字段,触发了 PostgreSQL的Toast机制 以存储溢出数据,而在向Toast表中插入chunk元组时,需要为chunk_id字段(OID类型)生成一个新的、未使用的OID。
-- 查看该表的Toast表结构
\dt+ pg_toast.pg_toast_1143412359
-- 输出包含:
-- Column | Type | Storage
-- ------------+------+---------
-- chunk_id | oid | plain -- 此字段需要唯一OID
-- chunk_seq | integer
-- chunk_data | bytea
在 toast_save_datum 函数中,为新数据分配chunk_id(即OID)的逻辑核心是调用 GetNewOidWithIndex。该函数需要扫描Toast表的索引,以寻找一个未被占用的OID。当Toast表中的数据条目极其庞大时,这个扫描过程会变得非常耗时。
本案例中,问题表的Toast表数据量达到了28亿条:
SELECT count(1) FROM pg_toast.pg_toast_1143412359;
-- count
-- ------------
-- 2882740871
系统OID的最大值约为42亿(2^32)。当可用OID空间紧张时,寻找空闲OID的效率会进一步下降,在极端情况下,甚至可能出现日志告警:“OID candidates have been checked ... times, but no unused OID has been found yet.”
进一步检查表结构发现,该业务表包含了249个字段,且均为text类型。这种设计极易产生大量Toast数据,是导致本次性能问题的根本原因。
解决方案与优化建议
- 清理历史数据:最直接的缓解措施是清理 Toast表中的历史冗余数据,减少数据条目,释放OID空间,从而提升
GetNewOidWithIndex的扫描效率。
- 优化表结构:建议业务侧整改表结构。避免在单表中设计过多(尤其是可变长的)大字段。可以根据业务逻辑,将频繁访问的小字段与不常用的大字段拆分到不同的表中,或考虑使用更合适的存储方式。
总结
本次 INSERT 性能问题并非简单的锁等待,而是由于表设计不合理,导致Toast表膨胀,进而使得为新的Toast数据块分配唯一OID的过程变得极其缓慢。这提醒我们,在设计数据库表结构时,需对可能产生Toast的大字段保持警惕,合理的建模是保障数据库长期稳定高效运行的基础。
参考代码
toast_save_datum 函数逻辑可参阅 PostgreSQL 源码:src/backend/access/common/toast_internals.c