1. 为什么UPDATE和DELETE会成为生产事故的高发区
1.1 三条最常见的翻车路径
PostgreSQL里执行UPDATE和DELETE,表面上看就是两条SQL,但生产环境里翻车往往都集中在几个特定场景。我自己经历过、也帮别人排查过不少类似事故,总结下来最常见的有三条路。
第一条是误操作影响范围失控。写WHERE条件的时候少加了一个过滤条件,或者FROM关联子查询时产生了笛卡尔积,一条UPDATE下去直接更新了全表十几万行。这种事故的可怕之处在于它不会立刻报错,数据库也不会提示你“这个条件很危险”,等业务发现数据不对的时候,往往已经过了一段时间,日志都被刷过去了。
第二条是大事务长时间持有锁。在业务高峰期跑一个UPDATE,更新几万行甚至几十万行,整个事务迟迟不提交。PostgreSQL的行级锁是事务提交后才释放的,这期间所有涉及这些行、甚至涉及这些表结构的操作都在排队。数据库连接池被耗光,应用疯狂报错,最后只能重启服务——但锁还在,重启也没用。
第三条是DELETE之后发现删多了或删错了。PostgreSQL的MVCC机制决定了DELETE并不会立刻物理删除数据,但逻辑上那些行已经不可见了。如果事务已经提交,恢复的代价非常高。没有任何数据库在提交前会问你“确定要删吗”。
这三条路径背后其实是同一个核心问题:操作者没有在执行前建立一套可验证、可回退、可兜底的检查机制。也正是因为这样,市面上各种“安全UPDATE/DELETE技巧”的本质,都是在围绕“控制影响面”和“保留后悔药”这两个方向做文章。
1.2 事务与MVCC:理解安全底线的基础
想真正用好安全技巧,先得搞清楚PostgreSQL在UPDATE和DELETE时后台到底做了什么。
PostgreSQL用的是MVCC(多版本并发控制)模型。UPDATE操作不是原地修改旧数据,而是给受影响的行生成一个新版本,旧版本行会保留在表里,直到事务提交后由VACUUM进程在合适的时机清理。DELETE操作也不是物理删除,只是把那行标记为“已删除”,同样需要VACUUM最终回收空间。
这个机制带来两个直接影响。
第一,UPDATE和DELETE操作不会因为影响行数多而立刻报错,它们在事务内的代价是逐渐累积的。你更新10万行,WAL日志就会写入相应的量,表里会积累大量旧版本行。一旦事务回滚,这些写入全部作废,但WAL已经写过一遍了,资源已经被消耗掉了。
第二,锁的粒度是行级。正在被UPDATE或DELETE的行,会被加上行级排他锁。其他事务如果也要修改这些行,只能等待当前事务结束。如果当前事务迟迟不提交,锁等待就开始了。这里有一个容易被忽视的点:PostgreSQL的DELETE和UPDATE在表级别获取的是ROW EXCLUSIVE锁,它和普通SELECT并不冲突,所以不会被SELECT查询阻塞,也不会阻塞SELECT。但如果有其他事务需要对这个表执行DDL操作(比如ALTER TABLE加列),会被你这个事务挡住。
理解了这一点,就能明白安全使用UPDATE和DELETE的核心不是“学会语法”,而是学会管理事务边界和锁的持有时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 执行前必须建立的防护习惯
2.1 事务包裹:给操作加上后悔药
这是最基础、也最实用的一招。执行UPDATE或DELETE之前,先手动开启一个事务,执行完SQL后不要急着提交,先查询验证一遍结果,确认无误再COMMIT,不对就ROLLBACK。
sql复制BEGIN;
UPDATE users SET status = 'frozen'
WHERE email LIKE '%@example.com';
-- 此时先检查影响行数
-- 或者查询被更新后的数据,验证是否和预期一致
-- 确认无误后再提交
COMMIT;
-- 发现不对就回滚
-- ROLLBACK;
这个习惯的价值在于:它把“确认”和“提交”之间隔开了一个窗口期,而这个窗口期是你可以主动控制的。很多人以为脚本里写完UPDATE就是执行,执行完就结束,完全忽略了PostgreSQL本身支持事务回滚。
我个人的建议是,在业务高峰期以外的窗口执行大范围UPDATE/DELETE时,事务要开,但不要开太久。两个原因:一是大事务会积累大量快照信息,影响autovacuum清理;二是长事务会拖住复制流,导致从库滞后。安全另一个角度来说,在事务里做验证的黄金时间就是几秒到几分钟,不是几小时。
2.2 SELECT先探路:WHERE条件必须在查询里验证
事务包裹只是兜底,更重要的防护是执行前先跑一遍SELECT COUNT(*),确认WHERE条件实际会命中多少行。
sql复制-- 先看影响范围
SELECT count(*) FROM users
WHERE email LIKE '%@example.com';
-- 再看具体数据,确认有没有误伤
SELECT id, email, status, created_at
FROM users
WHERE email LIKE '%@example.com'
LIMIT 50;
这一步在整个流程中最容易被跳过,但它恰恰是事故预防的核心。为什么?
因为WHERE条件是否精确,你不能靠眼睛判断,要靠执行计划判断。同样的WHERE条件,如果目标表有12万行符合条件你以为是1万行,一条UPDATE下去就出事了。先跑SELECT COUNT(*)可以在不锁任何行的情况下,准确告诉你影响范围。
如果一个执行计划预计扫描的行数和实际行数偏差太大,说明统计信息已经过时了,要先跑ANALYZE users;更新统计信息,再重新验证执行计划。
2.3 使用RETURNING当场核验影响行数
PostgreSQL的UPDATE、DELETE、INSERT都支持RETURNING子句,这玩意特别适合安全操作场景。
sql复制-- 更新后返回被改行的关键字段
UPDATE users SET status = 'frozen'
WHERE email LIKE '%@example.com'
RETURNING id, email, status, updated_at;
-- 删除后返回被删除行的主键
DELETE FROM temp_logs
WHERE created_at < now() - interval '30 days'
RETURNING id, created_at, substr(payload, 1, 50);
RETURNING的作用是让你在事务内直观看到“刚刚这条SQL到底改了哪些行”。如果你在psql里执行,它会直接打印出来。你一眼就能发现有没有不该被改的行混进来了,还没有提交的话直接ROLLBACK。
这里有一个实用建议:UPDATE时RETURNING可以带上修改前和修改后的值,但默认只显示修改后的值。如果你需要对比,可以使用UPDATE ... SET col = ... RETURNING col, ...,或者用旧值判断时,先SELECT出来缓存下来再更新。没有专门的“旧值”语法,所以要养成先SELECT再UPDATE的习惯。
2.4 给事务设置锁超时和语句超时
这是很多人忽略但极其重要的一层防护。PostgreSQL默认情况下,一条UPDATE/DELETE如果碰到了行锁等待,会无限期地等下去。等多久完全取决于持锁事务什么提交。生产环境里这种等待会拖垮整个连接池。
所以安全用UPDATE和DELETE,执行前应该显式设置锁等待超时:
sql复制SET lock_timeout = '5s';
SET statement_timeout = '30s';
BEGIN;
UPDATE ...
COMMIT;
lock_timeout控制的是“等锁”最多等多久,超过就报错;statement_timeout控制的是“单条语句执行”最多跑多久,超过就取消。两个都设置,相当于给操作加了一个保险丝,避免在最坏情况下把生产拖死。
不过要注意,statement_timeout一旦触发,这条语句会报错终止,但当前事务并没有回滚,你还要手动ROLLBACK。一个常见失误就是语句超时后直接新开事务,结果旧事务还挂着锁。所以无论什么时候,只要语句执行中出现异常,都要先ROLLBACK。
3. UPDATE关联更新的正确打开方式
3.1 FROM子句关联更新的原理
热搜词里有“update语句关联表”,说明这是很多人日常会遇到的场景。PostgreSQL中关联UPDATE的语法和MySQL不太一样,MySQL用的UPDATE t1 JOIN t2 ON ...语法在PostgreSQL里是不支持的。PostgreSQL的标准写法是:
sql复制UPDATE order_items
SET status = 'cancelled'
FROM orders
WHERE order_items.order_id = orders.id
AND orders.status = 'pending'
AND orders.created_at < now() - interval '48 hours';
这种UPDATE ... FROM ...的写法,本质上等价于先做一次SELECT ... FROM order_items JOIN orders ON ...,然后对满足条件的order_items行执行更新。FROM子句里每一行匹配,都会让目标表行被标记一次更新。
需要注意的一个大坑:如果FROM子句关联出来的结果里,同一行order_items出现了多次(比如orders表本身有重复id,或者关联条件不够严格),PostgreSQL不会报错,而会“选一条”来更新。但选了哪一条,是没有保证的。这就是导致所谓“更新结果不确定”问题的根源。
3.2 关联更新中的重复行陷阱
举个真实的坑:
sql复制-- 假设有个用户标签表user_tags,一个用户可能有多个标签
UPDATE users
SET vip_level = 3
FROM user_tags
WHERE users.id = user_tags.user_id
AND user_tags.tag = 'gold';
如果user_tags里用户1001有两条tag='gold'的记录,users表中用户1001会被更新两次,最终vip_level=3倒是结果一样,看起来没事。但如果你SET的数据来自user_tags字段,问题就大了:
sql复制UPDATE users
SET vip_level = user_tags.level
FROM user_tags
WHERE users.id = user_tags.user_id
AND user_tags.tag = 'gold';
假设用户1001有两条gold标签,一条level=2,一条level=5,最终vip_level是2还是5?没有保证。PostgreSQL文档里明确说这种情况结果是“non-deterministic”(不确定的)。在实际生产中,这可能意味着用户被提权或降权,完全不可控。
解决方案是在关联更新前,先通过ROWNUM或者GROUP BY把右侧降维成“每个目标行最多对应一行”:
sql复制UPDATE users
SET vip_level = t.max_level
FROM (
SELECT user_id, max(level) AS max_level
FROM user_tags
WHERE tag = 'gold'
GROUP BY user_id
) t
WHERE users.id = t.user_id;
这样右侧子查询每个user_id只出一行,更新结果就确定了。
3.3 用EXISTS子查询更新:更保守的替代方案
如果关联更新的目标只是“给符合条件的行打标”,不依赖关联表的具体值,其实更稳妥的写法是EXISTS子查询:
sql复制UPDATE users
SET is_gold = true
WHERE EXISTS (
SELECT 1 FROM user_tags
WHERE user_tags.user_id = users.id
AND user_tags.tag = 'gold'
);
EXISTS子查询的好处是天然不会产生重复行问题,只要存在就满足条件,不存在就跳过,结果永远是布尔判断。它在实践中比JOIN更不容易出错,性能一般也不差,因为EXISTS一旦找到匹配行就会短路返回。
所以我的习惯是:
- 需要从关联表取值回填时,用
FROM关联,但右侧必须提前去重; - 只需要判断“是否存在”时,用
EXISTS子查询; - 需要批量同步复杂字段时,考虑用CTE(WITH语句)先算出目标值,再关联更新。
CTE版本的更新看起来长,但逻辑最清晰:
sql复制WITH target_users AS (
SELECT u.id, t.max_level AS new_level
FROM users u
JOIN (
SELECT user_id, max(level) AS max_level
FROM user_tags
WHERE tag = 'gold'
GROUP BY user_id
) t ON u.id = t.user_id
)
UPDATE users
SET vip_level = x.new_level
FROM target_users x
WHERE users.id = x.id;
这种写法在复杂业务场景下可读性和可维护性是最高的,排查问题也方便。
4. DELETE的安全边界与锁分析
4.1 DELETE大表的锁与WAL消耗
DELETE在PostgreSQL里看着容易:“删除最后30天的日志”,但实际生产里最容易出问题的恰恰是这个操作。
关键点在于:DELETE一万行和DELETE一亿行的锁等待、WAL日志量、autovacuum压力完全不是一个量级。
一行DELETE会在表里标记删除为不可见,同时写入WAL。批量删除会带来几个连锁问题:
- 事务要持有所有被删除行的行锁,提交前不能释放;
- 表里堆积大量dead tuple(死亡元组),autovacuum要花很长时间清理;
- 如果同时有流复制,从库需要重放同样的WAL记录,二进制日志压力翻倍。
我见过有团队在业务高峰期跑一条DELETE FROM logs WHERE created_at < now() - interval '7 days',直接删了上千万行。事务持续了十几分钟,期间查询变慢,连接堆积,最后不得不kill掉事务回滚。
DELETE的安全第一原则是:控制单次事务内删除的行数,让整个删除过程分批进行,随时可以暂停、随时可以恢复。
4.2 分批删除的节奏与CHECKPOINT配合
分批删除是目前工业界最主流的做法。核心思想是每次删除一个有限的行数,提交后立刻释放锁和WAL压力,然后循环。
sql复制DO $$
DECLARE
batch_size int := 10000;
deleted int := 0;
BEGIN
LOOP
DELETE FROM logs
WHERE id IN (
SELECT id FROM logs
WHERE created_at < now() - interval '7 days'
LIMIT batch_size
);
GET DIAGNOSTICS deleted = ROW_COUNT;
EXIT WHEN deleted < batch_size;
COMMIT;
-- 每批之间休眠一下,给其他业务让路
PERFORM pg_sleep(1);
END LOOP;
END $$;
这里有几个细节值得说。
第一,子查询里LIMIT batch_size是必不可少的,没有它整个批量策略就废了,因为DELETE的WHERE条件会扫到所有匹配行。
第二,COMMIT在DO块里是可以用的,前提是之前已经执行过BEGIN或当前的自动提交已关闭。如果是psql脚本,用\set AUTOCOMMIT off控制。
第三,批量删除之间的休眠时间要根据业务对资源的敏感度来调。凌晨跑任务,每批间隔可以短一点;白天在线业务高峰期,每批间隔就要长一点,甚至再加一个只对最近时间窗口生效的WHERE条件。
第四,大批量删除后需要留意autovacuum的进度。你删了百万行,autovacuum要很久才能清理掉这些dead tuple,期间表的膨胀是正常的。删除完成后,如果表特别大,可以手动执行VACUUM (ANALYZE) table_name来加速回收和更新统计信息。
4.3 TRUNCATE和DELETE的取舍
DELETE有这么多安全上的坑,很多人会想到TRUNCATE。
它们的使用边界其实是清晰的:
| 操作 | 特点 | 使用场景 |
|---|---|---|
| DELETE | 支持WHERE条件、支持事务回滚、可以精确控制影响行数 | 删除部分行、需要精细控制 |
| TRUNCATE | 全表清空、不产生逐行日志、速度快、不能回滚特定行 | 清空整表、不需要保留数据 |
TRUNCATE在PostgreSQL里也是事务性的,可以ROLLBACK,但如果误删了整张业务表,恢复成本依然很高。生产里对活跃业务表做TRUNCATE前,我至少会做两件事:确认没有外键依赖会连带事务失败,以及确认备份到位。
另外注意,DELETE删完之后表空间不会立即变小,这是PostgreSQL的正常行为。如果你删完发现磁盘占用没降,不要慌,先看autovacuum是否已经跑完。如果确实需要立即释放空间,可以VACUUM FULL,但VACUUM FULL会锁表,必须在维护窗口执行。
5. 并发场景下的锁冲突排查与对策
5.1 定位锁等待的查询
生产环境里,一条UPDATE或者DELETE执行半天没反应,最常见的不是慢查询本身,而是它在等锁。你需要在另一个会话里看看当前的锁和阻塞情况。
PostgreSQL提供了系统视图pg_stat_activity和pg_locks可以从两个角度排查。
简单高效的一条排查SQL:
sql复制SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocked.pid = any(
pg_blocking_pids(blocked.pid)
)
WHERE blocked.wait_event_type = 'Lock';
这个查询的核心是pg_blocking_pids()函数,它能返回阻塞某个进程的所有PID。有了阻塞PID,你就能看到谁握着锁,它执行的什么语句,事务跑了多久。
排查的基本流程:
- 登录psql,先看哪些会话处于
Lock等待事件; - 用
pg_blocking_pids找出阻塞源; - 检查阻塞会话的
state字段——如果是idle in transaction,说明它事务开着但啥也没干,锁却还攥着,这种是最常见的“锁泄漏”; - 确认后可以
pg_terminate_backend(blocking_pid)终止阻塞会话。
这里要注意:不要在业务高峰期贸然杀掉事务,除非你已经确认它没有其他重要工作。最稳妥的做法是先通知应用侧切流量,再终止事务。
5.2 FOR UPDATE的正确姿势与SKIP LOCKED
热搜词里出现了“for update 后接参数”和“limit 1 for update skip locked 的组合使用”,说明很多人对行级锁的具体行为有疑问。
先说FOR UPDATE。它会在SELECT返回的行上加行级排他锁,锁在事务提交或回滚时释放。它的作用是防止并发事务同时修改同一行,典型场景是“查库存->扣减库存”这类经典的并发安全操作。
sql复制BEGIN;
SELECT stock FROM products
WHERE id = 1001
FOR UPDATE;
-- 拿到结果后做扣减业务逻辑
UPDATE products
SET stock = stock - 5
WHERE id = 1001;
COMMIT;
这里有个容易被误解的点:FOR UPDATE只锁定SELECT查出来的行,如果WH ERE条件匹配1000行,就锁1000行;如果用LIMIT 1,就只锁那1行。它不会锁定“所有符合WHERE条件的行”以外的范围。这个语义在LIMIT 1 ... FOR UPDATE SKIP LOCKED组合中体现得最明显。
SKIP LOCKED是PostgreSQL 9.5引入的能力,意思是:跳过已经被其他事务锁定的行,直接读取剩余可用的行,不等待。它天然就是为“任务队列消费”这类场景设计的。
sql复制SELECT id, payload
FROM job_queue
WHERE status = 'pending'
ORDER BY priority DESC
LIMIT 10
FOR UPDATE SKIP LOCKED;
并发场景下,多个worker同时执行这条SQL,每个worker拿到的都是尚未被锁定的行,不会互相等待,也不会处理重复任务。这是目前实现PostgreSQL轻量级任务队列最推荐的方案,没有之一。
组合起来:LIMIT 1 FOR UPDATE SKIP LOCKED锁定的是满足条件、并且没有被其他事务锁定的那一行(如果LIMIT 1只取一行的话),而不是所有WHERE条件匹配的行。热搜词里这个问题问得很精准,一句话回答:锁的是LIMIT定格后的那1条,不是所有where条件行。
5.3 死锁的检测与避免
多个并发UPDATE/DELETE在交叉更新同一批行时,有可能触发死锁。PostgreSQL默认的deadlock_timeout是1秒,也就是说一个事务等锁超过1秒后会触发死锁检测,如果发现死锁,其中一个事务会被回滚。
死锁看起来可怕,实际并不可怕,因为PostgreSQL能自动检测并回滚其中一个受影响的事务。真正需要做的是业务侧把死锁当成正常分支处理——捕获冲突错误并按需重试。
避免死锁的操作习惯也很简单:多个事务按相同顺序访问资源。比如所有事务都先更新users再更新orders,而不是有的先users后orders、有的先orders后users,就能显著降低死锁概率。
6. 事故复盘:一条UPDATE导致的服务大面积阻塞
6.1 事故画面还原
一次线上事故很能说明问题。
业务侧在凌晨跑了一个数据订正任务,脚本里的核心SQL长这样:
sql复制UPDATE payment_orders
SET status = 'paid'
WHERE user_id IN (
SELECT user_id FROM users WHERE channel = 'app'
);
这条SQL看起来人畜无害,但它实际上把payment_orders表里所有channel为app的用户的所有订单全部更新了。原意是只更新某一天的一个状态,但脚本里漏掉了created_at时间段的过滤条件。凌晨执行时表不大,几秒钟跑完,没人发现。
到了早上业务高峰,这些更新过的订单行已经在事务提交后正常可见,但另一个新上线的对账任务又在同一批订单上做UPDATE,加上大量用户端在查订单,数据库连接逐渐耗尽。
6.2 排查链路
当时排查顺序大概是这样的:
第一步,看连接数。应用报“connection limit exceeded”,说明连接池已满。
第二步,看pg_stat_activity。大量会话处于Lock等待,等待事件都是transactionid,说明在等锁。
第三步,用pg_blocking_pids找到最上游的阻塞会话,发现是那个凌晨没退出的数据订正事务——它的执行计划匹配了几十万行,事务一直没提交,锁一直没放。
第四步,确认这个阻塞事务已经没有意义(业务已经确认数据被更新错了),直接pg_terminate_backend把它干掉,锁释放,堆积的连接陆续恢复。
6.3 复盘出的安全规则
这次事故后,团队把规则沉淀成了硬性要求,这里直接分享给大家:
- 线上UPDATE/DELETE必须带事务,且必须在事务内验证RETURNING结果后才允许COMMIT;
- 影响行数超过1000行的操作,必须走分批循环,不允许单条SQL一把梭;
- 所有UPDATE/DELETE脚本里必须先跑SELECT COUNT(*)验证影响行数,并且把执行计划保存到日志里;
- 设置
lock_timeout为5秒,防止无限等待把连接池拖死; - 凌晨跑批任务必须设置
statement_timeout,超过5分钟自动中止并告警。
这套规则后来几乎成了团队内部所有数据库变更的默认门槛。
7. 我最后想分享的几个经验
工具和方法论说了很多,最后说几个我在实际操作中觉得特别有用的技巧。
第一,psql里习惯打开\set VERBOSITY verbose和\set ON_ERROR_STOP on,错误信息更完整,出现异常就停止当前脚本,避免一连串失败的语句继续执行。
第二,执行大范围DELETE之前,先执行一次SELECT ... FOR UPDATE把行提前锁住并查看行数,可以提前预判锁的等待情况,也能在未提交前发现会不会有其他事务阻塞你。
第三,维护窗口改造数据时可以结合pg_terminate_backend配置一个“熔断”机制——比如写个小脚本,如果锁等待超过阈值就自动终止阻塞者,防止一个坏事务拖垮整个实例。
第四,在批量更新场景里,用information_schema.statistics或EXPLAIN提前确认索引可用,避免UPDATE的WHERE条件无法命中索引导致全表扫描。全表扫描在大表上不仅慢,还会加大锁的持有时间和WAL压力。
PostgreSQL的UPDATE和DELETE本身不难,难的是在正确的时间、正确的范围、正确的锁策略下进行。把事务、SELECT验证、RETURNING、锁超时、分批删除这五板斧用好,绝大多数的生产事故都是可以避免的。
