Redis客户端
通信协议
Redis 监听默认 6379 的端口号,可以通过 TCP 方式建立连接。
服务端约定了一种特殊的消息格式,叫做 Redis Serialization Protocol(RESP,Redis 序列化协议),发消息或者响应消息需要按这种格式编码,接收消息需要按这种格式解码。
Redis 设计这种格式的原因∶ 容易实现、解析快、可读性强。
Redis6.0新特性里面说的RESP协议升级到了3.0 版本,其实就是对于服务端和客户端可以接收的消息进行了升级扩展,比如客户端缓存的功能就是在这个版本里面实现的。
常用客户端
官网推荐的Java客户端有3个∶ Jedis,Redisson 和 Luttuce。
Jedis
Jedis 有一个问题∶多个线程使用一个连接的时候线程不安全。
解决思路∶使用连接池,为每个请求创建不同的连接,基于Apache Common pool实现。
Jedis 的连接池有三个实现∶JedisPool、ShardedJedisPool、JedisSentinelPool,都是用 getResource 从连接池获取一个连接。
Jedis 的供比较完善,Redis 官方的特性全部支持,比如发布订阅、事务、Lua脚本、客户端分片、哨兵、集群、pipeline 等等。
Cluster 连接哨兵只需要配置任何一个master 或者slave 的地址就可以了。
Cluster获取连接原理
使用Jedis 连接 Cluster 的时候,我们只需要连接到任意一个或者多个redis group中的实例地址,那我们是怎么获取到需要操作的 Redis Master 实例的?
为了避免 get、set的时候发生重定向错误,我们需要把 slot和Redis节点的关系保存起来,在本地计算 slot,就可以获得 Redis 节点信息。
先思考一下,16384个虚拟槽slots,假设有3个redis group,那么这个映射关系应该保存在一个什么数据结构里面呢?
会不会出现16384个slots不够用的情况?可以修改吗?
注意∶slots 的数量是写死,不能修改的。
第一,在服务端表示16384个位,只需要2KB的大小(每个Group 维护一个位数组,在16384bit里面把对应下标的值改成1,就代表slot由当前节点负责)。再大的话,获取slots 信息有点浪费通信资源。
第二,一般来说集群的节点数不会特别大,16384个 slots 够他们分了。
关键问题∶在于如何存储 slot 和 Redis 连接池的关系。
-
程序启动初始化集群环境,读取配置文件中的节点配置,无论是主从,无论多少个,只拿第一个,获取 redis 连接实例。
-
discoverClusterNodesAndSlots方法,用获取的redis连接实例执行clusterSlots()方法,实际执行 redis 服务端 cluster slots 命令,获取虚拟槽信息。
该集合的基本信息为 [long,long,List,List],第一,二个元素是该节点负责槽点的起始位置,第三个元素是主节点信息,第四个元素为主节点对应的从节点信息。该list的基本信息为 [string,int,string],第一个为host信息,第二个为 port信息,第三个为唯一 id。
-
获取有关节点的槽点信息后,调用getAssignedSlotArray(slotinfo)来获取所有的槽点值。
-
再获取主节点的地址信息,调用 generateHostAndPort(hostInfo)方法,生成一个 hostAndPort 对象。
-
在 assignSlotsToNode 方法中,再根据节点地址信息来设置节点对应的 JedisPool,即设置 Map<String, JedisPool>nodes 的值。
接下来判断若此时节点信息为主节点信息时,则调用 assignSlotsToNodes 方法,设置每个槽点值对应的连接池(slave不需要连接),即设置 Map<Integer,JedisPool> slots 的值。
很明显,这个Map有16384个key,key 对应的value是一个连接池信息。有几个 Redis Group(或者说有几个 master),就有几个不同的连接池。
获取 slot 和 Redis 实例的对应关系之后,接下来就是从集群环境存取值。
Jedis 集群模式下所有的命令都要调用这个方法∶
核心代码∶JedisClusterCommand#runWithRetries 116 行
connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16getSlot(key));
步骤也很简单∶
1、把 key作为参数,执行 CRC16 算法,获取 key 对应的 slot值。
2、通过该 slot值,去 slots 的 map 集合中获取 jedisPool 实例。
3、通过jedisPool实例获取jedis 实例,最终完成 redis 数据存取工作。
Jedis 实现分布式锁
首先分布式锁的实现是多种多样的,越安全的代码也越复杂。
分布式锁的基本需求∶
1、互斥性∶ 只有一个客户端能够持有锁。
2、不会产生死锁∶即使持有锁的客户端崩溃,也能保证后续其他客户端可以获取锁。
3、只有持有这把锁的客户端才能解锁。
参数解读∶
1、lockKey是Redis key的名称,谁添加成功这个key,就代表谁获取锁成功。比如有一把修改 1001 账户余额的锁。
2、requestId 是客户端的ID(设置成value),如果我们要保证只有加锁的客户端才能释放锁,就必须获得客户端的ID(保证第 3 点,自己才能解锁)。
3、SETIF_NOTEXIST是我们的命令里面加上 NX(保证第1点,互斥)。
4、SETWITH_EXPIRE_TIME,PX代表以毫秒为单位设置key 的过期时间(保证第2点,不会死锁)。expireTime 是自动释放锁的时间,比如 5000 代表5秒。
释放锁,直接删除 key 来释放锁可以吗? 就像这样∶
没有对客户端 requestId进行判断,可能会释放其他客户端持有的锁。
先判断后删除呢?
如果在释放锁的时候,这把锁已经不属于这个客户端(例如已经过期,并且被别的客户端获取锁成功了),那就会出现释放了其他客户端的锁的情况。
所以,要先判断是不是自己加的锁,才能释放。为了保证原子性,我们把判断客户端是否相等和删除 key 的操作放在 Lua 脚本里面执行。
Lettuce
特点
与Jedis 相比,Lettuce 则完全克服了其线程不安全的缺点∶ Lettuce是一个可伸缩的线程安全的Redis 客户端,支持同步、异步和响应式模式(Reactive)。多个线程可以共享一个连接实例,而不必担心多线程并发问题。
Lettuce基于Netty框架构建,支持Redis的全部高级功能,如发布订阅、事务、lua脚本、Sentinel、集群、Pipeline 支持连接池。
Lettuce 是Spring Boot 2.x 默认的客户端,替换了Jedis。集成之后我们不需要单独使用它,直接调用 Spring 的RedisTemplate操作。
Redisson
Redisson是一个在 Redis 的基础上实现的Java驻内存数据网格(In-Memory Data Grid),提供了分布式和可扩展的Java数据结构,比如分布式的 Map、List、 Queue、Set,不需要自己去运行一个服务实现。
特点
基于 Netty 实现,采用非阻塞 IO,性能高;支持异步请求。
支持连接池、pipeline、LUA Scripting、Redis Sentinel、Redis Cluster。
不支持事务,官方建议以LUA Scripting 代替事务。
主从、哨兵、集群都支持。Spring 也可以配置和注入 RedissonClient。
在 Redisson 里面提供了更加简单的分布式锁的实现。
加锁:
在获得RLock 之后,只需要一个 tryLock 方法,里面有3个参数∶
1、watiTime∶获取锁的最大等待时间,超过这个时间不再尝试获取锁
2、leaseTime∶如果没有调用 unlock,超过了这个时间会自动释放锁
3、TimeUnit∶释放时间的单位
Redisson 的分布式锁是怎么实现的呢?
在加锁的时候,在 Redis写入了一个HASH,key是锁名称,field是线程名称,value是1(表示锁的重入次数)。
源码∶trylock()—tryAcquire()–tryAcquireAsync()–tryLockInnerAsync()
最终也是调用了一段 Lua脚本。里面有一个参数,两个参数的值。
释放锁,源码:
Redisson 跟 Jedis 定位不同,它不是一个单纯的 Redis 客户端,而是基于 Redis 实现的分布式的服务,如果有需要用到一些分布式的数据结构,比如我们还可以基于Redisson 的分布式队列实现分布式事务,就可以引入Redisson的依赖实现。
数据一致性
缓存使用场景
针对读多写少的高并发场景,我们可以使用缓存来提升查询速度。当我们使用 Redis 作为缓存的时候,一般流程是这样的∶
-
如果数据在 Redis 存在,应用就可以直接从Redis 拿到数据,不用访问数据库。
-
应用新增了数据,只保存在数据库中,这个时候 Redis 没有这条数据。如果 Redis 里面没有,先到数据库查询,然后写入到 Redis,再返回给应用。
一致性问题的定义
因为数据最终是以数据库为准的(这是我们的原则),如果Redis没有数据,就不存在这个问题。当Redis 和数据库都有同一条记录,而这条记录发生变化的时候,就可能出现一致性的问题。
一旦被缓存的数据发生变化(比如修改、删除)的时候,我们既要操作数据库的数据,也要操作 Redis 的数据,才能让 Redis 和数据库保持一致。
所以问题来了。现在我们有两种选择∶
- 先操作 Redis 的数据再操作数据库的数据
- 先操作数据库的数据再操作 Redis 的数据
**
到底选哪—种?
首先需要明确的是,不管选择哪一种方案, 我们肯定是希望两个操作要么都成功,要么都一个都不成功。但是,Redis 的数据和数据库的数据是不可能通过事务达到统一的,我们只能根据相应的场景和所需要付出的代价来采取一些措施降低数据不一致的问题出现的概率,在数据一致性和性能之间取得一个权衡。
比如,对于数据库的实时性一致性要求不是特别高的场合,比如T+1的报表,可以采用定时任务查询数据库数据同步到 Redis 的方案。
由于我们是以数据库的数据为准的,所以给缓存设置一个过期时间,删除 Redis 的数据,也能保证最终一致性。
我们既然提到了 Redis 和数据库一致性的问题,一般是希望尽可能靠近实时一致性,操作延迟带来的不一致的时间越少越好。
方案选择
Redis∶ 删除还是更新?
这里我们先要补充一点,当存储的数据发生变化,Redis 的数据也要更新的时候,我们有两种方案,一种就是直接更新 Redis 数据,调用 set;还有一种是直接删除 Redis 数据,让应用在下次查询的时候重新写入。
这两种方案怎么选择呢? 这里我们主要考虑更新缓存的代价。
更新缓存之前,是不是要经过其他表的查询、接口调用、计算才能得到最新的数据,而不是直接从数据库拿到的值。如果是的话,建议直接删除缓存,这种方案更加简单,而且避免了数据库的数据和缓存不一致的情况。在一般情况下,我们也推荐使用删除的方案。
所以,更新操作和删除操作,只要数据变化,都用删除。
这一点明确之后,现在我们就剩—个问题∶
1、到底是先更新数据库,再删除缓存
2、还是先删除缓存,再更新数据库我们先看第一种方案。
先更新数据库,再删除缓存
正常情况∶
更新数据库,成功。删除缓存,成功。
异常情况∶
- 更新数据库失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。
- 更新数据库成功,删除缓存失败。数据库是新数据,缓存是旧数据,发生了不一致的情况。
这种问题怎么解决呢?
方案一:我们可以提供一个重试的机制。
比如∶如果删除缓存失败,我们捕获这个异常,把需要删除的key 发送到消息队列。让后自己创建一个消费者消费,尝试再次删除这个 key。这种方式有个缺点,会对业务代码造成入侵。
方案二:(异步更新缓存)
因为更新数据库时会往 binlog 写入日志,所以我们可以通过一个服务来监听binlog的变化(比如阿里的canal),然后在客户端完成删除key的操作。如果删除失败的话,再发送到消息队列。
总之,对于后删除缓存失败的情况,我们的做法是不断地重试删除,直到成功。无论是重试还是异步删除,都是最终一致性的思想。
先删除缓存,再更新数据库
正常情况∶
删除缓存,成功。更新数据库,成功。
异常情况∶
- 删除缓存,程序捕获异常,不会走到下一步,所以数据不会出现不一致。
- 删除缓存成功,更新数据库失败。 因为以数据库的数据为准,所以不存在数据不一致的情况。
看起来好像没问题,但是如果有程序并发操作的情况下∶
- 线程 A 需要更新数据,首先删除了 Redis 缓存
- 线程 B 查询数据,发现缓存不存在,到数据库查询旧值,写入 Redis,返回
- 线程 A 更新了数据库
这个时候,Redis 是旧的值,数据库是新的值,发生了数据不一致的情况。
这个是由于线程并发造成的问题。能不能让对同一条数据的访问串行化呢?代码肯定保证不了,因为有多个线程,即使做了任务队列也可能有多个应用实例(应用做了集群部署)。
数据库也保证不了,因为会有多个数据库的连接。只有一个数据库只提供一个连接的情况下,才能保证读写的操作是串行的,或者我们把所有的读写请求放到同一个内存队列当中,但是强制串行操作,吞吐量太低了。
怎么办呢? 删一次不放心,隔一段时间再删一次。
所以我们有一种延时双删的策略,在写入数据之后,再删除一次缓存。
高并发问题
缓存雪崩
什么是缓存雪崩
缓存雪崩就是 Redis 的大量热点数据同时过期(失效),因为设置了相同的过期时间,刚好这个时候 Redis 请求的并发量又很大,就会导致所有的请求落到数据库。
这个问题怎么解决呢?
缓存雪崩的解决方案
- 加互斥锁或者使用队列,针对同一个key 只允许一个线程到数据库查询
- 缓存定时预先更新,避免同时失效
- 通过加随机数,使 key 在不同的时间过期
- 缓存永不过期
缓存穿透
缓存穿透何时发生
在这里 Redis 起到了提升查询速度和保护数据库的作用。
还有一种情况,数据在数据库和Redis 里面都不存在,可能是一次条件错误的查询。在这种情况下,因为数据库值不存在,所以肯定不会写入Redis,那么下一次查询相同的 key的时候,肯定还是会再到数据库查一次。那么这种循环查询数据库中不存在的值,并且每次使用的是相同的 key 的情况,我们有没有什么办法避免应用到数据库查询呢?
while(true){
where id ='*xxxxx'
}
(1)缓存空数据 (2)缓存特殊字符串,比如&&
我们可以在数据库缓存一个空字符串,或者缓存一个特殊的字符串,那么在应用里面拿到这个特殊字符串的时候,就知道数据库没有值了,也没有必要再到数据库查询了。
但是这里需要设置一个过期时间,不然的会数据库已经新增了这一条记录,应用也还是拿不到值。
这个是应用重复查询同一个不存在的值的情况,如果应用每一次查询的不存在的值是不一样的呢?
即使你每次都缓存特殊字符串也没用,因为它的值不一样,比如我们的用户系统登录的场景,如果是恶意的请求,它每次都生成了一个符合ID规则的账号,但是这个账号在我们的数据库是不存在的,那 Redis 就完全失去了作用。
while(true){
where id = random()
}
这种因为每次查询的值都不存在导致的Redis 失效的情况,我们就把它叫做缓存穿透。这个问题我们应该怎么去解决呢?
经典面试题
如何在海量元素中(例如 10 亿无序、不定长、不重复)快速判断一个元素是否存在?
如果是缓存穿透的这个问题,我们要避免到数据库查询不存的数据,肯定要把这 10亿放在别的地方。为了加快检索速度,我们要把数据放到内存里面来判断,问题来了
如果我们直接把这些元素的值放到基本的数据结构(List、Map、Tree)里面,比如一个元素1字节的字段,10亿的数据大概需要900G的内存空间,这个对于普通的服务器来说是承受不了的。
所以,我们存储这几十亿个元素,不能直接存值,我们应该找到一种最简单的最节省空间的数据结构,用来标记这个元素有没有出现。(比如签到表按顺序打钩)
这个东西我们就把它叫做位图,他是一个有序的数组,只有两个值,0和1。0代表不存在,1代表存在。
那我们怎么用这个数组里面的有序的位置来标记这10亿个元素是否存在呢?我们是不是必须要有一个映射方法,把元素映射到一个下标位置上?
对于这个映射方法,我们有几个基本的要求∶
- 因为我们的值长度是不固定的,我希望不同长度的输入,可以得到固定长度的输出。
- 转换成下标的时候,我希望他在我的这个有序数组里面是分布均匀的,不然的话全部挤到一对去了,我也没法判断到底哪个元素存了,哪个元素没存。
这个就是哈希函数,比如 MD5、SHA-1等等这些都是常见的哈希算法。
比如,这 6 个元素,我们经过哈希函数和位运算,得到了相应的下标。
哈希碰撞
这个时候,Tom和 Mic 经过计算得到的哈希值是一样的,那么再经过位运算得到的下标肯定是一样的,我们把这种情况叫做哈希冲突或者哈希碰撞。
如果发生了哈希碰撞,这个时候对于我们的容器存值肯定是有影响的,从数据结构和映射方法这两个角度来分析,我们可以通过哪些方式去降低哈希碰撞的概率呢?
第一种就是扩大维数组的长度或者说位图容量。因为我们的函数是分布均匀的,所以,位图容量越大,在同一个位置发生哈希碰撞的概率就越小。
是不是位图容量越大越好呢?不管存多少个元素,都创建一个几万亿大小的位图,可以吗?当然不行,因为越大的位图容量,意味着越多的内存消耗,所以我们要创建一个合适大小的位图容量。
除了扩大位图容量,我们还有什么降低哈希碰撞概率的方法呢?
如果两个元素经过一次哈希计算,得到的相同下标的概率比较高,我可以不可以计算多次呢? 原来我只用一个哈希函数,现在我对于每一个要存储的元素都用多个哈希函数计算,这样每次计算出来的下标都相同的概率就小得多了。
同样的,我们能不能引入很多个哈希函数呢?比如都计算100次,都可以吗?当然也会有问题,第一个就是它会填满位图的更多空间,第二个是计算是需要消耗时间的。
所以总的来说,我们既要节省空间,又要很高的计算效率,就必须在位图容量和函数个数之间找到一个最佳的平衡。
布隆过滤器原理
布隆过滤器的本质就是我们刚才分析的,一个位数组,和若干个哈希函数。
集合里面有3个元素,要把它存到布隆过滤器里面去,应该怎么做?
首先是a元素,这里我们用 3 次计算。b、c 元素也一样。
元素已经存进去之后,现在我要来判断一个元素在这个容器里面是否存在,就要使用同样的三个函数进行计算。
比如 d元素,我用第一个函数f1计算,发现这个位置上是1,没问题。第二个位置也是1,第三个位置也是 1。
如果经过三次计算得到的下标位置值都是1,这种情况下,能不能确定d元素一定在这个容器里面呢?
实际上是不能的。比如这张图里面,这三个位置分别是把a,bc存进去的时候置成1的,所以即使d元素之前没有存进去,也会得到三个1,判断返回true。所以,这个是布隆过滤器的一个很重要的特性,因为哈希碰撞不可避免,所以它会存在一定的误判率。这种把本来不存在布隆过滤器中的元素误判为存在的情况,我们把它叫做假阳性(False Positive Probability,FPP)。
我们再来看另一个元素,e元素。我们要判断它在容器里面是否存在,一样地要用这三个函数去计算。第一个位置是 1,第二个位置是1,第三个位置是 0。
e元素是不是一定不在这个容器里面呢? 可以确定一定不存在。如果说当时已经把 e元素存到布隆过滤器里面去了,那么这三个位置肯定都是1,不可能出现0。
总结∶布隆过滤器的特点∶
从容器的角度来说∶
- 如果布隆过滤器判断元素在集合中存在,不一定存在
- 如果布隆过滤器判断不存在,一定不存在
从元素的角度来说∶
- 如果元素实际存在,布隆过滤器一定判断存在
- 如果元素实际不存在,布隆过滤器可能判断存在
布隆过滤器在项目中的使用
布隆过滤器的工作位置:
布隆过滤器的不足与变种
如果数据库删除了,布隆过滤器的数据也要删除。但是布隆过滤器里面没有提供删除的方法。
为什么布隆过滤器不提供删除的方法呢?或者说,如果删除了布隆过滤器的元素,会发生什么问题?
比如我们把a删除了,那个三个位置都要改成 0。但是再来判断b元素是否存在的时候,因为有一个位置变成了0,所以b元素也判断不存在。就是因为存在哈希碰撞,所以元素只能存入,不能删除。
那如果我们要实现删除的功能,怎么做呢?
类似于 HashMap的链地址法,我们可以在每个下标位置上增加一个计数器。比如这个位置命中了两次,计数器就是2。当删除 a 元素的时候,先把计数器改成1。删除b元素的时候,计数器变成0,这个时候下标对应的位才置成 0。
实际上在布隆过滤器提出来的几十年里面,出现了很多布隆过滤器的变种,这种通过计数器提供删除功能的 bf 就叫做 Counting Bloom Filter。
布隆过滤器的其他应用场景
布隆过滤器解决的问题是什么?如何在海量元素中快速判断一个元素是否存在。所以除了解决缓存穿透的问题之外,我们还有很多其他的用途。
比如爬数据的爬虫,爬过的url我们不需要重复爬,那么在几十亿的 url里面,怎么判断一个 url 是不是已经爬过了?
还有我们的邮箱服务器,发送垃圾邮件的账号我们把它们叫做 spamer,在这么多的邮箱账号里面,怎么判断一个账号是不是 spamer?
等等一些场景,我们都可以用到布隆过滤器。