ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

CI/CD测试重试策略:如何区分失败类型,避免重试掩盖真实缺陷

2026/9/29 17:21:38 拓冰建站 浏览量
CI/CD测试重试策略:如何区分失败类型,避免重试掩盖真实缺陷 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到Jenkins3.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_failureRunner系统层面的失败比如机器挂了、环境清理失败stuck_or_timeout_failurejob因为队列阻塞或超时而失败job_execution_timeoutjob执行超时顺带说一句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 21 - 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.logif [ $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都有日志可查。那一次的经验也让我形成了一个习惯任何涉及“失败后自动重试”的配置我都会顺手给对应的监控看板上加一个“重试通过率”指标。你要是也准备调整自己团队的重试策略不妨也把这块捎上——它不会立刻显现价值但到了某次诡异故障复盘时你会感谢当时那个顺手加了指标的自己。