数据库与持久化
你当前正在浏览历史版本 Vert.x 4.1.8 的文档。 访问本页面的 最新版本。

Vert.x Redis

Vert.x Redis 是配合 Vert.x 一起使用的 Redis 客户端。

该模块支持在 Redis 中保存、检索、搜索和删除数据。 Redis 是一个开源的高级键值对存储数据库。 它通常被称为数据结构服务器,因为键可以包含字符串(Strings)、哈希(Hashes)、列表(Lists)、集合(Sets)和有序集合(Sorted Sets)。 使用该模块前,你的网络中必须有一个正在运行的 Redis 服务实例。

Redis 拥有丰富的 API,大致可划分为以下几组:

  • Cluster —— 集群管理相关命令。请注意,使用其中大部分命令都需要 Redis 服务器版本 >= 3.0.0。

  • Connection —— 允许切换数据库、连接、断开连接以及向服务器进行身份验证的命令。

  • Hashes —— 针对哈希进行操作的命令。

  • HyperLogLog —— 用于估算多重集合中基数(不重复元素数量)的命令。

  • Keys —— 操作键的命令。

  • List —— 操作列表的命令。

  • Pub/Sub —— 创建消息队列以及发布/订阅客户端的命令。

  • Scripting —— 在 Redis 中执行 Lua 脚本的命令。

  • Server —— 管理和获取服务器配置信息的命令。

  • Sets —— 操作无序集合的命令。

  • Sorted Sets —— 操作有序集合的命令。

  • Strings —— 操作字符串的命令。

  • Transactions —— 处理事务生命周期的命令。

  • Streams —— 处理流式数据的命令。

使用 Vert.x Redis

要使用 Vert.x Redis 客户端,请将以下依赖项添加到项目构建描述文件的 dependencies 部分中。

  • Maven(在 pom.xml 中):

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-redis-client</artifactId>
  <version>${vertx-redis.version}</version>
</dependency>
  • Gradle(在 build.gradle 中):

compile 'io.vertx:vertx-redis-client:${vertx-redis.version}'

连接到 Redis

Redis 客户端可以在 4 种不同的模式下运行:

  • 单机客户端(Standalone client,可能是大多数用户所需要的)。

  • Sentinel(以高可用模式运行 Redis 时)。

  • Cluster(以集群模式运行 Redis 时)。

  • 复制模式(Replication,单分片,单节点写入,多节点读取)。

连接模式通过 Redis 接口上的工厂方法进行选择。 无论采用哪种模式,都可以使用 RedisOptions 数据对象对客户端进行配置。 默认情况下,部分配置项会初始化为以下默认值:

  • netClientOptions:默认值为 TcpKeepAlive: true,TcpNoDelay: true

  • endpoint:默认值为 redis://localhost:6379

  • masterName:默认值为 mymaster

  • role:默认值为 MASTER

  • useReplicas:默认值为 NEVER

可以使用以下代码获取连接:

Redis.createClient(vertx)
  .connect()
  .onSuccess(conn -> {
    // 使用该连接
  });

如果配置中包含密码(password)和/或 select 数据库,与服务器成功建立连接后将自动执行这两条命令。

Redis.createClient(
    vertx,
    // 客户端支持解析 REDIS URL。根据规范,select 数据库为 URL 的数字路径部分,
    // 密码为 URL 认证信息的 password 字段
    "redis://:abracadabra@localhost:6379/1")
  .connect()
  .onSuccess(conn -> {
    // 使用该连接
  });

使用 TLS 连接

你可以通过配置客户端的 TCP 选项来使用 TLS 连接到 Redis 服务器,请确保设置了以下内容:

  • ssl 标志

  • 服务器证书或 trust all(信任所有证书)标志

  • 主机名验证算法:若要验证服务器身份则设置为 "HTTPS";否则设为 ""

RedisOptions options = new RedisOptions();
options.setConnectionString("redis://:abracadabra@localhost:6379/1");
NetClientOptions tcpOptions = options.getNetClientOptions();
tcpOptions
  .setSsl(true)
  .setTrustOptions(new PemTrustOptions().addCertPath("/path/to/server.crt"))
  // 算法可以设为空字符串 "",或者设为 "HTTPS" 以验证服务器主机名
  .setHostnameVerificationAlgorithm(algo);
Redis.createClient(
    vertx,
    options)
  .connect()
  .onSuccess(conn -> {
    // 使用该连接
  });
关于 TLS 客户端配置的更多详细信息,请参阅 此处。

连接字符串

客户端能够识别符合以下表达式的地址:

redis://[:password@]host[:port][/db-number]

或者

unix://[:password@]/domain/docker.sock[?select=db-number]

当指定了密码或数据库时,这些命令总是在连接建立之初自动执行。

异步提供 RedisConnectOptions

Redis.createClient() 方法接收一个包含全部配置项的 RedisOptions 对象。 这是连接到 Redis 最常用的方式。

不过,还可以同步提供 RedisOptions 并异步提供 RedisConnectOptions。 具有相同参数列表的 4 个方法支持该功能:

  • Redis.createStandaloneClient()

  • Redis.createReplicationClient()

  • Redis.createSentinelClient()

  • Redis.createClusterClient()

这些方法接收 Vertx 对象、RedisOptions 对象以及一个 Supplier<Future<RedisConnectOptions>>。 RedisOptions 对象主要提供静态的 NetClientOptions 与 PoolOptions。 而 Supplier<Future<RedisConnectOptions>> 会在每次需要创建连接时被调用,用以提供动态配置项。 该类型清晰表明这些动态配置项可以异步提供。

最典型的应用场景是配合 IAM 认证使用 Amazon ElastiCache。 IAM 认证使用短效 Token(生命周期仅 15 分钟),因此需要频繁重新生成。

以下是 Supplier<Future<RedisConnectOptions>> 的一种实现,它会将 RedisConnectOptions 缓存 10 分钟:

public class RedisConnectOptionsSupplier<OPTS extends RedisConnectOptions> implements Supplier<Future<OPTS>> {
  private final Vertx vertx;
  private final RedisOptions options;
  private final Function<RedisOptions, OPTS> creator;
  private final Supplier<String> userName;
  private final Supplier<String> password;
  private final AtomicReference<Future<OPTS>> future;

  public RedisConnectOptionsSupplier(Vertx vertx, RedisOptions options, Function<RedisOptions, OPTS> creator,
    Supplier<String> userName, Supplier<String> password) {
    this.vertx = vertx;
    this.options = options;
    this.creator = creator;
    this.userName = userName;
    this.password = password;
    this.future = new AtomicReference<>();
  }

  @Override
  public Future<OPTS> get() {
    while (true) {
      Future<OPTS> currentFuture = this.future.get();
      if (currentFuture != null) {
        return currentFuture;
      }

      Promise<OPTS> promise = Promise.promise();
      Future<OPTS> future = promise.future();
      if (this.future.compareAndSet(null, future)) {
        OPTS result = creator.apply(options);
        result.setUser(userName.get());
        result.setPassword(password.get()); // TODO 在 Worker 线程中执行
        promise.complete(result);

        // 10 分钟后清理,以强制刷新
        vertx.setTimer(10 * 60 * 1000, ignored -> {
          this.future.set(null);
        });

        return future;
      }
    }
  }
}
package examples;

import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.http.SdkHttpMethod;
import software.amazon.awssdk.http.SdkHttpRequest;
import software.amazon.awssdk.http.auth.aws.signer.AwsV4FamilyHttpSigner;
import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner;
import software.amazon.awssdk.http.auth.spi.signer.SignRequest;

import java.net.URI;
import java.time.Duration;

public class IamAuthToken {
  private static final String PROTOCOL = "http://";

  private final String userId;
  private final String replicationGroupId;
  private final String region;
  private final AwsCredentialsProvider credentials;

  public IamAuthToken(String userId, String replicationGroupId, String region, AwsCredentialsProvider credentials) {
    this.userId = userId;
    this.replicationGroupId = replicationGroupId;
    this.region = region;
    this.credentials = credentials;
  }

  public String getUserId() {
    return userId;
  }

  public String getToken() {
    URI uri = URI.create(PROTOCOL + replicationGroupId + "/");
    SdkHttpRequest request = SdkHttpRequest.builder()
      .method(SdkHttpMethod.GET)
      .uri(uri)
      .appendRawQueryParameter("Action", "connect")
      .appendRawQueryParameter("User", userId)
      .build();

    SdkHttpRequest signedRequest = sign(request, credentials.resolveCredentials());
    return signedRequest.getUri().toString().replace(PROTOCOL, "");
  }

  private SdkHttpRequest sign(SdkHttpRequest request, AwsCredentials credentials) {
    SignRequest<AwsCredentials> signRequest = SignRequest.builder(credentials)
      .request(request)
      .putProperty(AwsV4HttpSigner.REGION_NAME, region)
      .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, "elasticache")
      .putProperty(AwsV4HttpSigner.EXPIRATION_DURATION, Duration.ofSeconds(900))
      .putProperty(AwsV4HttpSigner.AUTH_LOCATION, AwsV4FamilyHttpSigner.AuthLocation.QUERY_STRING)
      .build();
    return AwsV4HttpSigner.create().sign(signRequest).request();
  }
}

可以这样实例化该辅助类:

AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder()
  .asyncCredentialUpdateEnabled(true)
  .build();
IamAuthToken token = new IamAuthToken("my-user", "my-redis", "us-east-1", credentialsProvider);

然后,可以按如下方式实例化 Redis 客户端:

Redis client = Redis.createStandaloneClient(vertx, redisOptions, new RedisConnectOptionsSupplier<>(vertx,
  redisOptions, RedisStandaloneConnectOptions::new, token::getUserId, token::getToken));

执行命令

在 Redis 客户端连接到服务器后,即可通过该模块执行所有命令。 该模块提供了一套整洁的 API 来执行命令,无需手动拼接命令字符串。例如,若想获取某个键的值,可以这样编写:

RedisAPI redis = RedisAPI.api(client);

redis
  .get("mykey")
  .onSuccess(value -> {
    // 处理业务逻辑...
  });

响应对象是一个泛型类型,支持从 Redis 基础类型转换为你的编程语言类型。 例如,如果响应类型为 INTEGER,你可以将其值获取为任何基本数值类型,如 int、long 等。

或者你也可以执行更复杂的任务,例如将响应作为迭代器进行遍历:

if (response.type() == ResponseType.MULTI) {
  for (Response item : response) {
    // 处理 item...
  }
}

Sentinel 模式

使用 Sentinel 模式(也称为高可用模式)时,连接创建方式非常类似:

Redis.createClient(
    vertx,
    new RedisOptions()
      .setType(RedisClientType.SENTINEL)
      .addConnectionString("redis://127.0.0.1:5000")
      .addConnectionString("redis://127.0.0.1:5001")
      .addConnectionString("redis://127.0.0.1:5002")
      .setMasterName("sentinel7000")
      .setRole(RedisRole.MASTER))
  .connect()
  .onSuccess(conn -> {
    conn.send(Request.cmd(Command.INFO))
      .onSuccess(info -> {
        // 处理业务逻辑...
      });
  });

这里的连接字符串指向 Sentinel 节点,用于发现实际的集群拓扑(完整的 Sentinel 节点集合、主节点以及副本节点)。 Sentinel Redis 客户端会维护已发现拓扑的缓存。 当缓存为空时,第一次尝试获取连接将对配置的某个 Sentinel 节点执行 3 条命令(SENTINEL SENTINELS <master name>、SENTINEL GET-MASTER-ADDR-BY-NAME <master name>、SENTINEL REPLICAS <master name>)。 该缓存具有可配置的 TTL(存活时间),默认值为 1 秒。

需要特别注意的是,在此模式下,当选定的角色为 MASTER(默认值)且启用了自动故障转移(RedisOptions.setAutoFailover(true))时,会额外与其中一个 Sentinel 建立一条连接以监听故障转移(Failover)事件。 当 Sentinel 通知选举产生新的 Master 时,所有客户端都将关闭与旧 Master 的连接,并透明地重连到新 Master。

请注意,在旧 Master 发生故障到新 Master 选举完成之间存在短暂的时间窗口,在此期间既有连接上的所有操作都会暂时失败。 在新 Master 选举产生后,连接将自动切换到新 Master 并恢复正常工作。

Cluster 模式

使用 Cluster 集群模式时,连接创建方式也非常类似:

final RedisOptions options = new RedisOptions()
  .setType(RedisClientType.CLUSTER)
  .addConnectionString("redis://127.0.0.1:7000")
  .addConnectionString("redis://127.0.0.1:7001")
  .addConnectionString("redis://127.0.0.1:7002")
  .addConnectionString("redis://127.0.0.1:7003")
  .addConnectionString("redis://127.0.0.1:7004")
  .addConnectionString("redis://127.0.0.1:7005");

在此场景下,配置中需要指定集群中一个或多个已知的成员节点。 该列表将用于向集群查询当前配置信息;这意味着列表中任何不可用的成员都会被自动跳过。

在 Cluster 模式下,客户端会与每个节点都建立一条连接,执行命令时需要格外注意。 建议阅读 Redis 官方手册以深入了解集群的工作原理。 在此模式下运行的客户端会尽力识别执行的命令所使用的哈希槽(Slot),以便将其发送到正确的节点上执行。 在某些无法确定对应槽位的情况下,客户端会尽力在随机节点上运行该命令。

为了掌握哪些 Redis 节点持有对应的槽位,集群 Redis 客户端维护了一份集群拓扑缓存(哈希槽分配情况)。 当缓存为空时,第一次尝试获取连接将执行 CLUSTER SLOTS。 该缓存具有可配置的 TTL(存活时间),默认值为 1 秒。 此外,无论何时客户端执行的任何命令收到了 MOVED 重定向响应,该缓存都会被清空。

Cluster 工具

RedisCluster 类提供了一些在 Redis Cluster 中非常实用的方法。 若要创建实例,可使用 Redis 对象或 RedisConnection 对象调用 create()。 如果对非集群模式的 Redis / RedisConnection 调用 create(),将会抛出异常。

RedisCluster 提供的方法包括:

  • onAllNodes(Request):在集群中的所有节点上执行该请求。 返回一个 Future,操作成功时返回各节点的响应列表(每个节点一个响应),若其中某项操作失败则宣告失败。 请注意,一旦发生失败,无法保证该请求在其他 Redis 集群节点上是否已成功执行。 同时也不保证结果的顺序。

  • onAllMasterNodes(Request):在集群中的所有 Master 节点上执行该请求。 返回一个 Future,操作成功时返回各 Master 节点的响应列表(每个 Master 节点一个响应),若其中某项操作失败则宣告失败。 请注意,一旦发生失败,无法保证该请求在其他 Redis 集群 Master 节点上是否已成功执行。 同样不保证结果的顺序。

  • groupByNodes(List<Request>):将请求分组为 RequestGrouping,其中包含:

    • 带键(keyed) 请求:包含键的请求,因此可以确定应该将其发送到哪个 Master 节点;keyed 集合中每个内部列表的所有请求,都保证会发送到同一个 Master 节点;

    • 无键(unkeyed) 请求:不包含键的请求,因此 无法 确定应该发送到哪个 Master 节点。

    如果任何请求包含属于不同 Master 节点的多键,返回的 Future 将会失败。

    如果集群客户端创建时配置了 RedisReplicas.SHARE 或 RedisReplicas.ALWAYS,并且命令是单独执行的(使用 RedisConnection.send() 而非 RedisConnection.batch()),那么命令可能会分散分发到同一个 Master 节点的不同 Replica 副本上。

    请注意,该方法仅在 Redis 集群处于稳定状态时才可靠。 若发生重新分片(Resharding)、故障转移(Failover)或任何集群拓扑变化,均无法保证结果的有效性。

复制模式 (Replication Mode)

复制模式对客户端来说是透明的。 获取连接是一项开销较大的操作。 客户端会轮询提供的端点列表,直到找到 Master 节点。 一旦识别出 Master 节点(所有写命令都将在此节点执行),客户端将尽力连接所有 Replica 副本节点(读节点)。

掌握所有节点信息后,客户端将读写操作分别过滤并路由到正确的节点类型。 请注意,useReplica 配置会影响此选择。 与集群模式一样,当配置声明 Replica 节点的使用策略为 ALWAYS 时,任何读操作都将在 Replica 节点上执行;SHARED 会在 Master 和 Replica 之间随机分担读操作;而 NEVER 则表示绝不使用 Replica 节点。

鉴于获取连接的开销,此模式的推荐用法是在应用程序需要期间尽可能复用该连接。

Redis.createClient(
    vertx,
    new RedisOptions()
      .setType(RedisClientType.REPLICATION)
      .addConnectionString("redis://localhost:7000")
      .setMaxPoolSize(4)
      .setMaxPoolWaiting(16))
  .connect()
  .onSuccess(conn -> {
    // 这是一个复制模式客户端,
    // 写操作将路由至 Master 节点
    conn.send(Request.cmd(Command.SET).arg("key").arg("value"));
    // 读操作可能路由至 Replica 节点
    // (取决于具体配置及可用状态)
    conn.send(Request.cmd(Command.GET).arg("key"));
  });

静态拓扑

复制模式支持静态配置多节点拓扑。 在静态拓扑中,配置中的第一个节点被假定为 Master 节点,而其余节点被假定为 Replica 节点。 客户端不会验证这些节点;确保静态配置正确是应用程序开发者的责任。

配置步骤如下:

  • 重复调用 RedisOptions.addConnectionString() 配置静态拓扑(第一次调用配置 Master 节点,后续调用配置 Replica 节点),并且

  • 调用 RedisOptions.setTopology(RedisTopology.STATIC)。

Redis.createClient(
    vertx,
    new RedisOptions()
      .setType(RedisClientType.REPLICATION)
      .setTopology(RedisTopology.STATIC)
      .addConnectionString("redis://localhost:7000")
      .addConnectionString("redis://localhost:7004")
      .setMaxPoolSize(4)
      .setMaxPoolWaiting(16))
  .connect()
  .onSuccess(conn -> {
    // 这是一个复制模式客户端,
    // 写操作将路由至 Master 节点
    conn.send(Request.cmd(Command.SET).arg("key").arg("value"));
    // 读操作可能路由至 Replica 节点
    // (取决于具体配置及可用状态)
    conn.send(Request.cmd(Command.GET).arg("key"));
  });

请注意,自动发现拓扑通常是首选方案。 仅在必要时才应使用静态配置。 其中一个典型案例是 Amazon ElastiCache for Redis (Cluster Mode Disabled),在此场景下:

  • Master 节点应设置为 主端点(Primary Endpoint),并且

  • 一个 Replica 节点应设置为 只读端点(Reader Endpoint)。

请注意,ElastiCache for Redis (Cluster Mode Disabled) 的只读端点是一个域名,该域名解析为指向其中一个副本的 CNAME 记录。 只读端点所解析到的 CNAME 记录会随时间而变化。 这种基于 DNS 的负载均衡机制与 DNS 解析缓存和连接池结合使用时效果欠佳。 因此,某些副本很可能未被充分利用。 而 ElastiCache for Redis (Cluster Mode Enabled) 则不存在该问题,因为它采用经典的轮询 DNS(Round-robin DNS)。

Redis 事务

Vert.x Redis 客户端支持 Redis 事务。 你只需发出相应的命令即可:MULTI、EXEC、DISCARD、WATCH 或 UNWATCH。 请注意,Redis 中的事务 并不是 SQL 数据库中经典的 ACID 事务;它们只是将多个命令排队以便稍后执行。

事务必须在单个连接上执行。 尝试在无连接模式(Redis.send())下执行事务命令将会失败。 可以在无连接批处理(Redis.batch())中执行事务,但该批处理必须包含整个事务;严禁将其拆分到多个批处理中。

建议始终先获取连接(Redis.connect()),然后在该连接上执行事务的所有命令。

Cluster 中的事务

默认情况下,Redis Cluster 中的事务是禁用的。 尝试执行事务命令将导致失败。

可以通过以下方式在 Redis Cluster 中启用单节点事务:

options.setClusterTransactions(RedisClusterTransactions.SINGLE_NODE);

在单节点事务中,第一个命令(如果是 WATCH)或第二个命令(如果第一个命令是 MULTI)决定了事务应在哪个节点上执行。 连接将绑定到所选节点,所有后续命令都将发送到该节点,而无需考虑集群拓扑(哈希槽分配情况)。 当事务的结束命令(EXEC 或 DISCARD)执行后,连接将重置为默认模式,不再绑定到单个节点。

如果事务以 WATCH 开始,该命令带有键,因此决定了目标节点。 如果事务以 MULTI 开始,该命令不会直接发送到 Redis,而是排队等待直到下一条命令执行。 正是下一条命令决定了目标节点(因此该命令应该带有键,否则目标节点将是随机的)。

请注意,上述所有规则仅适用于 RedisConnection.send()。 命令批处理(RedisConnection.batch())始终在集群中的单个节点上执行,因此没有针对事务的特殊支持(它们在默认情况下甚至未被禁用)。 同样,批处理必须包含整个事务;严禁将其拆分到多个批处理中。

发布/订阅模式 (Pub/Sub)

Redis 支持消息队列和发布/订阅(Pub/Sub)模式。在此模式下运行且某条连接一旦进入订阅者模式后,便不能再用于执行除退出该模式以外的其他命令。

启动订阅者的操作如下:

Redis.createClient(vertx, new RedisOptions())
  .connect()
  .onSuccess(conn -> {
    conn.handler(message -> {
      // 在此处处理接收到的消息
    });
  });

并在代码的其他地方向频道/队列发布消息:

redis.send(Request.cmd(Command.PUBLISH).arg("channel1").arg("Hello World!"))
  .onSuccess(res -> {
    // 发布成功!
  });
请务必注意,命令 SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE 和 PUNSUBSCRIBE 是 void 类型的。 这意味着成功时的结果为 null,而不是响应实例。 所有接收到的消息随后都会通过客户端上注册的处理器进行路由分发。

EventBusHandler

Vert.x Redis 客户端 4.x 版本中,除非显式注册了 RedisConnection.handler(),否则会自动将消息转发到 Vert.x 事件总线(Event Bus)。

在 Vert.x Redis 客户端 5.x 版本中,该自动转发已被移除。 如果你仍需要该行为,必须手动创建 EventBusHandler 实例并通过 RedisConnection.handler() 进行注册:

Redis redis = Redis.createClient(vertx);
redis.connect()
  .onSuccess(conn -> {
    conn.handler(EventBusHandler.create(vertx));
    conn.send(Request.cmd(Command.SUBSCRIBE).arg("news"));
  });

EventBusHandler 允许自定义地址前缀;如果你希望使用 com.example.<频道名称> 的地址(而非 io.vertx.redis.<频道名称>),可以使用 EventBusHandler.create(vertx, "com.example")。

发送到 Vert.x 事件总线的消息是一个 JsonObject,格式如下:

{
  "status": "OK",
  "type": "message|subscribe|unsubscribe|pmessage|psubscribe|punsubscribe",
  "value": {
    "channel": "<the channel>", (1)
    "message": "<the message>", (2)
    "pattern": "<the pattern>", (3)
    "current": <number of current subscriptions> (4)
  }
}
1 适用于 [p]message、subscribe 和 unsubscribe。
2 适用于 [p]message。
3 适用于 pmessage、psubscribe 和 punsubscribe。
4 适用于 [p]subscribe 和 [p]unsubscribe。

对于 message、subscribe 和 unsubscribe 消息,事件总线地址为 <prefix>.<频道名称>;对于 pmessage、psubscribe 和 punsubscribe 消息,事件总线地址为 <prefix>.<模式匹配表达式>。

追踪命令 (Tracing)

当 Vert.x 启用了分布式追踪(Tracing)时,Redis 客户端可以追踪命令的执行情况。

客户端会上报包含以下详细信息的 客户端(Client) Span:

  • 操作名称(Operation Name):Command

  • 标签(Tags):

    • db.user:数据库用户名(若已设置)

    • db.instance:数据库编号(若已知,通常为 0)

    • db.statement:Redis 命令,不带参数(例如 get 或 set)

    • db.type:redis

默认的追踪策略为 PROPAGATE,客户端仅在处于活跃追踪上下文中时才会创建 Span。

你可以使用 setTracingPolicy 更改客户端策略,例如可以将其设置为 ALWAYS 以始终上报 Span:

options.setTracingPolicy(TracingPolicy.ALWAYS);

Domain Socket (域套接字)

前面的大多数示例展示的都是连接到 TCP 套接字,但也可以使用 UNIX Domain Socket 连接 Redis:

Redis.createClient(vertx, "unix:///tmp/redis.sock")
  .connect()
  .onSuccess(conn -> {
    // 处理业务逻辑...
  });

请注意,高可用(HA)模式和 Cluster 模式返回的服务器地址始终为 TCP 地址而非 Domain Socket。 因此无法将这两者结合使用。 这不是因为该客户端的限制,而是 Redis 本身的工作机制所致。

连接池 (Connection Pooling)

所有客户端变体底层都由连接池提供支持。 默认情况下,配置将连接池大小设置为 1,这意味着其表现类似于单条连接。 连接池具有 4 个可调参数:

  • maxPoolSize:池中的最大连接数(默认为 6)

  • maxPoolWaiting:队列中等待获取连接的最大处理器数量(默认为 24)

  • poolCleanerInterval:清理连接的执行时间间隔(默认为 30 seconds)

  • poolRecycleTimeout:在池中保持空闲连接的超时时间(默认为 3 mintues)

使用连接池能有效避免手动管理连接,例如可以像这样直接使用:

Redis.createClient(vertx, "redis://localhost:7006")
  .send(Request.cmd(Command.PING))
  .onSuccess(res -> {
    // 此时应已收到 pong...
  });

需要注意的一点是,此处既没有显式获取连接也没有显式归还连接,一切均由连接池自动处理。 然而,当多个并发请求同时尝试从连接池中获取连接时,可能会出现伸缩性瓶颈;为了克服这一点,我们需要对连接池进行调优。 常见的配置是将连接池的最大容量设置为可用 CPU 核心数,并允许获取连接的请求排队等待:

Redis.createClient(
    vertx,
    new RedisOptions()
      .setConnectionString("redis://localhost:7006")
      // 允许最多与 Redis 建立 8 条连接
      .setMaxPoolSize(8)
      // 允许最多 32 个连接请求在队列中等待可用连接
      .setMaxWaitingHandlers(32))
  .send(Request.cmd(Command.PING))
  .onSuccess(res -> {
    // 此时应已收到 pong...
  });
连接池与 SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE 或 PUNSUBSCRIBE 不兼容,因为这些命令会改变连接的运行模式,导致连接无法被复用。

实现出错重连

虽然连接池非常实用,但出于性能考量,连接有时不应自动托管,而是应该由你主动控制。 在此情况下,你需要自行处理连接恢复、错误处理以及重新连接。

一个典型的场景是:每当发生错误时,用户都希望重新连接到服务器。 Redis 客户端本身并未内置自动重连机制,因为强制某种重连行为可能会与用户的预期不符,例如:

  1. 正在进行中的未完成请求(in-flight requests)应该如何处理?

  2. 是否应该触发异常处理器?

  3. 如果重试也失败了该怎么办?

  4. 是否应该恢复先前的状态(数据库选择、身份验证、订阅关系)?

  5. 等等……

为了赋予用户充分的灵活性,这些决策不应由客户端代为决定。 不过,可以按如下方式实现一个带有退避超时的简单重连逻辑:

class RedisVerticle extends VerticleBase {

  private static final int MAX_RECONNECT_RETRIES = 16;

  private final RedisOptions options = new RedisOptions();
  private Redis redis;
  private RedisConnection client;
  private final AtomicBoolean CONNECTING = new AtomicBoolean();

  @Override
  public Future<?> start() {
    return createRedisClient()
      .onSuccess(conn -> {
        // 已成功连接到 Redis!
      });
  }

  /**
   * 创建 Redis 客户端,并在连接发生异常时设置重连处理器。
   */
  private Future<RedisConnection> createRedisClient() {
    Promise<RedisConnection> promise = Promise.promise();

    // 若存在旧连接,确保将其关闭失效
    if (redis != null) {
      redis.close();;
    }

    if (CONNECTING.compareAndSet(false, true)) {
      redis = Redis.createClient(vertx, options);
      redis
        .connect()
        .onSuccess(conn -> {
          client = conn;

          // 确保在发生错误时客户端进行重连
          // 例如:底层 TCP 连接已关闭但客户端尚未感知,
          // 客户端尝试使用过期连接与服务端通信时将抛出异常
          conn.exceptionHandler(e -> {
            attemptReconnect(0);
          });

          // 确保在连接关闭时客户端进行重连
          // 例如:底层 TCP 连接通过正常的四次挥手关闭,
          // 该处理器将立即收到通知
          conn.endHandler(placeHolder -> {
            attemptReconnect(0);
          });

          // 允许继续后续处理
          promise.complete(conn);
          CONNECTING.set(false);
        }).onFailure(t -> {
          promise.fail(t);
          CONNECTING.set(false);
        });
    } else {
      promise.complete();
    }

    return promise.future();
  }

  /**
   * 尝试重连,最多重试 MAX_RECONNECT_RETRIES 次
   */
  private void attemptReconnect(int retry) {
    if (retry > MAX_RECONNECT_RETRIES) {
      // 已达最大重试次数,停止重连
      CONNECTING.set(false);
    } else {
      // 退避重试,最大退避时间 10240 ms
      long backoff = (long) (Math.pow(2, Math.min(retry, 10)) * 10);

      vertx.setTimer(backoff, timer -> {
        createRedisClient()
          .onFailure(t -> attemptReconnect(retry + 1));
      });
    }
  }
}

在此示例中,重连时会替换客户端对象,应用程序最多重试 16 次,最大退避时间为 1280ms(注:代码中上限为 10240ms)。 通过丢弃原客户端,可以确保所有旧的未完成响应都被丢弃,所有新请求都将在新连接上执行。

请务必注意,重连会创建新的连接对象,因此不应缓存这些对象引用,而应在每次使用时重新获取。

协议解析器

本客户端同时支持 RESP2 和 RESP3 协议。 默认情况下,客户端在连接握手时会尝试协商使用 RESP3。

可以使用 setPreferredProtocolVersion 方法选择首选版本(RESP2 或 RESP3):

options.setPreferredProtocolVersion(ProtocolVersion.RESP2);

解析器内部会根据从服务器接收到的所有数据分块创建一个“无限”可读缓冲区。为了避免在垃圾回收方面产生过多内存垃圾,可以在 JVM 启动时配置一个可调的水位线(Watermark)值。 系统属性 io.vertx.redis.parser.watermark 定义了在丢弃之前该可读缓冲区中保留的数据量。 默认情况下,此值为 16 KB。 这意味着与服务器的每条连接将至少占用该数量的内存。 由于客户端以流水线(Pipeline)模式运行,保持较少的连接数能获得最佳效果,这意味着将占用 16 KB * 连接数 的内存。 如果应用程序需要大量连接,建议将水位线调低或将其完全禁用。