重试时临时网络问题与永久失败在日志中看起来完全相同,文章介绍了幂等性 Key、idempotency token 等机制,让重试决策可被观测和信任。
重试的核心问题在于:无论是在短暂网络抖动后恢复正常,还是陷入永久性失败的死循环不断消耗预算,重试的表象看起来完全相同。没有对重试过程的可见性,你的重试逻辑实际上是在猜测。
幂等键(idempotency key)是附加在请求上的唯一令牌,告诉系统"如果你之前见过这个请求,直接返回缓存结果,而不是重新执行操作"。它并非直接解决重试问题——它是源自一条更深层的规则:只有当操作具备幂等性时重试才是安全的,而幂等性只有在操作被稳定标识符标记后才能被验证。
import uuid, time
class RetryableCall:
def __init__(self, operation_name, user_id, resource_id):
self.idempotency_key = f"{user_id}:{resource_id}:{operation_name}:{int(time.time() * 1000)}"
# ^ 在重试窗口内稳定(如1秒),间隔超过1秒的两次重试会得到不同的key
self.attempt = 0
def call(self, client):
self.attempt += 1
headers = {"X-Idempotency-Key": self.idempotency_key}
return client.do_work(headers=headers)
键的构造方式很关键。如果你的幂等键只是每个任务一个 UUID(而非每次尝试一个),那么同一任务的所有重试共享这个键——这正是你想要的。如果精确到毫秒级生成,那么间隔 100ms 的两次重试会得到不同的键,服务器会重复执行。稳定性窗口应该与你的重试窗口匹配:如果你最多重试 5 秒,键应该在 5 秒以上保持稳定。
这就是你告诉服务器"这是第 3 次尝试,但如果你缓存了第 1 次的结果,用那个"的方式。没有它,你的重试就是回放:服务器会真正执行两次操作。
一旦你掌握了幂等键,就需要追踪哪次尝试对应哪次执行。这就是重试尝试关联(retry-attempt correlation)的用武之地——它是一条日志追踪链,将一系列尝试串联起来,使人类(或监控系统)能够追踪单个逻辑操作经历重试的完整过程。
class CorrelatedRetry:
def __init__(self, logical_op_id):
self.logical_op_id = logical_op_id # 跨所有尝试保持稳定
self.attempt_sequence = []
def log_attempt(self, attempt_num, latency_ms, status, error=None):
entry = {
"logical_op_id": self.logical_op_id,
"attempt": attempt_num,
"latency_ms": latency_ms,
"status": status,
"error": error,
"timestamp": time.time(),
}
self.attempt_sequence.append(entry)
# 发送到日志 / 追踪系统
logger.info("retry_attempt", extra=entry)
# 示例用法:
logical_id = str(uuid.uuid4())
retry_tracer = CorrelatedRetry(logical_id)
for attempt in range(1, max_attempts + 1):
try:
start = time.monotonic()
result = call_downstream()
latency = (time.monotonic() - start) * 1000
retry_tracer.log_attempt(attempt, latency, "success")
return result
except Exception as e:
latency = (time.monotonic() - start) * 1000
retry_tracer.log_attempt(attempt, latency, "failed", str(e))
if attempt == max_attempts:
raise
这样做有什么收益?当一个请求在 3 次重试后失败,你可以问:"每次尝试失败的原因是相同的,还是各不相同?"如果三次尝试都得到相同错误(例如"速率限制:请在 60 秒后重试"),说明这不是瞬时故障——而是你遇到的真实限制。如果第一次超时而第二次成功,你就有了证据说明那个瞬时故障确实是瞬时的,重试确实起作用了。
日志成为你的调试面。当运维打电话来说"这个用户的事务失败了",你通过 logical_op_id 追溯,看到的不只是"失败:500",而是"尝试 1:3.2 秒后超时;尝试 2:2.8 秒后超时;尝试 3:上游速率限制失败"。
重试预算约束了重试的量。成本核算告诉你重试的内容——以及预算是否真的在保护你,还是你在钻空子。
class RetryBudgetObserver:
def __init__(self, budget_name):
self.budget_name = budget_name
self.total_attempts = 0
self.successful_retries = 0 # 带来成功的重试
self.failed_retries = 0 # 失败的重试
self.abandoned_retries = 0 # 未执行的重试(预算耗尽)
self.total_cost_usd = 0.0
def on_retry_attempt(self, cost_usd, eventual_success=None):
self.total_attempts += 1
self.total_cost_usd += cost_usd
if eventual_success is None:
self.abandoned_retries += 1 # 预算说不
elif eventual_success:
self.successful_retries += 1
else:
self.failed_retries += 1
def report(self):
roi = (self.successful_retries / max(1, self.successful_retries + self.failed_retries)) if self.successful_retries + self.failed_retries > 0 else 0
return {
"budget_name": self.budget_name,
"total_attempts": self.total_attempts,
"successful_retries": self.successful_retries,
"failed_retries": self.failed_retries,
"abandoned": self.abandoned_retries,
"roi": roi, # 我们执行的重试的成功率
"cost_usd": self.total_cost_usd,
"cost_per_success": self.total_cost_usd / max(1, self.successful_retries),
}
30% 的 ROI 意味着每三次重试尝试中有一次恢复了操作。5% 的 ROI 意味着你的预算在死胡同里燃烧——要么你的预算设置得太宽松,要么你在重试永远不会成功的事情。
每次成功成本(cost-per-success)指标是在生产环境中最重要的指标。如果每次成功重试的成本是 0.001 美元且成功率为 95%,预算运转良好。如果每次成功成本是 1.00 美元(因为你重试的是昂贵的操作),问题就变成了"这个成本比用户的替代方案便宜吗?"——在这种规模下,熔断器可能比重试预算更合理。
当这三个部分都连接起来后,可观测性面就变得活跃起来。你不只是记录"发生了重试"——而是发出结构化记录:
{
"logical_op_id": "550e8400-e29b-41d4-a716-446655440000",
"operation": "process_transaction",
"idempotency_key": "user:12345:transaction:1721779200000",
"attempt_sequence": [
{
"attempt": 1,
"status": "timeout",
"latency_ms": 30001,
"downstream": "payment_svc",
"error": "read timeout after 30s"
},
{
"attempt": 2,
"status": "timeout",
"latency_ms": 30002,
"downstream": "payment_svc",
"error": "read timeout after 30s"
},
{
"attempt": 3,
"status": "rate_limit",
"latency_ms": 245,
"downstream": "payment_svc",
"error": "429: too many requests"
}
],
"budget_name": "user_transactions",
"budget_decision": "abandon_further_retries",
"cost_usd": 0.0023,
"eventual_outcome": "failed"
}
这条记录讲述了一个故事:"同一操作遭遇了两种不同的失败——先是超时(瞬时的?),然后是速率限制(硬限制)。我们停止了重试。成本:0.0023 美元,结果:失败。"阅读这条记录的人可以判断:"速率限制在两次超时后触发——如果我们等待更长时间,可能就成功了。或者:超时不是瞬时的,而是级联故障的症状——等待没有帮助。"
从中产生的指标(每个重试预算的成功率、每次成功成本、放弃时间)成为控制信号。当每次成功成本攀升超过阈值,意味着你的预算在追逐无法解决的失败——是时候收紧它了。当成功率下降,意味着你的瞬时故障假设是错误的——你以为会过去的错误并没有过去。这正是 measuring-success 那篇文章所阐述的:如何构建这些指标使其在生产环境中保持可用。
这就是让重试决策变得可信的方式:你对其进行充分instrumentation,让日志自己告诉你决策是否有效。