pg_stat_statements 很适合回答“数据库平时把时间花在哪里”,却不等于一份完整的 SQL 审计记录。尤其当 AI Agent 每轮都重新组织 SQL 时,它会制造大量只执行一次、解析树各不相同的语句。这些条目恰好最容易在统计区满时被淘汰。
一次实验中,Agent 对 40 张表分别执行了 DELETE;随后又读取两遍 schema,产生约 12,800 条没有修改数据的语句。最终,40 条写操作从视图中消失,只留下 pg_stat_statements_info.dealloc = 20,表明统计条目曾因容量压力发生过多次回收。这个计数不是“删除了多少条 SQL”,也无法还原被回收的内容。
pg_stat_statements 记录的是有限容量的聚合统计
pg_stat_statements 默认最多保留 5,000 个不同条目。超过上限后,PostgreSQL 会丢弃执行次数较少的语句,为新条目腾出空间。
这套策略非常适合传统应用:应用通常反复执行一小组参数化 SQL,热点语句的 calls 会持续增长,不容易被淘汰。但 Agent 的行为模式正好相反:
- 每次临时生成 SQL;
- 同一问题可能换一种连接、别名或谓词顺序;
- 跨许多表执行一次性操作;
- schema 探索会快速产生大量低频查询。
因此,“只执行过一次的关键 DELETE”和“只执行过一次的无害元数据查询”,在淘汰策略眼中可能没有本质区别。视图中没有某条语句,不能证明它没有执行过。
还要注意,dealloc 统计的是发生了多少次条目回收,不是被回收语句的数量。它只能发出“这里发生过统计信息丢失”的信号。
常量会合并,解析树变化却会拆分条目
PostgreSQL 计算 query ID 时会归一化常量。因此下面三条 SQL 通常聚合到一个条目中:
SELECT count(*) FROM orders WHERE customer_id = 42;
SELECT count(*) FROM orders WHERE customer_id = 77;
SELECT count(*) FROM orders WHERE customer_id = 313;
视图里会看到类似的代表性文本:
SELECT count(*) FROM orders WHERE customer_id = $1
真正制造近似重复项的不是不同 ID,而是解析树变化。实验中的 12 种问法产生了 8 个条目:小写关键字、换行、开头注释以及 public. 前缀可以合并;下列改写则会分别占据条目:
SELECT count(1) FROM orders WHERE customer_id = 42;
SELECT count(*) FROM orders o WHERE o.customer_id = 42;
SELECT count(*) FROM orders
WHERE customer_id >= 42 AND customer_id <= 42;
SELECT count(*) FROM orders
WHERE status = 'new' AND customer_id = 42;
SELECT count(*) FROM orders
WHERE customer_id = 42 AND status = 'new';
SELECT count(*)
FROM (SELECT id FROM orders WHERE customer_id = 42) s;
WITH c AS (
SELECT * FROM orders WHERE customer_id = 42
)
SELECT count(*) FROM c;
尤其值得警惕的是两个 AND 条件的顺序。对开发者来说,它们表达的是同一件事;对 query ID 来说,不同的解析树可以得到不同条目。Agent 如果不能稳定输出谓词顺序,一项工作就会被分散到多行统计中。
PostgreSQL 版本也会改变归一化结果。实验显示,PostgreSQL 18 会把多个元素的 IN 列表进一步合并,而单元素列表仍单独存在:
SELECT count(*) FROM orders WHERE id IN (1,2,3);
SELECT count(*) FROM orders WHERE id IN (1,2,3,4,5,6,7);
SELECT count(*) FROM orders WHERE id IN (900);
在 PostgreSQL 18 中,前两条可以落入形如 IN ($1 /*, ... */) 的同一条目;PostgreSQL 17 则会按列表长度形成不同条目。升级前后比较 query ID 时,不能假定分组规则完全稳定。
可以这样复现实验
下面的脚本使用官方 PostgreSQL 18 容器。运行前可修改端口、密码和镜像标签;它会删除同名测试容器,并在本机 55432 端口启动数据库。
#!/usr/bin/env bash
set -euo pipefail
docker rm -f pgss-lab >/dev/null 2>&1 || true
docker run -d \
--name pgss-lab \
-e POSTGRES_PASSWORD=postgres \
-p 55432:5432 \
postgres:18 \
-c shared_preload_libraries=pg_stat_statements \
-c compute_query_id=on \
-c pg_stat_statements.max=5000
until docker exec pgss-lab pg_isready -U postgres >/dev/null 2>&1; do
sleep 1
done
docker exec -i pgss-lab psql -U postgres -v ON_ERROR_STOP=1 <<'SQL'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL,
status text NOT NULL
);
INSERT INTO orders VALUES
(1, 42, 'new'),
(2, 42, 'paid'),
(3, 77, 'new');
SELECT pg_stat_statements_reset();
SELECT count(*) FROM orders WHERE customer_id = 42;
SELECT count(*) FROM orders WHERE customer_id = 77;
SELECT count(*) FROM orders WHERE customer_id = 313;
SELECT count(1) FROM orders WHERE customer_id = 42;
SELECT count(*) FROM orders o WHERE o.customer_id = 42;
SELECT count(*) FROM orders WHERE status = 'new' AND customer_id = 42;
SELECT count(*) FROM orders WHERE customer_id = 42 AND status = 'new';
WITH c AS (
SELECT * FROM orders WHERE customer_id = 42
)
SELECT count(*) FROM c;
SELECT queryid, calls, rows, query
FROM pg_stat_statements
WHERE query ILIKE '%orders%'
ORDER BY calls DESC, queryid;
SELECT dealloc, stats_reset
FROM pg_stat_statements_info;
SQL
预期结果不是固定的 query ID 数字,而是观察两件事:三个仅常量不同的查询会聚合;别名、count(1)、谓词顺序和 CTE 等结构变化会产生额外条目。
如果要观察淘汰,可以在测试环境把 pg_stat_statements.max 降低后重启实例,再对大量不同表执行一次性查询。不要在生产环境为了实验而制造这种洪泛。
application_name 不能弥补共享角色的问题
pg_stat_statements 的聚合维度包含用户和数据库,但条目本身不会按 application_name 分开。实验中,两个连接使用同一个数据库角色,一个设置为 payments-api,另一个设置为 claude-agent,最终仍进入同一条统计记录。
因此,如果 Agent 借用应用账号,事后仅靠该视图通常无法判断某次调用来自 API 服务还是 Agent。更稳妥的做法是为 Agent 分配独立登录角色:
CREATE ROLE claude_agent LOGIN PASSWORD 'replace-me';
GRANT CONNECT ON DATABASE appdb TO claude_agent;
GRANT USAGE ON SCHEMA public TO claude_agent;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO claude_agent;
实际授权应继续收紧,例如只允许访问指定 schema、限制危险 DDL,并通过事务超时和行数保护控制批量修改。application_name 仍值得设置,但它更适合写入日志、连接监控和实时排查,不能替代独立身份。
需要追责时,让日志与统计互补
默认 pg_stat_statements.track = top 时,PL/pgSQL 函数或 DO 块内部的语句不会单独出现;实验中的报错语句也没有留下相应条目。若需要观察函数内部 SQL,可以评估 track = all,但要测试额外开销,而且它仍不应被当作审计系统。
对于需要回答“谁在什么时间执行了哪条 SQL”的场景,可以配置数据库日志,同时记录角色与应用名:
log_line_prefix = '%m [%p] user=%u db=%d app=%a '
log_min_duration_statement = 250ms
修改后需要按部署方式重载或重启配置。日志可能包含字面量、个人信息或密钥,因此还要设置访问控制、脱敏、保留周期和存储预算。高风险写操作可考虑更有针对性的审计方案,而不是无限降低慢查询阈值。
上线 Agent 前的检查清单
- 给 Agent 使用独立数据库角色,不要复用应用账号。
- 设置稳定、明确的
application_name,并把%u与%a写入日志前缀。 - 在生成 SQL 时固定别名、谓词顺序、CTE 使用方式和查询模板。
- 使用参数化 SQL,但不要误以为参数化能合并所有结构差异。
- 定期采集
pg_stat_statements到外部时序存储,避免只查看当前快照。 - 监控
pg_stat_statements_info.dealloc;一旦增长,标记该时间段的数据可能不完整。 - 谨慎增大
pg_stat_statements.max:它会消耗更多共享内存,而且不能提供永久历史。 - 对 DELETE、UPDATE、DDL 等高风险操作建立独立审计或业务事件记录。
pg_stat_statements 仍然是极有价值的性能工具,只是它回答的是“当前保留下来的聚合工作负载是什么”,而不是“数据库历史上发生过什么”。当 SQL 由 Agent 动态生成时,这个边界必须被纳入系统设计。