去年618大促期间,我们团队经历了一次令人印象深刻的线上事故。发版后的第二天早上,业务部门突然反馈商家未收到门店收货明细邮件,直接影响了门店收货业务的正常运转。作为技术负责人,我当时的第一反应是:这不可能啊,邮件发送任务明明已经稳定运行了大半年。
我们立即启动了应急响应流程,通过全链路日志追踪发现:发版过程中服务重启,导致正在执行的邮件发送任务被意外终止。更糟糕的是,这个任务既没有配置任何重试策略,业务代码中也没有进行异常检测和重试处理。这意味着任务失败后就彻底"躺平"了,既不会自动恢复执行,也没有触发任何告警,直到业务方主动反馈才发现问题。
这个事故给我们上了深刻的一课:在分布式系统中,任何认为"不会出问题"的环节,往往就是最薄弱的环节。
在排查问题的过程中,我们发现其实使用的EasyJob分布式任务调度系统本身就具备重试机制,只是默认处于关闭状态。这套重试策略基于三个核心参数构建:
这三个参数的组合形成了典型的指数退避(Exponential Backoff)策略。举个例子,配置F=10s,M=2,C=5时,重试间隔会按照10s、20s、40s、80s、160s的节奏递增。
为了验证这个机制的实际效果,我们设计了一个测试任务,强制让它执行失败。以下是实际的执行日志:
code复制21:45:29.990 [pool-1-thread-1] INFO - 开始执行发送邮件任务(首次执行)
21:45:40.204 [pool-1-thread-2] INFO - 第一次重试(间隔10.21s)
21:46:00.674 [pool-1-thread-3] INFO - 第二次重试(间隔20.47s)
21:46:41.749 [pool-1-thread-4] INFO - 第三次重试(间隔41.07s)
21:48:02.398 [pool-1-thread-5] INFO - 第四次重试(间隔80.65s)
21:50:43.008 [pool-1-thread-1] INFO - 第五次重试(间隔160.61s)
从日志中可以清晰看到,每次重试间隔基本符合F×M^(n-1)的指数增长规律。这种设计既能给系统恢复留出时间,又避免了固定间隔重试可能导致的"惊群效应"。
在实际配置时,我们发现EasyJob对参数有以下限制:
这些限制实际上反映了行业的最佳实践范围。比如乘数超过4时,几次重试后间隔时间就会变得过长(M=8时,第5次重试间隔已达4096秒);而重试次数超过10次后,总耗时往往超出业务可接受范围。
虽然EasyJob提供了内置的重试机制,但理解其原理后,我们可以自己实现一个更灵活的重试执行器:
java复制public class CustomRetryExecutor {
private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);
private final long initialDelay;
private final double multiplier;
private final int maxAttempts;
public void executeWithRetry(Runnable task) {
doExecute(task, 1);
}
private void doExecute(Runnable task, int attempt) {
executor.schedule(() -> {
try {
task.run();
log.info("任务在第{}次尝试时成功执行", attempt);
} catch (Exception e) {
if (attempt >= maxAttempts) {
log.error("达到最大重试次数{},任务最终失败", maxAttempts);
return;
}
long delay = (long)(initialDelay * Math.pow(multiplier, attempt-1));
log.warn("任务第{}次失败,{}ms后重试", attempt, delay);
doExecute(task, attempt + 1);
}
}, attempt == 1 ? 0 : calculateDelay(attempt), TimeUnit.MILLISECONDS);
}
private long calculateDelay(int attempt) {
return (long)(initialDelay * Math.pow(multiplier, attempt-1));
}
}
这段代码实现了:
在实现过程中,有几个关键点需要特别注意:
线程池管理:
异常处理:
资源清理:
第n次重试的间隔时间可以用公式表示为:
code复制interval(n) = F × M^(n-1)
其中:
总耗时则是各次间隔的累加:
code复制total_time = Σ(F × M^(k-1)) for k=1 to C
根据等比数列求和公式,可以简化为:
code复制total_time = F × (M^C - 1)/(M - 1) (当M≠1时)
基于数学模型和行业实践,我们总结出参数选择的经验法则:
| 参数 | 短期任务(<1分钟) | 中期任务(1小时) | 长期任务(>1天) |
|---|---|---|---|
| 乘数(M) | 1.5-2 | 2 | 1.5 |
| 重试次数(C) | 3-5 | 5-8 | 8-12 |
| 初始间隔(F) | 1-5秒 | 30-60秒 | 5-10分钟 |
典型场景示例:
我们调研了主流框架的重试默认配置:
| 系统/框架 | 默认乘数 | 默认重试次数 | 备注 |
|---|---|---|---|
| AWS SDK | 2 | 3 | 支持Jitter随机因子 |
| gRPC | 1.5 | 5 | 线性与指数退避可选 |
| Spring Retry | 无 | 3 | 需自行实现退避策略 |
| Kafka | 无 | 10 | 主要针对消费者 |
好的重试策略应该能根据系统状态动态调整。我们实现了基于负载感知的动态参数调整:
java复制public class AdaptiveRetryPolicy {
private double currentMultiplier;
public void updateBasedOnLoad(SystemMetrics metrics) {
if (metrics.getLoad() > 0.8) {
currentMultiplier = Math.min(originalMultiplier * 1.5, maxMultiplier);
} else {
currentMultiplier = originalMultiplier;
}
}
}
重试必须建立在幂等操作基础上。常见的实现方式包括:
例如支付回调的幂等处理:
java复制@Transactional
public void handlePaymentCallback(String orderId, String transactionId) {
Payment payment = paymentRepo.findByOrderIdAndTransactionId(orderId, transactionId);
if (payment != null && payment.isCompleted()) {
return; // 已处理过
}
// 处理支付逻辑
}
我们采用分层隔离策略:
完善的监控体系包括:
示例监控指标:
code复制task_retries_total{task="email_sending"} 5
task_last_failure_timestamp{task="email_sending"} 1672531200
坑1:忽视Jitter导致重试风暴
早期我们没有在重试间隔中添加随机抖动(Jitter),结果大量任务在同一时间重试,导致下游服务瞬间过载。修复方案:
java复制long delay = (long)(baseDelay * (0.9 + 0.2 * Math.random()));
坑2:无限重试耗尽资源
有个任务设置了C=Integer.MAX_VALUE,结果遇到持久性故障时不断重试,最终耗尽线程池。现在我们强制所有任务必须设置合理的最大重试次数。
坑3:忽略上下文传递
重试时丢失了原始请求的上下文信息(如认证token、跟踪ID等),导致下游服务拒绝请求。现在我们会自动携带原始上下文。
完善的测试方案应该包括:
示例测试用例:
java复制@Test
public void testRetryWithExponentialBackoff() {
RetryPolicy policy = new RetryPolicy()
.withDelay(1000)
.withMultiplier(2)
.withMaxAttempts(3);
long start = System.currentTimeMillis();
policy.execute(this::flakyOperation);
long duration = System.currentTimeMillis() - start;
assertTrue(duration >= 3000); // 至少经历1s+2s的等待
}
虽然本文主要讨论定时任务的重试,但这些原则同样适用于分布式事务中的补偿机制。两者的核心思想是相通的:
在Saga模式中,每个参与服务都需要实现补偿操作,这些补偿逻辑同样需要遵循本文讨论的重试原则。