什么是redis?
redis 是一个使用 C 语言写成的,开源的 key-value 缓存数据库。它支持的数据类型包括String(字符串)、List(链表)、Set(集合)、Zset(sorted set --有序集合)和Hash(哈希类型)。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。为了保证效率,这些数据都是缓存在内存中。
redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。
redis支持的数据类型
1. String
常用命令: set,get,decr,incr,mget 等。
String数据结构是简单的key-value类型,value其实不仅可以是String,也可以是数字。
String类型是Redis最基本的数据类型,一个键最大能存储512MB。
应用:常规计数:微博数,粉丝数等。
2. Hash
常用命令: hget,hset,hgetall 等。
Redis Hash是一个String类型的field和value的映射表,Hash特别适合用于存储对象。
3. List
常用命令: lpush,rpush,lpop,rpop,lrange等。
List就是链表,Redis List的应用场景非常多,也是Redis最重要的数据结构之一,比如微博的关注列表,粉丝列表,最新消息排行等功能都可以用Redis的List结构来实现。
4. Set
常用命令:sadd,spop,smembers,sunion 等。
Redis的Set是String类型的无序集合。与List不同的是可以自动去重。Set也提供了判断某个成员是否在一个Set集合内的重要接口。
在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis可以非常方便的实现如共同关注、共同喜好、二度好友等功能。
5. Sorted Set
常用命令: zadd,zrange,zrem,zcard等。
与Set相比,Sorted Set增加了一个权重参数score,使得集合中的元素能够按score进行有序排列。
在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息,适合使用Redis中的SortedSet结构进行存储。
问:MySQL里有2000w数据,Redis中只存20w的数据,如何保证Redis中的数据都是热点数据?(redis有哪些数据淘汰策略?)
redis 提供6种数据淘汰策略:
- volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-enviction(驱逐):禁止驱逐数据
redis这么快的原因?
1. 完全基于内存,绝大部分请求是纯粹的内存操作,不受磁盘I/O限制,执行效率较高。
2. 数据结构简单,对数据操作也简单。redis不使用表、不用预定义。其存储结构就是键值对,所以对数据的操作时间复杂度都是O(1)。
3. 采用单线程(只针对处理I/O请求的主线程,在处理除了I/O有关的业务时,还是多线程),单线程也能处理高并发请求,想多核也可以启动多实例。
redis对于高并发的请求,使用的是主线程为单线程的结构,主线程:处理I/O事件、I/O对应的相关业务的处理、过期存储键的处理、主从协调、集群协调。这些除了I/O有关的业务都会被封装为一个周期性的任务,进行周期性的处理。对于客户端的请求,都由一个主线程串行进行处理,避免了频繁的上下文切换和锁竞争。
4. 使用多路I/O复用模型,非阻塞IO。
redis的I/O操作是单线程的,所有的操作都是线性执行的,所以某一请求如果需要等待用户进行读写操作,就会阻塞I/O,从而影响其他的客户端的请求响应。所以引入了I/O多路复用。
redis的持久化
为了防止数据丢失以及服务重启时能够恢复数据,redis支持数据的持久化,主要分为两种方式,分别是RDB和AOF。
1. RDB
RDB(快照持久化)持久化是把当前进程数据生成快照保存到磁盘上的过程,由于是某一时刻的快照,那么快照中的值要早于或者等于内存中的值。比照mysql的redo_log日志
生成的rdb文件的名称以及存储位置由redis.conf中的dbfilename和dir两个参数控制,默认生成的rdb文件是dump.rdb。
触发方式
触发rdb持久化的方式有2种,分别是手动触发和自动触发。
手动触发:
redis客户端执行save命令和bgsave命令都可以触发rdb持久化,但是两者还是有区别的。
1. 使用save命令时是使用redis的主进程进行持久化,此时会阻塞redis服务,造成服务不可用直到持久化完成,线上环境不建议使用;
2. bgsave命令是fork一个子进程,使用子进程去进行持久化,主进程只有在fork子进程时会短暂阻塞,fork操作完成后就不再阻塞,主进程可以正常进行其他操作。
3. bgsave是针对save阻塞主进程所做的优化,后续所有的自动触发都是使用bgsave进行操作。
自动触发:
在以下4种情况时会自动触发
1. redis.conf中配置save m n,即在m秒内有n次修改时,自动触发bgsave生成rdb文件;
2. 主从复制时,从节点要从主节点进行全量复制时也会触发bgsave操作,生成当时的快照发送到从节点;
3. 执行debug reload命令重新加载redis时也会触发bgsave操作;
4. 默认情况下执行shutdown命令时,如果没有开启aof持久化,那么也会触发bgsave操作;关闭rdb持久化:
1. 执行命令(redis-cli):config set save ""
2. 修改配置文件:
// 打开该行注释 save "" // 注释掉以下内容 # save 900 1 # save 300 10 # save 60 10000
rdb持久化的流程图如下所示:
- redis客户端执行bgsave命令或者自动触发bgsave命令;
- 主进程判断当前是否已经存在正在执行的子进程,如果存在,那么主进程直接返回;
- 如果不存在正在执行的子进程,那么就fork一个新的子进程进行持久化数据,fork过程是阻塞的,fork操作完成后主进程即可执行其他操作;
- 子进程先将数据写入到临时的rdb文件中,待快照数据写入完成后再原子替换旧的rdb文件;
- 同时发送信号给主进程,通知主进程rdb持久化完成,主进程更新相关的统计信息(info Persitence下的rdb_*相关选项)。
优点
1. RDB文件是某个时间节点的快照,默认使用LZF算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
2. redis加载RDB文件恢复数据要远远快于AOF方式;
缺点
1. RDB方式实时性不够,无法做到秒级的持久化;
2. 每次调用bgsave都需要fork子进程,fork子进程属于重量级操作,频繁执行成本较高;
3. RDB文件是二进制的,没有可读性,AOF文件在了解其结构的情况下可以手动修改或者补全;
4. 版本兼容RDB文件问题;
2. AOF (Append-Only-File)
aof方式持久化是使用文本协议将每次的写命令记录到aof文件中,经过文件重写后记录最终的数据生成命令,在redis启动时,通过执行aof文件中的命令恢复数据。比照mysql的undo_log日志
aof方式主要解决了数据实时性持久化的问题,aof方式对于兼顾数据安全性和性能非常有帮助。
开启aof
开启aof模式持久化需要修改redis.conf文件中的如下配置:
# 开启aof appendonly true # aof文件名称 appendfilename "appendonly.aof" # aof文件存储位置 dir ./
也可以在redis客户端使用命令行的方式开启或者关闭aof
# 开启aof config set appendonly yes # 关闭aof config set appendonly no
aof持久化流程如图所示:
1. append
aof文件只记录写命令,不记录读命令,当服务端接收到写命令后,redis会将命令写入到aof缓冲区中,之所以写入缓冲区而不直接写入aof文件中是因为如果每次都将命令直接写入到文件中,那么redis的性能将完全取决于硬盘的读写能力,这与redis性能至上的理念不符,另外,写入缓冲区中也便于使用不同的同步策略。
2. sync
文件同步,即将aof缓冲区中的命令同步到aof文件中,redis提供三种策略以供选择,由参数appendfsync控制,三种策略分别是:
always: 表示命令append到缓冲区以后调用系统fsync操作同步到aof文件中,fsync操作完成后主线程返回;
no:表示命令写入aof缓冲区后调用操作系统write操作,不对aof文件做fsync同步,同步到硬盘操作由操作系统负责,通常同步周期最长30秒;
everysec:表示命令写入aof缓冲区后调用操作系统write操作,write操作完成后主线程返回,由专门的线程每秒去进行fsync同步文件操作。
3. 重写(rewrite)
随着写命令越来越多,aof文件的体积也越来越大,此时就需要重写机制来按照特定的机制清除或者合并命令从而达到减小文件体积,便于redis重启加载的目的。
重写机制
- 进程内已经过期的数据不再写入文件;
- 只保存最终数据的写入命令,如set a 1, set a 2, set a 3,此时只保留最终的set a 3;
- 多条写命令合并为一条命令,如lpush list 1, lpush list 2, lpush list 3合并为lpush list 1,2,3,同时为了防止单条命令过大,对于list、set、zset、hash等以64个元素为界限拆分为多条命令。
重写流程:
- 手动或者自动触发文件重写后主进程需要先判断当前是否有子进程存在,如果存在则直接返回,不存在则fork子进程;
- fork操作完成后,主进程即可响应其他命令,在子进程生成新的aof文件过程中,主进程仍然维持原来的流程以保证原有aof机制的正确性;
- 在子进程生成新的aof文件过程中主进程执行的新命令同时会被写入到aof重写缓冲区中,当新aof文件生成后再将这一部分命令写入到新aof文件中,防止数据丢失;
- 子进程根据内存快照,根据重写规则生成新的aof文件,每次批量写入硬盘数据量由配置aof-rewrite-incremental-fsync控制,默认为32MB,防止单次刷盘数据过多造成硬盘阻塞;
- 父进程把aof重写缓冲区的数据写入到新的aof文件中;
- 使用新aof文件替换旧的aof文件并发送信号给主进程表示重写完成。
优点
1. 数据安全性较高,每隔1秒同步一次数据到aof文件,最多丢失1秒数据;
2. aof文件相比rdb文件可读性较高,便于灾难恢复;
缺点
1. 虽然经过文件重写,但是aof文件的体积仍然比rdb文件体积大了很多,不便于传输且数据恢复速度也较慢;
2. aof的恢复速度要比rdb的恢复速度慢。
补充:fork以及copy_on_write
fork
简而言之就是创建一个主进程的副本,创建的子进程除了进程id,其余任何内容都和主进程完全一致,这就是fork。
fork创建的子进程独立于主进程而存在,虽然两个进程内存空间的内容完全一致,但是对于内存的写入、修改以及文件的映射都是独立的,两个进程不会相互影响。
通过fork技术完美的解决了快照的问题,只需要某个时间点的内存中的数据,而父进程可以继续对自己的内存进行修改、写入而不会影响子进程的内存,这既不会阻塞主进程也不影响生成快照。
通过fork子进程的方式虽然能够完美解决不阻塞的情况下创建快照的问题,但是又会引入以下的问题:
子进程和主进程拥有相同的内存空间,就相当于瞬间将内存的使用量提高了一倍,假设服务器是16GB内存,主进程占用10GB,那么此时再创建子进程还需奥10GB,很明显超过了总内存,这很显然是存在很大问题的,即使不超过总内存,fork时将内存使用量提高一倍也是不可取的。
copy_on_write
COW就是为了解决fork的内存问题。
COW的主要作用就是将拷贝推迟到写操作真正发生时,这也就避免了大量无意义的拷贝。
在fork子进程时,父子进程会被内核分配到不同的虚拟内存空间中,对于父子进程来说它们访问的是不同的内存空间,但是两个虚拟内存空间映射的仍然是相同的物理内存,也就是说在fork完成后未发生任何修改时,父子进程对应的物理内存是同一份。
如果此时主进程执行了修改或者写入操作?因为有了修改或写入操作,此时父子进程内存就会出现不一致的情况,由于是主进程进行的修改,因此内核会为主进程要修改的内存块创建一个副本供主进程进行修改而不改变子进程的内存,也就是谁发生了修改就要为谁创建相应的副本。
redis实现分布式锁
核心命令:setnx,表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做。例如:两个客户端同时向redis写入try_lock,客户端1写入成功,即获取分布式锁成功。客户端2写入失败,则获取分布式锁失败。
分布式锁可能存在的问题
1. 死锁
服务器宕机导致无法释放锁,从而造成死锁
解决方法:给锁加一个过期时间。
Boolean flag = redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK,value,10L,TimeUnit.SECONDS);
2. 谁加锁,谁释放锁
例如设置了key的过期时间为10秒,如果业务逻辑比较复杂,需要调用其他微服务,处理时间需要15秒(模拟场景,别较真),而当10秒钟过去之后,这个key就过期了,其他请求就又可以设置这个key,此时如果耗时15秒的请求处理完了,回来继续执行程序,就会把别人设置的key给删除了,这是个很严重的问题!
解决方法:释放锁之前判断是否是当前服务的锁。
3. 给锁加过期时间,判断锁的归属,删除锁等操作之间的原子性问题
并发时上述一系列操作如果不是原子性的也会出现数据一致性问题。
解决方法:合并上述操作为一个原子操作。
比如setnx、expire命令合并成一条
SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]
EX seconds − 设置指定的到期时间(以秒为单位)
PX milliseconds − 设置指定的到期时间(以毫秒为单位)
NX − 仅在键不存在时设置键,与setnx类似
XX − 只有在键已存在时才设置
比如推荐Lua脚本进行锁的删除,地址:https://redis.io/commands/set
4. redis集群部署下,异步复制造成的锁丢失:主节点没来得及把刚刚set进来这条数据给从节点,就挂了等问题。
解决方法:直接使用Redisson实现的RedLock。
redis实现异步队列
使用List作为队列,RPUSH生产消息,LPOP消费信息。
redis异步队列可能存在的问题
1. 没有等待队列里有值就直接消费。
解决:可以通过在应用层引入Sleep机制去调用LPOP重试。或者更加精确的操作:BLPOP key [key] timeout ,引入blpop 阻塞直到队列有消息或超时。
2. 只能提供一个消费者进行消费。
解决:使用pub/sub主题订阅者模式。发送者(pub)发送消息,订阅者(sub)接收消息。订阅者可以订阅任意数量的频道。
pub发送消息(publish mytopic “hello”)给频道topic(mytopic),该消息会发送给订阅该topic频道的sub(subscribe mytopic)。但是消息的发布是无状态的,无法保证可达。如果某个订阅者下线,是无法接收到的,此时需要特殊的消息队列kafka、rabbitmq等。
缓存雪崩
redis中大量的Key同时过期,导致大量的请求访问到数据库,甚至造成数据库宕机。
解决:
1. 搭建Redis集群来解决这个问题;
2. 在Key上加一个随机的过期时间,防止批量Key过期。
缓存击穿
redis中某一个热点Key突然失效,导致大量的请求访问数据库,导致高并发情况下请求直接穿透redis缓存,到达数据库,给数据库造成非常大的压力。
解决:
考虑热点的Key是否需要设置不过期,或者根据相关需求设置合理的过期时间。
缓存穿透
某一时刻大量不存在的Key访问到redis服务,可以理解为一个黑客伪造大批量脏数据访问到缓存当中,因为缓存中没有这些Key,所以造成批量穿透访问到数据库。
解决:
使用布隆过滤器,只要是它认为是不存在的Key,那么这个Key就不存在,所以可以设置在缓存之前加一层布隆过滤器,拦截不存在的Key。
redis集群
redis提供了三种集群策略:主从模式,哨兵模式(sentinel),集群模式(cluster)。
1. 主从模式
将一台redis服务器的数据复制到其他redis服务器的一个过程,前者称为主节点(master),后者称为从节点(salve)。
redis主从模式中,一般是有一个master进行写操作、若干个sliver进行读操作,定期的备份操作也是(随机)选择一个sliver进行的,以便最大程度发挥redis的性能,为的是让其支持数据的最终一致性,不需要实时的保证master和sliver之间的数据是同步的,但是一定是趋于同步的。
redis可以使用主从同步和从从同步。第一次同步时,主节点做一次全量bgsave生产RDB文件,后续的增量操作记录在内存的buff中。待bgsave完成后,将RDB文件全量同步到从节点中,从节点将镜像全量加载到内存中,加载完成后,再通知主节点,将该期间的增量数据同步到从节点进行重放,从而完成同步过程。
2. 哨兵模式(sentinel)
redis主从同步的弊端,主节点宕机后,无法对外提供写入操作,redis sentinel(redis哨兵)可以使用主从切换解决该问题。
redis sentinel是分布式系统,在集群中可以运行多个redis sentinel进程,这些进程使用流言协议(Gossip)来接收主节点是否下线的通知和发现新节点,使用投靠协议决定是否执行自动故障迁移和选择哪一个从节点升级为新的主节点。
监测过程:
1.监控,检查主从服务器是否运行正常;
2.提醒,通过API向管理员或者其他应用程序发送故障通知;
3.自动故障迁移,主从切换(将宕机的主节点中的一个从节点升级为新的主节点,让其他的从节点识别新的主节点,进行主从同步,并向正在请求主节点的客户端返回新主节点的地址,保证集群的正常运行)。
3. redis集群(cluster)
集群技术是构建高性能网站架构的重要手段,redis集群采用无中心结构,每个节点都保存数据和整个集群的状态,每个节点都和其他节点进行连接,通过Gossip协议传递信息。集群的主要目的是将不同的key分散放置在不同的redis节点。
实现原理:
按照常规做法,获取key的hash值,然后通过节点数求模,但是有弊端,无法实现节点的动态增减。因此可以使用一致性hash算法。
如何从海量数据里快速找到所需
1. 分片,按照某种规则去划分数据,分散存储在多个redis节点上,通过分片操作可以降低某些redis节点的压力。
2. 一致性hash算法,对2^32取模,将hash值空间组织成虚拟的圆环。
- 假设某hash函数h的值空间为0~2^32-1,即hash值是一个32位的无符号整型。
- 将redis服务器(ip、主机名等)进行hash的变换,确定redis服务器在hash圆环上的位置。
- 将数据key使用相同的函数hash计算出hash值,顺时针最近的结点,确定此数据在hash环上的位置,进行分散存储。
3. 对于节点的增减,影响的只有一小部分数据,因此具有较好的容错性。
hash环的数据倾斜问题
当节点很少时,容易出现数据倾斜问题,即大量的缓存数据集中在某一台redis服务器上。
解决:可以引入虚拟结点解决数据倾斜问题,即对于一个redis服务器计算多个hash值,在该hash值对应的圆环上加入虚拟结点。数据定位算法不变,只是多了一步,数据从虚拟结点到实际结点的映射。还可以引入主从同步、redis哨兵等技术,确保集群的高可用性。
redis 如何保证数据库和缓存双写一致性
场景:一个读数据请求,一个写数据请求。当写数据请求把缓存删了之后,读数据请求,可能把当时从数据库查询出来的旧值,写入缓存当中。
方案1. 延时双删。
为什么要延时?
场景:
- 请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
- 这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
- 请求c将数据库中的旧值,更新到缓存中。
- 此时,请求d卡顿结束,把新值写入数据库。
- 一段时间之后,比如:500ms,请求d将缓存删除。
- 这样来看确实可以解决缓存不一致问题。
方案2. 先写数据库,再删缓存
在高并发的场景中,有一个读数据请求,有一个写数据请求,更新过程如下:
- 请求e先写数据库,由于网络原因卡顿了一下,没有来得及删除缓存。
- 请求f查询缓存,发现缓存中有数据,直接返回该数据。
- 请求e删除缓存。
在这个过程中,只有请求f读了一次旧数据,后来旧数据被请求e及时删除了,看起来问题不大。但如果是读数据请求先过来呢?
- 请求f查询缓存,发现缓存中有数据,直接返回该数据。
- 请求e先写数据库。
- 请求e删除缓存。
这种情况看起来也没问题。
问题:删缓存失败怎么办?
其实先写数据库,再删缓存的方案,跟缓存双删的方案一样,有一个共同的风险点,即:如果缓存删除失败了,也会导致缓存和数据库的数据不一致。那么,删除缓存失败怎么办呢?
答:需要加重试机制。
异步重试方式有很多种,比如:
- 每次都单独起一个线程,该线程专门做重试的工作。但如果在高并发的场景下,可能会创建太多的线程,导致系统OOM问题,不太建议使用。
- 将重试的任务交给线程池处理,但如果服务器重启,部分数据可能会丢失。
- 将重试数据写表,然后使用elastic-job等定时任务进行重试。
- 将重试的请求写入mq等消息中间件中,在mq的consumer中处理。
- 订阅mysql的binlog,在订阅者中,如果发现了更新数据请求,则删除相应的缓存。