走近科学之《Redis 的秘密》之精益求精
redis 是一个用 C/C++ 开发的开源、高性能、高并发、键值对的 Nosql 内存数据库。可用作缓存、数据库、消息中间件等。
memcached 是早些年各大互联网公司常用的缓存方案,redis 后来居上。
redis 主要支持 string、hash、list、set、sorted set 这几种数据类型。(关于这几种数据类型的具体操作命令可查看菜鸟教程,有各种骚操作哦)
string 是最简单的数据类型,字符串,做最简单的 k v 缓存,普通的 set get 操作。字符串类型的值最大存储 512M 的内容。
set key value # 存储
get key # 查看
eg: set zed 瞬狱影杀阵
get zed
hash 类似于 map 的数据类型,一般可以将结构化的数据放进 redis,比如对象(前提是这个对象没有嵌套其它对象),每次读写缓存的时候可以操作对象的某个属性。每个 hash 可以存储 2^32 - 1 个键值对(40 多亿)。
hset key field value field value # 存储
hgetall key # 查看
eg: hset zed Q q W q E e
hgetall zed
list 是有序可重复列表,可以存储一些类似于列表的数据结构,如用户列表、粉丝列表、评论列表等。
可以利用 pop 命令做消息队列,从 list 头进去,从尾巴出来。
可以利用 lrange 命令读取某个闭区间的元素,如基于 list 的缓存分页查询,比如 B 站评论下拉不断分页的功能。每个 key 可存储 2^32 - 1 个元素。
lpush key value value value # 存储
lrange key startindex endindex # 查看
eg: lpush mid zed fizz ahri riven
lrange mid 0 -1 # 0 表示开始元素位置,-1表示结束元素位置
lrange mid 2 3
set 是无序去重的数据类型,如系统中某些数据需要去重则可以使用它。当服务是单节点时可以使用 HashSet 来实现,但当服务是多节点部署时就可以考虑使用 redis 的 set 数据类型。每个 key 可存储 2^32 - 1 个元素。
而且可以基于 set 玩儿两个集合的交集、并集、差集等,如看两个 up 的共同好友、共同粉丝等。
sadd key value value # 存储
smembers key # 查看
sorted set 时有序去重数据类型,在 set 的基础上做了排序。存储时可以给元素设置排序序号(double 类型),会自动根据序号进行排序。每个 key 可存储 2^32 - 1 个元素。
zadd key index value # 存储,其中 value 表示该元素的排序位置
zrange key startindex endindex withscores # 查看指定索引间的元素
eg: zadd mid 1 zed
zadd mid 2 fizz
zadd mid 3 ahri
zrange mid 0 -1 withscores
简介:
bitmap 是 redis 中的一种存储机制或表示机制,并不是一种数据结构,实际上就是字符串,但是可以对字符串的位进行操作。
可以把 bitmap 想象成一个 bit 数组,数组的每个元素的值只能是 0 或 1,数组的下标叫做偏移量。
每个 bitmap 中最大可以存储 512M 的内容,512 * 1024 * 1024 * 8 = 2 ^ 32 bit,也就是一个 bitmap 中最多可以存放四十二亿多个值。
如上图所示,数字 0、5、16、27 在 bitmap 中的表示,实际上设置命令为 setbit momo 0/5/16/27 1,momo 为 key,0/5/16/27 表示 offset,1 为值。
命令:
# setbit key offset value
setbit momo 24 1 # 设置 key 为 momo 偏移量为 24 位置的值为 1
# getbit key offset
getbit momo 24 # 获取 key 为 momo 偏移量为 24 位置的值,结果为 1
# bitcount key [start end]
bitcount momo # 统计 key 为 momo 中值为 1 的个数
bitcount momo 0 0 # 统计 key 为 momo 中 第一个位置到第八个位置上值为 1 的个数
适用场景:
bitmap 多用来表示状态值,如有没有、是与否、对与错、0 与 1、true 与 false 等。
bitmap 特点是读写速度快,可在有限的空间内容纳大量小数据。
redis 是单进程单线程的。
redis 内部使用的文件事件处理器(file event handler),这个文件事件处理器是单线程的,所以 redis 才是单线程的模型。采用非阻塞的 IO 多路复用机制,同时监听多个 socket,将产生事件的 socket 压入内存队列中,然后事件分派器会根据 socket 上的事件类型来选择相应的事件处理器进行处理。
文件事件处理器包含四个部分,分别是:多个 socket、IO 多路复用程序、事件分派器、事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)。
多个 socket 可能会并发的产生不同的操作,每个操作对应不同的事件,IO 多路复用程序会监听多个 socket,并将产生事件的 socket 放入内存队列排队,事件分派器每次从队列中取一个 socket,根据其事件类型交给对应的事件处理器进行处理器。
如图所示,客户端 socket01 向 redis 的 server socket 请求建立连接,此时 server socket 会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,会将其压入队列。事件分派器从队列中获取到该事件,并将其交给连接应答器处理,连接应答器会创建一个能与客户端通信的 socket01,并将 socket01 的 AE_READABLE 事件与命令请求处理器关联。
假设此时客户端发送了一个 set key value 的请求,此时,redis 中的 socket01 会产生 AE_READABLE 事件,IO 多路复用程序会将该事件压入队列中。事件分派器从队列中取到该事件,由于前面已经将该事件与命令请求处理器关联,所以事件分派器会直接将其交给命令请求处理器处理。命令请求处理器读取 socket01 的 key value,并在自己内存设置 key value。操作完成后,它会将 socket01 的 AE_WRITABLE 事件与命令回复器关联。
如果此时客户端准备好接收返回结果了,那 redis 中的 socket01 会产生一个 AE_WRITABLE 事件,IO 多路复用程序将其压入队列。事件分派器从队列中取到事件,并交给命令回复处理器处理。命令回复处理器会对本次操作产生一个结果,比如 ok,将其发送到客户端,之后解除 socket01 的 AE_WRITABLE 事件与命令回复处理器的关联。
这样便完成了一次通信。
redis 的过期策略是 定期删除 + 惰性删除。
定期删除是指 redis 默认会每隔 100ms 随机抽取一些设置了过期时间的 key,检查其是否过期,若已过期则将其删除。
注意这里是随机抽取,并不是抽取所有设置了过期时间的 key。若 redis 里面存了 10w 个设置了过期时间的 key,那么一次定期删除可能直接就将 redis 干没了。
定期删除会造成很多过期了的 key 并没有被删除,于是就有了惰性删除。
惰性删除是指当客户端获取某个 key 时,redis 会先检查该 key 是否设置了过期时间,如果设置了则再检查其是否过期了,如果已过期,那么 redis 会将其删除,并不会返回给客户端任何东西。
惰性删除会造成长时间不被使用且没有定期删除删除掉的 key 依旧存在的情况,长期如此将会耗尽内存,于是就有了 内存淘汰机制。
// 代码不见啦
redis 提供了两种持久化方式,分别是 RDB(Redis Data Base)和 AOF(Append-only File)。
持久化主要是做灾难恢复、数据恢复,也是高可用的一种方案。如当 redis 宕机重启后,可通过持久化产生的文件恢复宕机前 redis 中存储的数据。
RDB 持久化机制是对 redis 中的数据执行周期性的持久化。
RDB 会生成多个数据文件,每个文件都代表了某一时刻 redis 中的数据,这种多个数据文件的方式,非常适合做冷备,可以将数据文件发送到安全稳定的云服务上存储,已预定好的策略来定期备份 redis 中的数据。
RDB 对 redis 对外提供读写服务的影响非常小,也就是不会影响 redis 的高性能。因为 redis 只需要 fork 一个子进程,让子进程来执行磁盘 IO 操作进行 RDB 数据持久化即可。
RDB 在每次 fork 子进程执行 RDB 快照数据文件生成的时候,如果数据特别大,则可能会导致 redis 对客户端提供的服务暂停数毫秒,甚至数秒。
AOF 持久化机制是将每条对 redis 数据操作的命令作为日志,以 append-only 的模式写入一个日志文件,在 redis 重启的时候,通过回放 AOF 日志文件中的指令来重新构建数据集。
AOF 可以更好的保护数据不丢失,一般 AOF 会每隔 1 秒,通过一个后台线程执行一次 fsync 操作,最多丢失一秒钟的数据。
AOF 日志文件以 append-only 的模式写入,所以没有任何磁盘寻址的开销,写入性能非常高,而且文件不易破损,即使文件尾部破损,也很容易修复。
AOF 日志文件过大时,会出现后台重写的操作,且不会对客户端的读写造成影响。因为在 rewrite log 的时候,会对指令进行压缩,创建出一份恢复数据的最小日志文件出来。在创建新日志文件的时候,老日志文件还是照常写入,当新的 merge 后日志文件 ready 的时候,再交换新老日志文件即可。
AOF 日志文件通过非常可读的方式进行记录,这个特性非常适合做灾难性误删的紧急恢复。比如某位小伙伴不小心用 flushall 命令清空了所有的数据,只要这个时候后台 rewrite log 还没发生,那么就可以立即拷贝 AOF 文件,将最后一条 flushall 命令删掉,然后再将该文件放回去,就可以通过恢复机制,自动恢复所有数据。
相对于 AOF 来说,直接基于 RDB 数据文件来重启和恢复 redis 进程,会更加快速。如果想要在 redis 故障时,尽可能少的丢失数据,那么 AOF 要优于 RDB。
一般来说,RDB 数据快照文件都是每隔 5 分钟,或者更长时间生成一次,这时候就得接收如果 redis 宕机,那么可能会丢失将近 5 分钟的数据。
AOF 开启后,支持的客户端的写 QPS 将略低于 RDB 支持的客户端的写 QPS,因为 AOF 一般会配成每秒 fsync 一次日志文件,多少都会影响客户端写。当然,每秒一次 fsync,性能还是很高的,如果是实时 fsync,那写的 QPS 会大降。
仅使用 RDB,虽然简单粗暴来得快,将会丢失很多数据;仅使用 AOF,虽然数据完整,但恢复速度较慢。
建议两者结合使用,天下无敌!
redis 主要基于主从架构来实现高并发,基于哨兵模式来实现高可用。
redis 单机可达 10w QPS,但在很多业务场景下,10w 的 QPS 远远是不够的,可以通过增加 redis 节点的方式来提高其 QPS 能力,也就是主从架构。既然增加了节点,那就会存在某个/些节点宕机的可能,则可以通过哨兵模式来解决。
主从架构,即一主多从。一个主节点,多个从节点,一般主节点用来提供给写入服务,单机大几万 QPS;多从节点用来提供读取服务,多个从节点可提供 10w 的 QPS。
集群模式,如果一主多从依旧扛不住请求,或者想容纳大量的数据,那就可以考虑使用集群模式。redis 集群模式可以看成是多个主从架构的组合,在提供了更大并发量能力的同时,可以容纳更多的数据。集群之后可提供几十万的读写并发。
哨兵模式,可以为 redis 的主从架构提供高可用的保障。当主节点宕机后,它会从从节点中选择一个节点来作为主节点,即可以进行主备切换。实际上在集群模式下,高可用机制是基于哨兵模式实现的,所以说,redis 实现高可用的本质还是哨兵模式。
如果数据库和缓存同时使用,那就会涉及到数据库和缓存的双存储双写,只要是双写,就一定会存在数据一致性问题,也就是数据库数据与缓存数据不一致的情况。
一般情况下,可以通过请求串行化来解决,即将读请求和写请求串行到一个内存队列中去。
串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,可能需要用比正常情况下多几倍的机器来满足线上的并发量。所以,如果可以允许缓存跟数据库稍微偶尔的有不一致的情况,也就是系统不是严格要求 “缓存 + 数据库” 必须保持一致的话,最好不要做这个方案。
CAP 即 Cache Aside Pattern,也就是最经典的 缓存 + 数据库 的读写模式。
读的时候,先读缓存,若缓存没有,再读数据库,取到数据后放入缓存,同时返回响应。
更新的时候,先更新数据库,然后删除缓存。
因为在很多时候,复杂业务场景下,缓存中的数据不单单是直接从数据库中取出来的。
比如有些时候,缓存数据是根据数据库中多张表的多个字段经过复杂计算得来的,而你更新时只更新了涉及这个缓存的一个或几个字段。如果这时候再去更新还缓存的话,必然会产生查询其它字段以及重新计算的耗时。
另外,如果更新的这个字段涉及多个缓存数据,那就会产生更新多个缓存的代价。
其次,对应的缓存会不会被频繁访问到?假设一个缓存涉及的表字段,在 1 分钟内更新了几十次、几百次,那么缓存也会跟着更新几十次、几百次,但是这 1 分钟内该缓存只被访问了一次。但如果你删除缓存的话,那么 1 分钟内,这个缓存只不过重新计算一次而已。将开销降到最低。
实际上删除缓存,而不是更新缓存,就是一个 lazy 处理的思想。不要每次都做那么复杂的计算,或者更新好多遍缓存,而是在它被访问的时候再去计算更新。
问题描述:
先更新数据库,再删除缓存。如果删除缓存失败了,那么数据库中是新数据,缓存中是旧数据,就出现了数据不一致的问题。
比如在库存服务中,假设此时库存 1000 个,一个减库存的请求过来,数据库中库存更新为 999,然后删除缓存失败了,此时数据库中库存为 999,缓存中对应的库存为 1000,就出现了数据不一致。
解决方案:
先删除缓存,再更新数据库。删除缓存后,再去更新数据库,如果更新数据库失败了,则数据库中为旧数据,但缓存中是空的。假设此时一个请求过来,先访问缓存,发现是空的,然后访问数据库,从数据库获取到数据,更新到缓存。仅仅只是数据没有更新成功,并不会出现不一致的问题。
问题描述:
先删除缓存,再更新数据库。先删除了缓存,再去更新数据库,假设此时一个请求过来,先访问缓存,发现缓存为空,则去访问数据库,此时数据库还没有更新完成,所以取到了旧数据,然后将其添加到缓存。随后数据库更新也完成了,就造成了数据库中为新数据,而缓存中为旧数据,数据不一致了。
只有在高并发场景下,才可能会出现这样的问题。如果并发量很低,特别是读并发量低,那么只会在极少情况下会出现不一致问题。但是如果你并发量很高,上亿流量,每秒并发读几万,那么一秒内只要有数据更新请求,就有可能会出现不一致问题。
解决方案:
请求串行化,即更新数据时,根据数据的唯一标识,操作路由之后,将更新操作放到一个 jvm 内存队列中去。读取数据时,如果缓存中没有,则将 读取数据库 + 更新缓存的操作,也根据唯一标识路由之后,将操作放到同一个 jvm 内存队列中。
一个队列对应一个工作线程,每个工作线程一个一个的执行队列中的串行操作。这样的话,如果一个更新请求过来,先删除缓存,再更新数据库,假设更新数据库操作还没完成,来了一个获取数据的请求,发现缓存中没有,那将其访问数据库 + 更新缓存的操作放入队列,此时,工作线程会先执行完更新请求,轮到执行获取数据请求时,再从数据库获取,并更新到缓存,此时获取到的必然是新数据。
同时需要注意,如果读请求还在等待时间范围内,通过不断轮询取到值了,那就直接返回;如果请求等待超过一定时长,那么这一次直接从数据库中读取当前的旧值,以免阻塞时间过长,影响用户体验。
该解决方案需要注意的问题:
请求时长阻塞:
由于读请求进行了非常轻度的异步化(等待写请求执行完成),所以一定要注意读读超时问题,每个请求必须在超时时间范围内返回。
该解决方案,最大的风险点在于说,可能数据更新很频繁,导致队列中积压了大量更
新操作在里面,然后读请求会发生大量的超时,最后导致大量的请求直接走数据库。务必通过一些
模拟真实的测试,看看更新数据的频率是怎样的。
另外一点,因为一个队列中,可能会积压针对多个数据项的更新操作,因此需要根据自己的业务情
况进行测试,可能需要部署多个服务,每个服务分摊一些数据的更新操作。如果一个内存队列里居
然会挤压 100 个商品的库存修改操作,每隔库存修改操作要耗费 10ms 去完成,那么最后一个商品
的读请求,可能等待 10 *100 = 1000ms = 1s 后,才能得到数据,这个时候就导致读请求的长时阻
塞。
一定要做根据实际业务系统的运行情况,去进行一些压力测试,和模拟线上环境,去看看最繁忙的
时候,内存队列可能会挤压多少更新操作,可能会导致最后一个更新操作对应的读请求,会 hang
多少时间,如果读请求在 200ms 返回,如果你计算过后,哪怕是最繁忙的时候,积压 10 个更新操
作,最多等待 200ms,那还可以的。
如果一个内存队列中可能积压的更新操作特别多,那么你就要加机器,让每个机器上部署的服务实
例处理更少的数据,那么每个内存队列中积压的更新操作就会越少。
其实根据之前的项目经验,一般来说,数据的写频率是很低的,因此实际上正常来说,在队列中积
压的更新操作应该是很少的。像这种针对读高并发、读缓存架构的项目,一般来说写请求是非常少
的,每秒的 QPS 能到几百就不错了。
我们来实际粗略测算一下。
如果一秒有 500 的写操作,如果分成 5 个时间片,每 200ms 就 100 个写操作,放到 20 个内存队
列中,每个内存队列,可能就积压 5 个写操作。每个写操作性能测试后,一般是在 20ms 左右就完
成,那么针对每个内存队列的数据的读请求,也就最多 hang 一会儿,200ms 以内肯定能返回了。
经过刚才简单的测算,我们知道,单机支撑的写 QPS 在几百是没问题的,如果写 QPS 扩大了 10
倍,那么就扩容机器,扩容 10 倍的机器,每个机器 20 个队列。
读请求并发量过高:
需要经过实际压测,在突然大量读请求到来时,看服务能不能扛得住,需要多少机器才能最大限度的抗住极限峰值。
多服务部署的请求路由:
如果部署了多个服务,那么必须保证,数据更新的请求和缓存更新的请求,都通过 nginx 路由到相同的服务实例上。
比如,对于同一个商品的读写请求,全部路由到同一台机器上。可以做服务间的按照某个请求参数的 hash 路由,也可以用 nginx 的 hash 路由功能等。
热点商品路由导致请求倾斜:
假设某个商品的读写请求特别高,为热点商品,然后全部请求打到了相同机器的同一队列中了,可能会造成该机器的负载过高。
redis 雪崩指的是在高并发场景下,当 redis 中大量 key 同时失效(过期)或 redis 宕机,导致大量请求直接落到数据库上,从而导致数据库崩溃的情况。
解决方案:
用户发送一个请求,系统收到请求后,先查本地 ehcache 缓存,若没有再查 redis,若 redis 也没有则查数据库,若数据库中有,则将其结果写入 ehcache 和 redis 中。
限流组件,可以设置每秒钟到达系统的请求,有多少能通过组件,剩余的未通过的怎么办?走降级,可以返回一些默认值或友好提示,或空值。
这样设计的好处是,数据库绝对不会死,限流组件确保了每秒只有多少个请求能直接到达数据库。对于没有通过限流组件的请求,对用户来说,无非就是多点几次页面,多刷新几次而已。
缓存穿透指的是,在高并发场景下,每秒内到达服务器的请求,百分之八九十都是黑客发出的恶意攻击,这些攻击会 “穿过” redis,直指数据库,直接导致数据库崩溃。
比如数据库 id 都是从 1 开始的,黑客发出的请求的 id 都是负数,那 redis 中肯定没有,然后就直接打到了数据库,最终导致数据库崩溃。
解决方案:
每次请求从数据库中没有查到数据时,就写一个空值到缓存中去,且设置一个过期时间,这样的话,下次有相同 key 来访问时,在缓存失效之前,都可以从缓存中取到数据。
这种方式虽然简单,但在某些场景下显得不优雅,还可能会缓存过多空值,更加优雅的方式是使用 redis 布隆过滤器。
缓存击穿指的是,某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,在这个 key 失效的瞬间,大量请求会击穿缓存,直接落在数据库上,导致数据库崩溃。
解决方案:
缓存穿透重点在于 “透”,大量请求透过了缓存层;缓存击穿重点在于 “击”,一个或几个热点 key 直接击穿了缓存层。
并发竞争问题指的是在高并发场景下多个客户端同时读写 key 而造成数据错误的问题。比如多个客户端同时写 key,key 对应 value 的初始值 1,正常情况下 value 值的写顺序为 2、3、4,最后是 4,但并于并发竞争写,顺序变成了 2、4、3,最后 value 变成了 3。
解决方案:
场景描述:
缓存穿透指的是大量请求请求缓存中不存在的 key,由于没有命中缓存,所以大量请求直接打到数据库,导致数据库崩溃。
利用布隆过滤器解决:
事先将存在的 key 都放入 redis 布隆过器中,进行存在性检测。当请求达到时,先通过布隆过滤器检查其所请求的 key 存不存在,若布隆过滤器说没有,那就一定没有,数据库中也没有,直接返回;若说有,那就可能有,放行。
布隆过滤器可能会误判,放过部分实际 key 不存在的请求,但不影响整体,所以,其是处理此类问题的最佳方案。
如上图所示,整个流程展示了 redis bloom filter 解决缓存穿透的过程。目前,已经介绍了两种解决缓存穿透问题的方案,分别是缓存空值和布隆过滤器,而图中,蓝色部分是缓存空值的方案,在外层加上布隆过滤器就是布隆过滤器的反感了。
集群模式,也就是 redis 的 cluster 模式,是 redis 原生的高可用机制。
自动将数据进行分片,每个 master 上放一部分数据。
提供内置的高可用支持,部分 master 不可用时,还是可以继续工作的。
在 redis cluster 架构下,每个 redis 要开放两个端口,比如一个是 6379,则另外一个是 16379,即 加 1w。
6379 端口是用来对外提供服务的,如读写服务。16379 端口是用来进行节点间通信的,也就是 cluster bus 的东西。cluster bus 通信用来进行故障检测、配置更新、故障转移授权等节点间的通信和数据交换。cluster bus 用了另一种二进制协议,gossip 协议,用来进行节点间高效的数据交换,占用更少的网络宽带和处理时间。
集群节点间的内部通信主要用来维护集群元数据,集群元数据的维护主要有两种方式:集中式、gossip 协议。Redis cluster 集群节点间采用 gossip 协议进行通信。
集中式:
集中式是将集群元数据(节点信息、故障等)存储在某个节点上。集中式元数据维护的一个典型代表,就是大数据领域的 storm。它是分布式的大数据实时计算引擎,是集中式元数据存储的架构,底层基于 zookeeper(分布式协调中间件)对所有元数据进行存储维护。
集中式的好处在于,元数据的读取和更新,时效性非常好,一单元数据发生了变更,就立即更新到集中式的存储中,其它节点读取的时候就可以感知到;不好的地方是,所有元数据的更新集中在一个地方,可能会导致元数据的存储有压力。
gossip 协议:
gossip 协议方式,所有节点都持有一份集群元数据,不同的节点如果出现了元数据的变更,就不断的将元数据发送给其它节点,让其它节点也进行元数据的变更。
goosip 的好处在于,元数据的更新比较分散,不是集中在一个地方,更新请求会陆续打到所有节点上去更新,降低了压力;不好的地方是,元数据的更新会有些许延迟,可能会导致集群中的一些操作会有一些滞后。
每个节点都有一个专门用于节点间通信的端口,就是自己对外提供服务的端口号 +1w。每个节点会每隔一段时间向其它几个节点发送 ping 消息,同时其它几个节点再接收到 ping 消息之后会返回 pong。
节点间交换的信息包括:故障信息、节点的增删、hash slot(哈希槽)信息等。
gossip 协议是一种二进制协议,包含多种消息,如 ping、pong、meet、fail 等。
meet:某个节点发送 meet 给新加入的节点,让其加入节点集群中,然后新节点就开始与集群中的其它节点通信。
ping:每个节点会频繁的向其它节点发送 ping,其中包含自己的状态和其维护的集群元数据,互相交换元数据。
pong:作为 ping 和 meet 的返回,包含自己的状态和其它,也用于信息的广播和更新。
fail:某个节点发现另一个节点 fail 后,就发送 fail 给其它节点,通知其它节点说,某个节点宕机啦。
ping 时要携带一些元数据,如果很频繁,则可能会增加网络负担。
每个节点每秒会执行 10 次 ping,每次会选择 5 个最久没有通信的其它节点。如果发现与某个节点间的通信延时达到了 cluster_node_timeout / 2,那么会立即发送 ping,以避免数据交换延时过长。cluster_node_timeout 可以调节,值越大,ping 的频率就越低。
每次 ping,会带上自己节点的信息,还会带上 1 / 10 其它节点的信息。至少包含 3 个其它节点信息,最多包含 n - 2 个节点的信息(n 为节点总数)。
Redis cluster 的高可用原理,跟哨兵模式非常相似,都是主备切换。
hash 算法
一致性 hash 算法
hash slot 算法
来了一个 key,先计算其 hash 值,再对节点数取模(hash(key) % n),然后根据取模的值将其打到对应的 master 节点上。一旦某个节点宕机,所有请求过来会基于剩余存活的节点数取模,然后尝试去取数据,这就导致大部分请求无法命中缓存,最终大量请求会直奔数据库。
一致性 hash 算法是将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织。
一般的 hash 环是 hash 值取模运算,即 hash(key) % n,n 取 2 ^ 32,这样就形成了一个 0 ~ 32 的 hash 环。寻址按顺时针方向进行,查找最近的一个节点。
如图所示,将 4 个节点按照 “ip + 名称” 哈希取模,即 location = hash(ip + 名称) % n,然后,4 个节点落在了 hash 环上如图所示的四个位置。当一个请求到达时,对 key 也进行哈希取模,假设其落在了如图所示的位置,然后顺时针进行查找,找到 节点 2,即请求 key 命中了节点 2。这便是一个简单的寻址过程。
当一个节点挂了,受影响的数据仅仅是该节点到上一个节点间的数据,即减少了容灾问题带来的数据迁移量大的问题,增加节点也同理。
然而,一致性 hash 算法因为节点分布不均匀或在节点太少的情况下,会造成缓存热点的问题。为了解决这种热点问题,一致性 hash 算法引入了虚拟节点机制,即对每一个节点计算多个 hash,每个计算结果位置都作为一个虚拟节点。这样就实现了数据的均匀分布,负载均衡。具体做法是在 “ip + 名称” 后面加上编号,如 “ip + 名称1”、“ip + 名称2”、“ip + 名称3”,对其哈希取模,确定其在 hash 环上的位置,当 key 定位到虚拟节点时,如 “ip + 名称2”,则其实际命中了 ip + 名称 节点。
一致性 hash 算法的优点是有效减少了动态增删节点带来的数据迁移问题,缺点是节点很难均匀分布在 hash 环上。
hash slot 即哈希槽,redis cluster 正是采用的这种寻址算法。
以 redis cluster 为例,redis cluster 有固定的 16384 个 hash slot,其中每个 master 都会持有部分 slot,如有 3 个 master,那可能每个 master 持有 5000 多个 slot。当请求到达时,先计算 key 对应的 hash slot,即 hash slot = CRC16(key) % 16384,然后根据 hash slot 就可以确定具体访问那个节点。
每增加一个节点,就将已有的 master 上的 hash slot 移动部分过去;每减少一个节点,就将其所持有的 hash slot 分到其它节点上。
移动 hash slot 的成本是非常低的,且任何节点宕机,都不会影响其它节点,因为 key 找的是 hash slot 而不是节点。这样,既减少了 hash 寻址带来的数据迁移问题,又相对一致性 hash 来说负载均衡效果更加明显。
分布式锁是用来解决在分布式系统中的数据一致性问题的一种技术。解决分布式系统中数据一致性问题的技术主要有分布式锁、分布式事务等。
分布式锁的实现方式主要有三种,分别是:
基于数据库实现分布式锁主要是利用乐观锁和悲观锁。
以 redis 为例,基于缓存实现分布式锁的主要方式是利用 redis 的 setnx 命令。setnx 即 set if not exist,其维护的是乐观锁。setnx 的含义是若 key 不存在则放入。
主要原理是:对于一个更新数据的请求,先以要更新数据唯一标识为 key,以 UUID 为 value,将其放入 redis,且只能在 key 不存在的情况下放入。然后更新数据。更新完成后删除这个 key,且删除前要以 value 为条件删除,也就是删除当前请求生成的 UUID。
通过 setnx 命令设置 key 即加锁时,需要设置 expire 超时时间,超过该时间则自动释放锁。获取锁时也要设置 expire,即若超过这个时间未获取到锁则放弃获取锁。释放锁的时候,需要判断 UUID 是不是当前请求生成的,只有在 UUID 相等的情况下删除。
以 zookeeper 为例,zookeeper 是一个分布式中间件,其内部维护了一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。
zookeeper 实现分布式锁有两种方式,分别是:
两种方式的区别:
分布式锁实现方式中基于分布式中间件的实现最多被使用,尤其是利用临时顺序节点的实现。
@XGLLHZ - 张国荣 -《当年情》.mp3