总结redis多机数据库

谈谈对redis的理解

谈谈我对redis应用的理解把,暂且不谈redis的各种特性,可以把redis看作是以一个数据结构集合的类库(尤其是我们在开发时集成redis进行使用的时候),其实我们也可以使用JDK提供的工具、或者各种开源库函数达到类似效果,但是redis可以抽离出单个应用、单个进程,看作一个中间件应用,因此它活跃的场景应该是分布式场景、多个程序需要共享某一些数据,他们就从这个中间件中取数据,如果我们做的的是单机应用,其实完全可以采用库函数如hashMap进行实现,而使用redis还必须涉及远程连接等。

总结:如果我们仅仅把redis作为一个类似hashMap那样的单机内存缓存去使用,就牛刀小用了。redis最初的角色定位就是分布式缓存组件

当然了,redis作为单机数据库也不是不可以,首先它有独立的内存,而不像HashMap那样,需要向JVM实例申请内存,使得内存大小称为瓶颈。另一方面它比hashMap这类的类库提供了更强大的功能包括持久化、过期键等

本地缓存就是在进程的内存如JVM堆内存开辟一块内存区域,用来实现缓存效果,不需要远程调用的开销,性能最好,但是受限于单机容量,一般缓存大小有限,而且不利于和其他系统共享缓存内容
分布式缓存一般具有良好的水平扩展能力(就是通过加多个缓存中间件提高性能),缺点就是需要远程调用,性能不如本地缓存。
实际业务中通常采用多级缓存,本地缓存保存访问频率最高的部分热点数据,其他的热点数据存储在分布式缓存中

复制

客户端可以通过slaveof命令(也可以通过配置文件进行配置),让一个服务器去复制另一个服务器。被复制的服务器是主服务器,执行复制的一方是从服务器。

一个master通常对应多个slave,其中master执行写操作,slave执行读操作。master执行写入操作后会通过命令传播,将数据库状态与slave进行同步。

初始阶段,客户端向从服务器发送slaveof,要求它和主服务器进行复制操作。从服务器收到命令后,先主服务器发送SYNC命令。主服务器收到SYNC命令后执行bgsave命令,子进程生成一个RDB文件,因为RDB文件是某一时刻的快照,为了保证一致性,主服务器还会维护一个缓冲区记录(增量)写命令。当bgsave命令执行完毕,这个快照文件会发生给从服务器,从服务器接收并载入这个RDB文件,将自己的数据库状态初始化为某个快照状态。之后主服务器再将缓冲区中的增量命令发送给从服务器

主服务器维护的缓冲区类似于AOF持久化使用到的缓冲区,只不过这一次不是从page cache刷盘,而是直接从page cache通过网卡发送到从服务器。这里主服务器和从服务器互为各自的客户端,从服务器向主服务器发送SYNC命令,主服务器向从服务器发送增量指令(并执行)

再次之后类似AOF的方式生成传递增量指令,主服务器充当从服务器客户端,先从服务器发送指令。主从服务器完成第一次数据同步之后,网络连接会一直维持着长连接,后续主服务器可以通过这个连接继续将写命令发送给从服务器。

2.8前,如果命令传播阶段,主从服务器因为网络原因而中断了复制,如果恢复连接则需要重新执行一个RDB文件的生成与传送(从服务器再次发送sync命令)。效率很低。
2.8开始,使用PSYNC替代SYNC命令,PSYNC可以在处理初次复制的情况采用完整同步,而针对断线重连的情况,采用部分同步

一般Redis都是使用集群的,从结构上,单个Redis服务会发生单点故障,如果不负载均衡,压力过大。从容量上,单个Redis服务器内存容量有限。(我司开发环境基于一主二从三哨兵)

复制的实现

服务器维护三部分变量:
【1】主服务器和从服务器的复制偏移量offset,就是两个指针(类比TCP连接两端的同步序号)
【2】主服务器的复制积压缓冲区backlog,一个冗余最近传播写命令的队列,并且记录了每个字节的复制偏移量。(从服务器发送psync命令将自己的复制偏移量offset发送给主服务器,如果offset对应的偏移量能够从队列中找到,则依次弹出队列中某个范围内的所有字节,重放命令。如果无法从队列中找到,那么将采用完整同步的方式
【3】服务器运行id——runid。运行id在服务器启动的时候自动生成,当主从初次复制的时候,主服务器会将自己的运行id传送给从服务器,从服务器会保存当前主服务器的运行id。当从服务器断线并重连一个主服务器的时候,从服务器向当前连接的主服务器发送之前保存的运行id。如果能对上则执行完整同步,否则执行部分同步。

偏移量、积压缓冲区可以类比TCP的报文传输机制。它是redis实现丢失命令重传重新连接后部分重新同步的基础。

【1】客户端向从服务器发送slaveof命令,从服务器将要复制的主服务器的端口号、ip地址保存到服务器状态结构体RedisServer中。

slaveof是一个异步的命令,从服务器会向发送slaveof的客户端返回一个OK表示响应,然后开始正式指执行复制操作。

【2】从服务器根据设置的端口号和IP地址,创建连向主服务器的套接字连接。并且从服务器将会为这个套接字关联一个专门用于处理复制工作的文件事件处理器(用于处理RDB文件接收已经传播命令的执行)。主服务器与从服务器建立连接后,将为该套接字创建相应的客户端状态。这时从服务器就是主服务器的客户端。
【3】从服务器向主服务器发送PING命令,检查连接状态以及套接字的读写状态。如果从服务收到PONG回复,说明网络连接状态正常。

如果从服务器噢诶这了master auth选项,还需要与主服务器进行身份验证,需要先主服务器发送一条AUTH命令并附带密码,需要和主服务器设置的requirepass相匹配才能继续复制工作。

【4】先主服务器发送从服务器的监听端口号,主服务器将这个信息记录在它所维护的客户端状态中。(主服务执行info replication命令的时候可以打印出从服务器的端口号)
【5】从服务器向主服务器发送PSYNC命令,执行同步操作。同步操作过后,两个服务器互为对方的客户端
【6】同步过后,主从服务器之间便进入命令传播阶段。

命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令(replconf ack offset 其中offset是从服务器当前的复制偏移量/同步指针)。该ACK可以用于检查主从服务器的网络连接状态、检查命令是否丢失。
根据从服务器发送的确认号,主服务器能够判断出哪些命令丢失了(类似TCP的确认机制),主服务器会主动将复制积压缓冲区中从服务器缺失的那部分数据重新发送给从服务器。(和重连后的部分同步相似,不过补发数据的操作是主服务器根据从服务器发来的ack、在没有断线的情况下主动触发的)

哨兵

主从模式是一个基础的多机模式(哨兵模式和集群模式都是易于主从复制的基础),可以用于数据备份、读写分离(负载均衡,提供并发量)、分担主服务器部分压力。但是此时并没有高可用的保证。sentinel是redis高可用的解决方案。相当于我引入一个第三方监督者,如果主服务器或者从服务器出现任何问题,监督者就进行介入。而哨兵一般是集群部署的,防止单点故障(哨兵至少使用三个集群)。

主从服务器一般都配合哨兵服务器去使用,主从模式本身不具有自动容错和恢复的能力,而哨兵服务器就要用于及时发现问题并介入的一个中间人服务器。

哨兵服务器本质上是一个运行在特殊模式的redis服务器,它不需要使用数据库,因此它不需要载入持久化文件,它更像是作为作为客户端和服务器双重身份存在。(如发送slaveof进行故障转移、接收其他服务器发送的info、ping等命令)
哨兵状态结构体的masters字典记录了所有被哨兵监视的主服务器的相关信息。masters字典通过载入哨兵服务器的配置信息进行初始化。

初始化

哨兵在初始化阶段,会先主服务器建立连接,成为主服务器的客户端。哨兵服务器会创建两个连向主服务器的异步网络连接:
命令连接,用于向主服务器发送命令
订阅连接,客户端可以订阅若干个频道,每当其他客户端向被订阅的频道发送消息时,频道的所有订阅者都会收到该消息。哨兵服务器需要通过该连接发现新的哨兵(监听同一主服务器),并且交换最新的消息

哨兵服务器默认每十秒一次的频率,通过命令连接向被监视的主服务器发送info命令,并通过主服务器向info命令的回复,获取当前主服务器的信息,同时还可以获取主服务器属下的从服务器的所有信息。从服务器的信息会被同步到masters字典中对应master的slaves字典中。

通过info命令,哨兵服务器可以主动发现从服务器,并与之建立两个连接。并且一旦有新的从服务器被发现后,也会为之创建实例结构,并且与之建立连接。并且也会以十秒一次的频率发送info命令(可以递归注册服务器)

默认以每隔两秒一次的频率,哨兵服务器通过命令连接发送信息(哨兵本身的信息和被监控的主服务器的信息)到频道,并且通过订阅连接接收信息。监视同一个服务器的多个哨兵服务器会通过订阅连接接收到,因此哨兵之间能够共享和交换消息。哨兵服务器为每个主服务器维护的实例中,其中的哨兵字典sentinels保存了除当前哨兵以为的同样监视该主服务器的其他哨兵的信息。通过从订阅连接中接收信息,哨兵字典将被不断更新。

当一个哨兵在频带中发现一个新的哨兵,它会为当前哨兵在哨兵字典中创建相应实例,还会创建一个连接向这个哨兵的命令连接,最终监视同一主服务器的哨兵将会形成一张网络

检测故障与转移故障

默认情况,哨兵服务器以每秒一次的频率,向所有与他建立了命令连接的实例(主、从、哨兵服务器)发送PING命令,判断对方是否在线。通过哨兵服务器的配置文件指定一个心跳阈值down-after-millseconds,如果无效恢复的时间超过该阈值,则当前哨兵服务器将目标服务器判断为主观下线
同时,该哨兵服务器向监视该主服务器的同一哨兵服务器进行询问,如果接收一定数量的已下线判断,那么当前哨兵将该服务器判断为客观下线,并开始发起故障转移操作。

哨兵配置文件中设置了quorum,如果已下线判断超过该值,则认为客观下线

当一个主服务器被判定为客观下线时,减少该主服务器的各个哨兵将会选举一个领头哨兵,由它负责执行故障转移。

每个哨兵都有机会成为领头,且先到先得。每个哨兵都有一次投票的机会,将某个哨兵设置为局部领头。当执行选举的时候,当前哨兵向其他哨兵发送命令,要求将它设置为局部哨兵,如果此时目标哨兵已经完成投票,那么就不能继续设置了。最终,如果某个哨兵被半数以上的哨兵选举为局部领头,那么它将成为最终的领头哨兵。否则将重新选举。选举的算法是raft。

故障转移分为三个步骤:
【1】从已经下线的主服务器属下的从服务器中,选出一个从服务器,并将它转换为主服务器。

挑选从服务器:先按照优先级排序,然后选取同步偏移量较大的从服务器(数据保存的相对完整)、最后按照runid进行排序,选取runid最小的服务器(资历比较老)

【2】让原主服务器的从服务器,与新的主服务器进行复制。(哨兵充当客户端角色,发送slaveof命令)
【3】将已下线的服务器设置为新的主服务器的从服务器,当它重新上线后将会称为新的主服务器的从服务器。(当它重新上线时,哨兵向它发送slaveof命令)

集群

哨兵在主从复制的基础上,解决了容灾的问题,但是主从复制仍然具有难以横向扩展的问题。集群的思路就是配置多个主服务器,将数据负载均衡到多台服务器,降低单条服务器的访问压力,进而保证高可用性。

集群实现了去中心化,将主服务器抽象为多个节点,这些节点共同完成整个内容的存储。
初始阶段,各个集群节点都是独立的,集群之间通过握手请求,不断进行连通(类似并查集算法),并且使用clusterNode记录只有集群模式才会用到的数据信息(例如节点之间的连通状态)。

客户端不需要连接集群中的所有节点,只需要连接任意一个集群节点即可。
如果客户端连接的客户端没有存放对应的entry,那么底层会和另一个集群节点(如果已经建立连接,直接切换套接字)建立套接字连接,并且重新发送命令。

当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令处理的数据库键属于哪一个槽,并检查这个槽是否指派给了自己。如果键所在的槽没有指派给当前节点,节点向客户端返回一个moved错误,指引客户端重定向到正确的节点,(这个错误不会被打印出来)。而一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向,就是换一个套接字发送命令

redis集群通过分片的方式保存数据库中的键值对,集群中的整个非空数据库被分为16384个槽,某个entry将会被放入其中任意一个槽。将请求发送到任意节点,接收到请求的节点会将查询请求发送到正确的节点上去处理。
当存取entry的时候,通过CRC16算法得到一个结果,然后于16384取余,最终落到某一个哈希槽中。为了保证高可用,每个主节点也通常对应多个从节点,并且主节点由哨兵进行监视。

寻址算法三种:
【1】hash取模。最简单,但是不适合大量节点的情况,因为当节点变化的时候会导致节点总数变化,导致节点大量迁移。【2】槽指派算法,Redis cluster采用该算法,将集群节点分为若干槽,每个节点负责其中一部分。【3】一致性哈希(连续环状哈希),适合大量节点的情况,动态调整时影响的节点相对较少。

当16384个槽都已经指派给相应的节点,集群进入上线模式。

主节点用于处理槽,而从节点用于复制主节点(做主节点的数据备份),并在被复制的主节点下线时,代替主节点继续处理命令请求。被选中的从节点,执行slaveOf no one 命令,称为新的主节点。将会接管下线主节点的槽,并且通过广播通知其他集群节点
此时的主从复制中,从服务器只能作为数据冷备份,并不能用于读写分离。其实本质上为哨兵模式提供服务

Redis cluster要求至少3个master才能组成一个集群(两个以下无法进行选举,一个节点不能给自己投票,两个节点存在AB-BA情况),同时每个master至少需要一个slave节点。各个节点通过TCP进行通信。当master下线,cluster的其他节点将会进行故障转移

集群节点具有故障检查的能力,集群中的每个节点会定期向集群中的其他节点发送ping信息,检查对方是否下线。
当一个从节点发现自己正在复制的主节点进入了下线状态,从节点将开始对下线主节点进行故障转移。(集群中每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将会获得主节点的投票

槽指派算法

将集群进行分片,分为16384(2^14)个槽,各个集群服务器保存一部分槽,每个进入redis的KV,按照key进行CRC16运算,并且使用16384进行取模,最终落入其中一个槽。

当动态增加或减少node的时候,需要对槽重新分配,并且进行KV迁移。在重新分片的过程中,如果客户端发送了命令,源节点首先会在自己的数据库中查找,如果没有,则可能已经迁移到目标节点去了,源节点向客户端返回一个ACK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。

Redis的重新分片可以在线进行,重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令。
Ask错误可以看作一种临时重定向302,而moved错误是一种永久重定向301

每个集群节点都对应一个clusterNode结构,其中使用位图作为标记数字,能够通过O(1)快速检查节点是否赋值处理某个槽,或者将某个槽指派给节点负责。并且还会将自己负责的slots数组传播给集群中的其他节点。

一致性哈希算法

一致性(consistent)哈希算法翻译为连续闭环哈希算法更好

不一致hash:哈希表的每次扩容和收缩都会导致条目分布的重新计算,这在分布式的存储系统下是不可容忍的。每个桶相当于一个机器,文件分布在哪台机器由hash算法决定,如果需要加一台机器,就需要停掉所有机器并进行一次节点迁移,才可以重新对外服务——无法动态扩容。

一致性哈希假想有很多桶如7个,但是一开始只有2个桶,如果一开始被分配到了不存在的桶(取模总是模7),就顺时针找到真实的桶放进去。
这样的话,增加一个机器,只需要和它后面的机器同步一下数据,就可以开始工作了。下线一个机器,只需要先把它的数据同步到后面的一台机器再下线。若是突然下线一台机器,只会影响这台机器上的数据(可以让每台机器同步一份自己前面机器的数据,这样即使掉线也不会影响这一部分的数据服务)。而且,可以保留把桶变成环状,这样每一个机器都有“后一个机器”了。

增加或下线一个节点,受影响的是局部节点(当前节点和逆时针前一个节点之间的数据)

一致性hash还可以实现部分的分布式系统无锁化,每个任务都有自己的编号,由于哈希算法的确定性,分到哪个桶也是确定的(各自在各自的区域访问,不存在竞争),因此不存在争抢,不需要分布式锁。

但是查找效率上,没有普通的哈希查询快。一致性哈希需要将排好序的桶组成一个链表,然后一路找下去,n个桶查询时间复杂度是O(n)。(可以通过跳表优化为logk)

适合大量节点的情况,节点较少存在数据倾斜问题,可以引入虚拟节点机制进行解决,对每一个节点计算多个hash值,每个计算结果的位置都放置一个虚拟节点,实现数据的均匀分布、负载均衡。

gossip

Redis cluster是一个可以在多个redis节点之间数据共享的分布式集群,服务器节点之间通过gossip协议进行通信。

每个集群节点都维护了一份从自己视角出发的集群状态——当前集群状态、各节点的槽信息、各节点的主从状态、各节点存活状态与投票信息
基于gossip协议,当集群状态发生变化时,需要尽快传播到所有节点,使得他们看到的内容达成一致

Redis集群是去中心化的,彼此之间状态通过gossip协议通信,
【1】meet。集群中的某个节点向另外一个节点发出加入当前集群的邀请
【2】ping。每秒向集群中的其他节点发送ping命令,包含当前节点视角的信息
【3】pong。Ping的响应。同时,一个节点也可以向集群广播pong消息使得集群中的其他节点立即刷新对该节点的认知
【4】fail。节点ping不通另外一个节点,则当前节点会向集群发送广播,通知其他节点将该节点标记为已下线

将某个节点标记为已下线(fail)状态需要以下两个条件(其实还是哨兵那一套):
【1】半数以上的主节点将该节点标记为疑似下线probabe fail 状态
【2】当前节点也将其标记为疑似下线状态

总体来说,redis集群是一个去中心化的P2P网络,而gossip就是该网络的通信协议。

当某个主节点下线后,从节点将对下线的主节点执行故障转移。
【1】当从节点发现自己正在复制的主节点下线时,将在集群中广播一条消息,要求收到该消息、并且有投票权力的主节点向该从节点进行投票。最终会选取出一个从节点成为新的主节点。(选举方式与哨兵模式类似,都是基于raft算法)
【2】新的主节点执行slaveof no one,成为新的主节点。
【3】撤销所有对已下线节点的槽指派,并且全部指派给自己。
【4】新的主节点向集群广播一条pong消息,其他节点收到该消息后,将得知故障转移的情况,并且更新各自的结构体信息。
【5】新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完毕。

总结

主从复制可以分离读写请求,减去redis单机服务器的压力。而哨兵模式可以使得主服务器挂掉后,自动选取一个从服务器进行转移与恢复。而集群模式横向扩展了主服务器,总的数据可以存放在多台主服务器中,进一步减轻了单个服务器的存储成本和读写压力(但是从服务器只能用于备份了,不再具有读写分离)

崩穿问题

穿透的意思是“以攻击为目的,一开始就准备穿透缓存”,击穿是大量访问到达redis,但是某一块缓存过期,导致请求穿过redis打在了数据源处

缓存穿透

例如对用户id进行了缓存,但是大量请求对不存在的用户id频繁请求,导致查询无法命中,这时就会查询数据库,由于每个请求对应一个线程,这时存在大量请求并发涌入DB中,如果数据库也不存在这个数据,那么下次再一次请求这个数据仍然会穿透缓存,直击数据库

每次请求都能绕过缓存,打到数据库,而且数据库也查不到这个数据,并发量上去,数据库服务器就承受不住压力宕机。

解决方案:
1.对不存在的用户,缓存保存一个空对象进行标记(例如使用 K - null 键值对临时存储),防止对相同id的请求再次访问数据库,相当于“特殊的命中”,防止大量请求透过redis进入DB。但是这个方法可能使得redis中存储大量无用数据,占用内存资源。(设置过期时间长了占用内存,时间短了可能会被攻击者趁虚而入)
2.布隆过滤器,存在性检测。对某个id的请求经过多组哈希函数最终会对应一个值,布隆过滤器可以保证如果布隆过滤器中存在某个key的计算值,那么可能存在,也可能不存在,但是如果没有则一定不存在。(通过数据结构和算法快速判断出key是否存在于数据库可以缩小过滤密度)
3.接口的调用层面增加校验,对应一定不会被响应的请求,直接在缓存之前就拦截住一定不可能被命中或数据源查出的数据
4.网关层Nginx设防,将当个IP每秒访问超过阈值的ip拉入黑名单

缓存击穿

某个热点数据失效的瞬间,大量针对该数据的请求进入DB,例如微博服务器宕机的例子。
某一个热点数据不定的承受大量并发访问,当key失效的瞬间,并发就直接打到数据库

解决方案:
1.热点数据设置永不过期(定时查询redis的记账信息,如果查询某个键超过一个阈值就使用persist命令解除过期时间)
2.读操作中,线程如果拿到数据发现已经过期,则先申请互斥锁,然后再更新缓存(涉及一个调用DAO接口的操作)。可以防止多个线程同时从数据库中读数据,然后同时更新缓存。
3.避免多个热点数据同时失效,将数据放入缓存的时候使用固定时间加上一个小的随机数避免大量热点key同一时刻失效
4.使用随机退避方式,数据失效时tryLock,如果没有获取到锁就随机sleep一小段时间,然后重新调用接口,此时的数据已经被其他线程更新为有效数据了,此时可以拿到数据。

缓存雪崩

缓存雪崩是更加严重的缓存击穿,往往是某一时间点,大量的热点key失效,从而导致大量请求打入数据源,即使数据库重新启动后仍然会被新到来的请求击垮。缓存中间件失效。

解决策略和缓存击穿类似。
1.我们应该避免缓存key的失效时间集中在一个时间段,可以在存入key的时候,加入随机数
2.快速失败的熔断策略,减少数据源瞬间压力。服务降级、限流,牺牲此时一部分用户的体验保证系统的稳定性。

限流组件可以设置每秒能通过多少请求,没有通过的请求走降级路线。通过系统部分用户的体验换取服务器的安全。

3.使用主从模式和集群模式保证缓存的高可用,将热点数据均分在不同的集群服务器上

数据一致性问题

读请求到达缓存层,如果存在数据(缓存命中)直接返回即可。如果没有命中就需要再次请求数据源,从数据源查出数据后该如何处理缓冲层?:将缓存层记录更新、将缓存层记录删除
如果**写请求(本身隐含读的语言)**达到缓存层,如果命中缓存:直接写缓存并返回(数据源的内容异步更新)、写数据源并删除缓存、写数据源并更新缓存

【1】同步更新
先更新数据库,再更新缓存。

存在线程安全问题(如A1B1B2A2),A的整套操作可能会被B打断,导致B的记录被A覆盖造成B的更新丢失,需要将这两步定义为一个原子方法。

【2】同步删除
先删除缓存,再更新数据库。或者先更新数据库,再删除缓存。

复杂的场景下,缓存不单单是数据库中直接取出来的值,而是需要进行计算的值。删除缓存而不是更新缓存,就是一个延迟计算的思想,只有当用到的时候才去重新计算

很多场景下,缓存的值通常是经过复杂计算的(只不过计算涉及了一部分字段),这时候如果因为一部分字段更新就需要重新计算,将占用额外CPU资源。删除缓存是一个懒计算的思想,当下次读到这个缓存的时候才进行重新计算,而不是一更新就计算

如果先更新数据库,再删除缓存删除缓存可能会失败导致不一致发生
而如果先删除缓存,再修改数据库,如果数据库修改失败,那么数据库中是旧数据,缓存中是空的,数据不会不一致,因为缓存已经被删除,而且数据库仍然是旧数据
因此推荐使用==“先删除缓存,再修改数据库”==

【3】异步更新数据库
只更新缓存,不更新数据源。数据源通过异步进行同步,这种方式将缓存作为了实际的数据源,适合频繁更新的场景,但是一致性比较弱。例如CPU的高速缓存、mysql的buffer pool。
【4】异步更新缓存
更新数据库内容,不更新缓存。缓存的内容由后台线程负责异步地进行刷新。
这种策略的考虑:缓存中的内容涉及负责的计算(如涉及加密的信息)、缓存的并发访问量较大,如果删除缓存可能出现缓存穿透的情况。

这里不讨论什么旁路更新、直写,我也记不住。但是本质上就是上述思想在面对读写请求的排列组合,最核心的是根据正确的应用场景选择合适的策略。

对应更新失败的情况,如果服务对耗时不敏感,可以增加重试的次数。如果对耗时敏感,可以将失败的任务放入消息队列,委托后台线程进行异步地重试,避免调用长时间阻塞。

延时双删机制:先删除缓存,然后更新数据库,休眠一会儿,再次删除缓存(一般一秒,休眠时间=读业务逻辑数据的耗时+几百毫秒,为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据)

这里的情况:一个请求线程去做写操作,一个做读操作。其中读写线程不是序列化的(为了提高并发度,仅保证写操作同步,读写操作非同步,带来了不一致问题),读线程先读出V1,然后写线程更新为V2并且删除缓存中的V1并返回。这个时候缓存仍然会被读线程更新为V1。这是就需要使用延时双删机制删除缓存中的旧数据

注意,读请求可能特别多,而且都读到了旧值,因此这里的睡眠时间是很讲究的,如果过短仍然有“漏网之鱼”的读线程进行更新缓存。不过一般读是很快的,因此一秒的时间已经非常保守了。(但是还是需要具体到业务场景,总和考虑读请求的执行时间)

删除缓存重试机制:删除失败就多试几次,防止脏数据产生。
写请求更新数据库,如果缓存因为某些原因删除失败,就把删除失败的key放到消息队列。后台线程从消费消息队列拿到消息,获取要删除的key,重试删除缓存操作(如果还是失败,就重复这个过程)。

redis分布式锁

如果我们现在操作的是一个单机的缓存(例如成员变量hashMap),那么同步多个本地线程只需要使用类库提供的锁机制。而当redis被用于一个多系统之间的缓存中间件时,这时的锁也需要上升为中间件级别的,必须被各个系统可见

分布式CAP理论
任何一个分布式系统都无法满足一致性consistency可用性available分区容错性 partition tolerance,最多只能满足其中两项。绝大多数互联网的应用场景选择牺牲强一致性,保证最终一致性

分布式锁主流的实现方式有三种:
通过mysql数据库实现(将能否插入一条记录看作获取锁的标志)、通过redis数据库实现(将能否成功设置一个key作为获取锁的标志)、通过成熟的分布式锁组件zookeeper进行实现(将能否创建一个文件作为获取锁的标志)

Redis是内存数据库,每次请求上锁只需要在内存中创建变量,而不是启动IO或者创建文件,因此效率更高。

【1】Setnx 当键不存在时才设置(获取锁)
【2】Expire 为一个key对象设置超时时间,超过时间后,会被redis线程惰性删除或者定时删除,相当于释放锁(异常释放锁的情况)
【3】Delete 主动删除锁,即拿到锁的线程执行释放锁操作。(正常释放锁)

Redis 2.8引入了set的扩展参数,将上锁 + 设置超时时间变为一个原子指令

优点就是性能好,缺点也很明显,一旦上锁后,必须设置超时时间以防止某个锁无人认领,导致后续用户无限等锁。这个锁也是不可重入的。最重要的是这个锁的过期时间很难把握,需要具体到业务场景。

细节

1、获取锁的时候setnx加锁,并使用expire命令为所添加一个超时时间,超过该事件则自动释放锁(设置expire有学问,因为有的任务确实需要很长执行时间)。锁的value是一个全局的uuid,因为当一个线程去delete的时候,这个锁可能已经被redis后台线程删除了,然后又被别的应用线程加锁,所以删除的时候需要判断uuid,来判断这个锁是不是当前用户持有的,避免误删其他人的锁。为了保证释放锁和判断的操作是原子执行的,我们把这些指令写入一个lua脚本,相当于把多个指令压缩为了一个指令去执行。

2、获取锁的时候设置一个获取的超时时间,超过超时时间还没有获得则放弃锁。同时,为了防止过早超时,我们可以开启一个后台线程,例如超时时间是30s,后台线程每隔10s重新设置expire,相当于进行==“续命”==操作。如果客户端下线,那么没有继续续命的线程,最终这个锁变量会超时释放。

3、释放锁的时候根据UUID判断是不是目标锁。是目标锁才delete。防止错误地释放了他人的锁变量。(如果释放失败,可以进行自旋或者存入队列,进行最大努力重试,超过一定次数则进行报警,通知人工介入)

4、可重入的实现其实属于客户端行为,系统内的客户端获取锁的时候进行约定,不再是通过key-lock进行上锁,而是通过key-lock-num进行上锁。一开始大家setnx key-lock-0 uuid ,当一个客户端上锁成功并且希望重入的时候,setnx key-lock-1 uuid 。释放的时候也是先释放lock-1,再释放lock-0。(逻辑上重入锁,实现上申请了多个锁

总结:
【1】申请锁的时候设置超时时间,防止客户端下线后无人释放锁导致死锁。k就是锁字符串,v就是用户的uuid。释放锁的时候先判断value再释放锁。其中指令的原子性通过LUA脚本进行保证。其中上锁的时候可以直接使用setnx expire 达到原子性。
【2】客户端开启“续命”线程,定时重置超时时间,防止锁提早释放。
【3】客户端之间阅读上锁方式,实现上创建多个锁,达到逻辑上的可重入效果。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值