写这篇的时候,相信有很多朋友还在用Jedis作为redis的客户端,我不禁有很多问号,Jedis还香吗?如果你早些年说它香我信,但是都2020年了,它真的不那么香了。那为什么还继续使用它呢?大部分原因或多或少是因为一遗留代码没人敢大动,就这样吧;二新项目没人主导使用其它实现做替换。祖传代码不轻易大动,这个真理必须相信,且坚持相信;至于没人主导拍板做技术替换,可能是习惯了Jedis的用法,也可能是没人了解其它技术实现,当然还有其它原因,有兴趣分享的朋友可以在评论区聊一聊。咳咳,扯远了,来聊我们今天的话题-Redisson实战用法。
在Redis的官网(https://redis.io/clients#JAVA)上可以看到Java语言的推荐客户端列表,除了我们都熟知的Jedis之外,Redisson也是官方推荐的客户端。从这我们了解到Redisson是一个Redis客户端,那它到底Redisson是什么呢?Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid),它充分利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类,让使用Redis更加简单、便捷,从而让使用者能够将更多精力集中到业务逻辑处理上。
也就是说Redisson不仅仅是一个Redis客户端,它还实现了很多具有分布式特性的常用工具类,例如分布式锁、布隆过滤器等,更多功能特性请移步https://redisson.org/。接下来,我们一起聊一下Redisson中如何轻松操作Redis中的字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)和有序集合(sorted sets),以及如何使用Redisson实现的布隆过滤器和分布式锁,最后分析一下Redisson中分布式锁的解决方案¬。
要使用Redisson,首先要创建RedissonClient对象实例。创建RedissonClient对象实例的方式多种多样,可以直接通过在代码中设置Redis服务的相关参数创建,也可以通过加载JSON格式或YAML格式配置文件创建,还可以通过在Spring XML文件中使用Redisson标签配置创建,具体如何创建RedissonClient对象实例可根据需要选择,这里就不一一介绍了,有想法的可以移步Redission官网。本文为了展示使用样例代码,使用了最简单的方式:在代码中设置单机Redis服务的相关参数创建RedissonClient对象实例,具体代码如下。
Config config = new Config();// 使用单机Redis服务config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 创建Redisson客户端RedissonClient redisson = Redisson.create(config);
Redisson将Redis中的字符串数据结构封装成了RBucket,通过RedissonClient的getBucket(key)方法获取一个RBucket对象实例,通过这个实例可以设置value或设置value和有效期,例如如下代码。
RBucket<String> nameRBucket = redisson.getBucket("name");// 只设置value,key不过期nameRBucket.set("四哥");
// 设置value和key的有效期nameRBucket.set("四哥", 30, TimeUnit.SECONDS);
// 通过key获取valueredisson.getBucket("name").get();
有朋友可能发现了,上面的nameRBucket使用了String约定了value类型,也就是说这里的value可以是其它类型,例如value是一个对象,可以这么操作。
Student tom = new Student();tom.setId(456L);tom.setChineseName("刘能");tom.setEnglishName("tom");tom.setAge(52);
// 通过key获取RBucket对象实例RBucket<Student> studentRBucket = redisson.getBucket("student");
// 设置value和有效期studentRBucket.set(tom, 300, TimeUnit.SECONDS);
// 通过key获取valueredisson.getBucket("student").get();
Redisson将Redis中的字符串数据结构封装成了RMap,操作示例代码如下。
// 通过key获取RMapRMap<String, String> studentRMap = redisson.getMap("studentMap");
// 设置map中key-valuestudentRMap.put("id", "123");studentRMap.put("name", "赵四");studentRMap.put("age", "50");
// 设置key有效期studentRMap.expire(300, TimeUnit.SECONDS);
// 通过key获取valueredisson.getMap("studentMap").get("name");
使用示例代码如下。
RList<Student> studentRList = redisson.getList("studentList");studentRList.add(jack);
// 设置有效期studentRList.expire(300, TimeUnit.SECONDS);
// 通过key获取valueredisson.getList("studentList").get(0);
使用示例代码如下。
RSet<Student> studentRSet = redisson.getSet("studentSet");studentRSet.add(jack);studentRSet.add(tom);
// 设置有效期studentRSet.expire(300, TimeUnit.SECONDS);
// 通过key获取valueredisson.getSet("studentSet");
使用示例代码如下。
RSortedSet<Student> studentSortedSet = redisson.getSortedSet("studentSortedSet");studentSortedSet.add(jack);studentSortedSet.add(tom);
// 通过key获取valueredisson.getSortedSet("studentSortedSet");
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。更多布隆过滤器的内容,请通过搜索引擎了解更多。
Redission提供了布隆过滤器的实现,可以直接使用,示例代码如下。
RBloomFilter seqIdBloomFilter = redisson.getBloomFilter("seqId");
// 初始化预期插入的数据量为10000000和期望误差率为0.01seqIdBloomFilter.tryInit(10000000, 0.01);
// 插入部分数据seqIdBloomFilter.add("123");seqIdBloomFilter.add("456");seqIdBloomFilter.add("789");
// 判断是否存在System.out.println(seqIdBloomFilter.contains("123"));System.out.println(seqIdBloomFilter.contains("789"));System.out.println(seqIdBloomFilter.contains("100"));
Redission提供了强大的分布式锁实现,使用简单、安全。下面模拟多线程竞争分布式锁,示例代码如下。
for (int i = 0; i < 5; i++) { new Thread(new Runnable() { @Override public void run() { final String lockKey = "abc"; RLock lock = redisson.getLock(lockKey); boolean hasLocked = lock.tryLock(); System.out.println(Thread.currentThread().getName() + ":" + hasLocked); } }).start();}
使用Redis实现分布式锁,一般的实现是使用setnx命令,但是这种实现方式在高并发且并发安全控制非常高的情况是有问题的,下面从三个方面分析这些问题。
•不具备可重入性
在执行setnx命令时,通常采用业务上指定的名称作为key名,用时间或随机值作为value来实现。这样的实现方式不具备追踪请求线程的能力,同时也不具备统计重入次数的能力,甚至有些实现方式都不具备操作的原子性。当遇到业务上需要在多个地方用到同样一个锁的时候,很显然使用不具有可重入的锁会很容易发生死锁的现象。特别是在有递归逻辑的场景里,发生死锁的几率会更高。Java并发工具包里的Lock对象和sychronized语块都具有可重入性,对于经常使用这些工具的人来说,往往会很容易忽略setnx的这个缺陷。
•不支持续约
在分布式环境中,为了保证锁的活性和避免程序宕机造成的死锁现象,分布式锁往往会引入一个失效时间,超过这个时间则认为自动解锁。这样的设计前提是开发人员对这个自动解锁时间的力度有一个很好的把握,太短了可能会出现任务没做完锁就失效了,而太长了在出现程序宕机或业务节点挂掉时,其它节点需要等很长时间才能恢复,而难以保证业务的SLA。setnx的设计缺乏一个延续有效期的续约机制,无法保证业务能够先工作做完再解锁,也不能确保在某个程序宕机或业务节点挂掉的时候,其它节点能够很快的恢复业务处理能力。
•不具备阻塞的能力
平常大家多少都接触过的锁,由于加锁策略(Locking Strategy)的差别,使得每种锁都有各自不同的特性。但是在通常情况下这些锁都具备两个共性:一是互斥性,二是阻塞性。互斥性是指在任何时刻最多只能有一个线程获得通行的资格。阻塞性是指的在有竞争的情况下,未获取到资源的线程会停止继续操作,直到成功获取到资源或取消操作。很显然setnx命令只提供了互斥的特性,却没有提供阻塞的能力。虽然在业务代码里可以引入自旋机制来进行再次获取,但这仅仅是把原本应该在锁里实现的功能搬到了业务代码里,通过增加业务代码的复杂程度来简化锁的实现似乎显得有点南辕北辙。
Redisson的分布式锁在满足以上三个基本要求的同时还增加了线程安全的特点。利用Redis的Hash结构作为储存单元,将业务指定的名称作为key,将随机UUID和线程ID作为field,最后将加锁的次数作为value来储存。同时UUID作为锁的实例变量保存在客户端。将UUID和线程ID作为标签在运行多个线程同时使用同一个锁的实例时,仍然保证了操作的独立性,满足了线程安全的要求。
加锁时通过Lua脚本先检查锁是否存在,如不存在则创建hash相关字段并设定过期时间后返回,这表示加锁成功。如果该hash字段已经存在,再检查随机字段和线程id是否一致。如果一致则递增value的值并重新更新过期时间后返回,此时表示同一节点同一线程再次成功加锁,从而保证了可重入性。如果hash存在且字段不一致,说明其他节点或线程已经拥有了这个锁。因此Lua脚本返回这个hash的当前有效期。当结果返回到该客户端后,如果加锁成功,则通过线程池依照设定好的参数定时执行续约,最后通知请求线程继续后续操作。如果加锁没有成功,则监听一个以这个key为后缀的pubsub频道,直到收到解锁消息后再次重试。
解锁时通过Lua脚本先检查锁是否存在,如果已经不存在则直接发布解锁消息并返回。如果仍然存在则检查标签是否存在,如果不存在则表示这个锁并不为本线程所拥有,这种情况请求线程将收到报错。如果存在则表示该锁正是被该线程所拥有。在这种情况下,递减标签字段后判断,如果返回的加锁数量仍然大于0,说明当前的锁仍然有效,仅仅只是重入次数减少了。相反这表示锁已经完全解开,则立即删除该锁并发布解锁信息。
Redisson的可重入锁解决了setnx锁的许多先天性不足,但是由于它仍然是以单一一个key的方式储存在固定的一个Redis节点里,并且有自动失效期。这样的设计虽然可以很大程度上避免客户端程序宕机或业务节点挂掉造成的影响,但是随之带来的弊端是遇到服务端Redis进程宕机或节点挂掉的情况,还是有可能会造成锁的信息丢失,这样的缺陷显然无法满足某些特定场景提出的高可用性要求。
介于这种情况,Redis作者Salvatore提出了一个基于多个节点的高可用分布式锁的算法,起名叫红锁(RedLock: https://redis.io/topics/distlock)。在这种算法下,客户端需要同时在多个节点里同时尝试获取一个独立的锁,只有当一次性成功获取了大多数锁的情况下才能被视为赢得了高可用分布式锁,否则需要解除已经部分获取到的锁,等待一个随机时间后再次重试。
在算法设计上,Salvatore依然采用的是setnx作为举例讲解分布式锁的互斥特性。在算法实现上,Redisson的RedissonRedLock采用的是前面提到的更加灵活方便的可重入锁。Redisson的扩展算法是Redis官网唯一认可的Java实现。
虽然Redlock的算法提供了高可用的特性,但建立在大多数可见原则的前提下,这样的算法适用性仍然有一定局限。Redisson为此提供了基于增强型的算法的高可用分布式联锁RedissonMultiLock。这种算法要求客户端必须成功获取全部节点的锁才被视为加锁成功,从而更进一步提高了算法的可靠性。