Redis是一个内存型数据库,读写速度非常快,常广泛应用于缓存,或分布式缓存,也常常用作高性能的分布式锁,其中Redis提供了各种数据结构(比如String,List,Set,Zset,Hash)来支持不同业务场景,自带持久化机制,过期策略,不同内存淘汰策略,提供Redis主从进行数据备份实现读写分离,哨兵模式进行故障自动主从转移实现高可用,Redis集群进行数据分片存储以及故障转移实现高并发高可用
为什么要用缓存?
高性能:缓存是直接操作内存,速度相当快。使用缓存可加快用户访问速度,提高用户体验
高并发:缓存能够承受的请求是远远大于直接访问数据库的,将大部分对数据库的请求转移到缓存上去,可以减低数据库负载,提高请求并发量.
为什么要用Redis作为缓存?
Redis采用单线程的文本事件处理器,使用非阻塞式的IO多路复用技术同时监听多个Socket,将Socket产生的事件放入队列中等待事件分派器将事件分派给对应的事件处理器进行处理。
比如客户端向服务器发起连接请求,此时 Server socket接收到该连接请求会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 Server socket 产生的事件后,将该事件压入队列中。文件事件分派器从队列中获取该事件,交给连接应答处理器。连接应答处理器会将Server Socket的 AE_READABLE 事件与命令请求处理器关联。
假设此时客户端发送了一个 set key value 请求,此时 redis 中的 Server Socket 会产生 AE_READABLE 事件,IO 多路复用程序将事件压入队列,此时事件分派器从队列中获取到该事件,由于Server socket的 AE_READABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。处理完成后,它会将 Server Socket 的 AE_WRITABLE 事件与命令回复处理器关联。如果此时客户端准备好接收返回结果了,那么 redis 中的 Server Socket 会产生一个 AE_WRITABLE 事件,同样压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 Server Socket 输入本次操作的一个结果,比如 ok,之后解除 Server Socket 的 AE_WRITABLE 事件与命令回复处理器的关联。这样便完成了一次通信
(1)String
常用命令:set,get,setnx(不存在时设置),setex(设置过期时间),decr,incr等
String是Redis中最基本的类型,是简单的key-value类型,Redis的string可以包含任何数据,比如jpg图片或者序列化的对象,一个键最大能存储512MB。常用于常规key-value缓存应用,比如常规计数:点赞数,微博数,粉丝数等。
(2)Hash
常用命令:hget,hset等
Hash 是一个 string 类型的 field 和 value 的映射表,常用于存储对象,比如存储用户信息,商品信息等等。
(3)List
常用命令:lpush,rpush,lpop,rpop,lrange,Blpop,Brpop
List 是一个双向链表, list常用于存储列表信息,比如微博的关注列表,粉丝列表,消息列表。并且list提供了blpop,brpop队列阻塞操作,可用于作为阻塞队列,List提供了lrange范围查询,可用于分页操作
(4)Set
常用命令:sadd,srem,sinter,sunion,sdiff,smembers,sismember,scard 等
Set 是一个自动去重的集合。当你需要存储一个列表数据,又不希望出现重复数据时,Set是一个很好的选择,使用Set 可轻易实现集合交集、并集、差集的操作,例如求共同好友,共同关注问题,共同粉丝等等
(5)Sorted Set
常用命令: zadd,zrem,zrange,zcount,zcard等等
sorted set是一个通过score排序的有序map,Sorted Set常用于排序,例如用户积分排名,活跃度排名等等
(6) BitMap
常用命令:SETBIT,GETBIT,BITCOUNT, BITOP
BitMap 是一种二进制位图,利用较小的内存记录超大数据量的存在/不存在信息,可用于记录日活跃用户,用户上线次数统计等等。具体见https://www.cnblogs.com/davidwang456/articles/9314662.html
(7)HyperLogLog
常用命令: PFADD,PFCOUNT,PFMERGE
HyperLogLog 用于基数统计,提供不精确的去重计数方案(标准误差是 0.81%),并且每个HyperLogLog 键只需要花费 12 KB 内存。它虽并不保留输入的元素但对输入元素具有记忆性能进行去重统计。比如统计需要每个网页每天的UV(不重复访问量,同一个用户一天之内的多次访问请求只能计数一次),如果统计PV(访客量), 那非常好办,给每个网页一个独立的 Redis 计数器就可以了,这个计数器的 key 后缀加上当天的日期。这样来一个请求,incrby 一次,最终就可以统计出所有的 PV 数据。若使用set需要为每一个页面一个独立的 set 集合来存储所有当天访问过此页面的用户 ID,如果你的页面访问量非常大,比如一个爆款页面几千万的 UV,你需要一个很大的 set 集合来统计,这就非常浪费空间。Redis 提供了 HyperLogLog 数据结构就是用来解决这种统计问题的.
(8)Geo
常用命令:geoadd,geopos,geodist,georadius
Geo 用于记录地理位置,可以向其中添加地理空间位置(纬度、经度、名称),就可以测量两地距离,距离某地距离多远的地点有哪些
redis内存淘汰机制是为针对缓存而设计,redis有一个最大内存是,可通过设置maxmemory来完成,当内存达到maxmemory时,则需要按照相应的策略对内存中的数据进行淘汰删除使得内存可以继续留有足够的空间保存新的数据。
redis 提供的8种淘汰策略:
(1)volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近未使用的数据淘汰。每一个对象都记录了一个lru,即该对象空转时间,LRU算法是在其数据集中随机挑选几个键值对,取出其中 lru 最小的键值对淘汰。
(2)volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选最将要过期的数据淘汰。从过期时间的表中随机挑选几个键值对,取出其中 ttl 最大的键值对淘汰
(3)volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
(4)allkeys-lru:从数据集(server.db[i].dict),移除最近最少使用的key(这个是最常用的)
(5)allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
(6)no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
4.0版本后增加以下两种:
(7)volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选使用频率最小的key淘汰。每一个对象记录着一个创建时间,以及使用次数,可计算出使用频率,从数据集中随机挑选几个键值对,取出其中使用频率最低的键值对淘汰
(8)allkeys-lfu:从数据集(server.db[i].dict)中,移除使用频率最小的key
常见策略选择:
(1)allkeys-lru:如果我们的redis应用是缓存应用,对缓存的访问存在相对热点数据,或者我们不太清楚我们应用的缓存访问分布状况,我们可以选择allkeys-lru策略。这个策略可以保证redis都是热点数据
(2)allkeys-random: 如果我们的redis应用是缓存应用,对于缓存key的访问概率相等,则可以使用这个策略。
(3)volatile-ttl:如果对数据有足够的了解,能够为key指定hint(通过expire/ttl指定),那么可以选择volatile-ttl进行置换
当redis作为一个内存型数据库时,需要将其内存的数据进行持久化写入文件以备机器重启或者机器故障之后的数据恢复,并生成的RDB文件可用于redis集群数据同步,数据备份。
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
appendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘
appendfsync no #让操作系统决定何时进行同步
Redis主从提供了数据备份,可用于实现读写分离,并且Redis主从是故障转移的必要条件
主服务器将写命令执行,向从服务器传播,让从服务器执行写命令,保证主从一致。
在命令传播阶段,从服务器会对主服务器进行心跳检测,每隔1S发送一个心跳检测请求,并且带有从服务器复制偏移量,主要用于检测主从网络连接状况以及在命令丢失条件下进行命令补发
Redis集群实现了数据分片存储以及故障转移,为缓存服务提供了高并发高可用可扩展的保证
一个集群由多个Redis节点组成,一个Redis节点就是一个运行在集群模式下的Redis服务器,而每个节点都会使用一个ClusterNode来记录自己节点的状态,一个ClusterState保存整个集群信息。
Redis集群通过分片方式来保存数据库的键值对,整个集群被分为16384个槽[0-16383],每一个键必定属于其中一个槽
分槽信息记录与传播
重新分片实现原理
Redis重新分片操作是由集群管理软件redis-trib负责,redis-trib通过向源节点和目标节点发送命令进而实现重新分片
Moved错误
客户端向服务器发出键命令,节点计算该键对应的槽位置,如果该槽在本节点,直接处理,否则找到负责该槽的节点,向客户端返回一个Moved错误并带有负责节点的ip和端口信息等Moved :,客户端接受到Moved错误后将请求转向该负责节点,由该负责节点处理该请求
ACK错误
如果一个节点收到了关于键key的命令请求,并且键Key的所在槽i由该节点负责,那么该节点会尝试在自己数据库中查找Key,若找到则直接返回,若没找到可能键Key对应的槽正在迁移,则会检查migrating_slots_to[i],若槽i没有迁移则说明key不存在,若槽i确实在迁移,则会向客户端返回ACK错误,引导其到正在导入的节点中去查找Key.
故障检测原理
集群中每个节点都会定期向其他节点发送ping命令,检测对方在线情况,若在规定时间内未收到该节点的PONG回复,则将该节点标记为疑下线状态为其生成下线报告,并且将下线报告广播给其他节点,集群中各个节点通过互相发消息的方式交换各节点的状态信息。若集群中存在一个节点发现某个主节点被半数以上的主节点标记为疑下线报告,则将其标记为已下线(Fai),并向集群广播该主节点Fail的消息,所有收到Fail消息的节点都将其标记为已下线(Fai)。接下来从节点对已下线的主节点进行故障转移。
故障转移原理
(1)基于Raft选举算法从 从节点选举出主节点
(2)将下线的主节点的槽撤销并分配给新的主节点
(3)新的主节点向集群广播一条PONG消息,宣布自己从 从节点变为主节点,使其它节点刷新对自己的认知
(4)故障转移完成,新的主节点开始接受和自己负责的槽相关的命令
Redis主从选举算法
(1)当从节点得知自己跟随的主节点已下线后,会向集群中发送投票消息进行广播,要求其他负责槽的主节点对其进行投票
(2)集群中的每个负责槽的主节点都由投票权,并且都会将票投给第一个向它申请的从节点,并向其支持的从节点发送支持消息
(3)如果某个从节点收到超过半数的主节点的支持消息,则该从节点成为主节点,主从选举结束,否则,集群进入新的配置纪元,再次投票重复上述过程直到选择出一个主节点
注:为了简化,以上选举过程都默认是在同一配置纪元下进行
- redis主从可以进行数据备份,进而进行读写分离,从节点处理读请求,主节点处理写请求,redis主从也是故障转移的必要条件
- 哨兵用于实现故障自动转移,保证高可用性,但哨兵没有解决写操作无法负载均衡及使用的全增量式存储,存储能力受到单机限制,不具有分布式特点,需要使用集群解决,一般使用集群,不用哨兵
- 集群不仅将数据分散存储,还提供了类似哨兵的故障转移,保证了高并发性高可用性
原因:缓存穿透是指故意请求数据库中一定不存在的数据,导致缓存失效,请求全部打到数据库中了,可能导致数据库崩掉
解决方案
原因:缓存击穿是指热点Key失效,大量请求同时请求过期键,请求全部打到数据库上去了,导致数据库崩掉。
解决方案
(1) 加入互斥锁,只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据就可以了,在分布式下可使用分布式锁(memcache的add,redis的setnx, zookeeper的添加节点操作)
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_mutex, "1")) {
// 3 min timeout to avoid mutex holder crash
redis.expire(key_mutex, 3 * 60)
value = db.get(key);
redis.set(key, value);
redis.delete(key_mutex);
} else {
//其他线程休息50毫秒后重试
Thread.sleep(50);
get(key);
}
}
}
(2) “永远不过期”,在redis内部确实没有为Key设置过期时间,但在Key对应的value对象中设置1个超时值(timeout1),当从cache读取到value时若发现它要过期了,通过一个后台的异步线程进行缓存的构建。这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,会出现数据不一致的情况,但是对于一般的互联网功能来说这个还是可以忍受
原因:缓存同一时间大面积的失效,比如缓存服务器宕机,同一时间大量缓存过期效等情况,导致大量请求打到数据库上,造成数据库短时间内承受大量请求而崩掉
解决方案
(1)保证缓存层服务高可用性,采用分布式缓存,主从替换策略进行故障转移,即使其中一台缓存服务器宕机,仍也可对外提供服务
(2)依赖隔离组件Hystrix为后端限流并降级,比如限流组件可以限制每秒最多2000个请求打到数据库上,多余的其余请求被降级,比如提示用户服务器忙,请稍后再试。
(2)采用双缓存策略。本地缓存+redis缓存,先去找本地缓存,再去找redis缓存,最后再找数据库,这样即使redis缓存挂掉,本地缓存还可以撑一会
(4)为不同的key,设置不同的过期时间,在原有基础上加一个随机time,让缓存失效的时间点尽量均匀,在同一时间不会有大量的key失效
https://www.jianshu.com/p/a9bbcd3ec1e5
高并发下缓存+数据库双写一致性方案
(1) 为什么是删除缓存,而不是更新缓存?
很多时候缓存数据并不是直接从一张表就能够查到的,有时需要关联多张表才能查询到数据,并且有时写操作很多,读操作很少,导致频繁更新缓存,却又很少使用缓存,代价太高得不偿失,而删除缓存则只可保证只要在使用时需要进行一次更新缓存操作,其实是采用Lazy思想,只有缓存真正被使用的时候才去做具体工作,可以避免重复计算,重复更新问题
(2) 为什么先删除缓存在去更新数据库而不采用先更新数据库在删除缓存?
如果先删除缓存然后更新数据库,理论上是无论删除缓存删除失败还是数据库更新失败,数据库与缓存数据都会保持一致。而先更新数据库在删除缓存,当更新数据库成功,删除缓存失败,则数据库与缓存数据不一致
(3) 先删除缓存再去更新数据库的读写冲突问题?
写操作删除完缓存后,准备更新数据库的时候,如果一个读请求过来查询数据,缓存不存在,查询数据库中的旧数据,更新旧数据到缓存中。此时就会出现读写冲突的问题。那么如何解决这一方案出现的问题那?
解决方案:采取读写操作串行化,读操作等待写操作完成后再进行
更新数据的时候,根据更新的Key,将写操作路由,发送到对应的内部队列中。并且一个队列对应一个工作线程,每个工作线程拿到对应的操作,然后一条一条的执行。执行时,先删除缓存,然后再去更新数据库,
但若正准备更新时一个读请求过来,读取数据的时候,发现数据不在缓存中,根据Key路由之后,发送同一个内部队列中,并同步等待对应的写队列缓存全部更新完成,其他的读请求如果看到队列里面已经有一个读请求在排队了,那么他们就不入队列,而是在外面轮询一段时间看看缓存中是否有数据了,比如每隔10ms轮询一次,最多轮询10次,如果有则直接返回,如果读请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。
高并发的场景下,该解决方案要注意的问题
该解决方案最大的风险点在于说,可能数据更新很频繁,导致队列中积压了大量更新操作在里面,然后读请求会发生大量的超时,最后导致大量的请求直接走数据库。务必通过一些模拟真实的测试,看看更新数据的频率是怎样的。并且估计读请求最多hang多少时间,可能需要部署多个服务,每个服务分摊一些数据的更新操作。
如果一个内存队列里居然会挤压 100 个商品的库存修改操作,每个库存修改操作要耗费 10ms 去完成,那么最后一个商品的读请求,可能等待 10 * 100 = 1000ms = 1s 后,才能得到数据,这个时候就导致读请求的长时阻塞。一定要做根据实际业务系统的运行情况,去进行一些压力测试,和模拟线上环境,去看看最繁忙的时候,内存队列可能会挤压多少更新操作,可能会导致最后一个更新操作对应的读请求,会 hang 多少时间,如果读请求在 200ms 返回,如果你计算过后,哪怕是最繁忙的时候,积压 10 个更新操作,最多等待 200ms,那还可以的。如果一个内存队列中可能积压的更新操作特别多,那么你就要加机器,让每个机器上部署的服务实例处理更少的数据,那么每个内存队列中积压的更新操作就会越少。