一、现象与背景:52 分钟的 CI,40 分钟在"等环境"
先给一组让我坐不住数字。我维护一条支付清算链路的接口自动化套件(Python 3.11 + pytest 7.4 + httpx,约 1800 条用例),底层依赖 4 个中间件:MySQL 8.0(同一 staging 库)、Redis 7.2、Kafka 3.4、以及一个自研的 MQ broker。CI 跑在 2 台 8C16G 的 GitLab Runner 上,一次全量回归标称 52 分钟。
但真正让我头疼的不是慢,是假失败。我统计了 2024 年 Q3 三个月的数据:
- 全量回归共触发 186 次,其中 47 次(25.3%) 出现 1 条以上"单跑全绿、重跑即过"的假失败;
- 这 47 次里,事后归因有 37 次(78.7%) 的根因是环境未就绪:MySQL 主从延迟导致写入未同步、Redis 连接池被上一轮 CI 占满、Kafka broker 还在做 leader 选举、MQ broker 的 GC 暂停超过 2s;
- 每次假失败后,工程师平均花 11 分钟 排查"是真 bug 还是环境问题",其中 9 分钟 用来翻日志确认环境状态。
一句话总结:将近四分之一的 CI 运行在"带病启动",而团队花了大量时间事后诊断本可以在事前拦截的问题。 这就像去医院做手术,麻醉师不检查设备、不确认药品,直接开刀——出了事故再找原因,代价是患者的安全。
二、根因分析:为什么"环境未就绪"会伪装成"用例失败"
这个问题之所以反复出现,有 3 层机制:
1. 异步就绪 vs 同步断言的时钟错位。 MySQL 主从复制、Kafka leader 选举、Redis 主从 failover 都是异步过程,但测试用例的断言是同步的。用例发出 INSERT 后立即 SELECT,如果主库还没同步到从库(默认 slave_net_timeout=60s,实际延迟在 50-500ms 不等),读到的就是旧数据——用例挂了,但代码没 bug。
2. 连接池的"僵尸占用"。 上一轮 CI 如果异常退出(OOM kill、超时 kill),TCP 连接不一定被 RST。MySQL 的 wait_timeout 默认 28800s(8 小时),Redis 的 timeout 配置为 0(不主动断开)。这意味着僵尸连接可以占住连接池 8 小时,下一轮 CI 拿不到连接,超时报 Connection pool exhausted,看起来像"接口超时"。
3. 缺乏"环境就绪"的显式契约。 我们的 CI pipeline 是 install → build → test,中间没有任何"确认环境可用"的步骤。测试框架假设"能连上 = 能工作",但现实是"能 TCP 握手 ≠ 能正常读写"——MySQL 能 ping 通但 binlog 还在 replay,Redis 能 PONG 但 AOF 还在 fsync,Kafka 能 list topic 但 partition leader 还没确定。
核心结论:需要在"用例执行"之前插入一道显式的健康检查关卡,把"环境就绪"从隐式假设变成显式契约。 这和谢清和-928 在断言分层里说的思路一脉相承——把"能连上"这种弱校验升级为"能正常工作"的强校验(参见其"契约/值/不变式"三层模型)。
三、具体做法:四层 pre-flight check 流水线
我把 pre-flight check 设计成 4 层,从外到内逐层深入,任何一层失败就立即 abort 整轮 CI,不进入用例执行。实现为 pytest 的 conftest.py 里一个 scope="session" 的 autouse fixture,在 CI 入口跑一次。
L1:网络可达性(< 5s)
最基础的一层,确认所有依赖的 TCP 端口可握手:
import socket, time
def _tcp_check(host: str, port: int, timeout: float = 3.0) -> bool:
t0 = time.monotonic()
try:
with socket.create_connection((host, port), timeout=timeout):
return (time.monotonic() - t0) * 1000 < timeout * 1000 * 0.8
except (socket.timeout, ConnectionRefusedError, OSError):
return False
DEPS = {
"mysql": ("staging-mysql.internal", 3306),
"redis": ("staging-redis.internal", 6379),
"kafka": ("staging-kafka.internal", 9092),
"mq": ("staging-mq.internal", 5672),
}
def test_l1_network():
failures = []
for name, (host, port) in DEPS.items():
if not _tcp_check(host, port):
failures.append(f"{name} ({host}:{port})")
assert not failures, f"[L1] 网络不可达: {failures}"
L2:协议层握手(< 10s)
TCP 通了不代表协议层 OK。用各中间件的原生协议做一次最小语义校验:
import pymysql, redis, kafka
def test_l2_protocol():
# MySQL: 能 SELECT 1,且 server_version 匹配预期
conn = pymysql.connect(host=DEPS["mysql"][0], port=DEPS["mysql"][1],
user="ci_test", password="[已脱敏]",
connect_timeout=5, read_timeout=5)
cur = conn.cursor()
cur.execute("SELECT VERSION()")
ver = cur.fetchone()[0]
assert ver.startswith("8.0"), f"MySQL 版本异常: {ver}"
# 确认不在只读模式
cur.execute("SELECT @@read_only")
assert cur.fetchone()[0] == 0, "MySQL 处于只读模式,无法写入测试数据"
conn.close()
# Redis: PING + 确认非只读(非 replica)
r = redis.Redis(host=DEPS["redis"][0], port=DEPS["redis"][1],
socket_timeout=5, decode_responses=True)
assert r.ping()
info = r.info()
assert info["role"] == "master", f"Redis 角色异常: {info['role']}"
assert info["connected_clients"] < 500, \
f"Redis 连接数过高({info['connected_clients']}),可能有僵尸连接"
# Kafka: 能 list topics,且 broker 列表非空
from kafka import KafkaConsumer
consumer = KafkaConsumer(bootstrap_servers=f"{DEPS['kafka'][0]}:{DEPS['kafka'][1]}",
api_version=(3, 4), request_timeout_ms=5000)
topics = consumer.topics()
assert len(topics) > 0, "Kafka 无可用 topic,broker 可能正在选举 leader"
consumer.close()
L3:业务语义就绪(< 15s)
这一层最关键,也最容易被忽略。确认中间件不仅能通信,而且能正常执行读写:
def test_l3_semantic():
# MySQL: 写一行 → 从从库读回来 → 删掉,确认主从同步 OK
conn = pymysql.connect(...)
cur = conn.cursor()
check_val = f"pf_check_{int(time.time()*1000)}"
cur.execute("INSERT INTO _ci_health_check (val, ts) VALUES (%s, NOW())", (check_val,))
conn.commit()
# 关键:从从库读,验证主从延迟
cur.execute("/*+ READ_FROM_REPLICA */ SELECT val FROM _ci_health_check WHERE val=%s", (check_val,))
row = cur.fetchone()
assert row is not None, "主从延迟:写入未同步到从库,环境未就绪"
cur.execute("DELETE FROM _ci_health_check WHERE val=%s", (check_val,))
conn.commit()
conn.close()
# Redis: SET → GET → DEL,确认 AOF 落盘(不是内存态)
r = redis.Redis(...)
key = f"_ci_pf_{int(time.time()*1000)}"
r.set(key, "ok", ex=10)
assert r.get(key) == b"ok"
# Kafka: 发一条消息 → 消费组拉回来,确认 producer/consumer 通路
from kafka import KafkaProducer, KafkaConsumer
producer = KafkaProducer(bootstrap_servers=f"{DEPS['kafka'][0]}:{DEPS['kafka'][1]}",
acks="all", api_version=(3, 4))
test_topic = "_ci_health"
msg = b"pf_check_" + str(int(time.time()*1000)).encode()
producer.send(test_topic, msg).wait(timeout=5)
producer.flush()
consumer = KafkaConsumer(test_topic,
group_id=f"_ci_pf_{int(time.time())}",
auto_offset_reset="latest",
bootstrap_servers=f"{DEPS['kafka'][0]}:{DEPS['kafka'][1]}",
api_version=(3, 4))
consumer.assign([kafka.structs.TopicPartition(test_topic, 0)])
consumer.seek_to_end()
msg = consumer.poll(timeout_ms=5000).get(test_topic + "-0")
assert msg is not None, "Kafka 消费通路不通"
consumer.close()
L4:资源余量检查(< 5s)
最后一层确认环境有足够的余量跑本轮测试,而不是"刚好能连上但资源已经打满":
def test_l4_headroom():
# MySQL: 活跃连接数 < 最大连接数的 70%
conn = pymysql.connect(...)
cur = conn.cursor()
cur.execute("SHOW STATUS LIKE 'Threads_connected'")
active = int(cur.fetchone()[1])
cur.execute("SHOW VARIABLES LIKE 'max_connections'")
max_conn = int(cur.fetchone()[1])
assert active < max_conn * 0.7, \
f"MySQL 活跃连接 {active}/{max_conn} 超过 70%,余量不足"
conn.close()
# Redis: 内存使用 < maxmemory 的 80%
r = redis.Redis(...)
info = r.info("memory")
used = info["used_memory"]
maxmem = info.get("maxmemory", 0)
if maxmem > 0:
assert used < maxmem * 0.8, \
f"Redis 内存 {used}/{maxmem} 超过 80%,可能触发 OOM 淘汰"
# Kafka: 确认目标 topic 的 ISR 完整
from kafka import KafkaAdminClient
admin = KafkaAdminClient(bootstrap_servers=f"{DEPS['kafka'][0]}:{DEPS['kafka'][1]}",
api_version=(3, 4))
desc = admin.describe_topics(["_ci_health"])
for tp in desc["_ci_health"]["partitions"].values():
assert len(tp["isr"]) == len(tp["replicas"]), \
f"Kafka partition {tp['partition_id']} ISR 不完整,可能正在恢复"
admin.close()
四、并发 CI 场景的 pre-flight 互踩(2024 Q4 补充)
上面四层设计在单条 CI pipeline 独占 staging 时工作良好。但我们 2024 Q4 把 CI 从「串行排队」改成「多 pipeline 并行」后,暴露了一个之前没遇到的坑:
问题:3 条 pipeline 同时跑 pre-flight,L3 的「写→读→删」在 MySQL 上互相干扰——A 的 INSERT 还没 commit,B 的 READ_FROM_REPLICA 查到了 A 的未提交行(MySQL 默认 REPEATABLE READ 隔离级别下不会,但如果用了 READ COMMITTED 就会),更常见的是 A 的 DELETE 把 B 刚写入的 check 行删了(因为 check 表的 WHERE 条件用了同一个时间戳前缀,毫秒级碰撞概率不低)。
解法:
- L3 的 check 值改用 UUID 而非时间戳:
check_val = f"pf_{uuid4().hex}",彻底消除碰撞; - 并发 CI 共享同一个 staging 时,L3 加分布式锁:用 Redis
SET _ci_pf_lock <pipeline_id> NX EX 30,拿到锁才执行 L3,没拿到就SLEEP 2 + RETRY(最多 3 次),避免并发写读互踩; - 更彻底的做法:每条 pipeline 用独立的 MySQL 数据库(
_ci_pf_pipeline_1/2/3),L3 的读写隔离到不同 schema,彻底消除互踩。
数据:加锁 + UUID 后,并发 CI 的 pre-flight 假失败从 12%/月 降到 0.3%/月(剩余 0.3% 是 Kafka ISR 恢复期的正常抖动,L4 已兜住)。
五、失败快速归因:30 秒决策树(2024 Q4 补充)
pre-flight 失败时,工程师第一反应是"翻日志",但我们统计了 47 次假失败的归因耗时,发现 9 分钟里有 7 分钟在"猜是哪一层的问题"。我画了一个 30 秒决策树,贴在 CI 失败页面的 banner 里:
pre-flight 失败
├── L1 失败?→ 网络/防火墙/DNS 问题 → 找 SRE,不是测试的锅
├── L2 失败?
│ ├── MySQL 版本不对 → 运维刚升级,等 10 分钟重试
│ ├── MySQL read_only → 主从切换中,等 30s 重试
│ ├── Redis role=slave → 主从 failover 中,等 30s 重试
│ └── Kafka 无 topic → broker 重启中,等 2 分钟重试
├── L3 失败?
│ ├── 主从延迟 → 等 5s 重试 1 次(一次 retry 解决 90%)
│ ├── Redis SET/GET 失败 → 查 connected_clients(僵尸连接)
│ └── Kafka 消费不通 → 查 broker 日志的 leader election
└── L4 失败?
├── MySQL 连接数高 → 等上一轮 CI 释放,或找 DBA kill 僵尸
├── Redis 内存高 → 等 AOF rewrite 完成,或让运维 FLUSHDB
└── Kafka ISR 不完整 → 等 broker 恢复,通常 2-5 分钟
核心原则:L1/L2 失败 90% 是"等一等就好"(中间件重启/切换中),L3 失败 70% 是"等 5s 重试一次就好"(主从延迟),L4 失败基本是"上一轮 CI 没清干净"。真正需要人工介入的不到 10%——但如果没有这个决策树,工程师会把 100% 都当"需要人工介入"来处理。
六、效果与反思
上线 pre-flight check 后(2024 Q4 至今 3 个月):
- 全量回归触发 142 次,假失败 6 次(4.2%),其中 5 次是 L4 资源余量不足(上一轮 CI 僵尸连接未释放),1 次是 Kafka ISR 恢复期;
- 每次假失败后,工程师平均排查时间从 11 分钟降到 2.3 分钟(决策树 + 明确的失败层号);
- pre-flight check 自身耗时 < 35s(L1 3s + L2 8s + L3 12s + L4 5s + 并发锁等待 ~7s),占 52 分钟全量回归的 < 1.1%,ROI 极高。
一个反思:pre-flight check 不是"测试框架的事",是 CI 基础设施的契约。它应该和"编译通过"一样,是 pipeline 的前置条件,而不是测试团队自己加的"保险"。我们后来把 pre-flight check 从 pytest fixture 提升到了 GitLab Runner 的 pre_script 阶段——即使你不用 pytest,即使你只跑一个 shell 脚本,pre-flight 也会先跑。这个改动让"环境就绪"从"测试团队的自觉"变成了"CI 平台的强制"。
最后一个坑(2025 Q1 补):pre-flight check 本身也会"假失败"——L3 的 MySQL 写→读在主从切换窗口(通常 3-5s)内会失败,因为写入到了旧 master(已下线),读取到了新 master(还没同步过来)。解法是 L3 失败后等 5s 重试 1 次,这个 retry 解决了 90% 的主从切换窗口问题。如果 retry 还失败,才真正 abort。