善
善·爱 ShanYi·AI
系统架构 · 开源造物
← 返回文集与随笔
SHANYI MANUSCRIPT · eleccloud-two-tier-retry
撰文:善意·爱 (ShanYi·AI) · 2026-10-31 10:02

给每一次失败一个确定的归宿:ElecCloud 两级重试设计

凌晨两点十七分,一笔退款请求返回了 504。重试真正的难点,从来不是「再来一次」,而是「失败了之后,谁还记得这件事」。两级时间协同,单向网络拓扑,PRE_SUBMIT 崩溃自愈。
给每一次失败一个确定的归宿:ElecCloud 两级重试设计

凌晨两点十七分,一笔退款请求返回了 504。

调用方已经按下重试,业务方在群里问这单为什么还没到账,而真正需要被问出口的那个问题,此刻还没有人说:这条已经发出、可能已经在对方系统里生效、又可能根本没落地的请求,眼下到底算什么状态?

做过后端的人都熟悉这个瞬间。它不刺激,不戏剧,只是安静地卡在某个地方,然后被各种临时手段一路推着往前,直到某天推不动了。


一、重试是最容易写错的十行代码

先把最常见的那十行摆出来:

for (int i = 0; i < 3; i++) {
    try {
        paymentApi.pay(orderId);
        return;
    } catch (Exception e) {
        Thread.sleep(1000);
    }
}

读起来没有问题,跑起来也能跑。但这十行里藏着四件事:

第一,sleep 占住的是 Tomcat 的业务线程。 每次卡一秒,三次就是三秒。下游一慢,线程池在几十秒内见底,然后整个服务开始拒绝它本来完全能处理的请求。一次下游抖动,被放大成一次全站雪崩。

第二,进程退出,重试的上下文一起消失。 内存里那个 i 不会留下来,正在等的这条请求也不会。

第三,集群三个实例会同时重试。 同一笔退款被扣三次,然后你在财务对账时第一次见到它们。

第四,间隔固定一秒。 下游正在限流,你恰好在它最痛的时候,用最规律的节奏反复敲门。

Spring Retry 解决了第一个——它可以配 backoff,可以不那么粗暴。但它没有解决后面三个,因为它的世界边界是单个 JVM。而分布式系统里,「失败」这个词的定义,本身就已经跨出了 JVM。

所以我对这件事的判断是:

重试真正的难点,从来不是「再来一次」,而是「失败了之后,谁还记得这件事」。

一次重试是动作。一段可恢复的重试,是状态。这两者的距离,比大多数人以为的远。


二、中心化重试平台,在容器时代撞上了一堵墙

先公道地说,中心化重试平台是一类值得尊重的设计:任务集中存储,到点由服务端反向 HTTP 回调业务接口,谁在重试、重了几次、卡在哪一步,全在一个后台里看得清清楚楚。

它有一个前提条件,而这个条件很少被写进架构文档里:

服务端必须能访问业务服务。

在物理机和固定 IP 的年代,这几乎是免费的。到了容器时代,它开始变贵:Pod 重建之后 IP 换了;跨 VPC 之后路由不归你管;至于内网安全隔离的环境,这条反向链路根本不存在。

这里有一件事我想说透,因为它比技术选型更根本:

网络拓扑,本质上就是权限拓扑。 「谁能反向访问谁」,在企业内网里从来不只是配置文件里的一行,它是安全评审、是防火墙策略、是审批流。一个要求「调度中心必须能打到你每一个业务容器」的组件,在很多环境里连上线评审都过不了,技术上再优雅也没用。

ElecCloud 的答案是把这个控制关系再反转一次:

服务端退化成只负责存取的数据节点,调度权下放给客户端 SDK。SDK 把延时任务投进 Redis ZSET 或 RabbitMQ 的延时队列,到期后自己取、自己反射执行。整条链路的方向变成「业务 → 平台」,只需要单向连通。

调度权放在哪一侧,决定了需要几条网络通路

三条实线是必须的,那条红色虚线是被主动砍掉的。砍掉它省下的不是带宽,是一整轮安全评审。

ElecCloud 控制台仪表盘:集群多节点运行拓扑

控制台首屏,把 Retry Server、SDK 消费集群、Redis 延时引擎、MySQL 持久层四段链路的健康状态并排摊开。判断一个分布式组件好不好用,先看它的首屏敢不敢把拓扑直接给你。

代价我不打算藏:

  • SDK 是代码,跟着业务版本走。 升级意味着发版,不像纯服务端方案那样改个配置就生效。
  • 服务端失去了「精确控制某一次执行」的能力。 它只能观测和干预状态,不能替业务执行。
  • 每个服务要维护自己的队列名做隔离。 命名这件事一旦随意,日后排查会很难受。

这三条是真实的成本。一个只有优点没有代价的架构描述,通常意味着作者还没上线过。


三、两级时间:快与慢必须分开算

如果只允许我保留 ElecCloud 的一个设计点,我会保留这个。

先看一个我认为被严重低估的工程常识:

不要让工具的时间粒度,粗于问题的时间粒度。

一次 50 毫秒的连接重置,和一次持续四十分钟的支付网关故障,是两件性质完全不同的事。前者是网络在打个喷嚏,后者是下游进了 ICU。用同一套机制去处理,结果必然是两头都不对:为一次喷嚏写一条数据库记录、投一条 MQ 消息,是荒谬的写放大;而面对 ICU 病人,靠本地 Thread.sleep 硬扛,是拿线程池陪葬。

所以 ElecCloud 把时间分成两级:

@RetryableTask(
    sceneType = 1001,
    idempotentKey = "#orderId",
    localRetryTimes = 2,      // 第 1 级:本地快重试两次
    localIntervalMs = 200     // 间隔 200 毫秒
)
public void processPayment(String orderId) {
    paymentApi.pay(orderId);
}

第一级:本地、200 毫秒、两次。零 DB、零 Redis、零 MQ。 绝大多数瞬态抖动在这一层就地消失,方法正常返回业务结果,调用方甚至不知道刚才发生过什么。

第二级:本地耗尽之后自动升级。 任务入库、投进延时队列、按指数退避重新排期,同时把当前线程还给容器。

两级重试的时间轴

同一根时间轴上的两级:0 到 400 毫秒属于本地,之后交给持久化层。分界线不是人为设定的阈值,而是「值不值得为它写一次盘」这个成本判断。

这个结构在生活里有个精准的对应:门诊与住院。

鼻血流一下,门诊处理完就回家,不需要办住院手续、不需要建病历档案。真正需要的是持续观察和排程治疗的,才建档、才排床位、才进入那套有记录可查的流程。你不会为一次鼻血走完住院流程,也不该让重症患者在门诊长椅上躺三天。

大部分重试系统的问题,就在于它只有其中一套流程。


四、预提交:把最危险的那一毫秒提前

最阴的失败不是抛异常。抛异常是好事,它至少给了你一次写日志、改状态的机会。

最阴的是进程根本来不及留下遗言。OOM Killer 挑中了你,容器被驱逐,机器直接掉电——业务侧已经把请求发出去了,本地一个字都没写。重启之后,没有人知道那笔请求存在过。

常规做法是「事后补偿」:失败了再记一笔,然后补偿。但这套逻辑偷偷依赖了一个假设——进程能活到把失败写下来。线上环境里,这个假设经常不成立。

ElecCloud 换了个方向,叫 PRE_SUBMIT:在业务方法执行之前,先把一条 INIT 状态的记录落进数据库。

  • 执行成功 → 状态改为 SUCCESS
  • 执行中进程死了 → 那条 INIT 还躺在库里,扫描任务会把它捡回来

差别就一句话:事后补偿假设进程会活,预提交假设进程随时会死。后者才是生产的真相。

代价同样明确——每次执行多一次写。所以它不该是全局默认,只该用在「重复执行会造成真实损失」的方法上:退款、扣款、发券、扣库存。同步缓存这类天然幂等的操作,走零 Hook 的普通模式就够了。

工程判断力,很多时候不体现在你加了什么,而体现在你知道哪些地方不该加。


五、真正的敌人不是失败,是重复

分布式系统里让人半夜惊醒的,从来不是「这次失败了」,而是「这次到底成功了几次」。

同一笔订单被两个实例同时捡起来执行,是这类系统最经典的翻车方式。ElecCloud 在这里设了两道闸:

第一道,唯一索引。 (scene_type, idempotent_key) 上的唯一约束,从数据库层面直接拒绝第二条记录进入。这不是应用层的 if 判断,是存储层的物理拒绝,绕不过去。

第二道,CAS 抢占。

UPDATE retry_task
SET status = 'EXECUTING', ...
WHERE id = ? AND status IN ('INIT', 'WAIT')

把状态从 INIT 或 WAIT 抢到 EXECUTING,改到行的那一个实例才算抢到。没改到就是没抢到,干净退出,不吵不闹。这是一种无锁的分布式互斥——没有分布式锁的开销,也没有锁服务挂掉之后的连环事故。

任务监控:重试任务生命周期执行流

任务监控页。RT1790071146466018e954 这类任务号、场景 #10 到 #12、业务幂等键、当前状态,一行一条,全部可追。分布式系统里「可观测」不是锦上添花,它是排查时唯一的抓手。

再往上,是业务语义层的防重——三阶段 Hook:

checkStatus → doQuery → doCallback

先去问下游「这件事到底成没成」,再决定要不要重做,最后做本地回调落账。很多所谓的重试事故,根因不是重试本身,而是不知道上一次到底成功了没有。把「先问再动」写成流程,比把重试次数从 3 调到 5 有用得多。

底层还有三层兜底:MQ 消息丢了 → 数据库扫描补;EXECUTING 状态僵死 → 超时自愈;扫描也失效 → 由分布式锁保证同一个扫描任务不被多节点重复执行。

这套设计不好看,也不聪明。但它每一层都对应一种真实发生过的故障模式。


六、四个退避策略,和一份「不做什么」的清单

退避策略给了四种:CUSTOM、FIXED、LINEAR、EXPONENTIAL。

大多数场景选指数退避,但有个细节必须配:jitter,抖动。如果不加随机扰动,一批在同一秒失败的任务,会在同一秒一起去重试,然后一起失败,一起再重试——这就是惊群。它看起来像「下游扛不住」,实际上是你的重试系统自己制造了一次小型 DDoS。

场景配置

场景配置页。一个 sceneType 对应一套退避策略和重试上限,业务侧只认这个编号,不用关心底层是 Redis 还是 RabbitMQ。

同样重要的是它不做什么,这份清单我想写得比功能列表更认真:

  • 不做 cron 批处理调度。 那是 XXL-JOB、ElasticJob 的地盘,它们做得很好,没必要抢。为每一次方法级的偶发失败去配一个 JobHandler,运维成本高得离谱。
  • 不做大而全。 没有 DAG 依赖,没有分片,没有工作流编排。一旦开始做这些,它就会变成另一个需要专职运维的中间件。
  • Standalone 模式有真实代价。 纯 SDK + 本地库,不需要任何外部服务,五分钟能跑起来;但集群多节点之间的协同也就没了,它只适合单体或小规模场景。
  • RabbitMQ 通道依赖延时插件。 选它之前,先确认运维愿意装。

架构上的克制不是道德品质,是维护成本的计算结果。一个开源项目最怕的不是功能少,是功能多到没人敢升级。


七、都江堰:水不会消失,只能被引导

公元前 256 年,李冰在岷江上修都江堰。

他没有筑一道坝把水拦住。他用鱼嘴把江水分成内江和外江,用飞沙堰在汛期把多余的洪水和泥沙甩回外江,用宝瓶口把真正需要的那部分水引进成都平原。三个工程,一个思路:

水不会消失,只能被引导。

这套东西用了两千两百年,今天还在灌溉一千万亩农田。它治的从来不是「水」,治的是「不可预测」这件事本身。你没办法让岷江不涨水,你只能让它在涨水的时候,有一个确定的方向、一个明确的出口、一条算得清的边界。

重试是同一件事。

下游会超时,网络会抖,进程会死,容器会被驱逐。这些不会因为你代码写得更认真就消失,也不会因为你在评审会上强调「这个接口很重要」而手下留情。工程能做且唯一能做的,是让每一次失败都有方向、有边界、有归宿。

项目叫电子云,说的也是这个。

量子力学里,单个电子的位置测不准——就像分布式网络里那一次突发的抖动、那一次说不清原因的超时。但无数电子在核外形成的概率云,给了原子一个极其稳定的物理边界,让原子核在宇宙射线里活了下来。

微观上不可测,宏观上必确定。这不是一句文案,这是分布式系统唯一能站得住的承诺。


八、回到那笔退款

它最终到达了。

不是因为这套系统从不出错——它每天都出错。而是因为出错之后,系统还知道该去哪里把它找回来。

如果只留三句话:

第一,重试不是「再来一次」,是「记住这件事」。 动作会随进程消失,状态不会。

第二,架构的第一性问题往往不是性能,是连通性。 谁能访问谁,决定了什么方案有资格存在。很多方案的死因,在写第一行代码之前就已经注定了。

第三,最好的兜底不是更强的重试,是更早的记录。 把状态提前写下去,比事后补一百行补偿逻辑都管用。

源码在这里,Apache-2.0,欢迎读、欢迎提 Issue、欢迎拍砖:

https://github.com/chaoking320/eleccloud
<dependency>
    <groupId>io.github.chaoking320</groupId>
    <artifactId>retry-client-sdk</artifactId>
    <version>1.0.1</version>
</dependency>

一行注解就能接进去。如果它帮到了你,点个 Star 是最省力的感谢。

善意·爱 · ShanYi·AI

永远对世界保持善意与好奇。