redis实战

redis实战

Redis客户端

客户端与服务端通信

通信协议

Redis监听默认6379的端口号,可以通过TCP方式建立连接。

服务端约定了一种特殊的消息格式,每个命令都是以\r\n (CRLF回车+换行)结尾。这种编码格式之前在AOF文件里面见到了,叫做Redis Serialization Protocol(RESP,Redis序列化协议),发消息或者响应消息需要按这种格式编码,接收消息需要按这种格式解码。

Redis设计这种格式的原因:容易实现、解析快、可读性强。Redis6.0新特性里面说的RESP协议升级到了3.0版本,其实就是对于服务端和客户端可以接收的消息进行了升级扩展,比如客户端缓存的功能就是在这个版本里面实现的。

示例

1
2
*3\r\n$3\r\nSET\r\n$5\r\nabcde\r\n$4\r\n2673\r\n
*2\r\n$3\r\nGET\r\n$5\r\nabcde\rn

其实就是把命令、参数和他们的长度用\r\n连接起来。

*3代表3个参数

$3代表第一个参数(set)的长度

$5代表第一个参数(abcde)的长度

$4代表第一个参数(2673)的长度

常用客户端

现在已经有很多比较成熟的客户端,实现了完整的功能和高级特性。

官网推荐的Java客户端有3个:Jedis,Redisson和Luttuce。

image-20210828095553474

Spring操作Redis提供了一个模板方法,RedisTemplate。

这个是不是Spring官方开发的一个客户端呢?实际上并没有。

Spring定义了定义了一个连接工厂接口:RedisConnectionFactory。

这个接口有很多实现,例如:JedisConnectionFactory、 JedisConnectionFactory、LettuceConnectionFactorySrpConnectionFactory。

也就是说,RedisTemplate对其他现成的客户端再进行了一层封装而已,在Spring Boot 2.x版本之前,RedisTemplate默认使用Jedis。2.x版本之后,默认使用Lettuce。

Jedis

Jedis有一个问题:多个线程使用一个连接的时候线程不安全。

解决思路:使用连接池,为每个请求创建不同的连接,基于Apachecommon pool实现。

Jedis的连接池有三个实现:JedisPool、ShardedJedisPool、 JedisSentinelPool,都是用getResource从连接池获取一个连接。

Jedis的功能比较完善,Redis官方的特性全部支持,比如发布订阅、事务、Lua脚本、客户端分片、哨兵、集群、pipeline等等。

其中 Pipeline是通过一个队列把所有的命令缓存起来,然后把多个命令在一次连接中发送给服务器。

Lettuce

https://lettuce.io/

特点

与Jedis相比,Lettuce则完全克服了其线程不安全的缺点:Lettuce是一个可伸缩的线程安全的Redis客户端,支持同步、异步和响应式模式(Reactive)。多个线程可以共享一个连接实例,而不必担心多线程并发问题。

Lettuce基于Netty框架构建,支持Redis的全部高级功能,如发布订阅、事务、lua脚本、Sentinel、集群、Pipeline支持连接池。

Lettuce是Spring Boot2.x默认的客户端,替换了Jedis,集成之后我们不需要单独使用它,直接调用Spring的RedisTemplate操作,连接和创建和关闭也不需要我们操心。

Redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid),提供了分布式和可扩展的Java数据结构,比如分布式的Map、 List、Queue、Set,不需要自己去运行一个服务实现。

由一个俄罗斯人和一个中国人共同发起的。

特点

基于Netty实现,采用非阻塞10,性能高;

支持异步请求。

支持连接池、pipeline、 LUA Scripting、 Redis Sentinel、 Redis Cluster

不支持事务,官方建议以LUA Scripting代替事务

主从、哨兵、集群都支持。

Spring也可以配置和注入RedissonClient。

Redisson跟Jedis定位不同,它不是一个单纯的Redis客户端,而是基于Redis实现的分布式的服务,如果有需要用到一些分布式的数据结构,比如我们还可以基于Redisson的分布式队列实现分布式事务,就可以引入Redisson的依赖实现。

数据一致性

缓存使用场景

针对读多写少的高并发场景,我们可以使用缓存来提升查询速度,

当我们使用Redis作为缓存的时候,一般流程是这样的:

1、如果数据在Redis存在,应用就可以直接从Redis拿到数据,不用访问数据库。

2、应用新增了数据,只保存在数据库中,这个时候Redis没有这条数据,如果Redis里面没有,先到数据库查询,然后写入到Redis,再返回给应用。

一致性问题的定义

因为数据最终是以数据库为准的(这是我们的原则),如果Redis没有数据,就不存在这个问题。当Redis和数据库都有同一条记录,而这条记录发生变化的时候,就可能出现一致性的问题。

一旦被缓存的数据发生变化(比如修改、删除)的时候,我们既要操作数据库的数据,也要操作Redis的数据,才能让Redis和数据库保持一致,所以问题来了,现在我们有两种选择:

1、先操作Redis的数据再操作数据库的数据

2、先操作数据库的数据再操作Redis的数据

首先需要明确的是,不管选择哪一种方案,我们肯定是希望两个操作要么都成功要么都一个都不成功。但是,Redis的数据和数据库的数据是不可能通过事务达到统一的,我们只能根据相应的场景和所需要付出的代价来采取一些措施降低数据不一致的问题出现的概率,在数据一致性和性能之间取得一个权衡。

比如,对于数据库的实时性一致性要求不是特别高的场合,比如T+1的报表,可以采用定时任务查询数据库数据同步到Redis的方案。

由于我们是以数据库的数据为准的,所以给缓存设置一个过期时间,删除Redis的数据,也能保证最终一致性。

既然提到了Redis和数据库一致性的问题,一般是希望尽可能靠近实时一致性,操作延迟带来的不一致的时间越少越好。

方案选择

Redis:删除还是更新?

当存储的数据发生变化,Redis的数据也要更新的时候,我们有两种方案,一种就是直接更新Redis数据,调用set;还有一种是直接删除Redis数据,让应用在下次查询的时候重新写入。

这两种方案怎么选择呢?这里我们主要考虑更新缓存的代价。

更新缓存之前,是不是要经过其他表的查询、接口调用、计算才能得到最新的数据,而不是直接从数据库拿到的值。如果是的话,建议直接删除缓存,这种方案更加简单,而且避免了数据库的数据和缓存不一致的情况。在一般情况下,我们也推荐使用删除的方案。

所以,更新操作和删除操作,只要数据变化,都用删除。

这一点明确之后,现在我们就剩一个问题:

1、到底是先更新数据库,再删除缓存

2、还是先删除缓存,再更新数据库

先更新数据库,再删除缓存

正常情况:

更新数据库,成功。

删除缓存,成功。

异常情况:

1、更新数据库失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

2、更新数据库成功,删除缓存失败。数据库是新数据,缓存是旧数据,发生了不一致的情况。

这种问题怎么解决呢?我们可以提供一个重试的机制。

比如:如果删除缓存失败,我们捕获这个异常,把需要删除的key发送到消息队列。然后自己创建一个消费者消费,尝试再次删除这个key,这种方式有个缺点,会对业务代码造成入侵。

所以我们又有了第二种方案(异步更新缓存):

因为更新数据库时会往binlog写入日志,所以我们可以通过一个服务来监听binlog的变化(比如阿里的canal),然后在客户端完成删除key的操作。如果删除失败的话,再发送到消息队列。

总之,对于后删除缓存失败的情况,我们的做法是不断地重试删除,直到成功。无论是重试还是异步删除,都是最终一致性的思想。

先删除缓存,再更新数据库

正常情况:

删除缓存,成功。

更新数据库,成功。

异常情况:

1、删除缓存,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

2、删除缓存成功,更新数据库失败,因为以数据库的数据为准,所以不存在数据。

看起来好像没问题,但是如果有程序并发操作的情况下:

1)线程A需要更新数据,首先删除了Redis缓存

2)线程B查询数据,发现缓存不存在,到数据库查询旧值,写入Redis,返回

3)线程A更新了数据库

这个时候,Redis是旧的值,数据库是新的值,发生了数据不一致的情况。

这个是由于线程并发造成的问题。

能不能让对同一条数据的访问串行化呢?

代码肯定保证不了,因为有多个线程,即使做了任务队列也可能有多个应用实例(应用做了集群部署)。

数据库也保证不了,因为会有多个数据库的连接。只有一个数据库只提供一个连接者的情况下,才能保证读写的操作是串行的,或者我们把所有的读写请求放到同一个内存队列当中,但是强制串行操作,吞吐量太低了。

怎么办呢?删一次不放心,隔一段时间再删一次。

所以我们有一种延时双删的策略,在写入数据之后,再删除一次缓存。

A线程:

1)删除缓存

2)更新数据库

3)休眠500ms (这个时间,依据读取数据的耗时而定)

4)再次删除缓存

高并发问题

在Redis存储的所有数据中,有一部分是被频繁访问的。有两种情况可能会导致热点问题的产生,一个是用户集中访问的数据,比如抢购的商品,明星结婚和明星出轨的微博。还有一种就是在数据进行分片的情况下,负载不均衡,超过了单个服务器的承受能力。

热点问题可能引起缓存服务的不可用,最终造成压力堆积到数据库。

出于存储和流量优化的角度,我们必须要找到这些热点数据

热点数据发现

首先,Redis的缓存淘汰机制,能够留下那些热点的key,不管是LRU还是LFU。除了自动的缓存淘汰机制之外,怎么找出那些访问频率高的key呢?或者说,我们可以在哪里记录key被访问的情况呢?

客户端

第一个当然是在客户端了,比如我们可不可以在所有调用了get、set方法的地方,加上key的计数。但是这样的话,每一个地方都要修改,重复的代码也多。如果我们用的是Jedis的客户端,我们可以修改Jedis的源码,在Jedis的Connection类的sendCommand()里面,用一个HashMap进行key的计数。

但是这种方式有几个问题:

1、会对客户端的代码造成入侵。

2、不知道要存多少个key,可能会发生内存泄露的问题。

3、只能统计当前客户端的热点key。

代理层

第二种方式就是在代理端实现,比如TwemProxy或者Codis,但是不是所有的项月都使用了代理的架构。

服务端

第三种就是在服务端统计,Redis有一个monitor的命令,可以监控到所有Redis执行的命令。

Facebook的开源项目redis-faina就是基式这个原理实现的,可以分析monitor的数据。

https://github.com/facebookarchive/redis-faina.git

1
redis-cli -p 6379 monitor| head -n l00000|./redis-faina.py

这种方法也会有两个问题:

1)monitor命令在高并发的场景下,会影响性能,所以不适合长时间使用。

2)只能统计一个Redis节点的热点key。

机器层面

还有一种方法就是机器层面的,通过对TCP协议进行抓包,也有一些开源的方案,比如ELK的packetbeat插件。

当发现了热点key之后,来看下热点数据在高并发的场景下可能会出现的问题,以及怎么去解决。

缓存雪崩

什么是缓存雪崩

缓存雪崩就是Redis的大量热点数据同时过期(失效),因为设置了相同的过期时间,刚好这个时候Redis请求的并发量又很大,就会导致所有的请求落到数据库。

缓存雪崩的解决方案

1)加互斥锁或者使用队列,针对同一个key只允许一个线程到数据库查询

2)缓存定时预先更新,避免同时失效

3)通过加随机数,使key在不同的时间过期

4)缓存永不过期

缓存穿透

缓存穿透何时发生

缓存穿透就是redis缓存和数据库都不存在对应的key,所有请求都落到了数据库中。

image-20210828104706417

在这里Redis起到了提升查询速度和保护数据库的作用。

还有一种情况,数据在数据库和Redis里面都不存在,可能是一次条件错误的查询。在这种情况下,因为数据库值不存在,所以肯定不会写入Redis,那么下一次查询相同的key的时候,肯定还是会再到数据库查一次。那么这种循环查询数据库中不存在的值,并且每次使用的是相同的key的情况,我们有没有什么办法避免应用到数据库查询呢?

(1) 缓存空数据

(2) 缓存特殊字符串,比如&&

我们可以在数据库缓存一个空字符串,或者缓存一个特殊的字符串,那么在应用里面拿到这个特殊字符串的时候,就知道数据库没有值了,也没有必要再到数据库查询了。

但是这里需要设置一个过期时间,不然的会数据库已经新增了这一条记录,应用也还是拿不到值。

这个是应用重复查询同一个不存在的值的情况,如果应用每一次查询的不存在的值是不一样的呢?

每次都缓存特殊字符电也没用,因为它的值不一样,比如我们的用户系统登录的场景,如果是恶意的请求,它每次都生成了一个符合ID规则的账号,但是这个账号在我们的数据库是不存在的,那Redis就完全失去了作用。

这个问题我们应该怎么去解决呢?

布隆过滤器

其实它也是一个通用的问题,关键就在于我们怎么知道请求的key在我们的数据库里面是否存在,如果数据量特别大的话,我们怎么去快速判断。

这也是一个非常经典的面试题:

如何在海量元素中(例如10亿无序、不定长、不重复)快速判断一个元素是否存在?

如果是缓存穿透的这个问题,我们要避免到数据库查询不存的数据,肯定要把这10亿放在别的地方。为了加快检索速度,我们要把数据放到内存里面来判断,问题来了,如果我们直接把这些元素的值放到基本的数据结构(List、Map、Tree)里面,比如一个元素1字节的字段,10亿的数据大概需要900G的内存空间,这个对于普通的服务器来说是承受不了的。

所以,我们存储这几十亿个元素,不能直接存值,我们应该找到一种最简单的最节省空间的数据结构,用来标记这个元素有没有出现。
这个东西我们就把它叫做位图,他是一个有序的数组,只有两个值,0和1,0代表不存在,1代表存在。

这个时候我们需要使用布隆过滤器,参考

Guava的实现

谷歌的Guava里面就提供了一个现成的布隆过滤器。

1
2
3
4
5
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactld>
<version>21.0</version>
</dependency>

创建布隆过滤器:

1
BloomFilter<String> bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),insertions);

布隆过滤器提供的存放元素的方法是put()。

布隆过滤器提供的判断元素是否存在的方法是mightContain()。

布隆过滤器在项目中的使用

布隆过滤器的工作位置:

image-20210828105856702

因为要判断数据库的值是否存在,所以第一步是加载数据库所有的数据。在去Redis查询之前,先在布隆过滤器查询,如果bf说没有,那数据库肯定没有,也不用去查了。如果bf说有,才走之前的流程。

布隆过滤器的不足与变种

如果数据库删除了,布隆过滤器的数据也要删除,但是布隆过滤器里面没有提供删除的方法。为什么布隆过滤器不提供删除的方法呢?

如果删除了布隆过滤器的元素,会发生什么问题?

image-20210828110043603

比如我们把a删除了,那个三个位置都要改成0。但是再来判断b元素是否存在的时候,因为有一个位置变成了0,所以b元素也判断不存在。就是因为存在哈希碰撞,所以元素只能存入,不能删除。

那如果我们要实现删除的功能,怎么做呢?类似于HashMap的链地址法,我们可以在每个下标位置上增加一个计数器。比如这个位置命中了两次,计数器就是2,当删除a元素的时候,先把计数器改成1,删除b元素的时候,计数器变成0,这个时候下标对应的位才置成0。

实际上在布隆过滤器提出来的几十年里面,出现了很多布隆过滤器的变种,这种通过计数器提供删除功能的bf就叫做Counting Bloom Filter。

布隆过滤器的其他应用场景

布隆过滤器解决的问题是什么?如何在海量元素中快速判断一个元素是否存在。

所以除了解决缓存穿透的问题之外,我们还有很多其他的用途。

比如爬数据的爬虫,爬过的url我们不需要重复爬,那么在几十亿的url里面,怎么判断一个url是不是已经爬过了?

还有我们的邮箱服务器,发送垃圾邮件的账号我们把它们叫做spamer,在这么多邮箱账号里面,怎么判断一个账号是不是spamer?

等等一些场景,我们都可以用到布隆过滤器。

打赏

请我喝杯咖啡吧~

支付宝
微信