多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

CI/CD测试结果归档与高效查询:从数据模型到落地实践

CI/CD测试结果归档与高效查询:从数据模型到落地实践 先说一个我经常遇到的场景某个Pipeline跑完构建是绿色的但测试报告散落在节点机的临时工作目录里。三个月后想排查一次历史版本回退翻遍了聊天记录、邮件附件、甚至本地缓存就是找不到当时那份完整的测试结果。后来我们项目组认真做了一轮CI/CD中的测试结果归档才把“查历史数据”从考古式翻找变成了一条SQL、一个接口就能解决的事。这篇内容围绕“高效查询历史数据”这个核心目标梳理归档对象、数据建模、存储选型和落地脚本适合正在维护CI/CD流水线、测试平台或想给团队补上“结果沉淀”能力的工程师参考。1. 为什么“归档”会成为CI/CD里绕不开的一环很多人一听到“归档”本能地以为就是把测试报告压缩存到一个共享目录里。实际操作过就知道这套思路撑不过三个月。CI/CD跑得越勤结果文件越分散日志、XML报告、截图、覆盖率数据散落在不同执行机的不同目录里节点一清理就全没了。归档不是简单的“文件搬家”而是要解决三个真实困境结果会丢、结果不可查、结果无法横向对比。1.1 测试结果分散带来的“考古式”排查困境我印象很深的一次项目组有个集成测试任务每周跑两轮某天开发反馈“这个用例上一次是通过的这次突然挂了”想确认具体是哪次执行、什么环境、有没有相关日志。结果一查上一轮的执行结果已经被节点机的磁盘清理策略删掉了唯一能佐证的就是聊天记录里一句“上周好像是绿的”。这不是个例。只要Pipeline用自建的临时目录存测试产物就不可避免要面对磁盘清理、节点替换、容器重建这些日常操作。今天不归档明天要查历史就得“考古”翻邮箱、翻缓存、找同事的本地目录时间成本极高。更麻烦的是就算文件还在格式也未必统一。有的任务输出JUnit XML有的只打日志有的把截图散在一堆子目录里没有统一入口查询基本靠人工。1.2 归档的本质是“数据工程”不是“文件管理”把测试结果当成数据资产来管理才算真正理解了归档。它有三个基本要素一是结构化元数据每条执行记录要有Pipeline名称、构建号、任务名、开始时间、用例级别的结果二是稳定存储文件本身要放到独立于执行节点的持久化存储里不能和临时目录共存亡三是统一检索入口让团队成员能通过一个固定接口或页面查历史。我见过不少人第一版就掉进“备份思维”里搞个定时任务把整个workspace压缩打包扔进对象存储以为归档完成。结果真到查询的时候得先把几百兆的压缩包拉下来解压再翻目录效率反而更差。所以我在设计归档方案时始终把“高效查询历史数据”当作第一目标存储只是载体查询和检索才是核心。2. 先理清归档对象哪些数据值得留哪些可以直接扔归档不是把所有东西都存下来。测试产物里既有高价值的结构化结果也有体积大、价值低的临时文件。全存既浪费存储又拖慢查询。比较好的做法是区分“必归档”“按需归档”“不归档”三类。2.1 一份完整的测试结果归档清单我把常见的测试产物整理成一张表方便对照自己的任务清单数据类型典型文件归档级别理由执行汇总汇总JSON/XML、Summary报告必归档决定“这次到底绿没绿”用例级结果JUnit XML、TestNG结果、pytest报告必归档支撑用例维度历史查询测试日志stdout/stderr日志、关键Debug日志按需归档排障必需但量大可做截断覆盖率数据各文件/模块覆盖率汇总按需归档做质量趋势分析时很关键截图/录屏UI自动化失败截图、视频按需归档体积大可只留失败用例环境信息构建参数、依赖版本、镜像ID必归档复现问题必需常见遗漏临时产物agent日志、编译中间文件不归档体积大、价值低这里想强调一个经常被忽略的字段环境信息。有时候测试失败不是代码问题而是依赖版本或镜像更新导致的。如果归档时只存了用例结果和日志等上线两天后才来排查环境已经变了很多东西就说不清了。所以我在设计数据模型时一定会把构建参数、IMAGE_TAG、依赖锁文件hash存进元数据宁可多存几个字段也别在排障时缺关键上下文。2.2 数据模型设计围绕“一次执行”和“一个用例”建模归档的存储结构直接影响查询效率。我的建议是两层模型执行汇总层和用例明细层。执行汇总层以“一次测试执行”为单位对应Pipeline里的一个任务用例明细层以“一个测试用例”为单位挂到对应的执行记录下。这样既支持“查某次执行的汇总情况”也支持“查某个用例的历史表现”。-- 执行汇总表 CREATE TABLE test_executions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, pipeline_id text NOT NULL, build_number int NOT NULL, task_name text NOT NULL, env_image text, git_commit text, branch text, started_at timestamptz, finished_at timestamptz, total int, passed int, failed int, skipped int, status text, artifact_prefix text, created_at timestamptz DEFAULT now() ); -- 用例明细表 CREATE TABLE test_cases ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, execution_id bigint NOT NULL REFERENCES test_executions(id), class_name text, case_name text, duration_ms int, status text, failure_message text, failure_trace text, screenshot_key text ); -- 高频查询索引 CREATE INDEX idx_exec_pipeline_build ON test_executions(pipeline_id, build_number DESC); CREATE INDEX idx_case_name_status ON test_cases(case_name, status); CREATE INDEX idx_case_execution ON test_cases(execution_id);这里有几个细节值得说明。第一test_executions和test_cases是一对多关系这样设计之后“查询某用例最近10次结果”这样的操作只需要一次JOIN效率很高。第二artifact_prefix字段记录了这份结果在对象存储里的目录前缀真正下载日志、截图时按这个路径去拉避免在数据库里存大字段。第三索引不要加太多保留这三个高频查询需要的就好否则写入性能会被拖垮。3. 高效查询历史数据的核心策略数据存进去了查询策略才是评价归档方案好坏的真正标准。我见过不少团队把文件往对象存储一丢就结束真到查的时候要么用对象存储的列表接口慢慢翻要么写脚本遍历几千个对象慢到怀疑人生。高效查询有四个关键词目录前缀规范、元数据索引、冷热分层、统一查询入口。3.1 对象存储目录命名让“前缀查询”跑起来对象存储本质上是个扁平的键值空间目录只是路径前缀。虽然没有真正的“目录跳转”但前缀查询非常快。所以命名规范要认真设计。我的习惯是tests/{pipeline_id}/{yyyy-MM-dd}/{build_number}/{task_name}/举个例子tests/order-service/2025-06-08/2145/unit-tests/junit.xml tests/order-service/2025-06-08/2145/unit-tests/summary.json tests/order-service/2025-06-08/2145/integration-tests/logs/stdout.log这样设计有三个好处。第一按日期前缀可以快速定位“今天/本周/上月”的所有执行记录第二构建号倒序排列时最新的记录自然排在前面人工浏览时很直观第三task_name把不同任务的产物分隔开避免混在一起覆盖。有一个我踩过的坑早期设计时把build_number放在日期前面结果同一天多个任务的文件被长时间运行的列表操作分页打散人为制造了“找文件难”的问题。后来统一改成“日期/构建号”顺序列表和检索都顺畅了。还有一个细节目录名里不要带空格和中文尽量用小写字母和连字符否则在跨平台脚本里很容易出幺蛾子。3.2 元数据入库查询走数据库下载走存储纯靠对象存储前缀查询在单次、单任务场景下还能用但一旦跨任务、跨版本对比就力不从心了。比如查“某个用例最近5次的历史通过率”需要先列出所有相关前缀再逐个下载XML解析费时费力。我的方案是“元数据入库文件入存储”把关键检索字段写到数据库里查询逻辑走SQL下载文件再走对象存储。这样做的好处很直观。SQL可以灵活组合条件比如pipeline_id status 时间范围、case_name status聚合函数还能直接算通过率对象存储只充当文件下载的通道压力小得多。有人觉得多维护一个数据库很麻烦但实际运行下来PostgreSQL这类关系型数据库处理这种量级的查询完全够用而且运维成本远低于在对象存储层面强行做全文检索。如果想更进一步还可以加一层全文搜索引擎或者专用报表服务但我的建议是别一上来就上重武器。先保证“元数据入表 SQL查询”能用等数据量真的大到查不动了再考虑加ES或者ClickHouse迁移时数据模型还是同一套不会白做。3.3 冷热分离与淘汰生命周期测试结果的价值随时间衰减但不像日志那么快。一周内的结果是排障热数据一个月内的结果用于版本回归分析超过三个月的更多用于季度质量盘点。如果所有数据一视同仁地存在同一个桶里存储成本在数据量上来后会变得很刺眼。我推荐按生命周期分冷热两层热数据放性能好的对象存储设置生命周期规则自动转为低频访问存储超过一年再清理或转冷归档。这里要给出一组我实际用过的建议参数数据阶段保留时长存储策略用途热数据30天标准存储日常排障、近期回归分析温数据30~180天低频访问存储版本趋势、月度质量分析冷数据180~365天归档存储季度盘点、审计追溯过期数据超过1年定时清理释放空间有一点要特别提醒清理策略一定要带“最后访问时间”或“创建时间”条件不能简单按文件名前缀删。我们早期吃过一次亏清理任务错误匹配了前缀把正在排查的一批日志误删了后来在清理脚本里加了age 365的判断并且先移动到回收目录观察一周再彻底删除才彻底放心。4. 最小可落地方案一套可以“抄作业”的归档查询体系聊完思路分享一个可以直接落到项目里的最小闭环方案。这套方案不需要重金打造平台一个对象存储、一个PostgreSQL、几个脚本就能跑起来。如果你的团队还没有任何归档基础设施建议从这套开始。4.1 基础设施选型与理由存储侧选型我推荐直接用开源的MinIO或者云厂商的对象存储服务。原因很简单兼容S3 API生态成熟SDK在任何语言里都能直接调不用自己封装存储层。数据库侧PostgreSQL足够量级到了千万条执行记录也还能轻松应对而且支持JSONB后续如果想扩展现有数据模型也不难。这里有一个选型逻辑异步任务多的团队可以选择把归档动作做成消息异步化Pipeline结束只发一个消息归档服务自己处理上传和入库任务量小的团队直接在Pipeline里同步执行归档脚本就够了不用额外维护消息队列。我给的建议是别上来就拆微服务一个定时任务加一个脚本入口覆盖90%的场景。等遇到“归档过程比测试本身还慢”的时候再拆也不迟。4.2 CI/CD脚本集成上传、入库、查询以一个常见的Pipeline为例在test阶段结束后增加一个archive阶段脚本负责三件事上传测试产物、解析汇总文件、写元数据。stages: - test - archive archive: stage: archive script: - python scripts/archive_test_results.py when: always artifacts: expire_in: 1 weekwhen: always很关键否则测试失败后Pipeline直接中断归档阶段根本不会执行结果就丢了。我把归档阶段设置成“无论测试结果如何都执行”这是所有CI/CD结果归档方案的基础前提。下面是归档脚本的核心片段import os import glob import json import boto3 from sqlalchemy import create_engine, text s3 boto3.client( s3, endpoint_urlhttp://minio:9000, aws_access_key_idminioadmin, aws_secret_access_keyminioadmin, ) engine create_engine(postgresql://user:passlocalhost/ci_archive) def upload_artifacts(pipeline_id, build_number, date_str, task_name, local_dir): prefix ftests/{pipeline_id}/{date_str}/{build_number}/{task_name} for root, _, files in os.walk(local_dir): for f in files: local_path os.path.join(root, f) key f{prefix}/{os.path.relpath(local_path, local_dir)} s3.upload_file(local_path, BUCKET, key) print(fuploaded {key}) return prefix def write_metadata(pipeline_id, build_number, task_name, summary, artifact_prefix): with engine.begin() as conn: exec_id conn.execute( text( INSERT INTO test_executions (pipeline_id, build_number, task_name, total, passed, failed, skipped, status, artifact_prefix) VALUES (:pipeline_id, :build_number, :task_name, :total, :passed, :failed, :skipped, :status, :artifact_prefix) RETURNING id ), { pipeline_id: pipeline_id, build_number: build_number, task_name: task_name, total: summary[total], passed: summary[passed], failed: summary[failed], skipped: summary[skipped], status: passed if summary[failed] 0 else failed, artifact_prefix: artifact_prefix, }, ).scalar() cases summary.get(cases, []) for case in cases: conn.execute( text( INSERT INTO test_cases (execution_id, class_name, case_name, duration_ms, status, failure_message, screenshot_key) VALUES (:execution_id, :class_name, :case_name, :duration_ms, :status, :failure_message, :screenshot_key) ), { execution_id: exec_id, class_name: case.get(class_name), case_name: case.get(name), duration_ms: case.get(duration_ms, 0), status: case.get(status), failure_message: case.get(failure_message), screenshot_key: case.get(screenshot_key), }, ) return exec_id if __name__ __main__: pipeline_id os.environ[CI_PIPELINE_ID] build_number int(os.environ[CI_BUILD_NUMBER]) task_name os.environ[TEST_TASK_NAME] date_str os.environ[BUILD_DATE] summary parse_junit_summary(results/) prefix upload_artifacts(pipeline_id, build_number, date_str, task_name, results/) write_metadata(pipeline_id, build_number, task_name, summary, prefix)脚本里值得注意的地方有三个。一是upload_artifacts和write_metadata分开做因为前者可能很慢网络上传后者必须得快分开部署时更灵活。二是上传时保留了相对路径结构这样在对象存储里看到的目录和本地完全一致下载后直接能用。三是summary里的cases必须从JUnit XML或pytest报告解析出来这里没有展示完整解析代码但大多数语言都有现成解析库不要自己硬写正则。4.3 三个高频查询场景的实操示例归档的数据跑起来之后我提供几个最常被团队问到的查询示例都是可以直接拿来改的。第一个场景查某个用例最近10次历史结果包括失败信息。这是排障时最常用的查询。SELECT e.build_number, e.branch, c.status, c.duration_ms, c.failure_message FROM test_cases c JOIN test_executions e ON c.execution_id e.id WHERE c.case_name LoginTest.test_login_success ORDER BY e.build_number DESC LIMIT 10;第二个场景统计某条Pipeline近30天的通过率趋势。SELECT date_trunc(day, started_at) AS day, count(*) AS total_runs, count(*) FILTER (WHERE status passed) AS passed_runs, round(100.0 * count(*) FILTER (WHERE status passed) / count(*), 2) AS pass_rate FROM test_executions WHERE pipeline_id order-service AND started_at now() - interval 30 days GROUP BY 1 ORDER BY 1;第三个场景对比两个构建号之间的失败用例差异。SELECT class_name, case_name FROM test_cases WHERE execution_id IN ( SELECT id FROM test_executions WHERE pipeline_id order-service AND build_number IN (2141, 2145) ) AND status failed ORDER BY class_name, case_name;这三个场景覆盖了“单用例排障、整体质量趋势、版本对比”三种核心诉求。如果你发现自己的查询总是绕着这几个方向转说明数据模型设计基本是合理的。5. 踩坑记录与排查技巧这套方案我前前后后迭代了好几版也翻过一些跟头。挑几个典型的坑给正在实施归档的团队打打预防针。5.1 高频问题速查现象根因解决办法归档后查不到最新一次结果when: always没设置失败用例直接中断了归档阶段在Pipeline定义里把归档阶段的when改成always对象存储里有文件但查询接口报404元数据里的artifact_prefix与实际上传路径不一致上传函数和写入函数必须复用同一套前缀拼接逻辑不要各写各的大量并发任务同时写数据库插入变慢索引过多或每用例逐条INSERT批量提交一次事务插入全部用例只保留必要的索引查询时发现字段全是null解析脚本对JUnit XML的命名空间处理不对确认XML路径正确优先使用成熟的解析库而非正则归档后的日志“找不到”日志文件被日志轮转策略覆盖了归档脚本先收集再清理顺序不要反过来清理任务误删了还在排查的数据按前缀宽泛匹配直接删除先移动到回收目录设置延迟比如7天后再彻底删除5.2 两条很实在的归档经验第一条归档字段的标准先定下来再谈存储和工具。我们第一版脚本里各个任务上报的字段特别随意有的叫caseName有的叫name有的直接不传。结果归档后查询时一个简单的“查用例”都要兼容三种字段名。后来统一了class_name case_name的命名约定并要求所有任务按同一份JSON Schema上报查询逻辑一下子清爽了很多。第二条查询入口一定要让所有人都能直达。归档做得再好如果入口只在一个不常用的脚本里或者某台服务器的临时端口上团队根本不会用。我当时搭了一个最简单的只读查询HTTP服务路由就三个查执行列表、查用例历史、查产物下载地址。开发抱怨“查不到历史”的声音立刻消失了。服务和归档脚本基于同一套数据库和存储层数据更新实时可见。另外归档之后的产物目录建议追加一个只读权限级别普通成员只读、维护者可以修改和删除。尤其要管住“删除”权限避免有人在排查问题时觉得“这个目录没用”就顺手清掉。我自己最早做这轮归档时也翻过车一上来图省事直接用共享目录结果搭完一周就被某次发布时的临时文件覆盖历史记录被冲掉一大半。后来痛定思痛认真按“元数据入库、文件入对象存储、生命周期分层”的思路重构才真正做到任何一次执行结果都能在几秒钟内查到。做归档这件事平时看着不起眼真正到线上排障、质量复盘、版本回归对比的时候才发现那套按清晰目录和数据模型沉淀下来的历史记录是整个团队节省下来的大量排查时间。如果你也在为“历史结果查不到”头疼别急着造各种查询工具先把归档对象和数据模型定住再按上面的方案落地一版很快就能看到效果。
返回列表