做 PostgreSQL 开发这些年,UPDATE 应该是我写过的所有 SQL 里最“危险”的一种。SELECT 查错最多就是多看几秒结果,INSERT 错了顶多删掉重来,但 UPDATE 一旦写错条件,那就是线上事故——数据被覆盖、关联条件算错、锁等待把业务堵死,每一个坑我都踩过。所以遇到 “PostgreSQL UPDATE 语句详解” 这个话题,我觉得有必要把实际使用中的经验好好梳理一遍,包括基础语法、关联表更新、并发锁控制、性能优化和常见报错排查,给正在用 PG 的朋友一份可以直接抄作业的参考。
这篇文章面向的是已经会写基本 SQL、想在 PostgreSQL 里把 UPDATE 用得明白的开发者。内容会覆盖从单表更新到多表关联,从行级锁到批量更新的完整链路。我会把每段操作背后的原理讲清楚,因为只知道 UPDATE t SET a=1 WHERE id=2 远远不够,真正让你在团队里靠谱的,是你知道为什么这样写性能好、为什么那样写会锁表、为什么某个报错是你条件写错了。
1. UPDATE 基础语法与执行流程
1.1 一条 UPDATE 到底做了什么
PostgreSQL 的 UPDATE 语句从语法上看很简单:
sql复制UPDATE table_name
SET column1 = value1,
column2 = value2
WHERE condition;
但执行过程并没有表面上这么简单。PG 的 UPDATE 在底层走的是“标记删除 + 插入新版本”的路线——MVCC(多版本并发控制)机制决定了更新后的数据不会原地覆盖旧数据,而是生成一个新的行版本,旧的版本会保留给还在跑的事务读取。这跟 MySQL 的 InnoDB 实现思路有本质区别,也是很多从 MySQL 转过来的同事最容易困惑的地方。
举个例子,你执行:
sql复制UPDATE users SET age = 30 WHERE id = 1;
PG 实际上做了这几件事:第一,在 users 表上找到 id=1 的行;第二,给这行加上排他锁;第三,检查该行对当前事务是否可见;第四,写入一条新的行版本,age 字段变成 30,同时 ctid(行的物理位置)会发生变化;第五,更新对应的索引条目。整个过程里,旧的 age=25 的行版本并没有被物理删除,它只是标记为“过期”,等未来某个时刻由 vacuum 进程回收。
这就是为什么 UPDATE 频繁的表在 PostgreSQL 里膨胀速度快、wal 日志量大的原因。很多监控告警提示 wal-data占用磁盘过大,本质就是 UPDATE 操作太多、生成的行版本太多、WAL 里记录的变化数据太多导致的。理解这个基础机制,你后面看性能问题就会非常通透。
1.2 SET 子句的几种写法与计算细节
SET 子句最基础的用法是直接赋常量,但实际工作中远不止这么简单。PG 允许你在 SET 子句里写表达式,这意味着你可以基于原值做运算:
sql复制UPDATE products
SET price = price * 1.1,
updated_at = now()
WHERE category = 'electronics';
这一段代码的意思是:所有电子类商品价格上调 10%,同时记录更新时间。表达式是按行计算的,每一行的 price 都取自己原来的值乘 1.1,不会出现“第一行更新完的 price 被第二行拿去当基数”的情况,这一点跟 MySQL 的 UPDATE ... SET price = price + 1 行为一致,不用担心行间互相干扰。
还有一个多表场景常见的写法——从其他表取值来更新:
sql复制UPDATE orders o
SET status = p.payment_status
FROM payments p
WHERE o.order_id = p.order_id;
这种用 FROM 关联的写法是 PG 非常关键的特性,后面第二大部分我会专门展开讲。这里先提醒一个新手常犯的错:如果你不想更新所有行,一定记得写 WHERE。我见过不止一次,有人写 UPDATE t SET a = 1 FROM other 忘了加关联条件,结果目标表所有行都被更新了,关联上的一行把整个表的数据全覆盖,这属于严重事故,恢复只能靠备份或者时间点恢复。
1.3 RETURNING 子句:让 UPDATE 有返回值
PostgreSQL 的一个非常实用的特性是 RETURNING 子句,它可以在更新完成后把被修改的行的数据返回给你:
sql复制UPDATE users
SET status = 'active'
WHERE id = 10
RETURNING id, status, updated_at;
执行完后,你会直接拿到 id=10 这行的最新值。这个特性能解决很多实际需求:比如更新完要用到新的 updated_at 时间戳,就不用再单独查一次;又比如要记录哪些行被更新了,可以直接用返回值做审计。在 Java 或者 Python 的 ORM 里,这个特性也能大大简化代码。Python 的 psycopg2 里可以这样配合使用:
python复制cur.execute(
"UPDATE users SET status = 'active' WHERE id = %s RETURNING id, status",
(10,)
)
row = cur.fetchone()
注意,RETURNING 返回的是执行该 UPDATE 的事务内可见的新版本数据,不受事务未提交的影响,所以用起来非常顺手。这个特性是 MySQL 目前没有原生支持的(MySQL 8.0 仍不支持 UPDATE ... RETURNING 语法),属于 PG 的加分项。
1.4 两个实用细节:UPDATE 的 WHERE 与 FROM 的区别
很多初学者会混淆 UPDATE 语句里 WHERE 子句和 FROM 子句各自的条件职责。简单说,WHERE 决定目标表哪些行会被更新,FROM 里的表和条件只负责提供关联数据,不会直接决定哪些行被更新。举个例子:
sql复制UPDATE orders o
SET o.amount = o.amount + p.discount
FROM promotions p
WHERE o.promo_code = p.code
AND p.status = 'active';
这里 p.status = 'active' 是过滤促销活动的状态,如果促销表里有多条相同 code 的记录,目标表 orders 的这一行可能被更新多次吗?不会。PG 的行更新只会发生一次,但具体取哪条关联数据,取决于执行计划里选择的驱动行。这个细节非常关键,因为它会带来不可预测性——如果 promotions 表里 code 字段不是唯一的,你无法确定 orders 行的 amount 是按哪条促销数据计算的。所以我建议:用 UPDATE ... FROM 关联时,关联键务必保证在关联表中是唯一的,否则你会得到“看似更新成功但数值不对”的诡异结果。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 关联表更新:UPDATE FROM 的高级玩法
2.1 为什么用 UPDATE FROM 代替子查询
在 MySQL 里,很多人习惯用 UPDATE JOIN 或者子查询来更新关联数据。PostgreSQL 没有 UPDATE JOIN 语法,最常用的就是 UPDATE ... FROM。它的优点首先是性能好、写法直观。比如你要把订单表里对应用户等级为 VIP 的订单标记为 “priority”,可以这么写:
sql复制UPDATE orders
SET priority = 'high'
FROM users
WHERE orders.user_id = users.id
AND users.level = 'VIP';
这种写法在 PG 里的执行计划通常会是 hash join 或者 nested loop,能够高效地把两表关联起来。而用子查询的写法也能执行,但语义上如果有多个用户同等级,子查询在有些场景下会显得别扭,性能也可能因为重复执行子查询而变差。
不过要强调的是,UPDATE FROM 并不总是比子查询更快。如果关联表很小、只有几十行,子查询配合索引扫描的效率往往也不错。我在实际项目中两条路都走过,最终都会看具体执行计划来定。这里给一个简单原则:数据量小用子查询,数据量大、关联键明确用 FROM。
2.2 三种关联更新场景与写法示例
第一种是最常见的按主键关联:
sql复制UPDATE employee e
SET department_name = d.name
FROM department d
WHERE e.dept_id = d.id;
第二种是按业务键关联,比如按用户邮箱关联:
sql复制UPDATE users u
SET city = s.city
FROM shipping_addrs s
WHERE u.email = s.email;
第三种是从聚合结果更新。比如给每个用户的订单总额累计到用户表的 total_spent 字段:
sql复制UPDATE users u
SET total_spent = t.total
FROM (
SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
) t
WHERE u.id = t.user_id;
第三种是压测和业务报表场景的高频操作。要注意的是,如果子查询的结果行数非常大(几十万上百万),一次性更新会撑满事务日志,最好分批处理,这个我在性能优化部分会详细讲。
2.3 关联更新中的 NULL 陷阱与条件过滤
关联更新中最隐蔽的坑是 NULL。关联条件遇到 NULL 永远不会匹配成功,比如:
sql复制UPDATE users u
SET city = s.city
FROM shipping_addrs s
WHERE u.email = s.email;
如果某个用户的 email 字段是 NULL,那这行永远匹配不到 shipping_addrs,更新不到。这不是 bug,是 SQL 的语义,但很多人会困惑“为什么我的表里那么多行没更新”。处理办法是:先确认关联字段是否有 NULL,如果有,用 COALESCE 或者写 IS NOT DISTINCT FROM:
sql复制UPDATE users u
SET city = s.city
FROM shipping_addrs s
WHERE u.email IS NOT DISTINCT FROM s.email;
这个写法会把 NULL 也当作相等来匹配,但代价是可能无法走索引,性能会差。所以通常我还是建议先清洗数据、把缺失的关联键处理好再更新。
还有一个类似的陷阱是过滤条件放错了位置。如果你想只更新那些有有效地址的用户,可能会写:
sql复制UPDATE users u
SET city = s.city
FROM shipping_addrs s
WHERE u.id = s.user_id
AND s.city IS NOT NULL;
这样没问题。但如果你把 s.city IS NOT NULL 放到 SET 表达式里用 CASE WHEN 处理,逻辑就会变得混乱,而且某些行可能被错误地设置为 NULL。所以保持 WHERE 条件干净明确,是关联更新里最好的习惯。
3. 行级锁与并发控制:FOR UPDATE、NOWAIT 与 SKIP LOCKED
3.1 从 UPDATE 的隐式锁说起
在 PostgreSQL 里,每一条 UPDATE 语句都会自动对要修改的行加行级排他锁,这个锁会一直保持到事务结束——不管是提交还是回滚。这就是并发控制的基础。如果你不显式使用事务,单条自动提交的 UPDATE 很快,锁也很快释放。但在复杂业务里,你要更新多张表,往往会手动开启事务,这时候锁的行为就要重点考虑。
举个例子,一个典型的转账业务:
sql复制BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
第一条 UPDATE 执行后,id=1 这行被锁住。如果此时另一个事务也想更新 id=1 的行,它会被阻塞,直到第一个事务提交或回滚。这就是行级锁的效果。PG 默认的隔离级别是读已提交(Read Committed),在这种级别下,第二个事务等锁释放后会自动重新评估条件,然后更新最新版本的数据。
3.2 SELECT FOR UPDATE:主动锁定不想被改的行
光用 UPDATE 本身锁行,是没办法控制“提前锁住数据”的。实际上,业务里有一种常见需求:我先查出来一批数据给用户展示,等用户操作完成后,再回写状态。这个过程里,如果别的并发事务把数据改了,就会出问题。这时候 SELECT ... FOR UPDATE 就派上用场了。
sql复制BEGIN;
SELECT * FROM orders WHERE status = 'pending' AND id = 100 FOR UPDATE;
-- 这里可以做一些复杂的业务判断
UPDATE orders SET status = 'processing' WHERE id = 100;
COMMIT;
这段代码的意思就是把 orders 表里 id=100 的这行锁定,然后在本事务内部安全地做后续更新。锁会一直持有到事务提交,期间其他事务对 id=100 的更新请求都会排队等待。
FOR UPDATE 有几种变体:FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE。普通 FOR UPDATE 是最强的行锁,会阻塞其他所有对该行的 UPDATE、DELETE、SELECT FOR UPDATE。而 FOR SHARE 允许别人继续读,但阻止别人写。大多数业务场景用 FOR UPDATE 就够了,只有在复杂的并发控制里才会细分锁模式。
3.3 NOWAIT 与 SKIP LOCKED:两个解决锁等待的利器
锁等待在并发高的场景下会拖垮吞吐。假设你有 100 个订单待处理,10 个 worker 并发去抢,如果都用 SELECT ... FOR UPDATE,那么当一个 worker 锁住一个订单后,其他 worker 必须排队等锁释放。这个行为不是你想要的,而 NOWAIT 和 SKIP LOCKED 就是针对这种场景的答案。
FOR UPDATE NOWAIT:如果目标行已经被锁,不等待,立即报错(could not obtain lock on row in relation "orders")。适合那种“锁不到就说明这个任务正在被别人处理,要立即失败重试”的场景。FOR UPDATE SKIP LOCKED:如果目标行已经被锁,直接跳过这些锁定的行,返回剩余的未锁定行。这个最适合任务队列、消息分发、批量拣货这类场景。
典型的任务队列写法:
sql复制SELECT * FROM orders
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
这个语句会把最先创建且当前没有被其他事务锁定的 10 个订单返回,并同时锁住它们。其他 worker 同时执行相同语句时,拿到的会是不重叠的 10 个订单。这就是“分布式任务队列”最简单的实现方式,不需要额外的消息中间件,用 PG 就能扛住很可观的并发量。
热词里有“limit 1 for update skip locked 的组合使用 锁住的是1条还是所有where条件的”这个问题,我在这里明确回答:LIMIT 1 会让语句只返回并锁住 1 条记录;所有满足 WHERE 条件的行中,只有返回的那 1 条会被锁定,其余未返回的行不会被锁。SKIP LOCKED 的语义是:先过滤满足条件的行,再跳过那些被其他事务锁住的行,最后按 LIMIT 返回所需数量。所以如果你看到 100 行满足条件,但 50 行已被锁,LIMIT 1 FOR UPDATE SKIP LOCKED 会从剩余未锁的 50 行中取 1 行锁住,其他 49 行保持原样。这个组合是实现并发任务调度非常高效的手段,但要意识到它只能保证唯一性,不能保证“优先级”或“公平性”——如果高优先级任务已经被人锁住,你就拿不到它。
3.4 行锁升级、死锁与重试策略
多个事务互相等待对方持有的锁时,会形成死锁。PG 会在死锁发生后,通过检测机制杀掉其中一个事务,并向它返回错误:deadlock detected。处理死锁的正确姿势不是“避免死锁”,而是“尽快发现并重试”。
实际业务里,比如转账场景 A 给 B 转、B 给 A 转,如果两个事务里先更新 A 再更新 B 的锁顺序不一致,就可能死锁。最简单的规避方法就是统一资源加锁顺序——先按账号 ID 升序锁,再执行更新。如果仍然偶发死锁,应用层要捕获 deadlock detected 错误并重试整个事务。很多高并发框架(比如 Go 的 pgx 库配合应用重试逻辑)都是这么处理的。
锁相关报错里,还有一个常见的是 could not obtain lock on row in relation,这个就是 NOWAIT 遇到行被锁时抛出的。排查思路通常是找到谁持有锁,可以通过 pg_stat_activity 和 pg_locks 视图来定位:
sql复制SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state = 'active' AND wait_event_type = 'Lock';
然后根据需要去终止长时间持锁事务。我遇到过很多次“UPDATE 一直卡住不动”,都是因为没有及时提交长事务,导致一堆后续更新排队。所以在设计业务时,事务里千万不要放耗时的外部调用(比如调用外部 HTTP 接口、发短信),不然锁的持有时间会被拉得很长,整个系统的并发能力都会受拖累。
4. 性能优化与常见问题排查实录
4.1 大批量 UPDATE 怎么提速
一次更新几十万行甚至几百万行,是 PG 运维里最棘手的工作之一。如果直接跑一条大事务 UPDATE,有几个明显问题:锁长时间持有,其他事务全部阻塞;事务日志(WAL)暴涨;回滚段累积很大,一旦失败恢复时间漫长;锁表的影响面大,容错率低。
实操中我推荐分批更新策略。核心思路是按主键范围(或唯一索引)把目标行切成小份,每批几千行提交一次事务,中间加短暂 sleep,避免 IO 尖峰,同时也能让其他业务有机会穿插执行。下面是个典型示例,用存储过程或者 Python 脚本控制循环:
sql复制DO $$
DECLARE
v_batch_size BIGINT := 5000;
v_min_id BIGINT;
v_max_id BIGINT;
BEGIN
SELECT COALESCE(MIN(id), 0), COALESCE(MAX(id), 0)
INTO v_min_id, v_max_id
FROM big_table
WHERE status = 'old';
FOR i IN v_min_id..v_max_id BY v_batch_size LOOP
UPDATE big_table
SET status = 'new'
WHERE id >= i
AND id < i + v_batch_size
AND status = 'old';
COMMIT;
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
这种批量更新的时间开销比单条大事务要大,但胜在安全可控。对线上系统来说,“慢一点但稳定”远比“快但容易出事”更值得。有人会问,为什么还要加 status = 'old' 条件?因为 UPDATE 中加一个跟目标状态匹配的过滤条件,可以避免重复更新已经改过的行,同时在部分批次失败重跑时,不会把已经更新的数据再改一遍,兼容重入。
另外还有个提效手段是 SET synchronous_commit = off 配合批量更新,但这样做有数据丢失风险,仅限非核心数据场景,我一般不建议。
4.2 索引对 UPDATE 的影响:是好帮手,也是成本来源
索引能加速 WHERE 条件的定位,对 UPDATE 来说是好事。但索引不是越多越好,原因在于 UPDATE 修改任意索引字段时,需要同步维护所有相关索引。一个表上每多一个索引,每次 UPDATE 的写入成本就多一份。有些运维事故的根源就是“字段上建了一堆索引,结果 UPDATE 慢到崩”。
建议原则:
- 核心查询路径上的索引必须保留,配合 WHERE 条件设计。
- 低频查询的索引如果对 UPDATE 成本影响明显,宁可不建或定期评估。
- 如果批量更新不涉及某个索引列,索引维护成本会低一些,但 PG 在行版本更新时仍然可能更新索引条目(因为
ctid变了),所以索引维护是避不开的。
实测里,一个 1000 万行的表,如果更新字段是普通列,不加索引的 WHERE 条件走全表扫描,一条大 UPDATE 会把 IO 打满。而加了合适索引后,定位行变得很快,整体时间能压缩一个数量级。但反过来,如果更新字段本身在索引中,单行的索引更新成本也会成倍增加。所以在设计表结构时,要通盘考虑查询和更新的平衡。
4.3 WAL 日志膨胀的处理思路
前面提到过,wal-data占用磁盘过大 是 PG 的常见告警。UPDATE 大量行时 WAL 会膨胀,是因为每个行版本和索引条目变化都会写 WAL。这在业务高峰期尤其明显。处理这个问题要从几个角度下手。
第一,确认 wal 目录位置和大小:
sql复制SHOW wal_level;
SELECT pg_current_wal_lsn();
SELECT pg_walfile_name(pg_current_wal_lsn());
第二,检查是否有长事务导致 WAL 无法回收。如果有一个事务一直打开着,旧 WAL 文件就不能删除,磁盘占用必然上涨:
sql复制SELECT pid, state, xact_start, backend_start
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY xact_start;
第三,调整 checkpoint 参数,让 WAL 更及时地落盘并回收。常见参数是 checkpoint_timeout、max_wal_size、min_wal_size 和 wal_keep_size。注意,调小 max_wal_size 会让 checkpoint 频繁触发,反而可能引起 IO 波动;调大则能缓解 WAL 暴涨,但磁盘占用会增加。这是需要权衡的。
坦白说,大批量 UPDATE 之后 WAL 占用上涨是 PG 的正常行为,关键不是让它不涨,而是让它在可接受范围内并且能及时回收。如果告警频繁,优先排查长事务,其次优化更新频率,再不行才是调参数。
4.4 UPDATE 常见报错与排查速查表
我整理了实际支持中经常遇到的 UPDATE 相关报错,做成一张表,方便大家对照排查。
| 报错信息 | 常见原因 | 解决方式 |
|---|---|---|
ERROR: column "xxx" of relation "table" does not exist |
字段名拼写错误或表结构里没有这个字段 | 检查表结构,确认字段名大小写敏感问题 |
ERROR: relation "table" does not exist |
表名错误、schema 未加前缀、或者是在事务里建表未提交 | 检查 search_path,使用 schema.table 全限定 |
ERROR: duplicate key value violates unique constraint |
更新后某个唯一索引出现重复值 | 先查询冲突数据,调整更新策略或先清理数据 |
ERROR: deadlock detected |
多事务互相持锁等待 | 统一加锁顺序,应用层捕获并重试 |
ERROR: could not obtain lock on row in relation |
NOWAIT 遇到行被锁 |
分析持锁事务,重试或改用 SKIP LOCKED |
ERROR: canceling statement due to statement timeout |
语句执行超时 | 检查执行计划,优化索引或批量拆分 |
ERROR: UPDATE is not yet supported for partitioned table |
对分区表使用了某些特殊更新语法 | 分区表更新有特定限制,检查分区键是否更新 |
ERROR: new row violates check constraint |
新值不满足 CHECK 约束 | 检查业务数据,调整更新逻辑 |
有一个经常被忽略的点:PostgreSQL 的标识符如果不带双引号,会被自动转成小写。也就是说 UPDATE users SET Name = 'Alice' 实际上是更新 name 列,而不是 Name 列。如果你的表里真的有带大写字母的字段,必须写成:
sql复制UPDATE users SET "Name" = 'Alice';
这个细节我见过不少从 MySQL 迁移过来的团队踩坑,因为 MySQL 在 Windows 上表名大小写不敏感,字段名也宽松一些。PG 这个特性并不是 bug,但确实需要适应。
另外还要注意 ERROR: UPDATE cannot be executed from a function 这种报错,通常是因为尝试在某个特殊上下文里执行更新。正常逻辑中很罕见,但碰到了不要慌,检查函数是不是被声明为正不可变(IMMUTABLE)之类的严格限制。
4.5 我的 UPDATE 审计检查清单
最后分享一个我自己的习惯。凡是在生产上执行更新,只要超过几十行,我都会先写一个等价的 SELECT 验证范围,然后再更新。比如:
sql复制-- 更新前,先查一下会被影响多少行
SELECT count(*)
FROM orders
WHERE status = 'pending'
AND created_at < '2024-01-01';
-- 再更新
UPDATE orders
SET status = 'archived'
WHERE status = 'pending'
AND created_at < '2024-01-01';
如果涉及的更新比较复杂、有多表关联,我会先把 UPDATE 改成 SELECT 版本的 join 跑一次,找到“可能受影响的行数”和“最大最小值”,确认无误后再套上 UPDATE。有些团队会要求 UPDATE 语句必须开启事务后执行,并且先 SELECT ... FOR UPDATE 锁住数据,再更新,最后显式提交。这个习惯能救命。
对于线上库,我还会额外做三步检查:
- 确认 WHERE 条件里有没有该用的索引,避免全表扫。
- 确认事务不会执行太久,监控锁等待情况。
- 确认有备份,且知道怎么快速恢复。
这一套流程看起来繁琐,但真出过一次事故后就知道值不值了。我自己就曾因为漏掉一个 WHERE 条件把整张业务表的状态字段全部重置成初始值,后来靠热备份恢复到前一分钟才救回来。从那以后,我的 UPDATE 操作规范就变得异常严格。
PostgreSQL 的 UPDATE 语句不算复杂,但它的底层机制、锁行为、跟并发控制的关系,决定了用得好不好会有天壤之别。把这篇文章里提到的概念吃透,再结合你自己的业务场景多测多试,基本就能避开绝大多数生产环境的坑。如果还有特殊场景拿不准,建议打开 EXPLAIN ANALYZE 看看执行计划,同时多查阅官方文档的 UPDATE 部分,那里面还有更多细节值得挖掘。
