字符串类型是最基本的数据类型,value 最多可以容纳的数据长度是 512M
。
哈希类型是键值对的集合,适用于存储对象的多个属性
列表类型是一个有序的字符串列表,可以从两端添加或删除元素,支持快速的插入和删除操作。列表的最大长度为 2^32 - 1
,由双向链表或压缩列表实现,
在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
arduino复制代码
struct quicklist{ quicklistNode *head; // 头部节点 quicklistNode *tail; // 尾部节点 unsigned long count; // 所有节点中元素的总数 unsigned long len; // 所有节点中元素的总数 int fill: 16; // ziplist节点的最大大小 unsigned int compress: 16; // 节点压缩深度,表示节点是否使用LZF算法压缩 }
集合类型是一个无序唯一的字符串集合,支持添加、删除和查找元素。同时还支持多个集合取交集、并集、差集。底层数据结构是由哈希表或整数集合实现的
- 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
- 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
适用于:
有序集合类型是一个有序的、不重复的字符串集合,每个元素都会关联一个分数用于排序。
Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score
(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。Zset 类型的底层数据结构是由压缩列表和跳表实现的:
压缩列表本质上就是个数组,只不过增加了上面几个黄色的属性,有利于快速的寻找列表的首、尾的节点
跳表在链表的基础上增加了多级索引,通过多级索引位置的转跳实现了快速查找元素。说的那么厉害实际上就是隔位取节点通过 二分查找
的思想一次遍历过滤几个节点,很明显 ,这个只能基于 有序
的特性下,时间复杂度为 logN
为什么用跳表而不用红黑树或者二又树呢?
位图类型是一种紧凑、高效的数据结构,本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型
随着 Redis 版本的更新,后面又支持了四种数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增) 。
一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。
适用于百万级网页 UV 计数,也就是看一个网站有多少人访问
Redis GEO 是 Redis 3.2 版本新增的数据类型,直接使用了 Sorted Set
集合类型
GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。
这样一来,我们就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。
主要用于存储地理位置信息,并对存储的信息进行操作。
Redis 专门为消息队列设计的数据类型。
在 Redis 5.0 Stream 没出来之前,消息队列的实现方式都有着各自的缺陷,例如:
基于以上问题,Redis 5.0 便推出了 Stream 类型也是此版本最重要的功能,用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一 ID、支持 ack 确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠。
Redis是纯内存操作,执行速度非常快
- 访问速度:内存中的数据可以直接通过内存地址访问,而外存(例如磁盘)需要通过磁头、磁盘转速等机械部件进行寻址和读写,因此访问速度较慢。内存的访问速度通常是纳秒级别,而外存通常是毫秒级别或更长。
- I/O 开销:外存的访问需要进行I/O操作,包括磁盘寻址、数据传输等,这些操作都会引起较大的开销。相比之下,内存操作不需要进行I/O操作,减少了这部分开销。
- 随机访问:内存具有随机访问的特性,即可以直接访问任意地址的数据,而外存通常需要按照顺序进行读写,无法实现像内存那样的随机访问。在一些场景下,如缓存、索引等,需要频繁随机访问数据,这时内存的速度优势就更加明显。
采用单线程,避免不必要的上下文切换可竞争条件,无需上锁同步,多线程还要考虑线程安全问题
使用I/O多路复用模型,非阻塞IO,通过事件驱动机制来处理客户端的请求。当有新的请求到达时,Redis 会将其加入到事件队列中,在事件循环中逐个处理。这种事件驱动的方式避免了线程切换和上下文切换的开销,提高了系统的响应速度。
简化的数据结构:Redis 是键值存储系统,支持的数据结构相对简单,例如字符串、哈希、列表等。这些数据结构的实现都非常轻量,没有复杂的逻辑和耗时的操作,因此可以在单线程下高效地处理请求。
Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度, I/O多路复用模型主要就是实现了高效的网络请求
(通过While循环持续获取对内核空间的等待数据,如果获取不到就直接返回0,然后持续进行获取数据-返回数据的状态,直到获取到等待数据才会进行复制数据返回的步骤,在等待数据阶段非阻塞,但是在复制数据的阶段还是阻塞的)
AIO本质上也是一种NIO,不过他比普通的NIO还吊,最常见的例子就是Netty中异步处理。AIO的本质就是开启一个新的线程去异步处理相关请求
异步守护进程进行,注意的是,由于守护进程会受主线程的影响,如果主线程终止了,不管守护线程是否完成了他的工作都会直接中断,为此,我们需要为主线程想办法让他阻塞一下或者把进行的程序拉长点,给守护进程的结果有呈现的机会
IO多路复用:是利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
阻塞IO和多路复用的区别在于,阻塞IO每一次都需要进行一个等待链接,获取到就进行消费。也就是说他的读取消费是单次的,哪怕有多个事务要进行,他也需要重新读取,而多路复用可以一次性读取到多个事件来进行队列缓存,依次遍历进行消费,不需要重新进行读取
监听Socket的方式、通知的方式又有多种实现,常见的有
select和poll只会通知用户进程有Socket就绪,但不确定具体是哪个Socket ,需要用户进程逐个遍历Socket来确认
epoll则会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间
Redis网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求
所谓的大key问题是某个key的value比较大,所以本质上是大value问题。大Key问题的坏处最典型的就是阻塞线程,并发量下降,导致客户端超时,服务端业务成功率下降。
下面列出常用的两种,其实最方便的是通过第三方工具去监控
redis-cli --bigkeys 命令
Redis内置命令对目标Key进行分析
缓存穿透:查询一个不存在的数据,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库
TiketHub项目中的用户注册板块针对恶意重复用户名注册请求进行缓存穿透风险的预防处理。项目中的解决方案是通过布隆过滤器来对用户名数据进行拦截校验,当注册了一个用户后就会将该用户名通过多次Hash计算后记录在布隆过滤器中,注册用户时校验用户名在布隆过滤器中如果其对应的多个位中有一个不为1,则认为该用户名可用,进入下一逻辑,反之打回请求。由于请求无边界,布隆过滤器的BitMap位图长度范围有边界,所以就会有一个逻辑误判的风险,所以我们布隆过滤器放行后还会再次查一次数据库进行一个校验操作。此外,在项目中针对布隆过滤器不支持元素删除的缺点,对于用户注销后用户名再次可用的需求,项目通过在布隆过滤器下一层通过Redis的Set结构添加了一个用户名注销可用表,将注销后的用户名存储在此,如果布隆过滤器说用户名存在,再返回响应前再次校验一次注销可用,如果存在就放行注册,并删除注销可用表中的该数据。
有一个问题可能会出现:如果用户频繁申请账号再注销,可能导致用户注销可复用的 Username Redis Set 结构变得庞大,增加了存储和查询的负担。
为了防止这种情况,我采取了以下解决方案:
- 异常行为限制:每次用户注销时,记录用户的证件号,并限制证件号仅可用于注销五次。超过这个限制的次数,将禁止该证件号再次用于注册账号。
- 缓存分片处理:对 Username Redis Set 结构进行分片。即使我们对异常行为进行了限制,如果有大量用户注销账户,存储这些数据在一个 Redis Set 结构中可能成为一个灾难,可能出现 Redis 大 Key 问题。因此,我将 Set 结构进行分片,根据用户名的 HashCode 进行取模操作,将数据分散存储在 1024 个 Set 结构中,从而有效地解决了这个问题。
对不存在的 Key 进行缓存,值设为 Null,并设置短暂过期时间,如 60 秒。
消耗内存,可能会发生不一致的问题(一开始没有,设置了空对象,后面突然有了,在空对象ttl到期前有不一致性)
使用确定的数据结构如 Redis 的 Set 集合来存储已注册用户名,判断时检查是否在集合内。
结论:由于该方案占用内存较多且复杂度较高,因此不适合实际应用。
针对高并发注册场景,可以先查询缓存,如果不命中则使用分布式锁来保证只有一个线程访问数据库,避免重复查询。
结论:这对用户的使用体验来说并不友好,因此我们不建议使用该方案。
缓存击穿:给某一个热点key设置了过期时间,当key过期的时候,恰好这时间点对这个key有大量的并发请求过来,或者说redis宕机,这些并发的请求可能会瞬间把DB压垮。
TiketHub项目中的车票购买板块就针对缓存击穿问题进行的处理,因为项目中的车票余额等数据存储在redis中,在节假日抢票期间属于一个热点key。然后针对抢票期间对于车票余额是属于一个追求强一致性的需求,所以我项目中采用的是一种同步上锁的操作,将该车票余额数据设置一个热度期之后的TTL,确保该热点key不会在关键时候因为TTL而过期失效;如果由于Redis宕机或者TTL过期等因素导致该车票余额数据的丢失,项目中就通过分布式锁同步的操作来确保一个强一致性的操作(这里不用担心MQ的消费堆积导致的双方不一致性,因为前面说了TTL属于热度期后,此外如果针对redis宕机,我们亦可以搭建集群架构确保高可用1),同时采用逻辑双判操作来避免无用上锁逻辑。
缓存雪崩:同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
TiketHub项目中的选票界面由于采用的是redis来保存车列数据和余票数据等大量数据,为了避免缓存雪崩问题,利用Redis集群提高服务的可用性。我们搭建的是一各哨兵集群的架构,通过哨兵机制的自动检测恢复机制,避免了Redis宕机带来的雪崩问题,此外,针对大量的缓存key同时失效问题,项目中从MySQL中获取到数据会基于一个随机值基于将多个key在一个区间散列分布,避免在一个时间点大量key的同时失效。
如何保证缓存和MySQL间的数据一致性问题 ——> 强一致性 / 最终一致性
TiketHub项目中,在购票逻辑中先将数据变更信息发布到MySQL中,通过Canal监听binlog将同步数据异步推送到消息队列中,最后再更新缓存。保证Redis与MySQL中的缓存数据保持一致
最新技术架构流程如下所示:
如果消息队列更新缓存失败了呢?其实这一点还好,凭借消息队列客户端消费的重试规则,如果更新失败次数都达到客户端重试阈值还是不行,那一定是数据或者缓存中间件有问题。
当然,如果重试次数多了,也必然会面临缓存与数据库不一致的时间变长了,这个是需要清楚的。
通过该技术方案,可以很好达到缓存与数据库最终一致性。
读请求第一次查询时,会查询到一个错误的数据,因为写请求还没有更新到缓存,写请求写入 MySQL 成功后会删除缓存中的历史数据。后续读请求查询缓存没有值就会再请求数据库 MySQL 进行重新加载,并将正确的值放到缓存中。
也就是说这种模型会存在一个很小周期的缓存与数据库不一致的情况,不过对于绝大多数的情况来说,是可以容忍的。除去一些电商库存、列车余票等对数据比较敏感的情况,比较适合绝大多数业务场景。
当缓存过期(可能是缓存正常过期也 可能是 Redis 内存满了触发清理策略)条件满足,同时读请求的回写缓存 Redis 的执行周期在数据库删除之前,那么就有可能触发缓存数据库不一致问题。
上面说的两种情况,缺一不可,不过能同时满足这两种情况概率极低,低到可以忽略这种情况。
Redis持久化主要分为两类:RDB 和 AOF
RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据
Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下:
bgsave开始时会fork主进程得到子进程,将主进程中的页表复制拷贝到子进程,子进程通过页表共享主进程的内存数据。完成fork后读取内存数据并写入 RDB 文件。fork采用的是copy-on-write技术:
RDB因为是二进制文件,在保存的时候体积也是比较小的,在进行数据恢复的时候速度较快。
AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。
AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF:
AOF的命令记录的频率也可以通过redis.conf文件来配:
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
触发时机
实际项目中,我们一般采用RDB + AOF 混合使用的方式,主要是为了兼顾数据恢复效率和数据安全性。
设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key
每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中的过期key)。
两种模式:
Redis的过期删除策略:惰性删除 + 定期删除两种策略进行配合使用可以兼顾实时性和效率。
当Redis中的内存不够用时,此时在向Redis中添加新的key,那么Redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。
三个维度考虑
默认策略:不淘汰任何key,但是内存满时不允许写入新数据,会抛出异常
Redis实现分布式锁主要利用Redis的 setnx
命令
当执行 SETNX
命令时,它会尝试将指定的键 key
设置为给定的值 value
,但只有在键 key
不存在时,才会进行设置。如果键不存在,则设置成功,返回值为 1;如果键已经存在,则设置失败,返回值为 0。需要在业务上针对返回值手动进行分支判断实现上锁逻辑。
Redison 实现的分布式锁基于 Redis 的 Lua 脚本
和 Redis 的 setnx指令
来实现
Redisson 的分布式锁支持多种功能,例如可重入锁、公平锁、读写锁等。
主从一致性是指在 Redisson 中,分布式锁的主节点和从节点之间的数据一致性。当一个 Redisson 分布式锁实例在主节点上获取锁成功后,该锁状态会被同步到所有的从节点上,确保所有节点对这个锁的状态是一致的。这样,即使主节点发生故障或者网络分区等情况导致主节点不可用,其他从节点仍然可以继续提供对这个锁的服务
解决方法
RedLock(红锁):不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁(n / 2 + 1),避免在一个redis实例上加锁。
如果业务中非要保证数据的强一致性,建议采用zookeeper实现的分布式锁
强大的容错性和高可用性:Redison 通过 Redisson 的 Redis Sentinel 自动故障转移和 Redis Cluster 自动分片功能
看门狗机制:每隔(releaseTime / 3)的时间做一次锁续期,根据业务复杂度动态调整锁超时释放时间,如果状态异常,则视为故障发生,不再续期,超时释放锁,避免死锁
CAS优化:支持原地自旋等待获取锁,减少线程上下文交换提高CPU利用率
线程安全:加锁、设置过期时间等操作都是基于lua脚本完成,确保操作原子性
锁可重入:利用hash结构记录线程id和重入次数
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。
全量同步执行时机
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
集群脑裂是由于主节点和从节点和sentinel处于不同的网络分区(当主节点失去连接并且无法正常工作时) ,使得sentinel没有能够心跳感知到主节点,所以通过选举的方式提升了一个从节点为主,这样就存在了两个master,就像大脑分裂了一样,这样会导致客户端还在老的主节点那里写入数据,新节点无法同步数据,当网络恢复后,sentinel会将老的主节点降为从节点,这时再从新master同步数据,就会导致数据丢失
解决办法
我们可以修改redis的配置,可以设置最少的从节点数量以及缩短主从数据同步的延迟时间,达不到要求就拒绝请求,就可以避免大量的数据丢失
Redis 分片集群引入了哈希槽的概念,Redis 集群有 16384 个哈希槽,每个 key通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分 hash 槽。