一个「只等 1 秒」的动作,怎么就把池子堵死了病根:这 1 秒里,线程在占着工位发呆JDK 9 塞给我们一个「定时闹钟」闹钟背后是谁在计时同一场实验,两种写法跑出来的差距能直接抄进项目的重试工具进阶一档:指数退避只改一个地方五个最容易踩的坑
线上有个接口偶尔超时,翻代码会发现里面写了段重试:调用第三方失败,就等 1 秒再试一次。看起来人畜无害——不就是等 1 秒吗。可正是这 1 秒,在并发一上来之后,能把整个线程池拖进泥里。
先把那段代码摆出来,它太常见了:
假设线程池里一共 4 个线程,同时来了 4 个请求,全都赶上支付方抖动失败。接下来发生的事情是这样的:
注意,这里没有死锁,也没有慢查询。线程什么都没干,但它占着工位不放——这才是最要命的地方。
Thread.sleep 的语义是「让当前线程暂停执行」。暂停的意思是它不消耗 CPU,但它也没有被还回线程池,池子记着的账上,这个线程仍然在执行任务。于是等待的这 1 秒,等于从池子里白扣掉一个名额。
我们真正想要的其实是另一种画面:等待期间,线程先交回去,让别人用;时间到了,再捞一个线程回来接着干。
差别就在这一念之间:一边是「睡着等」,一边是「放下活、定个闹钟、走人」。JDK 9 之后,第二种写法不用自己去造轮子,标准库里已经有一个现成的闹钟。
这个闹钟叫delayedExecutor,是 CompletableFuture 上的一个静态方法:
一句话说清它的作用:返回一个特殊的 Executor,交给它的任务不会立刻跑,而是等指定时间之后才跑。
要理解它,得先知道 Executor 原本长什么样。它其实小得可怜,只有一个方法:
平时我们见到的都是「提交即执行」的实现,而这个闹钟版本是第三种:
它一共三个重载版本,覆盖了绝大多数用法:
最短的一个例子是这样:
跑出来的结果,重点看时间戳:
第三行那个线程名值得记住:延迟到期后,任务是被丢进公共池执行的,不是主线程,也不是你原来那个池。
很多人第一次看到这里会犯嘀咕:既然说「零额外线程占用」,那到底是谁在掐表?
答案是JDK 内部一个专门的守护线程(Delayer),它不占你的业务线程池,只负责一件事——计时。时间到了,它再把任务推给你指定的执行器:
所以那句「零额外线程占用」是有前提的:不占的是你的用户线程池。等待期间,你的池子一个线程都没被拖住,可以照常接单。
光讲道理没意思,直接做对照实验。场景:一个只有 1 个线程的线程池,塞进两个都要重试的任务,前几次一律失败、后面才成功。
先看阻塞版:
任务 B 硬生生等了 3 秒才等到第一次进场机会。现在换非阻塞版,核心改动只有一处——把 sleep 换成「定个闹钟再回来」:
同样 1 个线程、同样的失败次数,两种写法的账完全不一样:
这里有个写法上的小机关值得点一句:runAsync(() -> {}, …) 里的空 lambda 是故意的。我们不需要它做事,只需要借用它的「延迟」效果,真正的活儿放在 whenComplete 里,等 1 秒后再提交回线程池。
上面那版写起来还是有点散。工程里更常见的做法是封装成一个通用工具:创建一个空的 CompletableFuture 当「承诺」,失败就定闹钟、递归再来,成功或超限就把承诺兑现或作废。
调用端一句就够了,retryAsync 返回的还是个 CompletableFuture,可以继续挂 thenApply、allOf 这些操作:
整个链路的时间轴摊开看很清爽:提交任务 → 失败 → 定闹钟交还线程 → 到点 → 再提交。中间那段等待,线程池是自由的。
真实世界的服务抖动,往往不是等固定 1 秒就能好。更稳的做法是让等待时间逐次翻倍——100ms、200ms、400ms、800ms,也就是常说的指数退避。用上面这套骨架,改动小得惊人:
比起一上来就猛敲对方接口,这种「越等越久」的节奏对下游更友好,也更符合服务恢复的真实规律。如果这套写法是给自己项目用的,还可以顺手加上重试次数上限和熔断,别让它无限拖下去。
这个方法本身不复杂,坑基本都在用法上。挑五个最常见的:
- 只创建不提交——单独写一句 delayedExecutor(1, SECONDS) 是没有任何效果的,它必须配合 runAsync / supplyAsync 使用,任务才会真的被排进去。
- 该用 runAsync 却用了 supplyAsync——supplyAsync 要求 lambda 有返回值,没有 return 会直接编译不过;不需要返回值就用 runAsync。
- 以为它会中断正在跑的任务——它只是个定时器,不取消、不打断任何已经在执行的任务,别把「延迟执行」理解成「延迟取消」。
- 延迟回调里又去 get() 阻塞——好不容易把线程放回去了,回调里一发 get() 又堵上,等于白折腾。链式操作请继续用 thenCompose 之类的异步写法。
- 不持有返回的 Future——主线程如果先退出,JVM 就关了,延迟任务根本没机会跑。至少把 Future 接住,必要时 get() 或 join() 等一下。
反过来,它也有明确的短板——只做「相对延迟」,不做「绝对时刻」,而且只延迟一次,不自动重复。下面这些需求,请换工具:
一句话给这个闹钟定位:它擅长「稍后再说」,不擅长「按时打卡」。
回到开头那个超时接口。同样的重试逻辑,把 sleep 换成 delayedExecutor,线程池就再也不会因为「等待」而排队了。这种改动的性价比很高——改一处,救一整池。
你在项目里是怎么写重试的?有没有被人吐槽过「sleep 太粗暴」?或者踩过 delayedExecutor 的什么坑?评论区说说,我挑几个有意思的展开聊~
热门跟贴