Redis原理与应用场景
Redis
线程模型

Redis内部使用文件事件处理器File Event Handler,这个文件事件处理器是单线程的,所以Redis才叫做单线程的模型。它采用I/O多路复用机制同时监听多个Socket,将产生事件的
Socket压入到内存队列中,事件分派器根据Socket上的事件类型来选择对应的事件处理器来进行处理。文件事件处理器包含5个部分:
- 
多个Socket 
- 
I/O多路复用程序 
- 
Scocket队列 
- 
文件事件分派器 
- 
事件处理器(连接应答处理器、命令请求处理器、命令回复处理器) 

通信流程
客户端与redis的一次通信过程:

- 
请求类型1: 客户端发起建立连接的请求- 
服务端会产生一个 AE_READABLE事件,I/O多路复用程序接收到server socket事件后,将该socket压入队列中
- 
文件事件分派器从队列中获取 socket,交给连接应答处理器,创建一个可以和客户端交流的socket01
- 
将 socket01的AE_READABLE事件与命令请求处理器关联
 
- 
- 
请求类型2: 客户端发起set key value请求- 
socket01产生AE_READABLE事件,socket01压入队列
- 
将获取到的 socket01与命令请求处理器关联
- 
命令请求处理器读取 socket01中的key value,并在内存中完成对应的设置
- 
将 socket01的AE_WRITABLE事件与命令回复处理器关联
 
- 
- 
请求类型3: 服务端返回结果- 
Redis中的socket01会产生一个AE_WRITABLE事件,压入到队列中
- 
将获取到的 socket01与命令回复处理器关联
- 
回复处理器对 socket01输入操作结果,如ok。之后解除socket01的AE_WRITABLE事件与命令回复处理器的关联
 
- 
执行效率高
Redis是单线程模型为什么效率还这么高?
- 
纯内存操作:数据存放在内存中,内存的响应时间大约是100纳秒,这是Redis每秒万亿级别访问的重要基础
- 
非阻塞的I/O多路复用机制:Redis采用epoll做为I/O多路复用技术的实现,再加上Redis自身的事件处理模型将epoll中的连接,读写,关闭都转换为了时间,不在I/O上浪费过多的时间
- 
C语言实现:距离操作系统更近,执行速度会更快
- 
单线程避免切换开销:单线程避免了多线程上下文切换的时间开销,预防了多线程可能产生的竞争问题
数据类型
String(字符串)
String 数据结构是简单的 key-value 类型,value 不仅可以是 String,也可以是数字(当数字类型用 Long 可以表示的时候encoding 就是整型,其他都存储在 sdshdr 当做字符串)。
底层实现原理:SDS(简单动态字符)
- SDS的基本结构
struct sdshdr {
    // buf中已使用的长度
    int len;
    // buf中未使用的长度
    int free;
    // 数据空间
    char buf[];
};
- 为什么SDS比C字符串更适合于Redis
- 
常数复杂度获取字符串长度,我们获取一个C字符串长度的方法是遍历,这一操作的时间复杂度是O(n),而SDS获取字符串长度的方法是直接去获取sdshdr中的len属性,这一操作的时间复杂度是O(1)
- 
动态扩容,杜绝缓冲区溢出,在SDS中想要在字符串后面增加一些字符,可采用下面函数,sdscatlen函数检查SDS字符串的未使用空间是否足够。如果不足,它会重新分配内存,确保有足够的空间存储新的字符。这个过程是透明的,避免了C字符串的缓冲区溢出问题
sds sdscat(sds s, const char *t) {
    return sdscatlen(s, t, strlen(t));
}
- 
二进制安全,SDS是二进制安全的,这意味着你可以在SDS中存储任何类型的数据,包括二进制数据
- 
性能优化,SDS通过预分配策略和惰性空间释放策略优化性能, 当SDS字符串需要扩展时,除了为要添加的字符分配空间外,还会分配额外的未使用空间, 当SDS字符串缩短时,SDS不会立即释放
 多余的内存空间,而是保留这些空间作为未使用空间。这些设计使得SDS在处理大型字符串时性能出色,且内存利用率高。
- 
兼容C字符串, 虽然SDS在很多方面都比C字符串优秀,但它仍然保持了与C字符串的兼容性。例如,SDS字符串的buf数组总是以空字符'\0'结束,这样可以确保任何期望C字符串的函数都可以正确处理SDS。
应用场景
- 
单值缓存:将经常使用的数据缓存在redis中,减少数据库的读取次数,提高系统性能 
- 
计数器:利用incr和decr命令将key中储存的数字值加一/减一,这两个操作具有原子性,总能安全地进行加减操作,因此可以用string类型进行计数,如 微博的评论数、点赞数、分享数,抖音作品的收藏数,京东商品的销售量、评价数等。
- 
分布式锁: 利用redis的原子性操作,实现分布式锁,避免多个进程同时修改同一资源造成的数据不一致问题 
Hash(字典)
哈希是一种键值对的集合,其中键和值都是字符串类型的数据。哈希在 Redis 中被用于存储对象,对象的属性可以通过键值对的形式存储在哈希中。
Redis 的哈希采用了类似于字典或者散列表的结构,可以高效地进行插入、删除和查找操作。哈希在存储和访问大量数据时具有良好的性能表现。
底层实现原理:全局hash表

Redis 整体就是一个 哈希表来保存所有的键值对,无论数据类型是 5 种的任意一种。哈希表,本质就是一个数组,每个元素被叫做哈希桶,不管什么数据类型,每个桶里面的 entry 保存着实际具体值的指针。
整个数据库就是一个全局哈希表,而哈希表的时间复杂度是 O(1),只需要计算每个键的哈希值,便知道对应的哈希桶位置,定位桶里面的 entry 找到对应数据,这个也是 Redis 快的原因之一。
那 Hash 冲突怎么办?
当写入 Redis 的数据越来越多的时候,哈希冲突不可避免,会出现不同的 key 计算出一样的哈希值。Redis 通过链式哈希解决冲突:也就是同一个 桶里面的元素使用链表保存。但是当链表过长就会导致查找性能变差可能,所以 Redis 为了追求快,使用了两个全局哈希表。用于rehash 操作,增加现有的哈希桶数量,减少哈希冲突。开始默认使用 hash 表 1 保存键值对数据,哈希表 2 此刻没有分配空间。
应用场景
- 
电商购物车: 以 用户id为key,商品id为field,商品数量为value(实际情况下value会存储购买商品的一些基础信息,如优惠券、会员),恰好构成了购物车的3个要素,如下图所示。
  
- 
对象信息: 做商户系统字段配置字典
| merchantId | configField | fieldValue | 
|---|---|---|
| 1 | AppName | MyApplication | 
| 1 | Version | 1.0.0 | 
| 2 | AppName | admin | 
- 用户维度+用户业务信息:如用户已领领取的优惠券信息,即以c- 用户id作为key,- 优惠券id或编码作为field,优惠券的具体信息(状态,名称、时间等数据)作为value存储
List(列表)
Redis的List是一个有序的字符串列表,它可以包含重复的元素。可以将其类比为Java中的LinkedList。每个元素都有一个索引值,通过索引值可以快速访问和修改列表中的元素。

应用场景
- 消息异步回传: 推送第三方等信息,可以将业务id放入Redis的list队列中,后台通过定时任务从Redis中获取业务id进行查询,异步推送,如果成功移除队列,失败继续推送(推送记录做重试记录,避免大规模重试)
- 微博、公众号等消息订阅信息推送:
 A关注B的微博或者公众号(可通过Set进行关注)
 1.B发了微博或者公众号文章,消息id为 10086
 2.后台定时任务执行  LPUSH  msg:{关注人A的id} 100086   (因为定时任务的时间差,所以每个人刷到的时间不一致)
 
 3.查看最新消息  LRANGE msg:{关注人A的id}    0   10 
SET
Set 就是一个集合,集合的概念就是一堆不重复值的组合。利用 Redis 提供的 Set 数据结构,可以存储一些集合性的数据

应用场景
- 点赞或点踩,收藏等模型,可以放到set中实现
# uid:1 用户对文章article:1 点赞
> SADD article:1 uid:1
- 店铺关注
# 用户A关注了店铺B,用户A成为了店铺B的粉丝
> SADD  USER:A用户的id:FOLLOW  B的storeId  用户A的关注店铺 
> SADD  STORE:B的storeId:FANS A的用户id    店铺B的粉丝
- 黑名单过滤: 设置用户黑名单,通过sismember命令判断当前用户是否在黑名单内
- 抽奖:通过SRANDMEMBER key [count]命令从set获取随机数量
Sorted Set(有序集合)

和Sets相比,Sorted Sets是将 Set 中的元素增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,另外还可以用 Sorted Sets 来做带权重的队列,比如普通消息的 score 为1,重要消息的 score 为2,然后工作线程可以选择按 score 的倒序来获取工作任务。让重要的任务优先执行。
应用场景
- 排行榜:例如学生成绩的排名榜、游戏积分排行榜、视频播放排名、电商系统中商品的销量排名等。
linkedList(双端列表)

后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来。这也是为何 Redis 快的原因,不放过任何一个可以提升性能的细节。
zipList(压缩列表)

压缩列表是 List 、hash、 sorted Set 三种数据类型底层实现之一。当一个列表只有少量数据的时候,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表键的底层实现。
ziplist 是由一系列特殊编码的连续内存块组成的顺序型的数据结构,ziplist 中可以包含多个 entry 节点,每个节点可以存放整数或者字符串。ziplist 在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表占用字节数、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
struct ziplist<T> {
    int32 zlbytes;           // 整个压缩列表占用字节数
    int32 zltail_offset;  // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength;          // 元素个数
    T[] entries;              // 元素内容列表,挨个挨个紧凑存储
    int8 zlend;               // 标志压缩列表的结束,值恒为 0xFF
}
如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N)。
skipList(跳跃表)

sorted set 类型的排序功能便是通过「跳跃列表」数据结构来实现。跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均 O(logN)、最坏 O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。跳表在链表的基础上,增加了多层级索引,通过索引位置的几个跳转,实现数据的快速定位。
intset(整数数组)
当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现,节省内存。结构如下:
typedef struct intset{
     //编码方式
     uint32_t encoding;
     //集合包含的元素数量
     uint32_t length;
     //保存元素的数组
     int8_t contents[];
}intset;
contents 数组是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。length 属性记录了整数集合包含的元素数量,也即是 contents 数组的长度。
特殊数据结构
HyperLogLog(基数统计)
HyperLogLog 主要的应用场景就是进行基数统计。实际上不会存储每个元素的值,它使用的是概率算法,通过存储元素的hash值的第一个1的位置,来计算元素数量。HyperLogLog 可用极小空间完成独立数统计。命令如下:
| 命令 | 作用 | 
|---|---|
| pfadd key element ... | 将所有元素添加到key中 | 
| pfcount key | 统计key的估算值(不精确) | 
| pgmerge new_key key1 key2 ... | 合并key至新key | 
应用案例
如何统计 Google 主页面每天被多少个不同的账户访问过?
对于 Google 这种访问量巨大的网页而言,其实统计出有十亿的访问量或十亿零十万的访问量其实是没有太多的区别的,因此,在这种业务场景下,为了节省成本,其实可以只计算出一个大概的值,而没有必要计算出精准的值。
对于上面的场景,可以使用HashMap、BitMap和HyperLogLog来解决。对于这三种解决方案,这边做下对比:
- HashMap:算法简单,统计精度高,对于少量数据建议使用,但是对于大量的数据会占用很大内存空间
- BitMap:位图算法,具体内容可以参考我的这篇,统计精度高,虽然内存占用要比- HashMap少,但是对于大量数据还是会占用较大内存
- HyperLogLog:存在一定误差,占用内存少,稳定占用 12k 左右内存,可以统计 2^64 个元素,对于上面举例的应用场景,建议使用
Geo(地理空间信息)
Geo主要用于存储地理位置信息,并对存储的信息进行操作(添加、获取、计算两位置之间距离、获取指定范围内位置集合、获取某地点指定范围内集合)。Redis支持将Geo信息存储到有序集合(zset)中,再通过Geohash算法进行填充。命令如下:
| 命令 | 作用 | 
|---|---|
| geoadd key latitude longitude member | 添加成员位置(纬度、经度、名称)到key中 | 
| geopos key member ... | 获取成员geo坐标 | 
| geodist key member1 member2 [unit] | 计算成员位置间距离。若两个位置之间的其中一个不存在, 那返回空值 | 
| georadius | 基于经纬度坐标范围查询 | 
| georadiusbymember | 基于成员位置范围查询 | 
| geohash | 计算经纬度hash | 
GEORADIUS
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count]
以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素。范围可以使用以下其中一个单位:
- m 表示单位为米
- km 表示单位为千米
- mi 表示单位为英里
- ft 表示单位为英尺
在给定以下可选项时, 命令会返回额外的信息:
- WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。 距离单位和范围单位保持一致
- WITHCOORD: 将位置元素的经度和维度也一并返回
- WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大
命令默认返回未排序的位置元素。 通过以下两个参数, 用户可以指定被返回位置元素的排序方式:
- ASC: 根据中心的位置, 按照从近到远的方式返回位置元素
- DESC: 根据中心的位置, 按照从远到近的方式返回位置元素
在默认情况下, GEORADIUS 命令会返回所有匹配的位置元素。 虽然用户可以使用 COUNT <count> 选项去获取前 N 个匹配元素, 但是因为命令在内部可能会需要对所有被匹配的元素进行处理, 所以在对一个非常大的区域进行搜索时, 即使只使用 COUNT 选项去获取少量元素, 命令的执行速度也可能会非常慢。 但是从另一方面来说, 使用 COUNT 选项去减少需要返回的元素数量, 对于减少带宽来说仍然是非常有用的。
Pub/Sub(发布订阅)
发布订阅类似于广播功能。redis发布订阅包括 发布者、订阅者、Channel。常用命令如下:
| 命令 | 作用 | 时间复杂度 | 
|---|---|---|
| subscribe channel | 订阅一个频道 | O(n) | 
| unsubscribe channel ... | 退订一个/多个频道 | O(n) | 
| publish channel msg | 将信息发送到指定的频道 | O(n+m),n 是频道 channel 的订阅者数量, M 是使用模式订阅(subscribed patterns)的客户端的数量 | 
| pubsub CHANNELS | 查看订阅与发布系统状态(多种子模式) | O(n) | 
| psubscribe | 订阅多个频道 | O(n) | 
| unsubscribe | 退订多个频道 | O(n) | 
Bitmap(位图)
Bitmap就是位图,其实也就是字节数组(byte array),用一串连续的2进制数字(0或1)表示,每一位所在的位置为偏移(offset),位图就是用每一个二进制位来存放或者标记某个元素对应的值。通常是用来判断某个数据存不存在的,因为是用bit为单位来存储所以Bitmap本身会极大的节省储存空间。常用命令如下:
| 命令 | 作用 | 时间复杂度 | 
|---|---|---|
| setbit key offset val | 给指定key的值的第offset赋值val | O(1) | 
| getbit key offset | 获取指定key的第offset位 | O(1) | 
| bitcount key start end | 返回指定key中[start,end]中为1的数量 | O(n) | 
| bitop operation destkey key | 对不同的二进制存储数据进行位运算(AND、OR、NOT、XOR) | O(n) | 
应用案例
有1亿用户,5千万登陆用户,那么统计每日用户的登录数。每一位标识一个用户ID,当某个用户访问我们的网站就在Bitmap中把标识此用户的位设置为1。使用set集合和Bitmap存储的对比:
| 数据类型 | 每个 userid 占用空间 | 需要存储的用户量 | 全部占用内存量 | 
|---|---|---|---|
| set | 32位也就是4个字节(假设userid用的是整型,实际很多网站用的是长整型) | 50,000,000 | 32位 * 50,000,000 = 200 MB | 
| Bitmap | 1 位(bit) | 100,000,000 | 1 位 * 100,000,000 = 12.5 MB | 
应用场景
- 用户在线状态
- 用户签到状态
- 统计独立用户
BloomFilter(布隆过滤)

当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点(使用多个哈希函数对元素key (bloom中不存value) 进行哈希,算出一个整数索引值,然后对位数组长度进行取模运算得到一个位置,每个无偏哈希函数都会得到一个不同的位置),把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:
- 如果这些点有任何一个为0,则被检元素一定不在
- 如果都是1,并不能完全说明这个元素就一定存在其中,有可能这些位置为1是因为其他元素的存在,这就是布隆过滤器会出现误判的原因
应用场景
- 解决缓存穿透:事先把存在的key都放到redis的Bloom Filter 中,他的用途就是存在性检测,如果 BloomFilter 中不存在,那么数据一定不存在;如果 BloomFilter 中存在,实际数据也有可能会不存
- 黑名单校验:假设黑名单的数量是数以亿计的,存放起来就是非常耗费存储空间的,布隆过滤器则是一个较好的解决方案。把所有黑名单都放在布隆过滤器中,再收到邮件时,判断邮件地址是否在布隆过滤器中即可
- Web拦截器:用户第一次请求,将请求参数放入布隆过滤器中,当第二次请求时,先判断请求参数是否被布隆过滤器命中,从而提高缓存命中率

 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号