任务粒度定义与原则

咱们先聊聊什么是任务粒度。说白了,就是你在 CI 流水线里把一个 job 拆成多细。拆得太粗,一个 job 跑半小时,失败了全重来;拆得太细,光编排依赖就得折腾半天。我早年带一个项目,有个同事把所有事情塞进一个 job,测试、打包、部署一口气弄完。结果 lint 失败了,整个流程白跑。嗯,这就是粒度失控的典型。

什么是任务粒度

任务粒度指 CI 阶段中每个 job 承担的工作规模。一个 job 可以是一个脚本、一个步骤、或者一个并行任务。想象一下拆积木:颗粒太大,搬不动;颗粒太小,拼起来累死人。合适的粒度,让每个 job 独立、可复用、容易定位问题。

我个人习惯用这个标准判断粒度是否合理:一个 job 出错了,能不能只重跑它? 如果答案是“必须连同别的 job 一起重跑”,说明拆得不够细。反过来,如果每个 job 只做几秒钟的事,那又太碎了,维护成本飙升。

核心定义:任务粒度 = 单个 CI job 所覆盖的职责范围。理想状态是每个 job 只做一件事,并且这件事可以被独立验证。

粒度设计三大原则

1. 单一职责原则

一句话:一个 job 只干一件事。比如编译是编译,单元测试是单元测试,不要再混着生成文档。我在项目中遇到过:有个同事把 lint、test、coverage 全写在一个 job 里,结果 coverage 工具报错,但 lint 通过的代码也被拦住了。拆开后,各自独立失败,各自独立修复,效率翻倍。

为什么会这样?因为 CI 的本质是快速反馈。单一职责让每个 job 的失败原因清晰可见。日志一眼就能定位,不用去猜。

举个 YAML 的例子(GitLab CI):

# 错误示范:一个 job 干所有事
build_and_test:
  script:
    - npm install
    - npm run lint
    - npm test
    - npm run build

# 正确做法:拆成三个 job
lint:
  script: npm run lint

unit_test:
  script: npm test

build:
  script: npm run build
  needs: [lint, unit_test]

你看,第二个版本里如果 lint 失败,只重跑 lint job,测试和构建不受影响。这就是单一职责带来的直接好处。

2. 可复用原则

任务写出来,不仅要服务当前项目,最好能拷贝到其他项目里直接跑。我习惯把公共操作提取成“模板”或“函数”。比如代码检查、依赖安装、镜像构建这些,几乎每个项目都有。

以 GitHub Actions 为例:

# 可复用的 action 定义
- name: Setup Node
  uses: actions/setup-node@v3
  with:
    node-version: '18'

- name: Install dependencies
  run: npm ci

# 每个项目只需要引入,不用重复写安装逻辑

可复用的粒度需要你抽象出共同逻辑,但别过度抽象。我曾经定义一个超级通用的“构建 job”,塞了太多参数,结果新项目使用时配置变得比直接写还复杂。记住:复用不是万能药,适可而止。

3. 可调试原则

这可能是最容易被忽视的原则。任务粒度决定了你调试时的痛苦程度。用一个 job 跑 500 个测试用例,失败了你只能看原生日志。但如果拆成 10 个并行 job,每个只跑 50 个,失败范围立即缩小。

我踩过一个坑:构建镜像的 job 里混了编译和测试,结果镜像层缓存失效,每次构建都要半小时。拆开后,编译 job 独立,测试 job 不用构建镜像,直接用缓存,调试时只需关注编译或测试之一。

这里有三个具体建议:

  • 日志粒度:每个 job 只输出与自身相关的信息,不要混杂。
  • 运行环境:尽量让 job 可本地重现。我习惯在 job 脚本里写上 "如果环境变量 DEBUG=1,打印更多调试信息"。
  • 重跑策略:支持只重跑失败的 job,甚至只重跑失败的子步骤。
避坑指南:我曾经把一个构建 job 设计成“先编译,再打包,再签名”,结果签名脚本出错了,但编译已经成功。为了重跑签名,不得不把整个构建流程再跑一遍,白白浪费 10 分钟。后来我把签名拆成独立 job,问题才根治。

原则对比速览

原则 核心要求 反面典型 理想粒度
单一职责 一个 job 只做一件事 同时 lint + test + build lint job、test job、build job 各一个
可复用 同类操作可跨项目共享 每个项目重写一遍安装脚本 抽成模板或 action,参数化
可调试 失败 job 可快速定位根因 一个 job 跑 500 个测试 拆成并行子 job,每组 50 个

如何权衡这三个原则?

你可能注意到了,这三个原则有时会冲突。比如,为了“可调试”拆得更细,但“可复用”可能要求保留通用性,导致 job 内多了一点逻辑。我个人经验:优先保证单一职责,其次是可调试,最后才是可复用。为什么?因为职责不清的 job,就算复用也很难让人理解。先让每个 job 干净,再考虑抽象。

举个例子:我有一个项目,单元测试按模块拆成 6 个 job,虽然脚本看起来有点重复,但每个失败一目了然。后来我用 CI 框架的隐式矩阵并行(比如 GitLab CI 的 parallel:matrix)来减少重复,既保持了单一职责,又提升了复用性。

小技巧:当你拿不准粒度是否合适时,问自己一个问题:“这个 job 失败了,我能在一分钟内知道原因吗?” 如果不能,说明粒度太粗了。
任务粒度设计原则逻辑图谱 任务粒度设计 单一职责 可复用 可调试 快速定位失败 跨项目共享 本地重复 & 日志清晰 核心决策线 优先保证单一职责 → 其次可调试 → 最后考虑可复用

上面这张图帮你梳理了三个原则之间的关系。你写 job 的时候,先从“单一职责”出发,把一个 job 的职责定清楚;然后思考“能否在另一项目直接复用”;最后确保“失败后我能轻松调试”。三者结合,任务粒度自然就合理了。

再说一句:没有正确的粒度,只有适合你团队的粒度。我见过有些团队为了极致可复用,把 job 拆成“安装依赖 v1”、“安装依赖 v2”两个,反而增加了困惑。原则是指导,不是教条。实际中多练几次,你就能找到手感。

总结:任务粒度的本质是权衡。用单一职责划定边界,用可复用减少重复,用可调试减少痛苦。这三个原则就像三脚架,缺一个就容易翻车。