← 中文资源

简体中文版本

在序列化失败时重试整个事务

只对 SQLSTATE 40001 与 40P01 做有上限的指数退避重试,绝不重试必然再次失败的错误。

阅读英文原文

概述

在 SERIALIZABLE(有时也包括 REPEATABLE READ)隔离级别下,PostgreSQL 为保证结果等价于串行执行,会主动中止事务。SQLSTATE 40001 不是缺陷,而是“请重新执行”的指令,死锁 40P01 同理。把这两种情况当成 500 返回属于设计错误,因为重试通常就能成功,用户根本不会察觉。

步骤

  1. 把 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();
  }
}
  1. 外层只对可重试错误做指数退避加抖动的重试:
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));
    }
  }
}
  1. 事务内只写 outbox 记录,邮件、扣款、第三方调用等副作用放到提交之后由中继执行。
  2. 用两个并发事务做集成测试,确认至少有一次重试且两个请求最终都成功。

验证

node --test test/serializable-retry.test.js 应显示所有转账成功、重试计数至少为 1、调用方没有收到 40001。再补一个单元测试:让 work 连续两次抛出 { code: '40001' },第三次成功,断言调用次数为 3;改为抛出 23505 时应立即失败、不重试。

注意事项

  • 重试会增加延迟与负载,必须设置次数上限并导出重试计数,重试率上升说明事务需要缩短。
  • 只有序列化失败与死锁可以安全重试,唯一约束冲突、校验失败需要各自的处理策略。
  • 工作单元内不能有非幂等的外部副作用,否则重试会重复发邮件或重复扣款。
  • 达到重试上限应返回 503 而不是伪装成功,让调用方知道请求未被处理。
  • 不要只在 READ COMMITTED 下测试重试逻辑,隔离级别不一致时该逻辑等于没有验证。