Skip to content

第 2 章 psql 与基础 SQL

学习目标:能熟练使用 psql 命令行(包括 30+ 个元命令);理解 database / schema / table 三层结构和 search_path 的工作原理;能写出建库、建表、增删改查的完整一条龙;用一个「图书管理系统」练手,掌握 PG 特色的 INSERT ... RETURNINGILIKEFETCH FIRST n ROWS ONLY


2.1 psql:PG 的瑞士军刀

2.1.1 什么是 psql?

psql 是 PostgreSQL 官方自带的命令行客户端。你可以把它理解成 PG 世界的 mysql 命令,但是功能强一个数量级 —— 它既是 SQL 终端,也是脚本执行器、库管理器、交互式调试器。

                ┌──────────────┐  TCP  ┌─────────────────┐
                │   psql CLI   │ ◀──▶  │  PostgreSQL     │
                │  (客户端)   │       │   Backend 进程   │
                └──────────────┘       └─────────────────┘
                       │                          │
                       ▼                          ▼
                 .psqlrc 配置          系统目录 pg_class
                 历史命令、超时          pg_namespace 等

2.1.2 连接:5 个常用参数

bash
# 标准写法
psql -h 127.0.0.1 -p 5432 -U postgres -d learn_pg

# 等价(带 -W 强制提示输入密码)
psql -h 127.0.0.1 -p 5432 -U postgres -d learn_pg -W

# 用连接串(DSN)
psql "host=127.0.0.1 port=5432 dbname=learn_pg user=postgres"

# 用 URL(方便从配置文件读)
psql "postgresql://postgres@127.0.0.1:5432/learn_pg"
参数含义默认值
-hhostUNIX socket(Linux 通常 /var/run/postgresql
-pport5432
-Uuser当前 OS 用户名
-ddatabase-U 同名
-W强制密码提示(否则用 ~/.pgpass 或环境变量)

📌 与 MySQL 的区别:MySQL 默认用户是 root,PG 默认用户是 postgres;MySQL 用 -p 强制输密码,PG 是 -W(小心 -p 在 PG 里是端口)。

2.1.3 进入后的提示符

learn_pg=#  ← 当前数据库名 + (#超级用户 / >普通用户)
learn_pg-#  ← 上一行没结束(缺分号、引号没闭合)
learn_pg(# ← 在括号里

2.1.4 第一个会话:从 PING 到再见

sql
$ psql -h 127.0.0.1 -U postgres -d learn_pg
psql (17.0)
Type "help" for help.

learn_pg=# SELECT 1 + 1 AS answer;
 answer
--------
      2
(1 row)

learn_pg=# SELECT now(), current_database(), version();
              now              | current_database |                       version
-------------------------------+------------------+------------------------------------------------------
 2026-04-17 11:22:33.456789+08 | learn_pg         | PostgreSQL 17.0 on x86_64-pc-linux-gnu, ...
(1 row)

learn_pg=# \q

\q 是 psql 的「元命令」(meta command),下面会大讲特讲。


2.2 元命令大全(重点中的重点)

2.2.1 元命令是什么?

psql 中以反斜杠 \ 开头的命令叫元命令。它们不会发到服务端,而是 psql 客户端自己处理(查 catalog 后渲染表格)。元命令不需要分号结尾

            ┌────────────┐                 ┌────────────────┐
\d users → │  psql 解析  │ → 内部转换 → SELECT ... FROM pg_class ... → │ Backend 执行   │
            └────────────┘                                              └────────────────┘

                        psql 把结果排版成 \d 的好看格式

想看 \d 内部到底跑了什么 SQL?加 -E 启动:psql -E -h 127.0.0.1 -U postgres -d learn_pg,然后每次执行元命令都会先打印对应的 SQL —— 这是学 PG 内部表的最快方法。

2.2.2 必背的 30+ 元命令

① 帮助类

命令作用
\?列出所有元命令(最常用!
\h列出所有 SQL 语法
\h SELECTSELECT 的完整语法
\? variables查 psql 变量帮助

② 库 / 用户 / 模式

命令作用等价 SQL
\l\list列出所有数据库SELECT datname FROM pg_database;
\c db_name切换数据库(断开 + 重连)
\du列出所有角色(用户)SELECT * FROM pg_roles;
\dn列出所有 schemaSELECT * FROM pg_namespace;

③ 对象类(最常用)

命令作用
\d列出当前 schema 下所有对象(表/视图/序列)
\dt只列
\dt+列表 + 行数估算 + 大小
\dt schema.*列指定 schema 下的表
\d table_name看表结构(列、索引、约束、触发器)
\d+ table_name看表结构 + 列存储参数 + 描述
\dv列出视图
\df列出函数
\df+ funcname看函数定义
\di列出索引
\ds列出序列
\dT列出自定义类型
\dx列出已安装扩展

④ 输入 / 输出

命令作用
\i file.sql执行外部 SQL 文件
\o /tmp/out.txt把后续查询结果输出到文件
\o取消输出重定向
\copy users TO 'users.csv' CSV HEADER客户端侧导出 CSV
\copy users FROM 'users.csv' CSV HEADER客户端侧导入 CSV
\e$EDITOR 打开编辑器写 SQL
\!执行 shell 命令(如 \! ls

⑤ 显示控制

命令作用
\x切换扩展显示模式(每行一列,长结果必备)
\x auto结果太宽时自动开扩展显示
\timing切换是否显示每个 SQL 的耗时(调优必开
\pset format aligned默认对齐输出
\pset format csvCSV 输出
\pset format jsonJSON 输出(PG 12+)
\pset null '<NULL>'NULL 显示成 <NULL> 而不是空白
\pset border 2加粗边框

⑥ 历史 / 调试 / 其他

命令作用
\q退出
\g重新执行上一条 SQL
\s显示历史命令
\set ECHO_HIDDEN on显示元命令背后跑的 SQL(同 -E
\conninfo当前连接信息
\password user修改密码
\watch 2每 2 秒重复执行上条 SQL(监控类查询神器)

2.2.3 实战速查:5 个最常用组合

sql
-- ① 看完整对象列表
learn_pg=# \dt+
                                       List of relations
 Schema |    Name    | Type  |  Owner   | Persistence | Access method |  Size   | Description
--------+------------+-------+----------+-------------+---------------+---------+-------------
 public | ch2_authors    | table | postgres | permanent   | heap          | 16 kB   |
 public | ch2_books      | table | postgres | permanent   | heap          | 32 kB   |
 public | ch2_categories | table | postgres | permanent   | heap          | 16 kB   |
(3 rows)

-- ② 看某张表结构
learn_pg=# \d ch2_books
                                          Table "public.ch2_books"
   Column     |            Type             | Collation | Nullable |              Default
--------------+-----------------------------+-----------+----------+-----------------------------------
 id           | bigint                      |           | not null | nextval('ch2_books_id_seq'::regclass)
 title        | text                        |           | not null |
 author_id    | bigint                      |           |          |
 category_id  | bigint                      |           |          |
 isbn         | varchar(20)                 |           |          |
 published_at | date                        |           |          |
 stock        | integer                     |           |          | 0
 price        | numeric(10,2)               |           |          |
 created_at   | timestamp with time zone    |           |          | now()
Indexes:
    "ch2_books_pkey" PRIMARY KEY, btree (id)
    "ch2_books_isbn_key" UNIQUE CONSTRAINT, btree (isbn)
    "idx_ch2_books_title" btree (title)
Foreign-key constraints:
    "ch2_books_author_id_fkey" FOREIGN KEY (author_id) REFERENCES ch2_authors(id)
    "ch2_books_category_id_fkey" FOREIGN KEY (category_id) REFERENCES ch2_categories(id)

-- ③ 计时 + 扩展显示,配合长查询
learn_pg=# \timing on
Timing is on.
learn_pg=# \x auto
Expanded display is used automatically.
learn_pg=# SELECT * FROM ch2_books LIMIT 1;
-[ RECORD 1 ]+--------------------------
id           | 1
title        | 三体
author_id    | 1
category_id  | 1
isbn         | 9787229030933
published_at | 2008-01-01
stock        | 12
price        | 38.50
created_at   | 2026-04-17 10:00:00+08

Time: 1.234 ms

-- ④ 把元命令背后的 SQL 显出来
learn_pg=# \set ECHO_HIDDEN on
learn_pg=# \dn
********* QUERY **********
SELECT n.nspname AS "Name", pg_catalog.pg_get_userbyid(n.nspowner) AS "Owner"
FROM pg_catalog.pg_namespace n
WHERE n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
ORDER BY 1;
**************************
   List of schemas
   Name   |  Owner
----------+----------
 public   | pg_database_owner
(1 row)

-- ⑤ 监控:每 2 秒看一次活跃连接
learn_pg=# SELECT pid, state, query FROM pg_stat_activity WHERE state != 'idle';
learn_pg=# \watch 2

📌 与 MySQL 的区别:MySQL 客户端的 SHOW TABLES; DESC users; STATUS; 在 PG 里都换成了反斜杠元命令。背 5 个:\l \dt \d \du \dn,基本日常够用。


2.3 三层结构:Database / Schema / Table

2.3.1 一个图书馆的类比

╔══════════════════════════════════════════════════════════════╗
║                    PostgreSQL 实例                            ║
║                  (= 一座大学城)                              ║
║                                                                ║
║   ┌───────────┐ ┌───────────┐ ┌───────────────┐               ║
║   │ database  │ │ database  │ │  database     │               ║
║   │ learn_pg  │ │ ecommerce │ │  analytics    │  ← 数据库      ║
║   │ (= 图书馆 A)│ │(= 图书馆 B)│ │(= 图书馆 C)    │     (= 图书馆) ║
║   └─────┬─────┘ └───────────┘ └───────────────┘               ║
║         │                                                       ║
║         │ database 之间不能直接 JOIN(要用 FDW)                 ║
║         ▼                                                       ║
║   ┌──────────────────────────────────────────┐                 ║
║   │ database learn_pg                          │                 ║
║   │  ┌────────────┐  ┌──────────┐  ┌────────┐ │                 ║
║   │  │ schema     │  │ schema   │  │schema  │ │ ← 模式          │
║   │  │ public     │  │ stats    │  │ archive│ │   (= 图书馆楼层) │
║   │  └─────┬──────┘  └──────────┘  └────────┘ │                 ║
║   │        │                                    │                 ║
║   │        ▼                                    │                 ║
║   │  ┌──────────────────────────────────┐     │                 ║
║   │  │ schema public                      │     │                 ║
║   │  │   ┌─────┐ ┌──────┐ ┌──────────┐   │     │                 ║
║   │  │   │books│ │authors│ │categories│   │ ← 表(= 书架)         │
║   │  │   └─────┘ └──────┘ └──────────┘   │     │                 ║
║   │  └──────────────────────────────────┘     │                 ║
║   └──────────────────────────────────────────┘                 ║
╚══════════════════════════════════════════════════════════════╝
  • database(图书馆):物理上隔离,连接时只能选一个,库之间默认不能直接 JOIN。
  • schema(楼层 / 命名空间):同一个 database 内的逻辑分组,可以跨 schema JOIN。
  • table(书架):实际存数据的地方。

📌 与 MySQL 的对比

  • MySQL 没有真正的 schemaCREATE SCHEMA xxx 在 MySQL 里就是 CREATE DATABASE xxx 的同义词。
  • PG 的 schema 是「真·命名空间」:可以建 app1.usersapp2.users 两个不冲突的同名表,多租户 SaaS 项目常用 schema 做「软隔离」。

2.3.2 search_path:表名查找的「PATH 环境变量」

很多人写 SQL 不带 schema 前缀(SELECT * FROM ch2_books),那 PG 怎么知道找哪个 schema 的 books 表?

答案是 search_path。它就像 Linux 的 $PATH —— 按顺序找第一个匹配的:

sql
learn_pg=# SHOW search_path;
   search_path
-----------------
 "$user", public
(1 row)

-- 含义:先找名字等于当前用户的 schema(如果存在),找不到再找 public

实操:

sql
learn_pg=# CREATE SCHEMA stats;
CREATE SCHEMA

learn_pg=# CREATE TABLE stats.daily(d DATE, pv BIGINT);
CREATE TABLE

learn_pg=# SELECT * FROM daily;
ERROR:  relation "daily" does not exist
LINE 1: SELECT * FROM daily;
                      ^

-- 怎么查 stats.daily?两个办法:
-- 办法 ①:写全名
learn_pg=# SELECT * FROM stats.daily;

-- 办法 ②:把 stats 加进 search_path
learn_pg=# SET search_path = stats, public;
SET
learn_pg=# SELECT * FROM daily;   -- 现在能找到了

2.3.3 创建你的第一个数据库 + schema

sql
-- 在 postgres 默认库里执行(注意:CREATE DATABASE 不能在事务里用)
postgres=# CREATE DATABASE learn_pg
              WITH OWNER = postgres
                   ENCODING = 'UTF8'
                   LC_COLLATE = 'en_US.UTF-8'
                   LC_CTYPE = 'en_US.UTF-8'
                   TEMPLATE = template0;
CREATE DATABASE

-- 切到新库
postgres=# \c learn_pg
You are now connected to database "learn_pg" as user "postgres".

-- 在 learn_pg 里建一个新 schema
learn_pg=# CREATE SCHEMA library AUTHORIZATION postgres;
CREATE SCHEMA

learn_pg=# \dn
   List of schemas
   Name    |   Owner
-----------+-----------
 library   | postgres
 public    | pg_database_owner
(2 rows)

📌 与 MySQL 的区别:MySQL 的 CREATE DATABASE 通常自动指定字符集(utf8mb4);PG 的字符集在 initdb 时已经确定整个 cluster,单库通常都是 UTF8,无需操心。


2.4 DDL:建表与改表

2.4.1 CREATE TABLE 标准写法

我们用一个图书管理系统贯穿 demo(同一份建表语句也放在 02_psql_basic/init.sql):

sql
-- 类别表
CREATE TABLE ch2_categories (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- SQL 标准的自增
    name        VARCHAR(64) NOT NULL UNIQUE,
    description TEXT
);

-- 作者表
CREATE TABLE ch2_authors (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name        VARCHAR(64) NOT NULL,
    country     VARCHAR(32),
    born_year   INTEGER CHECK (born_year > 0)
);

-- 图书表(带外键)
CREATE TABLE ch2_books (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title        TEXT NOT NULL,
    author_id    BIGINT REFERENCES ch2_authors(id),
    category_id  BIGINT REFERENCES ch2_categories(id),
    isbn         VARCHAR(20) UNIQUE,
    published_at DATE,
    stock        INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
    price        NUMERIC(10, 2) CHECK (price >= 0),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 加一个普通索引方便按书名搜
CREATE INDEX idx_ch2_books_title ON ch2_books(title);

几个 PG 特色点:

  1. GENERATED ALWAYS AS IDENTITY:SQL 标准的自增写法,优于 SERIAL。从 PG 10 起官方推荐用这种,因为 SERIAL 的本质是隐式建一个序列,权限管理麻烦。
  2. TIMESTAMPTZ:带时区时间戳。强烈推荐用这个而不是 TIMESTAMP,能避免时区切换 bug。
  3. NUMERIC(10,2):精确小数,对应金额场景,比 FLOAT 安全。
  4. CHECK:行级检查约束,可以写任意布尔表达式。

📌 与 MySQL 的对比

MySQLPostgreSQL
INT AUTO_INCREMENTBIGINT GENERATED ALWAYS AS IDENTITY
VARCHAR(255) 没限制TEXT 推荐(VARCHAR 只用于长度真有限制时)
DECIMAL(10,2)NUMERIC(10,2)
DATETIMETIMESTAMPTZ
CHECK 5.7 之前忽略CHECK 严格执行

2.4.2 ALTER TABLE:修改表

sql
-- 加一列
ALTER TABLE ch2_books ADD COLUMN description TEXT;

-- 改类型
ALTER TABLE ch2_books ALTER COLUMN price TYPE NUMERIC(12, 2);

-- 加约束
ALTER TABLE ch2_books ADD CONSTRAINT ch2_books_price_positive CHECK (price > 0);

-- 重命名
ALTER TABLE ch2_books RENAME COLUMN description TO summary;
ALTER TABLE ch2_books RENAME TO ch2_book;
ALTER TABLE ch2_book RENAME TO ch2_books;       -- 改回去

-- 删一列
ALTER TABLE ch2_books DROP COLUMN summary;

🔥 PG 的杀手锏:DDL 是 事务性的,可以放进 BEGIN; ... ROLLBACK;

sql
BEGIN;
ALTER TABLE ch2_books ADD COLUMN bad_col TEXT;
-- 发现搞错了
ROLLBACK;
-- bad_col 没有留下!

MySQL 做不到。

2.4.3 DROP

sql
DROP TABLE IF EXISTS ch2_books CASCADE;   -- CASCADE:级联删依赖(如外键、视图)
DROP SCHEMA IF EXISTS stats CASCADE;
DROP DATABASE IF EXISTS scratch;

2.5 DML:增删改查

2.5.1 INSERT —— 含 PG 特色 RETURNING

sql
-- 单条插入
INSERT INTO ch2_authors (name, country, born_year)
VALUES ('刘慈欣', '中国', 1963);

-- 多条
INSERT INTO ch2_authors (name, country, born_year) VALUES
    ('村上春树', '日本', 1949),
    ('George Orwell', '英国', 1903),
    ('J.K. Rowling', '英国', 1965);

-- 🌟 PG 特色:INSERT ... RETURNING 把刚生成的字段拿回来
INSERT INTO ch2_categories (name, description)
VALUES ('科幻', '面向未来的想象力')
RETURNING id, name, description;
--  id |  name  |    description
-- ----+--------+-------------------
--   1 | 科幻    | 面向未来的想象力
-- (1 row)

RETURNING 是 PG / Oracle 都有但 MySQL 8.0 之前没有的能力。它能省掉「插入后再 SELECT 一次拿主键」的来回开销 —— 在 web 框架里特别有用。

2.5.2 UPDATE

sql
UPDATE ch2_books SET stock = stock + 5 WHERE id = 1;

-- 也能 RETURNING
UPDATE ch2_books SET price = price * 0.9
WHERE category_id = 1
RETURNING id, title, price;

2.5.3 DELETE

sql
DELETE FROM ch2_books WHERE stock = 0;
DELETE FROM ch2_books WHERE id = 99 RETURNING *;  -- 删完返回被删的行

2.5.4 SELECT —— 基础句法

sql
SELECT id, title, price
FROM ch2_books
WHERE category_id = 1 AND stock > 0
ORDER BY published_at DESC
LIMIT 10;
子句作用
SELECT选哪些列
FROM从哪张表(或 JOIN 多表)
WHERE行过滤
GROUP BY聚合分组(第 5 章详讲)
HAVING聚合后过滤
ORDER BY排序
LIMIT / OFFSET分页

2.6 WHERE 子句的 8 种武器

sql
-- 1. 等值
SELECT * FROM ch2_books WHERE id = 1;

-- 2. AND / OR / NOT
SELECT * FROM ch2_books WHERE stock > 0 AND price < 50;
SELECT * FROM ch2_books WHERE category_id = 1 OR category_id = 2;
SELECT * FROM ch2_books WHERE NOT (stock = 0);

-- 3. IN
SELECT * FROM ch2_books WHERE category_id IN (1, 2, 3);

-- 4. BETWEEN ... AND ...
SELECT * FROM ch2_books WHERE price BETWEEN 20 AND 50;
SELECT * FROM ch2_books WHERE published_at BETWEEN '2020-01-01' AND '2024-12-31';

-- 5. LIKE:模式匹配(区分大小写)
SELECT * FROM ch2_books WHERE title LIKE '三%';     -- 以"三"开头
SELECT * FROM ch2_books WHERE title LIKE '%体%';    -- 含"体"
SELECT * FROM ch2_books WHERE title LIKE '_体%';    -- 任意 1 字 + "体"

-- 6. 🌟 ILIKE:PG 特色,大小写不敏感的 LIKE
SELECT * FROM ch2_books WHERE title ILIKE '%harry%';
-- 同时匹配 Harry / harry / HARRY

-- 7. IS NULL / IS NOT NULL
SELECT * FROM ch2_books WHERE category_id IS NULL;

-- 8. 正则(PG 特色,基于 POSIX)
SELECT * FROM ch2_books WHERE title ~ '^三体[0-9]?$';   -- 区分大小写
SELECT * FROM ch2_books WHERE title ~* '^三体';         -- 不区分大小写
SELECT * FROM ch2_books WHERE title !~ 'test';          -- 不匹配

📌 与 MySQL 的区别:MySQL 的 LIKE 默认不区分大小写(依赖 collation),PG 的 LIKE 严格区分ILIKE 才不区分 —— 这点最容易踩坑。

2.6.1 NULL 的真相:SQL 三值逻辑

sql
-- 千万别用 = NULL!
SELECT * FROM ch2_books WHERE category_id = NULL;     -- ❌ 永远返回空
SELECT * FROM ch2_books WHERE category_id IS NULL;    -- ✅ 正确

-- 因为 SQL 里 NULL 不是值,是「未知」:
--   NULL = NULL    →  NULL(既不是 true 也不是 false)
--   NULL = 1       →  NULL
--   NULL <> NULL   →  NULL

PG 严格遵守 SQL 标准的「三值逻辑」(true / false / NULL),所以 WHERE col = NULL 永远没结果。MySQL 在某些 mode 下会容忍这种写法,PG 不会。


2.7 ORDER BY 与分页:LIMIT vs FETCH FIRST

sql
-- 写法 ①:MySQL/PG 都支持
SELECT * FROM ch2_books
ORDER BY price DESC, id ASC
LIMIT 10 OFFSET 20;

-- 写法 ②:标准 SQL(PG 也支持,MySQL 8.0+ 支持)
SELECT * FROM ch2_books
ORDER BY price DESC, id ASC
OFFSET 20 ROWS
FETCH FIRST 10 ROWS ONLY;

两点 PG 细节

  1. PG 中没有 ORDER BY 的查询,行的顺序是不确定的(哪怕你看着像是按插入顺序),分页务必显式 ORDER BY
  2. OFFSET 大了性能差(PG 也要扫前面 N 行),千万级数据用「主键游标」代替(WHERE id > 上次最后一个 id)。

🌟 NULL 排序:PG 默认 ASC 时 NULL 在最后、DESC 时 NULL 在最前。可以显式 ORDER BY price DESC NULLS LAST。MySQL 行为相反。


2.8 一个完整的「图书管理系统」实操

把上面所有知识串起来。下面整段记录可以复制到 psql 里直接跑

sql
\timing on
\x auto

-- ========== ① 准备数据(已在 init.sql 里建表) ==========
\i /data/workspace/learnNote/postgre/02_psql_basic/init.sql

-- ========== ② 看看库里有什么 ==========
learn_pg=# \dt
              List of relations
 Schema |    Name    | Type  |  Owner
--------+------------+-------+----------
 public | ch2_authors    | table | postgres
 public | ch2_books      | table | postgres
 public | ch2_categories | table | postgres
(3 rows)

-- ========== ③ 增 ==========
learn_pg=# INSERT INTO ch2_authors(name, country, born_year)
            VALUES ('陈楸帆', '中国', 1981)
            RETURNING id;
 id
----
  6
(1 row)

-- ========== ④ 查 ==========
-- 4.1 当下还有库存的科幻类书(按价格降序)
SELECT b.id, b.title, a.name AS author, b.price, b.stock
FROM ch2_books b
JOIN ch2_authors a    ON a.id = b.author_id
JOIN ch2_categories c ON c.id = b.category_id
WHERE c.name = '科幻' AND b.stock > 0
ORDER BY b.price DESC
FETCH FIRST 5 ROWS ONLY;

-- 4.2 模糊搜书名(PG 大小写不敏感)
SELECT id, title FROM ch2_books WHERE title ILIKE '%harry%';

-- 4.3 缺货的书(NULL 处理)
SELECT id, title FROM ch2_books WHERE stock = 0 OR stock IS NULL;

-- ========== ⑤ 改 ==========
-- 全场科幻 9 折
UPDATE ch2_books
SET price = ROUND(price * 0.9, 2)
WHERE category_id = (SELECT id FROM ch2_categories WHERE name = '科幻')
RETURNING id, title, price;

-- ========== ⑥ 删 ==========
-- 把绝版且无库存的书删掉
DELETE FROM ch2_books
WHERE stock = 0 AND published_at < '2010-01-01'
RETURNING id, title;

-- ========== ⑦ 看活跃事务 ==========
SELECT pid, state, query
FROM pg_stat_activity
WHERE datname = 'learn_pg' AND state != 'idle';

2.9 psql 输出格式控制

2.9.1 \x 扩展显示:长行救星

-- 普通模式:列太多撑爆屏幕
learn_pg=# SELECT * FROM ch2_books LIMIT 1;
 id | title | author_id | category_id |     isbn      | published_at | stock | price |          created_at
----+-------+-----------+-------------+---------------+--------------+-------+-------+-------------------------------
  1 | 三体  |         1 |           1 | 9787229030933 | 2008-01-01   |    12 | 38.50 | 2026-04-17 10:00:00.123456+08

-- 开 \x 后,每列一行
learn_pg=# \x
Expanded display is on.
learn_pg=# SELECT * FROM ch2_books LIMIT 1;
-[ RECORD 1 ]+-------------------------------
id           | 1
title        | 三体
author_id    | 1
category_id  | 1
isbn         | 9787229030933
published_at | 2008-01-01
stock        | 12
price        | 38.50
created_at   | 2026-04-17 10:00:00.123456+08

2.9.2 输出 CSV / JSON

sql
-- CSV
learn_pg=# \pset format csv
learn_pg=# SELECT id, title, price FROM ch2_books LIMIT 3;
id,title,price
1,三体,34.65
2,Harry Potter,52.00
3,1984,28.00

-- JSON(PG 12+,按行的 JSON 数组)
learn_pg=# \pset format aligned    -- 切回默认
learn_pg=# SELECT json_agg(t) FROM (SELECT id, title FROM ch2_books LIMIT 3) t;
                              json_agg
---------------------------------------------------------------------
 [{"id":1,"title":"三体"},{"id":2,"title":"Harry Potter"},{"id":3,"title":"1984"}]

2.9.3 \copy 导入导出(极快)

bash
# 客户端侧的 COPY,权限走 psql 用户,无需服务端文件系统权限
learn_pg=# \copy ch2_books TO '/tmp/books.csv' WITH CSV HEADER;
COPY 6
learn_pg=# \copy ch2_books FROM '/tmp/new_books.csv' WITH CSV HEADER;
COPY 100

COPY 是 PG 最快的批量加载方式,比 INSERT 快 10-100 倍 —— 数据迁移、ETL 场景必用。


2.10 DCL 简介:GRANT / REVOKE(详解见第 13 章)

sql
-- 创建一个只读用户
CREATE ROLE reader LOGIN PASSWORD 'reader_pass';

-- 授读权限
GRANT CONNECT ON DATABASE learn_pg TO reader;
GRANT USAGE ON SCHEMA public TO reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO reader;

-- 让以后新建的表也自动有 SELECT 权限
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO reader;

-- 收回
REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM reader;

📌 PG 的权限模型核心两点: ① 没有 USAGE ON SCHEMA 的话,给 SELECT ON TABLE 是无效的; ② ALTER DEFAULT PRIVILEGES 只对之后创建的对象生效。

第 13 章会展开行级安全(Row-Level Security)、pg_hba.conf、SSL 配置等。


2.11 底层一瞥:psql 元命令背后发生了什么

关键点:所有元命令都是「psql 客户端把它翻译成对系统目录(system catalog)的 SQL 查询」,然后渲染输出。所以掌握元命令本质上等于学了一遍 PG 的 catalog 模型。


2.12 本章小结

┌────────────────────────────────────────────────────────────┐
│                       本章核心要点                           │
├────────────────────────────────────────────────────────────┤
│                                                              │
│ ① psql 元命令必背 8 个:                                       │
│    \?  \l  \c  \dt  \d <tbl>  \du  \dn  \timing  \x  \i      │
│                                                              │
│ ② 三层结构:database → schema → table                        │
│    schema 是 PG 比 MySQL 多出来的「真·命名空间」              │
│    search_path 决定不写 schema 时找谁                          │
│                                                              │
│ ③ 建表 4 个最佳实践:                                          │
│    • 用 GENERATED ALWAYS AS IDENTITY 而不是 SERIAL            │
│    • 时间戳一律 TIMESTAMPTZ                                   │
│    • 金额用 NUMERIC(p,s)                                       │
│    • 主键 BIGINT,避免将来溢出                                  │
│                                                              │
│ ④ INSERT/UPDATE/DELETE 都支持 RETURNING(PG 特色)             │
│                                                              │
│ ⑤ ILIKE 才是大小写不敏感(LIKE 严格区分),与 MySQL 反着来     │
│                                                              │
│ ⑥ NULL 三值逻辑:用 IS NULL,永远别用 = NULL                   │
│                                                              │
│ ⑦ 分页用 LIMIT/OFFSET 或 FETCH FIRST n ROWS ONLY,            │
│    OFFSET 大时改用主键游标                                     │
│                                                              │
│ ⑧ DDL 在 PG 里是事务性的,可以 ROLLBACK                       │
│                                                              │
└────────────────────────────────────────────────────────────┘

🎮 配套演示

用浏览器打开 ./02_psql_basic/demo.html,跟着可视化动画再走一遍本章核心概念。

配套代码在 ./02_psql_basic/code/,每个脚本都可以独立 python xxx.py 运行,先跑 init.sql 准备数据。


2.13 面试高频题

Q1:psql 中 \dSHOW TABLES 有什么区别?背后的原理是?

考察点:是否理解 psql 元命令是「客户端侧」还是「服务端侧」。

标准答案

  1. \d 是 psql 客户端的元命令不是 SQL。它不会发到服务端 —— psql 自己把它翻译成对系统目录(pg_class / pg_namespace / pg_attribute 等)的 SQL 查询,再把结果按特定格式渲染。

  2. 服务端不存在 \d 这个命令:从 JDBC、psycopg、Go 的 pgx 驱动里发 \d users 会报语法错误。

  3. 想验证:用 psql -E 启动,或在 psql 里 \set ECHO_HIDDEN on,每次执行元命令都会先打印对应 SQL。

  4. PG 没有 MySQL 风格的 SHOW TABLES,但有等价 SQL:

    sql
    SELECT table_schema, table_name
    FROM information_schema.tables
    WHERE table_type = 'BASE TABLE'
      AND table_schema NOT IN ('pg_catalog','information_schema');

加分项:能列出几个常用 catalog 表 —— pg_class(所有 relation:表/视图/索引/序列)、pg_namespace(schema)、pg_attribute(列)、pg_index(索引),以及对外友好的 information_schema.*

易错点:以为 \d 在所有客户端通用 —— 它只是 psql 的功能,DBeaver / Navicat 里查表结构是各家自己实现。


Q2:PostgreSQL 的 schema 是什么?和 MySQL 的「数据库」有什么区别?

考察点:对 PG 三层结构的理解,以及多租户场景下的方案选择。

标准答案

  1. MySQL 把 schema 等同于 databaseCREATE SCHEMA xCREATE DATABASE x 是同义词。
  2. PG 是真正的三层结构database → schema → table
    • 一个 PG 实例下可以有多个 database,database 之间是物理隔离,不能直接 JOIN(要用 FDW 跨库);
    • 一个 database 下可以有多个 schema,schema 是命名空间,可以无缝跨 schema JOIN 和外键引用;
    • schema 下才是真正的表 / 视图 / 函数等对象。
  3. search_path 决定查表时找哪个 schema:默认是 "$user", public,可以 SET search_path 临时改。
  4. 典型用法
    • 多租户 SaaS:每个租户一个 schema(tenant_001.userstenant_002.users),共享同一份建表 DDL;
    • 大型项目按业务模块分 schema(auth.* / order.* / payment.*);
    • 临时实验放在自己的私有 schema 不污染 public。

加分项

  • 能说出 schema 隔离 vs database 隔离的取舍:schema 下事务、连接共享,性能好但隔离弱;database 隔离强但跨库查询要走 FDW(postgres_fdw)。
  • 能说出 schema 不能跨 database:所以多租户用 schema 时所有租户共用一个连接池,扩容到一定规模会改用「逻辑分库」。

易错点:把 PG 的 CREATE SCHEMA 当成 CREATE DATABASE 来用 —— PG 里这是两个完全不同的概念。


Q3:PG 的 LIKEILIKE 有什么区别?为什么 MySQL 的 LIKE 不区分大小写而 PG 区分?

考察点:对字符串匹配 + 字符集 / collation 的理解。

标准答案

  1. LIKE 在 PG 中严格区分大小写title LIKE 'harry%' 不会匹配 Harry Potter
  2. ILIKE 是 PG 特有的忽略大小写版本I = Insensitive)。
  3. MySQL 的 LIKE 大小写敏感性取决于列的 collation:默认 utf8mb4_general_ciutf8mb4_0900_ai_ci_ci = case insensitive,所以默认不区分;如果改成 _bin 则区分。
  4. 底层差异:PG 的字符串比较行为由数据库 collation + 列 collation 控制,但 LIKE 操作符本身的实现路径是字符级二进制比较,与排序 collation 解耦;MySQL 把 LIKE 与 collation 紧密绑定。
  5. PG 还提供:
    • ~ / ~*:POSIX 正则(区分 / 不区分大小写)
    • ~~ / ~~*:等价于 LIKE / ILIKE 的操作符形式
    • 全文检索 tsvector @@ tsquery(第 18 章详讲)

加分项

  • 提到为高效模糊搜索可以建 pg_trgm 扩展上的 GIN/GIST 索引,让 ILIKE '%xxx%' 也能走索引(普通 B-Tree 索引在前缀通配符场景失效);

  • 提到 LIKE 'abc%'(前缀匹配)能走 B-Tree 索引,但前提是列使用 C 或 POSIX collation 创建索引text_pattern_ops),否则非英文 collation 下 LIKE 也走不了索引:

    sql
    CREATE INDEX idx_ch2_books_title_pattern
      ON ch2_books(title text_pattern_ops);

易错点:从 MySQL 迁过来的同学常忘了 PG 的 LIKE 区分大小写,导致线上模糊搜全部失效。


Q4:PG 的 DDL 可以放在事务里吗?这给运维带来什么好处?

考察点:对「事务性 DDL」机制的理解 + 实际运维价值。

标准答案

  1. PG 的 DDL 大部分是事务性的CREATE TABLE / ALTER TABLE / DROP TABLE / CREATE INDEX 都可以放在 BEGIN; ... ROLLBACK; 里,回滚后表不会留下。
  2. MySQL 不支持:MySQL 的 DDL 是隐式提交的,发出 CREATE TABLE 立刻提交当前事务,无法回滚。
  3. PG 的极少数例外
    • CREATE DATABASE / DROP DATABASE:因为涉及到目录创建/删除,无法回滚;
    • CREATE INDEX CONCURRENTLY:为了不阻塞写入,自身不在事务里;
    • VACUUM / REINDEX CONCURRENTLY 同理。
  4. 运维好处
    • 批量改表脚本可以打包:上线脚本里 10 条 DDL,中途出错 ROLLBACK 全回退,不会留下半改的烂摊子;
    • 配合 schema migration 工具(Alembic / Flyway / Liquibase)的「单事务迁移」模式,迁移失败自动回滚到迁移前状态;
    • 灰度发布更安全:可以 BEGIN 后跑全套 DDL + 数据校验,OK 才 COMMIT。

加分项

  • 能讲出 PG 实现事务性 DDL 的机制:所有 schema 信息存在系统目录表(pg_class / pg_attribute 等)里,这些 catalog 表本身也走 MVCC 与事务,所以 DDL 也能像普通 DML 一样回滚;
  • 能提到「长事务持有 ACCESS EXCLUSIVE 锁」的代价:DDL 默认拿独占锁,事务时间长会阻塞所有读写,所以生产上要么用 lock_timeout 加保护,要么用 CONCURRENTLY 系列变体。

易错点:以为「PG 所有 DDL 都能回滚」 —— CREATE DATABASE 等少数 DDL 例外。


Q5:PG 的自增列 SERIALGENERATED ALWAYS AS IDENTITY 有什么区别?为什么官方推荐后者?

考察点:对 PG 自增机制底层实现的理解。

标准答案

  1. SERIAL 是 PG 早期的语法糖。id SERIAL PRIMARY KEY 实际等价于:

    sql
    CREATE SEQUENCE table_id_seq;
    CREATE TABLE table (
        id INTEGER NOT NULL DEFAULT nextval('table_id_seq') PRIMARY KEY
    );
    ALTER SEQUENCE table_id_seq OWNED BY table.id;

    本质是「创建一个序列 + 列默认值用 nextval」

  2. GENERATED ALWAYS AS IDENTITY 是 SQL 标准(SQL:2003)写法,PG 10 开始支持。它比 SERIAL 更好的原因:

    • 权限干净SERIAL 创建的序列权限要单独管理,常因为忘记 GRANT USAGE ON SEQUENCE 导致权限错误;IDENTITY 把序列封装为表的一部分,权限随表走。
    • OVERRIDING 行为更严格ALWAYS 模式下手动 INSERT id 会直接报错(除非加 OVERRIDING SYSTEM VALUE),避免人为写入造成自增冲突。
    • 导出干净pg_dump 导出 IDENTITY 列时不会产生独立的 CREATE SEQUENCE 语句,迁移更平滑。
    • 类型修改方便SERIAL 改为 BIGSERIAL 比较麻烦;IDENTITY 直接 ALTER COLUMN id SET DATA TYPE BIGINT
  3. 两种 IDENTITY 模式

    • GENERATED ALWAYS禁止手动指定值;
    • GENERATED BY DEFAULT:允许手动指定值(与 SERIAL 行为一致)。

加分项

  • 能解释自增列**断号(gap)**问题:序列 nextval 是事务外的(消费完不退还),ROLLBACK 不会归还 ID,因此自增列不保证连续;
  • 能提到 PG 也支持 IDENTITY 自定义起始值与步长:
    sql
    id BIGINT GENERATED ALWAYS AS IDENTITY (START WITH 1000 INCREMENT BY 10)

易错点:把 SERIAL 当成「类型」 —— 它只是个语法糖,底层类型还是 INTEGER


Q6:PG 中的 LIMIT/OFFSET 在大数据量分页时有什么问题?怎么解决?

考察点:对查询执行原理 + 真实性能问题的理解。

标准答案

  1. OFFSET N 的本质是「扫描并丢弃前 N 行」,不是跳过它们。所以 LIMIT 10 OFFSET 1000000 会真实地读 1,000,010 行再返回 10 行,性能极差。
  2. PG 与 MySQL 都有这个问题,本质是 SQL 标准的语义决定。
  3. 解决方案
    • 方案 ① 主键游标(推荐)

      sql
      -- 第一页
      SELECT * FROM ch2_books ORDER BY id LIMIT 10;
      -- 后续每页用上一页最后一行的 id
      SELECT * FROM ch2_books WHERE id > 1234 ORDER BY id LIMIT 10;

      性能恒定 O(log N),不随页数增加而劣化。

    • 方案 ② 复合游标:当排序字段不唯一时(比如按 created_at),需要把 (created_at, id) 一起作为游标:

      sql
      SELECT * FROM ch2_books
      WHERE (created_at, id) < ('2026-04-01 00:00:00+08', 9999)
      ORDER BY created_at DESC, id DESC
      LIMIT 10;
    • 方案 ③ 物理分页:极端深页用 TABLESAMPLE 抽样,或在前端限制最大页数。

  4. 额外坑OFFSET + ORDER BY 没用唯一字段时,相邻页可能出现重复或丢失(因为同 created_at 的行顺序不稳定)。

加分项

  • 能讲出 LIMIT/OFFSET 在执行计划里对应「Limit + Sort + Seq Scan」(或 IndexScan),可以用 EXPLAIN ANALYZE 看到 actual rows=1000010 的真实读取量;
  • 能说出 PG 17 引入的「Incremental Sort」对 ORDER BY ... LIMIT 类查询的优化场景。

易错点:以为 OFFSET 等价于「跳过」,其实是「扫到再丢掉」,所以「翻到第 1000 页」远比「翻到第 1 页」慢。


Q7:什么是 RETURNING?它解决了什么问题?

考察点:对 PG 特色 + 实际开发痛点的理解。

标准答案

  1. RETURNINGINSERT / UPDATE / DELETE 直接返回受影响行的数据,无需再 SELECT 一次:

    sql
    INSERT INTO orders(user_id, amount) VALUES (1, 99.5)
    RETURNING id, created_at;
  2. 解决的问题

    • 省一次往返:传统写法要 INSERT 后再 SELECT LAST_INSERT_ID() / SELECT * WHERE id = ?,多一次 RTT;
    • 拿到默认值 / 触发器生成的值now() 默认值、触发器写入的字段,只有 RETURNING 能立刻拿到;
    • 批量场景一次拿全INSERT ... SELECT ... RETURNING * 能拿回所有插入的行,CTE + RETURNING 还能做出复杂的「插入并删除并查询」的原子操作(参见下例)。
  3. CTE + RETURNING 的高阶用法

    sql
    WITH moved AS (
        DELETE FROM events WHERE created_at < now() - interval '30 days'
        RETURNING *
    )
    INSERT INTO events_archive SELECT * FROM moved;

    一条 SQL 完成「删 + 归档」,全部在一个事务里。

  4. MySQL 对比:MySQL 8.0 之前完全没有 RETURNING;MariaDB 10.5 起支持但语法略不同;MySQL 真正引入是 8.0 在 INSERT 上有实验性 RETURNING,但远不如 PG 完整。

加分项

  • 能举一个 ORM 场景:psycopg / SQLAlchemy 等会自动用 RETURNING 拿主键,所以 PG 上插入 + 拿 ID 是一次 RTT;
  • 能提到性能:相比 INSERT + SELECT 两次往返,RETURNING 在高并发场景能显著降低延迟。

易错点:以为 RETURNING * 没成本 —— 它会增加返回数据量,超大行数据(含 TEXT/JSONB)批量时要按需指定列。


🔗 延伸阅读


📌 下一章预告:第 3 章「数据类型详解」—— 我们要全面盘点 PG 的类型系统,包括数组、JSONB、范围、UUID、自定义复合类型,以及它们在生产环境的最佳实践。

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

python
"""basic_query.py —— 第 2 章配套代码 · 图书管理系统

用途
    用 psycopg v3 演示连接、查询、参数化查询、INSERT ... RETURNING、
    事务(BEGIN/COMMIT/ROLLBACK)、批量执行(executemany)、
    高效批量加载(COPY)等 PG 常用编程姿势。

前置
    1. 已经按第 2 章 init.sql 建好 ch2_books / ch2_authors / ch2_categories 三张表:
           psql -h 127.0.0.1 -U postgres -d learn_pg -f ../init.sql
    2. 安装 psycopg v3:
           pip install "psycopg[binary]>=3.1"

运行
    python basic_query.py
"""

from __future__ import annotations

import io
import sys
from decimal import Decimal

try:
    import psycopg
    from psycopg import sql
    from psycopg.rows import dict_row
except ImportError:
    sys.exit('请先安装 psycopg v3:pip install "psycopg[binary]>=3.1"')


CONN_INFO = "host=127.0.0.1 port=5432 dbname=learn_pg user=postgres"


def section(title: str) -> None:
    line = "─" * 70
    print(f"\n{line}\n  {title}\n{line}")


# ------------------------------------------------------------------ #
#  Demo 1:基础查询 + 参数化(防 SQL 注入)
# ------------------------------------------------------------------ #
def demo_select_basic(conn: psycopg.Connection) -> None:
    section("Demo 1 · 基础查询 + 参数化查询")

    with conn.cursor(row_factory=dict_row) as cur:
        cur.execute("SELECT count(*) AS n FROM ch2_books")
        print("ch2_books 总条数 :", cur.fetchone()["n"])

        # 参数化查询:%s 占位符(不是 Python 的 % 格式化!)
        category = "科幻"
        min_stock = 1
        cur.execute(
            """
            SELECT b.id, b.title, a.name AS author, b.price, b.stock
            FROM ch2_books b
            JOIN ch2_authors    a ON a.id = b.author_id
            JOIN ch2_categories c ON c.id = b.category_id
            WHERE c.name = %s AND b.stock >= %s
            ORDER BY b.price DESC
            FETCH FIRST 5 ROWS ONLY
            """,
            (category, min_stock),
        )
        print(f"分类 = {category}, 库存 >= {min_stock} 的 Top5:")
        for row in cur.fetchall():
            print(
                f"  #{row['id']:<3}  {row['title']:<30}  "
                f"author={row['author']:<10}  ¥{row['price']}  stock={row['stock']}"
            )


# ------------------------------------------------------------------ #
#  Demo 2:INSERT ... RETURNING(PG 特色)
# ------------------------------------------------------------------ #
def demo_insert_returning(conn: psycopg.Connection) -> int:
    section("Demo 2 · INSERT ... RETURNING 一次拿到主键 + 时间戳")

    with conn.cursor() as cur:
        cur.execute(
            """
            INSERT INTO ch2_books (title, author_id, category_id, isbn, published_at, stock, price)
            VALUES (%s, %s, %s, %s, %s, %s, %s)
            RETURNING id, created_at
            """,
            (
                "psycopg 实战手册",
                5,                     # Knuth 占个位
                3,                     # 技术分类
                "9999999999999",
                "2026-04-01",
                100,
                Decimal("59.90"),
            ),
        )
        new_id, created_at = cur.fetchone()
        print(f"  新书已插入:id={new_id}, created_at={created_at}")

    conn.commit()
    return new_id


# ------------------------------------------------------------------ #
#  Demo 3:事务 —— BEGIN / COMMIT / ROLLBACK
# ------------------------------------------------------------------ #
def demo_transaction(conn: psycopg.Connection, book_id: int) -> None:
    section("Demo 3 · 事务的 ROLLBACK 保护数据安全")

    with conn.cursor() as cur:
        cur.execute("SELECT stock FROM ch2_books WHERE id = %s", (book_id,))
        before = cur.fetchone()[0]
        print(f"  事务前 stock = {before}")

    # psycopg v3 默认就是事务模式(autocommit=False),
    # with conn.transaction() 显式开启嵌套事务 / 保存点。
    try:
        with conn.transaction():
            with conn.cursor() as cur:
                cur.execute(
                    "UPDATE ch2_books SET stock = stock + 999 WHERE id = %s", (book_id,)
                )
            raise RuntimeError("人为抛出错误,触发回滚")
    except RuntimeError as exc:
        print(f"  捕获异常并回滚:{exc}")

    with conn.cursor() as cur:
        cur.execute("SELECT stock FROM ch2_books WHERE id = %s", (book_id,))
        after = cur.fetchone()[0]
        print(f"  事务后 stock = {after}(与事务前一致 → 回滚成功)")


# ------------------------------------------------------------------ #
#  Demo 4:executemany —— 批量插入
# ------------------------------------------------------------------ #
def demo_executemany(conn: psycopg.Connection) -> None:
    section("Demo 4 · executemany 批量插入 5 个新作者")

    rows = [
        ("Sample Author A", "测试国", 2000),
        ("Sample Author B", "测试国", 2001),
        ("Sample Author C", "测试国", 2002),
        ("Sample Author D", "测试国", 2003),
        ("Sample Author E", "测试国", 2004),
    ]

    with conn.cursor() as cur:
        cur.executemany(
            "INSERT INTO ch2_authors(name, country, born_year) VALUES (%s, %s, %s)",
            rows,
        )
        print(f"  已插入 {cur.rowcount} 行")

        cur.execute(
            "DELETE FROM ch2_authors WHERE name LIKE 'Sample Author%' RETURNING id"
        )
        print(f"  已清理 {cur.rowcount} 行(避免污染数据)")
    conn.commit()


# ------------------------------------------------------------------ #
#  Demo 5:COPY —— PG 最快的批量加载
# ------------------------------------------------------------------ #
def demo_copy(conn: psycopg.Connection) -> None:
    section("Demo 5 · COPY FROM STDIN 高速批量加载(10x ~ 100x 快于 INSERT)")

    csv_data = io.StringIO()
    for i in range(20):
        csv_data.write(f"copy_demo_author_{i}\t测试国\t{1900 + i}\n")
    csv_data.seek(0)

    with conn.cursor() as cur:
        with cur.copy(
            "COPY ch2_authors(name, country, born_year) FROM STDIN WITH (FORMAT text)"
        ) as cp:
            cp.write(csv_data.read())

        cur.execute(
            "DELETE FROM ch2_authors WHERE name LIKE 'copy_demo_author_%' RETURNING id"
        )
        print(f"  COPY 加载并清理 {cur.rowcount} 行")
    conn.commit()


# ------------------------------------------------------------------ #
#  Demo 6:动态构造表名 —— sql.Identifier 防注入
# ------------------------------------------------------------------ #
def demo_dynamic_identifier(conn: psycopg.Connection) -> None:
    section("Demo 6 · 动态拼接表名(用 psycopg.sql 模块,不要用 f-string!)")

    table = "ch2_books"
    with conn.cursor() as cur:
        query = sql.SQL("SELECT count(*) FROM {tbl}").format(
            tbl=sql.Identifier(table)
        )
        cur.execute(query)
        print(f"  表 {table}{cur.fetchone()[0]} 行")


# ------------------------------------------------------------------ #
#  Demo 7:服务端游标 —— 流式遍历大结果集
# ------------------------------------------------------------------ #
def demo_server_cursor(conn: psycopg.Connection) -> None:
    section("Demo 7 · 服务端游标流式读 ch2_books 表(避免一次性加载到内存)")

    with conn.cursor(name="books_stream") as cur:
        cur.itersize = 5
        cur.execute("SELECT id, title FROM ch2_books ORDER BY id")
        for row in cur:
            print(f"  id={row[0]:<3}  title={row[1]}")


# ------------------------------------------------------------------ #
#  Main
# ------------------------------------------------------------------ #
def main() -> None:
    print(f"连接:{CONN_INFO}")
    try:
        conn = psycopg.connect(CONN_INFO)
    except psycopg.OperationalError as exc:
        sys.exit(f"连接失败:{exc}\n请先按第 2 章说明初始化 learn_pg 数据库与 init.sql。")

    try:
        with conn:
            demo_select_basic(conn)
            new_id = demo_insert_returning(conn)
            demo_transaction(conn, new_id)
            demo_executemany(conn)
            demo_copy(conn)
            demo_dynamic_identifier(conn)
            demo_server_cursor(conn)

            with conn.cursor() as cur:
                cur.execute(
                    "DELETE FROM ch2_books WHERE isbn = %s RETURNING id", ("9999999999999",)
                )
                if cur.rowcount:
                    print(f"\n  [清理] 删掉 demo 插入的书 id={cur.fetchone()[0]}")
            conn.commit()
    finally:
        conn.close()

    print("\n✅ All demos done.")


if __name__ == "__main__":
    main()
markdown
# 第 2 章 配套代码

> 演示「图书管理系统」(`ch2_books / ch2_authors / ch2_categories`)下 psql + psycopg 的常见编程姿势。

## 准备工作

1. 跑初始化脚本建表 + 灌测试数据:
   ```bash
   psql -h 127.0.0.1 -U postgres -d learn_pg -f ../init.sql
  1. 安装依赖:
    bash
    pip install "psycopg[binary]>=3.1"
  2. (可选)通过环境变量覆盖默认连接信息:
    bash
    export PG_DSN="host=127.0.0.1 port=5432 dbname=learn_pg user=postgres"

脚本一览

脚本一句话说明关键 PG 特性
basic_query.py用 psycopg v3 打通连接、查询、事务、批量写入、流式读参数化查询、INSERT ... RETURNINGexecutemanyCOPY FROM STDINsql.Identifier、服务端游标

后续随着章节推进,本目录会陆续新增 transaction_savepoint.py / copy_csv_loader.py 等脚本,统一遵循「先建表 → 再 python xxx.py」的运行约定。

预期输出

basic_query.py 跑完会看到 7 段 demo 输出,关键片段大致是:

──────────────────────────────────────────────────────────────────────
  Demo 1 · 基础查询 + 参数化查询
──────────────────────────────────────────────────────────────────────
ch2_books 总条数 : 14
分类 = 科幻, 库存 >= 1 的 Top5:
  #3    三体 III:死神永生              author=刘慈欣      ¥42.00  stock=25
  ...

──────────────────────────────────────────────────────────────────────
  Demo 2 · INSERT ... RETURNING 一次拿到主键 + 时间戳
──────────────────────────────────────────────────────────────────────
  新书已插入:id=15, created_at=2026-04-17 10:00:00+08
...
✅ All demos done.

常见报错

  • connection refused → PG 没起 / 端口不对 / 监听 localhost 时用了 127.0.0.1 之外的 host
  • relation "ch2_books" does not exist → 没跑 ../init.sql
  • password authentication failed → 修改 ~/.pgpass 或环境变量 PGPASSWORD
  • permission denied for sequence ch2_books_id_seq → 用 IDENTITY 而非 SERIAL 时不会出现;如果出现,说明老库残留了 SERIAL 的序列对象,重新跑 init.sql 即可

basic_query.py ↗ · README.md ↗