ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PostgreSQL触发器性能排查:用pg_stat_user_functions量化隐形开销

PostgreSQL触发器性能排查:用pg_stat_user_functions量化隐形开销

在数据库运维里,最容易被低估的性能隐患往往不是某条慢SQL,而是那些挂在DML语句背后偷偷执行的触发器。一条UPDATE orders SET status=2 WHERE id=10086,执行计划显示就一行更新,可实际产生的内部SQL可能串出了四五条INSERT、SELECT和DELETE。应用日志里看不到,慢查询日志里也常常找不到,等业务方反馈“批量更新从几秒变成几分钟”时,很多人第一反应是加索引、调参数,折腾一圈才发现元凶是触发器。今天我想聊的就是怎么给这些隐形动作做量化体检,主角是PostgreSQL自带的pg_stat_user_functions。

这篇文章适合三类人:需要定期给数据库做性能体检的DBA,在触发器里写过业务逻辑的后端开发,以及被线上批量任务拖垮却找不到原因的运维同学。我会从触发器为什么容易成为性能黑洞讲起,先让你明白为什么需要独立的函数级统计,然后拆解pg_stat_user_functions的统计口径和开关配置,再给出一套可以照抄的“触发器性能评估四步法”,最后用一个实际项目复盘演示整个定位、优化、复测的过程。全程都是可直接运行的SQL和操作步骤,读完你应该能立刻在自己库上跑一遍。

1. 触发器为什么容易成为性能黑洞

1.1 触发器的隐式放大效应

触发器本质上是一个“被动的函数调用”:你对某张表执行INSERT、UPDATE、DELETE时,PostgreSQL在事务的特定节点替你调用一个预设函数。问题在于,这个函数内部又可以执行更多SQL,于是单条应用层SQL会被放大成一组内部SQL。

举个最常见的例子:

UPDATE orders SET status = 2 WHERE id = 10086;

EXPLAIN ANALYZE看这条语句,执行计划整洁得很,一行更新,耗时可能只有0.2毫秒。但如果orders上挂了三个FOR EACH ROW触发器:

  • 触发器A:往审计表orders_audit写一条变更记录
  • 触发器B:更新订单统计表order_stat
  • 触发器C:删除缓存表order_cache里的相关记录

那么这条UPDATE实际会附带至少3条INSERT/UPDATE/DELETE,如果每个触发器里还做了SELECT判断,那内部SQL条数可能翻倍。写代码的人只提交了一条UPDATE,数据库却替你干了一整套活儿。

这种放大在批量任务里尤其恐怖。应用端循环10000次UPDATE,每行只更新一个字段,看起来是10000条SQL。但加上触发器,数据库实际执行的是40000~50000条内部SQL。更关键的是,这多出来的部分在应用日志里完全不可见,在慢查询日志里也常常被忽略——因为慢查询记录的主体是顶层SQL,函数内部语句默认不落日志。于是业务方看到的现象就是“同样的批量任务,加了触发器之后从5秒变成5分钟”,但谁都无法立刻指出瓶颈在哪。

1.2 触发器性能为什么难定位

我踩过不少这类坑,发现触发器性能问题最难缠的一点是:常规诊断工具对它近乎盲视。

第一,执行计划里看不到细节。EXPLAIN ANALYZE确实会在输出尾部显示触发器耗时,比如Trigger trg_orders_audit: time=2.090 ms calls=1,但它只告诉你“有触发器和总耗时”,不告诉你触发器函数内部到底哪条SQL慢。函数里的十几个语句,哪条是主要耗时,执行计划一概不区分。

第二,等待事件是分散的。触发器内部会写多张表,可能还有锁等待、缓冲区写入、FSYNC等待,这些等待分布在不同的表和资源上,不像单条慢SQL那样有一个集中的wait event可以抓。

第三,影响容易被业务量放大。触发器通常是行级触发(FOR EACH ROW),也就是说,一行DML就触发一次。即使单个触发器函数平均只花0.5毫秒,看起来微不足道,但如果有50万行更新,就是250秒的额外时间。这个量级的成本,微基准测试完全看不出来,只有在统计视图里累加起来才触目惊心。

1.3 为什么是pg_stat_user_functions

PostgreSQL提供了一组用户功能统计视图,其中pg_stat_user_functions专门记录用户自定义函数的调用次数和耗时。触发器函数本质上就是一个普通的用户函数,只是由触发器调用而已,所以它天然会被这个视图统计到——前提是开启了对应的追踪开关。

也就是说,pg_stat_user_functions相当于贴在触发器“肚子”上的听诊器,能告诉你某个触发器函数被调了多少次、累计花了多少时间、平均一次多少毫秒。它解决的核心问题就是“函数级量化”,把原本隐形的触发器开销变成一张可以排序、对比、监控的报表。

2. pg_stat_user_functions统计口径:先搞清楚它到底在统计什么

2.1 视图字段与含义

在PostgreSQL 14及以上版本中,pg_stat_user_functions的主要字段如下:

字段类型含义
funcidoid函数对象ID
schemanamename函数所在schema
funcnamename函数名
callsbigint被调用次数
total_timedouble precision总耗时(毫秒),包含该函数调用其他函数的耗时
self_timedouble precision自身耗时(毫秒),不包含被调用函数的耗时

需要特别注意:total_time和self_time的差别。假如一个触发器函数内部又调用了一个普通函数do_check(),而do_check()本身要花30毫秒,触发器函数自身逻辑花10毫秒,那么记录将是:

  • 触发器函数:total_time = 40毫秒,self_time = 10毫秒
  • do_check函数:total_time = 30毫秒,self_time = 30毫秒

在PostgreSQL 13及更早版本中,这个视图没有self_time字段,只有total_time。如果你在旧版本上排查,就只能靠对比父函数和子函数的total_time来估算嵌套关系。

另外这个视图只统计用户自定义函数,不包含系统内置函数。C语言扩展函数是否被统计,取决于track_functions的设置。

2.2 开启追踪开关track_functions

pg_stat_user_functions不会默认开启数据采集,需要先设置track_functions参数。它有三种取值:

取值含义适用场景
none不追踪任何函数统计默认值
pl只追踪过程语言函数(PL/pgSQL等)日常推荐
all追踪所有函数,包括SQL函数和C函数需要全量统计时

触发器函数绝大多数是PL/pgSQL写的,所以生产环境设置成pl就够用了,统计开销相对较小。修改方式:

# 编辑 postgresql.conf track_functions = pl

然后在数据库中重载配置即可,不用重启:

SELECT pg_reload_conf(); SHOW track_functions;

验证是否生效,直接查视图:

SELECT * FROM pg_stat_user_functions LIMIT 10;

如果库里一直有函数被调用,但这里显示空,九成是track_functions还是none。

有一点要心里有数:track_functions=all确实能把SQL函数和C函数也纳入统计,但收集器多记录一个函数调用是有CPU开销的,在极高并发场景下这个开销会放大。我一般建议线上先开pl,只有排查C函数或纯SQL函数时才临时开到all,排查完再改回去。

2.3 重置统计的正确姿势

做性能对比时,经常需要清空统计重新计时。常用的重置手段有三个:

-- 重置所有统计信息(包括表、索引、函数等),慎用 SELECT pg_stat_reset(); -- 只重置单个函数的统计 SELECT pg_stat_reset_single_function_counters('public.audit_orders_fn'::regprocedure); -- 如果你的函数有重载,要用带参数类型的regprocedure SELECT pg_stat_reset_single_function_counters('public.audit_orders_fn(smallint, timestamptz)'::regprocedure);

这里有两个坑。第一,pg_stat_reset()是“大锅端”,会同时清空表级、索引级、函数级统计,如果别人正在做基线对比,你一个reset全给人毁了。第二,函数重载时,直接用'regproc'会报歧义,必须用带完整参数类型的regprocedure。我就是因为没注意重载,reset错了函数还浑然不觉,白等了半天数据。

2.4 统计信息不是实时的,耐心等一两个周期

pg_stat_user_functions的背后是PostgreSQL的统计收集器,数据不是实时的,默认大约每5秒刷新一次。也就是说,执行一条DML后立刻查视图,可能什么都看不到。实际使用时,建议操作后等10秒、20秒再查,或者直接拉一个时间窗口做快照对比。

而且要注意,这个统计是基于当前数据库实例的,不同的数据库之间互相隔离。你在database_a里查到的函数统计,不会包含database_b里同名函数的调用。排查时要确认自己连的是哪个库。

3. 触发器性能评估四步法:可照抄的实操流程

3.1 第一步:盘点存量触发器清单

动手前先把目标表上的触发器摸清楚。下面的SQL会列出所有非内部触发器(排除了主外键约束自动生成的那批):

SELECT t.tgname AS trigger_name, c.relname AS table_name, t.tgenabled AS enabled_flag, pg_get_triggerdef(t.oid) AS trigger_definition FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid WHERE NOT t.tgisinternal AND NOT EXISTS ( SELECT 1 FROM pg_constraint con WHERE con.conname = t.tgname AND con.conrelid = t.tgrelid ) ORDER BY c.relname, t.tgname;

tgenabled字段的值如果不是O,说明该触发器被临时禁用了。排查时一定要区分:禁用的触发器不会立即产生性能影响,但很容易造成“怎么统计不到”的困惑。

拿到清单后,建议给每个触发器标注几个信息:触发时机(BEFORE/AFTER/INSTEAD OF)、触发表、对应函数、是否行级触发、是否有WHEN条件。这个清单会帮你快速圈定“嫌疑对象”,避免在后面统计视图里大海捞针。

3.2 第二步:建立统计窗口,用快照差值说话

不要直接看pg_stat_user_functions里的累计值,那样混合了长期历史和当前波动,很难反映真实问题。正确做法是建立一个短时间窗口,记录起点快照,等业务跑一段后,再算差值。

-- 起点:把当前统计复制到临时表 CREATE TEMP TABLE func_stat_baseline AS SELECT * FROM pg_stat_user_functions; -- 业务运行一段时间(例如15分钟)后,计算增量 SELECT p.schemaname, p.funcname, (p.calls - COALESCE(b.calls, 0)) AS delta_calls, ROUND((p.total_time - COALESCE(b.total_time, 0))::numeric, 2) AS delta_total_ms, ROUND((p.self_time - COALESCE(b.self_time, 0))::numeric, 2) AS delta_self_ms, ROUND(((p.total_time - COALESCE(b.total_time, 0)) / NULLIF(p.calls - COALESCE(b.calls, 0), 0))::numeric, 4) AS avg_total_ms_per_call, ROUND(((p.self_time - COALESCE(b.self_time, 0)) / NULLIF(p.calls - COALESCE(b.calls, 0), 0))::numeric, 4) AS avg_self_ms_per_call FROM pg_stat_user_functions p LEFT JOIN func_stat_baseline b USING (funcid) WHERE (p.calls - COALESCE(b.calls, 0)) > 0 ORDER BY delta_total_ms DESC;

这个SQL非常实用。delta_calls高说明调用频繁,avg_total_ms_per_call高说明单次调用昂贵,两者相乘就是总开销。在实际使用中,我倾向于把窗口设为业务高峰期的15~30分钟,因为低谷期的数据往往掩盖真实水位,而高峰期数据会让问题函数直接冲到Top。

3.3 第三步:用auto_explain和pg_stat_statements抓函数内部语句

函数级统计只能告诉你“哪个函数耗时”,不能告诉你“函数里哪条语句耗时”。想要进一步精确定位,需要用auto_explain的记录嵌套语句能力。

在postgresql.conf中配置:

shared_preload_libraries = 'auto_explain'

然后设置参数:

SET auto_explain.log_min_duration = 500; -- 记录超过500ms的语句 SET auto_explain.log_nested_statements = on; -- 关键:记录函数内部嵌套语句 SET auto_explain.log_analyze = on; -- 连执行计划一起记录

这样当触发器函数整体执行较慢时,日志里不仅会记录顶层UPDATE,还会把函数内部的INSERT、SELECT一条条带执行计划打出来,问题语句一眼就能看到。

另外一个互补工具是pg_stat_statements。如果它的track参数设置为all,它会记录PL/pgSQL函数内部执行的SQL语句的统计数据:

# postgresql.conf shared_preload_libraries = 'pg_stat_statements,auto_explain' track = all
CREATE EXTENSION IF NOT EXISTS pg_stat_statements; SELECT calls, ROUND(mean_exec_time::numeric, 2) AS mean_ms, query FROM pg_stat_statements WHERE query ILIKE '%orders_audit%' ORDER BY total_exec_time DESC LIMIT 20;

注意,把pg_stat_statements.track设为all会显著增加统计记录量,生产环境要先在测试库验证开销,确认可接受后再上。我一般只在专项排查时临时开启,排查完改回top。

3.4 第四步:把函数耗时换算成业务影响

最后一步,是把统计数字翻译成业务语言。单纯说“某个函数平均2毫秒”没有说服力,要说“这批50万行的更新,因为触发器额外多了100秒”。

推算方法很简单:

额外总耗时 ≈ 行级DML总量 × 触发器函数平均耗时

举个例子:某业务表orders每天有50万次UPDATE,orders上有一个FOR EACH ROW的审计触发器,pg_stat_user_functions显示审计函数平均每次调用2.1毫秒。那么每天触发器带来的额外开销是:

50万 × 2.1ms = 1,050,000ms = 1050秒 ≈ 17.5分钟

如果一张表上有3个这样的触发器,那每天因为触发器产生的额外耗时就是50多分钟。这种量化数字,足够在向上汇报时直接说明问题严重性。

还可以结合pg_stat_user_tables做交叉验证:

SELECT relname, n_tup_ins, n_tup_upd, n_tup_del FROM pg_stat_user_tables WHERE relname IN ('orders', 'orders_audit');

如果orders表更新量才5万行,审计表插入量却有30万行,说明审计触发器的调用次数明显异常,多半是FOR EACH ROW触发的单行操作,叠加了批量更新场景。

4. 实战复盘:模拟订单系统审计触发器优化全过程

4.1 项目背景与故障现象

我用一个模拟项目X来复盘完整的排查链路。项目背景是某订单管理平台,核心库是PostgreSQL 14,orders表约500万行,每天业务高峰期有大量订单状态更新。

故障现象很典型:运营人员批量更新订单状态时,一个包含5000个订单号的UPDATE批次,原来约1.2秒可以完成,最近变成7秒以上。数据库CPU不高、IO也不高,但是WAL写入量暴涨,pg_stat_activity里能看到大量会话卡在“等待更新审计表”上。应用侧看到的是页面转圈,数据库侧表面看却一切正常。

4.2 用pg_stat_user_functions定位元凶

在高峰期开启统计窗口后,15分钟内得到了这样一份快照增量:

函数名delta_callsdelta_total_msdelta_self_msavg_total_ms
public.audit_orders_fn50,000105,300102,1002.106
public.get_user_display_name50,00084,20084,2001.684
public.order_status_check2,3001,2001,1500.522

Top一名的audit_orders_fn就是审计触发器对应的函数,15分钟被调用了5万次,平均每次2.1毫秒。结合pg_stat_user_tables的orders表更新量:15分钟内orders表有5.1万次UPDATE,和调用次数基本吻合,确认是FOR EACH ROW触发器。

再叠加会话的等待事件和WAL增长数据,可以拼出完整结论:每次订单状态更新,触发器都要额外执行一次审计INSERT,并且审计函数内部还调用了一个get_user_display_name函数去查询用户表——这个子函数每次又要花1.68毫秒。orders表本身更新只占0.1毫秒级别的执行时间,触发器整体却把单次更新的耗时放大到2.1毫秒左右,放大超过20倍。

4.3 三个优化动作与对应效果

定位后做了三个优化,效果立竿见影。

第一个优化是给触发器加WHEN条件,只有状态真正变化时才记审计:

CREATE OR REPLACE TRIGGER trg_orders_audit AFTER UPDATE ON orders FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status) EXECUTE FUNCTION audit_orders_fn();

这一步很关键。很多订单更新其实只是改了归属部门、操作人等字段,状态根本没变,WHEN条件让触发器只在状态变化时才触发,调用次数立刻下降。

第二个优化是干掉触发器函数里的用户查询:

CREATE OR REPLACE FUNCTION audit_orders_fn() RETURNS TRIGGER AS $$ BEGIN INSERT INTO orders_audit(order_id, old_status, new_status, operator_name, changed_at) VALUES (NEW.id, OLD.status, NEW.status, NEW.operator_name, now()); RETURN NEW; END; $$ LANGUAGE plpgsql;

原函数为了拿到操作人姓名,特意去users表查了一次。实际上orders表本身就冗余了operator_name字段,直接取NEW行里的值就行,省掉了每次调用的额外查询。

第三个优化是给审计表做索引瘦身。审计表原来有三个索引:主键、order_id、changed_at。日志查询里很少单独按changed_at过滤,drop掉这个索引,INSERT的写放大明显减小。

4.4 优化前后的量化对比

优化完成后,重新建立统计窗口,同样在高峰期取15分钟数据:

指标优化前优化后
触发器调用次数50,00011,600
平均每次耗时2.106ms0.423ms
触发器总耗时105,300ms4,900ms
5000行批量更新耗时约7秒约1.3秒

调用次数减少了约77%,单次耗时下降了约80%,总耗时下降了95%以上。这个案例说明:评估触发器性能时,既要看平均耗时,也要看调用次数,两者往往决定了优化方向完全不一样。

注意:案例中的优化方案是在可控环境验证后逐步上线的。生产环境操作前务必做好备份和灰度,尤其是索引删除和触发器重建,都应该在维护窗口内执行。

5. 常见问题与排查技巧实录

5.1 查询pg_stat_user_functions总是空的

最常见的原因是track_functions没开启。先SHOW track_functions,确认是pl或all,而不是none。其次,这个视图只统计用户自定义函数,并且基于当前数据库。如果你一连到数据库就查,总想看到历史累计数据,它可能是空的,因为实例刚启动时统计本来就是清零的。还有一种被低估的情况:统计收集器每5秒刷新一次,你操作完立刻查,还没来得及写入。

排查建议按顺序来:看配置、看实例启动时间、看是否已经有函数被调用、等10秒再看。如果这些都确认过还没数据,再检查一下函数是不是C函数且track_functions=pl,C函数要all才能统计。

5.2 调用次数爆炸但平均耗时很低

这种情况同样危险。我们常说“平均耗时很低”不代表没问题,因为行级触发器会放大调用次数。每次0.1毫秒的调用,在100万行批量INSERT面前就是100秒额外耗时。

遇到这类问题,优化思路应从“怎么让触发器更快”转向“怎么让触发器少触发”。具体手段包括:

  • 加WHEN条件,缩小触发范围
  • 把同一行上的多个触发器合并成一个函数
  • 批量加载场景临时禁用触发器
  • 评估是否需要FOR EACH STATEMENT替代FOR EACH ROW(例如某些仅需要汇总的触发器)

5.3 触发器函数内部SQL查不到

如果你已经确定某个触发器很慢,但pg_stat_user_functions只看得到总耗时,看不到函数内哪条SQL慢,先检查两件事:auto_explain.log_nested_statements是否已经开启,以及pg_stat_statements.track是否为all。

如果这两个都开了还是查不到,可能是慢查询阈值设置太高,函数内部语句被过滤了。可以把auto_explain.log_min_duration临时调低,比如设成100毫秒,甚至0毫秒,让日志把函数内部语句全部打出来。注意这会产生大量日志,只适合短时间排查。

还有一个土办法:把触发器函数的函数体复制出来,手动构造OLD和NEW变量,单独执行函数体里的每一条SQL,用EXPLAIN ANALYZE逐条核查。虽然麻烦,但很可靠。

5.4 统计数字对不上:重置、重启、函数混用

统计数字对不上通常有三个原因。

第一,统计信息延迟和重置误操作。统计收集器不是实时的,而pg_stat_reset()又很容易被误用。我建议建立基线时用临时表快照而不是reset,这样就算别人reset了,你的基线数据也还留在临时表里。

第二,数据库重启会把统计清零。如果你对比的是重启前后的数值,没有任何意义。

第三,同一个函数被多个入口调用。触发器函数可能既被触发器调用,也被业务系统直接select调用。pg_stat_user_functions只按函数聚合,不区分调用来源。所以看到高调用量后,要回头检查这个函数是否被普通SQL也调用了,避免误判业务量和触发器无关。

5.5 什么时候不该用触发器

最后说一个我自己的判断标准。触发器适合做“低成本、无条件、必须实时”的兜底逻辑,比如审计、状态校验、父表的级联更新。但如果一个触发器里开始包含复杂业务判断、多张表的查询、循环和异常处理,我建议慎重。这时考虑三条替代路线:

  • 用约束(CHECK、FOREIGN KEY)替代一部分状态校验逻辑
  • 把审计同步写改成异步任务,由应用层或队列消费
  • 在应用层事务中显式计算和写入审计,而不是靠触发器隐式完成

触发器的维护成本很高,尤其是牵涉到多张表和函数嵌套时,线上一次事故的排查成本往往远高于最初写触发器省下的工作量。量化评估、及时优化、敢于废弃,才是触发器管理的正确姿态。

我在实际项目中养成的一个习惯是:每月抽10分钟,用pg_stat_user_functions拉一次Top10函数的增量快照,看看哪些函数调用次数在涨、平均耗时在涨。不比不知道,一旦把“触发器开销”变成一张定期报表,很多问题根本不会等到线上故障才暴露。如果你们项目正在纠结某张表的触发器要不要保留,我的建议是:先量化,再决策;能砍则砍,不能砍的也要尽量让它变轻。触发器不是原罪,真正的问题是大多数团队从来没认真看过这些数字。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进