七的博客

Redisson在项目中的实践

开发经验

Redisson在项目中的实践

最近在项目中引入了 Redisson 这个组件,使用场景主要是分布式锁、信号量以及一些分布式集合等。

说是一个组件,其实本质上还是一个 Java Redis 的客户端。 不过跟 Jedis 跟 Lettuce 这两个客户端比较起来,还是有些比较明显的特点跟优势。 不然项目中也不会单独额外的引用另一个客户端组件。

1. Redisson 特点

Redisson 使用下来可以总结出下面几点好用的地方:

  • 像使用普通 Java 对象一样使用 Redis。比如,你可以用使用 Map、List、Set 等集合去操作 Redis 里面的数据。操作起来特别的简单,你几乎感觉不到数据是存储在 Redis 中。
  • 做分布式锁比较方便,不用考虑各种场景下的异常等。
  • 支持集群、哨兵等模式。不用担心数据只支持存储在单个 Redis 节点上。
  • 支持在客户端本地保存一部分数据,减少网络请求。 有点类似于多级缓存一样的。
  • 跟 Spring 框架可以进行集成,减少一些配置成本。

基本上上面几点,就已经覆盖了实际项目中大部分的场景。 同时也满足一定的性能要求,不用担心 Redisson 的逻辑处理会成为瓶颈。

2. 对比 Jedis、Lettuce

Jedis 算是 Redis 官方提供的 Java 版本客户端。在众多 Java 客户端中也是最老牌的一个,从2010年左右开始发布,到现在已经很多年了。

作为官方提供的客户端,也是提供了很直观的 API 来操作 Redis 。 同时社区也是非常活跃的,一些 Bug 提上去比较快速就能被解决。

但是有些缺点比较明显:

  • Jedis 操作都是同步的,意味着在等待Redis响应时,线程会被阻塞。在一些并发高的场景下性能稍微会低一点。
  • Jedis 实例不是线程安全的,要在多线程环境下操作通常需要做一些锁之类的处理。
  • 对异步操作支持的不太好。

Lettuce 是一个稍微新一点的 Redis Java客户端,之前应该也是关注度不是特别高。 我第一次注意到它是 Spring Data Redis 开始替换默认的实现为 Lettuce 的时候,特地去搜索了下有什么特别的地方。

Lettuce 是 2011年发布的首个版本,2014年左右开始不怎么活跃。 而且 Lettuce 的作者居然是 wrk 的作者,这倒是很让人以外。

Lettuce 是基于 Netty 框架去开发的,所以很容易猜的出来,它肯定是异步非阻塞操作。这样有利于高效的处理大量并发连接,同时 Lettuce 的连接是线程安全的,支持多个线程共享一个连接。

当然 Lettuce 也还是有很多其他的特性,不过也存在一些不足:

  • Lettuce 的 API 会稍微复杂一点,因为很多异步操作以及响应式编程。
  • Lettuce 的文档稍微没那么多,案例会更少。
  • 涉及到 Netty ,一些 Netty 的参数也会成为性能优化的方面,给研发增加一定的负担。

对比起来,Redisson 能流行还是依靠提供了更加便利,更符合 Java 程序员一些 API 。 通过这些特性可以让开发过程中减少很大一部分工作量。

3. Redisson 基本的用法

3.1 连接 Redis

通过 Redisson 连接 Redis 的步骤还是比较少的,比如下面三行代码就连接一个本地的 Redis 实例。

final Config config=new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson=Redisson.create(config);

在实际项目开发中,通常配置都会写在配置文件中,Redisson 也支持直接从 yml 等文件中配置属性。这样可以让项目更加灵活。比如下面是官方的配置文件示例:

---
singleServerConfig:
  idleConnectionTimeout: 10000
  connectTimeout: 10000
  timeout: 3000
  retryAttempts: 3
  retryInterval: 1500
  password: -1
  subscriptionsPerConnection: 5
  clientName: null
  address: "redis://1.1.1.1:1"
  subscriptionConnectionMinimumIdleSize: 1
  subscriptionConnectionPoolSize: 50
  connectionMinimumIdleSize: 24
  connectionPoolSize: 64
  database: -1
  dnsMonitoringInterval: 5000
threads: 16
nettyThreads: 32
codec: !<org.redisson.codec.JsonJacksonCodec> { }
transportMode: "NIO"

然后读取上面的文件初始化实例:

final InputStream fileStream=Files.newInputStream(new File("配置文件路径").toPath());
final Config redissonConfig=Config.fromYAML(fileStream);
final RedissonClient redisson=Redisson.create(redissonConfig);

3.2 分布式锁

分布式锁主要确保是多节点环境中对共享资源的安全访问。比如你在单机的 Java 应用中,为了保证对某一个变量等进行安全访问,就会进行加锁的操作。

 private final Lock lock=new ReentrantLock();
 lock.lock();
 try{
  // 加锁的逻辑
 }finally{
    lock.unlock();
 }

但是本地锁只在单个 JVM内有效,所以只适用于单机多线程环境。

使用 Redis 做分布式锁的话一般会使用 setnx 指令去实现,或者使用 lua 脚本去实现,都比较麻烦。

import org.redisson.api.RLock;

final RLock lock=redisson.getLock("myLock");
lock.lock();
 try{
  // 执行需要同步的代码
 }finally{
  lock.unlock();
 }

Redisson 提供的分布式锁跟 Java 内置的锁使用方式上几乎是一模一样,背后的工作都由 Redisson 去处理。包括锁时间的续期、集群节点下挂了几个节点导致锁出现问题等。

3.3 分布式 Map

RedissonMap 是 Redisson 提供的一个分布式数据结构。 特点就是它类似于 Java 的 ConcurrentHashMap,但是它可以在分布式环境中使用。

用着 Map 的 API ,但是它实际上操作的是 Redis 上的数据,很大程序上可以简化程序中的一些操作。

// 初始化一个 Map ,并放入 key value
final RMap<String, Integer> map=redisson.getMap("myMap");
map.put("key",1);

// 获取 map 中 key对应的 value
final Integer value=map.get("key");
System.out.println(value);

上面的例子,看着就是跟 Java 的 Map 操作一模一样。实际上在 Redis 中存储的数据是用的哈希结构。Map 的 Key Value 则存储为 Redis 中的 Hash key 和 Hash value。

3.3 分布式队列 Queue

分布式队列是类似于 Java 中的 Queue 接口,但是它是支持在分布式环境中使用的队列数据结构。它利用 Redis 来实现跨多个节点的队列功能,利用 Redis 的持久化功能,确保队列数据不会丢失。

// 初始化一个队列,并放入元素 element1
final RQueue<String> queue=redisson.getQueue("myQueue");
queue.add("element1");

// 从队列中获取元素
String element=queue.poll();

Redisson Queue 使用 Redis 的 List 数据类型来存储队列的元素,队列的每个元素是 List 中的一个值。

在我们的项目场景中,我们使用它作为消息队列,用于多个系统间的异步通信。因为项目中已经引入了 Redis ,同时需要类似于消息队列的功能,那么就直接复用现有的中间件即可。

3.4 分布式 Set

跟 Java 里面的 Set 几乎是一样的,只不过是支持通过 Redis 去同步数据。 因为 Redis 有数据持久化,所以也不会重启应用也不会丢数据。

// 初始化一个集合
final RSet<String> set=redisson.getSet("mySet");
// 往集合中添加一个元素
set.add("item1");

// 移除集合中的一个元素
set.remove("item1");

存储是使用 Redis 的 Set 数据类型来存储集合元素,每个元素在集合中是唯一的。

实际项目中可以用来去数据进行去重等操作,或者是利用 Redis 的集合操作进行交集、并集等运算。

3.5 分布式 List

Redisson 中的 List 就是一种类似于 Java 的 List 接口的集合数据结构,可以适用于分布式环境。

// 初始化一个 List
final RList<String> list=redisson.getList("myList");
// 往列表中添加一个元素
list.add("element1");
// 获取列表中的首个元素
final String listElement=list.get(0);

本质上还是利用 Redis 来实现跨多个节点的数据共享。数据使用 Redis 的 List 数据类型来存储集合元素,元素的顺序也是按插入顺序存储。

在实际运用中,可以用来实现任务的顺序处理,适合生产者-消费者模型。

3.6 发布/订阅

发布/订阅并不是 Redisson 自己搞出来的新功能,而是基于 Redis 的发布/订阅机制 (Pub/Sub)。这点在 Jedis 以及 Lettuce 中也提供了类似的 API 接口。

不过区别点在于 Redisson 提供了高级封装,易于使用。

// 先实例化一个 topic name 为 myTopic , 然后再往 topic 发送一条消息 Hello, World!
final RTopic topic=redisson.getTopic("myTopic");
topic.publish("Hello, World!");

// 另外一个应用去订阅这个 topic 里面的消息
topic.addListener(String.class,new MessageListener<String>(){
@Override
public void onMessage(CharSequence channel,String msg){
   System.out.println("Received: "+msg);
 }
});

上面的例子就是 Redisson 发布订阅的基本用法,代码很简洁。不过本质上用的还是 Redis 的发布订阅,所以要注意使用上的限制。

3.7 分布式计数器

所谓的计数器就是类似于JDK 的 AtomicLong 等工具类。

// 初始化一个计数器
final RAtomicLong atomicLong=redisson.getAtomicLong("myAtomicLong");
// 自增1
atomicLong.incrementAndGet();

计数器是使用 Redis 的 String 数据类型存储数值,自增等操作也是使用 Redis 的INCRBY 命令、DECRBY 命令 等进行操作。

在实际项目中,比较常用的几点:

  • 当做一个ID生成器,类似于数据库自增主键的作用。
  • 用于限制操作次数,如 API 调用计数。
  • 实现多个应用间的计数,如页面访问量、库存数量等等。

4. 使用 Redisson 比较常见的问题

主要的几个问题,在 Redisson 的文档里面其实都有提到过,在实际项目中也确实是比较高的频率出现。

4.1 导致 RedisTimeoutException 的原因

因为 Redisson 是基于 Netty 开发,所以超时这块大部分时候都是 Netty 连接相关的问题。

通常出现的原因有:

  • 所有 Netty 线程都忙于处理,导致 Redis 响应解码和发送命令延迟。
  • Netty 所有连接都在使用中,很繁忙无法处理其他操作。
  • Redis服务器处理请求时间过长。
  • Java 应用程序自身处理逻辑过多,没有响应之类的。
  • 在 subscribeOnElements 方法中进行阻塞调用。
  • 不稳定的网络导致 TCP 丢包。
  • Redis服务商限制了并发连接数量上限。

解决方案:

  • 尝试为 nettyThreads 设置不同的值(如32、64、128、256),以便 Redisson 可以获得一个空闲的 Netty 线程来解码响应或发送命令。
  • 增加 retryInterval 或 timeout 的值,以便在命令超时前能优雅地失败。
  • 增加连接池的设置,以提高获取空闲连接的概率。

说到底就是 Netty 是一个异步事件驱动的网络应用框架,使用多线程处理I/O操作,线程不够用了就容易出现这个错误。

4.2 Redisson 需不需要手动关闭

答案是可以手动去关闭,这样可以释放一些连接 Redis 的连接资源。 Redisson 本质上只是一个客户端库,本身是通过网络连接获取 Redis 中的数据。手动关闭 Redisson 可以提早释放网络连接,这一点上 跟 Jedis 、Lettuce 没有很大的区别。

通常 Redisson 跟着 Java 应用程序的启动和停止。关闭过程会断开所有的活动连接,并清理某些需要手动销毁的Redisson对象,最后停止事件循环。

4.3 MapCache/SetCache/SpringCache/JCache 到了过期时间数据没有被删除

Redis 本身不支持 Hash 、Set 等过期的,这些类型的数据过期都是 Redisson 提供的特性。

Redisson 采用了主动和被动两种方式确保元素在过期时被移除。

  • 定期运行就是定期去扫描 Redis 中过期元素,发现有过期的元素就会去删除。
  • 访问元素的时候也会检查元素是否过期,过期就会立马删除掉。

从上面两点可以看出, Redisson 的线程运行的时候或者说 Redisson 的 API 调用的时候,设置的元素过期才会生效。 如果 Redisson 线程不在运行的话,上面这些元素是永远过期了都不会删除的。

4.4 Redisson 线程安全问题

Redisson 实例及其提供的所有对象都是线程安全的。API只是操作句柄,保持线程安全。所以可以在多个线程间共享实例,全局公用一个 Redisson 实例即可。

4.5 数据编码问题

Redisson 中可以设置一个全局默认的数据编解码器,也可以在每一个 API 调用的时候指定单独的编解码器。

比如:

final RMap<String, String> map = redisson.getMap("myMap", new MyCodec());

4.6 跟其他应用 Spring Data Redis 协作的时候序列化问题

如果出现跨项目,一个项目使用 Spring Data Redis ,另外一个项目使用 Redisson 的话,两边也是可以进行操作同一份数据的。需要注意的是设置好两边 value 的反序列以及序列化策略,保持一致即可。

Spring Data Redis 使用 Jackson 作为序列化器:

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    final RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(connectionFactory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    return template;
}

Redisson 使用 JsonJacksonCodec 作为序列化器:

codec: !<org.redisson.codec.JsonJacksonCodec> {}

这种配置确保了 Redisson 和 Spring Data Redis 都使用 Jackson 进行 JSON 序列化和反序列化,这样就可以公用数据。

参考链接