log_statement 语句日志
在数据库安全体系里,日志是最基础、也是最容易「开着开着就失效」的一道防线。log_statement 是 PostgreSQL 内置的语句级日志开关,它决定「哪些 SQL 会被原样写进服务器日志」。相比后面要学的 pgaudit 扩展,它是零依赖、开箱即用的,几乎所有合规检查项里的「数据库操作留痕」都可以从它开始做起。
但语句日志也是一把双刃剑。它记录的是 SQL 的原始文本——这意味着用户的输入参数、甚至 CREATE ROLE ... PASSWORD 'xxx' 里的明文口令,都可能原封不动地落入日志文件。开启它的同时,你等于把数据库里发生的每一件事都誊写了一份副本,这份副本落在哪、谁能读、保留多久、会不会把磁盘写满,都必须在打开开关之前想清楚。
本页从四个取值的语义讲起,覆盖它与慢查询日志的交互、log_line_prefix 的归因配置、敏感信息泄漏风险、日志量与磁盘预算、按角色与数据库的差异化覆盖,最后给出它与 pgaudit 的定位对比。学完本页,你应该能够为任意规模的业务选出一档「既满足审计需求、又不把系统拖垮」的语句日志策略。
前置知识
建议先阅读:证书与 TLS 配置
基础概念
语句日志相关的参数都集中在「错误报告与日志(Error Reporting and Logging)」配置节里。先把这一族参数的总览放在这里,后面逐一展开:
| 参数 | 类型 / 默认值 | 作用 |
|---|---|---|
log_statement | enum,默认 none | 按「语句类别」记录 SQL 文本:none / ddl / mod / all |
log_min_duration_statement | int,默认 -1 | 把执行时长超过阈值的语句连同文本一起记录(慢查询日志) |
log_duration | bool,默认 off | 记录每条已完成语句的执行时长(不含语句文本) |
log_min_error_statement | enum,默认 ERROR | 当语句触发指定级别以上的错误时,把出错语句的文本记入日志 |
log_parameter_max_length | int,默认 -1(PG13+) | 限制语句日志中绑定参数值的最大长度(字节) |
log_parameter_max_length_on_error | int,默认 0(PG13+) | 限制错误日志中绑定参数值的最大长度(字节) |
log_line_prefix | string | 每条日志行首的元信息前缀(时间、用户、库、进程号等) |
logging_collector | bool | 日志收集器,把 stderr 捕获到日志文件(配合 log_destination) |
语句日志在日志体系中的位置
PostgreSQL 的日志可以粗分为四类,log_statement 只是其中一类:
- 连接与会话日志:谁连进来、何时断开(
log_connections/log_disconnections,下一页的主角)。 - 语句日志:执行了什么 SQL(本页的
log_statement、log_min_duration_statement)。 - 错误日志:什么操作失败了(
log_min_error_statement以及所有以LOG/ERROR/FATAL级别落盘的事件)。 - 运维事件日志:checkpoint、VACUUM、WAL 归档等后台活动(
log_checkpoints等)。
这四类共同构成审计的最小素材库。语句日志的特殊之处在于:它记录的是语义层面的内容——你能从日志里直接读懂「有人对 orders 表执行了 DELETE」,而不需要像 WAL 那样反推。
四档取值的语义边界
log_statement 的四个取值是一组递增的过滤器,挡位越高、记录越多:
none ⊂ ddl ⊂ mod ⊂ allnone(默认):不记录任何语句。绝大多数默认安装的数据库都处于这一档,也就是说默认情况下数据库里执行过什么 SQL,事后是无据可查的。ddl:记录所有数据定义语句(CREATE/ALTER/DROP以及同类的 DDL 命令)。结构变更是影响面最大的操作,通常也是审计要求里「必须留痕」的部分。mod:在ddl的基础上,再记录所有数据修改语句(INSERT/UPDATE/DELETE/TRUNCATE/COPY FROM)。注意SELECT仍然不在其中。all:记录所有语句,包括SELECT和各类实用命令(utility commands)。
另外有一个反直觉的细节:包含简单语法错误的语句不会被记录——即使 log_statement = all。因为语法解析在语句被识别之前就失败了,日志系统拿不到一条「完整的语句」。审计视角下,别指望语句日志能抓到注入探测时发出的那堆语法垃圾。
生效方式与修改权限
这一族参数的修改权限并不相同,排障前先确认「这个参数谁有权改、怎么改才生效」:
log_statement/log_min_duration_statement/log_min_error_statement:超级用户级参数,普通用户即使SET也会被拒绝;超级用户可以在会话内临时修改(对当前会话生效),全局修改走配置文件或ALTER SYSTEM后 reload。log_duration:普通用户即可在会话内设置。log_parameter_max_length:超级用户级;log_parameter_max_length_on_error普通用户可设。
reload 与 restart 的分界也要记住:本页所有参数都支持 reload 生效,但只对新建会话或后续语句执行生效(会话内已有 SET 覆盖的除外)。用 pg_settings 确认参数的来源与等待状态:
SELECT name, setting, source, pending_restart
FROM pg_settings
WHERE name IN ('log_statement','log_min_duration_statement',
'log_min_error_statement','log_duration'); name | setting | source | pending_restart
-----------------------------+---------+--------------------+-----------------
log_duration | off | default | f
log_min_duration_statement | 1000 | configuration file | f
log_min_error_statement | error | configuration file | f
log_statement | ddl | configuration file | fsource 列能直接回答「这个值是配置文件给的、ALTER SYSTEM 给的,还是某个角色/数据库覆盖给的」,pending_restart = t 则提醒你上次修改还在等重启。
与慢查询日志的分工
log_min_duration_statement(慢查询日志)和 log_statement(语句日志)都能把「语句文本 + 参数」写进日志,但触发条件完全不同:
- 前者按执行时长触发,回答的是「这条语句为什么慢」;
- 后者按语句类别触发,回答的是「谁在什么时间执行了什么」。
它们可以同时开启,但存在一条重要的交互规则,下一节详细说明。定位上,慢查询日志是性能工具,语句日志是审计工具——用错挡位(比如想审计却开了 log_statement=all 全量记)是日志系统最常见的翻车姿势。
语法与用法
基础配置
语句日志相关的参数多数可以在配置文件中设置并 reload 生效,无需重启:
# postgresql.conf
# 语句日志挡位:none / ddl / mod / all
log_statement = 'ddl'
# 慢查询日志阈值:0 表示记录所有语句的耗时,-1 表示关闭
# 支持 ms / s / min / h / d 单位
log_min_duration_statement = 1s
# 出错语句记录阈值:ERROR 级别及以上的错误会连同语句文本一起落盘
log_min_error_statement = 'error'
# 是否记录每条语句的纯耗时(不含语句文本)
log_duration = off
# 日志行首前缀:时间 [进程号] 用户@数据库
log_line_prefix = '%m [%p] %q%u@%d '
# 使用日志收集器把 stderr 捕获到文件(生产环境建议开启)
logging_collector = on
log_destination = 'stderr' # 也可选 csvlog / jsonlog(PG15+)修改后重新加载:
# 方式一:直接 reload
pg_ctl reload -D /var/lib/postgresql/18/main
# 方式二:通过 SQL 触发 reload
psql -U postgres -c "SELECT pg_reload_conf();"
# 确认参数已生效
psql -U postgres -c "SHOW log_statement;"按数据库与角色覆盖
log_statement 与 log_min_duration_statement 允许按数据库、按角色设置更细的值(这些参数属于超级用户级设置,由超级用户执行 ALTER):
-- 业务库把 DML 也记下来
ALTER DATABASE appdb SET log_statement = 'mod';
-- 报表角色查询量大,退回只记 DDL,避免日志洪水
ALTER ROLE bi_reader SET log_statement = 'ddl';
-- 管理角色一切留痕
ALTER ROLE dba_ops SET log_statement = 'all';
-- 报表库的慢查询阈值放宽到 5s
ALTER DATABASE reportdb SET log_min_duration_statement = '5s';
-- 查看某角色的生效值
SELECT rolname, rolconfig FROM pg_roles WHERE rolname = 'bi_reader';按角色/数据库的设置保存在 pg_db_role_setting 目录表中,优先级高于 postgresql.conf 的全局值,可以用 SHOW 或 current_setting() 验证实际生效值。
日志长什么样
开启 log_statement = 'ddl' 后执行一条 DDL,日志文件中会出现两行:先是一行携带前缀元信息(以及可选的连接来源信息),然后是语句文本行:
2026-10-07 10:12:33.181 CST [21831] dba@appdb LOG: statement: ALTER TABLE orders ADD COLUMN remark text;如果同一连接触发了错误(且级别达到 log_min_error_statement),还会附带 STATEMENT: 行:
2026-10-07 10:14:02.770 CST [21831] dba@appdb ERROR: syntax error at or near "FORM" at character 15
2026-10-07 10:14:02.770 CST [21831] dba@appdb STATEMENT: SELECT name FORM users WHERE id = 1;验证与自检:确认挡位真的生效
挡位调整后做一次端到端自检,比「看起来 reload 成功了」可靠得多:
-- 1. 全局挡位
SHOW log_statement;
-- 2. 角色级覆盖是否存在(rolconfig 非空即有覆盖)
SELECT rolname, rolconfig
FROM pg_roles
WHERE rolconfig IS NOT NULL
ORDER BY rolname; rolname | rolconfig
-----------+--------------------
bi_reader | {log_statement=ddl}
ops_admin | {log_statement=all}-- 3. 用一条真实语句验证落盘
CREATE TABLE _audit_probe (id int);
DROP TABLE _audit_probe;# 日志尾部应出现两条 statement 记录
tail -n 5 /var/lib/postgresql/18/main/log/postgresql-$(date +%F).log自检要点:验证用的会话要与目标角色一致(挡位按角色分域时,用 postgres 超级用户验证会看到完全不同的挡位);探针表名加下划线前缀便于日志检索,验证完立即清理。
详细说明
ddl / mod / all 三挡到底各自抓住什么
用一张真实语句清单来划边界,比读定义更直观:
| 语句 | ddl | mod | all |
|---|---|---|---|
CREATE TABLE / ALTER TABLE / DROP TABLE | ✅ | ✅ | ✅ |
CREATE ROLE ... PASSWORD 'xxx' | ✅ | ✅ | ✅ |
INSERT / UPDATE / DELETE / TRUNCATE | ❌ | ✅ | ✅ |
COPY FROM(导入数据) | ❌ | ✅ | ✅ |
SELECT(纯查询) | ❌ | ❌ | ✅ |
EXPLAIN SELECT ... | ❌ | ❌ | ✅ |
PREPARE / EXECUTE(内部为 DML 时) | ❌ | ✅ | ✅ |
SET search_path = ... | ❌ | ❌ | ✅ |
| 含语法错误的任意语句 | ❌ | ❌ | ❌ |
几个容易被忽视的点:
PREPARE/EXECUTE/EXPLAIN这类「包裹型」命令,是否被记录取决于其内部的命令是否落在当前挡位的类别内。EXPLAIN DELETE FROM t在mod挡会被记录。- 使用扩展查询协议(驱动普遍采用)时,语句在被
Execute消息触发执行时记录,Bind阶段传入的参数值会包含在日志里(字符串中的单引号会翻倍转义)。 log_statement = all并不意味着「所有到达服务器的字节都被记录」——快速失败的语法错误、连接层的握手内容都不在语句日志范畴。
扩展查询协议下的参数记录
现代驱动(JDBC、libpq、psycopg、node-postgres)默认使用扩展查询协议:SQL 骨架走 Parse,参数走 Bind。这带来两个后果:
- 日志里看到的语句是带
$1、$2占位符的骨架 + 参数值,而不是拼接好的最终 SQL。追查问题时需要把两者拼回去。 - 参数值默认会被完整记录。一条
INSERT INTO logs(msg) VALUES ($1)如果msg是 10 MB 的 JSON,日志里就多出 10 MB——这正是log_parameter_max_length(PG13+)要解决的问题,后文详述。
log_min_duration_statement 的交互规则
官方文档对两者并存的交互有明确说明:当 log_statement 已经记下了某条语句时,log_min_duration_statement 对这些语句无效——同一条语句不会因为「既属于已开启的类别、又执行得够慢」而被记录两次。
由此产生的工程结论是:
log_statement = all一旦开启,慢查询日志事实上被「吞掉」了:所有语句都已落入日志,但日志行里不带执行耗时。想要「全量文本 + 耗时」的组合,all挡位给不了。- 反过来,只开
log_min_duration_statement(不设log_statement)是经典的性能观测模式:记录量与慢语句数量成正比,通常可控。 - 同时要说明的是:无论由哪个参数触发,带文本的语句日志都会包含绑定参数值,默认不截断。
log_duration:只记耗时、不记文本
log_duration = on 会为每一条完成的语句记录一行耗时,但不含语句文本:
2026-10-07 10:20:11.902 CST [21840] app@appdb LOG: duration: 12.341 ms它和 log_min_duration_statement = 0 的区别是:后者「超过阈值时强制把语句文本也记下来」,而前者永远只有数字。因此 log_duration 适合「想要全局耗时基线、但不想让语句文本刷爆日志」的场景——缺点也很明显:只有耗时没有语句,你只能对着进程号去猜执行了什么。生产中更常见的组合是 log_duration = off + 合理的 log_min_duration_statement 阈值。
log_min_error_statement:把出错语句送进日志
默认 log_min_error_statement = error 的含义是:当一条语句产生 ERROR 及以上级别的错误时,日志会在错误详情之后追加一行 STATEMENT:,原样给出出错语句的文本。可选级别从 DEBUG5 到 PANIC,级别越低记录越多(例如设成 warning 会把产生警告的语句也记下来)。
它是排查「应用报错但没人知道发的是什么 SQL」的第一工具,也是审计的兜底:即使语句日志全关,出错的语句也会留下痕迹。需要注意它与 log_parameter_max_length_on_error(PG13+)的配合——后者为 0 时,出错语句里的绑定参数值不会被记入错误日志;设置为正数(字节)后,参数值按该长度截断记录。这是错误路径上防止参数泄漏的专用阀门。
csvlog / jsonlog 下的语句日志形态
log_destination 决定这些日志行长什么样,也决定采集端要不要写解析器:
stderr(默认):人类可读,log_line_prefix+ 消息体;跨行关联靠前缀。csvlog:一行一个 CSV 记录,字段固定(时间、用户、库名、PID、连接来源、消息、详情、语句……),语句文本落在专门的列里,ETL 可直接拆列。jsonlog(PG15+):一行一个 JSON 对象,字段名稳定,是日志平台(ELK / Loki / 云日志服务)的推荐形态。
同一条慢查询在 jsonlog 下的形态:
{"timestamp":"2026-10-07 10:52:14.033 CST","user":"app","dbname":"appdb",
"pid":22040,"remote_host":"10.0.3.17","session_id":"689e4c1e.5618",
"level":"LOG","message":"duration: 812.994 ms",
"statement":"SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC;",
"detail":"parameters: $1 = '4711'"}工程含义:结构化格式下「谁、在哪、执行了什么、多久」天然成列,审计报表与告警规则不再依赖正则切分。注意结构化格式有自己的固定字段清单,log_line_prefix 模板主要服务于 stderr 输出;从 stderr 切到 csvlog / jsonlog 后,采集端解析器与告警正则要按新字段重新校准——格式变更属于采集链路的破坏性变更,走变更流程。
log_line_prefix:让日志可归因
一条只有 SQL 文本的日志在审计上几乎无用——你不知道是谁、在哪个库、从哪台机器发出的。log_line_prefix 负责给每行日志补上元信息。常用转义符:
| 转义符 | 含义 |
|---|---|
%m | 毫秒级时间戳(%t 为秒级,%n 为毫秒) |
%u | 用户名 |
%d | 数据库名 |
%p | 后端进程号(PID) |
%a | application_name |
%h | 客户端主机名或 IP |
%r | 客户端主机:端口 |
%c | 会话 ID(可把同一会话的多行日志串起来) |
%l | 本会话内的日志行号 |
%i | 命令标签(command tag,如 UPDATE) |
%e | SQLSTATE 错误码 |
%x | 事务 ID |
%s | 会话开始时间戳 |
%b | 后端类型(PG13+,如 client backend / walsender) |
%q | 对无会话上下文的进程,此位置之后的转义符全部省略 |
推荐的生产配置是 %m [%p] %q%u@%d (连接日志一页会展开说明),如果日志会被集中收集,再补上 %h 与 %c 会大幅提升跨行关联能力。注意 %q 是一个「分隔哨兵」而不是内容——不写 %q 时,无会话的后台进程(如 checkpointer)会因为取不到 %u 而输出空字段,加上 %q 可以让这类进程优雅地跳过用户/库字段。
敏感信息泄漏:密码与绑定参数
语句日志会把密码写进日志
log_statement(任何非 none 挡位)记录的是 SQL 的原始文本。执行 CREATE USER app WITH PASSWORD 'S3cret!'; 时,这行 SQL 连同明文密码会被完整写入日志。PostgreSQL 不会对语句文本中的口令字面量做脱敏(它只对认证失败类错误消息做隐藏)。因此:凡是开启语句日志的实例,改密码一律走 ALTER ROLE ... PASSWORD 的交互式 \password 或应用侧参数化执行,并定期轮换日志中出现过的凭证。
三类典型的泄漏通道需要逐一封堵:
- 口令字面量:如上,
CREATE ROLE/ALTER ROLE ... PASSWORD/CREATE SERVER ... OPTIONS (password ...)的参数都会原样落盘。规避手段是超级用户用 psql 的\password(口令不经过 SQL 文本)或让应用永远用绑定参数。 - 绑定参数值:扩展协议下参数默认完整记录。PG13+ 用
log_parameter_max_length限制语句日志中参数值的最大字节数(默认-1即不限制),用log_parameter_max_length_on_error(默认0即错误路径不记参数值)单独控制错误日志。给两者都设一个如 64 字节的温和上限,能挡住绝大多数「参数里带手机号 / 证件号 / Token」的意外泄漏。 - 错误语句文本:
log_min_error_statement触发的STATEMENT:行同样可能包含敏感字面量,同样受log_parameter_max_length_on_error约束。
日志文件本身的访问控制也要跟上:log_file_mode(默认 0600)、日志目录属主仅限数据库运行账户、集中收集链路上的最小权限,都属于同一条防线的延伸。
把三类泄漏通道与对应阀门整理成自查表:
| 泄漏通道 | 触发条件 | 封堵阀门 |
|---|---|---|
| 口令字面量 | 语句日志记录到 CREATE/ALTER ROLE ... PASSWORD | \password 交互式改密;参数化接口;改密语句审计复查 |
| 绑定参数值 | 语句 / 慢查询日志记录参数(默认完整) | log_parameter_max_length 截断(PG13+) |
| 错误语句参数 | 错误触发 STATEMENT: 行记录参数 | log_parameter_max_length_on_error(0 = 不记参数值) |
| 日志文件本身 | 文件权限过宽、采集链路裸传 | log_file_mode = 0600、目录权限收紧、传输走 TLS |
日志量评估与磁盘预算
log_statement 的记录量没有理论上限,估算要靠实测。一个可操作的步骤:
- 用
pg_stat_statements统计单位时间内的语句执行次数(按calls聚合),乘以「平均日志行长度」(可用采样日志实测,通常 100~400 字节)得到基础量。 - 注意执行次数和语句数的区别:同一个高频简单查询(如每秒几百次的点查)在
all挡下会贡献几千行/分钟的日志,而它们的审计价值几乎为零。 - 给日志目录单独的分区或配额,避免与
pg_wal、表数据抢占同一块盘。logging_collector的log_directory、log_filename(按日滚动)、log_rotation_age/log_rotation_size、log_truncate_on_rotation组成一套滚动策略。 - 保留期由合规要求倒推(常见 90~180 天),并且压缩归档——日志文本的压缩比通常在 10:1 以上,长期保留应归档压缩而非保留原始文件。
经验上:中小规模 OLTP 实例开 ddl 挡,日志增量通常可以忽略;开 mod 挡需要实测;开 all 挡在写入密集型业务上每天产生几十 GB 日志并不罕见。
从语句日志到 pgaudit 的演进
log_statement 是「开箱即用」的语句留痕,但它有三个天花板:挡位只有三档(颗粒度到不了「某张表」)、日志行没有结构化的类别与对象字段、mod / all 挡在 OLTP 规模下日志量难以承受。当审计要求精确到对象时,就该考虑迁移到 pgaudit 扩展:
| 维度 | log_statement | pgaudit 会话审计 | pgaudit 对象审计 |
|---|---|---|---|
| 粒度 | 三挡,可下放库 / 角色 | 八个类别,可下放库 / 角色 | 单对象(GRANT 声明) |
| 日志格式 | 原始语句文本 | 固定九段 AUDIT 格式 | 固定九段 AUDIT 格式 |
| 前置条件 | 无(改配置即可) | 预加载 + 重启 + 建扩展 | 同左 + 审计角色 |
| 典型用途 | 应急放量、快速留痕 | 常态化审计(DDL / ROLE 等) | 核心敏感表读写 |
迁移不是替换关系:log_statement 依然是应急放量的首选——一条 GUC、reload 即生效,无需预加载与扩展;pgaudit 是常态化审计的形态,代价是接管语句日志参数并要求重启窗口。适用边界与迁移清单见 pgaudit 一页的实战示例。
按角色 / 数据库覆盖与日志采样
全库统一挡位往往是「要么不够审计、要么日志爆炸」的两难,解决思路是把挡位下放到更细的粒度:
- 按角色分档(见「语法与用法」一节):管理角色
all、普通业务角色mod或ddl、高频只读角色none/ddl。审计重点是「人 + 高危操作」,而不是每一行SELECT。 - 按数据库分档:核心业务库与日志型的边缘库区别对待。
- 运行时窗口采样:
log_min_duration_statement属于可在运行期修改的参数,可以在可疑时段临时调低阈值(如1s→100ms)或临时把某角色切到all,观察期结束再恢复。这相当于「按需提高采样率」,比长期全量记录经济得多。 - 采集端采样:如果日志链路接入了 Fluent Bit、Vector、rsyslog 等采集器,可以在采集端做抽样或聚合(例如对相同语句骨架的重复行做计数折叠),数据库侧保持完整、分析侧控制噪音。此外,离线分析工具如 pgBadger 可以在统计报表层面消化海量日志,缓解「记了没人看」的问题。
与 pgaudit 的对比定位
log_statement 与 pgaudit(下一节的主角)回答的审计问题不同,实际项目里往往是「前者兜底、后者主审」:
| 维度 | log_statement | pgaudit |
|---|---|---|
| 依赖 | 无(内核自带) | 独立扩展,需 shared_preload_libraries 预加载 |
| 分类粒度 | ddl / mod / all 三挡粗粒度 | READ / WRITE / DDL / ROLE / MISC 等类别可任意组合 |
| 对象级审计 | 不支持 | 支持(pgaudit.role + GRANT 指定表) |
| 记录格式 | 普通日志行,无结构化标记 | 带 AUDIT: 前缀的结构化字段(类别、命令、对象、语句、参数) |
| 按角色 / 按对象细分 | 按角色可设置挡位 | 按角色 + 按对象可精确指定 |
| 升级维护成本 | 零 | 跟随大版本安装对应分支的扩展 |
简单结论:没有扩展安装权限、或只需要满足「DDL 留痕」这类最低要求时,log_statement 完全够用;当合规要求「按表审计、按类别出报表、审计记录可机读」时,应当引入 pgaudit,并把 log_statement 退回 none,避免同一语句被两套机制重复记录。
用 pgBadger 分析语句日志
pgBadger 是 PostgreSQL 生态最常用的离线日志分析器:解析 postgresql 日志,产出 HTML 报表(Top 查询、耗时分布、错误统计、连接来源、checkpoint/VACUUM 时间线等)。它与本页的关系是「数据库侧记录 → 离线侧消化」:
# 解析单日日志生成 HTML 报表
pgbadger -I -o report-2026-10-07.html \
/var/lib/postgresql/18/main/log/postgresql-2026-10-07.log
# 多文件并行解析(大日志提速)
pgbadger -I -j 4 -o report.html postgresql-2026-10-*.log对语句审计的实用价值:
- Top 查询榜单:哪些语句在刷日志一目了然,是「按角色分档」调挡位的直接依据;
- 耗时分布:配合
log_min_duration_statement的阈值调整,验证采样挡位是否合理; - 错误与会话统计:
log_min_error_statement记录的错误语句会按 SQLSTATE 聚合,异常错误码的尖刺通常先于故障被发现。
两个限制要清楚:pgBadger 的慢查询统计依赖 duration 信息——log_statement = all 产生的纯语句行没有耗时,对性能报表没有贡献(再次印证「审计挡位与性能观测分开」);以及它是离线工具,实时告警仍要靠采集链路(见审计策略一页的告警设计)。
实战示例
示例一:给核心库开启 DDL 审计
目标:所有结构变更留痕,业务流量零感知。
# postgresql.conf(或 ALTER SYSTEM SET)
log_statement = 'ddl'
log_line_prefix = '%m [%p] %q%u@%d '
log_min_error_statement = 'error'# 生效并验证
psql -U postgres -c "SELECT pg_reload_conf();"
psql -U postgres -c "SHOW log_statement;"-- 用一个普通角色做一次结构变更
CREATE TABLE audit_test (id int PRIMARY KEY);
ALTER TABLE audit_test ADD COLUMN note text;
DROP TABLE audit_test;2026-10-07 10:40:01.220 CST [22011] dev@postgres LOG: statement: CREATE TABLE audit_test (id int PRIMARY KEY);
2026-10-07 10:40:01.246 CST [22011] dev@postgres LOG: statement: ALTER TABLE audit_test ADD COLUMN note text;
2026-10-07 10:40:05.118 CST [22011] dev@postgres LOG: statement: DROP TABLE audit_test;示例二:慢查询日志与语句日志的正确并存
需求变更:DDL 继续全记,同时希望慢于 500ms 的语句(含 SELECT)带文本落盘。注意 log_statement = 'ddl' 不会吞掉慢查询日志——只有已被它记录的 DDL 不会再被慢日志重复记录:
log_statement = 'ddl'
log_min_duration_statement = 500ms-- 慢 SELECT 的日志行(由 log_min_duration_statement 触发)
2026-10-07 10:52:14.033 CST [22040] app@appdb LOG: duration: 812.994 ms statement:
SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC;
2026-10-07 10:52:14.033 CST [22040] app@appdb DETAIL: parameters: $1 = '4711'若错误地把 log_statement 开成 all,上面这行慢查询日志就「消失」了——语句仍会记录,但没有 duration 字段,慢查询分析工具(如 pgBadger)将失去耗时数据。
示例三:按角色差异化挡位
-- 超级用户执行
ALTER ROLE ops_admin SET log_statement = 'all'; -- 运维操作全留痕
ALTER ROLE app_write SET log_statement = 'mod'; -- 业务写入留痕
ALTER ROLE app_read SET log_statement = 'ddl'; -- 高频只读角色只记 DDL 兜底
ALTER ROLE etl_batch SET log_statement = 'none'; -- 海量批处理角色彻底静默
-- 校验
SELECT rolname, rolconfig FROM pg_roles
WHERE rolname IN ('ops_admin','app_write','app_read','etl_batch'); rolname | rolconfig
-----------+---------------------------------------
ops_admin | {log_statement=all}
app_write | {log_statement=mod}
app_read | {log_statement=ddl}
etl_batch | {log_statement=none}
(4 rows)之后各角色新建的会话将按各自挡位记录。已在运行的旧会话不受影响,需要重连后生效。
示例四:估算日志量并设置滚动与告警
-- 用 pg_stat_statements 粗估语句吞吐(需已启用该扩展)
SELECT calls,
round(total_exec_time::numeric / calls, 2) AS avg_ms,
left(query, 60) AS query_head
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 10;# 观察当前日志增长速度(开启新挡位后静置 10 分钟)
du -sh /var/lib/postgresql/18/main/log/
# 设置每日滚动 + 保留压缩归档(logrotate 片段示意)/var/lib/postgresql/18/main/log/postgresql-*.log {
daily
rotate 90
compress
delaycompress
missingok
notifempty
copytruncate
}把 du 的结果外推到天,再乘以 1.5~2 倍安全系数,就是这条挡位下的磁盘预算;预算不通过,就回到「按角色分档」下调挡位,而不是砍保留期。
示例五:可疑时段的临时放大与回收
事件背景:安全团队怀疑某内部账号在非工作时间导出数据,需要对该账号做 24 小时全语句审计,之后完全回收。
-- 1. 记录基线(回收时对比)
SELECT rolname, rolconfig FROM pg_roles WHERE rolname = 'suspect_rw';
-- 2. 临时放大(超级用户执行)
ALTER ROLE suspect_rw SET log_statement = 'all';
SELECT pg_reload_conf();# 3. 观察窗口内按天检查日志量,防止意外洪水
du -sh /var/lib/postgresql/18/main/log/-- 4. 24 小时后回收
ALTER ROLE suspect_rw RESET log_statement;
SELECT pg_reload_conf();
-- 5. 确认回收干净
SELECT rolname, rolconfig FROM pg_roles WHERE rolname = 'suspect_rw'; rolname | rolconfig
------------+-----------
suspect_rw |运维要点:放大前记录原值——RESET 恢复的是「无角色级覆盖」,若该角色原本就有覆盖值,回收要用 ALTER ROLE ... SET 写回原值而不是 RESET;窗口结束在值班系统登记关闭,避免「临时审计」变成永久日志洪水;期间产生的日志按事件取证流程归档,作为排查结论的支撑材料。
注意事项
log_statement = all 不会让 log_min_duration_statement 再记录这些语句。 两套机制对同一条语句只落一次日志,且 all 挡落的日志不带耗时。需要耗时数据的场景(性能监控、pgBadger 分析)不要依赖 all 挡。
口令字面量会被原样记录。 CREATE ROLE ... PASSWORD / ALTER ROLE ... PASSWORD 的明文口令会进入日志。审计开启期间必须把改密流程迁移到 \password 或参数化接口,并把日志文件当成「包含历史凭证的敏感文件」来管理。
语法错误的语句不会被记录。 即使 all 挡也是如此。不要把语句日志当成 WAF 或注入检测的替代品,那不是它的设计目标。
mod 挡不记录 SELECT。 如果合规要求「数据导出行为」留痕,SELECT 大结果集、COPY TO、\copy 这类操作在 mod 挡下是盲区,需要 all 或 pgaudit 的 READ 类别。
参数值默认完整落盘。 PG13 之前的版本没有 log_parameter_max_length,大参数、敏感参数一律裸奔;即使 PG13+,这两个参数默认也是「不限制」与「不记录」,需要显式设置为合理的截断长度。
log_statement 是超级用户级参数。 普通用户无法把自己的会话调成 all「给自己记日志」,也无法改掉强加给自己的挡位。这既是安全特性(防抵赖)也是运维约束(调整都要走超级用户)。
按角色 / 按数据库的设置只对新建会话生效。 ALTER ROLE ... SET 之后,长连接(包括连接池里复用的后端连接)仍沿用旧值。变更后要评估连接池的连接重建,或在维护窗口统一重启应用连接池。
日志滚动策略必须先于挡位调整。 先配置好 log_rotation_age / log_rotation_size / log_truncate_on_rotation 与外部 logrotate,再提高日志挡位。顺序颠倒的典型事故是:挡位一开,单文件膨胀数 GB,归档与传输链路被打爆。
别忘了错误语句的记录阈值。 log_min_error_statement 被调低到 warning 甚至 notice 后,日志量会陡增;反过来在 PG13+ 把 log_parameter_max_length_on_error 设为 0 可以让错误路径只记语句骨架、不记参数值,是敏感库的推荐值。
ALTER SYSTEM 与配置文件并存时的优先级要写进运维手册。 ALTER SYSTEM SET 写入 postgresql.auto.conf,加载优先级高于手工编辑的 postgresql.conf;「改了文件没生效」的一半原因是 auto.conf 里留着旧值。
日志挡位变更要走变更单并附回滚语句。 挡位、前缀、滚动策略的每次调整都影响采集与磁盘;变更单里写清「改什么、为什么、怎么回滚」,事后审计配置漂移时也有据可查。
log_line_prefix 模板只作用于 stderr 输出。 csvlog 与 jsonlog 有自己的固定字段清单(时间、用户、库、PID、来源、消息、语句……),不使用 prefix 模板。切换到结构化格式后,采集端解析与告警规则要按新字段重新校准。
log_min_duration_statement = 0 是「全量带耗时」,与 all 挡语义不同。 前者按「慢查询阈值」机制记录全部语句并携带耗时,适合临时观测窗口;后者按语句类别记录、不带耗时。两者对同一语句也只落一次日志——把 0 当成 all 的等价物来配,容易在回归时发现「怎么没有语句挡位的语义字段」。
挡位调整的告警要覆盖「日志量骤变」。 挡位被误改(比如误发的 ALTER SYSTEM)最直接的表现是日志量分钟级翻倍或归零;在日志平台对「实例级日志速率」建一条 ±3 倍告警,能把配置事故的发现时间从天级压到分钟级。
拿语句日志当审计证据,就要按证据的标准管理它。 引用语句日志满足审计要求时,确认保留期、防篡改与访问控制都达标——运维日志升级成「证据」,管理等级要同步升级,不能停在「排障用文件」的待遇上。
最佳实践
- 把语句日志当审计工具、慢查询日志当性能工具,分开规划。 审计需要的是「确定类别必留痕」(DDL、管理操作),性能需要的是「超阈值带耗时」。两者触发条件不同,混用必然两头不讨好。
- 生产环境默认从
ddl挡起步。 DDL 影响面大、发生频率低,是性价比最高的一挡。mod与all必须先做日志量实测再上。 - 永远不要在业务高峰前临时把挡位调到
all。 日志写入是同步路径上的额外 I/O,全量记录可能显著拖慢短查询。需要临时观测时,用「降低log_min_duration_statement阈值」代替「提高log_statement挡位」。 - 用角色分档实现「人」维度的审计分级。 管理角色
all、业务角色mod/ddl、批处理角色none,让日志量与审计价值正相关,而不是与流量正相关。 - PG13+ 实例显式设置
log_parameter_max_length与log_parameter_max_length_on_error。 前者建议 64~1024 字节,后者建议保持 0 或设为极小值,从机制上限制参数值在语句日志与错误日志中的暴露面。 log_line_prefix必须包含时间、PID、用户、库四要素(%m [%p] %q%u@%d),接入集中日志的再加%h、%c、%i。没有归因前缀的语句日志在审计上等于没记。- 改密操作一律走
\password或参数化接口。 同时用流程约束(变更审批、事后检查日志中的PASSWORD关键字)兜底,防止口令字面量落盘。 - 给日志目录独立分区并设置滚动配额。
logging_collector的按天滚动 + 外部压缩归档 + 保留期对齐合规要求;磁盘水位告警要覆盖日志分区。 - 对高频流量做采集端采样或骨架折叠。 数据库侧保持完整记录,分析侧用采集器(Fluent Bit / Vector / rsyslog)或 pgBadger 做聚合,让「日志完整」与「人能看得过来」同时成立。
- 当需要对象级、结构化审计时切换到
pgaudit,并把log_statement归位none。 两套机制同时开启只会带来重复记录与更高的日志量,见下一节的扩展审计方案。 - 把语句日志纳入灾备演练与访问控制。 日志里常有业务数据影子,其读取权限、传输加密、保留销毁流程都应与「含敏感数据的副本」同等对待。
- 定期做「审计覆盖度自检」。 用一条已知 DDL、一条已知
mod语句在预发环境走一遍,确认日志中出现、前缀归因正确、采集链路可达——挡位配置「看起来对」和「真的记下来」是两回事。
参考链接
- PostgreSQL 官方文档:Error Reporting and Logging(日志配置总览)
- PostgreSQL 官方文档:When to Log(log_statement / log_min_duration_statement 等)
- PostgreSQL 官方文档:What to Log(log_line_prefix / log_parameter_max_length 等)
- PostgreSQL 官方文档:Log File Maintenance
- PostgreSQL 官方文档:ALTER ROLE
- PostgreSQL 官方文档:ALTER DATABASE
- PostgreSQL 官方文档:Configuration Settings(参数优先级与生效方式)
- pgBadger:PostgreSQL 日志分析工具
- pgaudit:PGDG 审计扩展(下一节之后详述)