概述
在 SERIALIZABLE(有时也包括 REPEATABLE READ)隔离级别下,PostgreSQL 为保证结果等价于串行执行,会主动中止事务。SQLSTATE 40001 不是缺陷,而是“请重新执行”的指令,死锁 40P01 同理。把这两种情况当成 500 返回属于设计错误,因为重试通常就能成功,用户根本不会察觉。
步骤
- 把 BEGIN、COMMIT、ROLLBACK 收敛到一个函数中,整个工作单元一起重试:
async function transferOnce(pool, from, to, amount) {
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, from]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, to]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
- 外层只对可重试错误做指数退避加抖动的重试:
const RETRYABLE = new Set(['40001', '40P01']);
async function withRetry(work, attempts = 4) {
for (let attempt = 1; ; attempt += 1) {
try { return await work(); }
catch (err) {
if (!RETRYABLE.has(err.code) || attempt >= attempts) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 10 + Math.random() * 25));
}
}
}
- 事务内只写 outbox 记录,邮件、扣款、第三方调用等副作用放到提交之后由中继执行。
- 用两个并发事务做集成测试,确认至少有一次重试且两个请求最终都成功。
验证
node --test test/serializable-retry.test.js 应显示所有转账成功、重试计数至少为 1、调用方没有收到 40001。再补一个单元测试:让 work 连续两次抛出 { code: '40001' },第三次成功,断言调用次数为 3;改为抛出 23505 时应立即失败、不重试。
注意事项
- 重试会增加延迟与负载,必须设置次数上限并导出重试计数,重试率上升说明事务需要缩短。
- 只有序列化失败与死锁可以安全重试,唯一约束冲突、校验失败需要各自的处理策略。
- 工作单元内不能有非幂等的外部副作用,否则重试会重复发邮件或重复扣款。
- 达到重试上限应返回 503 而不是伪装成功,让调用方知道请求未被处理。
- 不要只在
READ COMMITTED下测试重试逻辑,隔离级别不一致时该逻辑等于没有验证。