SQL 查询实战
本章假设你已经执行了 02-数据类型与DDL.md 第 9 节的建表语句(users / products / orders 三表底座),在本章开头的种子数据之上,把增删改查、JOIN、聚合、子查询、CTE、UPSERT、分页这些日常动作一次过完,并把最容易炸的几个坑(LEFT JOIN 的 WHERE、NOT IN 遇 NULL、深分页、BETWEEN 边界)做成可复现的例子。
种子数据
-- 归档表:演示数据修改 CTE 时使用(orders 的"退役"版本,无生成列)
CREATE TABLE orders_archive (
id bigint PRIMARY KEY,
order_no text NOT NULL,
user_id bigint NOT NULL,
product_id bigint NOT NULL,
quantity int NOT NULL,
unit_price numeric(12,2) NOT NULL,
status order_status NOT NULL,
paid_at timestamptz,
created_at timestamptz NOT NULL,
archived_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO users (id, email, nickname, password_hash, referrer_id, balance)
OVERRIDING SYSTEM VALUE VALUES
(1, 'alice@shop.example', '爱丽丝', 'hash_a', NULL, 100.00),
(2, 'bob@shop.example', '鲍勃', 'hash_b', 1, 0.00),
(3, 'carol@shop.example', NULL, 'hash_c', 1, 50.00),
(4, 'dave@shop.example', '戴夫', 'hash_d', NULL, 0.00);
-- 注意:referrer_id 这一列存在 NULL(1、4 号为空),后面 NOT IN 的坑全靠它
-- carol 昵称为 NULL 是 LEFT JOIN / 聚合章节的演示素材
INSERT INTO products (id, title, price, stock, tags, attrs) OVERRIDING SYSTEM VALUE VALUES
(1, '机械键盘', 399.00, 50, ARRAY['外设','输入设备'], '{"color":"黑","switch":"红轴"}'),
(2, '无线鼠标', 89.90, 120, ARRAY['外设','输入设备'], '{"color":"白"}'),
(3, '27寸显示器', 1299.00, 8, ARRAY['显示设备'], '{"size":"27","res":"4K"}'),
(4, 'USB-C 扩展坞',199.00, 0, ARRAY['外设','接口'], '{}'),
(5, '电竞椅', 899.00, 5, ARRAY['家具'], '{"material":"网布"}');
INSERT INTO orders (id, order_no, user_id, product_id, quantity, unit_price, status, paid_at, created_at)
OVERRIDING SYSTEM VALUE VALUES
(1, 'ORD20261001001', 1, 1, 1, 399.00, 'paid', '2026-10-01 10:30+08', '2026-10-01 10:00+08'),
(2, 'ORD20261002002', 1, 2, 2, 89.90, 'shipped', '2026-10-02 09:10+08', '2026-10-02 09:00+08'),
(3, 'ORD20261005003', 2, 3, 1, 1299.00,'paid', '2026-10-05 15:20+08', '2026-10-05 15:00+08'),
(4, 'ORD20260815004', 2, 2, 1, 89.90, 'pending', NULL, '2026-08-15 20:00+08'),
(5, 'ORD20261010005', 3, 5, 1, 899.00, 'pending', NULL, '2026-10-10 11:00+08'),
(6, 'ORD20261003006', 1, 4, 1, 199.00, 'cancelled', NULL, '2026-10-03 18:00+08'),
(7, 'ORD20260920007', 2, 1, 3, 399.00, 'completed', '2026-09-20 12:00+08', '2026-09-20 11:00+08');
-- 格局:alice 3 单(paid/shipped/cancelled)、bob 3 单(含 8 月 15 日的过期 pending)、
-- carol 只有 1 单且未支付、dave 一单没有 —— LEFT JOIN 与 NOT EXISTS 的素材都在这。
-- 本章多数例子的"当前时间"按 2026-10-20 前后理解。插入里出现 OVERRIDING SYSTEM VALUE:identity 列默认不许用户写 id,迁移/造数据时用它显式覆盖,这是 02 里"GENERATED ALWAYS 防手滑"的代价——需要防的就是这种场景,需要绕过时用关键字写明。
CRUD 与 RETURNING:让数据库把结果还给你
四件套本身没什么可讲的,PG 给的特色是 INSERT / UPDATE / DELETE 都支持 RETURNING——省掉"写完再查"的第二次往返,也是 Go 端拿自增 ID 的标准姿势(见 08-Go客户端pgx.md)。下面整段包在一个事务里演示,末尾统一 ROLLBACK,这样本章后面的读示例仍能看到干净的种子数据(真实业务里当然该提交):
BEGIN;
-- 插入并拿回主键、生成列算出的金额、落库时间
INSERT INTO orders (order_no, user_id, product_id, quantity, unit_price)
VALUES ('ORD20261020008', 4, 2, 1, 89.90)
RETURNING id, order_no, total_amount, created_at;
-- total_amount 是生成列:不用给值也直接读回(89.90 × 1 = 89.90)
-- 批量改状态,直接知道改了谁
UPDATE orders SET status = 'shipped'
WHERE order_no IN ('ORD20261005003')
RETURNING id, status, updated_at;
-- 删除并留底(先查后删有竞态窗口,DELETE ... RETURNING 删到的是真实被删的行)
DELETE FROM orders WHERE status = 'cancelled' AND quantity = 0
RETURNING id, order_no;
-- 种子数据里 cancelled 的单 quantity 是 1,谓词不匹配——RETURNING 无输出正好说明"没删到东西"
-- 更新时 PG 特有的 UPDATE ... FROM(多表更新没有标准语法,PG 用 FROM 凑)
UPDATE products p
SET stock = p.stock - t.qty
FROM (VALUES (1, 1), (3, 2)) AS t(product_id, qty)
WHERE p.id = t.product_id
RETURNING p.id, p.stock; -- 1 号变 49,3 号变 6
ROLLBACK;updated_at 不需要手工 SET——底座里 set_updated_at() 触发器会刷新。
JOIN 全家福
INNER / LEFT / RIGHT / CROSS / SELF
-- 内连接:只留两边都有的
SELECT u.email, o.order_no FROM users u JOIN orders o ON o.user_id = u.id;
-- 左外连接:左表全保留,右表缺失补 NULL —— dave 没下单也要出现
SELECT u.id, u.email, COUNT(o.id) AS order_cnt
FROM users u LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email; -- dave 的 order_cnt = 0
-- 右外连接:LEFT 的镜像,实践中几乎总可以改写成 LEFT,见到能认即可
-- 交叉连接:笛卡尔积,常用于"数据 × 维度"生成报表骨架
SELECT p.title, m.name
FROM products p CROSS JOIN (VALUES ('12期免息'), ('24期免息')) AS m(name);
-- 自连接:邀请人链条(users 引 users)
SELECT u.email AS invitee, r.email AS inviter
FROM users u JOIN users r ON r.id = u.referrer_id; -- bob→alice, carol→aliceCOUNT(o.id) 而不是 COUNT(*):LEFT JOIN 后 dave 的那行 o.id 是 NULL,COUNT 列会忽略 NULL 得到 0,COUNT(*) 会数到 1——这是聚合与外连接交界处最经典的数字错误。
LEFT JOIN 的 ON 与 WHERE:一字之差,结果集面目全非
先跑一遍两种写法对比——需求:“每个用户和他已支付的订单,没支付过的用户也要显示”:
-- 正确:右表过滤条件放在 ON —— 先按条件配对,配不上的右侧补 NULL,左侧全保留
SELECT u.id, u.email, o.id AS paid_order
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';
-- 1 alice | 1 ; 2 bob | 3 ; 3 carol | NULL ; 4 dave | NULL
-- 事故:条件挪进 WHERE —— NULL 行被谓词杀死,LEFT JOIN 退化成 INNER JOIN
... WHERE o.status = 'paid';
-- 只剩 alice、bob 两行;carol/dave 消失本质:ON 是配对规则(决定右侧谁能贴到左侧行上),WHERE 是配对之后的整行过滤。NULL = 'paid' 的结果是 NULL,WHERE 把 NULL 当"不为真"丢掉,于是"右表没匹配"的行被误杀。修复口诀:要么条件留在 ON,要么在 WHERE 里写成 o.status = 'paid' OR o.id IS NULL。这个坑的隐蔽之处在于:表里有数据时测试环境可能"碰巧"看不出差别。
FULL OUTER 与 LATERAL
-- 全外连接:两边不匹配的都不丢,对账/比对两套数据用
SELECT a.order_no, b.order_no
FROM orders a
FULL JOIN orders_archive b ON a.order_no = b.order_no;
-- LATERAL:右表可以"引用"左表当前行,相当于逐行执行的相关子查询
SELECT u.id, u.email, latest.order_no, latest.created_at
FROM users u
JOIN LATERAL (
SELECT o.order_no, o.created_at
FROM orders o
WHERE o.user_id = u.id
ORDER BY o.created_at DESC, o.id DESC
LIMIT 1
) latest ON true
ORDER BY u.id;LATERAL 是"每个用户最新一单"这类 top-1-per-group 问题的正解之一。同一需求还有 PG 独有的 DISTINCT ON 写法(见下一节聚合),两种对比:DISTINCT ON 短、对排序前缀有强制要求,一次只能按一组键取一行;LATERAL 更直白,能带 LIMIT 1 以外的复杂谓词,且配合 users 上的小表驱动 + orders(user_id, created_at) 复合索引(见 04-索引实战.md)时每用户是一次索引定位,数据量大时常常比全表排序快。
聚合
GROUP BY 的语义:把行按分组键装进桶,每桶产出一行。所以 SELECT 列表里只能出现分组键或聚合函数——SELECT user_id, status, COUNT(*) FROM orders GROUP BY user_id; 会报错 column "orders.status" must appear in the GROUP BY clause,因为一个桶里 status 有好几个值,数据库没法决定显示哪个。PG 的一个宽容扩展:GROUP BY 和 ORDER BY 可以直接引用 SELECT 输出列名(别名),但 WHERE 和 HAVING 不行——它们逻辑上在 SELECT 之前求值。
HAVING 过滤分组,WHERE 过滤行:先 WHERE 圈行、再 GROUP BY 装桶、再 HAVING 筛桶。
-- 下单超过 2 次的用户
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 2; -- alice(3) bob(3)FILTER (WHERE ...) 是 PG 的招牌糖:同一查询里按条件切分子统计,比一堆 CASE WHEN 可读性高一个量级:
SELECT user_id,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_cnt,
SUM(total_amount) FILTER (WHERE status <> 'cancelled') AS net_amount
FROM orders
GROUP BY user_id;
-- 旧写法 SUM(CASE WHEN ... THEN total_amount ELSE 0 END) 功能等价,但三四个 CASE 叠起来就没法看了COUNT 家族:COUNT(*) 数行且永远不为 NULL——PG 的优化器还认识它(数行不取任何列,会挑最小的可用索引或表做扫描,这是 09 里"COUNT(*) 为什么比 COUNT(常量) 不差"的背景);COUNT(col) 忽略该列 NULL 的行;COUNT(DISTINCT col) 去重计数(注意它阻止并行聚合,大表上慢)。array_agg/string_agg 把组内值折叠成数组/字符串,支持组内排序:
SELECT user_id,
string_agg(status, ',' ORDER BY created_at) AS status_flow,
array_agg(product_id) AS products
FROM orders GROUP BY user_id;注意
DISTINCT ON是 PG 独有(不是标准 SQL),用来每组取首行:
-- 每个用户最早的一单
SELECT DISTINCT ON (user_id) user_id, order_no, created_at
FROM orders
ORDER BY user_id, created_at, id; -- 前缀必须与 DISTINCT ON 表达式完全一致规则:ORDER BY 的开头若干列必须正好是 DISTINCT ON 的表达式列表,顺序也要一致,否则报 SELECT DISTINCT ON expressions must match initial ORDER BY expressions。取完每组第一行后,外层想再排序只能再套一层子查询——这是它和 LATERAL 相比不灵活的地方。
GROUPING SETS / CUBE / ROLLUP:报表的"小计/总计"
一次查询同时产出"明细分组 + 各级小计 + 总计",不用 UNION 拼三段:
SELECT to_char(created_at, 'YYYY-MM') AS mon, -- timestamptz 按会话时区格式化
status,
COUNT(*) AS cnt,
SUM(total_amount) AS amt
FROM orders
GROUP BY ROLLUP (mon, status) -- = GROUPING SETS ((mon,status),(mon),())
ORDER BY mon NULLS LAST, status NULLS LAST;ROLLUP 生成"月×状态、按月、总计"三层;CUBE 再把 (status) 单独一层也生成;被卷起的列在结果里是 NULL,报表端常用 COALESCE(status, '【全部】') 标注。GROUPING SETS 在 9.5 就有了,现代报表 SQL 见到不要陌生。
子查询与 EXISTS:NOT IN 是埋好的雷
标量子查询(SELECT 里放一个只返回一行一列的子查询,没匹配时返回 NULL):
SELECT email, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_cnt
FROM users u;EXISTS vs IN:判断"有订单的用户",两种写法:
SELECT u.* FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- EXISTS 只问"有没有",命中第一行短路返回;优化器通常走半连接(semi join)
-- 老资料说"永远 EXISTS 比 IN 快",现代优化器下多数场景两者等价;
-- 真正必须用 EXISTS 的理由只有一个:NOT IN 的 NULL 语义。用本章种子数据复现这个坑——需求:“从没邀请过任何人的用户”(id 从没出现在 referrer_id 集合里):
SELECT id, email FROM users
WHERE id NOT IN (SELECT referrer_id FROM users);
-- 结果:0 行。不是空表,是"全军覆没"。三值逻辑的账:子查询结果 {NULL,1,1,NULL} 里有一个 NULL,id NOT IN (...) 展开成 id<>NULL AND id<>1 AND ...,任何值 <> NULL 都是 NULL,一长串 AND 里掺一个 NULL 整个谓词就永远不为真。修复只有两条路:补 WHERE referrer_id IS NOT NULL,或者——军规是——一律改写为 NOT EXISTS:
SELECT u.id, u.email FROM users u
WHERE NOT EXISTS (SELECT 1 FROM users r WHERE r.referrer_id = u.id);
-- 2 bob ; 3 carol ; 4 dave(alice 邀请过 bob/carol 所以出局)。
-- 语义清晰,且不怕子查询将来出现 NULL。ANY / ALL 是 IN 的近亲:user_id = ANY(ARRAY[1,2]) 等价 IN;quantity > ALL(...) 对空集合恒为真——和 NOT IN 的 NULL 陷阱一样属于"空/NULL 边界"的账,见到 ALL 先想空集。
集合操作
UNION 合并并去重(多付一次排序/哈希),UNION ALL 原样拼接;报表数据源确定不重叠时用 ALL,性能差距在大结果集上是实打实的。INTERSECT 交集、EXCEPT 差集,同样各有 ALL 变体(ALL 按出现次数逐次抵消,用得少但要认识)。规则:各分支列数相同、类型按位置兼容;列名以第一个分支为准;ORDER BY / LIMIT 写在最后作用于整体,要作用于单个分支必须把分支用括号包起来:
(SELECT id, email FROM users WHERE balance > 0)
UNION ALL
(SELECT id, order_no FROM orders WHERE status = 'pending')
ORDER BY 1; -- 按结果第一列排序注意
NOT IN/EXCEPT对 NULL 的态度不一样:EXCEPT 的去重把 NULL 当"相等"(两个 NULL 只留一个),NOT IN 却会被 NULL 毒死整个谓词——两者混用是常见翻车点。
CTE:WITH 提升可读性,也管住物化
把长查询切成命名的步骤,可读性收益最大:
WITH paid AS (
SELECT user_id, SUM(total_amount) AS spent
FROM orders WHERE status IN ('paid','shipped','completed')
GROUP BY user_id
)
SELECT u.email, p.spent
FROM users u JOIN paid p ON p.user_id = u.id
WHERE p.spent > 500;版本分水岭:PG 12 起,没有副作用且只被引用一次的 CTE 会被内联进主查询(等价于子查询,优化器可以整体考虑谓词下推);被多次引用、或写了 MATERIALIZED 的才会先算一遍固化成临时结果集,NOT MATERIALIZED 则强制内联。这个开关在"先 LIMIT 再 JOIN"这类场景能改变性能走向,拿不准时加显式关键字,别赌优化器。
CTE 的杀手锏是数据修改 CTE:一条语句里先删后插,原子完成:
-- 把过期未支付订单归档(按种子数据:order 4,2026-08-15 的 pending)
WITH moved AS (
DELETE FROM orders
WHERE status = 'pending'
AND created_at < now() - interval '30 days'
RETURNING id, order_no, user_id, product_id, quantity,
unit_price, status, paid_at, created_at
)
INSERT INTO orders_archive
(id, order_no, user_id, product_id, quantity,
unit_price, status, paid_at, created_at)
SELECT * FROM moved
RETURNING (SELECT COUNT(*) FROM moved) AS archived_cnt;
-- archived_cnt = 1(order 5 只过期 10 天,留下)边界情况记三条:带副作用的 CTE(INSERT/UPDATE/DELETE)之间相对主查询的执行先后没有语义保证,别依赖顺序写逻辑;CTE 不能带参数(预编译场景改用函数或视图);递归查询 WITH RECURSIVE 和物化视图化 CTE 的高阶玩法放 07-高级SQL.md。
UPSERT:ON CONFLICT 为主,MERGE 补充
需求"邮箱不存在就插入、存在就改昵称",旧写法 select→判断→insert/update 在并发下必然竞态(两个请求同时查无、双双插入撞唯一键)。PG 9.5 起一条语句解决:
-- 冲突则什么都不做(order_no 幂等落单)
INSERT INTO orders (order_no, user_id, product_id, quantity, unit_price)
VALUES ('ORD20261001001', 1, 1, 1, 399.00)
ON CONFLICT (order_no) DO NOTHING
RETURNING id; -- 被跳过的行没有 RETURNING 输出,0 行即"已存在"
-- 冲突则更新:EXCLUDED 指"试图插入但因冲突没插成的那行"
INSERT INTO users (email, nickname, password_hash)
VALUES ('alice@shop.example', '爱丽丝A', 'hash_a_new')
ON CONFLICT (email) DO UPDATE
SET nickname = EXCLUDED.nickname,
updated_at = now()
WHERE users.balance < 1000 -- 部分 upsert:加 WHERE 守卫
RETURNING id, nickname;要点:冲突目标 (email) 必须能对上某个唯一约束或唯一索引;部分唯一索引要把索引的 WHERE 谓词一起写进冲突目标;DO UPDATE 并发正确性由唯一索引兜底——同键并发两笔 upsert 会串行化,不会双双赢。
MERGE(PG 15 加入,SQL 标准) 一条语句覆盖"匹配则更新、不匹配则插入、还可以 WHEN MATCHED AND cond THEN DELETE"三分支,做"两张表同步"这类逻辑比拼三段 ON CONFLICT 清晰:
MERGE INTO products p
USING (VALUES (1, 49), (6, 20)) AS s(id, stock)
ON p.id = s.id
WHEN MATCHED THEN UPDATE SET stock = s.stock
WHEN NOT MATCHED THEN INSERT (title, price, stock) VALUES ('新品', 99.00, s.stock);
-- NOT MATCHED 分支的 INSERT 不能手工给 id(GENERATED ALWAYS),由数据库自己发号日常取舍:单键幂等写入继续用 ON CONFLICT(从 9.5 起成熟、语义单一),多分支/批量对账场景再考虑 MERGE;15.0 刚发布的 MERGE 在并发语义上有过修补历史,生产用之前确认所跑小版本。
分页:OFFSET 的债,keyset 来还
-- 管理后台第 N 页:LIMIT/OFFSET 简单直白
SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 1000;问题在深分页。OFFSET 1000000 并不是"跳到第 100 万行"——执行器要按排序真真切切地生成前 1000020 行,再扔掉前 100 万行;索引扫描也要逐页访问这些行所在的叶子页,还要为它们检查 MVCC 可见性(死元组也要被数到)。翻页越深越慢,API 允许用户任意跳页时这就是现成的 DoS 面。
keyset(游标)分页:永远只问"上一页末尾之后的 20 条":
-- 首页
SELECT id, order_no, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 下一页:把上一页最后一行的 (created_at, id) 作为参数传入
SELECT id, order_no, created_at
FROM orders
WHERE (created_at, id) < ($1, $2) -- 行值比较,两列同向时可用
ORDER BY created_at DESC, id DESC
LIMIT 20;ORDER BY ... id 里的 id 是 tie-breaker:created_at 相同的一批行若不补唯一列,两次查询的"第 21 名"可能换人,出现丢单/重单。行值比较要求两列排序方向一致(都 DESC 或都 ASC);混合方向时展开成 created_at < $1 OR (created_at = $1 AND id < $2)。代价是 keyset 不支持"直接跳第 8 页",只能前进/后退——对信息流、订单列表这类 UI 本来就不需要跳页。性能侧的 EXPLAIN 证据见 09-查询优化与EXPLAIN.md。
NULL 专题
SQL 是三值逻辑:真、假、未知。任何与 NULL 的比较(=, <>, <, IN)结果都是未知,而 WHERE 只放行"真"的行:
SELECT NULL = NULL; -- NULL,不是 true
SELECT nickname <> '鲍勃' FROM users; -- carol(nickname 为 NULL)不会出现在结果里判空只能用 IS NULL / IS NOT NULL;需要"NULL 安全相等"用 PG 的 IS DISTINCT FROM(a IS DISTINCT FROM NULL 只有真 NULL 才为真,适合 WHERE 里过滤"没支付"这种语义)。聚合基本都忽略 NULL:AVG(paid_at - created_at)、MIN/MAX、array_agg 都不把 NULL 当事;两个例外是 COUNT(*)(数行)和 COUNT(col)(数非 NULL)。字符串拼接里 || 被 NULL 传染:'a' || NULL 是 NULL;CONCAT('a', NULL) 则忽略 NULL 返回 ‘a’。兜底函数:
SELECT COALESCE(nickname, email) AS display_name FROM users; -- carol 显示邮箱
SELECT quantity / NULLIF(stock, 0) FROM products; -- 除零变 NULL 而不是报错边界再补一刀:UNIQUE 列允许多行 NULL、外键允许 NULL(“没引用任何人"合法)、CHECK 谓词为 NULL 也算通过(CHECK 只拦"假”),这三条都是同一套三值逻辑的投影。
日期查询的正确姿势
要"2026 年 10 月的订单",用左闭右开区间,不要动列:
SELECT * FROM orders
WHERE created_at >= '2026-10-01' AND created_at < '2026-11-01';两个陷阱。其一,BETWEEN '2026-10-01' AND '2026-10-31' 两端都是闭区间——'2026-10-31 00:00:00+08' 之后(当天绝大多数数据)被排除在外;时间戳列上 BETWEEN 必须写成 BETWEEN '2026-10-01' AND '2026-10-31 23:59:59.999999+08' 才"对",但没人记得写全,所以规则是:日期范围一律 >= AND <。其二,不要写 date_trunc('month', created_at) = '2026-10-01'——对列套函数会让 B-tree 索引失效、每行现场算函数,改写成上面的范围谓词即可;反例的正解与代价测算见 04-索引实战.md 和 09-查询优化与EXPLAIN.md。
另外:timestamptz 列上的裸字面量 '2026-10-01' 会按会话时区解释成正好那一刻;跨时区服务要么字面量带偏移('2026-10-01+08'),要么统一 SET timezone,别让同一个字符串在不同机房含义不同。
模式匹配
SELECT * FROM users WHERE email LIKE '%@shop.example'; -- 后缀匹配:btree 索引用不上,必然全扫
SELECT * FROM users WHERE email ILIKE 'a%'; -- ILIKE 大小写不敏感;通配符能不能吃到索引条件很多,见 04
SELECT title FROM products WHERE title ~ '键盘|鼠标'; -- 正则 ~ (区分)/~* (不区分)
SELECT title FROM products WHERE title SIMILAR TO '%(键盘|鼠标)%';LIKE 的 % 匹配任意串、_ 匹配单字符,转义用 ESCAPE;SIMILAR TO 是 SQL 标准的"杂交正则",语法夹在两者之间两头不讨好——劝退,写正则就用 ~。真正要的是模糊搜索(任意位置的关键词、拼写容错),LIKE 全表扫描撑不住,上 pg_trgm 三元组 + GIN 索引,见 04-索引实战.md 与 07-高级SQL.md。
实战建议
- 时间查询一律左闭右开区间谓词,不碰 BETWEEN,不对列套 date_trunc——这既是对账正确性也是索引可用性的底线。
- 反连接一律 NOT EXISTS,永远不用 NOT IN 子查询(NULL 一旦进集合,结果静默变空,比报错更难发现)。
- 列表翻页用 keyset:ORDER BY 补 id 做 tie-breaker,下一页拿上一页末行值作参数;OFFSET 只留给管理后台的小表。
- 写操作带 RETURNING:拿自增 ID、确认影响行数、删前留证都在一条语句里完成,Go 端配合见
08-Go客户端pgx.md。 - CTE 控制物化:需要"先固化再 JOIN"(如 LIMIT 后再放大)时显式
MATERIALIZED;只是分段写法就用默认内联,别和递归/副作用混着赌行为。 - 组合技备忘:top-1-per-group 首选 LATERAL 或 DISTINCT ON;组内小计用 FILTER (WHERE …);报表小计总计用 ROLLUP/CUBE——这三样能把十行过程式 SQL 压成一句集合运算。