Redis 核心技术与实战 —— 实战(一)
“万金油”的String,为什么不好用了?
为什么 String 类型内存开销大?
除了记录实际数据,String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大了,有点“喧宾夺主”的意思。
建议:
Redis 基于压缩列表实现了 List、Hash 和 Sorted Set 这样的集合类型,这样做的最大好处就是节省了 dictEntry 的开销。
Redis 有一种底层数据结构,叫压缩列表(ziplist)
这些 entry 会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指针所占用的空间。
Redis Hash 类型的两种底层实现结构,分别是压缩列表和哈希表。
Hash 类型底层结构什么时候使用压缩列表,什么时候使用哈希表呢?
其实,Hash 类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了。
这两个阈值分别对应以下两个配置项:
-
hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。
-
hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。
如果我们往 Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries,或者写入的单个元素大小超过了 hash-max-ziplist-value,Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表。
一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。
为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在 Hash 集合中的元素个数。所以,在刚才的二级编码中,我们只用图片 ID 最后 3 位作为 Hash 集合的 key,也就保证了 Hash 集合的元素个数不超过 1000,同时,我们把 hash-max-ziplist-entries 设置为 1000,这样一来,Hash 集合就可以一直使用压缩列表来节省内存空间了。
1 2 3 4 5 6 7 8 | 127.0.0.1:6379> info memory # Memory used_memory:1039120 127.0.0.1:6379> hset 1101000 060 3302000080 (integer) 1 127.0.0.1:6379> info memory # Memory used_memory:1039136 |
使用Hash和Sorted Set存储时,虽然节省了内存空间,但是设置过期变得困难(无法控制每个元素的过期,只能整个key设置过期,或者业务层单独维护每个元素过期删除的逻辑,但比较复杂)。而使用String虽然占用内存多,但是每个key都可以单独设置过期时间,还可以设置maxmemory和淘汰策略,以这种方式控制整个实例的内存上限。 所以在选用Hash和Sorted Set存储时,意味着把Redis当做数据库使用,这样就需要务必保证Redis的可靠性(做好备份、主从副本),防止实例宕机引发数据丢失的风险。
有一个亿的keys需要统计应该用那种集合 (redis 不建议使用聚合统计)
聚合统计 (set)
Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞。所以,我给你分享一个小建议:你可以从主从集群中选择一个从库,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来完成聚合统计,这样就可以规避阻塞主库实例和其他从库实例的风险了。
排序统计 (最新的评论优先显示)
这就要求集合类型能对元素保序,也就是说,集合中的元素可以按序排列,这种对元素保序的集合类型叫作有序集合。
在 Redis 常用的 4 个集合类型中(List、Hash、Set、Sorted Set),List 和 Sorted Set 就属于有序集合。
List 是按照元素进入 List 的顺序进行排序的,而 Sorted Set 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。
二值状态统计 (Bitmap)
这里的二值状态就是指集合元素的取值就只有 0 和 1 两种,在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。
Bitmap 的偏移量是从 0 开始算的,也就是说 offset 的最小值是 0,如记录ID 3000 的用户在8。3 号已签到
1 | SETBIT uid:sign:3000:202008 2 1 |
如果记录了 1 亿个用户 10 天的签到情况,你有办法统计出这 10 天连续签到的用户总数吗?
Bitmap 按位做“与”“或”“异或”的操作,操作的结果会保存到一个新的 Bitmap 中。
基数统计 (HyperLogLog)
基数统计。基数统计就是指统计一个集合中不重复的元素个数。Set 类型默认支持去重,所以看到有去重需求时,我们可能第一时间就会想到用 Set 类型
如果 page1 非常火爆,UV 达到了千万,这个时候,一个 Set 就要记录千万个用户 ID。对于一个搞大促的电商网站而言,这样的页面可能有成千上万个,如果每个页面都用这样的一个 Set,就会消耗很大的内存空间
HyperLogLog 是一种用于统计基数的数据集合类型,它的最大优势就在于,当集合元素数量非常多时,它计算基数所需的空间总是固定的,而且还很小, 在 Redis 中,每个 HyperLogLog 只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数
在统计 UV 时,你可以用 PFADD 命令(用于向 HyperLogLog 中添加新元素)把访问页面的每个用户都添加到 HyperLogLog 中。
1 | PFADD page1:uv user1 user2 user3 user4 user5 |
接下来,就可以用 PFCOUNT 命令直接获得 page1 的 UV 值了,这个命令的作用就是返回 HyperLogLog 的统计结果
1 | PFCOUNT page1:uv |
HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。这也就意味着,你使用 HyperLogLog 统计的 UV 是 100 万,但实际的 UV 可能是 101 万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型。
GEO 是什么
位置信息服务 , 需要记录车辆的(经度和纬度)基于这个做范围查询,reids 中的 sorted set 是支持范围查询的,key 是具体的车辆 id 值(权重排序)就是经度和维度。sorted set 的权重不支持两个浮点数。GEO 的底层就是基于 sorted set 来实现 LBS(位置信息服务的)。
当我们要对一组经纬度进行 GeoHash 编码时,我们要先对经度和纬度分别编码,然后再把经纬度各自的编码组合成一个最终编码。
如何在Redis中保存时间序列数据
1 基于 Hash 和 Sorted Set 保存时间序列数据
Hash 单点查询,不支持范围查询
Sorted Set 支持范围插叙,单点查询的时间复杂度是 O(n),内存和存储大的时候可以使用这种方案
2 RedisTimeSeries 模块保存时间序列数据
这个可以设置过期时间 如600s 后过期, 支持平均值/最大值/最小值,弊端只能获取最新的一条数据
消息队列的考验:Redis有哪些解决方案
消息队列在存取消息时,必须要满足三个需求:
-
消息保序
-
处理重复的消息
-
保证消息可靠性
Redis 的 List 和 Streams 两种数据类型,就可以满足消息队列的这三个需求
基于 List 的消息队列解决方案
1 消息保序
List 本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List 作为消息队列保存消息的话,就已经能满足消息保序的需求了(一端写入0一端取出-1)
为了避免消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失。为了解决这个问题,
Redis 提供了 BRPOP 命令。BRPOP 命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用 RPOP 命令相比,这种方式能节省 CPU 开销
2 处理重复的消息
生产者生成消息的时候提供 uid保存到队列,消费者对比此条消息是否被处理过
3 何保证消息可靠性
为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存,示例如下
List 消息队列存在的问题: 生产者消息发送很快,而消费者处理消息的速度比较慢,这就导致 List 中的消息越积越多,给 Redis 的内存带来很大压力。
这个时候,我们希望启动多个消费者程序组成一个消费组,一起分担处理 List 中的消息。但是,List 类型并不支持消费组的实现。那么,还有没有更合适的解决方案呢?这就要说到 Redis 从 5.0 版本开始提供的 Streams 数据类型了。
基于 Streams 的消息队列解决方案
Streams 是 Redis 专门为消息队列设计的数据类型,它提供了丰富的消息队列操作命令。
-
XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
-
XREAD:用于读取消息,可以按 ID 读取数据;
-
XREADGROUP:按消费组形式读取消息;
-
XPENDING 和 XACK:XPENDING 命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息,而 XACK 命令用于向消息队列确认消息处理已完成。
XADD 消息队列名称后面的*,表示让 Redis 为插入的数据自动生成一个全局唯一的 ID(也可以自己指定,不建议这样去做)
1 2 | XADD mqstream * repo 5 "1599203861727-0" |
XREAD 在读取消息时,可以指定一个消息 ID,并从这个消息 ID 的下一条消息开始进行读取。
例如,我们可以执行下面的命令,从 ID 号为 1599203861727-0 的消息开始,读取后续的所有消息(示例中一共 3 条)。
1 2 3 4 5 6 7 8 9 10 11 | XREAD BLOCK 100 STREAMS mqstream 1599203861727-0 1) 1) "mqstream" 2) 1) 1) "1599274912765-0" 2) 1) "repo" 2) "3" 2) 1) "1599274925823-0" 2) 1) "repo" 2) "2" 3) 1) "1599274927910-0" 2) 1) "repo" 2) "1" |
RAED 时设定 block 配置项,实现类似于 BRPOP 的阻塞读取操作。当消息队列中没有消息时,一旦设置了 block 配置项,XREAD 就会阻塞
命令最后的“$”符号表示读取最新的消息
1 2 3 | XREAD block 10000 streams mqstream $ (nil) (10.00s) |
Streams 本身可以使用 XGROUP 创建消费组,创建消费组之后,Streams 可以使用 XREADGROUP 命令让消费组内的消费者读取消息,例如,我们执行下面的命令,创建一个名为 group1 的消费组,这个消费组消费的消息队列是 mqstream。
1 2 | XGROUP create mqstream group1 0 OK |
命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | XREADGROUP group group1 consumer1 streams mqstream > 1) 1) "mqstream" 2) 1) 1) "1599203861727-0" 2) 1) "repo" 2) "5" 2) 1) "1599274912765-0" 2) 1) "repo" 2) "3" 3) 1) "1599274925823-0" 2) 1) "repo" 2) "2" 4) 1) "1599274927910-0" 2) 1) "repo" 2) "1" |
消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了, 让 group1 内的 consumer2 读取消息时,consumer2 读到的就是空值,因为消息已经被 consumer1 读取完了。
1 2 3 | XREADGROUP group group1 consumer2 streams mqstream 0 1) 1) "mqstream" 2) (empty list or set ) |
使用消费组的目的是让组内的多个消费者共同分担读取消息,让 group2 中的 consumer1、2、3 各自读取一条消息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | XREADGROUP group group2 consumer1 count 1 streams mqstream > 1) 1) "mqstream" 2) 1) 1) "1599203861727-0" 2) 1) "repo" 2) "5" XREADGROUP group group2 consumer2 count 1 streams mqstream > 1) 1) "mqstream" 2) 1) 1) "1599274912765-0" 2) 1) "repo" 2) "3" XREADGROUP group group2 consumer3 count 1 streams mqstream > 1) 1) "mqstream" 2) 1) 1) "1599274925823-0" 2) 1) "repo" 2) "2" |
为了保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息,Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”。如果消费者没有成功处理消息,它就不会给 Streams 发送 XACK 命令,消息仍然会留存。此时,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。
XACK 命令通知 Streams,然后这条消息就会被删除
1 2 3 4 | XACK mqstream group2 1599274912765-0 (integer) 1 XPENDING mqstream group2 - + 10 consumer2 (empty list or set ) |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理