走近科学之《Redis 的秘密》

走近科学之《Redis 的秘密》

走近科学之《Redis 的秘密》之精益求精


1、简介:

redis 是一个用 C/C++ 开发的开源、高性能、高并发、键值对的 Nosql 内存数据库。可用作缓存、数据库、消息中间件等。

2、特点:

  • 性能优秀: 基于内存,内存天然支持高并发,单机可达 10w QPS(读 11w,些 8.1w)。
  • 线程模型: 单进程单线程,采用非阻塞 IO 多路复用机制。
  • 支持多种数据类型: 字符串(string)、散列(hash)、有序可重复集合(list)、无序去重集合(set)、有序去重集合(sorted set)、位图(bitmap)。
  • 支持数据持久化: RDB 和 AOF 持久化机制,可将数据持久化到磁盘,重启时加载。
  • 高并发、高可用: 主从架构、哨兵模式、集群模式。
  • 用途: 缓存、分布式锁、消息中间件、发布/订阅。

3、redis 与 memcached:

memcached 是早些年各大互联网公司常用的缓存方案,redis 后来居上。

区别:
  • redis 支持丰富的数据类型;memcached 的数据类型较单一,只支持 string。
  • redis 原生支持集群模式;memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据。
  • redis 是单核;memcached 是多核。所以在单核 redis 上存储小数据性能要高于 memcached,在 100k 以上数据中,memcached 要优于 redis。

4、数据类型:

redis 主要支持 string、hash、list、set、sorted set 这几种数据类型。(关于这几种数据类型的具体操作命令可查看菜鸟教程,有各种骚操作哦)

string:

string 是最简单的数据类型,字符串,做最简单的 k v 缓存,普通的 set get 操作。字符串类型的值最大存储 512M 的内容。

set key value   # 存储
get key   # 查看
eg: set zed 瞬狱影杀阵
	get zed
hash:

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:

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:

set 是无序去重的数据类型,如系统中某些数据需要去重则可以使用它。当服务是单节点时可以使用 HashSet 来实现,但当服务是多节点部署时就可以考虑使用 redis 的 set 数据类型。每个 key 可存储 2^32 - 1 个元素。

而且可以基于 set 玩儿两个集合的交集、并集、差集等,如看两个 up 的共同好友、共同粉丝等。

sadd key value value   # 存储
smembers key   # 查看
sorted set:

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:
  • 简介:
    bitmap 是 redis 中的一种存储机制或表示机制,并不是一种数据结构,实际上就是字符串,但是可以对字符串的位进行操作。
    可以把 bitmap 想象成一个 bit 数组,数组的每个元素的值只能是 0 或 1,数组的下标叫做偏移量。
    每个 bitmap 中最大可以存储 512M 的内容,512 * 1024 * 1024 * 8 = 2 ^ 32 bit,也就是一个 bitmap 中最多可以存放四十二亿多个值。
    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:设置 key 对应的 offset 偏移量的值,offset 取值范围为 0 <= offset < 2 ^ 32,value 取值只能为 0 或 1。
    • getbit key offset:获取 key 对应的 offset 偏移量的值,结果只为 0 或 1。
    • bitcount key [start end]:统计 key 中指定位置值为 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 等。

    • 如用户签到、用户在线状态、用户是否会员等。
    • 如视频属性,上亿个短视频是否具有某种属性,可以属性为 key,视频唯一标识为 offset,具不具有这种属性则其值是 0 或 1。

bitmap 特点是读写速度快,可在有限的空间内容纳大量小数据。

5、线程模型:

redis 是单进程单线程的。

redis 内部使用的文件事件处理器(file event handler),这个文件事件处理器是单线程的,所以 redis 才是单线程的模型。采用非阻塞的 IO 多路复用机制,同时监听多个 socket,将产生事件的 socket 压入内存队列中,然后事件分派器会根据 socket 上的事件类型来选择相应的事件处理器进行处理。

文件事件处理器包含四个部分,分别是:多个 socket、IO 多路复用程序、事件分派器、事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)。

多个 socket 可能会并发的产生不同的操作,每个操作对应不同的事件,IO 多路复用程序会监听多个 socket,并将产生事件的 socket 放入内存队列排队,事件分派器每次从队列中取一个 socket,根据其事件类型交给对应的事件处理器进行处理器。

redis-thread-model

如图所示,客户端 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 事件与命令回复处理器的关联。

这样便完成了一次通信。

6、过期策略:

redis 的过期策略是 定期删除 + 惰性删除。

定期删除:

定期删除是指 redis 默认会每隔 100ms 随机抽取一些设置了过期时间的 key,检查其是否过期,若已过期则将其删除。

注意这里是随机抽取,并不是抽取所有设置了过期时间的 key。若 redis 里面存了 10w 个设置了过期时间的 key,那么一次定期删除可能直接就将 redis 干没了。

定期删除会造成很多过期了的 key 并没有被删除,于是就有了惰性删除。

惰性删除:

惰性删除是指当客户端获取某个 key 时,redis 会先检查该 key 是否设置了过期时间,如果设置了则再检查其是否过期了,如果已过期,那么 redis 会将其删除,并不会返回给客户端任何东西。

惰性删除会造成长时间不被使用且没有定期删除删除掉的 key 依旧存在的情况,长期如此将会耗尽内存,于是就有了 内存淘汰机制。

7、内存淘汰机制:

八种内存淘汰机制:
  • no-eviction:禁止驱逐。即不采用任何淘汰机制。当内存不足以容纳新写入的数据时会报错,一般没人用。
  • allkeys-random:当内存不足以容纳新写入的数据时,会随机挑选 key 进行删除。
  • allkeys-lfu:当内存不足以容纳新写入的数据时,会挑选一段时间内最少使用的 key 进行删除。
  • allkeys-lru:当内存不足以容纳新写入的数据时,会挑选最近最少使用的 key 进行删除。
  • volatile-random:当内存不足以容纳新写入的数据时,从设置了过期时间的 key 中随机挑选 key 进行删除。
  • volatile-lfu:当内存不足以容纳新写入的数据时,从设置了过期时间的 key 中挑选一段时间内最少使用的 key 进行删除。
  • volatile-lru:当内存不足以容纳新写入的数据时,从设置了过期时间的 key 中挑选最近最少使用的 key 进行删除。
  • volatile-ttl:当内存不足以容纳新写入的数据时,从设置了过期时间的 key 挑选将要过期的 key 进行删除。
LRU 算法:
// 代码不见啦

8、持久化机制:

redis 提供了两种持久化方式,分别是 RDB(Redis Data Base)和 AOF(Append-only File)。

持久化主要是做灾难恢复、数据恢复,也是高可用的一种方案。如当 redis 宕机重启后,可通过持久化产生的文件恢复宕机前 redis 中存储的数据。

RDB:

RDB 持久化机制是对 redis 中的数据执行周期性的持久化。

RDB 会生成多个数据文件,每个文件都代表了某一时刻 redis 中的数据,这种多个数据文件的方式,非常适合做冷备,可以将数据文件发送到安全稳定的云服务上存储,已预定好的策略来定期备份 redis 中的数据。

RDB 对 redis 对外提供读写服务的影响非常小,也就是不会影响 redis 的高性能。因为 redis 只需要 fork 一个子进程,让子进程来执行磁盘 IO 操作进行 RDB 数据持久化即可。

RDB 在每次 fork 子进程执行 RDB 快照数据文件生成的时候,如果数据特别大,则可能会导致 redis 对客户端提供的服务暂停数毫秒,甚至数秒。

AOF:

AOF 持久化机制是将每条对 redis 数据操作的命令作为日志,以 append-only 的模式写入一个日志文件,在 redis 重启的时候,通过回放 AOF 日志文件中的指令来重新构建数据集。

AOF 可以更好的保护数据不丢失,一般 AOF 会每隔 1 秒,通过一个后台线程执行一次 fsync 操作,最多丢失一秒钟的数据。

AOF 日志文件以 append-only 的模式写入,所以没有任何磁盘寻址的开销,写入性能非常高,而且文件不易破损,即使文件尾部破损,也很容易修复。

AOF 日志文件过大时,会出现后台重写的操作,且不会对客户端的读写造成影响。因为在 rewrite log 的时候,会对指令进行压缩,创建出一份恢复数据的最小日志文件出来。在创建新日志文件的时候,老日志文件还是照常写入,当新的 merge 后日志文件 ready 的时候,再交换新老日志文件即可。

AOF 日志文件通过非常可读的方式进行记录,这个特性非常适合做灾难性误删的紧急恢复。比如某位小伙伴不小心用 flushall 命令清空了所有的数据,只要这个时候后台 rewrite log 还没发生,那么就可以立即拷贝 AOF 文件,将最后一条 flushall 命令删掉,然后再将该文件放回去,就可以通过恢复机制,自动恢复所有数据。

RDB 与 AOF 比较:

相对于 AOF 来说,直接基于 RDB 数据文件来重启和恢复 redis 进程,会更加快速。如果想要在 redis 故障时,尽可能少的丢失数据,那么 AOF 要优于 RDB。

一般来说,RDB 数据快照文件都是每隔 5 分钟,或者更长时间生成一次,这时候就得接收如果 redis 宕机,那么可能会丢失将近 5 分钟的数据。

AOF 开启后,支持的客户端的写 QPS 将略低于 RDB 支持的客户端的写 QPS,因为 AOF 一般会配成每秒 fsync 一次日志文件,多少都会影响客户端写。当然,每秒一次 fsync,性能还是很高的,如果是实时 fsync,那写的 QPS 会大降。

如何选择:

仅使用 RDB,虽然简单粗暴来得快,将会丢失很多数据;仅使用 AOF,虽然数据完整,但恢复速度较慢。

建议两者结合使用,天下无敌!

9、高并发&高可用:

redis 主要基于主从架构来实现高并发,基于哨兵模式来实现高可用。

redis 单机可达 10w QPS,但在很多业务场景下,10w 的 QPS 远远是不够的,可以通过增加 redis 节点的方式来提高其 QPS 能力,也就是主从架构。既然增加了节点,那就会存在某个/些节点宕机的可能,则可以通过哨兵模式来解决。

主从架构,即一主多从。一个主节点,多个从节点,一般主节点用来提供给写入服务,单机大几万 QPS;多从节点用来提供读取服务,多个从节点可提供 10w 的 QPS。

集群模式,如果一主多从依旧扛不住请求,或者想容纳大量的数据,那就可以考虑使用集群模式。redis 集群模式可以看成是多个主从架构的组合,在提供了更大并发量能力的同时,可以容纳更多的数据。集群之后可提供几十万的读写并发。

哨兵模式,可以为 redis 的主从架构提供高可用的保障。当主节点宕机后,它会从从节点中选择一个节点来作为主节点,即可以进行主备切换。实际上在集群模式下,高可用机制是基于哨兵模式实现的,所以说,redis 实现高可用的本质还是哨兵模式。

10、数据一致性问题:

如果数据库和缓存同时使用,那就会涉及到数据库和缓存的双存储双写,只要是双写,就一定会存在数据一致性问题,也就是数据库数据与缓存数据不一致的情况。

一般情况下,可以通过请求串行化来解决,即将读请求和写请求串行到一个内存队列中去。

串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,可能需要用比正常情况下多几倍的机器来满足线上的并发量。所以,如果可以允许缓存跟数据库稍微偶尔的有不一致的情况,也就是系统不是严格要求 “缓存 + 数据库” 必须保持一致的话,最好不要做这个方案。

CAP:

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 路由功能等。

  • 热点商品路由导致请求倾斜:
    假设某个商品的读写请求特别高,为热点商品,然后全部请求打到了相同机器的同一队列中了,可能会造成该机器的负载过高。

11、雪崩&穿透&击穿问题:

缓存雪崩:

redis 雪崩指的是在高并发场景下,当 redis 中大量 key 同时失效(过期)或 redis 宕机,导致大量请求直接落到数据库上,从而导致数据库崩溃的情况。

解决方案:

  • 事前:合理设置 key 过期时间,如对过期时间加上随机数,避免大量 key 同时失效;redis 高可用,主从 + 哨兵,redis cluster,避免全盘崩溃。
  • 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免数据库直接被干死。
  • 事后:redis 持久化,一旦重启,可自动快速恢复缓存数据。

redis-xuebeng-fangan

用户发送一个请求,系统收到请求后,先查本地 ehcache 缓存,若没有再查 redis,若 redis 也没有则查数据库,若数据库中有,则将其结果写入 ehcache 和 redis 中。

限流组件,可以设置每秒钟到达系统的请求,有多少能通过组件,剩余的未通过的怎么办?走降级,可以返回一些默认值或友好提示,或空值。

这样设计的好处是,数据库绝对不会死,限流组件确保了每秒只有多少个请求能直接到达数据库。对于没有通过限流组件的请求,对用户来说,无非就是多点几次页面,多刷新几次而已。

缓存穿透:

缓存穿透指的是,在高并发场景下,每秒内到达服务器的请求,百分之八九十都是黑客发出的恶意攻击,这些攻击会 “穿过” redis,直指数据库,直接导致数据库崩溃。

比如数据库 id 都是从 1 开始的,黑客发出的请求的 id 都是负数,那 redis 中肯定没有,然后就直接打到了数据库,最终导致数据库崩溃。

解决方案:
每次请求从数据库中没有查到数据时,就写一个空值到缓存中去,且设置一个过期时间,这样的话,下次有相同 key 来访问时,在缓存失效之前,都可以从缓存中取到数据。

这种方式虽然简单,但在某些场景下显得不优雅,还可能会缓存过多空值,更加优雅的方式是使用 redis 布隆过滤器。

缓存击穿:

缓存击穿指的是,某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,在这个 key 失效的瞬间,大量请求会击穿缓存,直接落在数据库上,导致数据库崩溃。

解决方案:

  • 若缓存的数据基本不会更新,则可将该热点数据设置为永不过期。
  • 若缓存的数据更新不频繁,且更新缓存的整个流程耗时较少,则可以采用基于 redis、zookeeper 等分布式中间件的分布式互斥锁,或本地互斥锁,以保证仅少量的请求可以请求数据库,以重新构建缓存,其余请求在锁释放后访问新的缓存。
  • 若缓存的数据更新频繁或更新流程耗时较长,则可以利用定时任务在缓存过期前主动构建缓存或延后缓存过期的时间,以保证所有的请求一直能访问到对应的请求。

缓存穿透重点在于 “透”,大量请求透过了缓存层;缓存击穿重点在于 “击”,一个或几个热点 key 直接击穿了缓存层。

12、并发竞争问题:

并发竞争问题指的是在高并发场景下多个客户端同时读写 key 而造成数据错误的问题。比如多个客户端同时写 key,key 对应 value 的初始值 1,正常情况下 value 值的写顺序为 2、3、4,最后是 4,但并于并发竞争写,顺序变成了 2、4、3,最后 value 变成了 3。

解决方案:

  • 分布式锁 + 时间戳/版本号:利用分布式锁 + 时间戳/版本号的方式来保证 set 操作的执行顺序。
  • 消息队列:利用消息中间件,将 set 操作读写串行化,来保证 set 操作的执行顺序。

13、布隆过滤器:

简介及原理:
  • 简介:
    布隆过滤器是一种巧妙的概率型数据结构,实际上它由一个很长的二进制向量和一系列随机映射函数组成。
    布隆过滤器可以用于检索某一个元素是否在一个集合中。它可以告诉某种东西可能存在或一定不存在。当布隆过滤器说这种东西存在时,那么它可能存在,也可能不存在;但当布隆过滤器说这种东西不存在时,那么它一定不存在。
    布隆过滤器的优点是空间占用少、查询时间短;缺点是存在一定误判,且元素不能删除。
  • 特性:
    • 检查一个元素是否在集合中,结果为 一定不存在、可能存在。
    • 支持添加元素、检查元素,但不支持删除元素。
    • 检查结果存在一定误判率,但已进入布隆过滤器内的元素不会被误判,只有未进入的才可能被误判。
    • 相比普通 set,非常节省空间。
    • 添加的元素超过预设容量越多,误判的可能性就越大。
  • 原理:
    布隆过滤器的半只是一个巨大的 bit 数组和几个不同的无偏 hash 函数。
    添加元素的过程是:首先使用多个不同的 hash 函数对元素进行哈希计算,得到多个 hash 值;每个 hash 值对 bit 数组取模得到其在数组中的位置 index;判断所有 index 的位置是否都为 1,若都为 1 则说明该元素可能存在了;任意一位不为 1 则说明一定不存在,且将不为 1 的位置置为 1。
    需要注意的是,虽然使用了无偏 hash 函数,使得 hash 值尽可能的均匀,但是不同的元素的 hash 值依旧有可能重复,所以布隆过滤器说元素存在,实际上可能不存在。
布隆过滤器解决缓存穿透问题:

场景描述:
缓存穿透指的是大量请求请求缓存中不存在的 key,由于没有命中缓存,所以大量请求直接打到数据库,导致数据库崩溃。

利用布隆过滤器解决:
事先将存在的 key 都放入 redis 布隆过器中,进行存在性检测。当请求达到时,先通过布隆过滤器检查其所请求的 key 存不存在,若布隆过滤器说没有,那就一定没有,数据库中也没有,直接返回;若说有,那就可能有,放行。

布隆过滤器可能会误判,放过部分实际 key 不存在的请求,但不影响整体,所以,其是处理此类问题的最佳方案。

bloom-filter

如上图所示,整个流程展示了 redis bloom filter 解决缓存穿透的过程。目前,已经介绍了两种解决缓存穿透问题的方案,分别是缓存空值和布隆过滤器,而图中,蓝色部分是缓存空值的方案,在外层加上布隆过滤器就是布隆过滤器的反感了。

应用场景:
  • 解决缓存穿透:
    解决缓存穿透问题参考上一节 “布隆过滤器解决缓存穿透问题”。
  • 黑名单校验:
    黑名单校验请参考第四条 “去重”。原理基本不差。
  • web 拦截器:
    可防止黑客恶意攻击或相同请求恶意请求。
    如第一次请求时以请求参数放入布隆过滤器,当同一个 ip 第二次请求时先判断请求参数是否被布隆过滤器命中,再进行拦截或放行操作。
  • 去重:
    可对大数据集的账号、号码、邮箱、url 等数据去重。
    如有 10 亿个电话号码,对与新到的号码你需要判断其是否已经存在于这 10 亿个号码集中。有些小伙伴觉得可以放缓存中,但如果放缓存中的话,以 java 为背景,号码长度为 11,不能用 int 表示,这里用 long 表示,占 8 个字节,10 亿 * 8 / 1024 / 1024 / 1024 = 7.4 G。占用 7.4 G 的空间,但如果放进 bitmap 的话,一个 bitmap 最多可以表示 2 ^ 32 大概四十二亿个值,一个 bitmap 最多占用 512 M 空间。
bitmap 与 bloom filter:
  • 两者都可以看成是一个巨大的 bit 数组。
  • bitmap 存放元素时是直接修改元素对应位的值;bloom filter 是先对元素进行多次不同 hash,再对 bit 数组取模,在修改模值对应位的值。
  • bitmap 中元素存不存在是一定的;bloom filter 中元素不存在是一定的,存在是可能的(这是由 hash 碰撞造成的)。
  • bitmap 中一位代表一个元素;bloom filter 中多位代表一个元素。
  • bitmap 的容量一定比 bloom filter 大,但在元素相同取值范围的作用下,bitmap 的内存利用率要低于 bloom filter。

14、集群模式:

集群模式,也就是 redis 的 cluster 模式,是 redis 原生的高可用机制。

Redis Cluster 介绍:

自动将数据进行分片,每个 master 上放一部分数据。

提供内置的高可用支持,部分 master 不可用时,还是可以继续工作的。

在 redis cluster 架构下,每个 redis 要开放两个端口,比如一个是 6379,则另外一个是 16379,即 加 1w。

6379 端口是用来对外提供服务的,如读写服务。16379 端口是用来进行节点间通信的,也就是 cluster bus 的东西。cluster bus 通信用来进行故障检测、配置更新、故障转移授权等节点间的通信和数据交换。cluster bus 用了另一种二进制协议,gossip 协议,用来进行节点间高效的数据交换,占用更少的网络宽带和处理时间。

集群节点间的内部通信机制:

集群节点间的内部通信主要用来维护集群元数据,集群元数据的维护主要有两种方式:集中式、gossip 协议。Redis cluster 集群节点间采用 gossip 协议进行通信。

  • 集中式:
    集中式是将集群元数据(节点信息、故障等)存储在某个节点上。集中式元数据维护的一个典型代表,就是大数据领域的 storm。它是分布式的大数据实时计算引擎,是集中式元数据存储的架构,底层基于 zookeeper(分布式协调中间件)对所有元数据进行存储维护。

    jizhongshi-meta

    集中式的好处在于,元数据的读取和更新,时效性非常好,一单元数据发生了变更,就立即更新到集中式的存储中,其它节点读取的时候就可以感知到;不好的地方是,所有元数据的更新集中在一个地方,可能会导致元数据的存储有压力。

  • gossip 协议:
    gossip 协议方式,所有节点都持有一份集群元数据,不同的节点如果出现了元数据的变更,就不断的将元数据发送给其它节点,让其它节点也进行元数据的变更。

    redis-gossip-meta

    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 的高可用原理,跟哨兵模式非常相似,都是主备切换。

  • 判断节点宕机:
    如果一个节点认为另外一个节点宕机,那么就是 pfail,主观宕机;如果多个节点都认为另外一个节点宕机,那么就是 fail,客观宕机。跟哨兵的原理几乎一样,sdown、odown。
    在 cluster-node-timeout 内,如果某个节点一直没有返回 pong,那么就会被认为 pfail。如果一个节点认为另外一个节点 pfail 了,就会在 gossip ping 消息中,ping 给其它节点,如果超过半数节点都认为该节点 pfail 了,那么就会变为 fail。
  • 从节点过滤:
    对宕机的主节点(master node),从其所有的从节点(slave node)中选择一个,切换成主节点。
    检查每个 slave node 与 master node 的断开连接时间,如果超过了 cluster-node-timeout * cluster-slave-validity-factor,那就没有资格切换成 master node。
  • 从节点选举:
    每个 slave node 都根据自己对 master node 数据复制的 offset,来设置一个选举时间。offset 越大(复制的数据越多)的 slave node,选举时间越靠前,优先进行选举。
    集群中所有 master node 开始为参与选举的 slave node 进行投票,如果大部分 master node (n / 2 + 1)都投给了某个 slave node,那么选举通过,被选举的 slave node 可以成为 master node。slave node 将进行主备切换,成为 master node。
  • 与哨兵比较:
    整个流程跟哨兵非常相似,所以说,redis cluster 功能强大,直接集成了 replication 和 sentinel。

15、分布式寻址算法:

hash 算法
一致性 hash 算法
hash slot 算法

hash 算法:

来了一个 key,先计算其 hash 值,再对节点数取模(hash(key) % n),然后根据取模的值将其打到对应的 master 节点上。一旦某个节点宕机,所有请求过来会基于剩余存活的节点数取模,然后尝试去取数据,这就导致大部分请求无法命中缓存,最终大量请求会直奔数据库。

一致性 hash 算法:

一致性 hash 算法是将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织。

一般的 hash 环是 hash 值取模运算,即 hash(key) % n,n 取 2 ^ 32,这样就形成了一个 0 ~ 32 的 hash 环。寻址按顺时针方向进行,查找最近的一个节点。

yizhixing-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 算法:

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 就可以确定具体访问那个节点。

hash-slot

每增加一个节点,就将已有的 master 上的 hash slot 移动部分过去;每减少一个节点,就将其所持有的 hash slot 分到其它节点上。

移动 hash slot 的成本是非常低的,且任何节点宕机,都不会影响其它节点,因为 key 找的是 hash slot 而不是节点。这样,既减少了 hash 寻址带来的数据迁移问题,又相对一致性 hash 来说负载均衡效果更加明显。

16、分布式锁:

分布式锁是用来解决在分布式系统中的数据一致性问题的一种技术。解决分布式系统中数据一致性问题的技术主要有分布式锁、分布式事务等。

分布式锁的特点:
  • 排它性:在同一时间只能有一个服务获取到锁,其它服务无法同时获取。
  • 高可用&高性能:获取锁与释放锁要高可用、高性能。
  • 避免死锁:具备锁失效机制,即一把锁在一段时间后一定会被释放,正常释放或异常释放。
  • 非阻塞:具备非阻塞特性,即获取锁失败时不能阻塞。
  • 可重入:具备可重入性,即同一个服务的一个请求在获取了一把锁之后,若在后续的处理流程中任需要锁,则可自动获取锁,不会因为之前已经获取过锁没释放而获取锁失败。
分布式锁的实现:

分布式锁的实现方式主要有三种,分别是:

  • 基于数据库实现。
  • 基于缓存实现。
  • 基于分布式中间件实现。
基于数据库实现:

基于数据库实现分布式锁主要是利用乐观锁和悲观锁。

  • 乐观锁方式:
    乐观锁的方式实际上是在数据库表中增加版本号字段(version),每次更新数据时都对版本号值进行 version++。
    如对于一个更新数据的请求,先从数据库中获取要更新数据的版本号,再执行更新操作。更新时要以 version 为条件,即只要当数据库中的版本号与获取到的版本号一致时才能更新,若不一致则说明在此期间有其它请求修改过该数据,则更新失败。同时要更新版本号,即 version++。
  • 悲观锁方式:
    悲观锁的方式是利用排它锁的机制,即利用 for update sql 语句为要更新的数据加锁,来保证在事务提交成功执行前没有其它请求更新数据。排它锁的作用是保证一个事务在未完成前其它事务可以读取但不能更新数据。
    需要注意的是,mysql InnoDB 默认是表级锁,所以需要对查询条件字段添加索引,以变为行级锁。
    遵循 一锁、二判、三更新、四释放 的原则(手动狗头)。
基于缓存实现:

以 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 实现分布式锁有两种方式,分别是:

  • 方式一:利用节点名称的唯一性来实现共享锁。
    若某客户端需要获取锁,则尝试在指定目录下创建节点,若创建成功,则活得锁。释放锁时,只需删除 lock 节点即可。
  • 方式二:利用临时顺序节点实现共享锁。
    若某客户端需要获取锁,则在指定目录下创建临时节点,若创建的节点的序列号小于目录下的其它节点序列号,则获得锁;若创建的节点序列号不是最小的,则监视比自己小的节点序列号,当其被删除时,自己再获得锁。
    释放锁时只需要删除这个临时节点即可。

两种方式的区别:

  • 方式一会产生惊群效应,即当有多个客户端在等待同一把锁,当锁被释放的时候所有等待的客户端都被唤醒,但仅有一个能获得锁。
  • 方式二是按顺序排队的实现,多个客户端共同等待同一把锁,当锁被释放时仅有一个客户端会被唤醒,避免了惊群效应。
  • 方式二优于方式一的另外一点是,当 zookeeper 宕机后,方式二中的临时节点会自动删除,获得锁的客户端会释放锁,不会造成锁等待;二方式一会造成锁等待。

分布式锁实现方式中基于分布式中间件的实现最多被使用,尤其是利用临时顺序节点的实现。

@XGLLHZ - 张国荣 -《当年情》.mp3

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值