网络超时、客户端重试、消息重复投递或进程恢复,都可能让同一项任务被执行多次。常见误区是把“任务只投递一次”当成幂等方案:投递次数无法替代业务侧的去重与结果持久化。可靠做法是先定义哪些请求代表同一项业务意图,再用持久化状态控制并发、重试和结果回放。
先划清幂等边界
幂等键应标识一次业务操作,而不是某次 HTTP 请求、消息投递或执行尝试。客户端超时后发起的新请求、队列重新投递的消息,以及进程恢复后的任务,都必须能算出相同的键。
一个常见的键结构是:
租户标识 + 操作类型 + 业务单号
例如,按“租户、支付操作、订单号”生成键。不要把重试次数、请求时间戳或随机数放进键里,否则每次重试都会变成新任务。
同时保存请求内容的摘要。相同幂等键如果对应不同业务参数,通常应拒绝,而不是复用旧结果:
幂等键:tenant-7:charge:order-123
请求摘要:规范化后的金额、币种等参数的哈希
规范化很重要:字段顺序、无关元数据或格式差异不应意外改变摘要;金额、目标资源等业务字段则不能被忽略。摘要算法和规范化规则一旦用于生产,应保持稳定,必要时通过版本字段演进。
方案取舍:唯一键、状态表、状态机与锁
| 方案 | 适用条件 | 主要优点 | 主要限制 |
|---|---|---|---|
| 数据库唯一约束 | 业务效果能在同一数据库事务内完成,且只需防止重复写入 | 实现简单,数据库可直接仲裁并发冲突 | 不便于保存处理中状态、重放结果或管理复杂重试 |
| 幂等记录表 | 需要记录请求摘要、执行状态和可回放结果 | 可明确区分重复请求、处理中请求和已完成请求 | 需要设计记录保留、状态迁移和失败恢复 |
| 状态机 | 任务有多个阶段、需要补偿或人工介入 | 迁移条件和异常路径更清楚 | 状态与转换规则增加实现及维护成本 |
| 分布式锁 | 需要短时间协调共享资源的访问 | 可减少并行冲突 | 锁过期、进程暂停和故障恢复都会引入边界问题;锁本身不保存业务结果 |
这几种手段不必互斥。对于需要返回稳定结果的接口,常见做法是用唯一约束建立幂等记录,再用状态字段管理任务生命周期。锁可以作为并发控制的辅助措施,但不能替代幂等记录或业务数据上的唯一约束。
用持久化状态处理重复请求
最小记录表需要保存幂等键、请求摘要、状态、结果、尝试次数和租约信息。下面以 PostgreSQL 风格的表结构为例:
CREATE TABLE task_idempotency (
idempotency_key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('processing', 'succeeded', 'retryable_failed', 'failed')),
result_json JSONB,
error_message TEXT,
owner_token UUID,
lease_until TIMESTAMPTZ,
attempts INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
owner_token 标识当前执行者,lease_until 表示占用状态的有效期限。它们让崩溃后遗留的任务可以被重新领取;但租约只解决“谁可以尝试执行”,不能单独保证外部副作用只发生一次。
请求处理可以按以下步骤实现:
- 计算稳定的幂等键和请求摘要。
- 在短事务中尝试插入记录。若键已存在,则锁定该记录并比较摘要。
- 摘要不同则返回冲突;状态为
succeeded则回放已保存结果;状态为processing且租约未过期则返回“处理中”或要求稍后重试。 - 对可重试失败或租约已过期的记录,生成新的
owner_token,更新租约并增加尝试次数,然后提交领取事务。 - 执行业务操作。业务写入与状态改为
succeeded应尽量放在同一个数据库事务中,并在提交前确认owner_token仍属于当前执行者。 - 遇到可确定的临时错误时记录
retryable_failed;遇到确定不可恢复的业务错误时记录failed。异常退出导致状态未更新时,由租约过期后的恢复流程接管。
下面是关键领取逻辑的 SQL 结构示意。具体返回方式和事务封装应按所用语言及数据库驱动调整:
BEGIN;
INSERT INTO task_idempotency (
idempotency_key, request_hash, status, owner_token, lease_until, attempts
)
VALUES (
:key, :request_hash, 'processing', :owner_token, now() + :lease_interval, 1
)
ON CONFLICT (idempotency_key) DO NOTHING;
SELECT request_hash, status, result_json, owner_token, lease_until, attempts
FROM task_idempotency
WHERE idempotency_key = :key
FOR UPDATE;
-- 应用代码在持有行锁时判断:
-- 1. request_hash 不一致:回滚并返回冲突。
-- 2. status = 'succeeded':读取 result_json,回滚并回放结果。
-- 3. status = 'processing' 且 lease_until > now():回滚并返回处理中。
-- 4. 其余情况:更新 token、租约和 attempts,再提交。
COMMIT;
完成业务操作时,不能只按幂等键更新状态,还要校验当前执行者的令牌。否则旧执行者在租约失效后恢复,可能覆盖新执行者的结果。对于同一数据库内的业务写入,应在一个事务中锁定任务记录、确认令牌、写入业务数据并标记成功:
BEGIN;
SELECT status, owner_token
FROM task_idempotency
WHERE idempotency_key = :key
FOR UPDATE;
-- 应用代码确认 status = 'processing' 且 owner_token = :owner_token。
-- 令牌不匹配时不得提交业务写入。
-- 在此处写入业务数据;业务表也应设置适当的唯一约束。
UPDATE task_idempotency
SET status = 'succeeded',
result_json = :result,
owner_token = NULL,
lease_until = NULL,
updated_at = now()
WHERE idempotency_key = :key
AND owner_token = :owner_token;
COMMIT;
这样,业务数据变更和成功状态可以一起提交或一起回滚。若执行结果依赖数据库之外的服务,就不能靠本地事务把远端副作用和幂等记录原子化。此时应把同一幂等键传给下游,由下游去重;如果下游不支持幂等,则需要查询、对账或补偿流程来处理结果不确定的情况。不要把网络超时直接判定为“操作失败”:请求可能已在对端成功,只是响应没有返回。
重试策略要区分失败与未知结果
并非所有错误都适合重试。至少应区分:
- 可重试失败:明确发生在副作用执行之前的暂时性错误,可按策略重试。
- 不可重试失败:参数校验、业务规则等确定性错误,应记录原因并停止自动重试。
- 结果未知:请求已发出,但超时或连接中断,无法确定对端是否完成。应使用原幂等键查询或安全重试,不能换新键再执行。
重试应有次数或时间上限,并采用退避策略,避免大量任务同时失败后立刻集中重试。退避间隔可按指数增长并加入随机抖动;具体上限应根据任务时效、下游承载能力和人工处理流程设定。耗尽自动重试后,保留最后错误、尝试次数及任务标识,便于告警和人工恢复。
状态迁移也应有明确约束:已成功的记录不应被普通重试改回处理中;过期租约允许重新领取,但新执行者必须获得新令牌;永久失败若需要人工重新执行,应使用明确的管理操作和审计信息,而不是悄悄清除记录。
验证并发与故障恢复
幂等设计需要验证“多次请求后业务结果仍符合预期”,而不只是验证接口没有报错。至少覆盖以下场景:
- 并发冲突:用屏障让多条请求同时提交相同幂等键和相同参数。确认只有一个执行者取得有效租约,其余请求返回处理中或同一已完成结果。
- 参数冲突:并发提交相同幂等键、不同业务参数。确认冲突请求不会覆盖原始摘要或触发第二次副作用。
- 完成后重复请求:任务成功后重复调用,确认返回已保存结果,业务表没有新增重复记录。
- 执行中进程退出:在领取任务后终止进程,确认租约到期后任务可被重新领取,且旧令牌不能提交结果。
- 副作用后、状态更新前故障:在业务操作完成后注入进程退出或响应超时。确认数据库事务能将业务写入与成功状态一起回滚;若副作用在外部系统,则确认下游幂等、查询或补偿机制能识别这类不确定结果。
- 重试上限与永久错误:模拟临时错误和确定性错误,分别检查退避、停止条件、错误记录及告警路径。
结果校验应同时查看幂等记录和业务结果:记录状态、请求摘要、令牌变更、尝试次数、返回结果,以及业务表中的实际变更数量。线上还应监控处理中任务数量、过期租约数量、重复请求比例、失败重试次数和长时间未完成任务。这样才能发现“接口返回成功,但任务仍重复产生副作用”一类仅凭响应码难以识别的问题。
幂等的核心不是让执行过程永不重复,而是让重复请求落在同一业务边界内,并确保每次重试都能依据持久化状态判断:回放结果、等待当前执行者、重新领取,还是进入人工处理。
发布者:jacky,转转请注明出处:https://kubiyun.com/archives/4605
评论列表(7条)
租约时长一般怎么定,感觉这块挺看业务场景的。
@快乐的云朵:可以先按任务耗时的常见范围留余量,再配合续租和超时监控;租约太短容易误接管,太长则故障恢复会变慢。
外部服务超时后结果未知,这种情况通常怎么对账?
幂等键里不放时间戳,那摘要冲突概率会不会高?
退避间隔的随机抖动怎么取值合适?
@萤火虫之夜:一般用指数退避加随机抖动,抖动量取当前退避值的一部分就行,比如0到1倍之间随机。
owner_token 校验这块写得清楚,旧执行者恢复覆盖新结果确实是容易踩的坑。