凌月风的个人博客

记录精彩人生

Open Source, Open Mind,
Open Sight, Open Future!
  menu

Java笔记系列——09-分布式存储(Redis)

0 浏览

Redis

数据类型


  • String
    • **常用命令 ** image-20220604185505539
    • **存储结构:Redis使用C语言编写,底层是个char数组 ** image-20220604185547785
    • String存储Key-Value,因为底层是char数组存储,value可以是各种类型:字符串、json、数值 image-20220604185524737
    • 使用过程中要注意Key的管理

  • List
    • **常用命令 ** image-20220604190256922
    • 存储结构
      • 采用双向列表(Quick List)进行存储,每个节点的数据通过压缩列表(ZipList)存储
      • 压缩列表:记录每个字段占用的空间大小,整体数据比较紧凑,没有空间浪费,节省内存 image-20220604190728008 image-20220604190834785
      • 整体结构如图: image-20220604190946026
      • 使用RPUSH的结构示意: image-20220604191132776
    • 栈结构和队列结构,支持阻塞和非阻塞的LIFO和FIFO。 image-20220604190230273

  • Hash
    • 常用命令 image-20220604191525578
    • 存储结构
      • 如果hash类型数量小于512个以及所有元素小于64个字节会使用压缩列表
      • 如果大于512个使用HashTable存储,时间复杂度为O1 image-20220604191705587
    • 使用于对象数据的存储,可以更新对象的某个属性 image-20220604191500056

  • Set
    • 常用命令 image-20220604192234814
    • 存储结构 image-20220604192300048

  • Zset
    • 常用命令 image-20220604192558913
    • 存储结构
      • **采用了跳表跟压缩列表存储,通过跳表减少根据score查询的时间复杂度 ** image-20220604192619946
    • 相较于Set,Zset是有序的,包含一个Score, image-20220604192643826

使用场景

  • 根据不同的数据类型来应对不同的使用场景,可以自由发挥,前提就是某个数据类型可以带来正向的开销

image-20220604193151344


  • String使用场景
    1. 可以用作应用缓存
    2. 可以生成全局ID
    3. 可以作为计数器限流
    4. 可以存储分布式Session
    5. 其他适用场景根据存储结构来自由发挥

  • List使用场景
    1. **可以用于消息队列,例如发红包的场景 ** image-20220604191416300
    2. 可以用于秒杀场景

  • Hash使用场景
    1. 可以缓存购物车信息
    2. 可以缓存商品详情
    3. 可以做计数器

  • Set可以存储用户标签的设置,用于相关商品信息的展示 image-20220604192217322

  • Zset适用于做排行榜、热点话题

Redis客户端

  • 可以通过Redis提供的命令工具redis-cli来完成交互,在Java中操作Redis就用到了Redis客户端
  • Java中的Redis客户端是根据 Redis Serialization Protocol(Redis序列化协议)与服务端进行通讯的工具。服务端收到客户端发送的网络请求,会基于Redis序列化协议对消息进行解码。
    • 使用抓包工具查看set n v这个操作发送了什么消息

      image-20220604195158209

      其中关键的数据是*3\r\n$3\r\nSET\r\n$1\r\n n\r\n$1\r\nv

      • *3代表参数个数有3个 set、n、v
      • $3代表 参数set的长度是1
      • 第一个$1代表参数n的长度是1
      • 第二个$1代表参数v的长度是1

  • 只要弄清楚了Redis序列化协议内容,可以自己编写Redis客户端。示例:
    • 定义关键变量
      public class CommandConstant {
          public static final String START="*";
          public static final String LENGTH="$";
          public static final String LINE="\r\n";
          public enum CommandEnum{
              SET,
              GET
          }
      }
      
    • 编写Socket通信
      public class CustomerRedisClientSocket {
          private Socket socket;
          private InputStream inputStream;
          private OutputStream outputStream;
          /*
           * 创建 socket连接
           */
          public CustomerRedisClientSocket(String ip,int port){
              try {
                  socket = new Socket(ip,port);
                  inputStream = socket.getInputStream();
                  outputStream = socket.getOutputStream();
              } catch (IOException e) {
                  e.printStackTrace();
              }
          }
          /*
           * 发送消息
           */
          public void send(String cmd){
              try {
                  outputStream.write(cmd.getBytes());
              } catch (IOException e) {
                  e.printStackTrace();
              }
          }
          /*
           * 读取消息
           */
          public String read(){
              byte[] bytes=new byte[1024];
              int count=0;
              try {
                  count=inputStream.read(bytes);
              } catch (IOException e) {
                  e.printStackTrace();
              }
              return new String(bytes,0,count);
          }
      }
      
    • 编写客户端实现
      public class CustomerRedisClient {
          private CustomerRedisClientSocket client;
          public CustomerRedisClient(String host,int port) {
              client = new CustomerRedisClientSocket(host,port);
          }
          public String set(String key,String value){
              String msg = convertToCommand(CommandEnum.SET,key.getBytes(),value.getBytes())
              client.send();
              return client.read(); //在等待返回结果的时候,是阻塞的
          }
          public String get(String key){
              client.send(convertToCommand(CommandConstant.CommandEnum.GET,key.getBytes()));
              return client.read();
          }
          public static String convertToCommand(CommandEnum commandEnum, byte[]... bytes){
              StringBuilder stringBuilder=new StringBuilder();
              stringBuilder.append(CommandConstant.START)
                  .append(bytes.length+1).append(CommandConstant.LINE);
              stringBuilder.append(CommandConstant.LENGTH)
                  .append(commandEnum.toString().length()).append(CommandConstant.LINE);
              stringBuilder.append(commandEnum.toString()).append(CommandConstant.LINE);
              for (byte[] by:bytes){
                  stringBuilder.append(CommandConstant.LENGTH)
                      .append(by.length).append(CommandConstant.LINE);
                  stringBuilder.append(new String(by)).append(CommandConstant.LINE);
              }
              return stringBuilder.toString();
          }
      }
      
    • 自己编写的客户端可以从以下几个方面进行优化,可以成长为成熟的Redis客户端
      • 在网络通信方面使用NIO优化
      • 使用连接池
      • 使用异步通信多线程
      • 对key\value进行序列化
      • 自定义配置
      • 提供成熟解决方案。比如锁等

  • 常用的Redis客户端
    • jedis
    • Lettuce
    • Redission

  • Redission分布式锁
    • 排他锁,不允许多个程序(线程、进程)同时访问某个共享资源
    • 分布式锁实现跨进程实现共享资源的互斥
    • 实现原理
      • Lua脚本实现锁的抢占和获取
      • 使用Pub/Sub机制:当锁被抢占释放后,通知被阻塞的进程去重新抢占锁
      • 使用时间轮机制实现过期任务的续期

功能特性

持久化

  • **Redis持久化支持两种方式的持久化,一种是RDB方式、另一种是AOF(append-only-file)方式,两种持久化 方式可以单独使用其中一种,也可以将这两种方式结合使用。 **

  • RDB的持久化方式是通过快照(snapshotting)完成的,它是Redis默认的持久化方式。快照的条件可以由用 户在配置文件中配置。
    # 第一个参数是时间窗口,第二个是键的个数
    # `save 5 1`:代表5秒内发生了1次操作就会记录快照
    save   <seconds> <changes>
    
  • RDB触发条件
    • **根据配置规则进行自动快照 **
    • 用户执行save或者bgsave命令
      • save命令在生成快照时会阻塞用户的操作
      • bgsave属于异步操作,不会阻塞用户操作,但是如果两次操作之间发生故障可能导致数据丢失
    • 执行flushall命令
      • **虽然会生成快照,但是会清空所有内存中的key **
    • 执行复制(replication)时会触发,比如主从复制的时候
  • RDB适灾备与定期备份的场景。

  • AOF:每次执行命令后将命令本身记录下来。
    • Redis 默认不开启。AOF采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改Redis数据的命令时,就会把命令写入到AOF文件中。
    • 通过修改redis.conf重启redis之后生效
      # 开关
      appendonly no /yes
      # 文件名
      appendfilename "appendonly.aof"
      
  • AOF磁盘同步方式
    • 虽然每次执行更改Redis数据库内容的操作时,AOF都会将命令记录在AOF文件中,但是事实上,由于 操作系统的缓存机制,数据并没有真正地写入硬盘,而是进入了系统的硬盘缓存。在默认情况下系统每 30秒会执行一次同步操作。以便将硬盘缓存中的内容真正地写入硬盘。
    • 在这30秒的过程中如果系统异常退出则会导致硬盘缓存中的数据丢失。一般来说能够启用AOF的前提是 业务场景不能容忍这样的数据损失,这个时候就需要Redis在写入AOF文件后主动要求系统将缓存内容 同步到硬盘中。在redis.conf中通过如下配置来设置同步机制。
      # AOF持久化策略(硬盘缓存到磁盘),默认值everysec
      # no 表示不执行fsync,由操作系统保证数据同步到磁盘,速度最快,但是不太安全;
      # always 表示每次写入都执行fsync,以保证数据同步到磁盘,效率很低;
      # everysec 表示每秒执行一次fsync,可能会导致丢失这1s数据。通常选择
      # everysec ,兼顾安全性和效率。
      
      appendfsync everysec
      
  • AOF文件重写
    • 磁盘同步会导致磁盘文件越来越大,如果文件过大会影响磁盘写入与恢复速度。为了解决这个问题,Redis新增了重写机制,当AOF文件的大小超过所设定的阈值时,Redis就会启动 AOF文件的内容压缩,只保留可以恢复数据的最小指令集。
      # 默认值为100。表示的是当目前的AOF文件大小超过上一次重写时的AOF文件大小的百分之多少时会再次进行重写,
      #如果之前没有重写过,则以启动时AOF文件大小为依据
      auto-aof-rewrite-percentage
      
      #默认64M。超过了这个大小的文件才会进行重写
      auto-aof-rewrite-min-size
      
    • 可以通过命令bgrewriteaof手工触发重写
    • AOF 文件重写并不是对原文件进行重新整理,而是直接读取服务器现有的键值对,然后用一条命令去代 替之前记录这个键值对的多条命令,生成一个新的文件后去替换原来的 AOF 文件,
  • AOF重写步骤:
    1. **主进程会fork一个子进程出来进行AOF重写,这个重写过程并不是基于原有的AOF文件来做的,而 是有点类似于快照的方式,全量遍历内存中的数据,然后逐个序列到AOF文件中。 **
    2. **在fork子进程这个过程中,服务端仍然可以对外提供服务,在此过程中主进程的数据更新操作,会缓存到 **aof_rewrite_buf中,也就是单独开辟一块缓存来存储重写期间收到的命令,当子进程重写完以后 再把缓存中的数据追加到新的AOF文件。
    3. 当所有的数据全部追加到新的AOF文件中后,把新的AOF文件重命名正式的文件名字,此后所有的操 作都会被写入新的AOF文件。
    4. **如果在rewrite过程中出现故障,不会影响原来AOF文件的正常工作,只有当rewrite完成后才会切 换文件。因此这个rewrite过程是比较可靠的。 ** image-20220616170322978
  • AOF 持久化的方法提供了多种的同步频率,即使使用默认的同步频率每秒同步一次,Redis 最多也 就丢失 1 秒的数据而已。每秒同步一次的频率也具有较高的性能

  • **对于具有相同数据的的Redis,AOF 文件通常会比 RDB 文件体积更大(RDB存的是数据快照)。 **
  • 在高并发 的情况下,RDB 比 AOF 具好更好的性能保证。
  • 通常情况下根据你自己的业务场景RDB与AOF配合使用

淘汰策略

  • 最大内存
    • 如果Redis中有很多无效的缓存,这些缓存数据会降低数据IO的性能,因为不同的数据类型时间复杂度 算法不同,数据越多可能会造成性能下降 随着系统的运行,Redis的数据越来越多,会导致物理内存不足。遇到这类问题的时候,我们一般有几种方法。
      • **对每个存储到Redis中的key设置过期时间,这个根据实际业务场景来决定。否则,再大的内存都会 虽则系统运行被消耗完。 **
      • **增加内存 **
      • 使用内存淘汰策略。
    • **在实际生产环境中,服务器不仅仅只有Redis,为了避免Redis内存使用过多对其他程序造成影响,我们 一般会设置最大内存。 Redis默认的最大内存 **maxmemory=0,表示不限制Redis内存的使用。我们可以修改redis.conf文 件,设置Redis最大使用的内存。
    • 当内存不足时,Redis提供了8种淘汰策略来应对这种情况,这8种淘汰策略种默认使用的是noeviction,即内存不足继续存入数据的话会报错OOM

  • 淘汰策略
    • volatile-lru,针对设置了过期时间的key,使用lru算法进行淘汰。
    • allkeys-lru,针对所有key使用lru算法进行淘汰。
    • volatile-lfu,针对设置了过期时间的key,使用lfu算法进行淘汰。
    • allkeys-lfu,针对所有key使用lfu算法进行淘汰。
    • volatile-random,从所有设置了过期时间的key中使用随机淘汰的方式进行淘汰。
    • allkeys-random,针对所有的key使用随机淘汰机制进行淘汰。
    • volatile-ttl,删除生存时间最近的一个键。
    • noeviction,不删除键,值返回错误。

  • 存淘汰算法的具体工作步骤
    1. **客户端执行一条新命令,导致数据库需要增加数据(比如set key value) **
    2. **Redis会检查内存使用,如果内存使用超过 **maxmemory,就会按照置换策略删除一些 key
    3. 新的命令执行成功
  • LRU算法
    • LRU是Least Recently Used的缩写,也就是表示最近很少使用,也可以理解成最久没有使用。
    • **当内存不够的时候,每次添加一条数据,都需要抛弃一条最久时间没有使用的旧数据。 **
    • 标准的LRU算法为了降低查找和删除元素的时间复杂度,一般采用Hash表和双向链表结合的数据结构, hash表可以赋予链表快速查找到某个key是否存在链表中,同时可以快速删除、添加节点
    • **双向链表的查找时间复杂度是O(n),删除和插入是O(1),借助HashMap结构,可以使得查找的时 间复杂度变成O(1) Hash表用来查询在链表中的数据位置,链表负责数据的插入 ** image-20220615133025037
  • Redis使用的LRU算法
    • Redis 3.0之前,Redis使用的LRU算法其实是一种不可靠的LRU算法,它实际淘汰的键并不一定是真正最少使用 的数据,它的工作机制是: 随机采集淘汰的key,每次随机选出5个key, 然后淘汰这5个key中最少使用的key 。随机采集的个数可以在redis.conf中配置
    • **Redis 3.0版本之后,Redis作者对于基于采样的LRU进行了一些优化: **
      • **Redis中维护一个大小为16的候选池,当第一次随机选取采用数据时,会把数据放入到候选池中, 并且候选池中的数据会更具时间进行排序。 **
      • 当第二次以后选取数据时,只有小于候选池内最小时间的才会被放进候选池。 当候选池的数据满了之后,那么时间最大的key就会被挤出候选池。当执行淘汰时,直接从候选池 中选取最近访问时间小的key进行淘汰。
    • Redis 3.0版本之后的LRU算法原理
      1. 先从目标字典中采集出maxmemory-samples个键(可配置),缓存在一个samples数组中,
      2. 当新的数据放到候选池时,从samples数组中一个个取出来,与要插入的键进行键的空闲时间对比,从而更新回收池。
      3. 在更新过程中,首先利用遍历找到的每个键的实际插入位置x,然后根据不同情况进行处理。
      4. **如果回收池满了,并且当前插入的key的空闲时间最小(也就是回收池中的所有key都比当前插入的key 的空闲时间都要大),则不作任何操作。 **
      5. 如果回收池满了,并且当前插入的key空闲时间将当前第x个以前的元素往前挪一个位置(实际就是淘汰了),然后执行插入操作。
      6. 如果回收池未满,并且插入的位置x没有键,则直接插入即可
      7. **如果回收池未满,且插入的位置x原本已经存在要淘汰的键,则把第x个以后的元素都往后挪一个位 置,然后再执行插入操作。 **image-20220615133600563
    • LRU算法弊端
    • 假入一个key值访问频率很低,但是最近一次被访问到了,那LRU会认为它是热点 数据,不会被淘汰。
    • 同样, 经常被访问的数据,最近一段时间没有被访问,这样会导致这些数据被淘汰掉,导致误判而淘汰掉热点 数据
  • LFU算法
    • LFU(Least Frequently Used),表示最近最少使用,它和key的使用次数有关,其思想是:根据key最 近被访问的频率进行淘汰,比较少访问的key优先淘汰,反之则保留。 LRU的原理是使用计数器来对key进行排序,每次key被访问时,计数器会增大,当计数器越大,意味着 当前key的访问越频繁,也就是意味着它是热点数据。
    • 它很好的解决了LRU算法的缺陷:一个很久没有 被访问的key,偶尔被访问一次,导致被误认为是热点数据的问题。
    • **LFU维护了两个链表,横向组成的链表用来存储访问频率,每个访问频 率的节点下存储另外一个具有相同访问频率的缓存数据。具体的工作原理是: **
      • 当添加元素时,找到相同访问频次的节点,然后添加到该节点的数据链表的头部。
      • **如果该数据链表 满了,则移除链表尾部的节点 **
      • 当获取元素或者修改元素时,都会增加对应key的访问频次,并把当前节点移动到下一个频次节 点。
      • 清理的时候清理掉整个访问频次少的数据即可image-20220615135812049

高性能

  • redis具有很高的性能主要有三个原因
    1. 使用单线程减少了上下文切换的开销
    2. 使用内存存储
    3. 使用了基于NIO多路复用机制的Reactor模型,提高网络通信性能

缓存问题

  • 数据库与Redis数据一致性问题
    • 针对于使用Redis作为缓存的情况,流程如下: image-20220604203155754
    • 当数据需要更新时,我们既要更新数据库又要更新缓存,此时就有操作顺序问题
      • 先更新数据库再删除缓存。

        有两种方案

        • 同步操作:在业务代码中,先调用更新数据库的操作,然后调用更新缓存的操作
        • 异步操作:在业务代码中只调用数据库的更新操作,然后通过一个服务来监听binlog的变化(比如阿里的 canal),服务解析binlog之后发起更新缓存的操作
          image-20220604205006484

        存在以下情况:

        1. 数据库更新失败,不进行下一步的缓存删除操作。不存在一致性问题
        2. 数据库更新成功,缓存删除成功。不存在一致性问题
        3. 数据库更新成功,缓存删除失败。这时存在一致性问题

        我们可以采用重试机制,来保证数据操作的最终一致性

        • 无论是同步操作还是异步操作,只要缓存删除失败,就将删除的指令发到一个MQ,由额外的消费者进行不断地重试删除,直到成功。
          image-20220604204451444
      • 先删除缓存再更新数据库

        存在以下情况

        1. 缓存删除失败,不进行下一步的数据库更新操作。不存在一致性问题
        2. 缓存删除成功,数据库更新成功。不存在一致性问题
        3. 缓存删除成功,数据库更新失败。此时如果用户查询数据,发现缓存为空,会将数据库的旧数据重新缓存到缓存中,因此这么看也不存在一致性问题

        如果有程序并发操作的情况下:

        1. **线程A需要更新数据,首先删除了Redis缓存 **
        2. **线程B查询数据,发现缓存不存在,到数据库查询旧值,写入Redis,返回 **
        3. 线程A更新了数据库
        4. 这个时候,缓存是旧的值,数据库是新的值,发生了数据不一致的情况。
        5. 这种情况就比 较难处理了,只有针对同一条数据进行串行化访问,才能解决这个问题,但是这种实现起来对性能影响 较大,因此一般情况下不会采用这种做法。
          image-20220604213624182
      • 要根据具体的业务看下是以缓存为主还是以数据库为主,然后再采用对应的方案

  • 缓存雪崩
    • 当大量热点数据同时失效,或者Redis节点故障,导致缓存起不到应有的作用。大量的并发请求直接到了数据库,导致整个数据库承受巨大压力甚至崩溃,这个现象就叫做缓存雪崩。.

    • 例如:假如原来数据库只能撑住100的并发,因为缓存的存在,那么整个系统的并发量可能到达了1000。当缓存失效时,数据库直面1000的并发,直接崩溃。

    • 缓存失效

      • 概念

        在实际开发中,我们缓存的数据比如验证码、优惠时间都有一定的有效时限。超过有效时限后这些数据就没有价值这些数据还在缓存中的话就会浪费内存。在Redis中提供了Expire命令设置一个键的过期时间,到期以后Redis会自动删除它。

      • 操作

        **按秒过期命令: **expire KEY_NAME TIME_IN_SECONDS

        **按毫秒过期命令: **pexpire KEY_NAME TIME_IN_MILLISSECONDS

        查看多少秒过期:ttl KEY_NAME

        查看多少毫秒过期:pttl KEY_NAME

        其他操作可以查看手册Redis 命令参考 — Redis 命令参考 (redisfans.com)

      • 过期时间存储原理

        • Redis使用一个过期字典(Redis字典使用哈希表实现,可以将字典看作哈希表)存储键的过期时间,字 典的键是指向数据库键的指针(使用指针可以避免浪费内存空间),字典的值是一个毫秒时间戳,所以 在当前时间戳大于字典值的时候这个键就过期了,就可以对这个键进行删除(删除一个键不仅要删除数 据库中的键,也要删除过期字典中的键)
        • 设置过期时间的命令都是使用pexpireat命令实现的,其他命令也会转换成pexpireat。给一个键设 置过期时间,就是将这个键的指针以及给定的到期时间戳添加到过期字典中。比如,执行命令 pexpireat key 1608290696843image-20220604221450335
      • 删除原理

        • 被动方式删除:被动方式的核心原理是,当客户端尝试访问某个key时,发现当前key已经过期了,就直接删除这 个key。
        • 主动方式删除
          有可能会存在一些key,一直没有客户端访问,就会导致这部分key一直占用内存,因此加 了一个主动删除方式。 主动删除就是Redis定期扫描带有过期时间的key进行超时删除,它的删除策略是: 随机获取20个key,删除这20个key中已经过期的key。 如果在这20个key中有超过25%的key过期,则重新执行当前步骤。实际上这是利用了一种概 率算法。
        • Redis结合这两种设计很好的解决了过期key的处理问题。
    • 解决方案

      • 过期时间设置随机值
      • 对于热点数据不设置过期时间
      • 增加二级缓存
  • 缓存穿透
    • 一般是指当前访问的数据在redis和mysql中都不存在的情况,有可能是一次错误的查询,也 可能是恶意攻击。
    • 解决方案
      • 设置空值
      • 布隆过滤器

        首先,项目在启动的时候,把所有的数据加载到布隆过滤器中。 然后,当客户端有请求过来时,先到布隆过滤器中查询一下当前访问的key是否存在,如果布隆过 滤器中没有该key,则不需要去数据库查询直接反馈即可

        image-20220604222715123

高可用

主从复制

  • 主从复制有多种形态
    • 一主多从,主节点进行接收数据操作与数据同步,从节点作为读取数据节点。实现读写分离,方便扩展
    • 级联复制,将一主多从的数据同步工作交给一个从节点。主节点只与一个从节点交互数据
    • 主主复制,其实相当于热备,两个节点共享数据,然后由三方的Keepalived决定由哪个节点进行服务
    • 多主一从,适用于类似分库的情况,两个主节点分别存有一部分数据,都同步到从节点进行一个数据汇总image-20220616171844676
  • 配置:从节点配置replicaof 192.168.221.128 6379指向主节点即可
  • 同步过程
    1. **在从节点初始化阶段,需要把主节点上所有数据都复制一份,从节点连接主节点,发送SYNC命令 **
    2. 主节点接收到SYNC命名后,开始执行BGSAVE命令生成RDB文件并使用缓冲区记录此后执行的 所有写命令;
    3. 主节点BGSAVE执行完后,向所有从节点发送快照文件,并在发送期间继续记录被执行的写命令(表示RDB异步生成快照期间的数据变更);
    4. **从节点收到快照文件后丢弃所有旧数据,载入收到的快照; **
    5. **主节点快照发送完毕后开始向从节点发送缓冲区中的写命令; **
    6. 从节点完成对快照的载入,开始接收命令请求,并执行来自主节点缓冲区的写命令;image-20220616173813859
  • Redis主从同步的可靠性保证
    1. 主节点在生成RDB文件时,如果收到新的写命令,会把所有新的写命令缓存在内存中。在 从节点保存了同步的RDB之后,再 将新的写命令复制给从节点。(跟AOF重写期间的思路是一样的)。复制阶段结束后,主节点执行的任何非查询语句都会异步发送给从节点。在命令传播阶段,除了发送写命令,主从节点还维持着心跳机制:PINGREPLCONF ACK
    2. 从节点会将收到的内容写入到硬盘上的临时文件,当写入完成后会用该临时文件替换原有的RDB快 照文件。在同步的过程中从节点并不会阻塞,仍然可以处理客户端的命令。

      默认情 况下从节点会用同步前的数据对命令进行响应,如果我们希望读取的数据不能出现脏数据,那么可 以在redis.conf文件中配置的参数slave-serve-stale-data no,来使得从节点在同步完成对所有命令之前,都回复错误:SYNC with master in progress

    3. 主从复制策略是采用乐观复制,也就是说可以容忍在一定时间内主从数据的内容是不 同的,但是两者的数据会最终同步成功。
      • Redis的主从同步过程本身是异步的,意味着主节点执行完客户端请求的命令后会立即返回 结果给客户端,然后异步的方式把命令同步给从节点。
      • 这一特征保证启用主从复制后主节点的性能 不会受到影响。 但是另一方面,如果在这个数据不一致的窗口期间,主从节点因为网络问题断开连接,而这个时 候,主节点是无法得知某个命令最终同步给了多少个从节点数据库。
      • Redis提供了一个配置项来限制 只有数据至少同步给多少个从节点的时候,主节点才是可写的。
        #表示只有当3个或以上的slave连接到master,master才是可写的
        min-replicas-to-write 3 
        #表示允许slave最长失去连接的时间,如果10秒还没收到slave的响应,则master认为该slave已经断开
        min-replicas-max-lag 10
        
    4. 从Redis 2.8开始,主从节点支持增量复制,并且是支持断点续传的增量复制,也就是说如果出现复制异 常或者网络连接断开导致复制中断的情况,在系统恢复之后仍然可以按照上次复制的地方继续同步,而 不是全量复制。
      • 主节点和从节点分别维护一个复制偏移量(offset),代表的是主节点向从节点传递 的字节数;
      • 主节点每次向从节点传播N个字节数据时,主节点的offset增加N;
      • 从节点每次收到主节点传 来的N个字节数据时,从节点的offset增加N。
      • 可以通过info replication命令查看
    5. 无磁盘复制。Redis复制的工作原理基于RDB方式的持久化实现的,也就是主节点在后台保存RDB快 照,从节点接收到RDB文件并载入,但是这种方式会存在一些问题。
      • **当master禁用RDB时,如果执行了复制初始化操作,Redis依然会生成RDB快照,当master下次 启动时执行该RDB文件的恢复,但是因为复制发生的时间点不确定,所以恢复的数据可能是任何时 间点的。就会造成数据出现问题 **
      • 当硬盘性能比较慢的情况下(网络硬盘),那初始化复制过程会对性能产生影响 因此Redis 2.8.18以后的版本,引入了无硬盘复制选项,可以不需要通过RDB文件去同步,直接发送数 据,通过repl-diskless-sync yes进行配置开启无磁盘复制功能
      • 主节点在内存中直接创建RDB数据,然后发送给从节点,不会在自己本地落地磁盘了
  • 主从模式解决了数据备份和性能(通过读写分离)的问题,但是还是存在一些不足:
    • 第一次建立复制的时候一定是全量复制,所以如果主节点数据量较大,那么复制延迟就比较长。

      • 此 时应该尽量避开流量的高峰期,避免造成阻塞;
      • 如果有多个从节点需要建立对主节点的复制,可以考虑 将几个从节点错开,避免主节点带宽占用过大。
      • 此外,如果从节点过多,也可以调整主从复制的拓扑结 构,由一主多从结构变为树状结构。
    • 在一主一从或者一主多从的情况下,如果主服务器挂了,对外提供的服务就不可用了,单点问题没有得到解决。如果每次都是手动把之前的从服务器切换成主服务器,这个比较费时费力,还会造成一定 时间的服务不可用。

      通过keepalived实现多主热备

哨兵机制

  • 当主从复制的架构中主节点发生故障时,只能通过手工方式选择一个从节点改为主节点这个过程需要人工干预。Redis并没有提 供自动master选举功能,而是需要借助一个哨兵来进行监控。
  • 哨兵的作用就是监控Redis系统的运行状况,它的功能包括两个
    • 监控master和slave是否正常运行,
    • **master出现故障时自动将slave数据库升级为master **
  • 哨兵是一个独立的进程,是属于Redis自带的功能,对 Sentinel做集群部署,因此Sentinel不仅仅监控Redis所有的主从节点,Sentinel也会实现相互监控。image-20220616181731428
  • 哨兵配置
    1. 从Redis源码包中拷贝sentinel.conf文件到redis/bin安装目录
      cp /data/program/redis-6.0.9/sentinel.conf /data/program/redis/sentinel.conf
      
    2. 修改sentinel.conf配置
      # 其中name表示要监控的master的名字,这个名字是自己定义,
      # ip和port表示master的ip和端口号,
      # 最后一个2表示最低通过票数,也就是说至少需要几个哨兵节点认为master下线才算是真的下线
      sentinel monitor mymaster 192.168.221.128 6379 2
      
      # 表示如果5s内mymaster没响应,就认为master下线
      sentinel down-after-milliseconds mymaster 5000 
      
      # 表示如果15秒后,mysater仍没活过来,则启动failover,从剩下的slave中选一个升级为master
      sentinel failover-timeout mymaster 15000 
      
      # 配置哨兵日志,需要提前创建好文件
      logfile "/data/program/redis/logs/sentinels.log" 
      
      
    3. 通过./redis-sentinel ../sentinel.conf命令启动sentinel哨兵
    4. 多个节点的配置和上面完全相同,都去监视master节点即可注意,sentinel.conf文件中 master节点的ip一定不能输127.0.0.1,否则其他sentinel节点无法和它通信
  • 实现原理
    1. **每个Sentinel以每秒钟一次的频率向它所知的Master/Slave以及其他 Sentinel 实例发送一个 PING 命令 **
    2. **如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 **down-after-milliseconds 选项所指定的值, 则这个实例会被 Sentinel 标记为主观下线。
    3. 如果一个Master被标记为主观下线,则正在监视这个Master的所有 Sentinel 要以每秒一次的频率 确认Master的确进入了主观下线状态。
    4. 当有足够数量的 Sentinel(大于等于配置文件指定的值:quorum)在指定的时间范围内确认 Master的确进入了主观下线状态, 则Master会被标记为客观下线 。
    5. 在一般情况下, 每个 Sentinel 会以每 10 秒一次的频率向它已知的所有Master,Slave发送 INFO 命令
    6. **当Master被 Sentinel 标记为客观下线时,Sentinel 向下线的 Master 的所有 Slave 发送 INFO 命令 的频率会从 10 秒一次改为每秒一次 ,若没有足够数量的 Sentinel 同意 Master 已经下线, Master 的 客观下线状态就会被移除。 **
    7. **若 Master 重新向 Sentinel 的 PING 命令返回有效回复, Master 的主观下线状态就会被移除。 **
  • 选举
    • **主观下线:Subjectively Down,简称 **SDOWN,指的是当前 Sentinel 实例对某个Redis服务器做出的下 线判断。
    • **客观下线:Objectively Down, 简称 **ODOWN,指的是多个 Sentinel 实例在对Master Server做出 SDOWN 判断,并且通过 SENTINEL之间交流后得出Master下线的判断。
    • 当Redis中的master节点被判定为客观下线之后,需要重新从slave节点选择一个作为新的master节点,选举步骤
      1. **先根据 **raft协议 从现在有的sentinel中选出一个作为Leader
      2. 选出Sentinel Leader之后,由Leader找出适合成为master的slave节点,成为master节点的因素
        • **断开连接时长,如果与哨兵连接断开的比较久,超过了某个阈值,就直接失去了选举权 **
        • **优先级排序,如果拥有选举权,那就看谁的优先级高,这个在配置文件里可以设置(replica-priority 100),数值越小优先级越高 **
        • 复制数量,如果优先级相同,就看谁从master中复制的数据最多(复制偏移量最大)
        • **进程id,如果复制数量也相同,就选择进程id最小的那个 **
      3. 由Sentinel Leader向合适的节点发送slaveof no one命令,让它成为独 立节点。
      4. 然后向其他节点发送replicaof x.x.x.x xxxx(第3步的独立节点),让它们成为这个节点的子节点,故障转 移完成。
  • **Sentinel功能总结 **
    • 监控:Sentinel会不断检查主服务器和从服务器是否正常运行。
    • **通知:如果某一个被监控的实例出现问题,Sentinel可以通过API发出通知。 **
    • **自动故障转移(failover):如果主服务器发生故障,Sentinel可以启动故障转移过程。把某台服务器升 级为主服务器,并发出通知。 **
    • 配置管理:客户端连接到Sentinel,获取当前的Redis主服务器的地址

集群机制

  • 主从切换的过程中会丢失数据,因为只有一个master,只能单点写,没有解决水平扩容的问题。而且每 个节点都保存了所有数据,一个是内存的占用率较高,另外就是如果进行数据恢复时,非常慢。而且数 据量过大对数据IO操作的性能也会有影响。 所以我们同样也有对Redis数据分片的需求,所谓分片就是把一份大数据拆分成多份小数据,
  • 在Redis 3.0之 前,我们只能通过构建多个Redis主从节点集群,把不同业务数据拆分到不同的集群中,或者通过Codis代理来进行数据分片。这种方式在业务 层需要有大量的代码来完成数据分片、路由等工作,导致维护成本高、增加、移除节点比较繁琐。
  • Redis 3.0之后引入了Redis Cluster集群方案,它用来解决分布式扩展的需求,同时也实现了高可用机 制。
  • Redis Cluster使用了Gossip协议进行节点之间的通信
    • **加入节点 **
    • **slot迁移 **
    • **节点宕机 **
    • slave选举成为master
  • Redis Cluster结构
    • 一个Redis Cluster由多个Redis节点构成,这多个节点又分为不同的节点组。
    • **一个节点组有且只有一个master节点,同时可以有0到多个slave节点,在这个节点组中只有master节 点对用户提供些服务,读服务可以由master或者slave提供。两者数据准实时一致,通过异步化的主从复制机制来保证。 **
    • 不同的节点组服务的数据没有交集,也就是每个一节点组对应 数据的一个分片。
    • 一般一组集群至少要6个节点才能保证完整的高可用,包含三个节点组。当master出现故障时,同节点组的slave会自动选举成 为master顶替主节点继续提供服务。image-20220617113128654
  • 分片处理
    • Redis Cluster中,Sharding(分片)采用slot(槽)的概念,一共分成16384个槽
    • 对于每个进入Redis的键值对,根据key进行散列,分配到这16384个slot中的某一个中。
    • 使用的 hash算法也比较简单,就是CRC16后16384取模[crc16(key)%16384]
    • Redis集群中的每个主节点负责分摊这16384个slot中的一部分,也就是说,每个slot都对应一个主节点负责处理。
    • 假设现在我们是三个主节点分别是:A, B, C 三个节点,它们可以是一台机器上的三个端 口,也可以是三台不同的服务器。那么,采用哈希槽 (hash slot)的方式来分配16384个slot 的话,它们 三个节点分别承担的slot 区间是: 节点A覆盖0-5000; 节点B覆盖5001-10000; 节点C覆盖10001-16383image-20220617114635406
  • 客户端重定向
    • 假设key应该存储在node 3上,而此时用户在node 1或者node 2上调用 set k v 指 令,
    • 这个时候服务端返回MOVED,也就是根据key计算出来的slot不归当前节点管理,服务端返回MOVED告诉客户 端去其他端口操作。 这时候切换到其他节点才能继续操作
    • 所以大部分的redis客户端都会在本地维护一份slot 和node的对应关系,在执行指令之前先计算当前key应该存储的目标节点,然后再连接到目标节点进行 数据操作。
    • redis集群中提供了下面的命令来计算当前key应该属于哪个slot :cluster keyslot key1
    • 如果想明确指定Key落入到哪个节点,可以在key里面加入{hash tag}。

      Redis在计算槽编号的时候只会获取{}之间的字符串进行槽编号计算, 这样由于上面两个不同的键,{}里面的字符串是相同的,因此他们可以被计算出相同的槽

      user{2673}base=…
      user{2673}fin=…
      
  • 主从切换
    • **如果主节点没有从节点,那么当它发生故障时,集群就将处于不可用状态。 **
    • 一旦某个master节点进入到FAIL状态,那么整个集群都会变成FAIL状态,同时触发failover机制,
    • failover的目的是从slave节点中选举出新的主节点,使得集群可以恢复正常,这个过程实现如下:
      1. 当slave发现自己的master变为FAIL状态时,便尝试进行Failover,以期成为新的master。
      2. 由于挂掉的 master可能会有多个slave,从而存在多个slave竞争成为master节点的过程, 其过程如下:
      3. slave发现自己的master变为FAIL 将自己记录的集群currentEpoch加1,并广播FAILOVER_AUTH_REQUEST 信息
      4. 其他节点收到该信息,收到消息的master节点会做出响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK, 对每一个epoch只发送一次ack
      5. 尝试failover的slave收集master返回的FAILOVER_AUTH_ACK s
      6. lave收到超过半数master的ack后变成新Master (这里解释了集群为什么至少需要三个主节点, 如果只有两个,当其中一个挂了,只剩一个主节点是不能选举成功的)
      7. 广播Pong消息通知其他集群节点。
      8. 从节点并不是在主节点一进入 FAIL 状态就马上尝试发起选举,而是有一定延迟,
      9. **一定的延迟确保我们 等待FAIL状态在集群中传播,slave如果立即尝试选举,其它masters或许尚未意识到FAIL状态,可能会 拒绝投票。 **
      10. 延迟计算公式:DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms SLAVE_RANK表示此slave已经从master复制数据的总量的rank。Rank越小代表已复制的数据越新。这 种方式下,持有最新数据的slave将会首先发起选举

3、关系型数据库优化

  • 关系型数据库出现性能问题时需要进行优化,出现性能问题的原因有多个
    • 表数据量过大
    • sql查询太复杂
    • **sql查询没走索引 **
    • 数据库服务器的性能过低
  • 如果是SQL方面引起的性能问题,那这个属于表结构或者查询方面的问题,如果表数据量过大需要对数据库进行拆分。阿里开发手册: 单表行数超过500W或者单表数据容量超过2G,属于表的数据量过大,但是也与表的列数有关,需要根据自己的实际情况进行判断
  • 按照数据库拆分的维度分为水平拆分和垂直拆分,拆分过程的策略可以使用一致性hash算法或者按照范围分片等
  • 拆分完成之后可能会出现一些问题
    • 非分片键的关系查询:可以采用k-v存储的方式 或者非分片键和分片键建立映射关系
    • 多个库的分离查询:做绑定表(订单表,订单明细表),业务层面做封装、全局表
    • 分页、排序:业务端做聚合, 存储在nosql。。
  • 在实际过程中去实施分库分表牵扯到 数据迁移 和模型上的改造,一般分为三个阶段
    • 新老库双写 (以老库为准):起定时任务来做数据校对,补平差异,定时任务去迁移老库数据到新库
    • 历史库已经导完了:数据校验也没问题:保持双写, 事务以新库为准,定时校验数据
    • 取消双写:所有数据只需要保存到新的模型, 老模型不需要写数据
  • 对于拆分过程可能出现的问题,已经有很成熟的解决方案以及对应的产品,不需要自己去手动实现一部分

ShardingSphere

分布式事务

  • 全局事务是一个DTP模型的事务,所谓DTP模型指的是X/Open DTP (X/Open Distributed Transaction Processing Reference Model),是X/Open 这个组织定义的一套分布式事务的标准,

    • X/Open,即现在的open group,是一个独立的组织,主要负责制定各种行业技术标准。
    • 官网地址:http://www.opengroup.org/
    • X/Open组织主要由各大知名公司或者厂商进行支持,这些组织不光遵循X/Open组织定义的行业技术标准,也参与到标准的制定。
    • X/Open了定义了规范和API接口,由各个厂商进行具体的实现,这个标准提出了使用二阶段提交(2PC –Two-Phase-Commit)来保证分布式事务的完整性。
    • 后来J2EE也遵循了X/OpenDTP规范,设计并实现了java里的分布式事务编程接口规范-JTAimage-20220617214349652
  • X/Open DTP模型定义了三个角色和两个协议,其中三个角色分别如下:

    • AP(Application Program),表示应用程序,也可以理解成使用DTP模型的程序
    • RM(Resource Manager),资源管理器,这个资源可以是数据库, 应用程序通过资源管理器对资源进行控制,资源管理器必须实现XA定义的接口
    • TM(Transaction Manager),表示事务管理器,负责协调和管理全局事务,事务管理器控制整个全局事务,管理事务的生命周期,并且协调资源。
  • 两个协议

    • XA协议:XA 是X/Open DTP定义的资源管理器和事务管理器之间的接口规范,TM用它来通知和协调相关RM事务的开始、结束、提交或回滚。目前Oracle、Mysql、DB2都提供了对XA的支持; XA接口是双向的系统接口,在事务管理器(TM ) 以及多个资源管理器之间形成通信的桥梁(XA不能自动提交)
    • TX协议: 全局事务管理器与资源管理器之间通信的接口
  • 在分布式系统中,每一个机器节点虽然都能够明确知道自己在进行事务操作过程中的结果是成功还是失败,但却无法直接获取到其他分布式节点的操作结果。因此当一个事务操作需要跨越多个分布式节点的时候,为了保持事务处理的ACID特性,就需要引入一个“协调者”(TM)来统一调度所有分布式节点的执行逻辑,这些被调度的分布式节点被称为AP。TM负责调度AP的行为,并最终决定这些AP是否要把事务真正进行提交到(RM)

  • 在X/OpenDTP模型中使用了2pc提交保证事务一致性image-20220617215140062

  • 基于XA协议的全局事务,是属于强一致性事务,因为在全局事务中,只要有任何一个RM出现异常,都会导致全局事务回滚。同时,本地事务在Prepare阶段锁定资源时,如果有其他事务要要修改相同的数据,就必须要等待前面的事务完成,这本身是无可厚非的设计,但是由于多个RM节点是跨网络,一旦出现网络延迟,就导致该事务一直占用资源使得整体性能下降

  • 另外,在XA COMMIT阶段,如果其中一个RM因为网络超时没有收到数据提交的指令,会导致数据不一致,为了解决这个问题,很多开源分布式事务框架都会提供重试机制来保证数据一致性。


心中无我,眼中无钱,念中无他,朝中无人,学无止境

纸上得来终觉浅,绝知此事要躬行

image/svg+xml