写点什么

Redis 异步客户端选型及落地实践

  • 2023-02-08
    北京
  • 本文字数:5560 字

    阅读完需:约 18 分钟

Redis 异步客户端选型及落地实践

作者:京东科技 王晨

Redis 异步客户端选型及落地实践

可视化服务编排系统是能够通过线上可视化拖拽、配置的方式完成对接口的编排,可在线完成服务的调试、测试,实现业务需求的交付,详细内容可参考:https://mp.weixin.qq.com/s/5oN9JqWN7n-4Zv6B9K8kWQ。


为了支持更加广泛的业务场景,可视化编排系统近期需要支持对缓存的操作功能,为保证编排系统的性能,服务的执行过程采用了异步的方式,因此我们考虑使用 Redis 的异步客户端来完成对缓存的操作。

Redis 客户端

Jedis/Lettuce

Redis 官方推荐的 Redis 客户端有 Jedis、Lettuce 等等,其中 Jedis 是老牌的 Redis 的 Java 实现客户端,提供了比较全面的 Redis 命令的支持,在 spring-boot 1.x 默认使用 Jedis。


但是 Jedis 使用阻塞的 IO,且其方法调用都是同步的,程序流需要等到 sockets 处理完 IO 才能执行,不支持异步,在并发场景下,使用 Jedis 客户端会耗费较多的资源。


此外,Jedis 客户端实例不是线程安全的,要想保证线程安全,必须要使用连接池,每个线程需要时从连接池取出连接实例,完成操作后或者遇到异常归还实例。当连接数随着业务不断上升时,对物理连接的消耗也会成为性能和稳定性的潜在风险点。因此在 spring-boot 2.x 中,redis 客户端默认改用了 Lettuce。


我们可以看下 Spring Data Redis 帮助文档给出的对比表格,里面详细地记录了两个主流 Redis 客户端之间的差异。


异步客户端 Lettuce

Spring Boot 自 2.0 版本开始默认使用 Lettuce 作为 Redis 的客户端。Lettuce 客户端基于 Netty 的 NIO 框架实现,对于大多数的 Redis 操作,只需要维持单一的连接即可高效支持业务端的并发请求 —— 这点与 Jedis 的连接池模式有很大不同。同时,Lettuce 支持的特性更加全面,且其性能表现并不逊于,甚至优于 Jedis。


Netty 是由 JBOSS 提供的一个 java 开源框架,现为 Github 上的独立项目。Netty 提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。


也就是说,Netty 是一个基于 NIO 的客户、服务器端的编程框架,使用 Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户、服务端应用。Netty 相当于简化和流线化了网络应用的编程开发过程,例如:基于 TCP 和 UDP 的 socket 服务开发。



上图展示了 Netty NIO 的核心逻辑。NIO 通常被理解为 non-blocking I/O 的缩写,表示非阻塞 I/O 操作。图中 Channel 表示一个连接通道,用于承载连接管理及读写操作;EventLoop 则是事件处理的核心抽象。一个 EventLoop 可以服务于多个 Channel,但它只会与单一线程绑定。EventLoop 中所有 I/O 事件和用户任务的处理都在该线程上进行;其中除了选择器 Selector 的事件监听动作外,对连接通道的读写操作均以非阻塞的方式进行 —— 这是 NIO 与 BIO(blocking I/O,即阻塞式 I/O)的重要区别,也是 NIO 模式性能优异的原因。


Lettuce 凭借单一连接就可以支持业务端的大部分并发需求,这依赖于以下几个因素的共同作用:


1.Netty 的单个 EventLoop 仅与单一线程绑定,业务端的并发请求均会被放入 EventLoop 的任务队列中,最终被该线程顺序处理。同时,Lettuce 自身也会维护一个队列,当其通过 EventLoop 向 Redis 发送指令时,成功发送的指令会被放入该队列;当收到服务端的响应时,Lettuce 又会以 FIFO 的方式从队列的头部取出对应的指令,进行后续处理。


2.Redis 服务端本身也是基于 NIO 模型,使用单一线程处理客户端请求。虽然 Redis 能同时维持成百上千个客户端连接,但是在某一时刻,某个客户端连接的请求均是被顺序处理及响应的。


3.Redis 客户端与服务端通过 TCP 协议连接,而 TCP 协议本身会保证数据传输的顺序性。



如此,Lettuce 在保证请求处理顺序的基础上,天然地使用了管道模式(pipelining)与 Redis 交互 —— 在多个业务线程并发请求的情况下,客户端不必等待服务端对当前请求的响应,即可在同一个连接上发出下一个请求。这在加速了 Redis 请求处理的同时,也高效地利用了 TCP 连接的全双工特性(full-duplex)。而与之相对的,在没有显式指定使用管道模式的情况下,Jedis 只能在处理完某个 Redis 连接上当前请求的响应后,才能继续使用该连接发起下一个请求。



在并发场景下,业务系统短时间内可能会发出大量请求,在管道模式中,这些请求被统一发送至 Redis 服务端,待处理完成后统一返回,能够大大提升业务系统的运行效率,突破性能瓶颈。R2M 采用了 Redis Cluster 模式,在通过 Lettuce 连接 R2M 之前,应该先对 Redis Cluster 模式有一定的了解。

Redis Cluster 模式

在 redis3.0 之前,如果想搭建一个集群架构还是挺复杂的,就算是基于一些第三方的中间件搭建的集群总感觉有那么点差强人意,或者基于 sentinel 哨兵搭建的主从架构在高可用上表现又不是很好,尤其是当数据量越来越大,单纯主从结构无法满足对性能的需求时,矛盾便产生了。


随着 redis cluster 的推出,这种海量数据+高并发+高可用的场景真正从根本上得到了有效的支持。


cluster 模式是 redis 官方提供的集群模式,使用了 Sharding 技术,不仅实现了高可用、读写分离、也实现了真正的分布式存储。

集群内部通信

在 redis cluster 集群内部通过 gossip 协议进行通信,集群元数据分散的存在于各个节点,通过 gossip 进行元数据的交换。


不同于 zookeeper 分布式协调中间件,采用集中式的集群元数据存储。redis cluster 采用分布式的元数据管理,优缺点还是比较明显的。在 redis 中集中式的元数据管理类似 sentinel 主从架构模式。集中式有点在于元数据更新实效性更高,但容错性不如分布式管理。gossip 协议优点在于大大增强集群容错性。


redis cluster 集群中单节点一般配置两个端口,一个端口如 6379 对外提供 api,另一个一般是加 1w,比如 16379 进行节点间的元数据交换即用于 gossip 协议通讯。


gossip 协议包含多种消息,如 ping pong,meet,fail 等。


1.meet:集群中节点通过向新加入节点发送 meet 消息,将新节点加入集群中。


2.ping:节点间通过 ping 命令交换元数据。


3.pong:响应 ping。


4.fail:某个节点主观认为某个节点宕机,会向其他节点发送 fail 消息,进行客观宕机判定。

分片和寻址算法

hash slot 即 hash 槽。redis cluster 采用的正式这种 hash 槽算法实现的寻址。在 redis cluster 中固定的存在 16384 个 hash slot。



如上图所示,如果我们有三个节点,每个节点都是一主一从的主从结构。redis cluster 初始化时会自动均分给每个节点 16384 个 slot。当增加一个节点 4,只需要将原来 node1~node3 节点部分 slot 上的数据迁移到节点 4 即可。在 redis cluster 中数据迁移并不会阻塞主进程。对性能影响是十分有限的。总结一句话就是 hash slot 算法有效的减少了当节点发生变化导致的数据漂移带来的性能开销。

集群高可用和主备切换

主观宕机和客观宕机:


某个节点会周期性的向其他节点发送 ping 消息,当在一定时间内未收到 pong 消息会主观认为该节点宕机,即主观宕机。然后该节点向其他节点发送 fail 消息,其他超过半数节点也确认该节点宕机,即客观宕机。十分类似 sentinel 的 sdown 和 odown。


客观宕机确认后进入主备切换阶段及从节点选举。


节点选举:


检查每个 slave node 与 master node 断开连接的时间,如果超过了 cluster-node-timeout * cluster-slave-validity-factor,那么就没有资格切换成 master。


每个从节点,都根据自己对 master 复制数据的 offset,来设置一个选举时间,offset 越大(复制数据越多)的从节点,选举时间越靠前,优先进行选举。


所有的 master node 开始 slave 选举投票,给要进行选举的 slave 进行投票,如果大部分 master node(N/2 + 1)都投票给了某个从节点,那么选举通过,那个从节点可以切换成 master。


从节点执行主备切换,从节点切换为主节点。

Lettuce 的使用

建立连接

使用 Lettuce 大致分为以下三步:


1.基于 Redis 连接信息创建 RedisClient


2.基于 RedisClient 创建 StatefulRedisConnection


3.从 Connection 中获取 Command,基于 Command 执行 Redis 命令操作。


由于 Lettuce 客户端提供了响应式、同步和异步三种命令,从 Connection 中获取 Command 时可以指定命令类型进行获取。


在本地创建 Redis Cluster 集群,设置主从关系如下:


7003(M) --> 7001(S)


7004(M) --> 7002(S)


7005(M) --> 7000(S)


List<RedisURI> servers = new ArrayList<>();servers.add(RedisURI.create("127.0.0.1", 7000));servers.add(RedisURI.create("127.0.0.1", 7001));servers.add(RedisURI.create("127.0.0.1", 7002));servers.add(RedisURI.create("127.0.0.1", 7003));servers.add(RedisURI.create("127.0.0.1", 7004));servers.add(RedisURI.create("127.0.0.1", 7005));//创建客户端RedisClusterClient client = RedisClusterClient.create(servers);//创建连接StatefulRedisClusterConnection<String, String> connection = client.connect();//获取异步命令RedisAdvancedClusterAsyncCommands<String, String> commands = connection.async();//执行GET命令RedisFuture<String> future = commands.get("test-lettuce-key");try {    String result = future.get();    log.info("Get命令返回:{}", result);} catch (Exception e) {    log.error("Get命令执行异常", e);}
复制代码


可以看到成功地获取到了值,由日志可以看出该请求发送到了 7004 所在的节点上,顺利拿到了对应的值并进行返回。



作为一个需要长时间保持的客户端,保持其与集群之间连接的稳定性是至关重要的,那么集群在运行过程中会发生哪些特殊情况呢?作为客户端又应该如何应对呢?这就要引出智能客户端(smart client)这个概念了。


智能客户端


在 Redis Cluster 运行过程中,所有的数据不是永远固定地保存在某一个节点上的,比如遇到 cluster 扩容、节点宕机、数据迁移等情况时,都会导致集群的拓扑结构发生变化,此时作为客户端需要对这一类情况作出应对,来保证连接的稳定性以及服务的可用性。随着以上问题的出现,smart client 这个概念逐渐走到了人们的视野中,智能客户端会在内部维护 hash 槽与节点的映射关系,大家耳熟能详的 Jedis 和 Lettuce 都属于 smart client。客户端在发送请求时,会先根据 CRC16(key)%16384 计算 key 对应的 hash 槽,通过映射关系,本地就可实现键到节点的查找,从而保证 IO 效率的最大化。


但如果出现故障转移或者 hash 槽迁移时,这个映射关系是如何维护的呢?

客户端重定向

MOVED

当 Redis 集群发生数据迁移时,当对应的 hash 槽已经迁移到变的节点时,服务端会返回一个 MOVED 重定向错误,此时并告诉客户端这个 hash 槽迁移后的节点 IP 和端口是多少;客户端在接收到 MOVED 错误时,会更新本地的映射关系,并重新向新节点发送请求命令。


ASK

Redis 集群支持在线迁移槽(slot)和数据来完成水平伸缩,当 slot 对应的数据从源节点到目标节点迁移过程中,客户端需要做到智能识别,保证键命令可正常执行。例如当一个 slot 数据从源节点迁移到目标节点时,期间可能出现一部分数据在源节点,而另一部分在目标节点,如下图所示



当出现上述情况时,客户端键命令执行流程将发生变化,如下所示:


1)客户端根据本地 slots 缓存发送命令到源节点,如果存在键对象则直 接执行并返回结果给客户端


2)如果键对象不存在,则可能存在于目标节点,这时源节点会回复 ASK 重定向异常。


3)客户端从 ASK 重定向异常提取出目标节点信息,发送 asking 命令到目标节点打开客户端连接标识,再执行键命令。如果存在则执行,不存在则返回不存在信息。


在客户端收到 ASK 错误时,不会更新本地的映射关系

节点宕机触发主备切换

上文提到,如果 redis 集群在运行过程中,某个主节点由于某种原因宕机了,此时就会触发集群的节点选举机制,选举其中一个从节点作为新的主节点,进入主备切换,在主备切换期间,新的节点没有被选举出来之前,打到该节点上的请求理论上是无法得到执行的,可能会产生超时错误。在主备切换完成之后,集群拓扑更新完成,此时客户端应该向集群请求新的拓扑结构,并更新至本地的映射表中,以保证后续命令的正确执行。


有意思的是,Jedis 在集群主备切换完成之后,是会主动拉取最新的拓扑结构并进行更新的,但是在使用 Lettuce 时,发现在集群主备切换完成之后,连接并没有恢复,打到该节点上的命令依旧会执行失败导致超时,必须要重启业务程序才能恢复连接。


在使用 Lettuce 时,如果不进行设置,默认是不会触发拓扑刷新的,因此在主备切换完成后,Lettuce 依旧使用本地的映射表,将请求打到已经挂掉的节点上,就会导致持续的命令执行失败的情况。


可以通过以下代码来设置 Lettuce 的拓扑刷新策略,开启基于事件的自适应拓扑刷新,其中包括了 MOVED、 ASK、PERSISTENT_RECONNECTS 等触发器,当客户端触发这些事件,并且持续时间超过设定阈值后,触发拓扑刷新,也可以通过 enablePeriodicRefresh()设置定时刷新,不过建议这个时间不要太短。


// 设置基于事件的自适应刷新策略ClusterTopologyRefreshOptions topologyRefreshOptions = ClusterTopologyRefreshOptions.builder()        //开启自适应拓扑刷新        .enableAllAdaptiveRefreshTriggers()        //自适应拓扑刷新事件超时时间,超时后进行刷新        .adaptiveRefreshTriggersTimeout(Duration.ofSeconds(30))        .build();
redisClusterClient.setOptions(ClusterClientOptions.builder() .topologyRefreshOptions(topologyRefreshOptions) // redis命令超时时间 .timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(30))) .build());
复制代码


进行以上设置并进行验证,集群在主备切换完成后,客户端在段时间内恢复了连接,能够正常存取数据了。

总结

对于缓存的操作,客户端与集群之间连接的稳定性是保证数据不丢失的关键,Lettuce 作为热门的异步客户端,对于集群中产生的一些突发状况是具备处理能力的,只不过在使用的时候需要进行设置。本文目的在于将在开发缓存操作功能时遇到的问题,以及将一些涉及到的底层知识做一下总结,也希望能给大家一些帮助。

发布于: 刚刚阅读数: 3
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
Redis 异步客户端选型及落地实践_redis_京东科技开发者_InfoQ写作社区