4、Redis连接错误断言:模拟连接失败场景、断言ERR_CONNECTION_REFUSED、ERR_CONNECTION_TIMEOUT
在搜索服务的集成测试中,Redis 连接异常是最常见且破坏性最大的故障模式之一。当搜索服务依赖的 Redis 集群因网络分区、服务宕机或防火墙规则变更而不可达时,服务必须能够快速感知并返回明确的错误码,而不是陷入无限重试或线程阻塞。本章聚焦于两种核心连接失败场景:连接被拒绝(ERR_CONNECTION_REFUSED)和连接超时(ERR_CONNECTION_TIMEOUT),并给出可复现的断言方案。
4.1 连接失败场景分类与测试目标
集成测试需要覆盖 Redis 客户端在建立 TCP 连接阶段可能遇到的两类异常。下表从故障原因、客户端表现和预期错误码三个维度进行对比:
| 场景 | 故障原因 | 客户端表现 | 预期错误码 |
|---|---|---|---|
| 连接被拒绝 | 端口未监听、进程崩溃、防火墙直接丢弃 SYN 并回复 RST | 抛出连接异常,几乎立即失败(毫秒级) | ERR_CONNECTION_REFUSED |
| 连接超时 | 网络不可达、路由黑洞、目标主机完全无响应 | 阻塞直到 connect timeout 阈值(通常 2~10 秒) | ERR_CONNECTION_TIMEOUT |
测试设计原则:两种场景必须独立验证。拒绝场景验证客户端对快速失败的响应逻辑;超时场景验证客户端对慢路径的降级与超时控制。不能用一个场景覆盖另一个。
4.2 模拟连接失败的技术方案
在集成测试环境中,不能依赖真实的 Redis 宕机(会污染其他测试用例)。推荐使用以下三种可控模拟手段:
- 端口占用/释放法:在测试容器或本地启动一个空 socket 监听目标端口,然后立即关闭;或者直接使用未占用的随机端口发起连接,触发 RST。
- 网络规则注入法:通过 iptables / nftables 在测试宿主机或 sidecar 容器中插入 DROP 或 REJECT 规则,精确控制到 Redis 端口的流量。
- 代理中间人法:在搜索服务与 Redis 之间部署一个可控的 TCP 代理(如 toxiproxy),动态设置延迟、断开或关闭连接。
以下 SVG 图展示了三种模拟方案在测试架构中的位置与数据流:
4.3 断言 ERR_CONNECTION_REFUSED
连接被拒绝是 TCP 层最直接的失败信号。当客户端向一个没有进程监听的端口发起 SYN 时,内核协议栈会回复 RST 包,客户端抛出异常。在测试代码中,断言的关键是捕获异常并验证错误码字符串。
以下是一个基于 Jedis 客户端的断言示例:
@Test
void shouldReturnConnectionRefusedWhenPortNotListening() {
// 使用一个确保未被占用的端口(例如 16379)
String host = "127.0.0.1";
int deadPort = 16379;
// 验证端口确实未被监听
assertFalse(isPortListening(host, deadPort),
"测试前提:端口必须未被占用");
// 执行连接并断言异常
Exception exception = assertThrows(JedisConnectionException.class, () -> {
try (Jedis jedis = new Jedis(host, deadPort, 2000)) {
jedis.ping();
}
});
// 核心断言:错误码包含 REFUSED
assertTrue(exception.getMessage().contains("REFUSED")
|| exception.getMessage().contains("Connection refused"),
"预期连接被拒绝错误,实际消息: " + exception.getMessage());
}
关键断言点:
- 异常类型必须是
JedisConnectionException或其子类。 - 异常消息必须包含
REFUSED或Connection refused关键字。 - 连接建立耗时应在毫秒级(通常 < 100ms),若耗时接近超时阈值则说明模拟方式错误。
4.4 断言 ERR_CONNECTION_TIMEOUT
连接超时发生在客户端发送 SYN 后,在指定的超时时间内未收到 SYN-ACK 或 RST。模拟超时的关键是让目标 IP 完全无响应,而不是立即拒绝。使用 iptables DROP 规则或 Toxiproxy 的断开模式均可实现。
超时场景的断言需要额外关注超时时间精度:
@Test
void shouldReturnConnectionTimeoutWhenNetworkUnreachable() {
// 假设 Redis 端口为 6379,通过 iptables 注入 DROP 规则
// 实际测试中通过 @BeforeEach 或 Testcontainers 的 execInContainer 执行
// iptables -A INPUT -p tcp --dport 6379 -j DROP
int connectTimeoutMs = 3000; // 客户端配置的超时时间
long start = System.currentTimeMillis();
Exception exception = assertThrows(JedisConnectionException.class, () -> {
try (Jedis jedis = new Jedis("10.0.0.99", 6379, connectTimeoutMs)) {
jedis.ping();
}
});
long elapsed = System.currentTimeMillis() - start;
// 断言1:错误码包含 TIMEOUT
assertTrue(exception.getMessage().contains("TIMEOUT")
|| exception.getMessage().contains("timed out"),
"预期连接超时错误,实际消息: " + exception.getMessage());
// 断言2:实际耗时应在超时阈值的 ±20% 范围内
assertTrue(elapsed >= connectTimeoutMs * 0.8
&& elapsed <= connectTimeoutMs * 1.2,
"连接超时耗时不符合预期阈值,期望 ~" + connectTimeoutMs
+ "ms,实际 " + elapsed + "ms");
}
常见陷阱:
- 使用
localhost或127.0.0.1模拟超时可能失败,因为本地回环接口的 DROP 规则行为与物理网络不同。建议使用一个独立的虚拟 IP 或容器网络。 - 超时断言必须包含耗时校验,防止客户端因其他原因(如 DNS 解析失败)提前返回,导致误判为超时。
- 清理测试环境时务必移除 iptables 规则,否则会影响后续测试用例。
4.5 两种错误码的断言对比清单
下表汇总了两种连接错误在断言层面的关键差异,帮助测试工程师快速编写正确的断言逻辑:
| 对比维度 | ERR_CONNECTION_REFUSED | ERR_CONNECTION_TIMEOUT |
|---|---|---|
| 模拟方式 | 未监听端口 / 防火墙 REJECT | 防火墙 DROP / 网络不可达 |
| 异常消息关键字 | REFUSED / Connection refused |
TIMEOUT / timed out / timeout |
| 耗时特征 | < 100ms(快速失败) | 接近 connectTimeout 配置值 |
| 客户端重试行为 | 通常不重试,直接向上抛 | 可能触发内部重试(取决于客户端配置) |
| 对搜索服务的影响 | 立即降级,返回缓存缺失 | 延迟降级,可能影响请求延迟 SLO |
4.6 测试用例编排与清理最佳实践
连接失败测试属于破坏性测试,必须严格遵循「建立隔离 → 注入故障 → 执行断言 → 恢复环境」的四步模式。以下是一个使用 Testcontainers + Toxiproxy 的完整编排示例:
class RedisConnectionFailureTest {
private static final int REDIS_PORT = 6379;
private static GenericContainer<?> redis;
private static ToxiproxyContainer proxy;
@BeforeAll
static void setup() {
// 启动 Redis 和 Toxiproxy 容器
redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(REDIS_PORT);
redis.start();
proxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0")
.withNetwork(redis.getNetwork());
proxy.start();
// 创建代理,将 Redis 端口映射到 Toxiproxy
ToxiproxyContainer.ContainerProxy redisProxy =
proxy.getProxy(redis, REDIS_PORT);
}
@Test
void testConnectionRefused() {
// 关闭代理,模拟端口不可达
proxy.getProxy(redis, REDIS_PORT).close();
assertThrows(JedisConnectionException.class, () -> {
try (Jedis jedis = new Jedis(proxy.getHost(),
proxy.getMappedPort(REDIS_PORT), 2000)) {
jedis.ping();
}
});
// 断言消息包含 REFUSED
}
@Test
void testConnectionTimeout() {
ToxiproxyContainer.ContainerProxy rp =
proxy.getProxy(redis, REDIS_PORT);
// 设置延迟大于客户端超时时间
rp.setLatency(5000, 100);
long start = System.currentTimeMillis();
assertThrows(JedisConnectionException.class, () -> {
try (Jedis jedis = new Jedis(proxy.getHost(),
proxy.getMappedPort(REDIS_PORT), 2000)) {
jedis.ping();
}
});
long elapsed = System.currentTimeMillis() - start;
assertTrue(elapsed >= 2000 && elapsed <= 3000);
}
@AfterAll
static void teardown() {
proxy.stop();
redis.stop();
}
}
工程建议:将连接失败测试用例放入独立的测试套件(如
RedisFailureTestSuite),与正常功能测试分开执行。在 CI 流水线中,建议作为「混沌测试」阶段的一部分,每周定时运行,而不是每次提交都执行,避免因环境残留导致假阳性。