1. 一次UPDATE引发的血案,让我重新审视这条SQL
先从一个真实的线上事故说起。去年我们某个业务系统的订单表在高峰期突然出现大量锁等待,数据库连接数直接被打满,业务方疯狂在群里刷屏问怎么回事。我上去一查,发现是一条看起来人畜无害的UPDATE语句:
sql复制UPDATE orders SET status = 'paid' WHERE order_id = 12345;
单看这条SQL,索引、主键、小范围过滤,压根没有任何问题。但结合当时的并发场景——同一张订单在用户端和回调端同时发起更新,再加上订单表上十几个触发器、外键约束、级联更新,这条UPDATE瞬间就变成了锁链的源头。
从那以后我养成了一个习惯:只要涉及UPDATE,我一定会把执行计划、锁级别、受影响行数、触发器链路全部过一遍。PostgreSQL的UPDATE远没有看起来那么简单,它背后涉及MVCC机制、行锁管理、页面清理、WAL日志写入、索引维护等一系列动作,任何一环没想清楚,都可能在生产环境里给你上一课。
这篇文章我就把PostgreSQL UPDATE语句从语法到原理、从单表到关联更新、从简单UPDATE到并发控制(FOR UPDATE、SKIP LOCKED)全部拆开讲一遍。不管你是刚接触PostgreSQL的新手,还是已经写了几年SQL的老手,这篇文章都值得你花十几分钟读完——里面有不少是我在生产环境里踩过坑之后才总结出来的经验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. UPDATE语句的基础语法与执行计划分析
2.1 标准语法拆解,先搞清楚每个关键字到底在干什么
PostgreSQL的UPDATE标准语法长这样:
sql复制[ 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 ] [, ...] ]
大部分人在日常开发中用到的最简形式就是 UPDATE table SET col = value WHERE id = x,但如果只看这个最简形式,你很难理解PostgreSQL在背后做了什么。实际上,我建议你把UPDATE拆成三个阶段来理解:
定位阶段:通过WHERE条件找到需要更新的行。这一步和SELECT的查询执行逻辑相同,会经过解析、优化、执行三个流程,可能走索引扫描、位图扫描或顺序扫描。
修改阶段:对命中的每一行做“标记删除+插入新行”的操作。这一步是PostgreSQL最特殊的地方——它不直接在原行上修改数据,而是把旧行标记为已删除,再在原位置(或新位置)插入一条新行。
收尾阶段:更新所有二级索引、写WAL日志、更新统计信息、触发ON UPDATE触发器。如果表上有外键引用或级联约束,还需要额外处理。
这里尤其要强调的是“修改阶段”。PostgreSQL基于MVCC(多版本并发控制)实现并发控制,一条UPDATE在生产环境里的真实开销往往是同量级SELECT的2到3倍以上。理解了这一点,你就明白为什么我在文章开头说“UPDATE远没有看起来那么简单”。
2.2 UPDATE执行计划怎么读,重点看哪儿
分析一条UPDATE语句是否健康,最直接的方式是用EXPLAIN看执行计划:
sql复制EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders SET status = 'paid' WHERE order_id = 12345;
输出大致如下:
code复制Update on orders (cost=0.42..8.44 rows=1 width=118) (actual time=0.312..0.313 rows=0 loops=1)
-> Index Scan using orders_pkey on orders (cost=0.42..8.44 rows=1 width=118) (actual time=0.205..0.207 rows=1 loops=1)
Index Cond: (order_id = 12345)
Buffers: shared hit=4
Planning Time: 0.189 ms
Execution Time: 0.441 ms
这个执行计划里有几个关键信息要看:
Update on orders是整个UPDATE操作的顶层节点,它负责调用执行器完成“删除旧行+插入新行”的动作。它的子节点负责扫描出需要更新的数据。Index Scan表示定位使用了主键索引,rows=1说明预估只更新一行。如果这里显示的是Seq Scan,那就得警惕了——整表扫描意味着所有行都可能被锁住。actual time是实际执行时间。很多人在调优时过度关注Planning Time,其实对于OLTP场景来说,Execution Time才是大头,Planning部分通常可以忽略。rows=0这里是顶层Update节点的输出行数,不是被更新的行数。受影响的真实行数得看UPDATE命令返回的结果——如果返回UPDATE 1,说明更新了一行;返回UPDATE 0说明没有匹配到任何行,这是判断“UPDATE是否生效”的最直接依据。
还有一个非常有用的变化:PostgreSQL 12之后,EXPLAIN支持了EXPLAIN (ANALYZE, BUFFERS)直接看到缓冲区命中和读写情况。如果Buffers: shared written数值很大,说明这条UPDATE产生了大量脏页写回。对频繁更新的大表,这往往意味着页面清理(vacuum)压力居高不下,后面我会细说。
2.3 三个容易忽略但很实用的基础写法
多字段一次更新。语法上支持两种写法:
sql复制-- 写法一:逐个字段赋值
UPDATE users SET first_name = 'Zhang', last_name = 'San' WHERE id = 1;
-- 写法二:用ROW构造函数或子查询批量赋值
UPDATE users SET (first_name, last_name) = ROW('Zhang', 'San') WHERE id = 1;
方法二的一个典型应用场景是把另一张表的某些列同步过来,可以借助子查询一次更新多个字段:
sql复制UPDATE users u
SET (first_name, last_name, email) = (
SELECT p.first_name, p.last_name, p.email
FROM profiles p
WHERE p.user_id = u.id
)
WHERE EXISTS (SELECT 1 FROM profiles p WHERE p.user_id = u.id);
使用DEFAULT关键字。当你希望某列在更新时恢复为默认值,可以直接写DEFAULT:
sql复制UPDATE products
SET stock = DEFAULT,
updated_at = now()
WHERE product_id = 100;
前提是products表里stock列定义了默认值。这比先查出当前值再手动改要省一步,也更不容易出错。
RETURNING子句。这是PostgreSQL和Oracle都支持、MySQL至今没有的用法。UPDATE之后直接返回被更新行的数据,非常适合需要回显“更新之后最新值”的场景,可以减少一次额外的SELECT:
sql复制UPDATE inventory
SET quantity = quantity - 5
WHERE product_id = 1001
RETURNING product_id, quantity AS new_quantity;
执行结果就是一行product_id=1001, new_quantity=95这样的数据。写API接口的时候,这个特性特别香——一次数据库交互搞定更新和返回,省掉一次网络往返。
3. 表达式更新与类型处理的坑点
3.1 CASE表达式更新,注意不同分支的数据类型一致性
实际开发中,经常要根据当前值决定新的值,最典型的就是按订单金额或状态做多级更新:
sql复制UPDATE orders
SET discount = CASE
WHEN total_amount >= 1000 THEN total_amount * 0.15
WHEN total_amount >= 500 THEN total_amount * 0.10
ELSE 0
END
WHERE created_at >= '2024-01-01';
这种写法很直观,但有一个坑:CASE表达式的所有分支返回类型必须兼容。比如THEN total_amount * 0.15返回小数值,但ELSE 0是整数,PostgreSQL会尝试做隐式类型转换。大部分情况它能自动处理,但遇到字符串和数字混用时就会直接报错或产生意外的类型转换。
所以我的建议是:写CASE表达式时尽量显式统一类型。比如上面这段代码中,ELSE 0可以写ELSE 0::numeric,这样既清楚又避免隐式转换带来的坑。
3.2 字段自增与算术更新,别让类型越界
订单表扣减库存是最常见的一种操作:
sql复制UPDATE products
SET stock = stock - 1
WHERE product_id = 999;
这个写法本身没问题,但要注意两个潜在风险:
库存扣成负数。如果业务上不允许负库存,仅仅靠这条SQL不够,你得加条件或用CHECK约束兜底:
sql复制UPDATE products
SET stock = stock - 1
WHERE product_id = 999 AND stock > 0;
数值类型溢出。如果stock是smallint(最大32767),而并发UPDATE导致累加超过上限,数据库会直接报smallint out of range错误。这在秒杀类场景中特别容易发生——热点商品的库存字段被高频更新,数值类型太小就会翻车。我的建议是:所有可能被高频累加或抵扣的数值字段,在设计表结构时就直接用integer或bigint,不要为省那两字节给自己埋雷。
3.3 字符串拼接和JSON字段更新的注意事项
PostgreSQL在字符串类型更新时有一个容易被忽略的细节:字段如果是varchar(n),UPDATE后总共长度超过n会报错,但先截断再拼接的方式仍然会报错。比如:
sql复制-- 假设 remark 是 varchar(50),原值为40个字符
UPDATE orders SET remark = remark || '(已退款)' WHERE id = 1;
如果拼接后总长度超过50,这条SQL会直接失败。这类问题在批量导入数据、更新备注类字段时经常发生,而且很难从业务日志里立刻定位。规避方法是:在拼接前用left()或substring()做截断,或者把字段类型改成text。
JSONB字段的部分更新是另一个高频场景。PostgreSQL支持用||运算符合并或覆盖键值:
sql复制UPDATE orders
SET attributes = attributes || '{"refund_status": "applied"}'::jsonb
WHERE id = 1;
这比先读出来再整个写回去高效很多,但在语义上要特别注意:||合并是两个JSONB的深度合并还是浅合并?PostgreSQL的||是浅层合并,内嵌对象会被整体替换而不是递归合并。如果要深度合并多个层级,需要自己写函数或拆分多次更新。
4. 关联表更新的几种模式与性能差异
4.1 用FROM子句实现UPDATE JOIN,PostgreSQL的推荐写法
MySQL有原生的UPDATE ... JOIN语法,PostgreSQL不支持这种写法,但提供了更灵活的FROM扩展:
sql复制UPDATE orders o
SET o.status = p.status
FROM payment_transactions p
WHERE o.payment_id = p.id
AND p.status = 'success';
这段SQL的含义是:从payment_transactions表里找出status为success的交易记录,把对应订单的status更新为交易状态。用生活化的比喻来说,就像你有一张订单表和一个支付流水表,现在要根据支付流水的最新状态来同步订单状态,这是典型的主数据表+明细表的对账场景。
性能要点:FROM子句中的表,在UPDATE执行计划里会先做连接(JOIN),连接结果决定了哪些行需要被更新。所以FROM子句中的表是否能高效参与JOIN,直接决定了整条SQL的性能。在上面这个例子里,o.payment_id = p.id依赖orders表的payment_id索引,如果orders表数据量大但payment_id没有索引,执行计划就会退化成嵌套循环甚至哈希连接,全表扫描是跑不掉的。
我建议你在写这类关联UPDATE之前,先用等价的SELECT跑一遍EXPLAIN:
sql复制EXPLAIN
SELECT o.id, p.status
FROM orders o
JOIN payment_transactions p ON o.payment_id = p.id
WHERE p.status = 'success';
如果这里出现了Seq Scan on orders,那就说明UPDATE大概率也要全表扫,先建索引再执行才是正路。
4.2 子查询更新,什么时候用EXISTS,什么时候用IN
除了FROM子句,子查询也是关联更新里的主力选手。最常见的需求是“把满足某些子条件的行的某个字段更新为固定值”:
sql复制UPDATE orders
SET is_priority = true
WHERE customer_id IN (SELECT id FROM customers WHERE level = 'VIP');
这写法能跑,但在数据量比较大的时候要小心。IN子查询的语义是“匹配子查询结果集中的任意一个值”,如果子查询返回的结果集很大,PostgreSQL的优化器有可能选择哈希半连接或嵌套循环半连接,性能未必差,但语义上和EXISTS有一个细微差别:IN对NULL值敏感,子查询结果集中一旦出现NULL,匹配行为会变得非常诡异——永远不会匹配成功。
所以更稳妥的写法是:
sql复制UPDATE orders o
SET is_priority = true
WHERE EXISTS (
SELECT 1 FROM customers c
WHERE c.id = o.customer_id AND c.level = 'VIP'
);
EXISTS走的是半连接语义,只要找到一条满足条件的记录就停止内层扫描,通常比IN更快,尤其当子查询命中大量重复值时优势更明显。这是我线上调优时的一个基本习惯:凡是“按条件更新父表”这类需求,一律优先写EXISTS。
4.3 关联更新时如何避免误更新和重复更新
关联更新最大的风险在于“一对多”关系导致目标行被重复更新。举个例子,一个订单对应多个支付尝试(失败后重试),如果你用payment_transactions去关联更新订单,可能同一个订单被更新两次,最后状态取决于最后一次执行的顺序,结果不确定:
sql复制-- 危险写法:一个订单可能对应多条流水
UPDATE orders o
SET status = p.status
FROM payment_transactions p
WHERE o.payment_id = p.id;
PostgreSQL遇到这种“一条订单匹配多条流水”的情况时,会任选其中一条来更新,而且不会报错,也不会提醒你。结果很可能不符合业务预期。我踩过这个坑之后的处理原则是:
- 先确认关联字段是否唯一,比如payment_transactions中一个order_id是否只对应一条记录。
- 如果确实可能一对多,就必须在子查询里先做去重,把每单对应的最新一条流水取出来再关联,例如用
DISTINCT ON (order_id)或窗口函数ROW_NUMBER()取最新状态。 - 更新前先用SELECT预览关联结果,数一数更新行数是否和预期一致。
4.4 MERGE语句,处理“存在则更新,不存在则插入”的更优方案
从PostgreSQL 15开始,官方加入了MERGE(UPSERT类操作)语法,用于处理“存在则更新,不存在则插入”的场景。以前的写法是INSERT ... ON CONFLICT DO UPDATE,但MERGE在可读性上更清晰,也支持更复杂的条件分支:
sql复制MERGE INTO inventory t
USING (SELECT 1001 AS product_id, 20 AS quantity) s
ON t.product_id = s.product_id
WHEN MATCHED THEN
UPDATE SET quantity = t.quantity + s.quantity, updated_at = now()
WHEN NOT MATCHED THEN
INSERT (product_id, quantity, updated_at)
VALUES (s.product_id, s.quantity, now());
注意:MERGE在PostgreSQL 15之前不可用,如果你还在用PG 14或更早版本,老老实实用INSERT ... ON CONFLICT。另外,MERGE在匹配到多行时同样有不确定性,和上面说的关联更新一个道理,需要先确保USING数据源在ON条件上是唯一的。
5. 并发控制进阶:FOR UPDATE、NOWAIT与SKIP LOCKED实战
5.1 行锁的底层逻辑,为什么UPDATE会自动加锁
很多初学者会忽略一个问题:普通的UPDATE语句不需要你手动加锁,数据库自动会对目标行加上行级排他锁(ROW EXCLUSIVE)。只有先理解了这一点,才能理解为什么会有FOR UPDATE这类语法。
PostgreSQL的锁机制严格遵循两阶段锁协议:事务中对某行数据执行UPDATE时,会获取该行的行级锁,并且一直持有到事务结束(提交或回滚)才释放。二级索引的记录也会被锁住,这导致即使你更新的列不在索引里,索引条目也可能因为行指针变化而需要加锁。
因此在高并发场景下,两条事务同时更新同一行时,后发起的事务只能等待,直到前一个事务提交或回滚。这个等待可能很快,也可能很久——如果前一个事务里还有别的操作、或网络开销很大,后一条UPDATE就可能把连接池占满。文章开头那个线上事故,本质就是锁等待不断堆积导致的。
这里有一个重要区别值得说清楚:PostgreSQL的UPDATE自动加的锁是“行锁”,但它建立在页面级别的共享锁之上。具体到执行计划层面,UPDATE会先对目标行所在的数据页面加BUFFER_CONTENT_LOCK的临时锁,再对行本身加MultiXact或HeapTuple锁。这些细节对绝大多数开发者来说不需要深入研究,但你至少要明白:UPDATE不是“无锁操作”,它是典型的写操作,天然需要协调并发。
5.2 SELECT FOR UPDATE,锁定查询出来的行,防止并发修改
有时候我们希望先SELECT出某些数据,然后在后续的几步操作中保证这些数据不被其他事务修改,这就用到了SELECT ... FOR UPDATE:
sql复制BEGIN;
SELECT * FROM inventory
WHERE product_id = 1001
FOR UPDATE;
-- 这里拿到 products 的锁,后续其他事务的UPDATE会被阻塞
-- 此时可以安全地做减库存操作
UPDATE inventory
SET quantity = quantity - 10
WHERE product_id = 1001;
COMMIT;
这段事务等价于“先锁定商品1001的库存记录,再扣减10”。在并发秒杀场景下,这种模式可以保证同一时刻只有一个请求能成功扣减某个商品的库存,而其他请求会阻塞在SELECT FOR UPDATE这一步,直到事务提交。
需要注意:FOR UPDATE锁的是“查询出来的行”,不是“查询条件涉及的所有行”。如果查询走了索引但只返回10行,那锁定范围就是这10行,而不是全表。但如果你用SELECT * FROM inventory WHERE product_id > 0 FOR UPDATE,那它就锁了全表所有行——几乎等同于锁表。所以FOR UPDATE必须配合尽量精确的WHERE条件使用。
FOR UPDATE还有两个变体,按需使用:
FOR NO KEY UPDATE:比FOR UPDATE弱一档,不阻塞其他事务对“非键列”的UPDATE,适合那些只是用某个字段做暂存、其他字段允许并发修改的场景。FOR SHARE和FOR KEY SHARE:共享锁,多个事务可以同时持有,但如果有人持有共享锁,任何人对这些行的UPDATE都必须等待,适合“只读但要求数据稳定”的场景。
5.3 NOWAIT,拒绝等待直接报错
加了FOR UPDATE之后,如果目标行已经被其他事务锁住,默认行为是等待锁释放。但有些场景不适合无限等待——比如定时任务里更新一批数据,如果某几行被锁住了,整个任务就得傻等,用户响应时间不可控。
这时候可以加NOWAIT:
sql复制SELECT * FROM inventory
WHERE product_id = 1001
FOR UPDATE NOWAIT;
如果这一行已经被别人锁住,这条SQL会立刻报错:
code复制ERROR: could not obtain lock on row in relation "inventory"
业务层面捕获这个错误后,可以立刻返回“商品忙,请稍后重试”之类的提示,而不是让用户傻傻地等几秒甚至几十秒。
NOWAIT适合低并发、单行操作的场景。在高并发秒杀场景下,NOWAIT反而可能让你频繁报错,体验不佳,这时候更适合用SKIP LOCKED。
5.4 SKIP LOCKED,跳过被锁的行,任务队列的神器
SKIP LOCKED从PostgreSQL 9.5开始引入,它的语义是:遇到被锁定的行就直接跳过,只处理当前没有被锁定的行。这是实现数据库任务队列的最佳实践。
举个例子,你有张任务表,多个worker进程同时从表里取任务执行:
sql复制BEGIN;
SELECT * FROM task_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- 这里拿到的10条任务,可以安全地更新状态为 processing
UPDATE task_queue
SET status = 'processing', started_at = now()
WHERE id IN (...);
COMMIT;
多个worker同时执行这段SQL时,每一个worker都会拿到不同的任务行,互不干扰,不需要额外的分布式锁。LIMIT 1 FOR UPDATE SKIP LOCKED组合使用时,注意一个关键细节:SKIP LOCKED的跳过逻辑基于行级锁状态,无论是FOR UPDATE还是普通UPDATE自动加的锁,都会被视为“已锁”,所以并发执行的多个worker能各自取到不同的行。如果只写LIMIT 1 FOR UPDATE而不加SKIP LOCKED,多个worker同时取任务时所有worker都会卡在同一个任务上,形成资源争抢。
网上有个高频问题:“LIMIT 1 FOR UPDATE SKIP LOCKED组合使用,锁住的是一条还是所有WHERE条件的行?”答案是:锁住的是LIMIT返回的那一批(这里是1条)行,不是所有满足WHERE条件的行。PostgreSQL先按WHERE条件扫描出候选行,然后按LIMIT取数并加锁,加完锁就返回;扫描过程中如果遇到已锁行,根据SKIP LOCKED决定跳过还是等待。理解这一点特别重要,否则你会误以为这个语法锁了全表,导致并发性能评估出大问题。
5.5 死锁预防与处理,两个UPDATE互相等待怎么办
并发UPDATE场景里,死锁是绕不开的话题。最常见的一种死锁是两个事务都先更新A再更新B,但由于执行顺序交叉,事务1锁了A等B,事务2锁了B等A,谁也不让谁,数据库就会检测到死锁并主动回滚其中一个事务:
code复制ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 6789; blocked by process 12346.
死锁的应对策略我总结了三条经验:
第一,保持事务内操作顺序一致。 如果多个事务都会同时更新A表和B表,那么建议在所有事务里都先更新A再更新B,形成一致的加锁顺序,从源头上减少循环等待。
第二,事务尽量短小。 事务里除了UPDATE,尽量少做其他耗时操作,比如远程调用、大结果集查询、循环更新。锁持有时间越短,死锁概率越低。
第三,应用层做好死锁重试。 即使做了各种预防,生产环境依然可能死锁。PostgreSQL检测到死锁后会选择回滚其中一个事务,错误码是40P01(deadlock_detected)。在应用层捕获这个错误并重试,是最后的兜底。
6. 性能优化与典型故障排查
6.1 为什么UPDATE会很慢,先看这几个指标
线上遇到UPDATE慢,我一般按以下顺序排查:
先看锁等待。 用这条SQL查看当前锁等待情况:
sql复制SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state = 'active' AND wait_event_type = 'Lock';
如果发现大量会话卡在Lock事件上,说明是锁竞争导致的UPDATE慢。这时重点看它们分别在等哪些锁,可能是其他长事务没提交,也可能是索引维护或外键检查导致的锁。
再看执行计划。 用EXPLAIN看是否存在Seq Scan、临时文件、排序等重操作。如果一条UPDATE变成了Seq Scan,那就相当于锁了全表,并发场景直接崩溃。
再看WAL日志量和页面清理。 高频UPDATE会产生大量WAL数据,同时表膨胀风险也会上升。pg_stat_user_tables视图能看到每次更新量和dead tuple数量:
sql复制SELECT relname, n_tup_updated, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
如果n_dead_tup持续偏高且last_autovacuum很久没跑,说明autovacuum参数需要调整,或者表结构设计上有问题(比如大量UPDATE都修改同一行)。
6.2 大表批量UPDATE,如何避免IO和锁风暴
很多人拿到“给全表某些行更新”的需求,第一反应就是直接跑一条大UPDATE。但大表批量UPDATE在水位线较高时,很容易带来两个问题:一是行锁会锁住大量行,长时间阻塞其他业务;二是PostgreSQL的MVCC机制会为每行新增一个版本,大量死元组会导致表和索引膨胀,触发autovacuum风暴。
业内比较通用的做法是把大UPDATE分批做。假设你要把orders表里所有created_at < '2023-01-01'的订单标记为archived,直接大批量执行会锁大量行,如果表足够大,可能一跑就是几十分钟甚至几小时,中间任何其他查询都可能受影响。改成循环分片更新:
sql复制DO $$
DECLARE
batch_size INT := 10000;
updated_rows INT;
BEGIN
LOOP
UPDATE orders
SET status = 'archived'
WHERE id IN (
SELECT id FROM orders
WHERE created_at < '2023-01-01' AND status <> 'archived'
ORDER BY id
LIMIT batch_size
);
GET DIAGNOSTICS updated_rows = ROW_COUNT;
EXIT WHEN updated_rows = 0;
COMMIT; -- 每个batch提交一次,尽快释放锁
PERFORM pg_sleep(0.1); -- 给其他事务喘息时间
END LOOP;
END $$;
这里的COMMIT在DO块里需要注意:DO块本身是单事务的,直接在中间写COMMIT会导致整个DO块报错。实际实现更适合用函数+外部驱动循环,或者用存储过程(PostgreSQL 11+支持procedure里事务控制)。上面的代码段是一个思路示范,如果你要用,建议把它放到应用层定时任务里分批执行,而不是直接塞进DO块。更稳妥的做法是应用层反复执行同一段UPDATE SQL,每次带上LIMIT batch_size的子查询,直到受影响行数为0。
分批更新的另一个好处是:每批执行完提交一次,锁不会长时间霸占,其他业务SQL能穿插执行。缺点是总耗时通常比一条大UPDATE长一些,但对OLTP系统的保护远远大于那点性能损失。
6.3 更新后没有生效?先查事务隔离级别和触发器
“UPDATE返回了UPDATE 1,但业务查询结果还是旧值”——这类问题通常有三个原因:
第一,事务隔离级别是READ COMMITTED(默认)还是REPEATABLE READ/SERIALIZABLE。 在REPEATABLE READ和SERIALIZABLE隔离级别下,同一个事务内的后续SELECT看到的是事务开始时的快照,即使UPDATE已经提交,只要没开启新事务,查询到的还是旧数据。这不是数据没更新,而是快照隔离机制在起作用。
第二,触发器把更新后的值又改了。 比如表上有个BEFORE UPDATE触发器,把某列强制设置为另一个值,那你UPDATE传进去的值可能被覆盖。排查方式是查看表上的触发器:
sql复制SELECT tgname, tgenabled, tgtype, tgrelid::regclass
FROM pg_trigger
WHERE tgrelid = 'orders'::regclass;
第三,视图/物化视图未刷新。 如果业务查询走的是物化视图,而不是基表,那UPDATE基表之后,物化视图的内容不会自动更新,需要手动执行REFRESH MATERIALIZED VIEW。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查/解决方式 |
|---|---|---|
| UPDATE卡住不动 | 行锁等待,其他事务未提交 | 查pg_stat_activity,定位阻塞源,必要时pg_terminate_backend |
| UPDATE报“could not obtain lock” | 行被其他事务锁定,加了NOWAIT | 业务重试或改用SKIP LOCKED |
| UPDATE执行计划显示Seq Scan | 缺少索引或CBO认为全表扫更快 | 为WHERE条件列建索引,或ANALYZE更新统计信息 |
| 更新后查询值还是旧值 | 事务隔离级别或触发器 | 确认事务隔离级别,检查表上触发器 |
| 大批量UPDATE后数据库变慢 | 表膨胀,dead tuple过多 | 调autovacuum参数,或VACUUM (VERBOSE, ANALYZE) table |
| UPDATE后触发器没执行 | tgenabled为disabled | 检查触发器是否被禁用 |
| 磁盘空间增长迅速 | UPDATE产生的旧版本和WAL未清理 | 调wal_level/checkpoint参数,执行VACUUM回收 |
6.5 一个真实案例:for update nowait 引发的业务雪崩
最后分享一个我调优过的案例。某团队做了一个调度系统,核心流程是多个worker并发取任务,一开始他们在取任务时用了SELECT ... FOR UPDATE NOWAIT,逻辑是“取到就处理,取不到就报错重试”。但上线后发现,任务并发高的时候,大量worker在报错,处理能力反而上不去。
我看了他们的SQL,发现问题是:取任务时不带SKIP LOCKED,只带NOWAIT,于是当某个任务正被其他worker处理时,后来的worker一执行FOR UPDATE NOWAIT就直接报错,业务方设定的重试逻辑又非常激进,导致大量无效的数据库请求,连接池被反复打满。
后来把SQL改成FOR UPDATE SKIP LOCKED,效果立刻好转:每个worker取任务时,遇到已锁行直接跳过,取到的是能够独占处理的行,不再出现“同时争抢同一个任务”的报错风暴。这个案例给我的启发是:NOWAIT适合“不允许跳过、但要立即知道被锁”的场景;SKIP LOCKED适合“取一批能处理的就走”的场景。选错关键字,业务表现差别极大。
7. 我的一点实操心得
写到这里,PostgreSQL UPDATE从基础语法、表达式更新、关联表更新,到并发控制(FOR UPDATE、NOWAIT、SKIP LOCKED)以及性能排查,基本都覆盖了。最后再分享几个我在实际工作中沉淀下来的习惯:
第一,UPDATE语句上线前一定要看执行计划。 这不是形式主义。我见过太多因为漏了索引导致UPDATE全表扫的案例,明明加个索引就能解决的性能问题,偏要等到线上告警才去处理。
第二,涉及高并发更新的表,建议开启track_commit_timestamp或利用pg_current_xact_id()做增量比对,方便排查“到底是哪条UPDATE改了数据”。 生产环境没有审计需求的话,也可以定期用log_statement = 'mod'记录所有写操作,出问题时有据可查。
第三,能用一条UPDATE解决的事情,不要拆成多条。 对PostgreSQL来说,一条UPDATE内部也会逐行处理,但相比多条SQL,它能减少解析、规划、事务提交的开销,也更容易保证原子性。
第四,批量更新优先考虑分批加SKIP LOCKED,而不是一把梭全表更新。 这条经验值多少磁盘空间,谁在生产环境吃过亏谁知道。
第五,数据库参数和表结构设计要一起考虑。 UPDATE性能不只是SQL层面的问题,表的填充因子、索引数量、外键约束、触发器都会影响它。比如一张表如果建了8个二级索引,每次UPDATE一行都要维护8个索引,开销自然大。很多情况下,把不常用的索引删掉几个,UPDATE性能就能立竿见影地提升。
最后再提一句:PostgreSQL的MVCC机制决定了UPDATE本质上是“写新版本”的操作,所以对频繁UPDATE的字段,尽量保持行宽度小一点——行越宽,每次更新写入的数据量和WAL日志量就越大。这个设计层面的意识,比任何SQL调优技巧都重要。
