SQL 建表、读写与结果
完整展开类型、增删改查、分组、连接、子查询和 CTE。
本分册采用 PostgreSQL 18 文档作为语法和行为基线。归档中 PostgreSQL 16 或其他环境的 SQL、计划与耗时保留为历史记录,2026-10-05 整理未重跑这些数据库实验。示例需在独立可丢弃数据库内使用,各章同名表可能有不同结构;先核对 DDL、角色和数据再执行。
上一章我们安装了 PostgreSQL 并用 psql 连上了服务器,但此刻的服务器更像一块刚平整好的空地:有地皮,还没有仓库和货架。本章我们在这块地上盖起贯穿全书的"简易在线书店"——创建数据库和表,把图书、顾客、订单装进去,再学会查询与修改。
操作数据库用 SQL(Structured Query Language,结构化查询语言)。它是声明式语言:你只描述"要什么结果",怎么拿交给数据库。学完本章,你能独立建库建表、增删改查,回答"哪类书最贵""哪个城市花钱最多""谁从没下过单"这类业务问题。示例都可在 psql 中逐条运行,建议边读边敲。
2.1 数据库与表:先盖仓库,再搭货架
为什么需要。 书店的订单和隔壁医院的病历不该堆在一处:业务先隔离,权限、备份和维护才有边界。
是什么。 一台 PostgreSQL 服务器按两级结构组织数据:服务器下有多个数据库(Database),每个数据库里有多张表(Table)。实际层级为集群、数据库、模式、表;未限定模式时,名称解析和建表位置由 search_path 及权限决定。想象一座物流园:数据库是园中的仓库,表是仓库里的货架;货架上每格放的一件商品是一条记录,即一行(Row),商品按同一套属性栏登记——书名、作者、价格——每个属性栏是一列(Column)。(第 1 章把数据库比作"账本",强调的是它保管的数据;这里的"仓库"强调的是它的隔离与边界——两个比喻各取一面,说的是同一个东西。)回到准确定义:数据库是相互隔离的命名空间,一次连接只能工作在其中一个库里;表由固定的一组列和任意多行组成。
怎么用。 建库:
CREATE DATABASE bookshop; -- 等价的命令行工具:createdb bookshop若已经建立 bookshop,可用 \c bookshop 进入。需要独立演示环境时,用另一个库名创建空库,避免依赖或覆盖前一章的表结构。
在 psql 里切换到新库:
\c bookshopPostgreSQL 没有 MySQL 的 USE 语法,切换数据库用 psql 的元命令 \c(它不是 SQL,不以分号结尾)。未加引号的数据库标识符通常以字母或下划线开头;双引号标识符可使用其他字符,常规构建的长度上限为 63 字节。删除数据库:
DROP DATABASE bookshop;这条语句会物理删除库中所有文件,不可恢复,库所有者或超级用户等具备相应权限的角色可以执行;仍有客户端连着该库时删除会失败。命令行工具 dropdb 必须显式写出库名,不像 createdb 默认用当前用户名。
三个 psql 要点:每条语句以分号结束,语句内部可自由换行;-- 之后到行末是注释;关键字与标识符大小写不敏感,不加双引号一律折叠为小写,CREATE TABLE 与 create table 等价。
2.2 用 CREATE TABLE 给数据立规矩
为什么需要。 不事先约定"每列放什么、哪些必须有值、价格能不能为负",任何数据都能塞进表里,用不了多久就会互相打架。
是什么。 建表语句由一组"列名 + 数据类型 + 可选约束(Constraint)"构成。约束是数据库替你把守的数据质量第一道防线:
| 约束 | 作用 |
|---|---|
| PRIMARY KEY | 主键:唯一标识一行,隐含 NOT NULL 和 UNIQUE,并自动建立索引(Index,后续章节详讲) |
| NOT NULL | 禁止空值(NULL) |
| DEFAULT | 未提供该列时取默认值,可以是 now() 这样的表达式 |
| UNIQUE | 全表内禁止重复值 |
| CHECK | 自定义检查条件,不满足则拒绝写入 |
怎么用。 为书店建三张表:books(图书)、customers(顾客)、orders(订单)。第 1 章示例里那张只有五个基本列的 books 表到此功成身退——下面这套表结构更接近真实业务,也将被后续各章沿用;若仍在原来的 bookshop 库中操作,请先执行 DROP TABLE books;(第 1 章 1.7 节在 catalog 模式下建的同名表不受影响)再运行建表语句:
CREATE TABLE books (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, -- 自增主键
title text NOT NULL, -- 书名,必填
author text, -- 作者,允许暂缺
category text NOT NULL, -- 类别
price numeric(10, 2) CHECK (price >= 0), -- 价格:精确小数,不许为负
stock integer NOT NULL DEFAULT 0, -- 库存
tags text[], -- 标签:文本数组
info jsonb, -- 扩展信息:JSON 文档
created_at timestamptz NOT NULL DEFAULT now() -- 上架时间,默认当前时刻
);
CREATE TABLE customers (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL,
city text, -- 城市可能未知,允许 NULL
email varchar(255) UNIQUE -- 邮箱不允许重复
);
CREATE TABLE orders (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
book_id bigint NOT NULL, -- 买的是哪本书
customer_id bigint, -- 未登录下单时为 NULL(匿名订单)
quantity integer NOT NULL DEFAULT 1, -- 数量
amount numeric(10, 2) NOT NULL CHECK (amount >= 0), -- 实付金额
ordered_at timestamptz NOT NULL DEFAULT now() -- 下单时间
);三点说明。其一,GENERATED BY DEFAULT AS IDENTITY 是 SQL 标准的自增主键写法,插入时不提供 id 即自动分配递增值;传统写法 serial/bigserial 被官方说明"不是真正的类型,只是一种记法便利",新项目建议用 IDENTITY(identity 还有 ALWAYS 与 BY DEFAULT 两种形式的取舍,第 3 章设计主键时会专门比较)。其二,orders.customer_id 故意允许 NULL——顾客未登录也能下单;这个 NULL 在 2.9 节会派上大用场。其三,删表用 DROP TABLE,加 IF EXISTS 可避免表不存在时报错:
DROP TABLE IF EXISTS old_logs; -- 表不存在时静默跳过而不是报错2.3 常用数据类型一览
为什么需要。 类型决定每列能放什么、占多少空间、支持哪些运算。选错类型(比如用浮点存金额)当时看不出问题,错误会在几个月后的一次对账里爆发。
是什么。 入门阶段掌握下表即可:
| 类别 | 常用类型 | 存储 | 要点 |
|---|---|---|---|
| 整数 | smallint / integer / bigint | 2 / 4 / 8 字节 | 常规数量用 integer;主键等大计数用 bigint |
| 精确小数 | numeric(p, s) | 变长 | 精确无舍入误差,金额必用 |
| 近似浮点 | real / double precision | 4 / 8 字节 | 约 6 位 / 15 位有效数字,只用于可容忍误差的场合 |
| 文本 | varchar(n) / char(n) / text | 变长(char(n) 为定长) | 默认 text;确需限长用 varchar(n);char(n) 定长补空格,少用 |
| 日期 | date | 4 字节 | 只有日期 |
| 时刻 | timestamp / timestamptz | 8 字节 | 记录事件发生时刻首选 timestamptz |
| 时间跨度 | interval | 16 字节 | 可直接加减,如 now() + interval '7 days' |
| 布尔 | boolean | 1 字节 | 接受 true/false 及 't'、'yes'、'on'、'1' 等写法 |
| 唯一标识 | uuid | 16 字节 | gen_random_uuid() 生成全局唯一 ID(PostgreSQL 13 起内置) |
| 数组 | 任意类型加 [],如 text[] | — | 一维数组最常用,如标签 |
| JSON | json / jsonb | 变长 | 半结构化数据,默认选 jsonb |
怎么选。 四条口诀。金额必须用 numeric:浮点类型是近似值,0.1 + 0.2 式的舍入误差对账务是灾难,官方文档明确说 numeric"特别适合金额等要求精确度的场合"。文本默认用 text:官方说明 varchar 与 text 性能几乎无差别,不必带着 MySQL 式的"长度焦虑",确需限长再显式用 varchar(n) 或 CHECK 约束。记录事件发生时刻用 timestamptz:与 timestamp 同为 8 字节,但内部按 UTC 存绝对时刻、显示随会话时区转换,避免跨时区混乱。标签这类简单多值用数组、半结构化信息用 jsonb:jsonb 存分解后的二进制格式,处理显著更快,支持包含运算符 @> 和 GIN 索引(GIN 是一种适合 JSON、数组这类复合值的"倒排式"索引,此处先记名字,第 4 章正式介绍);代价是写入稍慢、不保留空格与键顺序、重复键只留最后一个;json 保留原文但每次都要重新解析,官方建议大多数应用优先 jsonb。
json 与 jsonb 的差异一眼可见(::类型 是显式类型转换的写法):
SELECT '{"b": 1, "a": 2, "b": 3}'::json; -- 原样输出:{"b": 1, "a": 2, "b": 3}
SELECT '{"b": 1, "a": 2, "b": 3}'::jsonb; -- 键排序、去重复键:{"a": 2, "b": 3}数组和 JSONB 的查询写法先睹为快(2.4 节录入数据后即可运行):
SELECT title FROM books WHERE 'database' = ANY (tags); -- 含某标签的书
UPDATE books SET tags = array_append(tags, 'bestseller') -- 给书追加一个标签
WHERE id = 2 RETURNING tags;
SELECT title, info ->> 'publisher' AS publisher FROM books; -- 取 JSON 字段为文本
SELECT title FROM books
WHERE info @> '{"publisher": "启明出版社"}'; -- JSONB 包含匹配其中 ->> 取出 JSON 字段并转为文本;@> 判断左边的 JSONB 是否包含右边这个对象;= ANY (数组) 判断数组中是否至少有一个元素等于给定值。
2.4 INSERT:把书搬上货架
为什么需要。 表建好后只是空货架,得先把书放上去才有生意。
是什么。 INSERT 把一行(或一批行)写进表,常用四种形态:按列清单插入单行、一条语句多组 VALUES 批量插入、INSERT ... SELECT 从查询结果插入、INSERT INTO 表名 DEFAULT VALUES 全默认插入。写明列清单是好习惯——表结构变了语句也不容易坏;没列出的列自动取默认值或 NULL。
怎么用。 录入第一批图书:
INSERT INTO books (title, author, category, price, stock, tags, info)
VALUES
('SQL 入门经典', '林一', 'language', 49.00, 20,
'{sql,basic}', '{"publisher": "启明出版社", "pages": 240}'),
('PostgreSQL 实战', '林一', 'database', 129.00, 10,
'{postgres,database}', '{"publisher": "启明出版社", "pages": 512}'),
('数据库原理', '赵三', 'database', 139.00, 5,
'{theory,database}', '{"publisher": "学海出版社", "pages": 640}'),
('PostgreSQL 进阶', '林一', 'database', 99.00, 8,
'{postgres,advanced}', '{"publisher": "启明出版社", "pages": 380}'),
('Python 快速上手', '王二', 'language', 89.00, 30,
'{python,basic}', '{"publisher": "学海出版社", "pages": 300}');id、created_at 都没写,是数据库代劳的。想知道它到底填了什么,用 RETURNING 拿回来:
INSERT INTO books (title, author, category, price, stock)
VALUES ('Redis 深度历险', '钱四', 'database', 79.00, 12)
RETURNING id, created_at; -- 返回数据库实际生成的自增 id 和默认时间RETURNING 让 INSERT 像 SELECT 一样返回结果行,内容基于实际插入的每一行计算;它是 PostgreSQL 的扩展语法而非 SQL 标准。继续录入顾客和订单:
INSERT INTO customers (name, city, email) VALUES
('张三', '北京', 'zhangsan@example.com'),
('李四', '上海', 'lisi@example.com'),
('王五', '北京', 'wangwu@example.com'),
('赵六', NULL, NULL), -- 城市未知
('孙七', '广州', 'sunqi@example.com'); -- 注意:这位顾客从未下单
INSERT INTO orders (book_id, customer_id, quantity, amount) VALUES
(1, 1, 1, 49.00), -- 张三买《SQL 入门经典》
(1, 2, 2, 98.00), -- 李四买两本
(2, 3, 1, 129.00), -- 王五买《PostgreSQL 实战》
(5, 1, 1, 89.00), -- 张三买《Python 快速上手》
(3, NULL, 1, 139.00); -- 匿名订单:未登录顾客买《数据库原理》
-- 再造一条两年前的旧订单,供后面演示按时间清理:
INSERT INTO orders (book_id, customer_id, quantity, amount, ordered_at)
VALUES (2, 3, 1, 129.00, now() - interval '2 years');从查询结果插入。先准备一张暂存表(真实场景中它可能来自批量导入),再把便宜的书搬进正式表:
CREATE TABLE tmp_books (
title text,
author text,
category text,
price numeric(10, 2)
);
INSERT INTO tmp_books VALUES
('算法图解', '周五', 'language', 69.00),
('编译原理', '吴六', 'language', 149.00);
INSERT INTO books (title, author, category, price)
SELECT title, author, category, price
FROM tmp_books
WHERE price < 100; -- 只有 69 元的《算法图解》会被搬入 books最后是"有则更新、无则插入"的 UPSERT。ON CONFLICT 让 INSERT 在键冲突时改走另一条路:DO NOTHING 跳过,DO UPDATE 转为更新;EXCLUDED 指"原本要插进去的那一行":
-- 显式指定 id = 1 重录这本书;该 id 已存在,则更新书名和价格
INSERT INTO books (id, title, author, category, price, stock)
VALUES (1, 'SQL 入门经典(第 2 版)', '林一', 'language', 59.00, 25)
ON CONFLICT (id) DO UPDATE
SET title = EXCLUDED.title, price = EXCLUDED.price
RETURNING id, title, price;
-- 邮箱已存在就什么都不做(email 列上有 UNIQUE 约束)
INSERT INTO customers (name, city, email)
VALUES ('张三', '北京', 'zhangsan@example.com')
ON CONFLICT (email) DO NOTHING;ON CONFLICT 也是 PostgreSQL 扩展语法,整条语句原子完成。
2.5 UPDATE 与 DELETE:改价与下架
为什么需要。 书价会调、库存会变、旧订单要清理——数据是活账本,不是刻好的石碑。
是什么。 UPDATE 表 SET 列 = 新值 WHERE 条件 只改满足条件的行;DELETE FROM 表 WHERE 条件 只删满足条件的行。两者共同的最大危险是:省略 WHERE 就是全表生效——UPDATE 会改掉每一行,DELETE 会删光每一行(表还在但空了;官方文档提示清空整表用 TRUNCATE 更快)。
怎么用。 先改价,用 RETURNING 立刻核对结果:
UPDATE books SET price = price * 0.9 -- 九折促销(这里只对 id = 1 这本书)
WHERE id = 1
RETURNING id, title, price;再删除,并养成一个习惯:改写之前,先把 WHERE 条件拿去 SELECT 一遍,确认命中的就是想动的行;初学阶段还可把语句包在事务(Transaction,后续章节详讲)里,看对了再提交:
-- 第一步:这条 WHERE 会命中谁?
SELECT id, amount, ordered_at
FROM orders
WHERE ordered_at < now() - interval '1 year';
BEGIN; -- 开启事务
DELETE FROM orders
WHERE ordered_at < now() - interval '1 year'
RETURNING *;
ROLLBACK; -- 先撤销,把旧订单留给 2.9 节再用两个细节:返回 UPDATE 0 或 DELETE 0 表示没有行满足条件,这不是错误;PostgreSQL 的 DELETE 没有 LIMIT 子句,做不到"只删前 N 条"。(小贴士:PostgreSQL 18 起 RETURNING 还可用 old.列 / new.列 及 RETURNING WITH (OLD AS o, NEW AS n) 同时引用改前改后的值;兼容旧版本请只写列名或 *。)
2.6 SELECT:从货架上精确取书
为什么需要。 数据放进去只是第一步,价值在于按需取出来——找便宜书、按价格排序、翻页浏览。
是什么。 SELECT 由一系列子句组成,理解查询结果可以使用下列逻辑处理模型。优化器可以重排扫描、连接与过滤,实际执行过程由计划决定:
WITH → FROM(含 JOIN)→ WHERE → GROUP BY / HAVING → SELECT 输出表达式 → DISTINCT → 集合运算 → ORDER BY → LIMIT / OFFSET
这个语义模型能解释两项常见规则:聚合函数为什么不能写进 WHERE(WHERE 求值时还没有分组聚合);ORDER BY 为什么能用 SELECT 起的别名(排序发生在输出表达式之后)。
怎么用。 WHERE 过滤行:
SELECT title, price FROM books WHERE price < 100; -- 比较条件
SELECT title FROM books WHERE author LIKE '林%'; -- 模糊匹配:作者以"林"开头
SELECT name FROM customers WHERE city IS NULL; -- 判断空值SQL 使用三值逻辑:普通等值与大小比较遇到 NULL 时通常得到 NULL,表示结果未知。WHERE 只保留条件为 true 的行,因此 WHERE city = NULL 无法选中行。判断空值使用 IS NULL / IS NOT NULL;IS DISTINCT FROM 等专门谓词还可以给出包含 NULL 的确定比较结果。
排序与分页:
SELECT title, price FROM books
ORDER BY price DESC, title ASC; -- 先按价格降序,同价再按书名升序
SELECT id, title FROM books
ORDER BY id
LIMIT 10 OFFSET 20; -- 每页 10 条、跳过前 20 条,即第 3 页默认升序(ASC);NULL 被当作"最大":升序排在最后、降序排在最前,可用 NULLS FIRST / NULLS LAST 显式覆盖:
SELECT name, city FROM customers ORDER BY city NULLS FIRST; -- 空城市排最前分页铁律:LIMIT 必须搭配 ORDER BY,否则取到哪一批行不可预测;排序结果并列时顺序不稳定,必要时加第二排序键(如 id)打破并列。去重则用 DISTINCT:
SELECT DISTINCT city FROM customers; -- 去掉完全重复的行另有 DISTINCT ON (表达式) 可"每组保留第一行",但它必须配合 ORDER BY 且最左排序列与 DISTINCT ON 表达式一致,入门阶段了解即可。
2.7 聚合与分组:数一数、算一算
为什么需要。 "店里一共几种书?""database 类平均多少钱?"——业务问的常是一批行的汇总,而不是某一行。
是什么。 聚合函数(Aggregate Function)把多行压成一个值:count、sum、avg、max、min。GROUP BY 按列分组、每组输出一行;对组(而非对行)的筛选用 HAVING。
怎么用。 四步阶梯,用同一个例子讲透 WHERE 与 HAVING 的分工:
-- 第 1 步:全表计数
SELECT count(*) FROM books;
-- 第 2 步:按类别分组统计
SELECT category, count(*) AS n, min(price) AS min_price, max(price) AS max_price
FROM books
GROUP BY category;
-- 第 3 步:先用 WHERE 减少参与分组的行(输入过滤)
SELECT category, count(*) AS n, max(price) AS max_price
FROM books
WHERE category LIKE 'd%' -- 只看 database 类
GROUP BY category;
-- 第 4 步:再用 HAVING 过滤组(结果过滤),如"最高价低于 150 元的类别"
SELECT category, count(*) AS n, max(price) AS max_price
FROM books
WHERE category LIKE 'd%'
GROUP BY category
HAVING max(price) < 150;一句话讲透:WHERE 在分组之前过滤行,HAVING 在分组之后过滤组。因此聚合函数不能出现在 WHERE(那里还没聚合,写了直接报错);而非聚合的普通条件应尽量放 WHERE——先过滤、后分组,计算量更小,官方文档也明确建议这样做。还有一条硬规则:SELECT 列表不能引用既不在 GROUP BY 中、也不在聚合函数内的列,否则 PostgreSQL 直接报错(与一些 MySQL 老版本的宽松行为不同)。
那么"最贵的书是哪本"怎么查?max 不能进 WHERE,标准解法是把它放进子查询:
SELECT title, price
FROM books
WHERE price = (SELECT max(price) FROM books); -- 先算出最贵价,再找等值的书2.8 多表连接:把订单、图书和顾客拼起来
为什么需要。 为避免重复存储,数据拆进了三张表:订单里只存图书和顾客的编号。"谁买了什么书"需要把三张表拼起来回答——这正是"关系数据库"里"关系"的含义。
是什么。 连接(JOIN)按条件把两个表的行配对。两大基本型:内连接(INNER JOIN)只保留两边都匹配的行;左外连接(LEFT OUTER JOIN)保留左表全部行、右表无匹配时补 NULL。另有 RIGHT / FULL 外连接,思路相同。官方教程建议统一用 SQL-92 的显式 JOIN ... ON 写法,而非"逗号连表 + WHERE"的旧写法。
怎么用。 查询每笔订单的完整信息:
SELECT o.id, c.name AS buyer, b.title AS book, o.quantity, o.amount
FROM orders o
JOIN customers c ON c.id = o.customer_id -- 订单接顾客
JOIN books b ON b.id = o.book_id -- 再接图书
ORDER BY o.id;两个好习惯:给表起别名(o、c、b)让语句清爽;重名列加表限定(如 c.id)避免歧义。两表连接列同名时可用 USING (列名) 简写 ON 条件。
换成 LEFT JOIN,差异立现——列出所有顾客,包括从没下过单的孙七(他的 order_id 是 NULL,右表无匹配时补 NULL):
SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.name;"查未匹配行"正是 LEFT JOIN 的经典手法:
SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL; -- 右表列 IS NULL = 没有订单的顾客但要当心陷阱:在查询结果的逻辑模型中,外连接补值发生在 WHERE 筛选之前(这一叙述不规定物理执行顺序),对右表列加 WHERE 条件时,NULL 补齐行会因三值逻辑被过滤,LEFT JOIN 悄悄退化成 INNER JOIN。想"列出所有顾客及其金额超过 100 元的订单",右表条件应写进 ON:
SELECT c.name, o.amount
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.amount > 100; -- 条件放 ON:孙七保留自连接(Self Join)是常用技巧:同一张表取两个别名互相连接。例如找同一作者的书两两配对,b1.id < b2.id 既排除"自己连自己",也避免 (甲, 乙) 与 (乙, 甲) 重复:
SELECT b1.title AS book1, b2.title AS book2
FROM books b1
JOIN books b2 ON b1.author = b2.author AND b1.id < b2.id;2.9 子查询与 CTE:把复杂问题拆成小步
为什么需要。 问题一复杂,一条 SQL 就容易缠成一团毛线;把大问题切成有名字的小步骤,可读性和正确率都会上升。
是什么。 子查询(Subquery)是写在另一条语句里的查询,可出现在标量位置(如 2.7 的 WHERE price = (SELECT max(price) ...)),或用 IN / EXISTS / ANY / ALL 与外层查询关联。公用表表达式(Common Table Expression,CTE)用 WITH 给一段子查询起名字,供主查询及后续 CTE 引用。
怎么用。 先看 IN 和一个著名的坑。"从没下过单的顾客"很自然地会写成 NOT IN:
SELECT name FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders); -- 危险写法!如果子查询结果里混进一个 NULL(我们的匿名订单正好贡献了一个),按三值逻辑,id NOT IN (1, 2, 3, NULL) 对每行都算不出 true,整条查询一行都返回不了——孙七该出现却没有。官方文档对此有明确警告,稳妥写法是 NOT EXISTS:
SELECT name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);EXISTS 只判断"有没有行返回",惯例写 SELECT 1;2.3 用过的 = ANY (数组) 与 IN 是同族语法。
再看 CTE 的基本形态:
WITH city_sales AS (
SELECT c.city, sum(o.amount) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY c.city
)
SELECT city, total
FROM city_sales
WHERE total > 200; -- 先按城市汇总销售额,再挑出总额超过 200 元的城市多个 CTE 用逗号分隔,后面的可以引用前面的,像搭积木:
WITH city_sales AS (
SELECT c.city, sum(o.amount) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY c.city
),
big_cities AS (
SELECT city FROM city_sales WHERE total > 200
)
SELECT b.city
FROM big_cities b
ORDER BY b.city;WITH RECURSIVE 允许 CTE 引用自己,处理树形、层级数据(比如商品类目树)。经典入门例是计算 1 到 100 的和:
WITH RECURSIVE t(n) AS (
VALUES (1) -- 起点:第一行是 1
UNION ALL
SELECT n + 1 FROM t WHERE n < 100 -- 递归:从上一轮结果推出下一轮
)
SELECT sum(n) FROM t; -- 结果 5050CTE 里还能放 INSERT / UPDATE / DELETE,并用 RETURNING 把受影响的行传给后续语句——"把一年前的旧订单搬进归档表"一条语句完成:
-- 先建归档表(列与 orders 相同)
CREATE TABLE orders_archive (
id bigint PRIMARY KEY,
book_id bigint NOT NULL,
customer_id bigint,
quantity integer NOT NULL,
amount numeric(10, 2) NOT NULL,
ordered_at timestamptz NOT NULL
);
WITH moved AS (
DELETE FROM orders
WHERE ordered_at < now() - interval '1 year'
RETURNING * -- 被删的行在这里"交接"
)
INSERT INTO orders_archive SELECT * FROM moved;注意:数据修改型 CTE 的行要传给后续语句,就必须带 RETURNING;不带 RETURNING 的数据修改语句仍会执行,只是不产生可被查询其余部分引用的临时表(官方文档 7.8.4 节)。
常见误区
- UPDATE / DELETE 忘写 WHERE:省略即全表生效。改写前先 SELECT 验证 WHERE 命中范围,或包在 BEGIN ... COMMIT 里。
- 用 real / double precision 存金额:浮点是近似值(real 仅约 6 位有效数字),货币字段一律 numeric(p, s)。
- 用
= NULL判断空值:比较结果是 NULL 而非 true,永远查不出行;必须写 IS NULL / IS NOT NULL。 - NOT IN 的子查询里混入 NULL:一行都查不出来;改用 NOT EXISTS 或 LEFT JOIN ... IS NULL。
- 把聚合函数写进 WHERE:WHERE 在分组前求值,写了直接报错;组级过滤用 HAVING。
- GROUP BY 后乱选列:未分组又不在聚合函数内的列,PostgreSQL 直接报错。
- 分页不写 ORDER BY:LIMIT / OFFSET 返回哪批行不可预测,翻页可能重复或漏行。
- 对 NULL 的排序位置想当然:默认当"最大"——升序在最后、降序在最前;用 NULLS FIRST / NULLS LAST 覆盖。
- 对 LEFT JOIN 的右表列加 WHERE 条件:NULL 补齐行被过滤,外连接退化成内连接;右表条件写进 ON。
- 轻视建库删库:DROP DATABASE 物理删除全部文件、不可恢复;库名须以字母或下划线开头、最长 63 字节;createdb 成功时无输出,别误以为失败。
- 类型望文生义:char(n) 补空格;serial 只是记法便利,标准写法 GENERATED ... AS IDENTITY;json 不可索引,默认选 jsonb;时刻统一 timestamptz。
参考来源
- PostgreSQL 18 官方文档 Part I 教程:Chapter 2. The SQL Language(建表、插入、查询、连接、聚合、更新、删除):官方资料
- PostgreSQL 18 官方文档 §8 Data Types(数值、文本、时间、UUID、数组、JSON 类型):官方资料
- PostgreSQL 18 官方文档 SQL 命令参考:SELECT / INSERT / UPDATE / DELETE(子句处理顺序、RETURNING、ON CONFLICT、DELETE 无 LIMIT 等):官方资料
- PostgreSQL 18 官方文档 §7.8 WITH Queries (Common Table Expressions) 与 §9.24 子查询表达式(CTE、递归、数据修改 CTE、NOT IN 遇 NULL 的警告):官方资料
- PostgreSQL 18 官方文档 §1.3 Creating a Database(createdb / dropdb 与建库注意事项):官方资料
- PostgreSQL 官方新闻稿:PostgreSQL 18.6, 17.11, 16.15, 15.19, 14.24 and 19 Beta 3 Released(2026-08-13,用于核实当前版本事实):官方资料
最后更新于