CRUD 增删改查
本节讲解对表数据的四类基本操作:增(INSERT)、查(SELECT)、改(UPDATE)、删(DELETE),统称 CRUD(Create / Read / Update / Delete)。学完本节,你将能用 SQL 完成日常业务中绝大多数数据操作,并掌握 RETURNING、UPSERT 等 PostgreSQL 特色能力。
前置知识
阅读本节前,建议先了解:创建表与插入数据
基础概念
CRUD 四个字母分别对应 SQL 的四类语句,其中 SELECT 属于 DQL(Data Query Language),其余三个属于 DML(Data Manipulation Language)。
| 操作 | 英文 | SQL 语句 | 作用 | 是否修改数据 |
|---|---|---|---|---|
| 增 | Create | INSERT | 向表中新增行 | 是 |
| 查 | Read | SELECT | 从表中读取行 | 否 |
| 改 | Update | UPDATE | 修改已存在的行 | 是 |
| 删 | Delete | DELETE | 删除已存在的行 | 是 |
几个关键术语:
- 影响行数(Affected Rows):
INSERT/UPDATE/DELETE执行后,PostgreSQL 会返回本次操作实际影响的行数。写作UPDATE 3表示更新了 3 行;如果实际是UPDATE 0,说明没有行匹配条件——这往往意味着条件写错了,而非"操作成功"。 RETURNING子句:PostgreSQL 特有(MySQL 无此能力)的扩展,让INSERT/UPDATE/DELETE在修改数据的同时返回被修改行的列值,避免"改完再查一遍"的两次往返。- UPSERT:
INSERT ... ON CONFLICT,即"存在则更新,不存在则插入",用于实现幂等写入。 - 原子性(Atomicity):单条 DML 语句本身是原子的——要么全部行生效,要么一行都不生效(如中途违反约束则整条语句回滚)。注意这与事务的原子性是两个层级。
- CTID:PostgreSQL 内部用于定位物理行版本的隐藏系统列。
UPDATE实际上并不"原地修改",而是写入新版本行并将旧版本标记为过期(这正是 MVCC,多版本并发控制 的实现方式),因此UPDATE会让行大小与表体积增长。
语法与用法
四种语句的最小骨架:
-- 增:插入一行
INSERT INTO 表名 (列1, 列2) VALUES (值1, 值2);
-- 查:读取行
SELECT 列1, 列2 FROM 表名 WHERE 条件;
-- 改:更新行
UPDATE 表名 SET 列1 = 新值 WHERE 条件;
-- 删:删除行
DELETE FROM 表名 WHERE 条件;四者都可以配合 RETURNING 使用:
INSERT INTO 表名 (列1) VALUES (值1) RETURNING *;
UPDATE 表名 SET 列1 = 新值 WHERE 条件 RETURNING 列1, 列2;
DELETE FROM 表名 WHERE 条件 RETURNING *;下面用一张最小的示例表贯穿本节全部代码:
-- 建一张演示用的商品库存表
CREATE TABLE demo_stock (
sku TEXT PRIMARY KEY, -- 商品编号
name TEXT NOT NULL, -- 商品名称
price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
tags TEXT[] NOT NULL DEFAULT '{}', -- 标签数组
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);详细说明
查:SELECT 的基本形态
SELECT 是使用最频繁的语句。它的完整形态包含 WHERE、ORDER BY、LIMIT、GROUP BY 等子句,本节只讲最核心的部分,条件与排序将在下一节展开。
-- 查询所有列(生产环境建议显式列出列名,避免表结构变更导致结果错位)
SELECT * FROM demo_stock;
-- 查询指定列,并给列取别名
SELECT sku, name, price AS unit_price FROM demo_stock;
-- 只查询前 5 行
SELECT sku, name FROM demo_stock LIMIT 5;
-- 加常量列与表达式列
SELECT
name,
price,
price * 1.13 AS price_with_tax -- 计算列
FROM demo_stock;SELECT 主要子句一览:
| 子句 | 作用 | 是否必需 |
|---|---|---|
SELECT 列表 | 指定要返回的列或表达式 | 必需 |
FROM | 指定数据来源表 | 可选(如 SELECT 1) |
WHERE | 过滤行 | 可选 |
ORDER BY | 排序 | 可选 |
LIMIT / OFFSET | 限制返回行数与偏移 | 可选 |
需要注意 SELECT 的逻辑执行顺序与书写顺序不同:
书写顺序:SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT
执行顺序:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT这解释了一个常见困惑:为什么 WHERE 里不能使用 SELECT 中定义的列别名(因为 WHERE 先执行),而 ORDER BY 里可以。
-- 错误!WHERE 执行时别名还不存在
SELECT price * 1.13 AS price_with_tax
FROM demo_stock
WHERE price_with_tax > 100;
-- 正确:重复写表达式,或用子查询/CTE
SELECT price * 1.13 AS price_with_tax
FROM demo_stock
WHERE price * 1.13 > 100;
-- 正确:ORDER BY 可以使用别名
SELECT price * 1.13 AS price_with_tax
FROM demo_stock
ORDER BY price_with_tax DESC;增:INSERT 的多种形式
-- 单行插入
INSERT INTO demo_stock (sku, name, price, stock)
VALUES ('SKU-2001', '机械键盘', 349.00, 120);
-- 多行插入(一次往返写入多行,性能远优于循环单行插入)
INSERT INTO demo_stock (sku, name, price, stock, tags) VALUES
('SKU-2002', '无线鼠标', 129.00, 300, '{peripherals, wireless}'),
('SKU-2003', 'USB-C 扩展坞', 199.00, 80, '{accessories}'),
('SKU-2004', '显示器支架', 259.00, 45, '{accessories, ergonomic}');
-- 插入并返回服务端生成的列值(默认值、序列、生成列等)
INSERT INTO demo_stock (sku, name, price)
VALUES ('SKU-2005', 'HDMI 线缆', 39.00)
RETURNING sku, name, stock, updated_at;INSERT ... SELECT 可以把查询结果直接写入表中,适合数据搬迁与批量派生:
-- 建一张归档表
CREATE TABLE demo_stock_archive (LIKE demo_stock INCLUDING ALL);
-- 把库存为 0 的商品搬进归档表,并返回搬运结果
INSERT INTO demo_stock_archive (sku, name, price, stock, tags)
SELECT sku, name, price, stock, tags
FROM demo_stock
WHERE stock = 0
RETURNING sku, name;INSERT 中几个易错点:
| 场景 | 行为 | 说明 |
|---|---|---|
| 列名列表省略 | 必须按表定义顺序提供所有列的值 | 不推荐,表结构变更即报错 |
提供 DEFAULT 关键字 | 该列使用默认值 | 常用于只想覆盖部分列 |
NULL 与 DEFAULT 不同 | NULL 是显式写入空值,DEFAULT 是使用默认值 | 若列有 NOT NULL 约束,写 NULL 会报错 |
| 主键冲突 | 整条语句失败(除非使用 ON CONFLICT) | 报错信息为 duplicate key value violates unique constraint |
改:UPDATE 与 RETURNING
UPDATE 的核心是一定要写 WHERE。忘记 WHERE 会更新全表。
-- 修改单列
UPDATE demo_stock
SET stock = 100
WHERE sku = 'SKU-2001';
-- 修改多列,并使用 RETURNING 拿到更新后的值
UPDATE demo_stock
SET stock = stock + 50, -- 基于当前值计算
price = 399.00,
updated_at = now()
WHERE sku = 'SKU-2001'
RETURNING sku, price, stock, updated_at;-- 准备一张调价快照表
CREATE TABLE demo_price_snapshot (sku TEXT PRIMARY KEY, new_price NUMERIC(10, 2) NOT NULL);
INSERT INTO demo_price_snapshot VALUES ('SKU-2001', 359.00), ('SKU-2002', 139.00);
-- 使用 FROM 引用另一张表的数据批量改价(PostgreSQL 的 UPDATE ... FROM)
UPDATE demo_stock AS s
SET price = p.new_price,
updated_at = now()
FROM demo_price_snapshot AS p
WHERE s.sku = p.sku
RETURNING s.sku, s.price;SET 子句的两个重要规则:
- 右侧表达式看到的是行的旧值。因此
SET a = b, b = a可以交换两列的值,右侧的b与a都指更新前的旧值。 - 不能在同一条语句中重复设置同一列,例如
SET price = 1, price = 2会报错。
-- 交换两列的值:右侧全部取旧值,可安全互换
CREATE TABLE demo_pair (id INT PRIMARY KEY, a TEXT, b TEXT);
INSERT INTO demo_pair VALUES (1, '左', '右');
UPDATE demo_pair SET a = b, b = a WHERE id = 1;
SELECT * FROM demo_pair; -- a = '右',b = '左'-- 错误!同一列被设置两次
UPDATE demo_stock SET price = 1.00, price = 2.00 WHERE sku = 'SKU-2001';小技巧:执行
UPDATE/DELETE前,先用同一份WHERE条件跑一次SELECT count(*),确认要影响的行数符合预期,再执行修改。
-- 先确认影响范围
SELECT count(*) FROM demo_stock WHERE stock = 0;
-- 确认无误后再删除
DELETE FROM demo_stock WHERE stock = 0 RETURNING sku, name;删:DELETE 与 TRUNCATE
DELETE 删除符合条件的行,可以配合 WHERE、USING、RETURNING:
-- 删除指定行
DELETE FROM demo_stock WHERE sku = 'SKU-2005';
-- 删除并返回被删除的内容(便于审计或二次确认)
DELETE FROM demo_stock
WHERE stock < 10
RETURNING sku, name, stock;
-- 使用 USING 引用其他表作为条件
DELETE FROM demo_stock AS s
USING demo_discontinued AS d
WHERE s.sku = d.sku
RETURNING s.sku, s.name;TRUNCATE 用于一次性清空整张表,它与 DELETE 的差异非常重要:
| 对比项 | DELETE FROM t | TRUNCATE TABLE t |
|---|---|---|
| 粒度 | 可按 WHERE 删部分行 | 只能清空全表(或指定分区) |
| 速度 | 逐行删除,产生大量 WAL 与死元组 | 直接换存储文件,极快 |
| 是否可回滚 | 可以(事务内) | 可以(事务内,PostgreSQL 支持) |
| 触发器 | 触发 BEFORE/AFTER DELETE | 只触发 TRUNCATE 级触发器 |
| 自增序列 | 不重置 | 加 RESTART IDENTITY 可重置 |
| 外键引用 | 可删除被引用表中未使用的行 | 若被外键引用则报错,需 CASCADE |
| 权限 | 需要 DELETE 权限 | 需要 TRUNCATE 权限(更严格) |
-- 清空归档表并重置自增序列
TRUNCATE TABLE demo_stock_archive RESTART IDENTITY;
-- 一并清空有外键引用关系的表
TRUNCATE TABLE demo_order_item, demo_order RESTART IDENTITY CASCADE;UPSERT:INSERT ... ON CONFLICT
UPSERT = Update + Insert。用于"有则更新,无则插入"的幂等写入场景,例如定时同步上游商品数据。
-- PostgreSQL 9.5+:ON CONFLICT 实现 UPSERT
INSERT INTO demo_stock (sku, name, price, stock)
VALUES ('SKU-2001', '机械键盘 Pro', 379.00, 90)
ON CONFLICT (sku) -- 冲突判定的列/约束
DO UPDATE SET
name = EXCLUDED.name, -- EXCLUDED 代表本次尝试插入的行
price = EXCLUDED.price,
stock = EXCLUDED.stock,
updated_at = now()
RETURNING sku, name, price, stock;ON CONFLICT 有两种收尾方式:
-- 方式一:冲突时更新
ON CONFLICT (sku) DO UPDATE SET stock = EXCLUDED.stock;
-- 方式二:冲突时静默跳过(常用于导入去重)
INSERT INTO demo_stock (sku, name, price)
VALUES ('SKU-2002', '无线鼠标', 129.00)
ON CONFLICT (sku) DO NOTHING;EXCLUDED 是一个特殊别名,指代"本次因冲突而未能插入的候选行"。此外,DO UPDATE 的 WHERE 可以进一步筛选哪些冲突才需要更新:
-- 仅当新价格更低时才更新,避免无意义写入
INSERT INTO demo_stock (sku, name, price, stock)
VALUES ('SKU-2003', 'USB-C 扩展坞', 179.00, 100)
ON CONFLICT (sku) DO UPDATE
SET price = EXCLUDED.price,
updated_at = now()
WHERE demo_stock.price > EXCLUDED.price -- 注意:此处用表名限定旧值
RETURNING sku, price;区分本次操作到底是插入还是更新,是幂等同步中的常见需求:
-- 技巧:用系统列 xmax 判断。新插入的行 xmax = 0,被更新的行 xmax <> 0
INSERT INTO demo_stock (sku, name, price, stock)
VALUES ('SKU-2001', '机械键盘 Pro', 379.00, 90)
ON CONFLICT (sku) DO UPDATE SET stock = EXCLUDED.stock
RETURNING sku, (xmax = 0) AS inserted; -- true 表示新插入,false 表示命中更新实战示例
下面用一段完整流程演示 CRUD 的配合使用。假设 demo_stock 表已建好,我们来模拟"上架 → 调价 → 售出 → 下架"的生命周期。
-- 步骤 1:清理并准备数据
TRUNCATE TABLE demo_stock;
INSERT INTO demo_stock (sku, name, price, stock, tags) VALUES
('SKU-2001', '机械键盘', 349.00, 120, '{peripherals}'),
('SKU-2002', '无线鼠标', 129.00, 300, '{peripherals, wireless}'),
('SKU-2003', 'USB-C 扩展坞', 199.00, 80, '{accessories}');-- 预期输出
INSERT 0 3-- 步骤 2:查询现有商品(Read)
SELECT sku, name, price, stock FROM demo_stock ORDER BY sku; sku | name | price | stock
----------+--------------+--------+-------
SKU-2001 | 机械键盘 | 349.00 | 120
SKU-2002 | 无线鼠标 | 129.00 | 300
SKU-2003 | USB-C 扩展坞 | 199.00 | 80
(3 rows)-- 步骤 3:新增一个商品,并直接拿到生成的值(Create)
INSERT INTO demo_stock (sku, name, price, stock, tags)
VALUES ('SKU-2004', '显示器支架', 259.00, 45, '{accessories, ergonomic}')
RETURNING sku, name, stock, updated_at; sku | name | stock | updated_at
----------+------------+-------+-------------------------------
SKU-2004 | 显示器支架 | 45 | 2026-09-21 10:12:33.482+08
(1 row)-- 步骤 4:调价 + 补货(Update),返回更新后的完整行
UPDATE demo_stock
SET price = price * 0.9, -- 打九折
stock = stock + 30,
updated_at = now()
WHERE sku = 'SKU-2001'
RETURNING sku, name, price, stock; sku | name | price | stock
----------+----------+--------+-------
SKU-2001 | 机械键盘 | 314.10 | 150
(1 row)
UPDATE 1-- 步骤 5:批量 UPSERT,模拟每天同步上游价格与库存(幂等)
INSERT INTO demo_stock (sku, name, price, stock) VALUES
('SKU-2001', '机械键盘', 319.00, 150),
('SKU-2002', '无线鼠标', 119.00, 260),
('SKU-2005', 'HDMI 线缆', 39.00, 500)
ON CONFLICT (sku) DO UPDATE
SET price = EXCLUDED.price,
stock = EXCLUDED.stock,
name = EXCLUDED.name,
tags = demo_stock.tags, -- 保留原有标签,不被覆盖
updated_at = now()
RETURNING sku, name, price, stock, (xmax = 0) AS is_new; sku | name | price | stock | is_new
----------+------------+--------+-------+--------
SKU-2001 | 机械键盘 | 319.00 | 150 | f
SKU-2002 | 无线鼠标 | 119.00 | 260 | f
SKU-2005 | HDMI 线缆 | 39.00 | 500 | t
(3 rows)-- 步骤 6:售出商品,扣减库存(带条件的 Update,防超卖)
UPDATE demo_stock
SET stock = stock - 5
WHERE sku = 'SKU-2002'
AND stock >= 5 -- 关键:条件写在 WHERE 里,保证不出现负库存
RETURNING sku, stock; sku | stock
----------+-------
SKU-2002 | 255
(1 row)
UPDATE 1-- 步骤 7:下架缺货商品(Delete),返回被删除的内容用于记录
DELETE FROM demo_stock
WHERE stock = 0
RETURNING sku, name; sku | name
-----+------
(0 rows)
DELETE 0-- 步骤 8:最终核对库存总览(聚合查询)
SELECT count(*) AS sku_count, sum(stock) AS total_stock FROM demo_stock; sku_count | total_stock
-----------+-------------
5 | 1030
(1 row)完整流程中,一条 UPDATE 返回 UPDATE 0 并不代表出错,而代表"没有满足条件的行"。把影响行数当作业务断言使用,是避免"静默失败"的有效手段:
-- 在应用层把影响行数当作断言:期望 1 行,若不是则说明业务状态不符合预期
UPDATE demo_stock SET stock = stock - 5 WHERE sku = 'SKU-2002' AND stock >= 5;
-- 应用层检查:若返回 UPDATE 0,则应抛出"库存不足"业务异常,而不是继续下单注意事项
UPDATE/DELETE忘记WHERE会操作全表。 这是生产事故的高频原因。务必先用SELECT count(*)验证,并在必要时开启事务,确认后再提交。
-- 错误!会删除全表数据
DELETE FROM demo_stock;
-- 正确:先确认范围,再删除
SELECT count(*) FROM demo_stock WHERE stock = 0;
DELETE FROM demo_stock WHERE stock = 0;= NULL永远不成立。 判断NULL必须用IS NULL/IS NOT NULL。WHERE sku = NULL的结果集恒为空。
-- 错误!结果永远是 0 行
SELECT * FROM demo_stock WHERE tags = NULL;
-- 正确
SELECT * FROM demo_stock WHERE tags IS NULL;UPDATE是"写新版本 + 标旧版本",不是原地修改。 因此高频更新的大表会产生表膨胀(Bloat),需要依赖 autovacuum 回收。不要在大表上做无意义的全表更新(例如无条件SET updated_at = now())。
-- 错误!这会把全表每一行都写一次新版本,产生大量死元组
UPDATE demo_stock SET updated_at = now();
-- 正确:只更新真正需要变更的行
UPDATE demo_stock SET updated_at = now() WHERE sku = 'SKU-2001';ON CONFLICT必须能找到唯一索引。 括号里写的列或表达式必须对应一个唯一索引/唯一约束,否则会报there is no unique or exclusion constraint matching the ON CONFLICT specification。
-- 错误!demo_stock 上只有 sku 是主键,name 上没有唯一索引
INSERT INTO demo_stock (sku, name, price)
VALUES ('SKU-2006', '机械键盘', 349.00)
ON CONFLICT (name) DO NOTHING;ON CONFLICT DO UPDATE在高并发下可能报序列化错误。 当两个事务同时插入同一冲突键时,可能抛出could not serialize access due to concurrent update。需要在应用层捕获并重试。DELETE不会释放磁盘空间。 删除后的空间由 autovacuum 标记为可复用,但文件不会自动缩小。需要收缩时使用VACUUM FULL(会持有排他锁)或pg_repack扩展。TRUNCATE需要更强的锁。 它会对表加ACCESS EXCLUSIVE锁,阻塞所有读写,且若被其他表外键引用则需要CASCADE。线上清表慎用。SELECT *在有生成列/大字段的表上会拖慢查询。 生产代码应显式列出所需列。
最佳实践
- 所有修改类语句都带
RETURNING。 一次往返完成"写入 + 读取",既减少网络开销,也让操作结果可被直接验证。这是 PostgreSQL 相对 MySQL 的重要优势。 - 用影响行数做业务断言。
UPDATE/DELETE后检查返回行数,0应视为异常路径处理,而非成功。 - 批量写入优先用多行
VALUES或COPY。 循环内的单行INSERT是最常见的性能瓶颈。大批量导入使用COPY比INSERT快一个数量级。 - 幂等同步统一用
ON CONFLICT。 定时任务、消息重投、断点续传等场景,用 UPSERT 替代"先查再决定插还是改",可规避竞态条件。 - 条件写进
WHERE,不要写进应用逻辑。 例如扣减库存写成WHERE stock >= n,让数据库在行锁内完成判断,天然防止超卖。 - 大批量
UPDATE/DELETE分批执行。 每次处理 1000~10000 行并提交,避免长事务持有大量行锁、阻塞 autovacuum、造成主从复制延迟。
-- 分批删除示例(PostgreSQL 13+:使用 CTE + LIMIT)
WITH victim AS (
SELECT ctid FROM demo_stock WHERE stock = 0 LIMIT 1000
)
DELETE FROM demo_stock s
USING victim v
WHERE s.ctid = v.ctid
RETURNING s.sku;- 先
SELECT后修改,并用事务包裹。 生产环境的 DML 尽量显式BEGIN/COMMIT,给自己留一次ROLLBACK的机会。
BEGIN;
UPDATE demo_stock SET price = price * 1.1 WHERE tags @> ARRAY['peripherals'];
-- 检查影响行数与数据是否符合预期
SELECT sku, name, price FROM demo_stock WHERE tags @> ARRAY['peripherals'];
-- 确认无误后提交;否则 ROLLBACK;
COMMIT;- 为
WHERE条件列建索引。 CRUD 的查询与修改性能强依赖索引。常用作条件的列(外键、状态、时间)应有合适的索引。
-- 为经常作为过滤条件的列建索引
CREATE INDEX idx_demo_stock_stock ON demo_stock (stock);
CREATE INDEX idx_demo_stock_tags ON demo_stock USING gin (tags);下一节
继续学习:条件查询与排序