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 图展示了三种模拟方案在测试架构中的位置与数据流:

Redis 连接失败模拟方案架构 搜索服务 (被测服务) 方案1:端口占用 空 socket / 随机端口 方案2:网络规则 iptables DROP/REJECT 方案3:代理中间人 Toxiproxy 动态控制 推荐策略 单元测试用方案1,集成测试用方案3,端到端测试用方案2

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 流水线中,建议作为「混沌测试」阶段的一部分,每周定时运行,而不是每次提交都执行,避免因环境残留导致假阳性。