likes
comments
collection
share

Redis概述

作者站长头像
站长
· 阅读数 23

redis是常见的k-v数库,多用于缓存。支持五种value类型:字符串 hash表 list set sorted-set。

redis server结构和数据库redisDb

首先我们来看一下redis存储结构。一个redis db里定义了两个字典,一个记录所有存入redis中的数据,不区分value类型(在源码中定义了robj的对象,作为不同数据类型的统一表达,这个dict实际上就是一个key-robj的映射);还有一个记录了redis中所有key的过期时间。

Redis概述

另外,redis启动时会创建16个数据库实例,id则记录了数据库实例编号。由redis/redis.conf中database属性控制实例数量,通过select命令可以切换数据库实例。

127.0.0.1:6379> select 1
OK
127.0.0.1:6379[1]> select 0
OK

持久化

作为一款数据库产品,为了避免断电等意外情况从而造成数据丢失,redis提供了两种数据持久化方案:RDB和AOF。

RDB

我们先来说说RDB,也就是俗称的快照,会定期将redis中数据备份落盘,默认情况下生成dump.rdb文件存储备份。 redis提供了两种快照命令:save & bgsave。其中save是阻塞命令,当执行save时server不能响应其他请求;bgsave则通过fork一个新进程,由新进程落盘,原有的server端不受影响,可以继续响应其他请求。 关于RDB触发条件,可以在配置文件中配置

# Save the DB on disk:
#
#   save <seconds> <changes>
#
#   Will save the DB if both the given number of seconds and the given
#   number of write operations against the DB occurred.
#
#   In the example below the behaviour will be to save:
#   after 900 sec (15 min) if at least 1 key changed
#   after 300 sec (5 min) if at least 10 keys changed
#   after 60 sec if at least 10000 keys changed
#
#   Note: you can disable saving completely by commenting out all "save" lines.
#
#   It is also possible to remove all the previously configured save
#   points by adding a save directive with a single empty string argument
#   like in the following example:
#
#   save ""

save 900 1
save 300 10
save 60 10000

当seconds秒内,发生changes次改变,redis会进行一次备份。

bgsave为什么不阻塞进程?

子进程和父进程是共享同一片内存数据的,创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。

参考文献:

[1]: Redis persistence [2]: Redis实战(五):Redis的持久化RDB、fork、copyonwrite、AOF、RDB&AOF混合使用 [3]: Redis' RDB persistence principle

AOF

AOF将所有涉及redis数据库的写操作,都记录在文件中,从而达到数据持久化的目的。不过redis默认配置将AOF持久化方式关闭,如有需要修改appendonly属性。 关于判断合适将命令从缓冲区写入文件,redis配置中提供了三种不同策略:

# The fsync() call tells the Operating System to actually write data on disk
# instead of waiting for more data in the output buffer. Some OS will really flush
# data on disk, some other OS will just try to do it ASAP.
#
# Redis supports three different modes:
#
# no: don't fsync, just let the OS flush the data when it wants. Faster.
# always: fsync after every write to the append only log . Slow, Safest.
# everysec: fsync only one time every second. Compromise.
#
# The default is "everysec", as that's usually the right compromise between
# speed and data safety. It's up to you to understand if you can relax this to
# "no" that will let the operating system flush the output buffer when
# it wants, for better performances (but if you can live with the idea of
# some data loss consider the default persistence mode that's snapshotting),
# or on the contrary, use "always" that's very slow but a bit safer than
# everysec.
#
# More details please check the following article:
# http://antirez.com/post/redis-persistence-demystified.html
#
# If unsure, use "everysec".
modedescription
no交由操作系统决定,何时将命令从缓冲区刷新到文件中
always每执行一次写操作,就同步文件一次,影响redis效率,但是安全性最高
everysec每秒将命令同步到文件中一次

AOF 的日志记录采用先执行命令后记录日志的方式,并且是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。

当AOF日志过大,redis会对日志进行重写。读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到新的AOF文件中。虽然重写同样会fork子进程,但是在重写过程中的命令会进入AOF缓冲区,在子进程完成新AOF文件创立后,会将缓冲区内容append到AOF,不过刷新缓冲区的这段时间,可能造成redis主线程的阻塞。

参考文献:

[1]: Redis' AOF persistence principle

RDB是相对较快的数据恢复方式,尤其数据量较大的情况下,恢复速度快于AOF。但是由于RDB的触发条件可以认为是在固定时间间隔上备份,可能导致两个备份时间点间数据丢失。尽管在备份的时候fork进程,最大程度避免对server进程阻塞,但是当数据量过大,fork进程的时间开销也随之增大,造成server几百毫秒不可用的情况。 相比RDB,AOF持久化的数据更加安全,甚至可以保存每一条数据改动。但是对于同等数据量下,AOF生成的appending-only log要比RDB文件大。并且AOF的使用更加影响性能。

RDB和AOF混合使用

使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头

Redis概述

参考文献:

分布式方案

主从模式

在介绍主从模式前,先了解一下主从复制。主从复制保证了redis分布式方案中一致性。

Redis概述

简单来说,salve和master建立连接后会进行一次全量同步,master将RDB发送给slave,此后定期发送RDB文件增量进行同步。 但是这里有个问题,要知道不可能永远保证salve节点运行正常,如果出现意外与master的连接断开,再次连接需要又全量同步一次。为了优化这个问题,在2.8version以后提出了环形队列的方式,允许slave断开一段时间后重连后,仍以增量的形式同步。

Redis概述

server进程除了将操作写入RDB外,还额外写入环形队列,如果slave重连后能在环形队列上找到断开前的位置,就会增量同步;如果断开时间太久,那么就只能全量同步了。

# Set the replication backlog size. The backlog is a buffer that accumulates
# slave data when slaves are disconnected for some time, so that when a slave
# wants to reconnect again, often a full resync is not needed, but a partial
# resync is enough, just passing the portion of data the slave missed while
# disconnected.
#
# The bigger the replication backlog, the longer the time the slave can be
# disconnected and later be able to perform a partial resynchronization.
#
# The backlog is only allocated once there is at least a slave connected.
#
# repl-backlog-size 1mb

现在来看主从模式就很简单了。master节点负责写,slave只读,这样读写分离的设计提升了redis性能。

哨兵模式

在主从模式中,分区可用性其实不太好,只要主节点挂了那么整个集群都不能写入了,所以后来就有了Sentinel(哨兵)模式作为官推HA方案。Sentinel其实过于冗余,因为这种方案又引入了额外监控集群去监控redis集群的状态。为了防止单点故障从主节点转移到Sentinel集群,Sentinel间还会互相监控。

图 sentinel拓扑

Sentinel会定期向所监测的master发送请求,如果连续在配置时间down-after-milliseconds无响应,则当前的Sentinel认为master主观下线。但是此时Sentinel还要询问集群中其他Sentinel master的状态,只有集群中超过配置数量的Sentinel认为master已无响应,才会正式进行master切换,也就是客观下线。之后的工作就是master选举了。

集群模式

集群模式和基于主从的分布式方案不同,cluster集群方案实现分布式存储,每个节点存储不同数据,相比sentinel模式,cluster集群能更好的水平扩展。

cluster模式下将原本一个实例中数据,分割到多个实例中,每个实例仅包含一部分。这种存储方式就是所谓的redis分区,将数据存储到多个redis实例中。分区利用多台主机内存,构建了一个更大的数据库,提高性能。 redis在设计分区映射方式的时候,采用了hash slot。整个集群有16384个slot由整个集群共同维护,如果集群中共有3个节点,那么一个节点负责大约5000+slot。slot简单来说就是一个list中元素,key向redis存储时先经过散列算法(CRC16),再对list长度(16384)取余,这样确定key在list中的位置,也就是放入哪个slot。

这里的slot和一致性hash不太一样。slot将数据和节点解耦,数据绑定到slot上,slot作为redis集群中数据操作的最小单位。当集群伸缩时,数据随着slot在节点间移动。

到这里只说明了分区的存储实现,由于数据的分布式存储,在访问数据的时候却并不知道key在集群中的确切位置。实现分区访问分为三种方式:

  • 客户端分区 客户端直接选择正确节点读写指定键
  • 代理辅助分区 客户端通过Redis协议把请求发送给代理,而不是直接发送给真正的Redis实例服务器。这个代理会确保我们的请求根据配置分区策略发送到正确的Redis实例上,并返回给客户端。
  • 查询路由 一个请求发送给一个随机的实例,这时实例会把该查询转发给正确的节点。通过客户端重定向(客户端的请求不用直接从一个实例转发到另一个实例,而是被重定向到正确的节点)。

cluster模式下,混合使用了查询路由和客户端分区。Redis 节点接收任何键相关命令时首先计算键对应的槽,在根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复 MOVED 重定向错误,通知客户端请求正确的节点。 但是分区过后,便不在支持多个key的操作,比如不能在操作不同实例下,两个集合的交集。

由于在分布式场景下,避免单点故障造成的不可用,所以cluster集群模式下,仍然也采取主从进行数据备份,不过这次不是全部数据,而是对节点上各自负责的slot备份。cluster模式下也是没有要求数据强一致,采取异步复制,不等待半数节点写入确认。 Redis概述

故障转移和sentinel模式类似,基于主观下线和客观下线两步。不过底层实现不同,通过心跳检测和gossip协议完成节点状态监测。

集群模式下,redis只有1一个数据库实例。

过期策略

redis中提供了三种过期策略:

  • 定时删除 在set的时候设置定时器,过期删除
  • 惰性删除 当访问到key时查询是否过期,不主动删除key
  • 定期删除 定期遍历数据库中所有的key,主动删除过期key

即使存在过期策略,由于redis使用直接内存,还是存在将内存占满的情况。因此redis中还额外配合了最大内存策略

# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory
# is reached. You can select among five behaviors:
# 
# volatile-lru -> remove the key with an expire set using an LRU algorithm
# allkeys-lru -> remove any key according to the LRU algorithm
# volatile-random -> remove a random key with an expire set
# allkeys-random -> remove a random key, any key
# volatile-ttl -> remove the key with the nearest expire time (minor TTL)
# noeviction -> don't expire at all, just return an error on write operations
# 
# Note: with any of the above policies, Redis will return an error on write
#       operations, when there are no suitable keys for eviction.
#
#       At the date of writing these commands are: set setnx setex append
#       incr decr rpush lpush rpushx lpushx linsert lset rpoplpush sadd
#       sinter sinterstore sunion sunionstore sdiff sdiffstore zadd zincrby
#       zunionstore zinterstore hset hsetnx hmset hincrby incrby decrby
#       getset mset msetnx exec sort
#
# The default is:
#
# maxmemory-policy noeviction

默认情况下,当内存不足以写入新数据,那么redis会直接报错。其他策略大体上是在内存不足的情况下,应用不同的选择方式淘汰已过期的key。

缓存雪崩

redis缓存策略可能会导致数据库中大量key同时到期,导致大量查询直接向数据库查询,从而造成数据库压力过大。

所以在设定过期时间的时候,需要外加一个离散值避免过期时间过于集中。假设原有的key集中在5h后过期,那么在设定过期时间的时候,可以引入一个随机值,让这一组key集中在5h左右。

缓存击穿

缓存击穿和缓存雪崩差不多,缓存击穿针对某一个key过期导致数据库压力过大,当过期数量少的时候,自然也有不同的应对策略。

单一key过期,可以通过互斥的方案(setnx),只有一个请求透过redis向数据库请求,其他请求临时blocking。 还有一种就是设置永不过期

缓存穿透

请求缓存中不存在的数据,导致越过数据库直接访问数据库。

我们可以对请求的key进行预处理,过滤一定不存在key。因为hash相同但是对象不一定相同,但是hash不同那么对象一定不同。所以将数据库中所有key的hash值存储到bitmap中,对请求的key预先判别。 但是这样仍然存在问题。在bitmap中判断不存在的key不一定一直不存在,有可能过段时间查询数据库就有了,如果简单的在bitmap中过滤掉,那么这个key就出现问题了。所以为了保证时效性,可以在查询不到时临时存储一个空value,注意设置一个过期时间,这样兼顾流量和数据时效性。

分布式锁

单节点情况下

- 获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX 30000

- 释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

  • 如果获取锁不设定过期时间(PX 30000) 如果不设定过期时间,那么当获取锁的应用出现异常,那么这个锁就无法再释放,所以为了避免死锁就需要一个过期时间让应用主动释放

  • 如果应用响应时间超过设定的key过期时间 应用在提交对资源的改动时,额外对锁进行确认,获取到value后check是否仍然是自己的unique_value。在redssion中,则是由守护线程动态调整锁的过期时间。

  • 什么需要unique_value 假设又应用A B,set key的时候采用统一value,释放锁不对value进行确认。A首先获取锁,但是响应超时,锁被redis主动释放。随后B获取锁,但是A中线程仍然对资源做出改动,并在A应用上执行释放锁。此时B获取的锁被释放,但是B并没有执行完成,从而造成线程安全问题。

  • 为什么释放的时候需要检验value 见上。确保获取和释放都是同一个

  • 为什么用lua脚本

    在应用侧client中,释放锁get和del不是一个原子操作。

除此之外,这种锁实现还不支持重入锁。

分布式情况下

一般主从或者sentinel模式下,虽然保证了redis HA,但是由于读写分离,没有强一致性,写入高度依赖master。所以一旦master不可用,就可能造成锁获取出现问题。例如master已经在主节点写入,但是还没有同步到slave,那么其他应用读取时就会显示未加锁状态。 所以后续基于分布式场景下,又推出了redlock分布式锁实现。

假设有N个独立的Redis节点

  1. 获取当前时间(毫秒数)。
  2. 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间(比如PX 30000,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。
  3. 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认d为最终获取锁失败。
  4. 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
  5. 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(即前面介绍的Redis Lua脚本)。
  6. 释放锁:对所有的Redis节点发起释放锁操作

参考文献:

[1]: Distributed Locks with Redis

缓存和数据库双写一致

缓存和数据库不一致主要集中在对数据库的修改同步到缓存中。

比如最简单的实现,先更新数据库数据,然后删除缓存。这里可能出现删除失败,导致缓存和数据库数据不一致。 于是就有了改进方案:延时双删。先删除缓存,再该数据库,延迟一段时间再删一次缓存。但是这样仍然又最后一次删除失败的情况。

Redis概述

为了保证一定删除,进一步提升系统复杂度,将删除失败的key放入mq,由其他线程专门删除mq中的key,直到删除成功为止。

为了避免业务侧代码改动,线下的读取binlog异步淘汰缓存模块,读取binlog总的数据,然后进行异步淘汰。一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息推送至Redis,Redis再根据binlog中的记录,对Redis进行更新。

参考文献;

[1]: MySQL与Redis 如何保证双写一致性

转载自:https://juejin.cn/post/7223766585039142967
评论
请登录