每次发版前我都得手动翻一遍请求封装,一周两三次,烦得要命。不是怕改错,是怕那行 baseDelayMs * 2 ** attempt 又被人从老项目里复制粘贴回来。上个月我把里面两处动了,加起来不到二十行,这周下来抖动肉眼可见地少了。
第一个,退避那一行加随机。
原来的写法长这样,maxRetries=3、baseDelayMs=1000、timeout=2000,延迟是 baseDelayMs * 2 ** attempt——确定性的,1 秒、2 秒、4 秒,同一次故障里所有实例算出来的时间一模一样。一千个并发请求一起失败,它们就在 1 秒、2 秒、4 秒同时醒过来,第二波冲击比第一波还整齐。标准指数退避降低的只是单个客户端的重试频率,同步性一点没破,这事我理解了大半年才真的理解。
改成一行:
const delay = Math.random() * Math.min(cap, base * 2 ** attempt)
random(0, min(cap, base * 2 ** attempt)),就这么点东西。⚡这一下真正起作用的不是"退避得更久",是请求错开到达。AWS 拿 100 个竞争客户端做的模拟里,总调用量砍掉一半以上,完成时间还更快——看着反直觉,其实一点都不,排队的人不再同时挤同一扇门,门就好开了。
参数我顺手调了:baseDelayMs 从 1000 降到 200,加了个 maxCapMs=5000,timeout 还是 2000。base 给那么大是为了防雪崩,可你真正要防的是同步。至于 Equal Jitter 和 Decorrelated Jitter,我看了一眼就关了页面——Full Jitter 当默认足够,剩下的等我哪天真觉得不够用再说。为这个专门写个重试库我犹豫了半天,最后就是手写三行,回不去了。
第二个,给重试装个总闸。
tokens = 100, maxTokens = 100, retryCost = 10
普通请求每次加 1 个令牌,一次重试扣 10 个,长期重试量被压在总流量 10% 附近。这一下管住的不是"某次重试等多久",是"我这个进程一共还能重试几次"。前者是节奏,后者是总量,两码事。之前那个包装函数根本没有上限,重试起来是无限的。
有个坑得说:初始 tokens=100 意味着新进程一冷启动就能先花掉十个重试名额。评论区有人提过这个,我试了下确实——压测里新起的实例第一波就是重试潮。改成初始 0 或者一个很小的值。这条我一开始没注意,看到那条评论才回头改的。
预算放在哪,作者在回复里说得很清楚:放网关或者服务网格,别按客户端实例设。理由很直白,你按实例设 10%,autoscaling 拉到 30 个实例,总重试量就是三倍。这个我认同,但我们网关上还没完全收口,暂时是用实例级凑着,算个没解决的尾巴,先记着。
顺手说个更冤的:那段包装函数会重试所有错误,400、401 一样重试三次。401 重试三次不会变成 200,只会在日志里多三条假线索,把人往错的方向带。过滤只留瞬态传输错误和明确的 429、503,尊重 Retry-After。多层重试这事我现在是彻底不信了——5 层每层 3 次,一次 DB 超时放大成 243 倍,这不是吓人,是乘法。只在一层重试,其余层老老实实快速失败。
评论区有个叫 Nathan 的说的一点我抄走了:成功于第 2 次尝试的重试,会把可复现的故障伪装成偶发抖动。所以"第 N>1 次才成功"的请求我单独打了点,不计进正常成功率。以前这种修复在我的面板上就是一次"自愈",看着挺健康,其实故障被藏起来了!还有一条,幂等键直接实现成数据库唯一约束,让重复重试撞出可见冲突,比留下一条没人看得懂的状态残留强。
回本账:这行改完三分钟,下次抖动少一次就回本。老实说,如果你的调用链就一层、QPS 也低,不做预算也出不了大事,那就先只改 jitter 那一行,别一次全上。你们要是有更省事的写法,教教我。
