Redis详解
Redis介绍
Redis是C语言编写的开源高性能键值对存储的内存数据库,可以用作数据库、缓存、中间件,是NoSQL(not-only sql,泛指非关系型数据库)的数据库。
Redis作为一个内存数据库。
- 性能优异,数据在内存中,读写非常快,支持并发10W QPS
- 单线程但进程,线程安全,采用IO多路复用。 IO 多路复用指的是 Redis 服务器使用一个单线程来处理多个客户端的连接和请求,通过事件驱动和异步非阻塞 IO 来提高系统的效率和可伸缩性。
- 丰富的数据结构,主要为5种:字符串(String)、哈希表(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)。还有3中新的数据类型:位图(Bitmap)、超日志(HyperLogLog)和地理位置(Geo)。
- 支持持久化,可以将内存中数据保存在磁盘中,重启时加载。
- 可以配置主从模式,或者分布式
- 可以作为消息中间件使用,支持发布订阅。
Redis为啥快
官方提供的数据可以达到100000+的QPS(每秒内的查询次数)
Redis确实是单进程单线程的模型,因为Redis完全是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章的采用单线程的方案了(毕竟采用多线程会有很多麻烦)。
Redis完全基于内存,绝大部分请求是纯粹的内存操作,非常迅速,数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度是O(1)。
数据结构简单,对数据操作也简单。
采用单线程,避免了不必要的上下文切换和竞争条件,不存在多线程导致的CPU切换,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有死锁问题导致的性能消耗。
使用多路复用IO模型,非阻塞IO。
数据类型
redis内部使用一个redisObject对象来表示所有的key和value,redisObject最主要的信息如下图所示:
type表示一个value对象具体是何种数据类型,encoding是不同数据类型在redis内部的存储方式。比如:type=string表示value存储的是一个普通字符串,那么encoding可以是raw或者int。
1、string是redis最基本的类型,可以理解成与memcached一模一样的类型,一个key对应一个value。value不仅是string,也可以是数字。string类型是二进制安全的,意思是redis的string类型可以包含任何数据,比如jpg图片或者序列化的对象。string类型的值最大能存储512M。
2、Hash是一个键值(key-value)的集合。redis的hash是一个string的key和value的映射表,Hash特别适合存储对象。常用命令:hget,hset,hgetall等。
3、list列表是简单的字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部(左边)或者尾部(右边) 常用命令:lpush、rpush、lpop、rpop、lrange(获取列表片段)等。应用场景:list应用场景非常多,也是Redis最重要的数据结构之一,比如twitter的关注列表,粉丝列表都可以用list结构来实现。数据结构:list就是链表,可以用来当消息队列用。redis提供了List的push和pop操作,还提供了操作某一段的api,可以直接查询或者删除某一段的元素。实现方式:redis list的是实现是一个双向链表,既可以支持反向查找和遍历,更方便操作,不过带来了额外的内存开销。
4、set是string类型的无序集合。集合是通过hashtable实现的。set中的元素是没有顺序的,而且是没有重复的。常用命令:sdd、spop、smembers、sunion等。应用场景:redis set对外提供的功能和list一样是一个列表,特殊之处在于set是自动去重的,而且set提供了判断某个成员是否在一个set集合中。
5、zset和set一样是string类型元素的集合,且不允许重复的元素。常用命令:zadd、zrange、zrem、zcard等。使用场景:sorted set可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。当你需要一个有序的并且不重复的集合列表,那么可以选择sorted set结构。和set相比,sorted set关联了一个double类型权重的参数score,使得集合中的元素能够按照score进行有序排列,redis正是通过分数来为集合中的成员进行从小到大的排序。实现方式:Redis sorted set的内部使用HashMap和跳跃表(skipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。
应用场景总结
缓存问题
- 数据一致性
- 雪崩
- 缓存穿透
- 分布式锁
缓存一致性问题
在分布式系统中,缓存一致性问题是指多个缓存副本之间可能存在数据不一致的情况。Redis 作为缓存系统,也可能遇到缓存一致性问题。以下是一些常见的解决方法:
- 使用分布式锁:在更新数据库数据的同时,使用分布式锁确保同时只有一个操作可以修改缓存。
- 缓存空间分配:为缓存分配足够的空间以容纳更新的数据。
- 使用缓存验证:每次从缓存中读取数据后,对数据进行验证,如果验证失败,则从数据库重新加载数据并更新缓存。
- 数据版本控制:为数据库中的每项数据添加版本号或时间戳,当缓存数据过期时,通过版本号确定是否需要从数据库重新加载数据。
- 缓存过期时间设置:合理设置缓存的过期时间,确保缓存中的数据尽可能与数据库中的数据保持一致。
- 二级缓存策略:使用二级缓存(如Redis与本地内存缓存),Redis作为主要缓存,本地内存缓存作为次要缓存,只有在Redis中没有命中时才去本地内存缓存查询。
- 事件通知机制:如果数据库数据发生变更,可以通过事件通知机制通知缓存服务器删除相应的缓存数据。
缓存雪崩
Redis缓存雪崩是指在同一时间内,大量的缓存失效,导致数据库(DB)的负载过高,引起服务异常。这种情况可能会导致服务崩溃。
可能原因:
- 缓存服务器宕机。
- 缓存服务的网络故障。
- 缓存服务的配置错误。
- 缓存的数据过期时间(TTL)设置不合理,大量数据同时失效。
- 缓存服务器的内存溢出或磁盘故障。
解决方法:
- 缓存数据的过期时间设置随机化:避免热点数据集中失效。
- 使用锁或队列机制:确保只有一个客户端去数据库中查询数据并写入缓存。
- 数据预热:启动时预先加载热点数据到缓存中。
- 服务限流与降级:在高峰期对服务进行限流,并提供服务降级策略。
- 监控告警:实时监控Redis的运行状态,发现问题及时告警。
- Redis集群部署:通过集群模式提高可用性。
- 持久化机制:结合RDB和AOF进行数据持久化,防止数据丢失。
- 优化数据结构:使用更节省内存的数据结构和编码。
注意:
- 对于缓存服务器宕机,要保证有备用服务器接管,如使用Redis Sentinel或Redis Cluster。
- 对于网络问题,要确保网络稳定,或者使用云服务提供商的Redis服务。
- 对于配置错误,要定期检查并调整配置。
- 对于内存溢出或磁盘故障,要及时更换硬件或修复问题。
redis 缓存穿透
Redis缓存穿透是指查询请求的key在缓存中不存在,导致请求直接打到数据库上。
例如:数据库的id都是从1自增的,如果发起id=-1的数据或者id特别大不存在的数据,这样的不断攻击导致数据库压力很大,严重会击垮数据库。
为了解决这个问题,可以采用以下几种策略:
- 缓存穿透我会在接口层增加校验,比如用户鉴权,参数做校验,不合法的校验直接return,比如id做基础校验,id<=0直接拦截。
- 缓存空值:如果key不存在于数据库中,可以缓存一个特殊值,如null或空字符串,并设置一个较短的过期时间。
- 使用布隆过滤器:布隆过滤器可以检查一个元素是否可能在集合中,但不保证元素一定在集合中。如果一个元素不在布隆过滤器中,那么它肯定不在数据库中。
布隆过滤器demo
1 | pip install redis-py-cluster pybloom_live |
1 | from rediscluster import RedisCluster |
分布式锁
本地锁
首先我们来回顾下本地锁的问题:
微服务被拆分成了四个微服务。前端请求进来时,会被转发到不同的微服务。假如前端接收了 10 W 个请求,每个微服务接收 2.5 W 个请求,假如缓存失效了,每个微服务在访问数据库时加锁,通过锁(synchronzied
或 lock
)来锁住自己的线程资源,从而防止缓存击穿
。
这是一种本地加锁
的方式,在分布式
情况下会带来数据不一致的问题:比如服务 A 获取数据后,更新缓存 key =100,服务 B 不受服务 A 的锁限制,并发去更新缓存 key = 99,最后的结果可能是 99 或 100,但这是一种未知的状态,与期望结果不一致。
分布式锁
基于上面本地锁的问题,我们需要一种支持分布式集群环境下的锁:查询 DB 时,只有一个线程能访问,其他线程都需要等待第一个线程释放锁资源后,才能继续执行。
用 Redis 实现分布式锁的几种方案,我们都是用 SETNX 命令(设置 key 等于某 value)
Redis与Memache区别
存储方式上:memcache会把数据全部存在内存之中,断电后会挂掉,数据不能超过内存大小。redis有部分数据存在硬盘上,这样能保证数据的持久性。
数据支持类型上:memcache对数据类型的支持简单,只支持简单的key-value,,而redis支持五种数据类型。
使用底层模型不同:它们之间底层实现方式以及与客户端之间通信的应用协议不一样。redis直接自己构建了VM机制,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求。
value的大小:redis可以达到1GB,而memcache只有1MB。
淘汰策略
Redis的内存淘汰策略主要是指当Redis的内存超过了配置的最大内存值时,如何选择和清除数据以释放内存。Redis提供了以下几种策略:
noeviction
: 不进行内存淘汰,当内存不足时,新写入命令会报错。allkeys-lru
: 当内存不足以容纳更多数据时,使用最近最少使用算法(LRU)进行数据淘汰。allkeys-random
: 随机淘汰数据。volatile-lru
: 只对设置了过期时间的键进行LRU算法的淘汰。volatile-random
: 随机淘汰设置了过期时间的键。volatile-ttl
: 根据键值对的ttl属性来淘汰,移除即将过期的键。
可以通过配置文件或者CONFIG SET
命令动态设置淘汰策略。
例如,在redis.conf配置文件中设置内存淘汰策略:
1 | maxmemory-policy allkeys-lru |
或者使用Redis命令动态设置:
1 | CONFIG SET maxmemory-policy allkeys-lru |
在实际应用中,选择哪种淘汰策略取决于你的应用需求和数据的重要性。对于需要保持热点数据的应用,可以选择allkeys-lru策略;而对于允许部分数据丢失的应用,可以选择volatile-lru或allkeys-random策略。
持久化
redis为了保证效率,数据缓存在了内存中,但是会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件中,以保证数据的持久化。
Redis 提供了两种不同的持久化方式:RDB(Redis DataBase)和AOF(Append Only File)。
RDB 持久化:
RDB 是 Redis 默认的持久化方式。它会在一定的间隔时间内将内存中的数据集快照写入磁盘,生成一个dump.rdb文件。
配置文件中的关键配置项:
1 | save 900 1 # 900秒内至少1个键被修改则触发保存 |
AOF 持久化:
AOF 持久化是通过保存 Redis 服务器所执行的写命令来记录数据库状态的。
配置文件中的关键配置项:
1 | appendonly yes # 开启AOF持久化存储 |
在实际应用中,可以根据数据的重要性和性能需求选择合适的持久化方式,或者两者结合使用。如果需要快速恢复数据,可以使用RDB;如果需要保证数据的完整性,可以使用AOF。
主从模式
Redis 的主从复制和哨兵模式是用来保证Redis服务的高可用性的。
主从复制
主从复制是将一个Redis服务器的数据复制到其他服务器,通过配置主从复制,可以实现数据的多点备份,还可以实现读写分离,其中一个服务器作为主服务器,负责处理事务性命令,其他服务器作为从服务器,负责处理非事务性命令。
配置主从复制的步骤如下:
在从服务器的配置文件中加入如下配置:
1 | slaveof <master-ip> <master-port> |
哨兵模式
哨兵模式
哨兵模式是主从复制的升级版,有一个哨兵进程监控主服务器和从服务器,当主服务器宕机后,哨兵会自动进行故障转移,将一个从服务器升级为新的主服务器。
配置哨兵模式的步骤如下:
在哨兵的配置文件中加入如下配置:
1 | sentinel monitor <master-name> <master-ip> <master-port> <quorum> |
注意:在实际的生产环境中,哨兵模式通常与自动扩展的Redis集群(Redis Cluster)一起使用,以提供更好的可用性和可伸缩性。
集群模式
主从模式下实现读写分离的架构,可以让多个从服务器承载「读流量」,但面对「写流量」时,始终是只有主服务器在抗。
「纵向扩展」升级Redis服务器硬件能力,但升级至一定程度下,就不划算了。纵向扩展意味着「大内存」,Redis持久化时的”成本”会加大(Redis做RDB持久化,是全量的,fork子进程时有可能由于使用内存过大,导致主线程阻塞时间过长)
所以,「单实例」是有瓶颈的
用多个Redis实例来组成一个集群,按照一定的规则把数据「分发」到不同的Redis实例上。当集群所有的Redis实例的数据加起来,那这份数据就是全的
路由
Redis Cluster对数据的分发的逻辑中,涉及到「哈希槽」(Hash Solt)的概念,Redis Cluster默认一个集群有16384个哈希槽,这些哈希槽会分配到不同的Redis实例中。
当客户端有数据进行写入的时候,首先会对key按照CRC16算法计算出16bit的值(可以理解为就是做hash),然后得到的值对16384进行取模。
取模之后,自然就得到其中一个哈希槽,然后就可以将数据插入到分配至该哈希槽的Redis实例中
现在客户端通过hash算法算出了哈希槽的位置,那客户端怎么知道这个哈希槽在哪台Redis实例上呢?
在集群的中每个Redis实例都会向其他实例「传播」自己所负责的哈希槽有哪些。这样一来,每台Redis实例就可以记录着「所有哈希槽与实例」的关系了(:
有了这个映射关系以后,客户端也会「缓存」一份到自己的本地上,那自然客户端就知道去哪个Redis实例上操作了
在集群里也可以新增或者删除Redis实例啊,这个怎么整?
当集群删除或者新增Redis实例时,那总会有某Redis实例所负责的哈希槽关系会发生变化
发生变化的信息会通过消息发送至整个集群中,所有的Redis实例都会知道该变化,然后更新自己所保存的映射关系,但这时候,客户端其实是不感知的(:
所以,当客户端请求时某Key时,还是会请求到「原来」的Redis实例上。而原来的Redis实例会返回「moved」命令,告诉客户端应该要去新的Redis实例上去请求啦
客户端接收到「moved」命令之后,就知道去新的Redis实例请求了,并且更新「缓存哈希槽与实例之间的映射关系」
总结起来就是:数据迁移完毕后被响应,客户端会收到「moved」命令,并且会更新本地缓存。
集群扩容时,为了方便,可以使用二分法扩容。原实例可以扩容为2台,这样在2台新实例中,均匀分配原哈希槽。