更新数据(UPDATE)
UPDATE 看着简单——SET 加 WHERE 而已——但它是生产事故与性能问题最集中的语句:忘写 WHERE 会动全表,写错 WHERE 会静默更新 0 行,无条件更新会让表膨胀,从别的表取数时的 FROM 语义也容易踩坑。本节把这些讲透。
前置知识
阅读本节前,建议先了解:UPSERT(INSERT ... ON CONFLICT)
基础概念
UPDATE 修改已存在的行。它的核心行为由三个要素决定:更新哪些行(FROM + WHERE)、改成什么(SET)、改完返回什么(RETURNING)。
最重要的一点:PostgreSQL 的 UPDATE 不是原地修改。它写入一个全新的行版本(新元组),把旧版本标记为过期。这是 MVCC 的实现方式,带来两个直接后果:
- 每次
UPDATE都会让表和索引增大(产生死元组),需要VACUUM回收。 - 被更新的行的
ctid(物理位置)会变化,因此不能用ctid做长期标识。
| 组成要素 | 作用 | 是否必需 |
|---|---|---|
UPDATE 表名 | 目标表(可带别名) | 必需 |
SET 列 = 表达式 | 要改成什么 | 必需 |
FROM 其他表 | 从别的表取数(类似隐式 JOIN) | 可选 |
WHERE 条件 | 过滤要更新哪些行 | 可选(省略即全表) |
RETURNING | 返回被更新行的值 | 可选 |
几个关键术语:
- 全表更新(Full Table Update):省略
WHERE的UPDATE会更新表里每一行。在百万行大表上是灾难级操作。 - 基于新值的求值顺序:
SET子句里所有表达式的右侧都基于旧行计算,而不是"左边改完再用新值算右边"。SET a = b, b = a能正确交换两个值。 FROM与WHERE的隐式连接:UPDATE ... FROM other WHERE t.id = other.id是标准写法。若WHERE遗漏了关联条件,会退化成笛卡尔积,把目标行更新成任意一遍。- 歧义的目标表限定:
FROM里出现与目标表同名的表时,必须用别名区分,否则column reference is ambiguous。 ctid:行在表文件中的物理地址,格式(块号, 块内槽位)。它随更新而变化,只适合在单条语句内做行的临时定位。- 膨胀(Bloat):大量
UPDATE/DELETE留下的死元组占用的空间。UPDATE是膨胀的主要来源。
语法与用法
完整语法骨架:
[ WITH [ RECURSIVE ] with_query [, ...] ]
UPDATE [ ONLY ] table_name [ * ] [ [ AS ] alias ]
SET { column_name = { expression | DEFAULT } |
( column_name [, ...] ) = [ ROW ] ( { expression | DEFAULT } [, ...] ) |
( column_name [, ...] ) = ( sub-SELECT )
} [, ...]
[ FROM from_item [, ...] ]
[ WHERE condition | WHERE CURRENT OF cursor_name ]
[ RETURNING * | output_expression [ [ AS ] output_name ] [, ...] ]四种最常用的形态:
-- 1. 单列更新(务必带 WHERE)
UPDATE users SET status = 'active' WHERE id = 42;
-- 2. 多列更新
UPDATE users
SET status = 'active', updated_at = now()
WHERE id = 42;
-- 3. 基于自身表达式更新(累加、打折等)
UPDATE inventory SET qty = qty - 5 WHERE sku = 'SKU-1001';
-- 4. 从其他表取数更新
UPDATE orders o
SET status = p.status
FROM payments p
WHERE o.order_no = p.order_no AND o.status = 'pending';SET 右侧可用的表达式形式:
| 形式 | 示例 | 说明 |
|---|---|---|
| 字面量 | SET status = 'paid' | 直接赋值 |
| 自身表达式 | SET qty = qty + 10 | 基于旧值计算 |
| 函数 | SET updated_at = now() | 常用 now() / clock_timestamp() |
DEFAULT | SET status = DEFAULT | 重置为该列默认值 |
| 子查询 | SET total = (SELECT sum(x) FROM d WHERE d.oid = o.id) | 标量子查询 |
| 行构造 | SET (a, b) = (1, 2) | 多列一次赋值 |
| 行子查询 | SET (a, b) = (SELECT x, y FROM t WHERE ...) | 多列来自同一子查询 |
CASE WHEN | SET level = CASE WHEN qty > 100 THEN 'high' ELSE 'low' END | 条件赋值 |
下面用一张贯穿本节的示例表:
-- 示例:订单表 + 支付流水表
DROP TABLE IF EXISTS orders_demo;
CREATE TABLE orders_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no text NOT NULL UNIQUE,
user_email text NOT NULL,
amount numeric(12,2) NOT NULL CHECK (amount >= 0),
status text NOT NULL DEFAULT 'pending',
tags text[] NOT NULL DEFAULT '{}',
updated_at timestamptz NOT NULL DEFAULT now()
);
DROP TABLE IF EXISTS payments_demo;
CREATE TABLE payments_demo (
pay_no text PRIMARY KEY,
order_no text NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL,
paid_at timestamptz
);详细说明
WHERE 决定一切
UPDATE 的 WHERE 与 SELECT 的 WHERE 完全同构,可以写等值、范围、IN、EXISTS、数组包含、IS NULL 等任意条件。
-- 等值
UPDATE orders_demo SET status = 'paid' WHERE order_no = 'ORD-1001';
-- 范围 + 多列
UPDATE orders_demo
SET status = 'vip'
WHERE amount >= 1000 AND status = 'paid';
-- 数组包含(需 GIN 索引才高效)
UPDATE orders_demo
SET status = 'promoted'
WHERE tags @> ARRAY['promo'];
-- 子查询 / EXISTS
UPDATE orders_demo o
SET status = 'settled'
WHERE EXISTS (
SELECT 1 FROM payments_demo p
WHERE p.order_no = o.order_no AND p.status = 'success'
);UPDATE 0 的含义是没有行匹配条件。这通常意味着条件写错了,而不是"操作成功"——把影响行数当断言用,是发现静默失败的第一道防线。
UPDATE orders_demo SET status = 'paid' WHERE order_no = 'NOT-EXIST';UPDATE 0
-- 应用层应当把 UPDATE 0 视为异常:期望更新的行不存在FROM:从其他表取数
UPDATE ... FROM 是"用另一张表的数据改本表"的标准方式。它相当于在 WHERE 里做隐式 JOIN。
-- 用支付流水回写订单状态
UPDATE orders_demo o
SET status = p.status,
updated_at = now()
FROM payments_demo p
WHERE o.order_no = p.order_no
AND o.status = 'pending';最容易踩的坑:FROM 的目标表若在 WHERE 中没有与目标表关联,会变成笛卡尔积。
-- 危险!没有关联条件,每行订单会被随机更新为 payments 里的某个状态
UPDATE orders_demo o
SET status = p.status
FROM payments_demo p
WHERE o.status = 'pending';
-- 结果:o 的每一行与 p 的每一行做笛卡尔积,目标行被更新多次,
-- 最终值不确定(取决于执行计划),且行数看起来"正常"当目标表在 FROM 里被再次引用(自连接)时,必须给别名,且不能用表的原名去限定:
-- 把自己表里的最新价格回写到同表的历史行
UPDATE orders_demo o
SET amount = latest.amount
FROM orders_demo latest
WHERE latest.order_no = o.order_no || '-REV'
AND latest.amount > o.amount;
-- 注意:此时不能用 orders_demo.amount 限定,会报 ambiguous另一种等效但更直观的写法是用子查询或 CTE:
-- 用 CTE 先算出结果,再更新(可读性更好)
WITH paid AS (
SELECT order_no, sum(amount) AS paid_amount
FROM payments_demo
WHERE status = 'success'
GROUP BY order_no
)
UPDATE orders_demo o
SET amount = paid.paid_amount
FROM paid
WHERE o.order_no = paid.order_no;SET 的求值语义
SET 中所有右侧表达式都基于同一份旧行求值,这条规则能帮你写出很多"看起来不可能"的写法:
-- 交换两列的值(右侧都读旧值,因此可以正确交换)
UPDATE demo_swap SET a = b, b = a WHERE id = 1;-- 更新前:a = 1, b = 2
-- 更新后:a = 2, b = 1 (不是 a = 2, b = 2)由此还能推出:SET 中左侧列的顺序不影响结果,且有依赖关系的计算不会被"串起来"。
-- 左侧先写哪个都一样
UPDATE orders_demo SET amount = amount * 2, status = 'doubled' WHERE id = 1;
-- 右侧的 status 仍是旧值 'pending',不是 'doubled'
UPDATE orders_demo SET status = 'doubled', note = status WHERE id = 1;行构造与行子查询的写法可以一次赋值多列,避免重复写 WHERE:
-- 行构造:多列一次赋值
UPDATE orders_demo
SET (status, amount) = ('vip', 0)
WHERE amount > 10000;
-- 行子查询:多列来自同一个子查询(注意必须返回 1 行)
UPDATE orders_demo o
SET (status, amount) = (
SELECT p.status, p.amount
FROM payments_demo p
WHERE p.order_no = o.order_no
)
WHERE o.status = 'pending';无意义更新的代价
即使 SET 后的值与原来完全相同,UPDATE 仍然会写一个新版本、产生死元组、写 WAL。这是"定时任务全表刷新"类代码的最大隐患。
-- 这条语句把每一行都"重写"一遍,哪怕值没变
UPDATE orders_demo SET status = status;
-- 用 WHERE 过滤掉不需要改的行
UPDATE orders_demo
SET updated_at = now()
WHERE status IS DISTINCT FROM 'settled';诊断手段:用 pg_stat_user_tables 看 n_tup_upd(更新次数)与 n_dead_tup(死元组数):
SELECT relname, n_tup_upd, n_tup_hot_upd, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'orders_demo'; relname | n_tup_upd | n_tup_hot_upd | n_dead_tup | last_autovacuum
------------+-----------+---------------+------------+-------------------------
orders_demo| 1250 | 180 | 320 | 2026-09-24 15:40:12.115+08
(1 row)n_tup_hot_upd(HOT 更新) 是"堆内元组更新"次数:当更新不涉及任何索引列、且新版本能放进同一页的空闲空间时,PostgreSQL 走 HOT 路径,不需要更新索引,代价显著更低。因此:
- 更新索引列(如
status、user_email)会同时写索引,代价高。 - 更新非索引列(如
updated_at若未建索引)有机会走 HOT。 - 关键是留出页内空闲空间(
fillfactor小于 100)才能让 HOT 生效。
-- 为高频更新的表留出页内空间,提升 HOT 命中率
ALTER TABLE orders_demo SET (fillfactor = 85);并发更新与丢失更新
UPDATE 在 READ COMMITTED 下的行为:如果目标行已被其他未提交事务修改,本事务会等待;对方提交后,本事务会在最新版本上重新求值 WHERE。如果新版本不再满足 WHERE,该行就被跳过。
这带来经典的丢失更新(Lost Update)问题:
-- 事务 A 与事务 B 同时执行:
UPDATE inventory SET qty = qty - 10 WHERE sku = 'SKU-1001';上面这条是安全的,因为 qty = qty - 10 在最新版本上重新计算。
但下面这种"读出来算好再写回"的写法不安全:
-- 不安全:应用层先读 qty = 100,算出 90,再写回
UPDATE inventory SET qty = 90 WHERE sku = 'SKU-1001';
-- 若期间另一个事务已把 qty 改成 80,这里会直接覆盖为 90,丢失对方的 -20正确做法有三条路:
-- 方案 1:把计算写进 SET,让数据库在行锁内完成(推荐)
UPDATE inventory SET qty = qty - 10 WHERE sku = 'SKU-1001' AND qty >= 10;
-- 方案 2:显式加行锁后再读
SELECT qty FROM inventory WHERE sku = 'SKU-1001' FOR UPDATE;
-- 方案 3:用乐观锁版本号
UPDATE inventory SET qty = 90, version = version + 1
WHERE sku = 'SKU-1001' AND version = 7; -- 影响行数为 0 说明版本已被改,重试把业务条件写进 WHERE 还能天然防超卖:
-- 只有库存足够时才扣减;返回 0 行表示库存不足
UPDATE inventory SET qty = qty - 5 WHERE sku = 'SKU-1001' AND qty >= 5;大表批量更新
在大表上一次更新几百万行会带来连锁问题:长事务持有大量行锁、阻塞 autovacuum、产生巨额 WAL、主从延迟飙升、甚至触发锁等待超时。
标准做法是分批更新:
-- 分批:每次更新 1000 行,循环执行直到影响行数为 0
WITH victim AS (
SELECT ctid FROM orders_demo WHERE status = 'pending' LIMIT 1000
)
UPDATE orders_demo o
SET status = 'expired'
FROM victim v
WHERE o.ctid = v.ctid;UPDATE 1000
-- 重复执行;直到返回 UPDATE 0 表示处理完毕这里用 ctid 而不是主键,是因为 ctid 是物理地址,定位代价最低。但要注意:ctid 会因更新而改变,因此每次迭代都必须重新查出 ctid(上面的写法正是如此,每轮都重新 SELECT ctid)。另一个常见写法是用主键区间:
-- 按主键区间分批(适合 id 连续且无空洞的表)
UPDATE orders_demo
SET status = 'expired'
WHERE id BETWEEN 1 AND 1000 AND status = 'pending';
-- 下一轮改为 1001 ~ 2000,依此类推两种方案各有侧重:
| 分批依据 | 优点 | 缺点 |
|---|---|---|
ctid | 精准命中待更新行,不空转 | 必须每轮重新查询;行位置随更新变化 |
| 主键区间 | 逻辑简单、可断点续跑 | 区间内可能没有待更新行,存在空转 |
对"更新整表大部分行"的极端场景,还有更快的路子:建新表 + 灌数据 + 原子切换,代价是空间翻倍与短暂的写阻塞。
-- 重写式更新(适合更新比例 > 50% 的超大表)
BEGIN;
CREATE TABLE orders_demo_new (LIKE orders_demo INCLUDING ALL);
INSERT INTO orders_demo_new (order_no, user_email, amount, status, tags, updated_at)
SELECT order_no, user_email, amount,
CASE WHEN status = 'pending' THEN 'expired' ELSE status END,
tags, now()
FROM orders_demo;
ALTER TABLE orders_demo RENAME TO orders_demo_old;
ALTER TABLE orders_demo_new RENAME TO orders_demo;
COMMIT;实战示例
下面用一个"订单状态机推进 + 金额调整 + 批量过期"的完整流程演示 UPDATE 的典型用法。
-- 步骤 1:准备数据
DROP TABLE IF EXISTS orders_demo;
CREATE TABLE orders_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no text NOT NULL UNIQUE,
user_email text NOT NULL,
amount numeric(12,2) NOT NULL CHECK (amount >= 0),
status text NOT NULL DEFAULT 'pending',
tags text[] NOT NULL DEFAULT '{}',
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE orders_demo SET (fillfactor = 85);
DROP TABLE IF EXISTS payments_demo;
CREATE TABLE payments_demo (
pay_no text PRIMARY KEY,
order_no text NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL
);
INSERT INTO orders_demo (order_no, user_email, amount, status, tags) VALUES
('ORD-1001', 'alice@example.com', 199.00, 'pending', '{}'),
('ORD-1002', 'bob@example.com', 880.00, 'pending', '{promo}'),
('ORD-1003', 'carol@example.com', 3200.00, 'pending', '{}'),
('ORD-1004', 'dave@example.com', 15.90, 'pending', '{gift}');
INSERT INTO payments_demo (pay_no, order_no, amount, status) VALUES
('PAY-1', 'ORD-1001', 199.00, 'success'),
('PAY-2', 'ORD-1002', 880.00, 'failed'),
('PAY-3', 'ORD-1003', 3200.00, 'success');DROP TABLE
CREATE TABLE
ALTER TABLE
DROP TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 3-- 步骤 2:单行更新 —— 把订单标记为已支付,并返回改后结果
UPDATE orders_demo
SET status = 'paid',
updated_at = now()
WHERE order_no = 'ORD-1004'
RETURNING order_no, status, updated_at; order_no | status | updated_at
----------+--------+-------------------------------
ORD-1004 | paid | 2026-09-24 15:52:07.441+08
(1 row)
UPDATE 1-- 步骤 3:条件更新 —— 批量给已支付订单打标签(数组拼接)
UPDATE orders_demo
SET tags = tags || ARRAY['archived'],
updated_at = now()
WHERE status = 'paid'
RETURNING order_no, tags; order_no | tags
----------+---------------------
ORD-1004 | {gift,archived}
(1 row)
UPDATE 1-- 步骤 4:从其他表取数 —— 依据支付结果推进订单状态
UPDATE orders_demo o
SET status = CASE p.status
WHEN 'success' THEN 'settled'
WHEN 'failed' THEN 'payment_failed'
ELSE o.status
END,
updated_at = now()
FROM payments_demo p
WHERE o.order_no = p.order_no
AND o.status = 'pending'
RETURNING o.order_no, o.status; order_no | status
----------+-----------------
ORD-1001 | settled
ORD-1002 | payment_failed
ORD-1003 | settled
(3 rows)
UPDATE 3-- 步骤 5:基于自身表达式 —— 给高额订单打折
UPDATE orders_demo
SET amount = round(amount * 0.9, 2),
updated_at = now()
WHERE amount >= 1000
RETURNING order_no, amount; order_no | amount
----------+----------
ORD-1003 | 2880.00
(1 row)
UPDATE 1-- 步骤 6:批量过期(分批更新,每批 2 行,模拟大表场景)
UPDATE orders_demo
SET status = 'expired'
WHERE id IN (
SELECT id FROM orders_demo
WHERE status = 'payment_failed'
LIMIT 2
);UPDATE 1-- 步骤 7:核对结果分布
SELECT status, count(*) AS cnt, sum(amount) AS total
FROM orders_demo
GROUP BY status
ORDER BY status; status | cnt | total
----------------+-----+---------
expired | 1 | 880.00
paid | 1 | 15.90
settled | 2 | 3079.00
(3 rows)-- 步骤 8:确认无意义更新已被抑制 —— 重复执行一次相同的更新
UPDATE orders_demo
SET amount = 2880.00
WHERE order_no = 'ORD-1003'
AND amount IS DISTINCT FROM 2880.00;UPDATE 0
-- 值已经等于目标值,因此没有任何行被更新,不产生死元组-- 步骤 9:观察更新统计(HOT 更新占比)
SELECT relname, n_tup_upd, n_tup_hot_upd, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'orders_demo'; relname | n_tup_upd | n_tup_hot_upd | n_dead_tup
-------------+-----------+---------------+------------
orders_demo | 7 | 2 | 5
(1 row)上面的流程覆盖了 UPDATE 的主要形态:单行、条件批量、CASE 分支、FROM 关联、自身表达式、分批处理,以及"抑制无意义更新"这一生产必备技巧。
注意事项
- 省略
WHERE会更新全表。 这是最严重的生产事故类型之一。执行前务必先SELECT count(*)确认范围。
-- 灾难!会把全表 status 都改成 expired
UPDATE orders_demo SET status = 'expired';
-- 正确:先确认范围再更新
SELECT count(*) FROM orders_demo WHERE status = 'pending';
UPDATE orders_demo SET status = 'expired' WHERE status = 'pending';FROM少了关联条件会静默产生笛卡尔积。 结果可能"看起来对",但实际每行被更新了多次,最终值取决于执行计划。
-- 错误!缺少 o.order_no = p.order_no
UPDATE orders_demo o SET status = p.status
FROM payments_demo p
WHERE o.status = 'pending';SET右侧全部基于旧值求值。 不要指望"左边改了,右边就能读到新值"。
-- 结果:note 里存的是旧 status('pending'),不是 'paid'
UPDATE orders_demo SET status = 'paid', note = status WHERE id = 1;UPDATE会让行版本翻倍增长。 高频更新的大表必须关注膨胀,合理设置fillfactor、保证 autovacuum 有效运行,必要时用pg_repack在线整理。不要依赖
ctid作为行的长期标识。 任何UPDATE都会改变ctid。它只适合在单条语句内做定位。更新索引列会同时写索引。 若
SET涉及的列上有索引(含表达式索引、部分索引的谓词列),代价会明显高于仅更新非索引列。UPDATE与SELECT ... FOR UPDATE的锁语义不同。UPDATE会在行上取排他锁;若并发事务已锁定该行,会等待至超时(lock_timeout/deadlock_timeout)。WHERE CURRENT OF cursor_name只能配合游标使用。 它更新游标当前指向的行,通常只在存储过程/PL 里用。更新主键或唯一键可能引发冲突。 若新值与另一行重复,会报唯一约束错误并回滚整条语句。
UPDATE不触发ON UPDATE级联的错觉。 外键的ON UPDATE CASCADE会级联更新子表,这是"额外写入",评估影响时不要漏掉。
最佳实践
- 先
SELECT验证,再UPDATE,并用事务包裹。 生产环境的批量更新一律显式开启事务,给自己留一次ROLLBACK的机会。
BEGIN;
SELECT count(*) FROM orders_demo WHERE status = 'pending'; -- 先看范围
UPDATE orders_demo SET status = 'expired' WHERE status = 'pending' RETURNING order_no;
-- 核对返回结果无误后提交
COMMIT;
-- 有异常则 ROLLBACK;- 把影响行数当业务断言。 期望更新 1 行却得到 0 行,说明业务状态不符合预期,应作为异常路径处理。
UPDATE inventory SET qty = qty - 5 WHERE sku = 'SKU-1001' AND qty >= 5;
-- 应用层检查:若 UPDATE 0,抛出"库存不足",而不是继续下单- 把计算写进
SET,把条件写进WHERE。 让数据库在行锁内完成"读-算-写",天然规避丢失更新与超卖。
-- 推荐:原子扣减
UPDATE inventory SET qty = qty - 5, updated_at = now()
WHERE sku = 'SKU-1001' AND qty >= 5;
-- 不推荐:应用层先读后算再写回- 用
IS DISTINCT FROM抑制无意义写入。 定时任务、数据同步类更新必须过滤掉"值没变"的行,否则会持续制造死元组。
UPDATE products p
SET price = s.price, updated_at = now()
FROM stage_prices s
WHERE p.sku = s.sku
AND p.price IS DISTINCT FROM s.price;- 大表更新一律分批。 每批 1000 ~ 10000 行并提交一次,控制锁范围、WAL 量与复制延迟。批之间可加短暂 sleep 让 autovacuum 跟上。
-- 每批 1000 行的循环(示意)
WITH victim AS (
SELECT ctid FROM orders_demo WHERE status = 'pending' LIMIT 1000
)
UPDATE orders_demo o SET status = 'expired'
FROM victim v WHERE o.ctid = v.ctid;
-- 重复执行直至影响行数为 0- 高频更新的表调整
fillfactor。 留出页内空间让新版本能放在同一页,从而走 HOT 更新、免去索引写入。
ALTER TABLE orders_demo SET (fillfactor = 85);- 为更新语句的
WHERE条件列建索引。UPDATE本身也要先找到目标行,缺少索引会导致全表扫描 + 大量无谓行锁。
CREATE INDEX idx_orders_demo_status ON orders_demo (status);UPDATE ... FROM一定先写清关联条件。 养成"先写成SELECT验证行数,再改写成UPDATE"的习惯,可有效避免笛卡尔积事故。
-- 第一步:用 SELECT 验证关联行数与结果
SELECT o.order_no, o.status, p.status
FROM orders_demo o JOIN payments_demo p ON o.order_no = p.order_no
WHERE o.status = 'pending';
-- 第二步:确认无误后改写为 UPDATE ... FROM
UPDATE orders_demo o SET status = p.status
FROM payments_demo p
WHERE o.order_no = p.order_no AND o.status = 'pending';- 批量更新后主动
ANALYZE。 大比例更新会显著改变数据分布,统计信息滞后会让后续查询选错执行计划。
VACUUM (ANALYZE) orders_demo;下一节
继续学习:删除数据(DELETE)