任务幂等、重试、补偿与锁:重复执行怎样收敛为一个结果
账户已经扣减 100,调用方却没有收到结果。再次运行时,程序需要按业务操作查到这次提交并返回已保存结果,防止再次扣款。定时触发、人工补跑和故障接管产生的新执行记录,都应能找到同一笔操作;尚未完成的步骤则继续处理。
幂等处理重复操作,锁协调同时执行的调用。工作未完成时可以按条件重试;已经发生的扣款若需要纠正,则发起补偿。数据库事务覆盖了哪些写入,决定了故障后应选择哪种动作。
先给业务操作一个稳定身份
业务键与执行编号
调度平台的日志编号适合定位某次执行,但失败重试可能产生新日志。若把它当作幂等键,同一业务的每次重试都会获得新身份。
业务操作:商户 A 的结算窗口 W、规则版本 V
├── 定时触发:执行记录 101
├── 失败重试:执行记录 102
└── 人工补跑:执行记录 119
三次运行共用业务键:settlement:A:W:V
各自保留 executionId,便于定位尝试过程幂等键应覆盖操作的实际范围。按商户结算,需要包含商户和结算窗口;按订单扣减库存,需要订单操作编号;业务允许再次结算修订版本时,还要加入版本。键过粗会拒绝合法新操作,键过细则无法吸收重复请求。
输入参数也需要校验。同一个键第一次表示扣减 100,第二次却表示扣减 101,应返回冲突,而不是静默复用旧结果。简单场景可以保存关键字段;复杂输入可保存规范化参数摘要。摘要计算必须固定字段选择、排序、空值和数值表示规则。
成熟 API 也会对重复键和参数的一致性作出明确约定,例如 Stripe 的幂等请求规则。接入外部接口时,应核对它的保存期限、并发请求行为以及哪些结果会被缓存,不能只看到支持 Idempotency-Key 就假定所有请求可永久去重。
幂等记录需要保存结果
操作记录除了保存键,还需要让查询方读出处理阶段和已有结果。常见字段如下:
| 字段 | 用途 |
|---|---|
operation_key | 同一业务操作的唯一标识 |
request_hash 或关键参数 | 检查重复调用是否表达同一操作 |
status | 正在处理、已完成或待人工核对 |
result_ref | 已提交流水、订单或导出文件的引用 |
attempt_count | 记录尝试次数,不参与业务身份 |
next_attempt_at | 安排后续重试 |
owner、lease_until、token | 在需要跨事务领取时管理执行者 |
并非所有任务都需要全部字段。单数据库内的短事务可以让“创建操作记录、业务写入、记录成功”一次提交,外部只能看见完整结果或看不见该操作。长任务或跨系统调用则需要把运行中状态持久保存,以支持查询和接管。
去重记录的保留期限应覆盖自动重试、消息重新投递和人工补跑的最长时间。清理过早会使旧请求再次产生效果。需要永久避免重复的业务,通常让业务流水的唯一键长期保留,而不是仅依赖短期缓存。
用事务、租约和 fencing 约束写入
唯一约束承担并发竞争
“先查询是否存在,再插入”存在竞争窗口:两个事务可能同时查到不存在,然后一起执行业务。唯一约束需要落在数据库中:
CREATE TABLE lab_operation (
op text PRIMARY KEY,
amount integer NOT NULL,
status text NOT NULL
);
INSERT INTO lab_operation(op, amount, status)
VALUES ('order-A', 100, 'RUNNING')
ON CONFLICT (op) DO NOTHING;程序检查影响行数。插入成功的事务继续处理;未插入的事务读取已有参数和结果。PostgreSQL 的冲突处理建立在唯一约束或唯一索引上,同一个键的未提交写入可能让另一个事务等待。INSERT 与 ON CONFLICT
在默认 READ COMMITTED 下,等待结束后用新的 SELECT 语句读取已提交结果,便于处理并发提交。如果改用更强隔离级别,需要同时处理序列化失败,并重试整个事务;不能把“受影响行数为 0”统一理解为成功重放。事务隔离
事务 A:INSERT operation ── 业务写入 ── 更新 SUCCEEDED ── COMMIT
事务 B:INSERT 同一键 ───── 等待唯一键竞争 ────────────── 读取旧结果事务 A 提交后,重复调用复用结果。若它回滚,操作记录与业务写入一起消失,另一个调用便有机会重新插入业务键。
本地事务能覆盖哪些内容
以账户扣减为例,短事务中的操作顺序是:
- 插入业务键并检查是否获得执行权。
- 使用条件更新扣减余额,检查影响行数。
- 插入唯一业务流水。
- 更新操作状态和结果引用。
- 提交事务,再向调用方返回。
UPDATE lab_account
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;影响行数为 0 时,余额条件未满足,应按业务失败处理。SQL 抛错、连接失败或程序异常时回滚整个事务。只有第一步做了去重,后面的业务写入却在另一个自动提交连接中执行,仍然会留下部分结果。
远程 HTTP 请求不能自动参加 PostgreSQL 本地事务。数据库已扣款但消息发送失败时,常见做法是在同一事务内写入 outbox,由投递程序重试发送;消费者再按事件键去重。发送成功但确认丢失时,可能再次发送,所以接收方仍需幂等处理。
结果未知要先查询
调用方超时可能发生在数据库提交前,也可能发生在提交后、响应返回前。客户端无法从超时异常确定哪种情况。
提交前失败 查询无成功记录,按条件重试
提交成功、确认丢失 查询到成功记录,返回已保存结果
远端仍在处理中 保持处理中,稍后查询
无法查询远端结果 转人工核对或专门恢复流程对支付、短信或外部发货接口,应优先使用对方提供的请求键和结果查询接口。若对方既不支持幂等请求,也无法按业务编号查询,自动无限重试可能重复扣款或发货。这种不确定性需要进入业务处理流程。
远端结果尚未查清时,保留“未知”状态并安排后续核对。此时直接生成反向操作,可能补偿一笔根本没有成功的扣款。
租约解决持有者失联
短事务可以依靠数据库行锁串行处理。运行数分钟的任务不适合一直占用事务和行锁,通常先在短事务中领取工作,记录持有者和过期时间,再在事务外执行耗时计算。
UPDATE lab_lease
SET holder = 'worker-B',
token = token + 1,
deadline = clock_timestamp() + interval '30 seconds'
WHERE task = 'settle'
AND deadline < clock_timestamp()
RETURNING token;返回一行表示领取成功;没有返回行表示仍有人持有有效租约。心跳续租也要携带当前持有者和 token,并检查更新行数。失去续租资格后,执行者应停止提交新的业务结果。
这里用数据库时钟判断本数据库中的租约。PostgreSQL 的 CURRENT_TIMESTAMP 固定为事务开始时间,clock_timestamp() 返回实际调用时刻;长事务里混用两者可能影响过期判断。日期与时间函数
租约到期只允许别人接管,无法让暂停中的旧 JVM 自动停止。旧进程从长时间 GC 或网络阻塞中恢复后,可能继续执行已经算出的写入。
fencing 必须在被保护资源上校验
每次接管分配递增 token,并让真正接收写入的资源拒绝旧 token,才能防止旧执行者覆盖新结果。
worker-A 获得 token=1 ── 暂停
租约到期
worker-B 获得 token=2 ── 资源记录当前 fence=2
worker-A 恢复并提交 token=1 ── 拒绝
worker-B 提交 token=2 ── 接受实验中租约表和资源表都在同一 PostgreSQL 内。接管事务同时推进资源的 fence;写入事务先锁定并检查有效租约,再执行:
UPDATE lab_resource
SET value = 20
WHERE id = 1 AND fence = 2;行锁使“接管并推进 fence”与“旧持有者检查并写入”按事务顺序执行。PostgreSQL 显式锁
若真正的资源是另一个 HTTP 服务,token 就需要由那个服务校验。只在调度平台的任务表里比较 token,随后无条件调用外部接口,无法拦住已经离开本地事务的旧请求。
fencing 的序号还必须覆盖相同资源。多个任务分别从 1 开始计数,却都写同一份文件,资源无法从 token 判断新旧。应按资源维护可比较的版本,或者将写入转交单一受控服务。
在 PostgreSQL 中验证重复、回滚与接管
启动独立实验
下载任务幂等实验工程。需要 Linux、Bash、Docker Engine、Compose v2 和 unzip。宿主用户具有 Docker 权限;Maven 使用宿主 UID/GID 构建,应用容器使用 10001:10001。
版本为 Maven 3.9.12、JDK 17、PostgreSQL 18.6、JDBC 驱动 42.7.8。数据库端口仅发布到 127.0.0.1:18223。示例账号和固定口令只用于本地隔离实验。
unzip job-idempotency-retry-lab.zip
cd job-idempotency-retry-lab
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/.m2 \
--mount "type=bind,source=$PWD,target=/work" \
--mount "type=bind,source=$PWD/.m2,target=/m2" \
-w /work maven:3.9.12-eclipse-temurin-17 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 clean verify
docker compose -p idem20 up -d --wait postgres
docker compose -p idem20 run --rm lab init
docker compose -p idem20 run --rm lab verify初始化建立一条余额为 1000 的账户,以及操作、流水、租约、资源和补偿表。init 拒绝非空 schema;verify 要求尚未运行过的初始化数据库,避免旧结果干扰并发实验。
校验程序会查询数据库余额、行数、唯一键等待和更新结果,任一断言失败即非零退出。
两个连接同时处理同一笔业务
第一个连接插入 order-A 后暂停在提交前,第二个连接尝试插入同一个键。程序从 pg_stat_activity 观察第二个连接的 wait_event_type='Lock',确认发生真实等待,再放行第一个事务。
concurrentClaim=APPLIED,REPLAY lockWaitObserved=true ledgerRows=1余额从 1000 变为 900,只有一条扣减流水。第二次调用读取旧结果。锁等待如果始终未被观察到,实验失败,不会靠固定休眠后假定发生竞争。
随后把 order-A 的金额改为 101 再调用,预期得到:
IDEMPOTENCY_PARAMETER_CONFLICT这个异常来自参数不一致,与唯一键异常是不同的问题。调用方应修正请求或使用新的合法业务操作,不能自动换键重试同一笔扣减。
验证事务回滚与确认丢失
处理 order-B 时,程序在扣减余额和插入流水后注入 SQLState 为 P0001 的异常。当前事务回滚:
rollbackSqlState=P0001 operationRows=0 balance=900这里的 operationRows=0 指 order-B 没有留下操作记录,order-A 的成功结果仍保留。然后关闭故障注入,正常处理 order-B,余额变为 800。
提交后,实验故意不把第一次调用结果作为判断依据,而是按 order-B 查询成功记录,再用同一业务键重新调用:
unknownResult=resolvedByOperationKey retry=REPLAY这里丢弃的是调用返回值,数据库连接本身没有中断。按操作键查询到成功记录后,同键调用返回 REPLAY,调用方可以据此取得已提交结果。
旧持有者恢复后能否写入
初始领取得到 token 1。实验仅修改本实验任务的 deadline 使其过期,然后由另一个持有者领取 token 2,不修改宿主或数据库系统时钟。
oldToken=1 newToken=2 staleWriteRows=0 currentWriteRows=1资源最终保存新持有者写入的 20。旧 token 更新行数为 0,程序必须把它当成失去写入资格,而不是成功完成。
再对 order-A 执行两次补偿请求。补偿表按原操作编号唯一约束,只有第一次插入成功时才退回 100:
compensationRows=1 finalBalance=900最终余额对应两次各 100 的扣减和一次 100 的补偿:1000 - 100 - 100 + 100 = 900。
可直接查询业务表:
docker compose -p idem20 exec -T postgres \
psql -U idempotency -d idempotency -v ON_ERROR_STOP=1 \
-c "SELECT * FROM lab_account; SELECT * FROM lab_ledger; SELECT * FROM lab_compensation;"实验结束用 docker compose -p idem20 down 停止并保留数据。只有确认不再需要这些实验结果时,才执行 docker compose -p idem20 down -v 删除本项目卷。
重试与补偿怎样安排
按错误类别决定动作
| 情况 | 自动重试建议 | 需要保留的信息 |
|---|---|---|
| 连接建立失败、临时不可用 | 有次数和截止时间的退避重试 | 操作键、异常类别、下一次时间 |
| 死锁或序列化失败 | 回滚后重新执行整个事务 | SQLState、尝试次数 |
| 参数冲突、余额不足 | 停止机械重试,返回业务结果 | 失败原因及输入摘要 |
| 请求已发送后超时 | 先按业务键查询结果 | 远端请求号、结果未知标志 |
| 权限或配置错误 | 修正配置后受控重跑 | 版本、目标地址、权限主体 |
| 旧 token 或租约失效 | 当前执行者停止写入 | 当前 token 与实际持有者 |
数据库事务失败后,需要先回滚,再开启新事务。已经进入失败状态的连接事务不能继续执行剩余 SQL,把异常捕获后继续往下写往往只会产生更多错误。
重试预算同时限制次数和时间。可以用 min(maxDelay, baseDelay × 2^attempt) 计算退避上限,再从该区间随机选择等待时间,使同时失败的大批任务分散重试。还要限制并发重试数,防止下游尚未恢复就承受更高负载。AWS 的超时、退避与抖动说明
平台重试、应用重试和 SDK 重试会相乘。三个层次各尝试 3 次,最坏可能把一次业务放大到 27 次调用。应明确由哪一层控制整体截止时间,其他层只承担必要的短期重试。
重试队列也是持久工作
进程内 sleep 适合短暂退避,但等待数小时的补跑需要持久记录。任务表应保存下次重试时间和最后错误;调度程序按到期时间领取,领取过程仍使用数据库竞争机制。
大量到期任务可以分页领取并限制每轮数量。FOR UPDATE SKIP LOCKED 能让多个领取者跳过已锁定行,但跳过的行需要后续轮次重新扫描,不能把“本轮没有取到”判成全部完成。公平性、长期饥饿和积压告警都需要另外观察。
超过预算的任务应进入明确的待处理队列,保留业务键和错误摘要。人工重放沿用原操作身份,并记录操作者、原因和输入修订。若修改了金额等关键参数,需要新的业务操作,而不是覆盖旧请求摘要。
补偿处理已经发生的业务
补偿是新的业务操作,例如退款、释放预留库存或撤销尚未发出的配送请求。原操作成功记录仍应保留,补偿记录与之关联。
同一数据库里尚未提交的事务直接回滚即可。跨系统已经提交的结果,则可能需要补偿流程。补偿不一定能完全还原原状态:邮件已经被收件人阅读,物流已经出库,退款还可能涉及手续费和结算周期。
补偿自身也可能失败或被重复触发,因此需要独立幂等键、重试预算和结果查询。多步骤业务应保存每一步是否成功,再按业务依赖执行补偿,而不是简单将所有步骤逆序调用一次。
无法自动纠正的操作进入人工处理队列。先冻结重复请求,按业务编号核对实际流水,限制继续扣款或发货;原请求、查询结果和后续处理记录要保留在一起。
运行中应观察什么
重复命中率可以帮助发现过密调度或回调不稳定;参数冲突通常提示调用方身份设计错误;旧 token 拒绝次数增加,可能与租约过短、长时间暂停或续租失败有关。
成功率之外,还应记录有多少操作结果未知、最久等了多久,重试把调用次数放大了多少,补偿是否形成积压。查询某笔操作时,用 operation key 找到业务结果,再沿执行记录确定哪次尝试完成了提交。
权威资料与规范地址
请求幂等与重试退避
- Stripe 幂等请求规则:https://docs.stripe.com/api/idempotent_requests
- AWS 超时、退避与抖动:https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
数据库领取、事务与互斥
- PostgreSQL INSERT 与 ON CONFLICT:https://www.postgresql.org/docs/18/sql-insert.html
- PostgreSQL 事务隔离:https://www.postgresql.org/docs/18/transaction-iso.html
- PostgreSQL 日期与时间函数:https://www.postgresql.org/docs/18/functions-datetime.html
- PostgreSQL 显式锁:https://www.postgresql.org/docs/18/explicit-locking.html
