Redis的持久化机制(怎么保证rdb的完整性)
【AOF日志】
AOF是写后日志,即Redis先执行命令把数据写入内存,才把Redis命令记录到AOF日志中。写后日志可以避免记录错误命令,而且它不会阻塞当前的写操作。
但是AOF存在两个潜在的风险:如果刚执行完命令就宕机,则此命令就会有丢失的风险。当Redis直接用作数据库时,就无法进行数据恢复了;AOF日志也是在主线程中写入磁盘的,若AOF文件过大导致写盘很慢,则会阻塞后续的操作。所以AOP机制提供了三种写回策略,也就是AOF配置项appendfsnyc的三个可选值。
写回策略:
1.Always同步写回,命令执行后立即将日志写回磁盘。高可靠但会影响主线程性能。
2.Everysec每秒写回,命令执行完后先把日志写入内存缓冲区,每隔一秒把日志写入磁盘。减少了性能开销但存在宕机丢失的可能。
3.No操作系统来决定把内存缓冲区中的日志写入磁盘的时机。日志命令容易丢失。但即使我们根据系统性能需要选择了合适的写回策略,AOF还会出现问题。AOF是以追加的形式写入文件的,所以随着接收的写命令越来越多,AOF文件会越来越大。AOF文件过大会使得追加命令的速度变慢,而且如果发生宕机,AOF用于故障恢复时其中的命令要被一个个地执行,会影响到Redis的使用。所以就有了AOF的重写机制。
AOF重写机制在AOF文件过大时发生,Redis会根据当前数据库的现状创建一个新的AOF文件,文件中对每一个键值对都用一条命令记录它的写入。它实际上就是把旧文件中的多条命令变成了一条命令,压缩了使用空间。生成的AOF文件由后台子进程bgrewriteaof来写入磁盘的,所以它避免了AOF重写操作阻塞主线程,在AOF重写的过程中主线程会把新来的指令记录同时写入旧AOF缓冲区和新AOF缓冲区,保证日志操作弃权。
但是,AOF日志仅仅是用在“写操作”的过程中,对于故障恢复AOF只能把所有的操作命令重放,会影响Redis主线程的执行效率。所以我们使用RDB内存快照进行更快的数据恢复。 【RDB快照】:
内存快照把某一时刻的状态以文件的形式写入磁盘中。RDB复制一般是全量复制,即需要牵扯到大量数据从而阻塞主线程。所以Redis提供了bgsave命令来进行RDB复制:Redis创建一个子进程专门用于写入RDB文件,不会阻塞主线程。
但是我们在生成快照时,如果又写入新数据会导致快照不完整,如果阻塞写操作会降低Redis性能,所以Redis采用了写时复制,保证RDB快照的可靠性。
写时复制:对于读操作,主线程与子进程互不影响;对于主线程的写操作,fork子进程实际上是复制了主线程最开始的页表,而主线程会把要修改的数据复制到副本中,主线程操作副本并修改自己的页表映射,所以子进程不会受到主线程的影响。 【持久化机制的选择】 若RDB复制频率过小,会丢失在空档期间的数据记录;若RDB复制频率过大,会给磁盘带来很大压力,并且fork创建子进程的过程本身就会阻塞主线程,而且主线程内存越大,阻塞时间越长。所以我们混合使用AOF日志与RDB快照,这样的话快照不用很频繁地执行,而且AOF只需要记录两次RDB快照之间的增量,避免了文件过大造成的重写开销。
Redis淘汰机制
Redis使用的内存空间超过maxmemory时 或者 某个键值对到达过期时间时,会触发Redis的淘汰机制。
淘汰策略 --针对于超过内存空间限制情况时的扫描删除策略
「不进行数据淘汰」:noevction:Redis超过内存空间限制时,再有写请求Redis会直接报错。
「筛选设置了过期时间的键值对」(满内存或KV过期)
①volatile-ttl:根据过期时间的先后进行删除,越早过期的越先被删除。
②volatile-random:Redis会随机抽取20个具有过期时间的键值,删除其中已经过期的键。若删除的键多于5个,则Redis会立即再做一次循环。
③volatile-lru:最近最少使用。Redis中的每个字段数据都有一个lru字段,记录了最近一次访问的时间戳。Redis触发淘汰机制时会随机挑选N个数据作为一个候选集合,然后淘汰其中lru最小的数据。当再次触发淘汰机制时,Redis只会选择lru字段值小于集合中最小lru的数据进入候选集合,当候选集合数据达到阈值时,再删除lru最小的数据。但是对于扫描式单次查询 即仅一次查询访问了大量的键值对,lru的做法是每次触发淘汰机制时仅会拿出其中的一个淘汰,其他的低频键值对依然保留在内存中。LRU更关注数据的时效性,适用于一般的场景。
④volatile-lfu:
LFU是在LRU的基础上为每个数据增加了一个计数器来统计这个数据的访问次数的。Redis触发LFU淘汰机制时,首先淘汰访问次数最低的数据,若两个数据访问次数相同,LFU再比较这两个数据的访问时效性,淘汰距离上一次访问时间更久的数据。
LFU的实现:把原来的lru字段分成了idt访问时间戳和counter访问次数,比较时先比较访问次数再比较时间戳。(补充:但它的计数规则并不是,而是用计数器当前值*lfu_log_factor并取倒数,然后与(0,1)随机数比较大小,若大于才+1)。
而且LFU还设有lfu_decay_time衰减因子来不断衰减数据的访问次数,防止数据在段时间内被大量访问后不再被访问,它的被访问次数依然很大且无法被淘汰(补充:Redis会计算当前时间与此数据最近一次访问时间的差值,然后再除以lfu_decay_time得到的结果计算要衰减的值)
LFU更关注数据的访问频率,适用于扫描式查询场景
[筛选全部数据](满内存) ①allkeys-random:随机删除。②allkeys-lru:同上LRU
③allkeys-lfu:同上LFU 但是,Redis不会自动把修改的数据重新写回数据库,继而淘汰Redis数据时会导致修改丢失。所以就需要我们保证修改数据时的MySQL与Redis一致。
Redis与数据库的缓存一致性
读写缓存
读写缓存是对缓存的操作读写相当的场景(Redis当作数据库,MySQL备份)。一般就是在写缓存的同时把数据也写入数据库,当缓存满时需要使用淘汰机制把缓存淘汰入数据库。但是可能会出现异步落库失败,这样对短期的业务是不会影响的,但一旦缓存过去或满容后被淘汰,就会对业务产生影响。
解决:1)可以采取每晚凌晨进行全量校对,以Redis中为准。2)可以使用消息队列:把异步更新数据库的操作放入消息队列中。若操作成功则删除消息,否则进行消息重试。
读写缓存并发问题:
在读写并发时,因为是先写缓存,所以可能会造成缓存和数据库的短暂不一致。但是并发读读到都是缓存中的新数据,不会影响业务逻辑.
在写写并发时,可能会出现线程A和线程B更新缓存和更新数据库的操作执行顺序不一致的问题,导致线程A覆盖线程B更新的数据库,线程B覆盖线程A更新的Redis,出现数据不一致的情况。所以我们只能用ReentrantLock读写锁来保证每个线程的串行化操作
只读定向缓存
只读缓适用于对缓存大部分操作都是读,很少修改的场景。新增数据会直接写入数据库,不写入缓存,在查询缓存时若没有就从数据库读入即可。删改数据时直接会把缓存中的数据标记失效,等待下一次从数据库中查询出新值即可(或者监控MySQL的binlog,然后向订阅者发送消息)。
只读缓存在删改时会引发的缓存一致性问题:
①若先删缓存中的数据,但删数据库时失败了,则再次查询会查到数据库的旧数据;若先删数据库的数据,但删缓存失败了,就直接查到缓存中的旧数据了。解决:使用消息队列暂存要被删除的值,删除完成后回发ACK并去除队列中的值。则经过多次重试后,队列中留存的值就是执行删除失败了,就向业务层发送报错信息即可。
②若先删除缓存,数据库由于网络延迟没有及时更新。此时其他线程开始读数据,就会读到数据库中的旧数据。解决:1.延迟双删-线程在删除缓存与更新数据库的值之后,sleep一小段时间,再删除一次缓存,这样可以保证最终一致性。反过来,一样。
缓存异常
热key问题
突然有大量请求去访问某个特定key,流量过于集中导致Redis雪崩
解决方案
提前把热key打散到不同服务器
提前加载热key到内存中 redis宕机则走内存
缓存雪崩
Redis中有大量的热点数据同时过期。那此时对这些数据的请求就会直接打到数据库。如果并发请求量很大, 数据库就有可能无法支撑导致崩溃。或者Redis实例宕机引发的缓存雪崩。
解决方案
缓存定时预先更新,同时设置过期时间为 原时间+随机数,避免同时失效。
可以采用服务降级,当发生缓存雪崩时,让暂停非核心数据从缓存中的查询,让他们直接返回预定义的信息或者空值。
通过主从的方式构建Redis缓存集群。
缓存击穿(Redis中没有,但DB中有)
对刚刚失效的某一个key集中发起大量请求,导致大量请求穿过Redis直接打到了数据库,即redis失效(就好像桶上凿开了一个洞)
解决方案
若定时更新缓存,可以采用主从轮询的方式解决缓存击穿:添加缓存时添加主从双份,定时器更新缓存时先更新从缓存。用户先查询主缓存,若用户查不到就去查从缓存
加锁更新:比如查询某个key不存在Redis中,则对它加锁,然后到数据库中查询、写入缓存、返回给用户、解锁。这样后面的请求就可以从缓存中拿到数据了
缓存穿透(Redis和DB中都没有)
外部恶意攻击,大量请求访问Redis不存在的数据,导致大量请求直接打到数据库中。
解决方案
使用布隆过滤器解决缓存穿透:快速判断一个元素是否在数据库中。若不在则直接return,避免去数据库中查找存入数据库中的数据也存入布隆过滤器。当访问的数据布隆过滤器都不存在时,就不再去mysql中找了(redis→布隆过滤器→mysql)
Redis-分布式锁
基于单Redis节点实现分布式锁:
使用一个String键值对作为分布式锁,加锁时value置为1,释放锁时置为0.因为单Redis节点对数据的操作是单线程的,所以Redis会串行处理多客户端的请求。
加锁包含了三个操作,所以使用SETNX命令完成加锁,DEL命令完成释放锁(SET key value NX PX 10000 过期时间10秒/redisTemplate.opsForValue().setIfAbsent(k,v,time,timeUnit).如果要设置的kv不存在那么它会先创建kv再设置值。这里会保证原子性,即SETNX与expire是原子操作了)。
不过,为了预防某个客户端宕机无法执行DEL命令而造成死锁:我们要给锁变量设置一个过期时间。(expire key xxxS) 为了让锁变量能够区分不同客户端(因为锁过期但master命令还没执行完,其他master重新获取锁,然后前master命令执行完后释放锁,导致冲突,就会误删掉别人的锁):每个客户端获取锁变量时应该设置value为自己的唯一值(分段锁)或随机值,在解锁时lua判断锁的value释放与自己写入时的一致,防止其他客户端释放自己的锁;
但是对于超卖,仅用setNX根本无法保证。setNX只是compare and set,和扣减库存无关。所以我们应该保证setNX与扣减库存的原子性,或者保证每次只能抢购一个,然后进行Decr判断返回值即现有库存,为负则发生超卖,然后再可以通过滑动库存锁优化。
但是上面还有问题。我们在准备DEL删除锁时,正好该锁失效了,恰好另一个请求获取了key值相同的此锁。也就是说SETNX PX与DEL不是原子性操作。所以我们要使用lua脚本来保证原子性。
但如果发生脑裂、主备切换,就会导致重复加锁(因为新主并不知道已经有节点获取锁了),主备切换本质上的产生原因是因为Redis为了保证高性能,采用了主备异步复制。所以我们使用红锁算法,采用多独立master投票机制,避免主备异步复制的缺陷。
基于Redis集群的高可靠分布式锁(红锁算法):
基本思想:让客户端和多个独立的Redis实例依次请求加锁,如果客户端能和半数以上的Redis实例成功加锁,那么客户端就成功获得了分布式锁
步骤:
①客户端获取当前时间
②客户端按序依次向N个Redis实例执行加锁操作(结合上面的lua脚本)
③当客户端完成了和所有Redis实例的加锁操作,则客户端计算整个加锁过程的总耗时。然后判断是否满足 客户端从超过半数的Redis实例上获取到了锁 && 总耗时没有超过锁的有效时间。若满足,则重置锁的有效时间为初始值 - 客户端加锁的总耗时,然后我们可以评估有效时间和我们需要的操作时间,若不够了则直接不加锁了。若不满足,则依次释放锁,
但现在的难题是,我们并不知道客户端需要多久的分布式锁。
如果设置的过短,客户端可能还没用完锁就被Redis释放了;如果设置时间过长,客户端不能及时释放锁,就会导致其他客户端需要阻塞等待。所以,我们可以给每个锁设置较短的过期时间,然后在客户端还未使用完成但锁即将过期之前,自动延长该锁的过期时间。
那么如何实现锁的自动续约呢?
使用协程进行事件循环,协程每过0.x * lock_timeout事件就尝试延长锁的过期时间,完成后再把自己加入事件循环中。客户端释放锁时,也创建一个取消掉该循环事件的事件加入协程即可。
Redis实例宕机了如何解决:
我们可以使用Redis主从分片,采用读写分离的方式,主库处理写操作,然后同步给从库;主从共同处理读操作。Redis通过主从同步机制和哨兵机制来保证主库和从库的一致性,从而解决主库宕机问题。通过一致性hash算法,进行对节点的均匀分配请求。
主从同步
本质上是通过持久化文件来保证主从数据的一致性。
①当一台Slave实例启动时,它会向master发送命令,建立socket长连接,向master发送psync命令,表示要进行数据同步。master接收到slave的命令,fork一个子进程执行全量复制,生成rdb快照,与此同时master会将同步期间的增量数据记录到replication Buffer中。生成完成后把rdb快照发送给slave。
②Slave拿到RDB快照,先清空当前的数据,然后将rdb数据加载进内存,再通知master将replication Buffer中的增量数据发送给自己。
③后续的增量操作就通过AOF日志来同步了。
哨兵机制
哨兵机制是用来实现主从节点自动切换的。哨兵本质上是一个特殊状态的Redis实例,它可以通过info命令监控从Redis服务的可用性,通过发布/订阅来完成哨兵集群节点之间的通信。
①master下线:哨兵会每秒钟向Redis服务实例发送Ping。若在有效时间内没有收到回复,则哨兵会标记该Redis实例主观下线。若该服务实例为master节点,则哨兵会询问其他的哨兵节点。当多数哨兵都认为该master下线时,master才会客观下线,然后进行master重新选举。
②重新选举master:哨兵会根据时长短、优先级高、复制的RDB数据多、进程ID小,来选择master节点。选取完成后会向master节点发送slaveof no one命令,让它成为独立节点。然后向其他节点发送命令,让她们成为master节点的子节点。
脑裂
在Redis主从集群中,由于原主节点CPU被占满导致无法响应哨兵的心跳,所以又产生了一个新的主节点,以至于客户端不知道应该向哪个主节点写入数据。而且在哨兵切换主节点时,原主库在全量复制后,要清空本地所有的数据,然后加载新主库的RDB文件。所以在新旧主库主从切换期间保存的新写数据就丢失了。
如何配置Redis集群
①创建每个子节点的文件夹,每个节点对应的配置文件中修改port端口。 ②redis-cli --cluster create ip+port1 ip+port2... --cluster -replicas 1(这个1表示每个主节点一个从节点)
Redis Pipeline
批处理,把多个Redis操作合并为一个原子操作。预防集群间的并发问题,在并发出错概率非常小的情况下,可以利用pipeline+手动回滚来代替互斥锁。
- Redis集群
Cluster:Redis节点是通过pub/sub发布订阅机制来实现集群实例的相互发现的。也就是说,他们在启动时并不知道彼此,在连接主库后,通过发布订阅同一频道才知道了对方的ip+port
Codis架构
1.
2.Codis server:二次开发的Redis实例,负责执行redis读写请求,支持数据迁移。
3.Codis Proxy:接收并转发客户端请求给codis server集群。
4.Zookeeper集群:保存集群元数据,比如数据路由表、codis proxy访问列表。
5.Codis dashboard:管理集群,增删codis server、codis proxy地址、进行数据迁移、修改数据路由信息。Codis fe为操作页面。
三、基本读写流程
1.
四、Codis集群
1.数据分布
(1)Codis利用slot槽位来进行数据存取路由。Codis集群共有0-1023个槽位,codis dashboard会将slot分配给Codis Server。客户端读写数据时,会先根据“key进行CRC32计算出哈希值再取余1024”得到key的槽位,然后根据Zookeeper中存储的server-slot映射表,得到key所在的codis server。
(2)codis dashboard会把Server-slot映射表持久化到zookeeper集群中,保证codis数据路由的可用。codis dashboard还会把ZK中的路由表同步到codis-proxy的本地缓存中。新添加的数据都会被codis dashboard更新到Server-slot路由表中(我们也可以从codis fe手动修改)。
2.集群扩容与数据迁移
(1)数据量越来越大,server不足以支撑现有数据量,就需要Codis Server扩容。请求量越来越大,为了支撑请求,就需要Codis Proxy扩容。
(2)Codis Server扩容:数据迁移
①同步迁移:以slot为粒度将数据迁移到新server上,源server会阻塞等待目的server的返回确认,确认后再迁移slot上的下一个数据,同时本地迁移走的数据,直至迁移完成。
②异步迁移:源server一次性发出迁移slot数据命令后立即返回,并把本地迁移数据标记只读。目的server收到数据并反序列化保存后会返回ACK,源server收到ACK后删除本地迁移数据。且bigkey会进行拆分指令迁移,避免序列化大量资源导致阻塞的问题。Bigkey还有临时过期时间,防止迁移过程中的codis故障导致迁移非原子性。
(3)Codis Proxy扩容
①通过codis dashboard将codis proxy加入集群,更新zookeeper上存储的访问列表,分摊客户端请求压力
3.可靠性
(1)每个Codis Server都采用主从模式,组成每个server group,通过哨兵集群进行监控