最危险的写法是UPDATE不加WHERE校验,会导致全表误更新;正确做法是将业务规则写入WHERE条件(如WHERE id=$1 AND status='paid'),确保幂等性。

存储过程里直接写UPDATE,但没加WHERE状态校验
这是最常见也最危险的写法。比如写成 UPDATE order SET status = 'shipped',表面看只是改个状态,但脚本一旦被重复执行,就会把所有订单都强行设为 shipped,不管它原本是不是已发货、是不是在退款中。
真正要做的,是把业务规则“翻译”进 WHERE 条件里。例如发货只能从 'paid' 变成 'shipped',那必须写成:UPDATE order SET status = 'shipped', shipped_at = NOW() WHERE id = $1 AND status = 'paid'。影响行数为 0 就说明已被处理,不报错、不重试、不告警——这才是幂等该有的样子。
- 状态字段必须有明确的合法流转路径(如
created → paid → shipped → completed),不能只靠status != 'shipped'这种宽泛判断 - 避免用
NOW()做更新依据,MySQL 中它是语句开始时间,PostgreSQL 中可能随事务变化;时间敏感场景建议由应用层传入固定时间戳 - 如果要记录“首次变更时间”,用
shipped_at = COALESCE(shipped_at, NOW()),但得确认数据库对COALESCE和时间函数的行为一致
DDL操作(如CREATE TABLE、ADD COLUMN)放进存储过程却没做存在性检查
DDL 脚本重复执行,90% 的失败不是因为逻辑错,而是因为直接报错中断:MySQL 报 ERROR 1050 (42S01): Table 'xxx' already exists,PostgreSQL 报 ERROR: relation "xxx" already exists。这类错误不能靠 TRY/CATCH 简单跳过——不同数据库的错误码、SQLSTATE 不统一,捕获逻辑极易漏判。
正确做法是把存在性检查作为 DDL 前置步骤。PostgreSQL 支持 IF NOT EXISTS,但仅限部分语句(CREATE TABLE IF NOT EXISTS 可用,CREATE DATABASE 不支持);MySQL 几乎全不支持,必须手动查 information_schema 或用 DO $$ ... $$ 匿名块封装。
- 建表前查
SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'target_table'(PostgreSQL) - 加字段前查
SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'db_name' AND TABLE_NAME = 't' AND COLUMN_NAME = 'new_col'(MySQL) - 所有 DDL 操作应放在同一事务内,避免部分成功、部分失败导致元数据不一致
用 SELECT + UPDATE 实现“先查后更”却被并发打穿
有人在存储过程中写:SELECT status INTO v_status FROM order WHERE id = $1,再根据 v_status 决定是否 UPDATE。这在单线程下看似安全,但只要两个请求同时进入,就可能都读到 'paid',然后都执行发货更新——状态机彻底乱套。
根本问题在于:SELECT 和 UPDATE 是两步,中间没有原子性屏障。哪怕加了 SELECT FOR UPDATE,在存储过程调度场景下也极不可靠:连接超时、事务自动提交、ORM 隐式释放锁,都会让锁失效或残留。
- 放弃“先查后更”,一律改用单条带条件的
UPDATE或INSERT ... ON CONFLICT - 如果真需要读取旧值做计算(比如积分累加),改用
UPDATE ... SET score = score + $2 WHERE id = $1 AND version = $3 RETURNING score, version,靠 version 字段保证乐观锁 - 存储过程里不要依赖外部事务上下文——它很可能被当作独立事务执行,
FOR UPDATE行为难以预测
复杂任务(如跨表状态同步)没引入唯一业务标识和防重表
当一个存储过程要更新订单、扣库存、发通知三件事时,单靠 WHERE 条件已经不够。万一过程执行到第二步失败,重跑时前面的订单状态可能已变,但库存还没扣——状态撕裂。
这时候必须引入外部协调机制:用一笔业务的全局唯一 ID(比如 order_no 或 trace_id)作为防重键,在轻量表(如 task_log)里记录执行状态。存储过程开头先尝试插入该键,成功才继续,失败就直接退出。
-
task_log表结构只需(biz_type, biz_id, status, created_at),(biz_type, biz_id)加唯一索引 - 插入用
INSERT INTO task_log (...) VALUES (...) ON CONFLICT DO NOTHING(PostgreSQL)或INSERT IGNORE(MySQL) - 状态字段按阶段设为
'pending'→'processing'→'success',只允许单向推进,避免回滚污染
真正难的不是写 SQL,而是把“哪一步算成功”定义清楚——比如是订单表更新完成就算成功,还是所有关联表+消息队列都落库才算。这个边界一旦模糊,防重就形同虚设。

















