Redis基础
1.1 什么是Redis?
Redis 是速度非常快的非关系型(NoSQL)内存键值数据库,可以存储键和五种不同类型的值之间的映射。
五种类型数据类型为:String, List, Set, Sorted Set, Hash。
Redis 支持很多特性,例如将内存中的数据持久化到硬盘中,使用复制来扩展读性能,使用分片来扩展写性能。
1.2 为什么使用Redis?
主要是从两个角度去考虑:性能和并发。当然,redis还具备可以做分布式锁等其他功能,但是如果只是为了分布式锁这些其他功能,完全还有其他中间件(如zookpeer等)代替,并不是非要使用redis。因此,这个问题主要从性能和并发两个角度去答。
(一)性能(缓存快速响应)
我们在碰到需要执行耗时特别久,且结果不频繁变动的SQL,就特别适合将运行结果放入 Redis 中缓存。这样,后面的请求就去缓存中读取,使得请求能够迅速响应。
(二)并发(减少了数据库请求)
在大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。这个时候,就需要使用redis做一个缓冲操作,让请求先访问到redis,而不是直接访问数据库。
下面为一些具体的使用场景:
计数器
可以对 String 进行自增自减运算,从而实现计数器功能。
例如对于网站访问量,如果使用 MySQL 数据库进行存储,那么每访问一次网站就要对磁盘进行读写操作。而对 Redis 这种内存型数据库的读写性能非常高,很适合存储这种频繁读写的计数量。
缓存
将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。
查找表
例如 DNS 记录就很适合使用 Redis 进行存储。
查找表和缓存类似,也是利用了 Redis 快速的查找特性。但是查找表的内容不能失效,而缓存的内容可以失效。
消息队列
List 是一个双向链表,可以通过 lpop 和 lpush 写入和读取消息。
不过最好使用 Kafka、RabbitMQ 等消息中间件。
会话缓存
在分布式场景下具有多个应用服务器,可以使用 Redis 来统一存储这些应用服务器的会话信息,使得某个应用服务器宕机时不会丢失会话信息,从而保证高可用。
分布式锁实现
在分布式场景下,无法使用单机环境下的锁实现。当多个节点上的进程都需要获取同一个锁时,就需要使用分布式锁来进行同步。
除了可以使用 Redis 自带的 SETNX 命令实现分布式锁之外,还可以使用官方提供的 RedLock 分布式锁实现。
其它
Set 可以实现交集、并集等操作,例如共同好友功能。
ZSet 可以实现有序性操作,例如排行榜功能。
1.3 使用Redis会面临的问题
主要是四个问题,后面有一章(第5章)会讲这些问题的解决方法。
- 缓存和数据库双写一致性问题
- 缓存穿透问题
- 缓存击穿问题
- 缓存雪崩问题
- 缓存的并发竞争问题
1.4 Redis 单线程高性能的原因
原因大体有3点:
- 纯内存操作
- 单线程操作,避免了频繁的上下文切换
- 采用了非阻塞I/O多路复用机制,如 epoll 等
1.5 五种数据类型
字符串
支持求字串操作,以及数字的计算操作。
底层使用long(如果可以转为long)或动态字符串存储。
列表
两端压入或读出;读取单个多个元素;只保留范围内的一个值。
底层使用压缩链表 ziplist 或 双向链表 linkedlist 实现。
集合
支持交并差等集合操作,支持随机获取一个元素。
底层使用 intset 或 hashtable 实现。intset 查找可使用二分查找。
有序集合
支持范围查查找;支持获取key的排名。
底层使用 ziplist 或 zset(跳表+Hashtable) 实现。zset 的跳表结点用来存key,hashtable 用来存 key 相应的分数大小,这样就可以在 O(1) 的时间内获取分数。
哈希表
根据键值查找值
底层使用 ziplist 或 hashtable 实现。
1.6 与memcached比较
-
数据类型上:Redis 支持丰富的数据类型,memcached 只支持字符串。
-
IO 模型是:Redis 单线程,memchched 多线程
Memcached是多线程,非阻塞IO复用的网络模型,分为监听主线程和worker子线程,监听线程监听网络连接,接受请求后,将连接描述字pipe 传递给worker线程,进行读写IO, 网络层使用libevent封装的事件库,多线程模型可以发挥多核作用,但是引入了cache coherency和锁的问题,比如,Memcached最常用的stats 命令,实际Memcached所有操作都要对这个全局变量加锁,进行计数等工作,带来了性能损耗。
Redis使用单线程的IO复用模型,自己封装了一个简单的AeEvent事件处理框架,主要实现了epoll、kqueue和select,对于单纯只有IO操作来说,单线程可以将速度优势发挥到最大,但是Redis也提供了一些简单的计算功能,比如排序、聚合等,对于这些操作,单线程模型实际会严重影响整体吞吐量,CPU计算过程中,整个IO调度都是被阻塞住的。
-
持久化上:Redis 可以持久化数据, memcached 不支持
-
淘汰机制上:Redis 支持多种淘汰机制,memcached 只支持 lru。
-
内存分配上:Redis 使用时动态申请内存来存储数据,memcached 使用预先分配的内存池
-
一致性上:Redis 使用事物支持,memcached 使用 CAS 操作。
-
分布式的支持上:Memcached 不支持分布式,只能通过在客户端使用一致性哈希这样的分布式算法来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。Redis Cluster 实现了分布式的支持。
口诀:“类型线程持久化,淘汰内存一致性”
2. 过期策略及内存淘汰机制
Redis 可以为每个键设置过期时间,当键过期时,会自动删除该键。
注意键只能是五种类型的键,对于散列表这种容器,只能为整个键设置过期时间(整个散列表),而不能为键里面的单个元素设置过期时间。
2.1 redis是如何删除过期数据的
redis采用的是定期删除+惰性删除策略。
为什么不用定时删除策略?
定时删除,用一个定时器来负责监视key,过期则自动删除。虽然内存及时释放,但是十分消耗CPU资源。在大并发请求下,CPU要将时间应用在处理请求,而不是删除key,因此没有采用这一策略.
定期抽样删除+惰性删除是如何工作的呢?
定期抽样删除,redis默认每个100ms检查,是否有过期的key,有过期key则删除。需要说明的是,redis不是每个100ms将所有的key检查一次,而是随机抽取进行检查(如果每隔100ms,全部key进行检查,redis岂不是卡死)。因此,如果只采用定期删除策略,会导致很多key到时间没有删除。
于是,惰性删除派上用场。也就是说在你获取某个key的时候,redis会检查一下,这个key如果设置了过期时间那么是否过期了?如果过期了此时就会删除。
采用定期删除+惰性删除就没其他问题了么?
不是的,如果定期删除没删除key。然后你也没即时去请求key,也就是说惰性删除也没生效。这样,redis的内存会越来越高。那么就应该采用内存淘汰机制。
在redis.conf中有一行配置
# maxmemory-policy volatile-lru
2.2 内存淘汰机制
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。推荐使用。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。这种情况一般是把redis既当缓存,又做持久化存储的时候才用。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。
特别的,如果没有设置 expire 的key, 不满足先决条件(prerequisites); 那么 volatile-lru, volatile-random 和 volatile-ttl 策略的行为, 和 noeviction(不删除) 基本上一致。
3. 持久化和主从复制
Redis 是内存型数据库,为了保证数据在断电后不会丢失,需要将内存中的数据持久化到硬盘上。
3.1 快照持久化(RDB持久化)
配置文件redis.conf
中配置save <time> <n>
表示每隔time秒后若有n条数据变化,则进行持久化。
优点
- RDB持久化的Redis只有一张表,十分适合将快照复制到其它服务器从而创建具有相同数据的服务器副本。
- 在恢复大数据集是也较快(与AOF)相比。
缺点
- 阻塞/耗内存。save 会同步阻塞 Redis 直到持久化完成;bgsave不阻塞,但会fork 请求一个大小相同的 redis 内存(写时复制技术可以很好的解决这个问题)。
- 耗时。全量复制。如果数据量很大,保存快照的操作时间会很长。
- 丢失数据。如果系统发生故障,将会丢失最后一次创建快照之后的数据。所以可能会丢失较多的数据。
最佳策略
- 建议关闭RDB 无论是Redis主节点,还是从节点,都建议关掉RDB。但是关掉不是绝对的,主从复制时还是会借助RDB。
- 用作数据备份 RDB虽然是很重的操作,但是对数据备份很有作用。文件大小比较小,可以按天或按小时进行数据备份。
- 主从,从开? 在极个别的场景下,需要在从节点开RDB,可以再本地保存这样子的一个历史的RDB文件。虽然从节点不进行读写,但是Redis往往单机多部署,由于RDB是个很重的操作,所以还是会对CPU、硬盘和内存造成一定影响。根据实际需求进行设定。
3.2 AOF 持久化
使用日志文件来记录所有的写操作命令,并在服务器启动时,重新启动这次命令来还原数据集。 每次写时都会将写命令添加到 AOF 文件(Append Only File)的末尾。
由于AOF丢失数据的可能更小,所以当和RDB共存时,会优先使用AOF恢复。
持久化时机
对硬盘的文件进行写入时,写入的内容首先会被存储到缓冲区,然后由操作系统决定什么时候将该内容同步到硬盘,用户可以调用 file.flush() 方法请求操作系统尽快将缓冲区存储的数据同步到硬盘。
将写命令添加到 AOF 文件时,要根据需求来保证何时将添加的数据同步到硬盘上,有以下同步选项:
选项 | 同步频率 |
---|---|
always | 每个写命令都同步 |
everysec | 每秒同步一次 |
no | 让操作系统来决定何时同步 |
always 选项会严重减低服务器的性能;everysec 选项比较合适,可以保证系统奔溃时只会丢失一秒左右的数据,并且 Redis 每秒执行一次同步对服务器性能几乎没有任何影响;no 选项并不能给服务器性能带来多大的提升,而且也会增加系统奔溃时数据丢失的数量。
优点
默认每秒同步一次写命令到硬盘,丢失数据的可能更小
随着服务器写请求的增多,AOF 文件会越来越大;Redis 提供了一种将 AOF 重写的特性,能够去除 AOF 文件中的冗余写命令。
AOF 重写步骤
- Redis执行fork() ,现在同时拥有父进程和子进程,子进程分析数据库创建新的 AOF 文件
- 在子进程创建过程中,父进程接收到写命令不仅写入到现有的 AOF 文件,同时写到 AOF 重写缓存区中,等到子进程创建完成,再将 AOF 缓冲区中的命令写入到新的 AOF 文件
- 父进程将新的 AOF 文件替换掉旧的 AOF 文件,完成重写。
缺点
AOF生成的文件通常会比RDB生成的文件大
数据恢复较慢
最佳策略
- 建议开启AOF 如果Redis数据只是用作数据源的缓存,并且缓存丢失后从数据源重新加载不会对数据源造成太大压力,这种情况下。AOF可以关。
- AOF重写集中管理 单机多部署(多个Redis在进行AOF持久化)情况下,发生大量fork可能会内存爆满。
- everysec 建议采用每秒刷盘策略
3.3 主从复制
通过使用 slaveof host port 命令来让一个服务器成为另一个服务器的从服务器。
一个从服务器只能有一个主服务器,并且不支持主主复制。
从服务器连接主服务器的步骤
- 主服务器创建快照文件,发送给从服务器,并在发送期间使用缓冲区记录执行的写命令。快照文件发送完毕之后,开始向从服务器发送存储在缓冲区中的写命令;
- 从服务器丢弃所有旧数据,载入主服务器发来的快照文件,之后从服务器开始接受主服务器发来的写命令;
- 此后主服务器每执行一次写命令,就向从服务器发送相同的写命令。
主从链
随着负载不断上升,主服务器可能无法很快地更新所有从服务器,或者重新连接和重新同步从服务器将导致系统超载。为了解决这个问题,可以创建一个中间层来分担主服务器的复制工作。中间层的服务器是最上层服务器的从服务器,又是最下层服务器的主服务器。
不足
上述的主从模式有个缺陷就是,当主节点挂掉时需要手动选择一个从节点成为主节点,人工介入就会比较麻烦。redis 2.8 假如了哨兵工具,用来检测主从结点的运行状态,并在主节点挂掉时自动切换一个从结点为主节点。
3.4 Redis Cluster
上一节的主从复制实现的高可用方式,每个结点都保存了全量数据,浪费了内存且有木桶效应(一个结点慢会拖累它之后所有的从结点)。
有了Cluster功能后,Redis从一个单纯的NoSQL内存数据库变成了分布式NoSQL数据库,CAP模型也从CP变成了AP。也就是说,通过自动分片和冗余数据,Redis具有了真正的分布式能力,某个结点挂了的话,因为数据在其他结点上有备份,所以其他结点顶上来就可以继续提供服务,保证了Availability。然而,也正因为这一点,Redis无法保证曾经的强一致性了。这也是CAP理论要求的,三者只能取其二。
一些概念
slot:虚拟存储分区。共 16834 个 slot,
- 每个master 管理一部分 slot,这可以由 redis-trib.rb 初始化配置,用户也可以手动配置。
- clusterState.slots 数组记录了相应下标的槽对应的 master 结点信息。
- 集群使用 CRC16(key) % 16834 计算 key 的槽位置。
master :负责读写。
slave:负责冷备。也可以设置可读让slave 可以接收读请求。
Redis Cluster 所有的服务结点都相互通过总线连接,总线端口为客户端服务端口+1000。
重新分片(Resharding)
重新将一个槽的 master 结点转移到另一个 master 结点上。槽迁移的过程中有一个不稳定状态,假设将一个槽从主机 A 转移到主机 B。
转移开始时,A 处于 MIGRATING 状态,B 处于 IMPORTING 状态,转移过程中,每个键的转移过程都是拷贝到B,删除A的步骤,那么转移过程中的槽的一部分键会位于 A 上,一部分键会位于 B 上。
所以当 A 接收到键的槽处于MIGRATING 请求会有以下几种情况:
- 如果Key存在则成功处理
- 如果Key不存在,则返回客户端ASK,仅当这次请求会转向另一个节点,并不会刷新客户端中node的映射关系,也就是说下次该客户端请求该Key的时候,还会选择MasterA节点
- 如果Key包含多个命令,如果都存在则成功处理,如果都不存在,则返回客户端ASK,如果一部分存在,则返回客户端TRYAGAIN,通知客户端稍后重试,这样当所有的Key都迁移完毕的时候客户端重试请求的时候回得到ASK,然后经过一次重定向就可以获取这批键
相应的当 B 接收到键的槽处于 IMPORTING 状态时会有下面的情况:
- 正常命令会执行 MOVED 重定向,因为处于 IMPORTING 槽在完成转移还不属于当前结点,仍要去访问旧主。
- ASKING 命令会使对 IMPORTING 槽的请求有效
MOVED 重定向
键的槽不在当前结点,需要永久重定向,客户端接收到此重定向应当更新路由缓存
ASK 重定向
键的槽正在转移过程中,键可能已被转移走了,所以只改变下一次请求的结点,之后继续请求当前结点。完整语义如下:
- 如果客户端接收到
ASK
转向, 那么将命令请求的发送对象调整为转向所指定的节点。- 先发送一个
ASKING
命令,然后再发送真正的命令请求。
读写
客户端访问 redis cluster 集群时:
- 请求键的槽属于当前结点可读,不处于 MIGRATING 状态,直接返回值
- 请求键的槽属于当前结点可读,处于 MIGRATING 状态,若存在 key 直接返回值;不存在 key 则返回 ASK 重定向
- 请求键的槽不属于当前结点可读,收到非ASKING请求,返回 MOVED 重定向
- 请求键的槽不属于当前结点可读,但收到 ASKING 请求,相应槽位于 IMPORTING 状态,槽返回访问值。
故障转移(Master 选举和 Failover 切换)
4. 分片
Redis 中的分片类似于 MySQL 的分表操作,分片是将数据划分为多个部分的方法,对数据的划分可以基于键包含的 ID、基于键的哈希值,或者基于以上两者的某种组合。通过对数据进行分片,用户可以将数据存储到多台机器里面,也可以从多台机器里面获取数据,这种方法在解决某些问题时可以获得线性级别的性能提升。
假设有 4 个 Reids 实例 R0,R1,R2,R3,还有很多表示用户的键 user:1,user:2,... 等等,有不同的方式来选择一个指定的键存储在哪个实例中。最简单的方式是范围分片,例如用户 id 从 0~1000 的存储到实例 R0 中,用户 id 从 1001~2000 的存储到实例 R1 中,等等。但是这样需要维护一张映射范围表,维护操作代价很高。还有一种方式是哈希分片,使用 CRC32 哈希函数将键转换为一个数字,再对实例数量求模就能知道应该存储的实例。
4.1 客户端分片
客户端使用一致性哈希等算法决定键应当分布到哪个节点。
4.2 代理分片
将客户端请求发送到代理上,由代理转发请求到正确的节点上。
4.3 服务器分片
Redis Cluster。
5. Redis 常见问题的解决方法
5.1 Redis和数据库双写一致性问题
分析:一致性问题是分布式常见问题,还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。答这个问题,先明白一个前提。就是如果对数据有强一致性要求,不能放缓存。我们所做的一切,只能保证最终一致性。另外,我们所做的方案其实从根本上来说,只能说降低不一致发生的概率,无法完全避免。因此,有强一致性要求的数据,不能放缓存。
首先,采取正确更新策略,先更新数据库,再删缓存。其次,因为可能存在删除缓存失败的问题,提供一个补偿措施即可,例如利用消息队列。
5.2 如何应对缓存穿透和缓存雪崩问题
分析:这两个问题,说句实在话,一般中小型传统软件企业,很难碰到这个问题。如果有大并发的项目,流量有几百万左右。这两个问题一定要深刻考虑。
缓存穿透
查询某些 key 时,缓存和数据库查询结果都为空,而空的结果又不被缓存起来,而导致每次查询都去请求数据库层的情况。比如一些设计不好的爬虫就可能导致这种情况出现。
解决方案:
- 缓存空数据
- 提供一个能迅速判断请求是否有效的拦截机制,比如,利用布隆过滤器,内部维护一系列合法有效的key。迅速判断出,请求所携带的Key是否合法有效。如果不合法,则直接返回。
缓存击穿
查询某些 key 时,缓存过期,而导致有效缓存生成之前大量请求怼在数据库上,导致数据库不堪重负。
解决方案:
- 定时更新缓存中的过期key。只适合热点 key 相对固定的业务
- 懒更新。每次 get 请求时,更新过期时间小于一定时间的key
- 加互斥锁。当缓存失效时,一个线程去更新,其他线程阻塞等待更新结果。
- 两级缓存。一级缓存过期时间短,二级缓存过期时间长,当一级缓存过期时,当前线程访问数据库更新一级缓存,再更新完成之前其他线程返回二级缓存的结果。可以使用锁实现。
缓存雪崩,即缓存同一时间大面积的失效,这个时候又来了一波请求,结果请求都怼到数据库上,从而导致数据库连接异常。
解决方案:
-
给缓存的失效时间,加上一个随机值,避免集体失效。
-
双缓存。我们有两个缓存,缓存A和缓存B。缓存A的失效时间为20分钟,缓存B不设失效时间。自己做缓存预热操作。然后细分以下几个小点
- I 从缓存A读数据库,有则直接返回
- II A没有数据,直接从B读数据,直接返回,并且异步启动一个更新线程。
- III 更新线程同时更新缓存A和缓存B。
5.3 如何解决redis的并发竞争key问题
分析:这个问题大致就是,同时有多个子系统去set一个key。这个时候要注意什么呢?大家思考过么。需要说明一下,博主提前百度了一下,发现答案基本都是推荐用redis事务机制。不推荐使用redis的事务机制。因为我们的生产环境,基本都是redis集群环境,做了数据分片操作。你一个事务中有涉及到多个key操作的时候,这多个key不一定都存储在同一个redis-server上。因此,redis的事务机制,十分鸡肋。
回答:如下所示
(1)如果对这个key操作,不要求顺序
这种情况下,准备一个分布式锁,大家去抢锁,抢到锁就做set操作即可,比较简单。
(2)如果对这个key操作,要求顺序
假设有一个key1,系统A需要将key1设置为valueA,系统B需要将key1设置为valueB,系统C需要将key1设置为valueC.
期望按照key1的value值按照 valueA–>valueB–>valueC的顺序变化。这种时候我们在数据写入数据库的时候,需要保存一个时间戳。假设时间戳如下
系统A key 1 {valueA 3:00}
系统B key 1 {valueB 3:05}
系统C key 1 {valueC 3:10}
那么,假设这会系统B先抢到锁,将key1设置为{valueB 3:05}。接下来系统A抢到锁,发现自己的valueA的时间戳早于缓存中的时间戳,那就不做set操作了。以此类推。
其他方法,比如利用队列,将set方法变成串行访问也可以。总之,灵活变通。
6. Redis 事务
WATCH # 相当于 CAS 检测声明
MULTI # 事务开启,之后的命令全部入队列
EXEC # 执行事务,当 WATCH 的变量被其他线程更改时,会执行失败
7. Redis 的实际应用场景
6.1 一个简单的论坛系统分析
该论坛系统功能如下:
- 可以发布文章;
- 可以对文章进行点赞;
- 在首页可以按文章的发布时间或者文章的点赞数进行排序显示;
文章信息
文章包括标题、作者、赞数等信息,在关系型数据库中很容易构建一张表来存储这些信息,在 Redis 中可以使用 HASH 来存储每种信息以及其对应的值的映射。
Redis 没有关系型数据库中的表这一概念来将同类型的数据存放在一起,而是使用命名空间的方式来实现这一功能。键名的前面部分存储命名空间,后面部分的内容存储 ID,通常使用 : 来进行分隔。例如下面的 HASH 的键名为 article:92617,其中 article 为命名空间,ID 为 92617。
点赞功能
当有用户为一篇文章点赞时,除了要对该文章的 votes 字段进行加 1 操作,还必须记录该用户已经对该文章进行了点赞,防止用户点赞次数超过 1。可以建立文章的已投票用户集合来进行记录。
为了节约内存,规定一篇文章发布满一周之后,就不能再对它进行投票,而文章的已投票集合也会被删除,可以为文章的已投票集合设置一个一周的过期时间就能实现这个规定。
对文章进行排序
为了按发布时间和点赞数进行排序,可以建立一个文章发布时间的有序集合和一个文章点赞数的有序集合。(下图中的 score 就是这里所说的点赞数;下面所示的有序集合分值并不直接是时间和点赞数,而是根据时间和点赞数间接计算出来的)