在网络请求或数据库操作中,设计错误恢复策略的核心在于区分“可重试”与“不可重试”错误,并防止重试行为导致系统级雪崩。
一个成熟的防御式重试策略通常包含:识别、退避、抖动和熔断。
1. 识别:哪些错误值得重试?
不是所有失败都应该重试。
-
可重试错误:
- 网络波动:如超时(Timeout)、DNS 解析失败。
- 服务端繁忙:HTTP
429 Too Many Requests或503 Service Unavailable。 - 数据库死锁/连接超时:如
SQLite_BUSY或临时连接丢失。
-
不可重试错误:
- 逻辑错误:
404 Not Found、401 Unauthorized(需重新登录)、400 Bad Request。 - 数据损坏:数据库架构不匹配、违反唯一性约束。
- 逻辑错误:
2. 指数退避(Exponential Backoff)
如果服务器因过载而失败,立即重试只会加剧负担。指数退避通过增加每次尝试之间的等待时间来缓解压力。
公式:( 为当前重试次数)。
结合“抖动”(Jitter)
如果大量客户端在同一时刻失败并使用相同的退避逻辑,它们会在同一时间点再次发起请求(惊群效应)。通过引入随机波动(Jitter) ,可以将请求均匀分散。
防御式公式:。
3. 在 Swift 中的工程实现
利用 Swift 的 async/await,我们可以优雅地实现一个通用的重试包装器:
Swift
func retry<T>(
maxAttempts: Int = 3,
baseDelay: TimeInterval = 1.0,
task: () async throws -> T
) async throws -> T {
for attempt in 0..<maxAttempts {
do {
return try await task()
} catch {
// 检查是否达到最大尝试次数,或是否为不可重试错误
if attempt == maxAttempts - 1 || !isRetryable(error) {
throw error
}
// 指数退避 + 抖动计算
let delay = pow(2.0, Double(attempt)) * baseDelay
let jitter = Double.random(in: 0...(0.1 * delay))
let finalDelay = delay + jitter
Logger.log("第 (attempt + 1) 次失败,(finalDelay)s 后重试...")
try await Task.sleep(nanoseconds: UInt64(finalDelay * 1_000_000_000))
}
}
// 理论上不会执行到这里,由 throw 终止
throw URLError(.unknown)
}
4. 数据库特有的恢复策略:WAL 与事务回滚
数据库重试与网络请求略有不同,重点在于原子性。
- 事务重试:如果数据库返回
busy状态,通常意味着另一个写操作正在锁定。此时应重试整个事务块,而不仅仅是最后一条 SQL。 - WAL 模式(Write-Ahead Logging) :开启 WAL 可以允许并发读写,极大减少“数据库忙”报错的概率。
- 防御式自愈:如果检测到数据库文件损坏,重试无效。应设计自动触发逻辑,尝试从最近的备份中恢复,或者删除损坏文件并重新同步数据。
5. 高级模式:熔断器(Circuit Breaker)
当某个服务持续报错(如重试 10 次都失败)时,应暂时“断开”该服务的访问。
-
状态:
- Closed(关闭) :正常工作。
- Open(开启) :服务故障,直接返回错误,不再尝试请求。
- Half-Open(半开) :放行少量请求,若成功则恢复正常,否则重新断开。
总结:重试设计清单
- 限制最大次数:通常 3-5 次。
- 设置总超时:即使单次请求没超时,整个重试链路也应有截止时间(Deadline)。
- 识别幂等性:非常重要! 只有幂等请求(如 GET、PUT 更新、DELETE)可以安全重试。非幂等请求(如 POST 支付订单)在未确认服务器接收状态前禁止自动重试。
- 感知取消:重试期间必须检查
Task.isCancelled,防止用户退出界面后请求仍在后台无谓重试。