知行札记
专题技术实践数据库实践PostgreSQL 实践

连接、安全、备份与维护

从连接池和参数化查询连接角色、恢复材料与日常观察。

本分册采用 PostgreSQL 18 文档作为语法和行为基线。归档中 PostgreSQL 16 或其他环境的 SQL、计划与耗时保留为历史记录,2026-10-05 整理未重跑这些数据库实验。示例需在独立可丢弃数据库内使用,各章同名表可能有不同结构;先核对 DDL、角色和数据再执行。

前面几章,我们一直坐在"驾驶舱"里操作数据库:打开 psql,敲 SQL,看结果。但真实的在线书店不是这样运转的——凌晨三点有读者下单时,值守的是网站程序。程序怎么连库?用户输入怎么安全送进 SQL?应用该用什么身份和权限?数据误删了怎么救?数据库变慢从哪查起?本章沿一条主线回答:连接、安全查询、权限、备份与日常维护。示例沿用贯穿全书的在线书店案例(bookshop 库),以 PostgreSQL 18 文档为准,特性在 14~18 版均可用(版本差异随文标注)。

7.1 连接数据库:连接串与连接池

连接串:告诉驱动"去哪连、以谁的身份连"

程序连接 PostgreSQL 的第一步是给出连接串(Connection String)。它像快递面单:收件人(用户名)、地址(主机与端口)、门牌号(数据库名)缺一项包裹就送不到。回到准确定义:连接串是描述连接参数的文本,官方客户端库 libpq 与绝大多数驱动共用同一套约定——学会一处,处处能用。它有两种等价写法:

# URI 形式:像网址一样一目了然
psql -d "postgresql://app_user@localhost:5432/bookshop"

# keyword/value 形式:参数多时更清晰
psql -d "host=localhost port=5432 dbname=bookshop user=app_user"

连上后执行 psql 元命令 \conninfo,它会打印当前连接的库、用户、主机与端口,可验证参数是否生效。

省略的参数按驱动约定依次回退:先查环境变量(PGHOST、PGPORT、PGDATABASE、PGUSER 等),再取默认值——端口默认 5432,dbname 缺省与用户名同名。这正是本机随手一连就能进去的原因。

两个实务提醒:密码含 @、:、/ 等字符时,直接写进 URI 会解析错位,必须做百分号编码(percent-encoding),更好的做法是密码走环境变量或 ~/.pgpass;另外,千万别把带密码的连接串提交进 git。

sslmode:连接默认并不加密

libpq 的 sslmode 控制加密,共六档:disable、allow、prefer、require、verify-ca、verify-full,默认 prefer——先尝试加密,但服务器拒绝加密就退回明文。生产环境连外网或跨机房的库应显式用 require(必须加密)或 verify-full(加密且校验服务器证书身份),否则凭据可能明文传输。

为什么需要连接池

PostgreSQL 是"每连接一个进程"的架构:每个客户端连接对应一个服务器后端进程,建立连接要走 TCP 握手、fork 出服务器进程、再完成认证,成本不低;且 max_connections 设得越大,共享内存中相关结构的预分配就越多。

打个比方:数据库像一家只有 100 个窗口的银行(max_connections 默认 100),每开一个窗口就要雇一名柜员。若每位顾客都要求"专属窗口"、办完事窗口也不关,窗口很快被占光。"每个请求新建一个连接"正是如此,高并发下会拖垮数据库。**连接池(Connection Pool)**就是叫号系统:让大量客户端连接复用少量真实连接,谁办业务谁占用,办完立即归还。

PgBouncer 与三种池化模式

PgBouncer 是 PostgreSQL 生态最常用的连接池中间件,官方称其每个连接仅约 2KB 内存开销。它提供三种池化模式:

  • 会话池化(session pooling):客户端断开前独占服务器连接,功能全但复用率最低;
  • 事务池化(transaction pooling):事务一结束就把连接归还池中,复用率高,Web 应用常用;
  • 语句池化(statement pooling):每条语句执行完就归还,最激进,禁止多语句事务。

初学者至少要知道:事务池化下服务器连接在客户端之间轮换,凡是"绑定在会话上"的功能都会失效——所谓会话级功能,指依赖"后续请求仍落在同一条连接上"才成立的东西,例如第 1 章用过的 SET search_path(SET/RESET 一类)、SQL 级预编译语句 PREPARE/DEALLOCATE、LISTEN 通知监听、WITH HOLD 游标、会话级 advisory lock(咨询锁)、跨事务的临时表等(这份清单里多数功能本书没展开,不必逐个弄懂,记住"会话级状态会被池化打散"这一性质即可;协议级预编译语句需将 max_prepared_statements 设为非零才支持)。遇到"时好时坏"的诡异错误,先查 PgBouncer 文档的兼容表。

7.2 SQL 注入与参数化查询

成因:代码与数据没有分离

书店网站的登录框要按用户名查会员表,而输入来自陌生人的键盘。若把输入直接拼进 SQL 字符串,就埋下了 SQL 注入(SQL Injection) 的种子。做个对照实验——这是本章最重要的实验,先准备数据:

-- 书店会员表(仅演示用;真实系统的密码应存哈希值)
CREATE TABLE users (
    id       integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name     text,
    password text
);

INSERT INTO users (name, password) VALUES ('alice', 'secret');

错误示范,用 Python 字符串拼接构造 SQL。先交代 Python 侧的最小准备:pip install "psycopg[binary]" 安装本章使用的 psycopg 3 驱动,然后 import psycopg、conn = psycopg.connect("dbname=bookshop") 建立连接、cur = conn.cursor() 拿到游标——下面代码里的 cur 即来自这里:

# 错误示范:把用户输入直接拼进 SQL 字符串
user_input = "anyone' OR '1'='1"
cur.execute("SELECT * FROM users WHERE name = '%s'" % user_input)

拼接出来的 SQL 是 SELECT * FROM users WHERE name = 'anyone' OR '1'='1'——恒真条件让本该只查一个人的语句返回了全表;换成形如 '; DROP TABLE users; -- 的输入,甚至能在查询之外删掉整张表。

问题出在哪?应用把"数据"当"代码"发了出去:数据库收到的只是一段字符串,无法区分哪些字符是你写的 SQL、哪些来自用户键盘——OWASP 对注入成因的表述正是"数据库无法区分代码与数据"。它常年位居 Web 安全漏洞榜首。也别相信"我的输入都来自内部、可信":OWASP 明确指出,校验过的数据同样不等于可以安全拼接。

参数化查询:首选防护

**参数化查询(Parameterized Query)**将 SQL 结构与值分开提交,让参数作为数据参与执行。**预备语句(Prepared Statement)**还涉及预先准备并复用语句的执行机制;参数绑定可以走不持久复用命名预备语句的路径,两者有交集。psycopg 3 文档说明其常规参数执行将查询与参数分开发送;应使用驱动绑定接口,避免将输入拼接进 SQL 文本。

# 正确示范:占位符 %s 配参数元组,输入再刁钻也只是数据
cur.execute("SELECT * FROM users WHERE name = %s", (user_input,))

各驱动的占位符写法略有差异:psycopg 用 %s 或 %(name)s,SQLAlchemy 的 text() 用 :name,JDBC 用 ?。两个细节容易踩坑:占位符外面不要加引号(写成 '%s' 反而错了);不要用 %d、%f 之类的格式符——参数按位置或名字绑定,不做字符串格式化。

占位符管不了标识符:白名单来补

表名、列名和排序方向属于 SQL 结构,占位符帮不上忙(psycopg 对此提供 psycopg.sql.Identifier 专门 API)。典型场景:图书列表页让用户选按哪列排序。正确做法是**白名单(Whitelist)**映射——输入只能映射到固定的合法集合,映射不到就拒绝:

# 白名单:只放行允许排序的列,其余一律回退到 id
allowed = {"created_at", "price"}
order_col = allowed[user_choice] if user_choice in allowed else "id"
sql = f"SELECT * FROM books ORDER BY {order_col}"

这是初学者最容易遗漏的最后一块注入缺口。

7.3 用 ORM 操作数据库:以 SQLAlchemy 为例

为什么需要 ORM

手写 SQL、手动取结果、逐字段拼对象,样板代码又多又枯燥。**ORM(Object-Relational Mapping,对象关系映射)**把表映射成类、行映射成对象,让你以操作对象的方式读写数据库。本章选 Python 的 SQLAlchemy 2.0 作示例:它文档完善,且分为 ORM 与 Core 两层,随时可降级手写 SQL。

from sqlalchemy import create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

# echo=True 会在控制台打印实际发送的 SQL
engine = create_engine("postgresql+psycopg://app_user@localhost/bookshop", echo=True)

class Base(DeclarativeBase):
    pass

class User(Base):                     # 类对应一张表
    __tablename__ = "user_account"    # 会员表(演示沿用 SQLAlchemy 官方教程的表名,扮演 7.2 节 users 表的角色)
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]

# 首次运行先做两件事:按上面的模型建表,再准备两行样例数据
Base.metadata.create_all(engine)
with Session(engine) as session:
    session.add_all([User(name="spongebob"), User(name="wendy")])
    session.commit()

with Session(engine) as session:
    # 查询用构造器表达,生成的 SQL 自动使用绑定参数
    for user in session.scalars(select(User).where(User.name == "spongebob")):
        print(user.id, user.name)

建议初学者始终开着 echo=True,亲眼看到 ORM 到底发了什么 SQL。Session 按**工作单元(Unit of Work)**模式工作:跟踪对象修改,commit 时统一生成相应的 INSERT/UPDATE/DELETE;Engine 内置连接池,是上一节话题在应用侧的落点。

利与弊

利的方面:少写样板 SQL;自动适配数据库方言(Dialect),换数据库时改动小;查询构造器天然鼓励参数绑定,where(User.name == x) 生成的 SQL 一定使用绑定参数。

弊的方面:SQL 被隐藏了。ORM 生成的 SQL 可能低效——典型如循环里逐条触发的 N+1 查询;复杂报表最终还是要手写 SQL。更危险的是:OWASP 特别提醒,ORM 也能被注入——只要你在其中拼接字符串而不是用绑定参数。即便用 text() 写原生 SQL,也必须绑定参数:

from sqlalchemy import text

# text() 中一律用 :name 绑定参数,绝不 f-string 拼接
with engine.begin() as conn:
    conn.execute(text("SELECT * FROM users WHERE name = :name"), {"name": "wendy"})

实用姿势:简单增删改查交给 ORM,复杂查询用参数化的原生 SQL,并开着 SQL 日志随时观察。

7.4 角色、权限与最小权限原则

角色:用户与组是同一个东西

应用该用什么身份连数据库?绝不是超级用户。自 8.1 版起,PostgreSQL 把"用户"与"组"统一为角色(Role):带 LOGIN 属性的可登录(相当于用户),不带的可当组用(GRANT 组 TO 成员);角色属性决定能力上限,如 SUPERUSER、CREATEDB、CREATEROLE、REPLICATION。"一个角色既是身份又是权限集合"是权限管理的基础。

GRANT/REVOKE 与容易漏掉的 schema USAGE

对象权限用 GRANT 授予、REVOKE 回收,粒度可以细到列:

-- 授予 books 表的查、增、改权限
GRANT SELECT, INSERT, UPDATE ON books TO app;

-- 回收删除权限
REVOKE DELETE ON books FROM app;

最容易漏的一环:访问模式(schema,第 01 章讲过的对象命名层)里的对象,需要同时拥有该 schema 的 USAGE 权限和对象本身的权限,缺一个都会 permission denied。若不想每张新表都手动授权,ALTER DEFAULT PRIVILEGES 可以让指定角色未来新建的表自动带上授权。版本提示:PG 15 起 public schema 默认不再允许 PUBLIC 创建对象(出厂即已 REVOKE CREATE),迁移旧授权脚本时要留意。

最小权限原则:给应用一个"只做本职工作"的账号

**最小权限原则(Principle of Least Privilege)**要求应用使用专用的受限角色,只授予它真正需要的权限。道理很直接:一旦应用被注入攻破,攻击者的能力上限就是这个角色的权限——OWASP 明确要求应用账户绝不应拥有 DBA 权限;而超级用户可用 COPY ... TO PROGRAM 执行系统命令,等于把整台服务器交出去。最小权限把"代码漏洞"的损失限制在数据库权限这一层。

给 bookshop 配一个应用角色的完整脚本:

-- 1. 创建应用专用角色(密码用环境变量或配置管理注入,不要写死在代码里)
CREATE ROLE app LOGIN PASSWORD '...';

-- 2. 授予 schema 使用权:没有它,后面的对象权限全部无效
GRANT USAGE ON SCHEMA public TO app;

-- 3. 只授予业务需要的权限
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO app;

-- 4. 显式回收删除权限,双保险
REVOKE DELETE ON ALL TABLES IN SCHEMA public FROM app;

用 SET ROLE 切换身份验证效果:

SET ROLE app;
DELETE FROM books;   -- ERROR:  permission denied for table books
RESET ROLE;

7.5 备份与恢复

逻辑备份:pg_dump 三件套

误删表、磁盘损坏、升级出错——备份是数据安全的最后一道保险,且必须定期演练恢复:只备份、从不验证能恢复,等于没有备份。先说逻辑备份(Logical Backup):它导出的是"内容"(建表语句与数据),好比把账本誊抄成新文件,而不是复制整个书柜。三个工具各司其职:

  • pg_dump:备份单个数据库;导出一致性快照,通常允许并发读写,但其表锁可能与 DDL 冲突,长时间快照和 I/O 也会影响负载;
  • pg_restore:恢复 pg_dump 的自定义、目录格式备份;
  • pg_dumpall:备份整个集群,含角色和表空间——pg_dump 不导出这些,漏了它,恢复出的库没有角色,应用连不上。

格式上:纯文本输出用 psql 恢复;-Fc 自定义格式支持压缩与选择性恢复单表;-Fd 目录格式支持 -j 并行导出恢复,大库必选。pg_dump 也是跨大版本升级的标准手段。标准演练流程:

# 1. 备份:自定义格式
pg_dump -Fc -d bookshop -f bookshop.dump

# 2. 建一个干净的新库(新建库默认从模板库 template1 复制而来,若模板被改过会带上残留修改;-T template0 用保证原始的模板建库,避开这类污染)
createdb -T template0 bookshop2

# 3. 并行恢复
pg_restore -d bookshop2 -j 4 bookshop.dump

恢复后别忘了在新库执行 ANALYZE;,否则统计信息缺失,查询可能莫名其妙地慢。角色与表空间要单独备份一次:pg_dumpall --globals-only > globals.sql。

物理备份与时间点恢复(PITR)

pg_dump 有个根本局限:备份里没有 WAL 日志,只能回到备份那一刻,恢复不到"误操作前一瞬间"。要做到任意时刻恢复,需要**物理备份(Physical Backup)**加持续归档。打个比方:pg_dump 像定期拍照——只能回到按下快门的时刻;**时间点恢复(PITR,Point-in-Time Recovery)**则像行车记录仪持续录像——配合 WAL(Write-Ahead Log,预写日志)(由 16MB 的段文件组成)的连续归档,可以把数据库重放回过去的任意时刻,比如误执行 DROP TABLE 之前。

流程概念如下。先在 postgresql.conf 打开归档:

wal_level = replica
archive_mode = on
# 归档到 /arch 目录;test 防止覆盖已有归档
archive_command = 'test ! -f /arch/%f && cp %p /arch/%f'

再做一次物理基础备份(要求 wal_level=replica、足够的 max_wal_senders 和 REPLICATION 权限):

pg_basebackup -D /backup/base -X stream -P

误删表之后的恢复步骤(概念):

  1. 停库,保留旧数据目录以备不测;
  2. 清空数据目录,把基础备份还原进去;
  3. 配置 restore_command = 'cp /arch/%f %p',并设定恢复目标,如 recovery_target_time = '2026-10-04 12:00:00+08'(选在误操作之前);
  4. 创建空文件 recovery.signal 后启动,数据库重放 WAL 直到目标时刻。

两点务必记住:PITR 恢复的是整个集群,不是单张表;初学者先掌握 pg_dump,PITR 了解流程即可。(PG 17 起还新增了 pg_basebackup --incremental 增量备份与 pg_combinebackup,大库备份更快。)

7.6 日常监控与维护

三个入口:现在、刚才、长期

数据库出了状况不用慌,从三个入口入手:pg_stat_activity 看"现在正在发生什么",慢查询日志看"刚才什么慢",pg_stat_statements 看"长期谁最耗时间"。

连接数与 pg_stat_activity

pg_stat_activity 视图每个服务器进程一行,能看到每个连接的状态(active、idle、idle in transaction)、正在执行的 SQL 和等待事件。连接监控三连:

-- ① 当前客户端连接数(对照 max_connections,默认 100)
SELECT count(*) FROM pg_stat_activity
WHERE backend_type = 'client backend';

-- ② 找出"开了事务不提交"的连接:既占连接数,又阻塞 autovacuum 清理旧版本行
SELECT pid, state, now() - state_change AS idle_for
FROM pg_stat_activity
WHERE state = 'idle in transaction';

-- ③ 按 库/用户/应用名 分组,看连接都被谁占了
SELECT datname, usename, application_name, count(*)
FROM pg_stat_activity
GROUP BY 1, 2, 3
ORDER BY 4 DESC;

遇到 too many clients already,不要无脑调大 max_connections:它默认 100、只能启动时修改,调大会预分配更多共享内存并增加进程调度开销;正确方向是引入连接池、排查连接泄漏。好在 superuser_reserved_connections(默认 3)为超级用户保留了应急槽位,连接占满时仍能用 postgres 账号登进去排查。idle in transaction 的连接多半来自应用异常路径忘了回滚——正是第 05 章警告过的"长事务",值得定期巡检。

慢查询日志

log_min_duration_statement 参数让服务器记录所有耗时超过阈值的语句。注意它默认是 -1(关闭)——不显式打开,日志里什么都没有:

-- 记录耗时超过 250 毫秒的语句;改完重载配置即可,无需重启
ALTER SYSTEM SET log_min_duration_statement = '250ms';
SELECT pg_reload_conf();

随后执行 SELECT pg_sleep(0.5); 人为制造一条半秒的查询,就能在日志中看到它;再用 EXPLAIN (ANALYZE, BUFFERS) 分析其执行计划,定位慢在哪一步。

pg_stat_statements:找出累计热点

日志只能翻"最近",要回答"哪类查询最该优化",用 pg_stat_statements 扩展(第 04 章的慢查询排查动线里已经用过它):它按归一化语句(常量替换为 $1)累计每类查询的调用次数与总、平均耗时。启用分两步——先在 postgresql.conf 设置 shared_preload_libraries = 'pg_stat_statements'(需重启),再在库中创建扩展并查询:

CREATE EXTENSION pg_stat_statements;

-- 总耗时最高的 5 类语句:优化从这里下手
SELECT calls,
       round(total_exec_time::numeric, 1) AS total_ms,
       round(mean_exec_time::numeric, 1)  AS mean_ms,
       query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;

(耗时字段名以 PG 18 文档为准;使用旧版本时请对照当版手册。)

autovacuum:MVCC 的清道夫

第 05 章讲过:UPDATE/DELETE 会留下旧版本;不再被任何相关快照需要的版本成为可清理的死元组(dead tuple),依靠 VACUUM 回收空间、更新统计信息、防止事务 ID 回卷(wraparound)(autovacuum_freeze_max_age 默认 2 亿事务时触发强制清理)。autovacuum 后台进程自动完成这项工作,默认开启,触发公式是"基础阈值 + 比例因子 × 表行数":默认 autovacuum_vacuum_threshold = 50、autovacuum_vacuum_scale_factor = 0.2,即约 20% 的行发生变更就清理一次。

对千万行的订单表,20% 意味着要积攒两百万行变更才触发,可以按表覆盖参数:

-- 订单表变更频繁,把比例因子收紧到 5%,让它更勤快地清理
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.05);

配套的巡检是查表年龄(对照 2 亿上限)。这段 SQL 涉及系统目录,读不懂可先照抄:pg_class 是系统目录表,库里每张表在其中占一行;oid::regclass 把内部对象编号显示成人可读的表名,relfrozenxid 记录该表最近一次"冻结"清理时的事务号,age() 算出它距今多少个事务:

-- 年龄最大的 5 张表:越接近 2 亿越要警惕
SELECT c.oid::regclass, age(c.relfrozenxid)
FROM pg_class c
WHERE c.relkind = 'r'
ORDER BY 2 DESC
LIMIT 5;

千万不要为了"提升性能"关闭 autovacuum:表膨胀与回卷的风险,远大于它偶尔的开销。确实要手动整理空间时,避免在业务高峰执行 VACUUM FULL——它需要排他锁。

常见误区

  • 用字符串拼接或 f-string 把用户输入拼进 SQL,以为"输入是内部的、可信"——OWASP 明确指出校验过的数据也不等于可安全拼接,外部输入必须走占位符。
  • 想用占位符绑定表名、列名或排序方向:ORDER BY %s 传 'price DESC' 行不通,标识符不是值;必须白名单映射或用 psycopg.sql.Identifier,否则留下注入口。
  • 以为用了 ORM 就自动免疫注入:在 SQLAlchemy 里写 text(f"... name = '{name}'") 照样是拼接;ORM 只有使用绑定参数时才安全。
  • 应用直接用 postgres 超级用户连接(本地习惯带进生产):一旦注入被利用,攻击者可用 COPY ... TO PROGRAM 执行系统命令;应创建专用最小权限角色。
  • 报 too many clients already 就无脑调大 max_connections:每连接一个进程,内存与调度开销线性上涨;正确做法是引入连接池并排查连接泄漏。
  • 在事务池化模式下使用 SET、LISTEN/NOTIFY、会话级 advisory lock、跨事务临时表,出现"时好时坏"的诡异错误——先查 PgBouncer 官方的 SQL 兼容表。
  • 只备份从不演练恢复;或用 pg_dump 备份却忘了 pg_dumpall --globals-only,恢复出的库没有角色,应用连不上;恢复后还忘了跑 ANALYZE。
  • 以为 pg_dump 备份能"恢复到误操作之前的一瞬间":逻辑备份没有 WAL,只有基础备份加连续归档才能做 PITR,且 PITR 只能恢复整个集群。
  • 为"提升性能"关闭 autovacuum:短期省事,长期表膨胀、统计信息过期、回卷风险持续累积;应按表调整阈值,VACUUM FULL 也要避开业务高峰。
  • 以为慢查询日志默认开着:log_min_duration_statement 默认 -1(禁用),必须显式设置如 250ms;再配合 pg_stat_statements 看累计热点,别只靠翻日志猜。
  • 连接串密码含 @ : / 等特殊字符直接写进 URI 会解析错位:必须百分号编码;更好的做法是密码走环境变量或 ~/.pgpass,也别把带密码的连接串提交进 git。
  • 忽视 idle in transaction 的连接:应用开了事务不提交(常见于异常路径没有回滚),既占连接数又阻塞 autovacuum,需用 pg_stat_activity 定期巡检。

参考来源

  1. PostgreSQL 18 官方文档 §32.1 Database Connection Control Functions:官方资料
  2. PgBouncer 官方文档 Features:官方资料
  3. OWASP SQL Injection Prevention Cheat Sheet:官方资料
  4. psycopg 3 官方文档 Passing parameters to SQL queries:官方资料
  5. SQLAlchemy 2.0 官方文档 Engine 教程与 ORM 快速入门:官方资料、官方资料
  6. PostgreSQL 18 官方文档 §25.1 SQL Dump:官方资料
  7. PostgreSQL 18 官方文档 §25.3 Continuous Archiving and Point-in-Time Recovery:官方资料
  8. PostgreSQL 18 官方文档 pg_basebackup 参考页:官方资料
  9. PostgreSQL 18 官方文档 §19.10 Vacuuming 与 §24.1 Routine Vacuuming:官方资料、官方资料
  10. PostgreSQL 18 官方文档 §19.8 Error Reporting and Logging:官方资料
  11. PostgreSQL 18 官方文档 §21 Database Roles:官方资料
  12. PostgreSQL 18 官方文档 §19.3 Connections and Authentication:官方资料
  13. PostgreSQL 18 官方文档 §27.2 The Cumulative Statistics System:官方资料
  14. PostgreSQL 18 官方文档 F.32 pg_stat_statements:官方资料
  15. PostgreSQL 官方版本支持政策页:官方资料

最后更新于

本页目录