重试、幂等与补偿:重复执行怎样安全收敛
扣款已经提交,响应却没有到达客户端。此时再发一次请求,服务端需要知道它是在重放原来的扣款,还是要进行第二次扣款。这个区别由业务动作标识决定,TCP 连接、HTTP 请求编号和线程编号都无法替代它。
一个业务动作可以对应多次请求
先分清动作标识和尝试标识
动作:租户 demo 的扣款 charge-17,金额 10
├─ attempt 1:请求送达,事务提交,响应丢失
├─ attempt 2:携带同一个动作键,读取原有结果
└─ attempt 3:重复投递,仍读取原有结果
另一笔扣款:使用新的动作键 charge-18
撤销 charge-17:建立引用原动作的补偿记录尝试编号用来排查一次调用经过了几次发送;动作键用于确定哪些发送属于同一项业务操作。客户端必须在第一次发送之前持久保存或可靠保留动作键,后续重试沿用它。点击“再买一件”是新的动作,网络失败后的自动重发则通常沿用原动作。
HTTP 方法的幂等语义描述重复请求的预期效果,例如 PUT 用指定表示替换目标资源。它不要求每次状态码和响应正文相同,也不禁止服务端为每次请求记录日志。POST 默认没有统一的幂等保证,但可以通过明确的业务协议支持同键重放。RFC 9110 幂等方法给出了方法层定义及自动重试的条件。
幂等记录要包含足够的信息
一条最小的写入记录通常需要这些字段:
| 字段 | 用途 | 省略后的问题 |
|---|---|---|
| 作用域与动作键 | 区分租户、账号、操作类型及同一动作 | 不同用户可能互相碰撞或读取对方结果 |
| 请求指纹 | 检查同键重发的业务参数是否一致 | 原键可能被拿来提交不同金额 |
| 当前状态 | 表达执行中、成功、终止或已补偿 | 查询方无法决定等待、重放还是转人工 |
| 原始结果或结果引用 | 向重复调用返回已确定结果 | 为得到响应又执行一次副作用 |
| 创建、更新时间与保留期限 | 管理重放窗口和异常记录 | 过早清理后,迟到请求再次执行 |
指纹应覆盖会改变业务效果的字段。对 JSON 做摘要前,要定义字段顺序、数字形式、缺省值和忽略字段;直接散列原始字符串,可能把语义相同的两个请求误判为冲突。服务端认证得到的租户与账号应参与作用域,不能仅信任调用方随意填写的请求头。
记录过期后是否允许复用键,也属于接口契约。保留期至少覆盖客户端重试、消息重投、离线回放和人工恢复的有效窗口。资金或订单的业务唯一约束往往要比缓存中的短期去重记录保留更久。
超时会留下调用方的未知状态
“未知”首先描述调用方掌握的信息。服务端的事务可能已成功,也可能回滚。查询结果还受读副本延迟、请求是否仍在执行等因素影响;一次“未找到”需要结合查询来源和服务协议解释。
让账本和业务修改在同一事务提交
唯一约束决定谁执行第一次修改
下载 幂等账本与补偿实验。工程使用 Java 17/25、Maven 3.9.12、PostgreSQL 18.6 和 JDBC 42.7.13;数据库内有账户、操作记录和补偿记录三张表。
实验金额是整数单位,作用域固定为 demo,因此直接存储 amount 用于请求内容比较。实际接口还需要认证、币种、精度和完整请求指纹。
CREATE TABLE operation (
scope text NOT NULL,
op_key text NOT NULL,
amount integer NOT NULL,
state text NOT NULL,
receipt text,
PRIMARY KEY (scope, op_key)
);一次扣款按下面的顺序执行:
BEGIN(Read Committed)
INSERT 操作记录 ON CONFLICT DO NOTHING
├─ 插入成功:有条件地扣减余额 → 保存 receipt → COMMIT
└─ 已存在:读取原 amount 与 receipt
├─ amount 一致:返回原 receipt
└─ amount 不一致:409,结束事务两个事务同时插入相同主键时,唯一约束负责协调竞争。落败一方不能先扣款再判断冲突。使用 ON CONFLICT DO NOTHING 避免唯一键错误把事务置于错误状态,然后用下一条查询获取已提交记录;这些步骤在 Read Committed 下的可见性分别由语句快照决定。对应机制见 PostgreSQL INSERT 和事务隔离。
账户扣减还使用 balance >= amount 条件并检查更新行数。这样不同动作同时扣同一账户时,余额检查与修改仍在数据库内协调。只在 Java 中先读余额再执行无条件更新,会产生另一类并发错误。
幂等记录和扣款必须共同提交。如果先把键写入 Redis、随后写数据库,两个系统之间仍可能出现“键存在但扣款没发生”或“扣款成功但键丢失”。跨数据库、消息和外部支付要进一步采用事务发件箱、接收方去重或业务协调流程,从写入到一致状态介绍了这些状态传播关系。
在专用数据库运行正反实验
解压后进入 reliability-state,在 Linux Bash 中执行。需要 Docker Compose、curl,以及可使用 Docker 的当前用户。PostgreSQL 不发布宿主端口;初始化脚本创建非超级用户 lab,应用只能使用专用实验 schema。Compose 中的固定密码只用于本地隔离实验。
docker compose up -d --wait db
mkdir -p .m2-lab
docker run --rm --user "$(id -u):$(id -g)" \
--network reliability-state-demo_default --entrypoint mvn \
-e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
-e JDBC_URL=jdbc:postgresql://db:5432/reliability_lab \
-e JDBC_USER=lab -e JDBC_PASSWORD=local-app-only -e LAB_ALLOW_RESET=1 \
-v "$PWD:/work" -v "$PWD/.m2-lab:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
-B -Dmaven.repo.local=/m2 -Duser.home=/tmp clean verifyLAB_ALLOW_RESET=1 允许集成测试清空这三张实验表并把余额设为 100;不要连接已有业务数据库,也不要在服务接受其他请求时执行测试。测试使用真实 HTTP、独立 JDBC 连接和真实事务,按顺序得到:
lostResponse committedBalance=90 replaySameReceipt=true payloadConflict=409
beforeCommitFailure=500 operationAbsent=true balance=90
concurrentAttempts=2 operationRows=1 balance=80
refundAttempts=2 refundRows=1 missingOriginal=404 newChargeBalance=85JUnit 报告两项测试通过:一项贯穿提交后丢响应、同键重放、金额冲突、提交前回滚、并发请求、重复补偿和后续新动作;另一项在真实 JDBC 连接关闭后触发回滚失败,检查原始异常仍被抛出、回滚异常进入 suppressed。把 Maven 镜像改成 maven:3.9.12-eclipse-temurin-25 可以用 Java 25 重跑,数据会重新初始化。
连接失败时,先看 docker compose ps 和数据库健康检查;认证失败时核对环境变量及初始化卷是否来自本次工程。JDBC 连接、socket 和数据库语句均设置了有限等待,相关参数见 pgJDBC 连接设置。
亲自观察“超时,但已提交”
测试完成后余额为 85。启动应用,先确认真实接口可访问:
docker compose up -d app
BASE='http://127.0.0.1:19191'
ready=0
for i in $(seq 1 30); do
if curl -q --noproxy '*' --fail-with-body --max-time 2 -sS \
"$BASE/balance"; then ready=1; break; fi
sleep 0.2
done
test "$ready" = 1首次运行下列命令使用 curl-demo-1。实验服务先提交扣款,再延迟 1500ms 写回响应;curl 最多等待 500ms。delay 是该实验专用故障开关。
transport=0
curl -q --noproxy '*' --max-time 0.5 -sS -X POST \
"$BASE/charge/curl-demo-1?amount=10&delay=1500" || transport=$?
test "$transport" = 28
docker compose exec -T db psql -U lab -d reliability_lab -At -c \
"SELECT balance FROM account WHERE id=1;
SELECT op_key,amount,state,receipt FROM operation WHERE op_key='curl-demo-1';"应看到 curl 超时,数据库却已保存以下内容:
75
curl-demo-1|10|CHARGED|receipt=curl-demo-1 amount=10再用同键同金额请求,应返回原回执,余额仍为 75:
curl -q --noproxy '*' --fail-with-body --max-time 3 -sS -X POST \
"$BASE/charge/curl-demo-1?amount=10"
curl -q --noproxy '*' --fail-with-body --max-time 3 -sS "$BASE/balance"同键改成金额 11 是预期失败。这里保留 curl 传输退出码,再独立检查 HTTP 409,避免把连接失败当成业务拒绝:
transport=0
http=$(curl -q --noproxy '*' --max-time 3 -sS \
-o conflict.txt -w '%{http_code}' -X POST \
"$BASE/charge/curl-demo-1?amount=11") || transport=$?
test "$transport" = 0 && test "$http" = 409
cat conflict.txt成功路径使用 --fail-with-body,故意验证 409 的路径则显式判断状态;-q 与 --noproxy '*' 隔离客户端配置和代理。选项与退出码见 curl 手册。重复执行同键命令只重放原扣款;要观察一次新的扣款,需要更换动作键并重新计算预期余额。
只对有恢复意义的失败重试
重试条件同时考虑错误和业务语义
| 结果 | 常见处理 | 需要确认的条件 |
|---|---|---|
| 参数格式错误、权限拒绝 | 修正请求或身份,停止原样重试 | 认证更新后是新的有效尝试,不能无限刷新凭据 |
| 同键不同内容 409 | 查询原操作,纠正键或业务意图 | 避免自动换键绕过冲突 |
| 暂时连接失败、选定的 5xx | 在剩余预算内有限重试 | 接口允许安全重放,且依赖仍有余量 |
| 429 或带 Retry-After 的响应 | 按服务端节奏稍后尝试 | 延迟超出当前 deadline 时结束本次同步等待 |
| 事务序列化失败、死锁回滚 | 重做整项事务计算 | 清理旧事务,不能只重发最后一条 SQL |
| 超时且写入结果未知 | 查询原动作或同键重放 | 查询来源可靠、服务端仍保留该动作记录 |
状态码只是分类输入。某个 500 可能发生在事务提交之后;某个 503 也可能来自本地保护层,继续立刻重试会把压力送回同一资源。明确列出允许重试的异常或结果,通常比“除少数错误外全部重试”更容易维护。
Retry-After 可以是等待秒数,也可以是 HTTP 日期,客户端要处理解析失败和时钟偏差,并把它与剩余等待预算比较。具体语义见 RFC 9110 Retry-After。
限制总尝试次数,给退避增加随机性
最大尝试次数应明确是否包含第一次。Resilience4j maxAttempts=3 表示首次加最多两次重试,它也支持按结果、异常和尝试序号配置策略,见 Retry 文档。
指数退避的一个常用计算方式是先得到 cap = min(maxDelay, baseDelay × 2^retryIndex),再从 [0, cap] 中随机选择本次等待时长,即 full jitter。这样多个客户端同时遇到故障时,重发不会全部卡在同一时间点。计算时还要限制指数增长避免数值溢出,并检查退避后是否仍有足够时间执行。
下一次尝试须同时满足:
错误可重试
动作可安全重放
尝试次数未耗尽
总 deadline 尚有余量
重试流量配额允许在网关、业务客户端和 SDK 各设三次尝试,一次外部调用可能放大为九次乃至二十七次下游访问。为同一个同步动作选定清晰的重试承担层,并检查 SDK 隐含策略;异步消息的持久重投可以另有自己的协议和生命周期,不必强行要求整个系统只存在一个重试器。实际放大与共享资源实验见依赖治理与雪崩防护。
把补偿作为可以重复执行的业务操作
补偿保留历史,并改变当前业务状态
已经提交的扣款不能再靠原事务 ROLLBACK 撤销。退款需要一项新的、引用原扣款的业务操作:检查原扣款,增加余额,保存退款记录,再把原操作标记为已补偿。
实验补偿事务先锁定原操作行,补偿表以 (scope, op_key) 为主键。只有首次插入补偿记录的事务增加余额;重复请求直接返回已有结果。这样两次退款请求不会增加两次余额。
curl -q --noproxy '*' --fail-with-body --max-time 3 -sS -X POST \
"$BASE/refund/curl-demo-1"
curl -q --noproxy '*' --fail-with-body --max-time 3 -sS -X POST \
"$BASE/refund/curl-demo-1"
docker compose exec -T db psql -U lab -d reliability_lab -At -c \
"SELECT balance FROM account WHERE id=1;
SELECT state FROM operation WHERE op_key='curl-demo-1';
SELECT count(*) FROM refund WHERE op_key='curl-demo-1';"按前面流程首次执行,应得到余额 85、状态 COMPENSATED、退款记录数 1。原扣款回执仍保留,再次重放原扣款会返回历史回执,不会悄悄再扣款。当前是否已退款,应查询操作状态;历史回执和当前状态应在接口设计中分别表达。
缺少原操作的退款返回 404。真实退款还可能受结算状态、已退款金额、币种、退款窗口和外部渠道限制,一笔原操作也可能允许多个有独立退款键的部分退款。这里的一次全额补偿只是最小事务实现。
跨服务补偿需要可恢复的进度
订单涉及库存、支付和配送时,各步骤分别提交,补偿顺序要根据业务依赖决定。已经发出的短信无法“未发送”,已发货商品可能只能走退货流程。补偿也可能超时或失败,因此要保存已完成步骤、剩余步骤和每一步的动作键,支持重试与人工介入。Azure 补偿事务模式说明了这种业务逆操作和传统回滚的差异。
协调者崩溃后应从持久记录继续,而非从内存中的第一个步骤重新推断。对外部渠道返回未知结果,先用渠道支持的原业务标识查询;如果无法查询或安全重放,就需要暂缓自动操作并核对账务。
从异常记录继续恢复
发现重复扣款时,先按动作键检查记录数量和余额变更,再查作用域是否稳定、业务唯一约束是否存在、记录是否过早过期。发现“长时间执行中”时,检查持久任务、锁等待和执行者是否仍存活;抢占旧任务要有租约或 fencing 等协调方式,不能只按一条旧时间戳直接并发重做。
故障处理结束后,用一项新业务动作检查锁和连接能否继续使用、余额能否正确更新。实验最后的新扣款应把余额降到 85。
实验结束可执行 docker compose down 停止应用和数据库并保留卷。确认不需要其中的实验账本后,才使用 docker compose down -v 删除这个 Compose 项目的数据卷;删除后无法再查询本轮交易记录。
权威资料与规范地址
- RFC 9110:HTTP 方法的幂等语义和重试条件。https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods
- RFC 9110:Retry-After 格式及含义。https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after
- PostgreSQL 18 INSERT:唯一约束、ON CONFLICT 与返回结果。https://www.postgresql.org/docs/18/sql-insert.html
- PostgreSQL 18 事务隔离:并发语句的可见性。https://www.postgresql.org/docs/18/transaction-iso.html
- pgJDBC:连接、socket 和数据库会话设置。https://jdbc.postgresql.org/documentation/use/
- Resilience4j Retry:最大尝试数、分类和退避配置。https://resilience4j.readme.io/docs/retry
- Azure Architecture Center:补偿事务模式。https://learn.microsoft.com/en-us/azure/architecture/patterns/compensating-transaction
- curl 手册:客户端状态、HTTP 错误与传输退出码。https://curl.se/docs/manpage.html
