Redis进阶:
redis特点:
- Redis采用单线程模式处理请求
- 采用了非阻塞的异步事件处理机制;
- 缓存数据都是内存操作IO事件不会太长,单线程可以避免线程上下文切换产生的代价。
- Redis支持持久化,所以Redis不仅仅可以作为缓存,还可以作为NoSQL数据库。
- 相比Memcache 相比,Redis很大的优势是除了K-V之外还支持多种数据格式。
- Redis提供主从同步机制,以及Cluster集群部署能力,可以提供高可用服务。
HyperLogLog:
关于基数统计
基数统计(Cardinality Counting) 通常是用来统计一个集合中不重复的元素个数。
比如有一个问题计算一个网站UV
PV(浏览量,用户没点一次记录一次)和UV(独立访客,每个用户每天只记录一次)
如果是第一种问题给网页设置一个计数器,每每进行执行指令incrby就行;
第二种情况的话你可能会想到设置一个set集合器存放访问ID
但是问题就是如果访问流量太大,集合就会很大
如果需要去统计这个集合,也会很困难。
概率算法 不直接存储 数据集合本身,通过一定的 概率统计方法预估基数值,这种方法可以大大节省内存,同时保证误差控制在一定范围内
而众多概率算法中的HyperLogLog的表现是惊人的,存储1亿个统计数据他只需要1k左右。在 Redis 中实现的 HyperLoglog 也只需要 12 K 内存,在 标准误差 0.81% 的前提下,能够统计 264 个数据!
HyperLogLog的使用:
HyperLogLog提供了两个指令,PFADD和PFCOUNT,意思就是增加的,另一个是获取计数,类似set集合的 sadd使用。
当然,除了上面的 PFADD
和 PFCOUNT
之外,还提供了第三个 PFMEGER
指令,用于将多个计数值累加在一起形成一个新的 pf
值:
BloomFilter:布隆过滤器
存在是不一定的,不存在是一定的
Bloom Filter的优点
是一个占用空间很小、效率很高的随机数据结构,它由一个bit数组和一组Hash算法构成。
主要作用是:判断一个元素是否在一个集合中,具有查询效率很高,节省空间等优点;
Bloom Filter的缺点
bloom filter之所以能做到在时间和空间上的效率比较高,是因为牺牲了判断的准确率、删除的便利性,但是在大部分情况下的生成环境是可以接受的。
- 存在误判,Bloom Filter说这条数据存在,这条数据不一定存在;但是Bloom Filter说这条数据不存在,这条数据一定不存在。
- 删除困难。
Lua:
Redis官方文档介绍:
我们Redis采用相同的Lua解释器去运行所有命令,我们可以保证,脚本的执行是原子性的。作用就类似于加了MULTI/EXEC。 相当于就是redis的事务。
这个在并发场景下使用 redis 是非常合适的。
秒杀场景经常使用这个东西,利用他的原子性。
哨兵:Sentinal
是Redis来保证保证集群高可用的方式。在master宕机时会自动将slave提升为master,继续提供服务。
哨兵组件的主要功能:
- 集群监控:负责监视机器是否正常运行;
- 消息通知:如果某个机器出问题,就发送消息警报;
- 故障转移:如果主机宕机了,会转移到其他上;
- 配置中心:故障发生转移了,通知客户端新的主机地址。
哨兵必须使用三个实例来保证自己的健壮性的,哨兵+主从不能保证数据不丢失,但是可以保证集群的高可用。
master宕机了,s1和s2哨兵只要有一个认为你宕机了就切换了,并且会选择一个哨兵去执行故障,但是这个时候也要大多数哨兵是运行的。
如果是两个哨兵的情况下有可能不能执行故障转移的情况:
如果是master机器损坏的话,哨兵就只剩下一个s2,没有哨兵进行故障转移了,虽然另一个机器有R1,但是故障转移就是不执行。
经典集群是M1所在的机器挂了,哨兵还有两个,就选举一个出来执行故障转移。
Memcache:
官方一点的说法:Memcache 是高性能的分布式内存缓存服务器。一般的使用目的是,通过缓存数据库查询结果,减少数据库访问次数,以提高动态Web 应用的速度、提高可扩展性。
特点:
- 请求处理时候使用的多线程异步IO的方式,可以合理的利用CPU多核优势,性能很优越;
- 功能简单,使用内存存储数据;
- 对缓存的数据可以设置失效期,过期的数据会被清除;
- 失效的策略采用延迟失效,就是当再次使用数据时候会检查是否失效;
- 当内存满了,会对缓存中的数据进行剔除,剔除时处理对过期key进行清理,还会按LRU策略进行数据剔除。
另外Memcache的限制也是很致命的,是现在互联网选择Redis和MongoDB的重要原因:
- key不能超过250个字节;
- value不能超过1M字节;
- key最大的失效时间是30天;
- 只支持K-V结构,不能提供持久化和主从同步功能。
分步式锁;
当多个线程需要同时操作一个数据时,为避免出现数据异常,我们要将数据锁起来,使用结束后在 将锁打开,此时其他线程才可以继续访问该数据,Redis中使用分布式锁实现此场景
一般情况下,我们使用分布式锁主要有两个场景:
- 避免不同节点重复相同的工作:比如用户执行了某个操作有可能不同节点会发送多封邮件;
- 避免破坏数据的正确性:如果两个节点在同一条数据上同时进行操作,可能会造成数据错误或不一致的情况出现;
setnx(SET if Not eXists)
- 添加一个key,该key为分布式锁,我们知道
setnx
在设置数据时如果数据存在则返回0 - 设置该数据为锁,其他客户端要操作数据前先通过该指令的返回值检测如果返回值为0则表示当前数 据已被锁定不能操作,如果返回值为1表示加锁,然后操作
- 对加锁的数据使用后要解锁,通过
del lock-num
移除数据的方式实现解锁过程
先拿setnx来争抢锁,抢到之后,再用expire给锁加一个过期时间防止锁忘记了释放
此处设置了10秒过期时间。
分步锁的问题:
- A若是拿到锁,没有机会执行第二条指令就会发生死锁问题;
Redis2.8后的加入了 SET
指令的扩展参数:
set key value [ex seconds] [px milliseconds] [nx]设置分布式锁
分步锁的问题:
- 锁过期:A拿到锁操作时间太久导致所自动释放,被别人拿到锁了;不够准确的评估自己的操作时间;
- 释放别人的锁:没有判断是否自己的锁。
解决方式:
-
在加锁的时候先设置一个过期时间,同时在开启一个守护线程,定期去查看锁的失效时间,若是即将过期且操作共享资源的还未结束,就会自动进行锁续期的操作,重新设置过期时间。
此处有一个库已经实现了此些工作:Redisson
Redisson是一个java语言实现的Redis SDK客户端,再使用分布式锁时候会自动实现上述操作;
-
在加锁的时候进行设置一个编号,在释放锁的时候进行判断是否属于自己的锁
加锁:
释放锁:
释放锁使用的是GET+DEl指令,又需要说明的是原子操作了,
同时把get和del合成一条指令来用 使用Lua脚本, Lua 脚本可以 保证多个指令的原子性执行。
Redis的缓存一致性问题
对数据库一般都是读或者写操作
一般读操作的基本步骤就是经典的KV、DB读写模式
Cache Aside Pattern
- 读的时候:先读缓存,缓存没有进行数据库查询,然后取出数据后进行放入缓存,同时返回响应;
- 更新的时候:先更新数据库,再删除缓存。
/*
而对于更新数据库就会造成数据库和缓存数据不一致的问题;
理论上说给缓存设置一定的过期时间,当数据在一定时间内过就不会发生缓存的数据和数据库的数据不一致的问题
只要缓存时间过期,数据就会被删除了。然后就会从数据库进行查询且执行写入缓存操作。但是我们除了要设置过期时间该需要进行更异步的操作进行方式缓存不一致的问题。
*/
而此处就需要进行处理缓存和设置数据库成为一个原子操作;
更新数据库有两种方式:
- 先操作缓存,在操作数据库
- 先操作数据库,在操作缓存
操作缓存一般也有两种操作:
- 更新缓存:不使用
- 删除缓存:使用,体现一个懒加载的方式 在以前的文章里有说过
一、先操作数据库,在操作缓存:
正常情况:
-
数据库操作成功,缓存删除成功
异常情况:
-
操作数据库成功,删除缓存失败,导致数据库是新数据,而缓存是旧数据,不一致情况发生;
-
操作数据库失败,直接返回错误,不会出现不一致情况。
以下可能发生的例子:
- 缓存刚好失效
- 线程A查询数据库,得一个旧值
- 线程B将新值写入数据库
- 线程B删除缓存
- 线程A将查到的旧值写入缓存
删除缓存失败的解决思路:
- 将需要删除的key发送到消息队列中
- 自己消费消息,获得需要删除的key
- 不断重试删除操作,直到成功
二、先删除缓存,在操作数据库:
正常情况:
-
数据库操作成功,缓存删除成功
异常情况:
-
删除缓存成功,操作数据库失败,不会出现不一致情况;
-
删除缓存失败,返回异常
看起来没问题,但是
以下可能发生的例子:
- 线程A删除了缓存
- 线程B查询,发现缓存已不存在
- 线程B去数据库查询得到旧值
- 线程B将旧值写入缓存
- 线程A将新值写入数据库
解决方式
可以将
-
将删除缓存、修改数据库、读取缓存等的操作积压到队列里边,实现串行化。
-
延时双删:
-
思路是,为了避免更新数据库的时候,其他线程从缓存中读取不到数据,就在更新完数据库之后,再sleep一段时间,然后再次删除缓存。
-
sleep的时间要对业务读写缓存的时间做出评估,sleep时间大于读写缓存的时间即可。
流程如下:
- 线程A删除缓存,然后去更新数据库
- 线程B来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程A还没有更新完成,所以读到的是旧值,然后把旧值写入缓存
- 线程A,根据估算的时间,sleep,由于sleep的时间大于线程B读数据+写缓存的时间,所以缓存被再次删除
- 如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值;就会实现缓存一直的情况。
-
两种策略各自有优缺点:
-
先更新数据库,再删除缓存
- 在高并发下基本可以,在原子性被破坏不行
-
先删除缓存,再更新数据库
- 在高并发下不行,在原子性被破坏时基本可以