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作为热门的异步客户端,对于集群中产生的一些突发状况是具备处理能力的,只不过在使用的时候需要进行设置。本文目的在于将在开发缓存操作功能时遇到的问题,以及将一些涉及到的底层知识做一下总结,也希望能给大家一些帮助。