单例模式管理 Redis 连接

5 min read
Zekari
系统设计后端架构数据库可靠性工程

连接是起点

Redis 连接看起来是个技术细节,实际上是系统可靠性的第一道关卡。

应用启动,需要连接数据库。请求到来,需要读取缓存。任务执行,需要访问 队列。这一切的前提是:连接存在且可用。

没有连接,系统无法运行。连接断开,操作立即失败。连接不稳定,错误层出不穷。

连接的质量,决定了系统的稳定性下限。

初始化连接不只是调用一个 API。它涉及配置、时机、错误处理、资源管理。每个细节都可能成为系统的薄弱点。

💡 Click the maximize icon to view in fullscreen

单例模式的必然性

每次操作都创建新连接?这是灾难。

建立 TCP 连接需要时间。握手、认证、选择数据库,这些步骤都有开销。如果每次读写都重新连接,性能会急剧下降。

更严重的是资源耗尽。每个连接占用端口、内存、文件描述符。创建太多连接,服务器会拒绝新连接,应用会崩溃。

单例模式是自然的选择。应用启动时创建连接,整个生命周期复用同一个实例。

class RedisConnection {
  private static instance: Redis | null = null;

  static async getInstance(): Promise<Redis> {
    if (!this.instance) {
      this.instance = new Redis({
        host: process.env.REDIS_HOST || 'localhost',
        port: Number(process.env.REDIS_PORT) || 6379,
        password: process.env.REDIS_PASSWORD,
        maxRetriesPerRequest: 3,
        retryStrategy: (times) => Math.min(times * 50, 2000),
      });

      // 连接成功日志
      this.instance.on('connect', () => {
        console.log('Redis connected');
      });

      // 连接错误处理
      this.instance.on('error', (err) => {
        console.error('Redis error:', err);
      });
    }

    return this.instance;
  }
}

这不是过度设计,而是对资源的尊重。系统资源有限,连接不能随意创建和销毁。

单例解决了连接复用的问题,但在高并发场景下,单个连接可能成为瓶颈。

这时需要连接池。池中维护多个连接,请求到来时从池中获取,使用完毕后归还。这样既避免了频繁创建连接的开销,又支持并发访问。

import { createClient } from 'redis';

const pool = {
  connections: [],
  maxSize: 10,

  async acquire() {
    if (this.connections.length > 0) {
      return this.connections.pop();
    }

    if (this.currentSize < this.maxSize) {
      const client = createClient({
        url: process.env.REDIS_URL
      });
      await client.connect();
      this.currentSize++;
      return client;
    }

    // 等待可用连接
    await this.waitForConnection();
    return this.acquire();
  },

  release(connection) {
    this.connections.push(connection);
  }
};

但连接池也有代价。它增加了复杂度,需要管理连接的生命周期、健康检查、超时释放。

对大多数应用,单例足够。只有在高并发、低延迟要求的场景下,连接池才是必要的。

配置是对环境的预判

Redis 连接有很多配置项。超时时间、重试次数、重连策略、连接池大小。

这些数字不是随意的,它们反映了你对环境的预判。

connectTimeout: 5000 意味着你认为 5 秒内连接应该建立。如果网络延迟高,这个值太小,连接会频繁失败。如果网络很快,这个值太大,故障检测会很慢。

maxRetriesPerRequest: 3 意味着你允许一个请求失败三次。如果 Redis 不稳定,三次可能不够。如果 Redis 很稳定,三次重试可能浪费时间。

retryStrategy 定义了重试的节奏。立即重试可能加重 Redis 负担,导致雪崩。等待太久可能延误恢复时间。最常见的策略是 指数退避

const redisConfig = {
  host: process.env.REDIS_HOST,
  port: Number(process.env.REDIS_PORT),

  // 连接超时:根据网络环境调整
  connectTimeout: 10000,

  // 命令超时:防止慢查询阻塞
  commandTimeout: 5000,

  // 重试策略:指数退避
  retryStrategy: (times) => {
    if (times > 10) {
      // 重试 10 次后放弃
      return null;
    }
    // 第一次 50ms,第二次 100ms,第三次 150ms...
    return Math.min(times * 50, 2000);
  },

  // 重连策略:连接断开后如何重连
  reconnectOnError: (err) => {
    const targetError = 'READONLY';
    if (err.message.includes(targetError)) {
      // 遇到只读错误时重连
      return true;
    }
    return false;
  },
};

没有最优配置,只有适合当前环境的配置。

生产环境和开发环境的配置应该不同。高峰期和低谷期的配置可能需要调整。配置不是一次性的,而是随环境演化的。

错误处理决定了边界

连接会失败。Redis 挂了,网络断了,密码错了。

你无法阻止失败,但可以决定失败后如何应对。

class RedisManager {
  private client: Redis | null = null;
  private isConnecting = false;

  async getClient(): Promise<Redis> {
    if (this.client && this.client.status === 'ready') {
      return this.client;
    }

    if (this.isConnecting) {
      // 避免并发初始化
      await this.waitForConnection();
      return this.client!;
    }

    try {
      this.isConnecting = true;
      this.client = await this.createConnection();
      return this.client;
    } catch (error) {
      console.error('Failed to connect to Redis:', error);

      // 降级策略:返回内存缓存?抛出错误?
      throw new Error('Redis unavailable');
    } finally {
      this.isConnecting = false;
    }
  }

  private async createConnection(): Promise<Redis> {
    const client = new Redis({
      host: process.env.REDIS_HOST,
      retryStrategy: (times) => {
        if (times > 5) {
          console.error('Redis connection failed after 5 retries');
          return null;
        }
        return Math.min(times * 100, 3000);
      },
    });

    return new Promise((resolve, reject) => {
      client.on('ready', () => resolve(client));
      client.on('error', (err) => reject(err));

      // 设置超时
      setTimeout(() => {
        reject(new Error('Connection timeout'));
      }, 10000);
    });
  }
}

错误处理不只是 try-catch。它包括:

  • 检测错误:连接失败、超时、认证失败
  • 分类错误:可重试的 vs 不可重试的
  • 应对错误:重试、降级、报警
  • 恢复机制:自动重连、手动干预

系统的边界,是由错误处理定义的。

处理得好,系统有韧性,能从失败中恢复。处理得差,一个连接失败就导致整个应用崩溃。

初始化时机的权衡

什么时候初始化连接?应用启动时,还是第一次使用时?

启动时初始化的优点是及早发现问题。如果连接失败,应用启动就会失败,不会让错误潜伏到运行时。

缺点是增加了启动时间。如果 Redis 暂时不可用,应用无法启动,即使应用的其他功能可能不依赖 Redis。

按需初始化的优点是灵活。应用可以先启动,在真正需要 Redis 时再连接。如果 Redis 不可用,只有依赖它的功能会失败,其他功能仍然可用。

缺点是延迟了错误发现。用户请求时才发现连接失败,体验会很差。

// 启动时初始化
async function bootstrap() {
  const redis = await RedisConnection.getInstance();
  console.log('Redis ready');

  const app = createApp();
  app.listen(3000);
}

// 按需初始化
async function handleRequest(req, res) {
  try {
    const redis = await RedisConnection.getInstance();
    const data = await redis.get('key');
    res.json({ data });
  } catch (error) {
    // 降级处理
    res.status(503).json({ error: 'Service temporarily unavailable' });
  }
}

选择取决于你的容忍度。如果 Redis 是核心依赖,启动时初始化更合适。如果 Redis 只是锦上添花,按需初始化更灵活。

健康检查是持续的验证

连接建立了,不代表永远可用。

网络可能中断,Redis 可能重启,连接可能被回收。你需要持续验证连接的健康状态。

最简单的方式是 PING。

class RedisHealthCheck {
  private client: Redis;
  private checkInterval: NodeJS.Timer | null = null;

  constructor(client: Redis) {
    this.client = client;
  }

  startHealthCheck(intervalMs = 30000) {
    this.checkInterval = setInterval(async () => {
      try {
        const result = await this.client.ping();
        if (result !== 'PONG') {
          console.error('Redis health check failed: unexpected response');
          this.handleUnhealthy();
        }
      } catch (error) {
        console.error('Redis health check failed:', error);
        this.handleUnhealthy();
      }
    }, intervalMs);
  }

  stopHealthCheck() {
    if (this.checkInterval) {
      clearInterval(this.checkInterval);
    }
  }

  private async handleUnhealthy() {
    // 记录告警
    console.error('Redis connection unhealthy, attempting reconnection');

    // 触发重连
    try {
      await this.client.disconnect();
      await this.client.connect();
    } catch (error) {
      console.error('Reconnection failed:', error);
    }
  }
}

健康检查的频率是个权衡。检查太频繁,浪费资源。检查太少,问题发现太晚。

通常 30 秒到 1 分钟是合理的间隔。对关键系统,可以更频繁。对低优先级系统,可以更宽松。

连接不是一次性的,而是需要持续维护的。

优雅关闭是对资源的尊重

应用退出时,连接应该被正确关闭。

不关闭会怎样?连接会被强制断开,可能导致数据丢失或状态不一致。Redis 服务器会保留这个连接一段时间,占用资源。

class Application {
  private redis: Redis;

  async start() {
    this.redis = await RedisConnection.getInstance();

    // 注册关闭信号处理
    process.on('SIGTERM', () => this.shutdown());
    process.on('SIGINT', () => this.shutdown());
  }

  async shutdown() {
    console.log('Shutting down gracefully...');

    try {
      // 等待当前操作完成
      await this.waitForPendingOperations();

      // 关闭 Redis 连接
      await this.redis.quit();
      console.log('Redis connection closed');

      // 退出进程
      process.exit(0);
    } catch (error) {
      console.error('Error during shutdown:', error);
      process.exit(1);
    }
  }

  private async waitForPendingOperations() {
    // 实现取决于你的应用逻辑
    // 可能需要等待 [[bullmq-queue|队列]]清空、请求完成等
  }
}

优雅关闭的步骤:

  1. 停止接受新请求
  2. 等待当前请求完成
  3. 关闭连接
  4. 退出进程

这不只是礼貌,而是对资源的尊重。你使用了资源,就有责任正确释放。

连接即承诺

初始化 Redis 连接,是应用对外部世界的第一次握手。

这个握手建立了一个承诺:我会正确使用你的资源,会处理失败,会优雅关闭。

连接的质量反映了系统的成熟度。

随意创建连接,说明对资源没有敬畏。忽略错误处理,说明对失败没有准备。不做健康检查,说明对稳定性没有追求。

连接不是技术细节,而是系统与外部世界建立信任的方式。

在一个分布式的世界里,没有什么是可以完全信任的。网络会断,服务会挂,资源会耗尽。

但通过正确的初始化、配置、错误处理、健康检查,你可以把不可靠的连接变成相对可靠的基础。

这是所有可靠性的起点。


连接池的规模权衡

连接池过大会占用过多内存和端口。过小会导致请求等待,成为性能瓶颈。规模取决于并发量、Redis 响应时间和业务容忍度。

测试的复杂性

健康检查机制需要在测试环境中模拟各种故障场景。网络中断可以通过 iptables 规则模拟。Redis 重启可以通过容器重启或进程管理模拟。真实的混沌工程能暴露很多边界情况。

微服务的连接管理

多个服务共享 Redis 时,每个服务应该维护自己的连接实例。不要跨服务共享连接对象。连接池大小应该根据各服务的实际负载独立调整。

失败时的决策

应用启动时 Redis 连接失败,是否该停止整个应用,取决于 Redis 在系统中的角色。如果它只是缓存,可以降级运行。如果它是 任务队列的基础,停止可能更合理。

健康验证的多样性

PING 最简单但信息有限。执行一次实际的读写操作更能反映真实可用性。检查连接池的可用连接数量能提前发现资源耗尽。监控命令响应时间能发现性能下降。

连接泄露的防护

连接泄露通常由未正确释放的连接引起。监控活跃连接数的增长趋势能及早发现。设置连接超时和最大生命周期能防止僵尸连接累积。


参考资源

相关阅读

Related Posts

Articles you might also find interesting

BullMQ 队列

3 min read

队列不是技术选型,而是对时间的承认,对顺序的尊重,对不确定性的应对

系统设计异步处理
Read More

BullMQ Worker

2 min read

Worker 的本质是对时间的重新分配,是对主线的解放,也是对专注的追求

系统设计异步处理
Read More

CRUD 操作

2 min read

四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑

系统设计软件工程
Read More

数据库参数国际化:从 13 个迁移学到的设计原则

3 min read

数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。

数据库国际化
Read More

错误隔离

3 min read

失败是必然的。真正的问题不是失败本身,而是失败如何蔓延。错误隔离不是为了消除失败,而是为了控制失败的范围。

系统设计可靠性工程
Read More

监控观察期法

3 min read

部署不是结束,而是验证的开始。修复代码只是假设,监控数据才是证明。48小时观察期:让错误主动暴露,让数据证明修复。

系统设计监控
Read More

队列生产者实例的工厂函数

4 min read

工厂函数不是设计模式的炫技,而是对重复的拒绝,对集中管理的追求,对变化的准备

系统设计设计模式
Read More

监听 Redis 连接事件 - 让不可见的脆弱变得可见

4 min read

连接看起来应该是透明的。但当它断开时,你才意识到透明不等于可靠。监听不是多余,而是对脆弱性的承认。

系统设计Redis
Read More

指数退避超时 - 防止无限重试循环

3 min read

失败后立即重试是本能。但有些失败,需要时间来消化。指数退避不是逃避失败,而是尊重失败。

系统设计可靠性工程
Read More

幂等性检查

1 min read

在不确定的系统中,幂等性检查是对重复的容忍,是对稳定性的追求,也是对失败的预期与接纳

系统设计分布式系统
Read More

PostgreSQL 原生不支持直接添加枚举值

1 min read

当一个类型系统拒绝改变,它在保护什么?

数据库系统设计
Read More

管理后台需要两次设计

3 min read

第一次设计回答"发生了什么",第二次设计回答"我能做什么"。在第一次就试图解决所有问题,结果是功能很多但都不够深入。

系统设计API 设计
Read More

告警分级与响应时间

2 min read

不是所有问题都需要立即响应。RPC失败会在凌晨3点叫醒人。安全事件每15分钟检查一次。支付成功只记录,不告警。系统的响应时间应该匹配问题的紧急程度。

系统设计监控
Read More

文档标准是成本计算的前提

3 min read

API文档不只是写给开发者看的。它定义了系统的边界、成本结构和可维护性。统一的文档标准让隐性成本变得可见。

API文档
Read More

配置不会自动同步

2 min read

视频生成任务永远pending,代码完美部署,队列正确配置。问题不在代码,在于配置的独立性被低估。静默失败比错误更危险。

部署配置管理
Read More

执行数据库迁移的三种路径

2 min read

CLI、MCP 与线上 SQL——每种方法背后的权衡与适用场景。迁移不只是执行命令,更是选择控制权与便利性之间的平衡点。

数据库迁移
Read More

诊断 Supabase 连接失败:借助 MCP 工具链

2 min read

连接失败不仅是配置问题,更是关于理解系统状态边界的过程。通过 Supabase MCP 与 Claude Code,让不可见的问题变得可观测。

SupabaseMCP
Read More

Stripe Webhook中的防御性编程

2 min read

三个Bug揭示的真相:假设是代码中最危险的东西。API返回类型、环境配置、变量作用域——每个看似合理的假设都可能导致客户损失。

Web开发系统设计
Read More

双重验证:Stripe生产模式的防御性切换

7 min read

从测试到生产不是更换API keys,而是建立一套双重验证系统。每一步都在两个环境中验证,确保真实支付不会因假设而失败。

系统设计Stripe
Read More

在运行的系统上生长新功能

3 min read

扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。

系统设计架构
Read More

实现幂等性处理,忽略已处理的任务

3 min read

在代码层面识别和忽略已处理的任务,不是简单的布尔检查,而是对时序、并发和状态的深刻理解

系统设计并发控制
Read More

缺失值的级联效应

3 min read

一个NULL值如何在调用链中传播,最终导致错误的错误消息。理解防御层的设计,在失败传播前拦截。

系统设计防御性编程
Read More

用 MCP 让 Claude Code 执行 Prisma 迁移

2 min read

借助 Model Context Protocol,Claude Code 可以直接操作 Supabase 云数据库,完成 Prisma schema 的迁移和部署

Claude CodeMCP
Read More

Props Drilling

3 min read

数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。

React组件设计
Read More

查询先于假设

3 min read

数据库迁移后,所有功能失效。问题不在迁移本身,而在假设。真相只存在于查询结果中。

数据库迁移
Read More