《Java架构师的第一性原理》32分布式计算之分布式缓存第1篇Redis

1 进程内缓存

除了常见的redis/memcache等进程外缓存服务,缓存还有一种常见的玩法,进程内缓存。

1)什么是进程内缓存?

:将一些数据缓存在站点,或者服务的进程内,这就是进程内缓存。

进程内缓存的实现载体,最简单的,可以是一个带锁的Map。又或者,可以使用第三方库,例如leveldb。

2)进程内缓存能存储啥?

:redis/memcache等进程外缓存服务能存什么,进程内缓存就能存什么。

 

如上图,可以存储json数据,可以存储html页面,可以存储对象。

3)进程内缓存有什么好处?

与没有缓存相比,进程内缓存的好处是,数据读取不再需要访问后端,例如数据库。

如上图,整个访问流程要经过1,2,3,4四个步骤。

如果引入进程内缓存,

如上图,整个访问流程只要经过1,2两个步骤。

与进程外缓存相比(例如redis/memcache),进程内缓存省去了网络开销,所以一来节省了内网带宽,二来响应时延会更低。

4)进程内缓存有什么缺点?

:统一缓存服务虽然多一次网络交互,但仍是统一存储。

 

 

 如上图,站点和服务中的多个节点访问统一的缓存服务,数据统一存储,容易保证数据的一致性。

 

而进程内缓存,如上图,如果数据缓存在站点和服务的多个节点内,数据存了多份,一致性比较难保障。

5)如何保证进程内缓存的数据一致性?

:保障进程内缓存一致性,有几种方案。

 

第一种方案,可以通过单节点通知其他节点。如上图:写请求发生在server1,在修改完自己内存数据与数据库中的数据之后,可以主动通知其他server节点,也修改内存的数据。

这种方案的缺点是:同一功能的一个集群的多个节点,相互耦合在一起,特别是节点较多时,网状连接关系极其复杂。

第二种方案,可以通过MQ通知其他节点。如上图,写请求发生在server1,在修改完自己内存数据与数据库中的数据之后,给MQ发布数据变化通知,其他server节点订阅MQ消息,也修改内存数据。

这种方案虽然解除了节点之间的耦合,但引入了MQ,使得系统更加复杂。 

前两种方案,节点数量越多,数据冗余份数越多,数据同时更新的原子性越难保证,一致性也就越难保证。

第三种方案,为了避免耦合,降低复杂性,干脆放弃了“实时一致性”,每个节点启动一个timer,定时从后端拉取最新的数据,更新内存缓存。在有节点更新后端数据,而其他节点通过timer更新数据之间,会读到脏数据。

6)为什么不能频繁使用进程内缓存?

分层架构设计,有一条准则站点层、服务层要做到无数据无状态,这样才能任意的加节点水平扩展,数据和状态尽量存储到后端的数据存储服务,例如数据库服务或者缓存服务。

分层架构设计,有一条准则站点层、服务层要做到无数据无状态,这样才能任意的加节点水平扩展,数据和状态尽量存储到后端的数据存储服务,例如数据库服务或者缓存服务。

可以看到,站点与服务的进程内缓存,实际上违背了分层架构设计的无状态准则,故一般不推荐使用。

7)什么时候可以使用进程内缓存?

:以下情况,可以考虑使用进程内缓存。

情况一只读数据,可以考虑在进程启动时加载到内存。

画外音:此时也可以把数据加载到redis / memcache,进程外缓存服务也能解决这类问题。

情况二极其高并发的,如果透传后端压力极大的场景,可以考虑使用进程内缓存。

例如,秒杀业务,并发量极高,需要站点层挡住流量,可以使用内存缓存。

情况三,一定程度上允许数据不一致业务。

例如,有一些计数场景,运营场景,页面对数据一致性要求较低,可以考虑使用进程内页面缓存。

末了,再次强调,进程内缓存的适用场景并不如redis/memcache广泛,不要为了炫技而使用。

更多的时候,还是老老实实使用redis/mc吧。

2 选redis还是memcache

memcache和redis是互联网分层架构中,最常用的KV缓存。不少同学在选型的时候会纠结,到底是选择memcache还是redis。

画外音:不鼓励粗暴的实践,例如“memcache提供的功能是redis提供的功能的子集,不用想太多,选redis准没错”。 

虽然redis比memcache更晚出来,且功能确实也更丰富,但对于一个技术人,了解“所以然”恐怕比“选择谁”更重要一些

2.1 什么时候倾向于选择redis?

业务需求决定技术选型,当业务有这样一些特点的时候,选择redis会更加适合。

1)复杂数据结构

value是哈希,列表,集合,有序集合这类复杂的数据结构时,会选择redis,因为mc无法满足这些需求。 

最典型的场景,用户订单列表,用户消息,帖子评论列表等。 

2)持久化

mc无法满足持久化的需求,只得选择redis。

但是,这里要提醒的是,真的使用对了redis的持久化功能么?

千万不要把redis当作数据库用:

(1)redis的定期快照不能保证数据不丢失

(2)redis的AOF会降低效率,并且不能支持太大的数据量

不要期望redis做固化存储会比mysql做得好,不同的工具做各自擅长的事情,把redis当作数据库用,这样的设计八成是错误的。

缓存场景,开启固化功能,有什么利弊?

如果只是缓存场景,数据存放在数据库,缓存在redis,此时如果开启固化功能:

优点是,redis挂了再重启,内存里能够快速恢复热数据,不会瞬时将压力压到数据库上,没有一个cache预热的过程。

缺点是,在redis挂了的过程中,如果数据库中有数据的修改,可能导致redis重启后,数据库与redis的数据不一致。

因此,只读场景,或者允许一些不一致的业务场景,可以尝试开启redis的固化功能。

3)天然高可用

redis天然支持集群功能,可以实现主动复制,读写分离。

redis官方也提供了sentinel集群管理工具,能够实现主从服务监控,故障自动转移,这一切,对于客户端都是透明的,无需程序改动,也无需人工介入。

而memcache,要想要实现高可用,需要进行二次开发,例如客户端的双读双写,或者服务端的集群同步。

但是,这里要提醒的是,大部分业务场景,缓存真的需要高可用么?

(1)缓存场景,很多时候,是允许cache miss

(2)缓存挂了,很多时候可以通过DB读取数据 

所以,需要认真剖析业务场景,高可用,是否真的是对缓存的主要需求?

画外音:即时通讯业务中,用户的在线状态,就有高可用需求。

4)存储的内容比较大

memcache的value存储,最大为1M,如果存储的value很大,只能使用redis。

2.2 什么时候倾向于memcache?

纯KV,数据量非常大,并发量非常大的业务,使用memcache或许更适合。

这要从mc与redis的底层实现机制差异说起。

1)内存分配

memcache使用预分配内存池的方式管理内存,能够省去内存分配时间。

redis则是临时申请空间,可能导致碎片。

从这一点上,mc会更快一些。

2)虚拟内存使用

memcache把所有的数据存储在物理内存里。

redis有自己的VM机制,理论上能够存储比物理内存更多的数据,当数据超量时,会引发swap,把冷数据刷到磁盘上。

从这一点上,数据量大时,mc会更快一些。

3)网络模型

memcache使用非阻塞IO复用模型,redis也是使用非阻塞IO复用模型。

但由于redis还提供一些非KV存储之外的排序,聚合功能,在执行这些功能时,复杂的CPU计算,会阻塞整个IO调度。

从这一点上,由于redis提供的功能较多,mc会更快一些。

4)线程模型

memcache使用多线程,主线程监听,worker子线程接受请求,执行读写,这个过程中,可能存在锁冲突。

redis使用单线程,虽无锁冲突,但难以利用多核的特性提升整体吞吐量。

从这一点上,mc会快一些。

最后说两点

代码可读性,代码质量

看过mc和redis的代码,从可读性上说,redis是我见过代码最清爽的软件,甚至没有之一,或许简单是redis设计的初衷,编译redis甚至不需要configure,不需要依赖第三方库,一个make就搞定了。

而memcache,可能是考虑了太多的扩展性,多系统的兼容性,代码不清爽,看起来费劲。

例如网络IO的部分,redis源码1-2个文件就搞定了,mc使用了libevent,一个fd传过来传过去,又pipe又线程传递的,特别容易把人绕晕。

画外音:理论上,mc只支持kv,而redis支持了这么多功能,mc性能应该高非常多非常多,但实际并非如此,真的可能和代码质量有关。

5)水平扩展的支持

不管是mc和redis,服务端集群没有天然支持水平扩展,需要在客户端进行分片,这其实对调用方并不友好。如果能服务端集群能够支持水平扩展,会更完美一些。

3 缓存,你真的用对了吗

缓存,是互联网分层架构中,非常重要的一个部分,通常用它来降低数据库压力,提升系统整体性能,缩短访问时间。 

有架构师说“缓存是万金油,哪里有问题,加个缓存,就能优化”,缓存的滥用,可能会导致一些错误用法。 

缓存,你真的用对了么?

1)误用一:把缓存作为服务与服务之间传递数据的媒介

如上图:

  • 服务1和服务2约定好key和value,通过缓存传递数据

  • 服务1将数据写入缓存,服务2从缓存读取数据,达到两个服务通信的目的 

该方案存在的问题是:

  • 数据管道,数据通知场景,MQ更加适合

  • 多个服务关联同一个缓存实例,会导致服务耦合

2)误用二:使用缓存未考虑雪崩

常规的缓存玩法,如上图:

  • 服务先读缓存,缓存命中则返回

  • 缓存不命中,再读数据库

什么时候会产生雪崩?

:如果缓存挂掉,所有的请求会压到数据库,如果未提前做容量预估,可能会把数据库压垮(在缓存恢复之前,数据库可能一直都起不来),导致系统整体不可服务。

如何应对潜在的雪崩?

:提前做容量预估,如果缓存挂掉,数据库仍能扛住,才能执行上述方案。

否则,就要进一步设计。

常见方案一:高可用缓存

  

如上图:使用高可用缓存集群,一个缓存实例挂掉后,能够自动做故障转移。

常见方案二:缓存水平切分

如上图:使用缓存水平切分,一个缓存实例挂掉后,不至于所有的流量都压到数据库上。

3)误用三:调用方缓存数据

如上图:

  • 服务提供方缓存,向调用方屏蔽数据获取的复杂性(这个没问题)

  • 服务调用方,也缓存一份数据,先读自己的缓存,再决定是否调用服务(这个有问题)

该方案存在的问题是:

  • 调用方需要关注数据获取的复杂性

  • 更严重的,服务修改db里的数据,淘汰了服务cache之后,难以通知调用方淘汰其cache里的数据,从而导致数据不一致

  • 有人说,服务可以通过MQ通知调用方淘汰数据,额,难道下游的服务要依赖上游的调用方,分层架构设计不是这么玩的

4)误用四:多服务共用缓存实例

如上图:

  • 服务A和服务B共用一个缓存实例(不是通过这个缓存实例交互数据)

该方案存在的问题是:

  • 可能导致key冲突,彼此冲掉对方的数据

画外音:可能需要服务A和服务B提前约定好了key,以确保不冲突,常见的约定方式是使用namespace:key的方式来做key。

  • 不同服务对应的数据量,吞吐量不一样,共用一个实例容易导致一个服务把另一个服务的热数据挤出去

  • 共用一个实例,会导致服务之间的耦合,与微服务架构的“数据库,缓存私有”的设计原则是相悖的

建议的玩法是:

如上图:各个服务私有化自己的数据存储,对上游屏蔽底层的复杂性。

总结

缓存使用小技巧:

  • 服务与服务之间不要通过缓存传递数据

  • 如果缓存挂掉,可能导致雪崩,此时要做高可用缓存,或者水平切分

  • 调用方不宜再单独使用缓存存储服务底层的数据,容易出现数据不一致,以及反向依赖

  • 不同服务,缓存实例要做垂直拆分

4 缓存,究竟是淘汰,还是修改?

允许cache miss的场景,不管是memcache还是redis,当被缓存的内容变化时,是改修改缓存,还是淘汰缓存?这是今天将要讨论的话题。

1)问:KV缓存都缓存了一些什么数据?

(1)朴素类型的数据,例如:int
(2)序列化后的对象,例如:User实体,本质是binary
(3)文本数据,例如:json或者html
(4)...

2)问:淘汰缓存中的这些数据,修改缓存中的这些数据,有什么差别?

(1)淘汰某个key,操作简单,直接将key置为无效,但下一次该key的访问会cache miss
(2)修改某个key的内容,逻辑相对复杂,但下一次该key的访问仍会cache hit

可以看到,差异仅仅在于一次cache miss。

3)问:缓存中的value数据一般是怎么修改的?

(1)朴素类型的数据,直接set修改后的值即可
(2)序列化后的对象:一般需要先get数据,反序列化成对象,修改其中的成员,再序列化为binary,再set数据
(3)json或者html数据:一般也需要先get文本,parse成doom树对象,修改相关元素,序列化为文本,再set数据

结论:对于对象类型,或者文本类型修改缓存value的成本较高,一般选择直接淘汰缓存。

4)问:对于朴素类型的数据,究竟应该修改缓存,还是淘汰缓存?
答:仍然视情况而定。

案例1

假设,缓存里存了某一个用户uid=123的余额是money=100元,业务场景是,购买了一个商品pid=456。

分析:如果修改缓存,可能需要:
(1)去db查询pid的价格是50元
(2)去db查询活动的折扣是8折(商品实际价格是40元)
(3)去db查询用户的优惠券是10元(用户实际要支付30元)
(4)从cache查询get用户的余额是100元
(5)计算出剩余余额是100 - 30 = 70
(6)到cache设置set用户的余额是70
为了避免一次cache miss,需要额外增加若干次db与cache的交互,得不偿失。

结论:此时,应该淘汰缓存,而不是修改缓存。

案例2

假设,缓存里存了某一个用户uid=123的余额是money=100元,业务场景是,需要扣减30元。

分析:如果修改缓存,需要:
(1)从cache查询get用户的余额是100元
(2)计算出剩余余额是100 - 30 = 70
(3)到cache设置set用户的余额是70
为了避免一次cache miss,需要额外增加若干次cache的交互,以及业务的计算,得不偿失。

结论:此时,应该淘汰缓存,而不是修改缓存。

案例3

假设,缓存里存了某一个用户uid=123的余额是money=100元,业务场景是,余额要变为70元。 

分析:如果修改缓存,需要:
(1)到cache设置set用户的余额是70
修改缓存成本很低。

结论:此时,可以选择修改缓存。当然,如果选择淘汰缓存,只会额外增加一次cache miss,成本也不高。

总结
允许cache miss的KV缓存写场景:

  • 大部分情况,修改value成本会高于“增加一次cache miss”,因此应该淘汰缓存
  • 如果还在纠结,总是淘汰缓存,问题也不大

先操作数据库,还是先操作缓存?

关于这个问题,行业有两种不同的实践,大家根据自己的业务场景选择使用哪一种。

5.1 方案1:淘汰缓存,再操作数据库 

缓存存储,也是数据的冗余。

(1)数据库访问数据,磁盘IO,慢;

(2)缓存里访问数据,存操作,快;

(3)数据库里的热数据,可在缓存冗余一份;

(4)先访问缓存,如果命中,能大大的提升访问速度,降低数据库压力;

这些,是缓存的核心读加速原理。

但是,一旦没有命中缓存,或者一旦涉及写操作流程会比没有缓存更加复杂,这些是今天要分享的话题。

1)读操作,如果没有命中缓存,流程是怎么样的?

:如下图所示

(1)尝试从缓存get数据,结果没有命中;

(2)从数据库获取数据,读从库,读写分离;

(3)把数据set到缓存,未来能够命中缓存;

读操作的流程应该没有歧义。

2)写操作,流程是怎么样的?

:写操作,既要操作数据库中的数据,又要操作缓存里的数据。

这里,有两个方案:

(1)先操作数据库,再操作缓存;

(2)先操作缓存,再操作数据库;

并且,希望保证两个操作的原子性,要么同时成功,要么同时失败。 

这演变为一个分布式事务的问题,保证原子性十分困难很有可能出现一半成功,一半失败,接下来看下,当原子性被破坏的时候,分别会发生什么。

3)先操作数据库,再操作缓存

如上图,正常情况下:

(1)先操作数据库,成功;

(2)再操作缓存(delete或者set),也成功; 

但如果这两个动作原子性被破坏:第一步成功,第二步失败,会导致,数据库里是新数据,而缓存里是旧数据,业务无法接受

画外音:如果第一步就失败,可以返回调用方50X,不会出现数据不一致。

4)先操作缓存,再操作数据库

如上图,正常情况下:

(1)先操作缓存(delete或者set),成功;

(2)再操作数据库,也成功;

画外音:如果第一步就失败,也可以返回调用方50X,不会出现数据不一致。

如果原子性被破坏,会发生什么呢?

这里又分了两种情况:

(1)操作缓存使用set

(2)操作缓存使用delete

使用set的情况第一步成功,第二步失败,会导致,缓存里是set后的数据,数据库里是之前的数据,数据不一致,业务无法接受 

并且,一般来说,数据最终以数据库为准,写缓存成功,其实并不算成功。

使用delete的情况第一步成功,第二步失败,会导致,缓存里没有数据,数据库里是之前的数据,数据没有不一致,对业务无影响。只是下一次读取,会多一次cache miss。

画外音:此时可以返回调用方50X。

5)最终,先操作缓存,还是先操作数据库?

(1)读请求,先读缓存,如果没有命中,读数据库,再set回缓存

(2)写请求

    (2.1)先缓存,再数据库

    (2.2)缓存,使用delete,而不是set

末了,挖个坑:

 

在缓存读取流程中,如果主从没有同步完成,步骤二读取到一个旧数据,可能导致缓存里set一个旧数据,最终导致数据库和缓存数据不一致。

如何解决这种情况下,缓存与数据库数据不一致的问题,是下一章要讨论的内容。

5.2 方案2:先操作数据库,再淘汰缓存

1)什么是“Cache Aside Pattern”?

旁路缓存方案的经验实践,这个实践又分读实践,写实践

2)对于读请求

  • 先读cache,再读db

  • 如果,cache hit,则直接返回数据

  • 如果,cache miss,则访问db,并将数据set回缓存

 

如上图:

(1)先从cache中尝试get数据,结果miss了

(2)再从db中读取数据,从库,读写分离

(3)最后把数据set回cache,方便下次读命中

画外音:这一点上,与《究竟先操作缓存,还是数据库?》说的是一致的。

3)对于写请求

  • 淘汰缓存,而不是更新缓存

  • 先操作数据库,再淘汰缓存

如上图:

(1)第一步要操作数据库,第二步操作缓存

画外音:这一点上,与《究竟先操作缓存,还是数据库?》说的不一致,也是评论反驳比较激烈的地方。

(2)缓存,采用delete淘汰,而不是set更新

4)Cache Aside Pattern为什么建议淘汰缓存,而不是更新缓存?

:如果更新缓存,在并发写时,可能出现数据不一致。

如上图所示,如果采用set缓存。

在1和2两个并发写发生时,由于无法保证时序,此时不管先操作缓存还是先操作数据库,都可能出现:

(1)请求1先操作数据库,请求2后操作数据库

(2)请求2先set了缓存,请求1后set了缓存

导致,数据库与缓存之间的数据不一致。

所以,Cache Aside Pattern建议,delete缓存,而不是set缓存

5)Cache Aside Pattern为什么建议先操作数据库,再操作缓存?

:如果先操作缓存,在读写并发时,可能出现数据不一致。

如上图所示,如果先操作缓存。 

在1和2并发读写发生时,由于无法保证时序,可能出现:

(1)写请求淘汰了缓存

(2)写请求操作了数据库(主从同步没有完成)

(3)读请求读了缓存(cache miss)

(4)读请求读了从库(读了一个旧数据)

(5)读请求set回缓存(set了一个旧数据)

(6)数据库主从同步完成

导致,数据库与缓存的数据不一致。

所以,Cache Aside Pattern建议,先操作数据库,再操作缓存

6)Cache Aside Pattern方案存在什么问题?

:如果先操作数据库,再淘汰缓存,在原子性被破坏时:

(1)修改数据库成功了

(2)淘汰缓存失败了

导致,数据库与缓存的数据不一致。

 

不管先操作数据库,还是先操作缓存,其实都解决不了“写后立刻读,脏数据库入缓存”的问题。

什么是“写后立刻读,脏数据库入缓存”问题?

:发生写请求后(不管是先操作DB,还是先淘汰Cache),在主从数据库同步完成之前,如果有读请求,都可能发生读Cache Miss,读从库把旧数据存入缓存的情况。此时怎么办呢?遂引出了下一篇文章。

6 缓存与数据不一致,怎么办

6.1 数据库主从不一致

先回顾下,无缓存时,数据库主从不一致问题。

如上图,发生的场景是,写后立刻读:

(1)主库一个写请求(主从没同步完成)

(2)从库接着一个读请求,读到了旧数据

(3)最后,主从同步完成

导致的结果是:主动同步完成之前,会读取到旧数据。

可以看到,主从不一致的影响时间很短,在主从同步完成后,就会读到新数据。

6.2 缓存与数据库不一致

再看,引入缓存后,缓存和数据库不一致问题。

 

如上图,发生的场景也是,写后立刻读:

(1+2)先一个写请求,淘汰缓存,写数据库

(3+4+5)接着立刻一个读请求,读缓存,cache miss,读从库,写缓存放入数据,以便后续的读能够cache hit(主从同步没有完成,缓存中放入了旧数据)

(6)最后,主从同步完成

导致的结果是:旧数据放入缓存,即使主从同步完成,后续仍然会从缓存一直读取到旧数据。

可以看到,加入缓存后,导致的不一致影响时间会很长,并且最终也不会达到一致。

6.3 问题分析

可以看到,这里提到的缓存与数据库数据不一致,根本上是由数据库主从不一致引起的。当主库上发生写操作之后,从库binlog同步的时间间隔内,读请求,可能导致有旧数据入缓存。 

假如主从不一致没法彻底解决,引入缓存之后,binlog同步时间间隔内,也无法避免读旧数据。

但是,有没有办法做到,即使引入缓存,不一致不会比“不引入缓存”更糟呢?这是更为实际的优化目标。

思路转化为:在从库同步完成之后,如果有旧数据入缓存,应该及时把这个旧数据淘汰掉。

6.4 不一致优化

如上图所述,在并发读写导致缓存中读入了脏数据之后:

(6)主从同步

(7)通过工具订阅从库的binlog,这里能够最准确的知道,从库数据同步完成的时间

画外音:本图画的订阅工具是DTS,可以是cannal,也可以自己订阅和分析binlog

(8)从库执行完写操作,向缓存再次发起删除淘汰这段时间内可能写入缓存的旧数据 

如此这般,至少能够保证,引入缓存之后,主从不一致,不会比没有引入缓存更坏。

画外音:即使引入缓存,也只有一个很小的时间间隔,可能读到旧数据。

6.5 结尾

问:如何完全避免,主从同步时间差,数据的一致性?

:详见《数据库主从不一致,怎么解?》。 

:该方案,只能优化,并发读写情况下,缓存与数据库一致性问题。如果,缓存与数据库两次操作,原子性被破坏(例如:修改数据库成功,淘汰缓存失败,导致的数据不一致),如何优化数据的一致性呢?

:详见《究竟先操作缓存,还是数据库?》。

缓存与数据库的不一致,本质是由主从数据库延时引起的,有没有办法优化主从数据库的一致性呢?遂引出了下一篇文章。

7 主从数据库不一致怎么办

在聊数据库与缓存一致性问题之前,先聊聊数据库主库与从库的一致性问题。

1)问:常见的数据库集群架构如何?

一主多从,主从同步,读写分离。

如上图:

(1)一个主库提供写服务

(2)多个从库提供读服务,可以增加从库提升读性能

(3)主从之间同步数据

画外音:任何方案不要忘了本心,加从库的本心,是提升读性能。

2)问:为什么会出现不一致?

主从同步有时延,这个时延期间读从库,可能读到不一致的数据。

如上图:

(1)服务发起了一个写请求

(2)服务又发起了一个读请求,此时同步未完成,读到一个不一致的脏数据

(3)数据库主从同步最后才完成

画外音:任何数据冗余,必将引发一致性问题。

3)问:如何避免这种主从延时导致的不一致?

:常见的方法有这么几种。

方案一:忽略

任何脱离业务的架构设计都是耍流氓,绝大部分业务,例如:百度搜索,淘宝订单,QQ消息,58帖子都允许短时间不一致。

画外音:如果业务能接受,最推崇此法。 

如果业务能够接受,别把系统架构搞得太复杂。

方案二:强制读主

 

如上图:

(1)使用一个高可用主库提供数据库服务

(2)读和写都落到主库上

(3)采用缓存来提升系统读性能

这是很常见的微服务架构,可以避免数据库主从一致性问题。

方案三:选择性读主

强制读主过于粗暴,毕竟只有少量写请求,很短时间,可能读取到脏数据。

有没有可能实现,只有这一段时间,可能读到从库脏数据的读请求读主,平时读从呢?

 

可以利用一个缓存记录必须读主的数据。

如上图,当写请求发生时:

(1)写主库

(2)将哪个库,哪个表,哪个主键三个信息拼装一个key设置到cache里,这条记录的超时时间,设置为“主从同步时延”

画外音:key的格式为“db:table:PK”,假设主从延时为1s,这个key的cache超时时间也为1s。

如上图,当读请求发生时:

这是要读哪个库,哪个表,哪个主键的数据呢,也将这三个信息拼装一个key,到cache里去查询,如果,

(1)cache里有这个key,说明1s内刚发生过写请求,数据库主从同步可能还没有完成,此时就应该去主库查询

(2)cache里没有这个key,说明最近没有发生过写请求,此时就可以去从库查询

以此,保证读到的一定不是不一致的脏数据。

总结

数据库主库和从库不一致,常见有这么几种优化方案:

(1)业务可以接受,系统不优化

(2)强制读主,高可用主库,用缓存提高读性能

(3)在cache里记录哪些记录发生过写请求,来路由读主还是读从

8 分布式服务缓存Redis

8.1 什么是缓存?

缓存主要是为了协调访问之间的速度差异而存在的一种东西,可以是一个硬件,也可以是一个数据结构。比如我们的内存就是因为磁盘访问太慢了,用内存存储一些磁盘上的数据,便于CPU的读取。

再深入还有L1、L2等缓存,也是因为嫌内存慢而产生的。

还有一种慢,表明看起来是访问慢,实际上的访问的成本高。什么意思呢?例如一个复杂的计算,如果每次都去算一遍拿结果,就慢了,于是就用缓存把结果存起来,下次就直接拿结果不用重新算了。这就叫空间换时间(思维模型)

在我们实际工程应用上缓存可以分为三大类:静态缓存、分布式缓存、本地缓存

静态缓存常指的是前端静态页面,html 啊,js等等,常放在静态服务器上,还能通过 CDN 来缩减响应的时间,提高用户访问速度。

分布式缓存常指的是利用 Redis 、Memcached 等分布式缓存中间件来存放一些较为常用的数据,多个应用共享缓存,不仅可以提高访问速率,也算上在高并发下起到保护脆弱的数据库作用,算是高并发利器了!

本地缓存常指的是应用在同一个进程中的缓存组件,交互之间不会有网络开销,当你的项目还用不上分布式缓存,就存一些简单的变量时候可以用本地缓存来解决。最简单的 HashMap 就能作为本地缓存,或者Ehcache、Guava Cache等。

8.2 为什么用缓存?

协调访问之间的速度差异。

8.3 缓存读写策略

缓存读写策略其实是应对不同场景的。

读策略:从缓存中读数据。命中,则直接返回数据。不命中,则从数据库中查,查到数据后,将数据写入到缓存中,并且返回给用户。

写策略:先更新数据库,然后删除缓存(让数据库来保证数据正确,缓存就不更新,咱就做个搬运工,岂不美滋滋)。这种策略叫cache aside 旁路缓存策略,也是最常见的策略。

8.4 缓存穿透

那什么是缓存穿透,它就是指当用户在查询一条数据的时候,而此时数据库和缓存却没有关于这条数据的任何记录,而这条数据在缓存中没找到就会向数据库请求获取数据。它拿不到数据时,是会一直查询数据库,这样会对数据库的访问造成很大的压力。

1)解决方案一:缓存空对象

 2)解决方案二:布隆过滤器

布隆过滤器是一种基于概率的数据结构,主要用于判断当前某个元素是否在该集合中,运行速度快。我们也可以简单理解为是一个不怎么精确的 set 结构(set 具有去重的效果)。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。

职场潜规则:说你行,你不一定行。说你不行,你一定不行。

布隆过滤器的特点:

  • 一个非常大的二进制位数组(数组中只存在 0 和 1)
  • 拥有若干个哈希函数(Hash Function)
  • 在空间效率和查询效率都非常高
  • 布隆过滤器不会提供删除方法,在代码维护上比较困难。

 每个布隆过滤器对应到 Redis 的数据结构里面就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。

 向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。( 每一个 key 都通过若干的hash函数映射到一个巨大位数组上,映射成功后,会在把位数组上对应的位置改为1。)

为什么布隆过滤器会存在误判率?

其实它会误判是如下这个情况:

 当 key1 和 key2 映射到位数组上的位置为 1 时,假设这时候来了个 key3,要查询是不是在里面,恰好 key3 对应位置也映射到了这之间,那么布隆过滤器会认为它是存在的,这时候就会产生误判(因为明明 key3 是不在的)。

如何提高布隆过滤器的准确率呢?

要提高布隆过滤器的准确率,就要说到影响它的三个重要因素:

  • 哈希函数的好坏
  • 存储空间大小
  • 哈希函数个数

hash函数的设计也是一个十分重要的问题,对于好的hash函数能大大降低布隆过滤器的误判率。

同时,对于一个布隆过滤器来说,如果其位数组越大的话,那么每个key通过hash函数映射的位置会变得稀疏许多,不会那么紧凑,有利于提高布隆过滤器的准确率。同时,对于一个布隆过滤器来说,如果key通过许多hash函数映射,那么在位数组上就会有许多位置有标志,这样当用户查询的时候,在通过布隆过滤器来找的时候,误判率也会相应降低。

8.5 缓存击穿

缓存击穿是指有某个key经常被查询,经常被用户特殊关怀,用户非常 love 它 (^▽^),也就类比“熟客” 或者 一个key经常不被访问。但是这时候,如果这个key在缓存的过期时间失效的时候或者这是个冷门key时,这时候突然有大量有关这个key的访问请求,这样会导致大并发请求直接穿透缓存,请求数据库,瞬间对数据库的访问压力增大。

归纳起来:造成缓存击穿的原因有两个。

(1)一个“冷门”key,突然被大量用户请求访问。

(2)一个“热门”key,在缓存中时间恰好过期,这时有大量用户来进行访问。

 

 对于缓存击穿的问题:我们常用的解决方案是加锁。对于key过期的时候,当key要查询数据库的时候加上一把锁,这时只能让第一个请求进行查询数据库,然后把从数据库中查询到的值存储到缓存中,对于剩下的相同的key,可以直接从缓存中获取即可。

如果我们是在单机环境下:直接使用常用的锁即可(如:Lock、Synchronized等)。

在分布式环境下我们可以使用分布式锁,如:基于数据库、基于Redis或者zookeeper 的分布式锁。

8.6 缓存雪崩

缓存雪崩是指在某一个时间段内,缓存集中过期失效,如果这个时间段内有大量请求,而查询数据量巨大,所有的请求都会达到存储层,存储层的调用量会暴增,引起数据库压力过大甚至宕机。

原因:

  • Redis突然宕机
  • 大部分数据失效

对于缓存雪崩有以下解决方案:

(1)redis高可用

redis有可能挂掉,多增加几台redis实例,(一主多从或者多主多从),这样一台挂掉之后其他的还可以继续工作,其实就是搭建的集群。

(2)限流降级

在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量,对某个key只允许一个线程查询数据和写缓存,其他线程等待。

(3)数据预热

数据加热的含义就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的key。

(4)不同的过期时间

设置不同的过期时间,让缓存失效的时间点尽量均匀。

9 Redis使用

9.1 说说Redis基本数据类型有哪些吧

当然可以,但是在说之前,我觉得有必要先来了解下Redis内部内存管理是如何描述这5种数据类型的。说着,我拿着笔给面试官画了一张图:

  • 字符串:redis没有直接使用C语言传统的字符串表示,而是自己实现的叫做简单动态字符串SDS的抽象类型。C语言的字符串不记录自身的长度信息,而SDS则保存了长度信息,这样将获取字符串长度的时间由O(N)降低到了O(1),同时可以避免缓冲区溢出和减少修改字符串长度时所需的内存重分配次数。
  • 链表linkedlist:redis链表是一个双向无环链表结构,很多发布订阅、慢查询、监视器功能都是使用到了链表来实现,每个链表的节点由一个listNode结构来表示,每个节点都有指向前置节点和后置节点的指针,同时表头节点的前置和后置节点都指向NULL。
  • 字典hashtable:用于保存键值对的抽象数据结构。redis使用hash表作为底层实现,每个字典带有两个hash表,供平时使用和rehash时使用,hash表使用链地址法来解决键冲突,被分配到同一个索引位置的多个键值对会形成一个单向链表,在对hash表进行扩容或者缩容的时候,为了服务的可用性,rehash的过程不是一次性完成的,而是渐进式的。
  • 跳跃表skiplist:跳跃表是有序集合的底层实现之一,redis中在实现有序集合键和集群节点的内部结构中都是用到了跳跃表。redis跳跃表由zskiplist和zskiplistNode组成,zskiplist用于保存跳跃表信息(表头、表尾节点、长度等),zskiplistNode用于表示表跳跃节点,每个跳跃表的层高都是1-32的随机数,在同一个跳跃表中,多个节点可以包含相同的分值,但是每个节点的成员对象必须是唯一的,节点按照分值大小排序,如果分值相同,则按照成员对象的大小排序。
  • 整数集合intset:用于保存整数值的集合抽象数据结构,不会出现重复元素,底层实现为数组。
  • 压缩列表ziplist:压缩列表是为节约内存而开发的顺序性数据结构,他可以包含多个节点,每个节点可以保存一个字节数组或者整数值。

基于这些基础的数据结构,redis封装了自己的对象系统,包含字符串对象string、列表对象list、哈希对象hash、集合对象set、有序集合对象zset,每种对象都用到了至少一种基础的数据结构。

redis通过encoding属性设置对象的编码形式来提升灵活性和效率,基于不同的场景redis会自动做出优化。不同对象的编码如下:

  • 字符串对象string:int整数、embstr编码的简单动态字符串、raw简单动态字符串
  • 列表对象list:ziplist、linkedlist
  • 哈希对象hash:ziplist、hashtable
  • 集合对象set:intset、hashtable
  • 有序集合对象zset:ziplist、skiplist

简单说下5种数据类型:

  • 1、string是redis最基本的类型,可以理解成与memcached一模一样的类型,一个key对应一个value。value不仅是string,也可以是数字。string类型是二进制安全的,意思是redis的string类型可以包含任何数据,比如jpg图片或者序列化的对象。string类型的值最大能存储512M。
  • 2、Hash是一个键值(key-value)的集合。redis的hash是一个string的key和value的映射表,Hash特别适合存储对象。常用命令:hget,hset,hgetall等。
  • 3、list列表是简单的字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部(左边)或者尾部(右边) 常用命令:lpush、rpush、lpop、rpop、lrange(获取列表片段)等。应用场景:list应用场景非常多,也是Redis最重要的数据结构之一,比如twitter的关注列表,粉丝列表都可以用list结构来实现。数据结构:list就是链表,可以用来当消息队列用。redis提供了List的push和pop操作,还提供了操作某一段的api,可以直接查询或者删除某一段的元素。实现方式:redis list的是实现是一个双向链表,既可以支持反向查找和遍历,更方便操作,不过带来了额外的内存开销。
  • 4、set是string类型的无序集合。集合是通过hashtable实现的。set中的元素是没有顺序的,而且是没有重复的。常用命令:sdd、spop、smembers、sunion等。应用场景:redis set对外提供的功能和list一样是一个列表,特殊之处在于set是自动去重的,而且set提供了判断某个成员是否在一个set集合中。
  • 5、zset和set一样是string类型元素的集合,且不允许重复的元素。常用命令:zadd、zrange、zrem、zcard等。使用场景:sorted set可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。当你需要一个有序的并且不重复的集合列表,那么可以选择sorted set结构。和set相比,sorted set关联了一个double类型权重的参数score,使得集合中的元素能够按照score进行有序排列,redis正是通过分数来为集合中的成员进行从小到大的排序。实现方式:Redis sorted set的内部使用HashMap和跳跃表(skipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。

数据类型应用场景总结:

9.2 Redis为什么快呢?

redis的速度非常的快,单机的redis就可以支撑每秒10几万的并发,相对于mysql来说,性能是mysql的几十倍。速度快的原因主要有几点:

  • 完全基于内存操作
  • C语言实现,优化过的数据结构,基于几种基础的数据结构,redis做了大量的优化,性能极高
  • 使用单线程,无上下文的切换成本
  • 基于非阻塞的IO多路复用机制

9.2.1 完全基于内存实现

9.2.2 优化过的数据结构

在 Redis 中,常用的 5 种数据类型和应用场景如下:

  • String: 缓存、计数器、分布式锁等。
  • List: 链表、队列、微博关注人时间轴列表等。
  • Hash: 用户信息、Hash 表等。
  • Set: 去重、赞、踩、共同好友等。
  • Zset: 访问量排行榜、点击量排行榜等。

每种数据类型都有一种或者多种数据结构来支撑,底层数据结构有 6 种。

 

1)Redis hash 字典

Redis 整体就是一个 哈希表来保存所有的键值对,无论数据类型是 5 种的任意一种。哈希表,本质就是一个数组,每个元素被叫做哈希桶,不管什么数据类型,每个桶里面的 entry 保存着实际具体值的指针。

 

 整个数据库就是一个全局哈希表,而哈希表的时间复杂度是 O(1),只需要计算每个键的哈希值,便知道对应的哈希桶位置,定位桶里面的 entry 找到对应数据,这个也是 Redis 快的原因之一。

那 Hash 冲突怎么办?

当写入 Redis 的数据越来越多的时候,哈希冲突不可避免,会出现不同的 key 计算出一样的哈希值。

Redis 通过链式哈希解决冲突:也就是同一个 桶里面的元素使用链表保存。但是当链表过长就会导致查找性能变差可能,所以 Redis 为了追求快,使用了两个全局哈希表。用于 rehash 操作,增加现有的哈希桶数量,减少哈希冲突。

开始默认使用 hash 表 1 保存键值对数据,哈希表 2 此刻没有分配空间。当数据越来多触发 rehash 操作,则执行以下操作:

  1. 给 hash 表 2 分配更大的空间;
  2. 将 hash 表 1 的数据重新映射拷贝到 hash 表 2 中;
  3. 释放 hash 表 1 的空间。

值得注意的是,将 hash 表 1 的数据重新映射到 hash 表 2 的过程中并不是一次性的,这样会造成 Redis 阻塞,无法提供服务。

而是采用了渐进式 rehash,每次处理客户端请求的时候,先从 hash 表 1 中第一个索引开始,将这个位置的 所有数据拷贝到 hash 表 2 中,就这样将 rehash 分散到多次请求过程中,避免耗时阻塞。

2)SDS 简单动态字符

65 哥:Redis 是用 C 语言实现的,为啥还重新搞一个 SDS 动态字符串呢?

字符串结构使用最广泛,通常我们用于缓存登陆后的用户信息,key = userId,value = 用户信息 JSON 序列化成字符串。

C 语言中字符串的获取 「MageByte」的长度,要从头开始遍历,直到 「\0」为止,Redis 作为唯快不破的男人是不能忍受的。

C 语言字符串结构与 SDS 字符串结构对比图如下所示:

SDS 与 C 字符串区别

  • O(1) 时间复杂度获取字符串长度

C 语言字符串布吉路长度信息,需要遍历整个字符串时间复杂度为 O(n),C 字符串遍历时遇到 '\0' 时结束。

SDS 中 len 保存这字符串的长度,O(1) 时间复杂度。

  • 空间预分配

SDS 被修改后,程序不仅会为 SDS 分配所需要的必须空间,还会分配额外的未使用空间。

分配规则如下:如果对 SDS 修改后,len 的长度小于 1M,那么程序将分配和 len 相同长度的未使用空间。举个例子,如果 len=10,重新分配后,buf 的实际长度会变为 10(已使用空间)+10(额外空间)+1(空字符)=21。如果对 SDS 修改后 len 长度大于 1M,那么程序将分配 1M 的未使用空间。

  • 惰性空间释放

当对 SDS 进行缩短操作时,程序并不会回收多余的内存空间,而是使用 free 字段将这些字节数量记录下来不释放,后面如果需要 append 操作,则直接使用 free 中未使用的空间,减少了内存的分配。

  • 二进制安全

在 Redis 中不仅可以存储 String 类型的数据,也可能存储一些二进制数据。

二进制数据并不是规则的字符串格式,其中会包含一些特殊的字符如 '\0',在 C 中遇到 '\0' 则表示字符串的结束,但在 SDS 中,标志字符串结束的是 len 属性。 

3)zipList 压缩列表

压缩列表是 List 、hash、 sorted Set 三种数据类型底层实现之一。

当一个列表只有少量数据的时候,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表键的底层实现。

ziplist 是由一系列特殊编码的连续内存块组成的顺序型的数据结构,ziplist 中可以包含多个 entry 节点,每个节点可以存放整数或者字符串。

ziplist 在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表占用字节数、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。

struct ziplist<T> {
    int32 zlbytes; // 整个压缩列表占用字节数
    int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength; // 元素个数
    T[] entries; // 元素内容列表,挨个挨个紧凑存储
    int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}

 如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N)

4)linkedlist双端列表

Redis List 数据类型通常被用于队列、微博关注人时间轴列表等场景。不管是先进先出的队列,还是先进后出的栈,双端列表都很好的支持这些特性。

Redis 的链表实现的特性可以总结如下:

  • 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的复杂度都是 O(1)。
  • 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点。
  • 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表尾节点的复杂度为 O(1)。
  • 带链表长度计数器:程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,程序获取链表中节点数量的复杂度为 O(1)。
  • 多态:链表节点使用 void* 指针来保存节点值,并且可以通过 list 结构的 dup、free、match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。

quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来。

 

 

 这也是为何 Redis 快的原因,不放过任何一个可以提升性能的细节。

5)skipList 跳跃表

sorted set 类型的排序功能便是通过「跳跃列表」数据结构来实现。

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

跳跃表支持平均 O(logN)、最坏 O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。

跳表在链表的基础上,增加了多层级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:

 

 当需要查找 40 这个元素需要经历 三次查找。

6)整数数组(intset)

当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。结构如下:

typedef struct intset{
     //编码方式
     uint32_t encoding;
     //集合包含的元素数量
     uint32_t length;
     //保存元素的数组
     int8_t contents[];
}intset;

contents 数组是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。length 属性记录了整数集合包含的元素数量,也即是 contents 数组的长度。

7)合理的数据编码

Redis 使用对象(redisObject)来表示数据库中的键值,当我们在 Redis 中创建一个键值对时,至少创建两个对象,一个对象是用做键值对的键对象,另一个是键值对的值对象。

例如我们执行 SET MSG XXX 时,键值对的键是一个包含了字符串“MSG“的对象,键值对的值对象是包含字符串"XXX"的对象。

redisObject

typedef struct redisObject{
    //类型
   unsigned type:4;
   //编码
   unsigned encoding:4;
   //指向底层数据结构的指针
   void *ptr;
    //...
 }robj;

其中 type 字段记录了对象的类型,包含字符串对象、列表对象、哈希对象、集合对象、有序集合对象。

对于每一种数据类型来说,底层的支持可能是多种数据结构,什么时候使用哪种数据结构,这就涉及到了编码转化的问题。

那我们就来看看,不同的数据类型是如何进行编码转化的:

(1)String:存储数字的话,采用 int 类型的编码,如果是非数字的话,采用 raw 编码;

(2)List:List 对象的编码可以是 ziplist 或 linkedlist,字符串长度 < 64 字节且元素个数 < 512 使用 ziplist 编码,否则转化为 linkedlist 编码;

注意:这两个条件是可以修改的,在 redis.conf 中:

list-max-ziplist-entries 512
list-max-ziplist-value 64

(3)Hash:Hash 对象的编码可以是 ziplist 或 hashtable。

当 Hash 对象同时满足以下两个条件时,Hash 对象采用 ziplist 编码:

  • Hash 对象保存的所有键值对的键和值的字符串长度均小于 64 字节。
  • Hash 对象保存的键值对数量小于 512 个。

否则就是 hashtable 编码。

(4)Set:Set 对象的编码可以是 intset 或 hashtable,intset 编码的对象使用整数集合作为底层实现,把所有元素都保存在一个整数集合里面。

保存元素为整数且元素个数小于一定范围使用 intset 编码,任意条件不满足,则使用 hashtable 编码;

(5)Zset:Zset 对象的编码可以是 ziplist 或 zkiplist,当采用 ziplist 编码存储时,每个集合元素使用两个紧挨在一起的压缩列表来存储。

Ziplist 压缩列表第一个节点存储元素的成员,第二个节点存储元素的分值,并且按分值大小从小到大有序排列。

当 Zset 对象同时满足一下两个条件时,采用 ziplist 编码:

  • Zset 保存的元素个数小于 128。
  • Zset 元素的成员长度都小于 64 字节。

如果不满足以上条件的任意一个,ziplist 就会转化为 zkiplist 编码。注意:这两个条件是可以修改的,在 redis.conf 中:

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

9.2.3 单线程模型

“65 哥:为什么 Redis 是单线程的而不用多线程并行执行充分利用 CPU 呢?”

我们要明确的是:Redis 的单线程指的是 Redis 的网络 IO 以及键值对指令读写是由一个线程来执行的。 对于 Redis 的持久化、集群数据同步、异步删除等都是其他线程执行。

至于为啥用单线程,我们先了解多线程有什么缺点。

1)多线程的弊端

使用多线程,通常可以增加系统吞吐量,充分利用 CPU 资源。

但是,使用多线程后,没有良好的系统设计,可能会出现如下图所示的场景,增加了线程数量,前期吞吐量会增加,再进一步新增线程的时候,系统吞吐量几乎不再新增,甚至会下降!

在运行每个任务之前,CPU 需要知道任务在何处加载并开始运行。也就是说,系统需要帮助它预先设置 CPU 寄存器和程序计数器,这称为 CPU 上下文。

这些保存的上下文存储在系统内核中,并在重新计划任务时再次加载。这样,任务的原始状态将不会受到影响,并且该任务将看起来正在连续运行。

切换上下文时,我们需要完成一系列工作,这是非常消耗资源的操作。

另外,当多线程并行修改共享数据的时候,为了保证数据正确,需要加锁机制就会带来额外的性能开销,面临的共享资源的并发访问控制问题。

引入多线程开发,就需要使用同步原语来保护共享资源的并发读写,增加代码复杂度和调试难度。

2)单线程又有什么好处

  1. 不会因为线程创建导致的性能消耗;
  2. 避免上下文切换引起的 CPU 消耗,没有多线程切换的开销;
  3. 避免了线程之间的竞争问题,比如添加锁、释放锁、死锁等,不需要考虑各种锁问题。
  4. 代码更清晰,处理逻辑简单。

单线程是否没有充分利用 CPU 资源呢?

官方答案:因为 Redis 是基于内存的操作,CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。原文地址:https://redis.io/topics/faq。

9.2.4 I/O 多路复用模型

Redis 采用 I/O 多路复用技术,并发处理连接。采用了 epoll + 自己实现的简单的事件框架。epoll 中的读、写、关闭、连接都转化成了事件,然后利用 epoll 的多路复用特性,绝不在 IO 上浪费一点时间。

“65 哥:那什么是 I/O 多路复用呢?”

在解释 IO 多虑复用之前我们先了解下基本 IO 操作会经历什么。

1)基本 IO 模型

一个基本的网络 IO 模型,当处理 get 请求,会经历以下过程:

  1. 和客户端建立建立 accept;
  2. 从 socket 种读取请求 recv;
  3. 解析客户端发送的请求 parse;
  4. 执行 get 指令;
  5. 响应客户端数据,也就是 向 socket 写回数据。

其中,bind/listen、accept、recv、parse 和 send 属于网络 IO 处理,而 get 属于键值数据操作。既然 Redis 是单线程,那么,最基本的一种实现是在一个线程中依次执行上面说的这些操作。

关键点就是 accept 和 recv 会出现阻塞,当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。

类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。

 

 阻塞的原因由于使用传统阻塞 IO ,也就是在执行 read、accept 、recv 等网络操作会一直阻塞等待。如下图所示:

 

2) IO 多路复用

多路指的是多个 socket 连接,复用指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll。epoll 是最新的也是目前最好的多路复用技术。

它的基本原理是,内核不是监视应用程序本身的连接,而是监视应用程序的文件描述符。

当客户端运行时,它将生成具有不同事件类型的套接字。在服务器端,I / O 多路复用程序(I / O 多路复用模块)会将消息放入队列(也就是 下图的 I/O 多路复用程序的 socket 队列),然后通过文件事件分派器将其转发到不同的事件处理器。

简单来说:Redis 单线程情况下,内核会一直监听 socket 上的连接请求或者数据请求,一旦有请求到达就交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的事件处理器。所以 Redis 一直在处理事件,提升 Redis 的响应性能。

 

 Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。

9.2.5 唯快不破的原理总结

“65 哥:学完之后我终于知道 Redis 为何快的本质原因了,「码哥」你别说话,我来总结!一会我再点赞和分享这篇文章,让更多人知道 Redis 快的核心原理。”

  1. 纯内存操作,一般都是简单的存取操作,线程占用的时间很多,时间的花费主要集中在 IO 上,所以读取速度快。
  2. 整个 Redis 就是一个全局 哈希表,他的时间复杂度是 O(1),而且为了防止哈希冲突导致链表过长,Redis 会执行 rehash 操作,扩充 哈希桶数量,减少哈希冲突。并且防止一次性 重新映射数据过大导致线程阻塞,采用 渐进式 rehash。巧妙的将一次性拷贝分摊到多次请求过程后总,避免阻塞。
  3. Redis 使用的是非阻塞 IO:IO 多路复用,使用了单线程来轮询描述符,将epoll的开、关、读、写都转换成了事件,Redis 采用自己实现的事件分离器,效率比较高。
  4. 采用单线程模型,保证了每个操作的原子性,也减少了线程的上下文切换和竞争。
  5. Redis 全程使用 hash 结构,读取速度快,还有一些特殊的数据结构,对数据存储进行了优化,如压缩表,对短数据进行压缩存储,再如,跳表,使用有序的数据结构加快读取的速度。
  6. 根据实际存储的数据类型选择不同编码

9.3 那为什么Redis6.0之后又改用多线程呢?

redis使用多线程并非是完全摒弃单线程,redis还是使用单线程模型来处理客户端的请求,只是使用多线程来处理数据的读写和协议解析,执行命令还是使用单线程。

这样做的目的是因为redis的性能瓶颈在于网络IO而非CPU,使用多线程能提升IO读写的效率,从而整体提高redis的性能。

9.4 知道什么是热key吗?热key问题怎么解决?

所谓热key问题就是,突然有几十万的请求去访问redis上的某个特定key,那么这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis的服务器宕机引发雪崩。

针对热key的解决方案:

  • 提前把热key打散到不同的服务器,降低压力
  • 加入二级缓存,提前加载热key数据到内存中,如果redis宕机,走内存查询

如何发现热key

用redis自带命令、

 

如何解决热key

redis中的数据是一个定时任务(3min执行一次,缓存前20页)异步请求第三方服务,更新到redis的,未登录的情况下,理论上请求不会到第三方服务,都会命中redis且没有逻辑过期。我们对redis热key拼接上0~19的数字,将一个热key转化为20个key,每次请求的时候,生成一个0~19的随机数,这样就能将热key打散,避免redis热key的问题。定时更新redis的时候,更新20个key,请求时redisKey重新设计为apiName_page_size_language_randomNum

9.5 缓存击穿

缓存击穿的概念就是单个key并发访问过高,过期时导致所有请求直接打到db上,这个和热key的问题比较类似,只是说的点在于过期导致请求全部打到DB上而已。

解决方案:

  • 加锁更新,比如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写入缓存,再返回给用户,这样后面的请求就可以从缓存中拿到数据了。
  • 将过期时间组合写在value中,通过异步的方式不断的刷新过期时间,防止此类现象。

9.6 缓存穿透

缓存穿透是指查询不存在缓存中的数据,每次请求都会打到DB,就像缓存不存在一样。

针对这个问题,加一层布隆过滤器。布隆过滤器的原理是在你存入数据的时候,会通过散列函数将它映射为一个位数组中的K个点,同时把他们置为1。

这样当用户再次来查询A,而A在布隆过滤器值为0,直接返回,就不会产生击穿请求打到DB了。

显然,使用布隆过滤器之后会有一个问题就是误判,因为它本身是一个数组,可能会有多个值落到同一个位置,那么理论上来说只要我们的数组长度够长,误判的概率就会越低,这种问题就根据实际情况来就好了。

9.7 缓存雪崩

当某一时刻发生大规模的缓存失效的情况,比如你的缓存服务宕机了,会有大量的请求进来直接打到DB上,这样可能导致整个系统的崩溃,称为雪崩。雪崩和击穿、热key的问题不太一样的是,他是指大规模的缓存都过期失效了。

针对雪崩几个解决方案:

  • 针对不同key设置不同的过期时间,避免同时过期
  • 限流,如果redis宕机,可以限流,避免同时刻大量请求打崩DB
  • 二级缓存,同热key的方案。

9.8 Redis的过期策略有哪些?

redis主要有2种过期删除策略。

9.8.1 惰性删除

惰性删除指的是当我们查询key的时候才对key进行检测,如果已经达到过期时间,则删除。显然,他有一个缺点就是如果这些过期的key没有被访问,那么他就一直无法被删除,而且一直占用内存。

9.8.2 定期删除

定期删除指的是redis每隔一段时间对数据库做一次检查,删除里面的过期key。由于不可能对所有key去做轮询来删除,所以redis会每次随机取一些key去做检查和删除。

9.8.3 缓存淘汰

那么定期+惰性都没有删除过期的key怎么办?

假设redis每次定期随机查询key的时候没有删掉,这些key也没有做查询的话,就会导致这些key一直保存在redis里面无法被删除,这时候就会走到redis的内存淘汰机制。

  • volatile-lru:从已设置过期时间的key中,移出最近最少使用的key进行淘汰
  • volatile-ttl:从已设置过期时间的key中,移出将要过期的key
  • volatile-random:从已设置过期时间的key中随机选择key淘汰
  • allkeys-lru:从key中选择最近最少使用的进行淘汰
  • allkeys-random:从key中随机选择key进行淘汰
  • noeviction:当内存达到阈值的时候,新写入操作报错

补充一下:Redis4.0加入了LFU(least frequency use)淘汰策略,包括volatile-lfu和allkeys-lfu,通过统计访问频率,将访问频率最少,即最不经常使用的KV淘汰。

1)LRU

Java中的LRU实现方式

在Java中LRU的实现方式是使用HashMap结合双向链表,HashMap的值是双向链表的节点,双向链表的节点也保存一份key value。

  • 新增key value的时候首先在链表结尾添加Node节点,如果超过LRU设置的阈值就淘汰队头的节点并删除掉HashMap中对应的节点。
  • 修改key对应的值的时候先修改对应的Node中的值,然后把Node节点移动队尾。
  • 访问key对应的值的时候把访问的Node节点移动到队尾即可。

Redis中LRU的实现

  • Redis维护了一个24位时钟,可以简单理解为当前系统的时间戳,每隔一定时间会更新这个时钟。每个key对象内部同样维护了一个24位的时钟,当新增key对象的时候会把系统的时钟赋值到这个内部对象时钟。比如我现在要进行LRU,那么首先拿到当前的全局时钟,然后再找到内部时钟与全局时钟距离时间最久的(差最大)进行淘汰,这里值得注意的是全局时钟只有24位,按秒为单位来表示才能存储194天,所以可能会出现key的时钟大于全局时钟的情况,如果这种情况出现那么就两个相加而不是相减来求最久的key。
struct redisServer {
       pid_t pid; 
       char *configfile; 
       //全局时钟
       unsigned lruclock:LRU_BITS; 
       ...
};
typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    /* key对象内部时钟 */
    unsigned lru:LRU_BITS;
    int refcount;
    void *ptr;
} robj;
  • Redis中的LRU与常规的LRU实现并不相同,常规LRU会准确的淘汰掉队头的元素,但是Redis的LRU并不维护队列,只是根据配置的策略要么从所有的key中随机选择N个(N可以配置)要么从所有的设置了过期时间的key中选出N个键,然后再从这N个键中选出最久没有使用的一个key进行淘汰。
  • 下图是常规LRU淘汰策略与Redis随机样本取一键淘汰策略的对比,浅灰色表示已经删除的键,深灰色表示没有被删除的键,绿色表示新加入的键,越往上表示键加入的时间越久。从图中可以看出,在redis 3中,设置样本数为10的时候能够很准确的淘汰掉最久没有使用的键,与常规LRU基本持平。

2)LFU

LFU是在Redis4.0后出现的,LRU的最近最少使用实际上并不精确,考虑下面的情况,如果在|处删除,那么A距离的时间最久,但实际上A的使用频率要比B频繁,所以合理的淘汰策略应该是淘汰B。LFU就是为应对这种情况而生的。

A~~A~~A~~A~~A~~A~~A~~A~~A~~A~~~|
B~~~~~B~~~~~B~~~~~B~~~~~~~~~~~B|
LFU把原来的key对象的内部时钟的24位分成两部分,前16位还代表时钟,后8位代表一个计数器。16位的情况下如果还按照秒为单位就会导致不够用,所以一般这里以时钟为单位。而后8位表示当前key对象的访问频率,8位只能代表255,但是redis并没有采用线性上升的方式,而是通过一个复杂的公式,通过配置两个参数来调整数据的递增速度。
下图从左到右表示key的命中次数,从上到下表示影响因子,在影响因子为100的条件下,经过10M次命中才能把后8位值加满到255.
# +--------+------------+------------+------------+------------+------------+
# | factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
# +--------+------------+------------+------------+------------+------------+
# | 0      | 104        | 255        | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 1      | 18         | 49         | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 10     | 10         | 18         | 142        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 100    | 8          | 11         | 49         | 143        | 255        |
# +--------+------------+------------+------------+------------+------------+
  uint8_t LFULogIncr(uint8_t counter) {
      if (counter == 255) return 255;
      double r = (double)rand()/RAND_MAX;
      double baseval = counter - LFU_INIT_VAL;
      if (baseval < 0) baseval = 0;
      double p = 1.0/(baseval*server.lfu_log_factor+1);
      if (r < p) counter++;
      return counter;
  }
lfu-log-factor 10
lfu-decay-time 1
  • 上面说的情况是key一直被命中的情况,如果一个key经过几分钟没有被命中,那么后8位的值是需要递减几分钟,具体递减几分钟根据衰减因子lfu-decay-time来控制
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;
    unsigned long counter = o->lru & 255;
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}
lfu-log-factor 10
lfu-decay-time 1
  • 上面递增和衰减都有对应参数配置,那么对于新分配的key呢?如果新分配的key计数器开始为0,那么很有可能在内存不足的时候直接就给淘汰掉了,所以默认情况下新分配的key的后8位计数器的值为5(应该可配资),防止因为访问频率过低而直接被删除。
  • 低8位我们描述完了,那么高16位的时钟是用来干嘛的呢?目前我的理解是用来衰减低8位的计数器的,就是根据这个时钟与全局时钟进行比较,如果过了一定时间(做差)就会对计数器进行衰减。

9.9 持久化方式有哪些?有什么区别?

redis持久化方案分为RDB和AOF两种。

9.9.1 RDB

RDB持久化可以手动执行也可以根据配置定期执行,它的作用是将某个时间点上的数据库状态保存到RDB文件中,RDB文件是一个压缩的二进制文件,通过它可以还原某个时刻数据库的状态。由于RDB文件是保存在硬盘上的,所以即使redis崩溃或者退出,只要RDB文件存在,就可以用它来恢复还原数据库的状态。

可以通过SAVE或者BGSAVE来生成RDB文件。

SAVE命令会阻塞redis进程,直到RDB文件生成完毕,在进程阻塞期间,redis不能处理任何命令请求,这显然是不合适的。

BGSAVE则是会fork出一个子进程,然后由子进程去负责生成RDB文件,父进程还可以继续处理命令请求,不会阻塞进程。

9.9.2 AOF

AOF和RDB不同,AOF是通过保存redis服务器所执行的写命令来记录数据库状态的。

AOF通过追加、写入、同步三个步骤来实现持久化机制。

  • 当AOF持久化处于激活状态,服务器执行完写命令之后,写命令将会被追加append到aof_buf缓冲区的末尾
  • 在服务器每结束一个事件循环之前,将会调用flushAppendOnlyFile函数决定是否要将aof_buf的内容保存到AOF文件中,可以通过配置appendfsync来决定。
always ##aof_buf内容写入并同步到AOF文件
everysec ##将aof_buf中内容写入到AOF文件,如果上次同步AOF文件时间距离现在超过1秒,则再次对AOF文件进行同步
no ##将aof_buf内容写入AOF文件,但是并不对AOF文件进行同步,同步时间由操作系统决定

如果不设置,默认选项将会是everysec,因为always来说虽然最安全(只会丢失一次事件循环的写命令),但是性能较差,而everysec模式只不过会可能丢失1秒钟的数据,而no模式的效率和everysec相仿,但是会丢失上次同步AOF文件之后的所有写命令数据。

9.10 怎么实现Redis的高可用

要想实现高可用,一台机器肯定是不够的,而redis要保证高可用,有2个可选方案。

9.10.1 主从架构

主从模式是最简单的实现高可用的方案,核心就是主从同步。主从同步的原理如下:

  • slave发送sync命令到master
  • master收到sync之后,执行bgsave,生成RDB全量文件
  • master把slave的写命令记录到缓存
  • bgsave执行完毕之后,发送RDB文件到slave,slave执行
  • master发送缓存中的写命令到slave,slave执行

这里我写的这个命令是sync,但是在redis2.8版本之后已经使用psync来替代sync了,原因是sync命令非常消耗系统资源,而psync的效率更高。

9.10.2 哨兵

基于主从方案的缺点还是很明显的,假设master宕机,那么就不能写入数据,那么slave也就失去了作用,整个架构就不可用了,除非你手动切换,主要原因就是因为没有自动故障转移机制。而哨兵(sentinel)的功能比单纯的主从架构全面的多了,它具备自动故障转移、集群监控、消息通知等功能。

 

 

哨兵可以同时监视多个主从服务器,并且在被监视的master下线时,自动将某个slave提升为master,然后由新的master继续接收命令。整个过程如下:

  • 初始化sentinel,将普通的redis代码替换成sentinel专用代码
  • 初始化masters字典和服务器信息,服务器信息主要保存ip:port,并记录实例的地址和ID
  • 创建和master的两个连接,命令连接和订阅连接,并且订阅sentinel:hello频道
  • 每隔10秒向master发送info命令,获取master和它下面所有slave的当前信息
  • 当发现master有新的slave之后,sentinel和新的slave同样建立两个连接,同时每个10秒发送info命令,更新master信息
  • sentinel每隔1秒向所有服务器发送ping命令,如果某台服务器在配置的响应时间内连续返回无效回复,将会被标记为下线状态
  • 选举出领头sentinel,领头sentinel需要半数以上的sentinel同意
  • 领头sentinel从已下线的的master所有slave中挑选一个,将其转换为master
  • 让所有的slave改为从新的master复制数据
  • 将原来的master设置为新的master的从服务器,当原来master重新回复连接时,就变成了新master的从服务器

sentinel会每隔1秒向所有实例(包括主从服务器和其他sentinel)发送ping命令,并且根据回复判断是否已经下线,这种方式叫做主观下线。当判断为主观下线时,就会向其他监视的sentinel询问,如果超过半数的投票认为已经是下线状态,则会标记为客观下线状态,同时触发故障转移。

9.11 怎么实现Redis的高并发(能说说redis集群的原理吗?)

如果说依靠哨兵可以实现redis的高可用,如果还想在支持高并发同时容纳海量的数据,那就需要redis集群。redis集群是redis提供的分布式数据存储方案,集群通过数据分片sharding来进行数据的共享,同时提供复制和故障转移的功能。

9.11.1 节点

一个redis集群由多个节点node组成,而多个node之间通过cluster meet命令来进行连接,节点的握手过程:

  • 节点A收到客户端的cluster meet命令
  • A根据收到的IP地址和端口号,向B发送一条meet消息
  • 节点B收到meet消息返回pong
  • A知道B收到了meet消息,返回一条ping消息,握手成功
  • 最后,节点A将会通过gossip协议把节点B的信息传播给集群中的其他节点,其他节点也将和B进行握手

9.11.2 槽slot

redis通过集群分片的形式来保存数据,整个集群数据库被分为16384个slot,集群中的每个节点可以处理0-16384个slot,当数据库16384个slot都有节点在处理时,集群处于上线状态,反之只要有一个slot没有得到处理都会处理下线状态。通过cluster addslots命令可以将slot指派给对应节点处理。

slot是一个位数组,数组的长度是16384/8=2048,而数组的每一位用1表示被节点处理,0表示不处理,如图所示的话表示A节点处理0-7的slot。

 

 slot是一个位数组,数组的长度是16384/8=2048,而数组的每一位用1表示被节点处理,0表示不处理,如图所示的话表示A节点处理0-7的slot。

当客户端向节点发送命令,如果刚好找到slot属于当前节点,那么节点就执行命令,反之,则会返回一个MOVED命令到客户端指引客户端转向正确的节点。(MOVED过程是自动的)

 

 如果增加或者移出节点,对于slot的重新分配也是非常方便的,redis提供了工具帮助实现slot的迁移,整个过程是完全在线的,不需要停止服务。

9.11.3 故障转移

如果节点A向节点B发送ping消息,节点B没有在规定的时间内响应pong,那么节点A会标记节点B为pfail疑似下线状态,同时把B的状态通过消息的形式发送给其他节点,如果超过半数以上的节点都标记B为pfail状态,B就会被标记为fail下线状态,此时将会发生故障转移,优先从复制数据较多的从节点选择一个成为主节点,并且接管下线节点的slot,整个过程和哨兵非常类似,都是基于Raft协议做选举。

9.12 了解Redis事务机制吗?

redis通过MULTI、EXEC、WATCH等命令来实现事务机制,事务执行过程将一系列多个命令按照顺序一次性执行,并且在执行期间,事务不会被中断,也不会去执行客户端的其他请求,直到所有命令执行完毕。事务的执行过程如下:

  • 服务端收到客户端请求,事务以MULTI开始
  • 如果客户端正处于事务状态,则会把事务放入队列同时返回给客户端QUEUED,反之则直接执行这个命令
  • 当收到客户端EXEC命令时,WATCH命令监视整个事务中的key是否有被修改,如果有则返回空回复到客户端表示失败,否则redis会遍历整个事务队列,执行队列中保存的所有命令,最后返回结果给客户端

WATCH的机制本身是一个CAS的机制,被监视的key会被保存到一个链表中,如果某个key被修改,那么REDIS_DIRTY_CAS标志将会被打开,这时服务器会拒绝执行事务。

 

99. 直接读这些牛人的原文

民工哥技术之路:史上最全、最新的Redis面试题(2020最新版)

《我想进大厂》之Redis夺命连环11问

表弟面试被虐,我教他缓存连招,借机蹭了波奈雪的茶

 

关于Redis缓存,这三个问题一定要知道!

面试官:缓存一致性问题怎么解决

酷壳:缓存更新的套路

没人告诉过你更复杂的缓存穿透怎么解决

Redis缓存穿透、缓存击穿、热key问题优化 + 内存缓存

Redis缓存使用中的热key问题

如果20万用户同时访问一个热点缓存,如何优化你的缓存架构?

一个牛逼的 多级缓存 实现方案!

 

如何从0到1构建一个稳定、高性能的Redis集群?(附16张图解)

Redis 核心篇:唯快不破的秘密

Redis 日志篇:无畏宕机快速恢复的杀手锏

阿里面试这样问:redis 为什么把简单的字符串设计成 SDS?

高并发系统三大利器之缓存

面试时说Redis是单线程的,被喷惨了!

Redis的Java客户端Jedis的八种调用方式(事务、管道、分布式…)介绍

Redis Pipeline原理分析

超硬核!1.6W 字 Redis 面试知识点总结,建议收藏!

万字详解本地缓存之王 Caffeine 的高性能设计之道!

用 Redis 实现一个轻量级的搜索引擎

zset的底层数据结构跳表

Redis的最常被问到知识点总结

Redis 的 8 大应用场景

 

posted @ 2021-05-13 23:22  沙漏哟  阅读(151)  评论(0编辑  收藏  举报