Java面试题系列(十)——Redis

1. 分布式数据库的CAP原理

  • Consistency:强一致性
  • Availability:可用性
  • Partitition tolerance:分区容错性
  • 只能三选二:
    CA:传统关系型数据库
    AP:大型网站
    CP: Redis、Mongodb

2. BASE

  • Basically Available基本可用
  • Soft state 软状态
  • Eventually consistent 最终一致性

3. Redis

  Remote dictionary server(远程字典服务器)是一个高性能的(key/value)分布式内存数据库,基于内存运行,并支持持久化的NoSQL数据库。具有如下特点:

  • redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启时可以再次加载进行使用;
  • redis不仅支持key/value类型的数据,还提供list,set,zset,String等数据结构的存储;
  • redis支持数据的备份,即master-salve模式的数据备份。

4. Redis五大数据类型及应用场景

  • String 最多512M,以一种纯字符串作为value的形式存在的。value可以存储json格式、数值型等。
    • string使用场景一般是存储简单的键值类型。比如用户信息,登录信息,配置信息等。
    • string的incr/decr操作,即自减/自增操作。调用它是原子性的,无论调用多少次,都一一计算成功。例如需要增减库存的操作。
  • List 底层是一个链表,在redis中,插入list中的值,只需要找到list的key即可,而不需要像hash一样插入两层的key。list是一种有序的、可重复的集合。
    • list可以使用左推、左拉、右推、右拉的方式。所以你可以使用list作为集合存储,比如存储某宝商铺里面的所有商品。
    • 也可以用作轻量级别的队列来使用。左推左拉、右推右拉。需要注意的是尽管redis可以使用推拉的队列模式,但是一定要注意场景。因为redis的队列是一种轻量级别的,没有队列重试、队列重放机制。消费完队列消息在redis代表已经删除了。
  • Hash String类型的field和value的映射表,hash特别适合适用于存储对象。在redis中,hash因为是一个集合,所以有两层。第一层是key:hash集合value,第二层是hashkey:string value。所以判断是否采用hash的时候可以参照有两层key的设计来做参考。并且注意的是,设置过期时间只能在第一层的key上面设置。
    • 使用hash,一般是有那种需要两层key的应用场景,也可以是‘删除一个key可以删除所有内容’的场景。例如一个商品有很多规格,规格里面有不同的值。
    • 如果需要删除商品时,可以一次性删除‘商品id’的key,则商品里面的所有规格也会删除,而不需要找到对应的规格再做处理。如果查找商品id与规格id1的商品时,则通过两个key查找即可。
    • 或者查找所有商品的规格,查找商品id即可。
    • 需要注意的是,经过测试,在性能上来说一般hash里面的第二层key,不要超过200个为佳。尽管hash里面的key-value能达到500多MB的存储容量。
  • Set 是一种无序的,不能重复的集合。并且在redis中,只有一个key。
    • 如保存一些标签的名字。标签的名字不可以重复,顺序是可以无序的。
  • ZSet(Sorted Set:有序集合) 每个元素都会关联一个double类型的分数,分数允许重复
    • 排行榜

5. Redis String的实现

  Redis虽然是用C语言写的,但却没有直接用C语言的字符串,而是自己实现了一套字符串。目的就是为了提升速度,提升性能。Redis构建了一个叫做简单动态字符串(Simple Dynamic String),简称SDS

struct sdshdr{    
    //  记录已使用长度    
    int len;    
    // 记录空闲未使用的长度    
    int free;    
    // 字符数组    
    char[] buf;    
};  

Redis的字符串也会遵守C语言的字符串的实现规则,即最后一个字符为空字符。然而这个空字符不会被计算在len里头。

  • Redis动态扩展步骤:
    • 计算出大小是否足够
    • 开辟空间至满足所需大小
    • 开辟与已使用大小len相同长度的空闲free空间(如果len < 1M),开辟1M长度的空闲free空间(如果len >= 1M)
  • Redis字符串的性能优势
    • 快速获取字符串长度:直接返回len
    • 避免缓冲区溢出:每次追加字符串时都会检查空间是否够用
    • 降低空间分配次数提升内存使用效率:(1)空间预分配;(2)惰性空间回收

6. Redis持久化

  • RDB(redis database)在指定时间间隔内将内存中的数据集快照写入磁盘,也就是snapshot快照,恢复时是将快照文件直接读到内存里。Redis会单独创建(fork:复制一个与当前进程一样的进程)一个子进程来进行持久化,会先将数据写入到临时文件,待持久化过程结束,再替换上次持久化好的文件(dump.rdb)。主进程不进行IO操作。如果需要进行大规模数据的恢复,且对数据完整性不敏感,那么RDB比AOF更高效。缺点就是最后一次持久化的数据可能丢失。
    • 默认:1分钟改了一万次,5分钟改了10次,15分钟改了一次
  • AOF以日志的形式来记录每个写操作,将redis执行过的所有写的指令记录下来(读操作不记录),只需追加文件当不可以改写文件,redis重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
    • 同步策略:always一直同步、everysec每秒同步、no不同步
    • AOF的优点:
      • 备份机制更稳健,丢失数据概率低
      • 可读的日志文本,可以处理误操作
    • AOF的缺点:
      • 比RDB占用更多的磁盘空间
      • 恢复备份速度慢
      • 每次读写同步的话有一定的性能压力
      • 存在个别的bug,造成不能恢复
    • AOF重写机制:当aof文件的大小超过所设定的阈值时,redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集,可以使用命令bgrewriteaof,fork出一条新进程来将文件重写,redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的1倍且文件大于64M时触发。

7. Redis的事务

  • 定义:可以一次执行多个命令,部分支持事务
  • 命令:MULTI开启事务、EXEC执行事务、DISCARD放弃事务、WATCH监视一个或多个key,如果在事务执行之前这个key被其他命令所改动,那么事务将被打断、UNWACTH一旦执行了EXEC之前加的所有的监控锁都会被取消。
  • 特性:
    • 单独的隔离操作:事务中所有的命令都会序列化、按顺序地执行。事务执行过程中,不会被其他客户端发送过来的命令请求所打断。
    • 没有隔离级别的概念
    • 不保证原子性:只要有一条命令执行失败,其他的命令仍然会执行,不支持回滚

8. LUA脚本

  • 定义:LUA是一种小巧的脚本语言,可以很容易地被C/C++调用,也可以调用C/C++函数,一个完整的LUA解释器不超过200k,适合作为嵌入式脚本语言。
  • 在redis中的优势
    • 将复杂或者多步的redis操作,写为一个脚本,一次性提交给redis执行,减少反复连接redis的次数,提升性能。
    • LUA脚本类似redis事务,有一定的原子性,不会被其他命令插队

9. Redis内存淘汰策略

  • 这八种大体上可以分为4种,lru、lfu、random、ttl。
1)  volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
2)  volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。
3)  volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
4)  volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰。
5)  allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
6)  allkeys-lfu:从数据集中挑选使用频率最低的数据淘汰。
7)  allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
8)  no-enviction(驱逐):禁止驱逐数据,这也是默认策略。意思是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用no-enviction策略可以保证数据不被丢失。

10. 秒杀常见问题

  • 连接超时
    • 使用连接池
  • 超卖问题
    • 使用事务
  • 库存遗留
    • 使用LUA脚本

11. Redis发布订阅

  • SUBSCRIBE c1 c2 c3
  • PUBLISH c2 hello-redis

12. Redis主从复制

  • 配从不配主:slaveof 主库IP 主库端口,每次与master断开之后,都需要重新连接,除非配置redis.conf文件。
  • 配置文件细节操作:
    • 拷贝多个redis.conf文件
    • 开启daemonize yes
    • Pid文件名字
    • 指定端口
    • Log文件名字
    • Dump.rdb名字
  • 常用招式
    • 一主二仆
    • Info replication:查看信息
    • SLAVEOF 127.0.0.1 6379:配置从库


      image.png

13. 哨兵模式

  • 定义:反客为主(slaveof no one)自动化,能够监控主机是否故障,如果故障根据投票数自动将从库转为主库
  • 使用步骤:
    • 调整结构,6379带着80、81
    • 自定义的/myredis目录下新建sentinel.conf文件
    • 配置哨兵,填写内容:Sentinel monitor host637(被监控数据库名字(自己起名字))127.0.0.1 6379 1(多于1票则设为主机)
    • 启动哨兵:Redis-sentinel /myredis/sentinel.conf

14. Java使用redis

  • 连接:Jedis jedis = new Jedis(“127.0.0.1”,6379);
  • 插入:jedis.set(“k1”,”v1”);
  • 事务:
    Transaction transaction = jedis.multi();
    transaction.set(“k2”,”v2”);
    transaction.set(“k3”,”v3”);
    transaction.exec();
  • 加锁:
public class TestTransaction {
  public boolean transMethod() {
     Jedis jedis = new Jedis("127.0.0.1", 6379);
     int balance;// 可用余额
     int debt;// 欠额
     int amtToSubtract = 10;// 实刷额度

     jedis.watch("balance");
     //jedis.set("balance","5");//此句不该出现。模拟其他程序已经修改了该条目
     balance = Integer.parseInt(jedis.get("balance"));
     if (balance < amtToSubtract) {
       jedis.unwatch();
       System.out.println("modify");
       return false;
     } else {
       System.out.println("***********transaction");
       Transaction transaction = jedis.multi();
       transaction.decrBy("balance", amtToSubtract);
       transaction.incrBy("debt", amtToSubtract);
       transaction.exec();

       balance = Integer.parseInt(jedis.get("balance"));
       debt = Integer.parseInt(jedis.get("debt"));

       System.out.println("*******" + balance);
       System.out.println("*******" + debt);
       return true;
     }
  }

  /**
   * 通俗点讲,watch命令就是标记一个键,如果标记了一个键, 在提交事务前如果该键被别人修改过,那事务就会失败,这种情况通常可以在程序中重新再尝试一次。
   * 首先标记了键balance,然后检查余额是否足够,不足就取消标记,并不做扣减; 足够的话,就启动事务进行更新操作,
   * 如果在此期间键balance被其它人修改, 那在提交事务(执行exec)时就会报错, 程序中通常可以捕获这类错误再重新执行一次,直到成功。
   */
  public static void main(String[] args) {
     TestTransaction test = new TestTransaction();
     boolean retValue = test.transMethod();
     System.out.println("main retValue-------: " + retValue);
  }
}
  • 主从复制
    • 配置从库:Jedis jedis_s = new Jedis(“127.0.0.1”,6380);
    • Jedis_s.slaveof(“127.0.0.1”,6379);
  • JedisPool
JedisPoolConfig poolConfig = new JedisPoolConfig( );
poolConfig.setMaxActive ( 1000);
poolconfig.setMaxIdle ( 32);
poolconfig. setMaxwait (100*1000);poolconfig.setTestOnBorrow(true);

jedisPool = new JedisPool(poolConfig, "127.0.0.1",6379);

-   maxActive:控制一个pool可分配多少个jedis实例,通过pool.getResource()来获取;如果赋值为-1,则表示不限制;如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted。
-   maxIdle:控制一个pool最多有多少个状态为idle(空闲)的jedis实例;
-   whenExhaustedAction:表示当pool中的jedis实例都被allocated完时,pool要采取的操作:默认有三种。
-   WHEN_EXHAUSTED_FAIL -->表示无jedis实例时,直接抛出NoSuchElementException;
-   WHEN_EXHAUSTED_BLOCK -->则表示阻塞住,或者达到maxWait时抛出JedisConnectionException;
-   WHEN_EXHAUSTED_GRoW -->则表示新建一个jedis实例,也就说设置的maxActive无用;
-   maxWait:表示当borrow一个jedis实例时,最大的等待时间,如果超过等待时间,则直接抛JedisConnectionException;
-   testOnBorrow:获得一个jedis实例的时候是否检查连接可用性(ping());如果为true,则得到的jedis实例均是可用的:

15. 解决session存储问题

  • 方案一:存在cookie里
    • 不安全
    • 网络负担效率低
  • 方案二:存在文件服务器或数据库里
    • 大量的IO效率问题
  • 方案三:session复制
    • Session数据冗余
    • 节点越多浪费越大
  • 方案四:缓存数据库
    • 完全存在内存中,速度快数据结构简单

16. 单线程+多路IO复用

  多路复用是指用一个线程来检查多个文件描述符(socket)的就绪状态,比如调用select、poll、epoll函数进行监视,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)。
  多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。

  • Select:每一个请求都进行询问,最多1024个
  • Poll:每一个请求都进行询问不限制数量
  • Epoll:监视请求时为每个请求设置标识符,不需要一一询问

17. Select、poll、epoll

  • select的几大缺点:
    1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
    2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
    3)select支持的文件描述符数量太小了,默认是1024
  • 为什么epoll比select和poll更高效?
    1)减少了用户态和内核态之间文件描述符的拷贝
    2)减少了对就绪文件描述符的遍历
    3)select和poll只支持LT模式,而epoll支持高效的ET模式,并且epoll还支持EPOLLONESHOT事件。
  • 无论哪种情况下,epoll都比select和poll高效吗?
    • epoll适用于连接较多,活动数量较少的情况。
      1)epoll为了实现返回就绪的文件描述符,维护了一个红黑树和好多个等待队列,内核开销很大。如果此时监听了很少的文件描述符,底层的开销会得不偿失;
      2)epoll中注册了回调函数,当有事件发生时,服务器设备驱动调用回调函数将就绪的fd挂在rdllist上,如果有很多的活动,同一时间需要调用的回调函数数量太多,服务器压力太大。
    • select和poll适用于连接较少的情况。
      1)当select和poll上监听的fd数量较少,内核通知用户现在有就绪事件发生,应用程序判断当前是哪个fd就绪所消耗的时间复杂度就会大大减小。

18. REDIS缓存穿透,缓存击穿,缓存雪崩原因+解决方案

  • 缓存穿透:key对应的数据在数据库和缓存并不存在,每次针对此key的请求从缓存获取不到,请求都会到数据库,从而可能压垮数据库。比如用一个不存在的用户id获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。
    • 解决方案:
      1)最常见的则是采用布隆过滤器,在写入数据库时,将数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
      2)简单粗暴的方法:如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
  • 缓存击穿:是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
    • 解决方案
      1)使用互斥锁:就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。
      2)设置永不过期
  • 缓存雪崩:当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力。
    • 大量数据同时过期解决方案
      1)用加锁或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。
      2)将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
      3)双key策略,主key设置过期时间,备key设置永久,主key过期时,返回备key内容
      4)后台缓存更新,定时更新、消息队列通知更新
    • 服务器宕机解决方案
      1)服务熔断:在分布式系统中,我们往往需要依赖下游服务,不管是内部系统还是第三方服务,如果下游出现问题,我们不在盲目地去请求,在一个周期内失败达到一定次数,不在请求,及时失败。过一段时间,在逐步放开请求,这样既能防止不断的调用,使下游服务更坏,保护了下游方,还能降低自己的执行成本,快速的响应,减少延迟,增加吞吐量。
      2)服务降级:降级就是为了解决资源不足和访问量增加的矛盾,在有限的资源情况下,为了能抗住大量的请求,就需要对系统做出一些牺牲,有点“弃卒保帅”的意思。放弃一些功能,保证整个系统能平稳运行。比如:抢购可以占时限流评论,将流量让给秒杀业务
      3)请求限流:通过对并发访问进行限速。最简单的方式,把多余的请求直接拒绝掉,可以根据一定的用户规则进行拒绝策略
      4)构建redis高可靠集群


      image.png

19. 布隆过滤器

  布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。
布隆过滤器会通过 3 个操作完成标记:

  • 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
  • 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;
    举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。


    image.png

  在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。
  布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。
  所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。

20. 为什么要用redis而不用map做缓存?

  • Redis 可以用几十 G 内存来做缓存,Map 不行,一般 JVM 也就分几个 G 数据就够大了
  • Redis 的缓存可以持久化,Map 是内存对象,程序一重启数据就没了
  • Redis 可以实现分布式的缓存,Map 只能存在创建它的程序里
  • Redis 可以处理每秒百万级的并发,是专业的缓存服务,Map 只是一个普通的对象
  • Redis 缓存有过期机制,Map 本身无此功能
  • Redis 有丰富的 API,Map 就简单太多了

21. 如何保持缓存和数据库的一致性?

  • 淘汰缓存还是更新缓存?
    选择淘汰缓存,原因:数据可能为简单数据,也可能为较复杂的数据,复杂数据进行缓存的更新操作,成本较高,因此一般推荐淘汰缓存
  • 先淘汰缓存还是先更新数据库?
    选择先淘汰缓存,再更新数据库,原因:假如先更新数据库,再淘汰缓存,如果缓存淘汰失败,那么后面的请求都会得到脏数据,直至缓存过期。假如先淘汰缓存再更新数据库,如果数据库更新失败,只会产生一次缓存miss,相比较而言,后者对业务影响更小一点。
  • 延时双删策略:解决数据库读写分离
public void write(String key,Object data){
    redisUtils.del(key);
    db.update(data);
    Thread.Sleep(100);
    redisUtils.del(key);
}

22. Redis分布式锁的实现

  • 加锁:使用setnx key value命令,如果key不存在,设置value(加锁成功)。如果已经存在lock(也就是有客户端持有锁了),则设置失败(加锁失败)。
  • 解锁:使用del命令,通过删除键值释放锁。释放锁之后,其他客户端可以通过setnx命令进行加锁。

23. Redis集群

  • Redis集群解决内存压力,实现了对redis的水平扩容,即启动N个redis节点,将整个数据库分布在N个节点中,每个节点存储数据的1/N。
  • Redis集群通过分区来提供一定程度的可用性,即使集群中有一部分节点失效或者无法进行通讯,集群也可以基础处理命令请求。
  1. Redis部署模型
  • 模式一:单实例


    image.png
  • 模式二:一主一从


    image.png
  • 模式三:一主多从


    image.png
  • 模式四:多主多从


    image.png
  • 模式五:集群


    image.png
  1. Redis缓存预热
  • 缓存预热的思路
    (1) 提前给redis中嵌入部分数据,再提供服务,肯定不可能将所有数据都写入redis,因为数据量太大了,第一耗费的时间太长了,第二redis根本就容纳不下所有的数据
    (2) 需要更具当天的具体访问情况,试试统计出频率较高的热数据
    (3) 然后将访问频率较高的热数据写入到redis,肯定是热数据也比较多,我们也得多个服务并行的读取数据去写,并行的分布式的缓存预热
    (4) 然后将嵌入的热数据的redis对外提供服务,这样就不至于冷启动,直接让数据库奔溃了
  • 具体的实时方案:
    (1) nginx+lua将访问量上报到kafka中要统计出来当前最新的实时的热数据是哪些,我们就得将商品详情页访问的请求对应的流量,日志,实时上报到kafka中,
    (2) storm从kafka中消费数据,实时统计出每个商品的访问次数,访问次数基于LRU内存数据结构的存储方案。
    a) 优先用内存中的一个LRUMap去存放,性能高,而且没有外部依赖。否则的话,依赖redis,我们就是要防止reids挂掉数据丢失的情况,就不合适了;用mysql,扛不住高并发读写;用hbase,hadoop生态系统,维护麻烦,太重了,其实我们只要统计出一段时间访问最频繁的商品,然后对它们进行访问计数,同时维护出一个前N个访问最多的商品list即可。计算好每个task大致要存放的商品访问次数的数量,计算出大小,然后构建一个LURMap,apache commons collections有开源的实现,设定好map的最大大小,就会自动根据LRU算法去剔除多余的数据,保证内存使用限制,即使有部分数据被干掉了,然后下次来重新开始技术,也没什么关系,因为如果他被LRU算法干掉,那么它就不是热数据,说明最近一段时间很少访问,
    (3) 每个storm task启动的时候,基于zk分布式锁,将自己的id写入zk的一个节点中
    (4) 每个storm task负责完成自己这里的热数据的统计,比如每次计数过后,维护一个钱1000个商品的list,每次计算完都更新这个list
    (5) 写一个后台线程,每个一段时间,比如一分钟,将排名钱1000的热数据list,同步到zk中

你可能感兴趣的:(Java面试题系列(十)——Redis)