1. 为什么说测试重试是一把双刃剑
做了这么多年CI/CD流水线,我见过太多团队在“测试挂了怎么办”这个问题上走极端。一边是测试稳定但一天到晚报红,开发随手点个rerun就绿了——没人知道这个测试到底还靠不靠谱;另一边是凡是失败就直接标红,结果一查全是环境抖动、依赖拉取超时、容器启动慢了那么半秒。最后CTO一拍桌子说“必须重试!”,于是大家在pipeline里加了个retry: 2,把问题全都盖住了,等上线之后才发现核心链路早就断了。
先说清楚我这篇文章要聊的事:CI/CD流水线里的测试失败,到底该“失败就重跑”还是“直接失败”?这个问题看起来只是个判断题,但真正做起来,它牵扯到重试的粒度、重试的窗口、重试的代价、重试与测试类型的关系,甚至牵扯到团队的工作流习惯和发布信心。适合项目里已经开始用CI/CD、但经常被flaky test(不稳定测试)折磨的团队参考,也适合正在搭建第一条自动化流水线的DevOps新人——看完之后你能直接按照自己的场景设计重试规则,而不是照抄一个retry: 2草草收场。
先说我的结论:无脑重试是懒政,无脑失败是低级;合理的重试策略本质上是“区分故障类型”的成本控制手段。
为什么这么说?因为CI/CD流水线里的测试失败,从来就不是单一原因。代码回归导致的断言失败、依赖服务没起来导致的环境问题、网络瞬时抖动导致的超时、容器镜像拉取速度波动导致的资源不足……这些失败的“可重试性”完全不一样。你把它们混在一起处理,要么过度掩盖问题,要么过度放大噪声。
我见过一个真实案例:某个团队在GitLab CI里对e2e测试配了retry: 3,结果有个下单流程的接口返回字段从total_price变成了subtotal,前端脚本断言炸了。第一次跑挂了,重试成功——因为后端接口返回里两个字段都保留了,只是前端拿错了字段。这个bug被重试策略掩盖了整整两周,直到某个用户报出“订单金额显示不对”,大家才去查日志。你猜怎么着?那两周里流水线的通过率一直显示95%以上,看着非常健康。这就是无脑重试的代价——它让CI/CD系统从“质量守门员”变成了“问题遮掩器”。
反过来,我也见过一个团队把重试完全关掉,任何测试挂了就直接红。结果是开发抱怨、运维崩溃、凌晨三点被电话吵醒,最后查出来是测试环境里一个中间件服务偶尔启动慢,完全跟业务代码无关。这种“直接失败”也不是真正的严格,而是把环境噪声和技术债混为一谈,让整个团队对红色的流水线麻木甚至免疫。
所以在往下聊具体配置之前,我希望你先建立一个认知:**重试策略不是“要不要retry”的问题,而是“在什么条件下、以什么代价、重试哪一层”的问题。**想清楚了这一点,后面那些参数配置都是水到渠成的事。
2. 重试策略的关键判断:什么值得重试
2.1 先给测试失败分个类
我习惯把CI/CD流水线里的测试失败分成三个大类,每一类的处理逻辑完全不同:
| 失败类型 | 典型特征 | 举例 | 可重试性 |
|---|---|---|---|
| 代码缺陷型失败 | 断言失败、编译错误、依赖冲突 | 接口返回字段不匹配、某个单测期望值不对 | 不值得重试,必须修复代码 |
| 环境依赖型失败 | 服务启动超时、端口占用、资源不足 | 测试容器起不来、数据库连接池满、磁盘空间不足 | 有限重试,通常1~2次,且需要观察失败率 |
| 瞬态网络型失败 | 超时、连接重置、镜像拉取失败 | npm install偶发失败、外部API超时 | 值得重试,但要限制时间和次数 |
这个分类的重要性在于,它决定了你重试的目标不能是“让流水线变绿”,而必须是“让绿色有意义”。
你可能觉得这是废话,但实际操作中绝大多数团队根本不去区分失败类型。GitLab CI和Jenkins默认都给你一个简单的retry参数,你填个数字就完事了。结果就是代码缺陷型失败和环境依赖型失败被一视同仁地重试,前者掩盖了bug,后者浪费时间。
2.2 代码缺陷型失败:永远不要重试
先说代码缺陷型失败。这类失败的特点是:**代码不变,重试再多次结果都一样。**比如你写了一个单元测试,断言add(2, 3)等于6,代码实现返回的是5,那它重试100次还是失败。再比如前端组件测试里某个DOM元素找不到,因为组件结构改了,那你重试也没用。
为什么有些团队还是会对这类失败启用重试?我猜有两个原因:第一是没有追踪每次重试的原因,看到red就rerun,侥幸心理作祟;第二是部分不稳定的测试确实会偶尔因为数据清理不干净而闪红,但团队没花时间去修根因,只能靠重试来掩盖,结果连真正的代码缺陷也被一起掩盖了。
我的建议是:**对编译错误、静态检查、单测断言失败这类“确定性失败”,关闭全部重试。**如果某个单测脚本你三天两头重试才能通过,那不是重试策略的问题,是测试本身就是个flaky test,你需要的是去修它,而不是给它擦屁股。
2.3 环境依赖型失败:有限重试 + 自动清理
环境依赖型失败稍微复杂一点。它往往不是确定性失败,而是跟跑的次数、跑的时机、环境状态有关。典型场景是:
- 测试环境里有一个共用数据库,上一次测试留下的脏数据导致这次测试插入数据失败了
- 容器化的测试服务在资源紧张时启动超过了等待时间
- 某个mock服务忘记重置状态,导致后续测试读取到了不对的响应
这类失败重试是有意义的,因为你清理一下环境、等资源释放之后,很可能就过了。但你需要注意两点:
第一,**重试次数不要太多。**环境问题大概率在第一次重试就能解决,连续重试3次还是失败,说明环境本身有大问题,再重试20次也没意义。我通常设定环境依赖型失败最多重试2次。
第二,**在重试之前做环境重置动作。**单纯的retry其实是不动环境的,它只会重新执行一次测试脚本。更好的方案是在重试块里加入清理步骤,比如删除临时文件、清空缓存、杀掉残留进程。GitLab CI的before_script其实就是干这个的。
2.4 瞬态网络型失败:该重试,但要有天花板
瞬态网络型失败是重试策略最合适的应用场景。网络这个东西,你完全没法保证100%稳定,尤其是在拉取外部依赖、调用第三方API、跨区拉取镜像的时候。连续两次都超时、第三次就很通畅,这种情况我相信每个人都遇到过。
这类失败值得重试,而且重试的性价比非常高——大部分情况下你重试1次就通过了,成本极低收益极大。但同样是“要重试”,它有几个细节你必须注意:
- 设置重试次数的天花板。我推荐1~2次,最多3次。超过这个数还失败,大概率不是网络抖动,而是目标服务不可用或者你的网络本身有问题。
- 加上超时控制。如果你的测试脚本本身没做超时限制,网络挂起时它会一直傻等,这时候重试几次都白搭。每个HTTP请求、每个数据库连接、每个子进程都要设置超时。
- 考虑退避策略。瞬态网络问题往往在短时间密集重试时仍然抖动,间隔稍微拉长一点更容易成功。GitLab CI里
retry不支持直接配置退避,但你可以在脚本层面用循环实现,或者放到Jenkins的retry块里手工加sleep。
2.5 那么问题来了:重试到底掩盖了什么?
我必须强调一个关键认知:**任何重试本质上都是“允许一次失败而不记账”。**你重试了,就意味着这次失败不会通知到任何人,不会落到报表里,不会触发告警。如果这个失败其实代表着一个真实的问题——哪怕它只是环境不干净导致的——重试成功之后,问题的根因仍然存在。下一次跑,它可能又失败了,你重试又通过了。循环往复,根因永远不被修复,流水线看起来永远健康,直到它拖垮某一次上线前的发布验证。
所以我在设计重试策略时始终遵循三条铁律:
- 可重试的失败必须记录原始错误日志。即使重试后通过了,也要有地方能查到那次失败的具体原因。
- 设置“重试后通过”的监控指标。如果一个测试经常需要靠重试才能通过,它本身就是一个风险信号,哪怕它最终没有导致流水线红色。
- 重试成功后应该触发一次“根因排查”任务,至少是给相关的测试负责人发一条提醒,让他知道“这个测试又flaky了一次”。
3. 流水线中的重试配置实操:从GitLab CI到Jenkins
3.1 GitLab CI中的retry配置详解
先以GitLab CI为例,这是我现在用得最多的CI/CD平台。它自带原生的retry关键字,用起来非常简单,但很多人不知道它支持精细化配置。
最简单的形式是这样:
test: stage: test script: - npm run test:unit retry: 2意思是这个job失败后最多自动重试2次,也就是总共会跑3次。这里有个细节:GitLab CI的retry数字表示额外的重试次数,不是总运行次数。retry: 2表示第一次跑失败后重试1次、再失败再重试1次,共3次机会。如果你想要“失败后只重试1次”,就写retry: 1。
进阶一点的用法是按失败类型区分重试。GitLab CI的retry可以配置为when条件,支持的字段包括:
test: script: - npm run test:unit retry: max: 2 when: - runner_system_failure - stuck_or_timeout_failure - api_failuremax是最大重试次数,when是触发重试的失败场景。比较常用的几个when字段:
| 字段 | 含义 |
|---|---|
always | 任何失败都重试(最不推荐,因为会掩盖代码问题) |
api_failure | 流水线API层面的错误,比如Runner执行指令失败 |
runner_system_failure | Runner系统层面的失败,比如机器挂了、环境清理失败 |
stuck_or_timeout_failure | job因为队列阻塞或超时而失败 |
job_execution_timeout | job执行超时 |
顺带说一句,GitLab CIwhen字段里有一个unknown_failure,指的是无法归类的失败,这类失败我建议就不要开重试了——你都搞不清为什么失败,盲目重试大概率也没用。
3.2 Jenkins Pipeline中的retry与超时
如果你用的是Jenkins,它的语法更像编程语言,灵活性更高,但同样也因为灵活而容易用错。
Jenkins的声明式pipeline里没有retry这种现成的指令关键字,通常是在stage里用retry块来包裹步骤,或者在options里设置全局的retry。我自己常用的写法是:
pipeline { agent any stages { stage('Test') { retry(2) { sh 'npm run test:unit' } } } }注意,Jenkins这个retry块跟GitLab CI有一点本质区别:**它在重试时不会重新调度整个job,而是在同一个workspace里重新执行一遍步骤。**这意味着你上一次失败留下的环境状态会跟着重试一起跑。如果测试脚本本身不清理环境,之前失败留下的脏数据很可能导致重试依然失败。
所以我通常会写成这样,在重试前做环境重置:
stage('Test') { retry(2) { sh 'rm -rf node_modules && npm ci' sh 'npm run test:unit' } }3.3 一个完整的“分层重试”配置示例
讲了两种平台的基础语法,我直接给你一套我线上正在用的分层重试方案,你可以把它当作一个模板来参考。
我的做法是把整个CI流水线按测试类型拆成三个阶段,每一层的重试策略完全不同:
# GitLab CI .gitlab-ci.yml 示例 stages: - static - unit - integration static-analysis: stage: static script: - npm run lint - npm run type-check retry: max: 0 # 静态检查类确定性任务,失败即失败,不重试 when: [] unit-test: stage: unit script: - npm run test:unit -- --runInBand retry: max: 1 # 单测本身应该稳定,最多容忍瞬态问题1次 when: - runner_system_failure - api_failure integration-test: stage: integration script: - docker compose up -d --wait - sleep 5 # 给服务一点启动时间,不要依赖固定的sleep,建议用healthcheck - npm run test:e2e after_script: - docker compose logs > integration_logs.txt 2>&1 - docker compose down retry: max: 2 # 集成测试受环境影响大,可以多给1次机会 when: - runner_system_failure - stuck_or_timeout_failure - api_failure这套配置背后的逻辑是这样的:静态检查和类型检查是纯确定性任务,代码不变、结果不变,重试毫无意义,所以retry: 0。单元测试理论上也应该是确定的,但为了容忍npm安装偶发失败这类问题,允许重试1次,且必须是Runner系统或API层面的失败——绝不因为断言失败而重试。集成测试受环境影响最大,允许重试2次,同样限定重试条件。
关于集成测试我多说一句:docker compose up -d --wait这里的--wait是docker compose v2开始支持的参数,会等待所有依赖服务达到healthy状态才返回,比之前常用的固定sleep 10可靠得多。你可以把初始化脚本、依赖服务健康检查都放到这一步,大幅降低环境依赖型失败的概率。
4. 重试策略的进阶设计:从单元测试到E2E,分级决策
4.1 不同测试层级的“重试容忍度”完全不同
很多人写流水线的时候,对测试的重试策略是一杆子到底的:单测开了重试,集成测试也开同样的重试,E2E测试还开同样的重试。但我的经验是,不同层级的测试,重试容忍度完全不一样。
**单元测试是定量测试,应该追求确定性。**单元测试的运行环境和逻辑都相对可控,一个重要特征就是“某个测试用例如果失败,同一段代码在相同条件下重跑,结果应该一致”。如果你发现你的单测经常需要重试才能通过,先别想着加retry,而是要去查是不是测试用例之间有共享状态、是不是没有清理缓存、是不是模拟器/真实浏览器在并行跑时互相干扰。单测这种层级的测试,重试应该是最少的,最好是0到1次。如果单测的flaky率都超过了5%,你整个测试体系的可信度都需要重建。
**集成测试呢?**它介于单测和E2E之间,重试容忍度可以放宽一点。因为集成测试涉及到多个服务之间的交互,有数据库、有缓存、有外部API,这些组件的偶发问题不是你能完全控制的。我一般允许集成测试重试2次,但同时会把“重试成功的次数”作为指标去跟踪。如果这个指标稳定增长,说明集成测试的环境稳定性在恶化。
**E2E测试(端到端测试)是最尴尬的层级。**一方面E2E的稳定性天然就差,因为环境因素太多了;另一方面E2E处于流水线末端,它一旦失败,直接阻塞发布。我见过有人给E2E一口气开5次retry,结果一个真实的产品bug被重试直到成功,然后坏版本被发布了。我的建议是,E2E测试的重试要分场景:
- 外部服务配置错误、环境变量缺失这类问题,不重试,直接失败,因为需要人工介入
- 浏览器渲染偶发超时、网络瞬时波动这类问题,允许重试1~2次
但最关键的是,E2E重试成功后,你不能只是“绿了”就完事。一定要把重试日志、截图、录像保留下来,方便事后排查。我记得Playwright自带的trace.zip和录制视频功能,其实就是为这种场景准备的。
4.2 动态重试:让重试次数不再是一刀切
固定的重试次数固然简单,但它有个明显的问题:不管失败类型、不管失败历史、不管当前流水线状态,一律重试同样的次数。在真实场景里,这种方式还是太粗暴了。
更精细的做法是基于历史失败率动态调整重试次数。比如某个测试在最近10次运行中从没失败过,那么它跑了1次失败时,你可以优先考虑“是不是环境瞬态问题”,重试1次就行;而如果某个测试最近10次里失败了6次,它就极可能是一个flaky test,你需要做的不是给它加更大的重试次数,而是把它拎出来单独分析。
具体实现思路其实不复杂:在流水线的第一步把测试分成“稳定测试”和“疑似不稳定测试”两组(可以根据最近的运行数据生成一个清单),对稳定组用默认的低重试甚至零重试,对疑似不稳定组则用稍高的重试次数,但同时在上面挂一个“警告标记”,告诉团队这个测试不可信。我在项目里用的就是GitLab CI的一个隐含能力:在script阶段里先读取一个记录最近100次运行结果的文件,然后根据该文件动态决定是否给当前测试注入重试参数。
这个做法的成本不高,但收益非常明显——它让“重试策略”从一种死板的配置变成了有自我感知能力的系统。
4.3 测试重试与发布门禁的联动
还有一个经常被忽略的关联点:重试策略跟发布门禁联动。
很多团队把CI/CD流水线跑完之后,只要结果是绿色的就自动进入发布流程。这时候如果你的重试策略很激进,比如E2E重试3次,那就相当于发布门禁自动被“软化”了——真实的产品缺陷可能被冲过门禁。我建议至少做两步:
第一,发布门禁不看的只是“最后绿不绿”,还要看“有没有重试通过的记录”。如果流水线里有任何一次测试是通过重试通过的,在进入发布阶段前必须有人确认“这次重试原因是环境问题,不是产品缺陷”。
第二,核心验证路径上的测试不能启用重试。哪些是核心验证路径?比如登录、下单、支付、数据安全相关的E2E测试,这些测试一旦失败,不管什么原因,都要标记为“需要人工确认”。哪怕最后确认是环境问题,也要让人手动触发一次重新运行,而不是自动重试。
我自己做过的方案是:接入CI过程中调一个webhook,往IM群里发一条提醒:“注意:本次流水线测试单元test_login曾因超时失败,已自动重试成功,请确认是否需要排查”。有了这层人工确认,重试就不再是“无痕操作”,而是有审计痕迹的容错机制。
4.4 微服务架构下的重试策略要考虑依赖关系
如果你的项目是微服务架构,重试策略还要考虑服务之间的依赖关系。这一点我在实际工作中踩过不少坑。
微服务流水线通常会遇到“服务A测试通过,但集成测试因为服务B的偶发故障失败”。这种情况下,如果你对服务A的流水线只做简单的重试,那是对时间的浪费——因为问题根本不在服务A,怎么重试服务A都没用。比较合理的方案是把重试目标从“测试本身”转移到“依赖服务健康”。在集成测试阶段,先检查依赖服务的健康状态,如果服务B异常,就重试等待并重新拉起服务B,等它恢复之后再跑测试。这和“重跑测试”的效果一样,但处理逻辑上清晰得多。
另外一个跟依赖相关的点是:**多服务并行测试时,不要让所有服务同时重试。**因为同时重试容易导致资源争抢再次触发失败,形成“重试风暴”。我给每个服务设置不同的重试延迟:服务A失败后等10秒重试,服务B失败后等30秒重试。虽然GitLab CI本身不提供延迟重试配置,但你可以把它写进测试脚本里,比如加一个sleep $((RANDOM % 30))先做个随机退避,再开始重跑。
5. 常见问题与避坑实录
5.1 flaky test是重试能解决的吗?
这是我最想纠正的一个认知误区。重试不是flaky test的解决方案,它只是临时止痛药。我之前在GitHub上看到一个项目,他们统计过自己的测试套件,发现大约有8%的测试用例在过去的100次运行中出现过至少一次失败。按理说,这种测试应该被找到根因、修复或者标记为“不稳定”,但他们选择了对流水线整体开3次重试,于是一切都“看起来很好”。
直到某一天他们把重试关掉后测试了真实发布流程,发现自己需要修复的“隐藏测试缺陷”多达40多个。这些缺陷平时全被重试掩盖了。所以如果你发现自己团队频繁靠重试才能通过测试,请立即做两件事:
- 持续记录每一个“第一次失败但重试通过”的用例,整理成一份flaky list。
- 每两周集中清理一次flaky list里的测试——修复根因、隔离环境或者重写用例。
双管齐下之后,你才能放心地对剩下那部分真正的瞬态失败启用重试。
5.2 过度重试导致流水线时间膨胀
重试不是免费的。每一次重试都重新占用Runner、重新拉取代码、重新安装依赖。假设你有个集成测试job平均耗时20分钟,如果开3次重试上限,最坏情况下这一个job就耗掉80分钟。这还没算上重试过程中阻塞后续job的问题。
我见过一个团队为了图省事,对流水线里所有job统一配了retry: 3,结果CI队列经常积压,开发等合并验证的时间从10分钟拉长到1小时。因为他们没意识到重试是“最坏情况下的菊链延迟”。要解决这个,我给你两个建议:
- 按job的实际耗时来限制重试预算。一个5分钟的job,重试2次代价是可接受的;一个30分钟以上的job,重试1次都需要掂量一下。
- 用GitLab CI的
timeout和retry配合。比如timeout: 30m加上retry: 1,能保证最坏情况下:第一次跑30分钟失败,重试再跑30分钟,总共60分钟封顶。
5.3 重试导致并发资源争抢:并行job同时重试
并行阶段的job同时失败、同时重试,会瞬间抢占大量Runner资源。我有一个压测环境遇到过这种情况:10个集成测试job同时失败并自动重试,导致Runner资源一下子爆了,后面排队的几十个job全部原地卡死,整个流水线瘫痪了20分钟。
从那以后我就学乖了。处理办法有几个:
- 用
resource_group把同类型的retry job串行化。 - 用GitLab CI的
parallel: matrix时,设置每个组合的retry次数错开——不是所有组合都一次重试。 - 在脚本里加随机延迟,避免重试请求在同一时刻集中打到Runner上。
小成本、大收益,强烈建议做。
5.4 “重试后通过”也算失败:审计日志的落地
很多人忽略的一种情况是:测试虽然重试后通过了,但它消耗了额外的流水线时间,也暴露了环境的不稳定性。如果你不做任何记录,这些信号就白白丢失了。
我的做法是在测试脚本里把每次运行结果都写到流水线产物中,保留一个retry-history.log:
if [ "$CI_JOB_RETRY_COUNT" != "0" ]; then echo "job retried $CI_JOB_RETRY_COUNT times, final status: $CI_JOB_STATUS" >> retry-history.log fiGitLab CI提供了CI_JOB_RETRY_COUNT这个预定义变量,你可以非常方便地判断一个job经历过几次重试。把这个变量值上报到你常用的监控系统或者IM机器人,每个月拉一次“重试率”报表。如果某条流水线的重试率超过10%,它就值得你花时间去做一次专项治理了。这个指标比你单纯盯“流水线通过率”有价值得多——通过率可能高达99%,但重试率稳定攀升,说明技术债正在悄悄累积。
5.5 重试条件的本质:用确定性问题对抗不确定性问题
最后做一个拔高总结——其实也是我在设计所有重试策略时反复思考的底层逻辑。
CI/CD系统里的失败,本质上可以分成“确定性问题”和“不确定性问题”。重试本质上是用多次执行来对抗“不确定性”。如果问题本身就是不确定的(网络抖动、环境偶发异常),重试就是合理策略;如果问题是确定的(代码写错了、逻辑不对),那么再多次重试都是无用功,反而会浪费资源、掩盖风险。
所以真正的重试策略设计,不是在“重试”和“不重试”之间选一个,而是要先把失败背后的不确定性和确定性区分开,再分别制定规则。GitLab CI和Jenkins提供的那些retry开关,只是你落地规则的接口,而不是决策本身。这个认知我会一直放在脑子里,也建议你把它当作搭建CI/CD测试体系的一条核心准则。
用我自己刚才那段踩坑经历来收尾吧——我第一版流水线也是对所有失败一律retry: 2,后来因为一个被掩盖的bug被业务方投诉之后,我用了整整一个迭代来重构重试配置。现在线上这套分层策略已经稳定跑了两年多,流水线通过率稳定在97%以上,重试率被压到了3%以内,而且所有重试过的job都有日志可查。那一次的经验也让我形成了一个习惯:任何涉及“失败后自动重试”的配置,我都会顺手给对应的监控看板上加一个“重试通过率”指标。你要是也准备调整自己团队的重试策略,不妨也把这块捎上——它不会立刻显现价值,但到了某次诡异故障复盘时,你会感谢当时那个顺手加了指标的自己。