美文网首页缓存中间件
Redis系列(2) 主从复制/单线程高并发/先操作缓存or先操

Redis系列(2) 主从复制/单线程高并发/先操作缓存or先操

作者: suxin1932 | 来源:发表于2020-02-04 16:53 被阅读0次

    https://gitlab.com/zhangxin1932/java-tools.git (java-tools for redis5.0)

    1.Redis 是如何进行主从复制的
    2.高并发环境下,先操作数据库还是先操作缓存
    3.为什么 Redis 是单线程却能支撑高并发
    

    1.Redis 主从复制原理(主从数据同步原理)

    # master & slave
    在 redis 中, 可以通过 slaveof 命令或者设置 slaveof 配置项, 
    让一个redis服务器去复制(replicate) 另一个服务器, 
    被复制的服务器称之为 主服务器(master), 复制的服务器称为从服务器(slave)
    

    1.1 Redis 2.8 版本之前的复制机制

    Redis的复制功能分为同步(sync)和命令传播(command propagate)两个操作:
    >> 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
    >> 命令传播操作则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状
    态出现不一致时,让主从服务器的数据库重新回到一致状态。
    

    1.1.1 同步(sync)

    当客户端向从服务器发送slaveof命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作,
    也即是,将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
    从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令来完成,以下是SYNC命令的执行步骤:
    1) 从服务器向主服务器发送SYNC命令。
    2) 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
    3) 当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,
    从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主
    服务器执行BGSAVE命令时的数据库状态。
    4) 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,
    将自己的数据库状态更新至主服务器数据库当前所处的状态。
    
    Redis 2.8 版本之前的复制机制1--SYNC.png

    1.1.2 命令传播(command propagate)

    #问题引出: 主从数据不一致
    在同步操作执行完毕之后,主从服务器两者的数据库将达到一致状态,
    但这种一致并不是一成不变的,每当主服务器执行客户端发送的写命令时,
    主服务器的数据库就有可能会被修改,并导致主从服务器状态不再一致。
    
    举个例子.假设一个主服务器和一个从服务器刚刚完成同步操作,
    它们的数据库都保存了相同的五个键k1至k5,如图15-3所示。
    如果这时,客户端向主服务器发送命令DEL k3,那么主服务器在执行完这个DEL命令之后,
    #主从服务器的数据库将出现不一致:
    主服务器的数据库已经不再包含键k3, 但这个键却仍然包含在从服务器的数据库里面,如图15-4所示。
    
    #解决方案
    为了让主从服务器再次回到一致状态, 主服务器需要对从服务器执行命令传播操作:
    主服务器会将A己执行的写命令,也即是造成主从
    服务器不一致的那条写命令,发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态。
    在上面的例子中,主服务器因为执行了命令DEL k3而导致主从服务器不一致,
    所以主服务器将向从服务器发送相同的命令DEL k3。
    当从服务器执行完这个命令之后,主从服务器将再次回到一致状态,
    现在主从服务器两者的数据库都不再包含键k3 了,如图15-5所示。
    
    Redis 2.8 版本之前的复制机制2--command propagate.png

    1.1.3 不足之处

    若是从服务器首次连接主服务器, 则完全同步, 没有问题.
    若是从服务器断线重连, 将会重新先执行SYNC命令, 效率低下.
    
    #SYNC命令是一个非常耗费资源的操作
    每次执行SYNC命令,主从服务器需要执行以下动作:
    1) 主服务器需要执行BGSAVE命令来生成RDB文件,这个生成操作会耗费主服务器大量的CPU、内存和磁盘I/O资源。
    2) 主服务器需要将自己生成的RDB文件发送给从服务器,这个发送操会耗费主从服务器大量的网络资源(带宽和流量),并对主服务器响应命令请求的时间产生影响。
    3) 接收到RDB文件的从服务器需要栽入主服务器发来的RDB文件,
    并且在栽入期间,从服务器会因为阻塞而没办法处理命令请求。
    
    因为SYNC命令是一个如此耗费资源的操作,所以Redis有必要保证在真正有需要时才执行SYNC命令。
    

    1.2 Redis 2.8 版本-4.0版本之前的复制机制 (解决断线重连的同步问题)

    从Redis 2.8开始,如果遭遇连接断开,重新连接之后可以从中断处继续进行复制(部分重同步),而不必重新同步。
    
    #部分重同步核心构成
    >> 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量
    >> 主服务器的复制积压缓冲区(replication backlog)
    >> 服务器的运行id (run ID)
    
    #工作原理概述
    主服务器端为复制流维护一个内存缓冲区(in-memory backlog)。主从服务器都维护一个复制偏移量(replication offset)和master run id ,
    当连接断开时,从服务器会重新连接上主服务器,然后请求继续复制,
    假如主从服务器的两个master run id相同,并且指定的偏移量在内存缓冲区中还有效,复制就会从上次中断的点开始继续。
    如果其中一个条件不满足,就会进行完全重新同步(在2.8版本之前就是直接进行完全重新同步)。
    因为主运行id不保存在磁盘中,如果从服务器重启了的话就只能进行完全同步了。
    
    使用PSYNC命令进行断线重连后的部分重同步.png

    1.2.1 复制偏移量(replication offset)

    #问题提出
    假设从服务器A在断线之后就立即重新连接主服务器,并且成功,
    那么接下来,从服务器将向主服务器发送命令,报告从服务器A当前的复制偏移量,
    那么这时,主服务器应该对从服务器执行完整重同步还是部分重同步呢?
    如果执行部分重同步的话,主服务器又如何补偿从服务器A在断线期间丢失的那部分数据呢?
    以上问题的答案都和复制积压缓冲区有关。
    
    #概念
    复制积压缓冲区是一个由主服务器维护的固定长度的FIFO队列, 默认大小是 1M. 
    当主服务器进行命令传播时, 不仅会将命令发送到所有从服务器, 还会将命令入队到复制积压缓冲区里.
    当从服务器断线重连上主服务器后, 会通过PSYNC命令将自己的复制偏移量发给主服务器,
    主服务器根据这个复制偏移量判断进行何种方式同步:
    >> 如果偏移量仍存在复制积压缓冲区里, 主服务器会执行部分重同步操作.
    >> 如果不在,主服务器会执行完全重同步操作.
    

    1.2.2 根据需要调整复制积压缓冲区的大小

    Redis为复制积压缓冲区设置的默认大小为1 MB,
    >> 如果主服务器需要执行大量写命令,又或者主从服务器断线后重连接所需的时间比较长,那么这个大小也许并不合适。
    >> 如果复制积压缓冲区的大小设置得不恰当,那么命令的复制重同步模式就不能正常发挥作用。
    
    因此,正确估算和设置复制积压缓冲区的大小非常重要。
    复制积压缓冲区的最小大小可以根据公式
    second * write_size_per_second
    来估算:
    >> 其中second为从服务器断线后重新连接上主服务器所需的平均时间(以秒计算)。
    >> 而write_size_per_second则是主服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和)。
    例如,如果主服务器平均每秒产生1 MB的写数据,而从服务器断线之后平均要5秒才能重新连接上主服务器,那么复制积压缓冲区的大小就不能低于5 MB。
    
    为了安全起见,可以将复制积压缓冲区的大小设为
    "2 * second * write_size_per_second"
    这样可以保证绝大部分断线情况都能用部分重同步来处理。
    
    至于复制积压缓冲区大小的修改方法,可以参考配置文件中关于"repl-backlog-size"选项的说明。
    

    1.2.3 服务器运行ID (run ID)

    >> 每个Redis服务器,不论主服务器还是从服务,都会有自己的运行ID。
    >> 运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成,例如53b9b28df8042fdc9ab5e3fcbbbabffld5dce2b3.
    
    当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器则会将这个运行ID保存起来。
    
    当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行ID:
    >> 如果从服务器保存的运行ID和当前连接的主服务器的运行ID相同,
    那么说明从服务器断线之前复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作。
    >> 如果从服务器保存的运行ID和当前连接的主服务器的运行1D并不相同,
    那么说明从服务器断线之前复制的主服务器并不是当前连接的这个主服务器,主服务器将对从服务器执行完整重同步操作。
    
    举个例子,假设从服务器原本正在复制一个运行ID为53b9b28df8042fdc9ab5e3fcbbbabffld5dce2b3的主服务器,那么在网络断开,从服务器重新连接上主服务器之后,
    从服务器将向主服务器发送这个运行ID,主服务器根据自己的运行ID是否是
    53b9b28df8042fdc9ab5e3fcbbbabffld5dce2b3来判断是执行部分重同步还是执行完整重同步。
    

    1.2.4 不足之处

    psync需2个条件同时满足,才能成功
    >> master runid不变
    >> 复制偏移量在master复制积缓冲区中
    
    #问题
    >> 在redis slave重启,因master runid和复制偏移量都会丢失,需进行全量重同步;
    >> redis master发生故障切换,因master runid发生了变化;故障切换后,新的slave需进行全量重同步。
    
    而slave维护性重启、master故障切换都是redis运维常见场景。
    

    1.3 Redis4.0之后的复制机制

    redis4.0的加强版部分重同步功能-psync2,主要解决上文两类场景的部分重新同步。
    
    >> master_replid : 
    复制ID1(后文简称:replid1),一个长度为41个字节(40个随机串+'\0')的字符串。
    redis实例都有,和runid没有直接关联,但和runid生成规则相同。
    当实例变为从实例后,自己的replid1会被主实例的replid1覆盖。
    
    >> master_replid2:
    复制ID2(后文简称:replid2),默认初始化为全0,用于存储上次主实例的replid1。
    

    1.3.1 Redis从实例重启的部分重新同步

    在之前的版本,redis重启后,复制信息是完全丢失;所以从实例重启后,只能进行全量重新同步。
    
    #redis4.0为实现重启后,仍可进行部分重新同步,主要做以下3点:
    >> redis关闭时,把复制信息作为辅助字段存储在RDB文件中;以实现同步信息持久化。
    >> redis启动加载RDB文件时,会把复制信息赋给相关字段;为部分同步
    >> redis重新同步时,会上报repl-id和repl-offset同步信息,如果和主实例匹配,
    且offset还在主实例的复制积压缓冲区内,则只进行部分重新同步。
    

    1.3.2 redis master发生故障切换的部分重同步

    为使主实例故障切换后,尽量采取重新同步新主实例数据时使用psync:
    >> redis4.0使用两组replid、offset替换原来的master runid和offset.
    >> redis slave默认开启复制积压缓冲区功能;以便slave故障切换变化master后,其他落后从可以从缓冲区中获取写入指令。
    
    #第一组:master_replid和master_repl_offset
    如果redis是主实例,则表示为自己的replid和复制偏移量;
    如果redis是从实例,则表示为自己主实例的replid1和同步主实例的复制偏移量。
    
    #第二组:master_replid2和second_repl_offset
    无论主从,都表示自己上次主实例repid1和复制偏移量;用于兄弟实例或级联复制,主库故障切换psync.
    初始化时, 前者是40个字符长度为0,后者是-1; 
    只有当主实例发生故障切换时,redis把自己replid1和master_repl_offset+1分别赋值给master_replid2和second_repl_offset。
    

    1.10 主从复制相关的一些配置实践

    1.10.1 当主服务器不进行持久化时复制的安全性

    #建议
    在进行主从复制设置时,建议在主服务器上开启持久化,
    当不能这么做时,比如考虑到延迟的问题,应该将实例配置为避免自动重启。
    
    #原因
    为什么不持久化的主服务器自动重启非常危险呢?
    >> 设置节点A为主服务器,关闭持久化,节点B和C从节点A复制数据。
    >> 这时主节点A出现了一个崩溃,但很快自动重启了,重启了进程,因为关闭了持久化,节点重启后只有一个空的数据集。
    >> 节点B和C从节点A进行复制,现在节点A是空的,所以节点B和C上的复制数据也会被删除。
    
    当在高可用系统中使用Redis Sentinel,关闭了主服务器的持久化,并且允许自动重启,这种情况是很危险的。
    比如主服务器可能在很短的时间就完成了重启,
    以至于Sentinel都无法检测到这次失败,那么上面说的这种失败的情况就发生了。
    

    1.10.2 限制有N个以上从服务器才允许写入

    #旧版本
    min-slaves-to-write 3
    min-slaves-max-lag 10
    
    #新版本
    min-replicas-to-write 3
    min-replicas-max-lag 10
    
    第一个参数表示连接到master的最少slave数量
    第二个参数表示slave连接到master的最大延迟时间
    按照上面的配置,要求至少3个slave节点,且数据复制和同步的延迟不能超过10秒,
    否则的话master就会拒绝写请求,配置了这两个参数之后,
    >> 如果发生集群脑裂,原先的master节点接收到客户端的写入请求会拒绝,
    就可以减少数据同步之后的数据丢失。
    >> redis中的异步复制情况下的数据丢失问题也能使用这两个参数, 但需要考量性能与可用性。
    

    2.高并发环境下,先操作数据库还是先操作缓存?

    在分布式系统中,缓存和数据库同时存在时,
    如果有写操作的时候,先操作数据库还是先操作缓存呢?
    

    2.1 先操作缓存,再操作数据库 (这种方案废弃)

    2.1.1 假设有一写(线程A)一读(线程B)操作

    先删缓存,再入库--一读一写线程.jpg
    1)线程A发起一个写操作,第一步del cache
    2)此时线程B发起一个读操作,cache miss
    3)线程B继续读DB,读出来一个老数据
    4)然后老数据入cache
    5)线程A写入了最新的数据
    
    #问题
    老数据入到缓存了,每次读都是老数据,缓存与数据与数据库数据不一致。
    

    2.1.2 假设有双写(线程A, 线程B)操作

    先删缓存,再入库--双写线程.jpg
    1)线程A发起一个写操作,第一步set cache
    2)线程B发起一个写操作,第一步setcache
    3)线程B写入数据库到DB
    4)线程A写入数据库到DB
    
    #问题
    缓存保存的是B操作后的数据,数据库是A操作后的数据,缓存和数据库数据不一致。
    

    2.2 先操作数据库,再操作缓存

    2.2.1 方案1

    先入参,再删缓存--一读一写线程.png
    1)线程A发起一个写操作,第一步write DB
    2)线程A第二步del cache
    3)线程B发起一个读操作,cache miss
    4)线程B从DB获取最新数据
    5)线程B同时set cache
    
    #思考
    这种方案没有明显的并发问题,但是有可能步骤二删除缓存失败,当然概率比较小。
    

    2.2.2 方案2

    主从DB问题:
    因为主从DB同步存在同时延时时间如果删除缓存之后,
    数据同步到备库之前已经有请求过来时,会从备库中读到脏数据,如何解决呢?
    
    先入库,再删缓存--一读一写线程(优化版).png
    综上所述,
    在分布式系统中,缓存和数据库同时存在时,如果有写操作的时候,先操作数据库,再操作缓存。
    
    如下:
    (1)读取缓存中是否有相关数据
    (2)如果缓存中有相关数据value,则返回
    (3)如果缓存中没有相关数据,则从数据库读取相关数据放入缓存中key->value,再返回
    (4)如果有更新数据,则先更新数据,再删除缓存
    (5)为了保证第四步删除缓存成功,使用binlog异步删除
    (6)如果是主从数据库,binglog取自于从库
    (7)如果是一主多从,每个从库都要采集binlog,然后消费端收到最后一台binlog数据才删除缓存
    

    3 为什么 Redis 是单线程却能支撑高并发?(redis5.0及之前)

    #简单结论(读: 11w/s, 写: 8w/s)
    1、完全基于内存, 省去了从硬盘复制到内存中的IO操作。
    2、数据结构简单,对数据操作也简单
    3、采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,
    不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
    4、使用多路I/O复用模型,非阻塞IO;
    5、Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;
    
    #多路 I/O 复用模型
    多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,
    在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,
    于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
    
    这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。
    采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),
    且 Redis 在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈,
    主要由以上几点造就了 Redis 具有很高的吞吐量。
    

    3.1 为什么 Redis 中要使用 I/O 多路复用这种技术呢?

    Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,
    但是由于读写操作等待用户输入或输出都是阻塞的,
    所以 I/O 操作在一般情况下往往不能直接返回,
    这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,
    而 I/O 多路复用就是为了解决这个问题而出现的。
    

    3.2 Blocking I/O

    先来看一下传统的阻塞 I/O 模型到底是如何工作的:
    当使用 read 或者 write 对某一个文件描述符(File Descriptor 以下简称 FD)进行读写时,
    如果当前 FD 不可读或不可写,整个 Redis 服务就不会对其它的操作作出响应,导致整个服务不可用。
    这也就是传统意义上的,也就是我们在编程中使用最多的阻塞模型。
    
    阻塞模型虽然开发中非常常见也非常易于理解,但是由于它会影响其他 FD 对应的服务,
    所以在需要处理多个客户端任务的时候,往往都不会使用阻塞模型。
    
    BIO.jpg

    3.3 I/O 多路复用

    阻塞式的 I/O 模型并不能满足这里的需求,
    我们需要一种效率更高的 I/O 模型来支撑 Redis 的多个客户(redis-cli),
    这里涉及的就是 I/O 多路复用模型了。
    
    在 I/O 多路复用模型中,最重要的函数调用就是 select,
    该方法的能够同时监控多个文件描述符的可读可写情况,
    当其中的某些文件描述符可读或者可写时,select 方法就会返回可读以及可写的文件描述符个数。
    与此同时也有其它的 I/O 多路复用函数 epoll/kqueue/evport,
    它们相比 select 性能更优秀,同时也能支撑更多的服务。
    
    IO多路复用.jpg

    3.4 Reactor 设计模式

    Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符)
    
    文件事件处理器使用 I/O 多路复用模块同时监听多个 FD,
    当 accept、read、write 和 close 文件事件产生时,文件事件处理器就会回调 FD 绑定的事件处理器。
    
    虽然整个文件事件处理器是在单线程上运行的,但是通过 I/O 多路复用模块的引入,
    实现了同时对多个 FD 读写的监控,提高了网络通信模型的性能,同时也可以保证整个 Redis 服务实现的简单。
    
    Reactor模式.jpg

    3.5 I/O 多路复用模块

    I/O 多路复用模块封装了底层的 select、epoll、avport 以及 kqueue 这些 I/O 多路复用函数,为上层提供了相同的接口。
    
    Redis 对于 I/O 多路复用模块的设计非常简洁,
    通过宏保证了 I/O 多路复用模块在不同平台上都有着优异的性能,
    将不同的 I/O 多路复用函数封装成相同的 API 提供给上层使用。
    
    整个模块使 Redis 能以单进程运行的同时服务成千上万个文件描述符,
    避免了由于多进程应用的引入导致代码实现复杂度的提升,减少了出错的可能性。
    
    IO 多路复用模块.jpg 不同操作系统下选择不同的IO多路复用模块.jpg

    参考资源
    《Redis的设计与实现》
    https://mp.weixin.qq.com/s/y0UBeuTYPACXK4o3jAATPw (先删缓存 or 先入库?)
    https://mp.weixin.qq.com/s/_oQ98ANWOhzwEuoHThCKow (为什么 Redis 是单线程却能支撑高并发)

    相关文章

      网友评论

        本文标题:Redis系列(2) 主从复制/单线程高并发/先操作缓存or先操

        本文链接:https://www.haomeiwen.com/subject/wkarxhtx.html