11 String
为什么String类型内存开销大
RedisObject 结构体 + SDS:
- 当保存64位有符号整数时,String会存为一个8字节的Long类型整数,称为int编码方式
当数据包含字符串时,String类型会用简单动态字符串(SDS)结构体来保存
- buf:字节数组,保存实际数据。为了表示字节数组的结束,Redis 会自动在数组最后加一个“\0”,这就会额外占用 1 个字节的开销。
- len:占 4 个字节,表示 buf 的已用长度。
- alloc:也占个 4 字节,表示 buf 的实际分配长度,一般大于 len。
RedisObject
Redis 的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以,Redis 会用一个 RedisObject 结构体来统一记录这些元数据,同时指向实际数据。
一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针,这个指针再进一步指向具体数据类型的实际数据所在。
编码方式
- 保存 Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据了,只占用8字节。
- 保存小于等于 44 字节的字符串时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,可以避免内存碎片。这种布局方式也被称为 embstr 编码方式。
- 保存大于44字节的字符串时,Redis会给SDS独立的空间,用指针指向SDS结构,称为raw编码方式。
内存分配库jemalloc
Redis 会使用一个全局哈希表保存所有键值对,哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节。
jemalloc 在分配内存时,会根据我们申请的字节数 N,找一个比 N 大,但是最接近 N 的 2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。
申请 24 字节空间,jemalloc 则会分配 32 字节。所以,在我们刚刚说的场景里,dictEntry 结构就占用了 32 字节。
示例:10位数图片ID和对象ID存为String类型时,内存结构:
RedisObject:8(元数据)+ 8(INT)= 16字节
哈希表:8(key) + 8(value) + 8(next) = 24字节 =>jemalloc 分配32字节
总计: 16 * 2(键值对) + 32 = 64字节
压缩列表ziplist
压缩列表之所以能节省内存,就在于它是用一系列连续的 entry 保存数据。
- 表头:zlbytes(列表长度)、zltail(列表尾偏移量)、zllen(列表entry个数)
- 元数据:连续的entry
- 表尾:zlend(列表结束)
每个entry构成:
- prev_len:前一个entry的长度,小于254字节时占1字节,否则占5字节。
- len:表示自身长度,4 字节;
- encoding:记录该节点content属性保存数据的类型及长度。小于等于63字节占1字节,小于等于16383字节占2字节,否则占5字节。
- content:实际数据
Redis 基于压缩列表实现了 List、Hash 和 Sorted Set 这样的集合类型,这样做的最大好处就是节省了 dictEntry 的开销。
- 当你用 String 类型时,一个键值对就有一个 dictEntry,要用 32 字节空间。
- 采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,这样就节省了内存。
集合类型如何保存单值的键值对
可以采用基于 Hash 类型的二级编码方法:
把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为 Hash 集合的 value,这样一来,我们就可以把单值数据保存到 Hash 集合中了。
每个 entry 保存一个图片存储对象 ID(8 字节),此时,每个 entry 的 prev_len 只需要 1 个字节就行,因为每个 entry 的前一个 entry 长度都只有 8 字节,小于 254 字节。这样一来,一个图片的存储对象 ID 所占用的内存大小是 14 字节(1+ 4 + 1 + 8),实际分配 16 字节。新增一个图片:一个entry(16字节)
Hash类型的底层实现
Redis Hash 类型的两种底层实现结构,压缩列表和哈希表,都在什么时候使用呢?
Hash 类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了。
- hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。
- hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。
一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存,而不会再转回压缩列表了。
以图片 ID 1101000060 和图片存储对象 ID 3302000080 为例,我们可以把图片 ID 的前 7 位(1101000)作为 Hash 类型的键,把图片 ID 的最后 3 位(060)和图片存储对象 ID 分别作为 Hash 类型值中的 key 和 value。
为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在 Hash 集合中的元素个数。所以,在刚才的二级编码中,我们只用图片 ID 最后 3 位作为 Hash 集合的 key,也就保证了 Hash 集合的元素个数不超过 1000,同时,我们把 hash-max-ziplist-entries 设置为 1000,这样一来,Hash 集合就可以一直使用压缩列表来节省内存空间了。
小结
在保存的键值对本身占用的内存空间不大时,String 类型的元数据开销就占据主导了,这里面包括了 RedisObject 结构、SDS 结构、dictEntry 结构的内存开销。
针对这种情况,我们可以使用压缩列表保存数据。
使用 Hash 这种集合类型保存单值键值对的数据时,我们需要将单值数据拆分成两部分,分别作为 Hash 集合的键和值。
Redis容量预估网址:http://www.redis.cn/redis_mem...
12 集合统计模式
集合类型常见的四种统计模式,包括聚合统计、排序统计、二值状态统计和基数统计。
聚合统计(Set集合)
聚合统计,就是指统计多个集合元素的聚合结果,包括:
- 统计多个集合的共有元素(交集统计);
- 把两个集合相比,统计其中一个集合独有的元素(差集统计);
- 统计多个集合的所有元素(并集统计)。
示例:记录每天登录用户ID,统计累计用户,新增用户,留存用户。
key 是 user:id 以及当天日期,例如 user20200803;
value 是 Set 集合,记录当天登录的用户 ID。
累计用户:统计user:id和user20200803的并集,保存到user:id中
SUNIONSTORE user:id user:id user:id:20200803
新增用户:统计user20200804和user:id的差集,保存到user:new中
SDIFFSTORE user:new user:id:20200804 user:id
留存用户:统计0803和0804的交集,保存到userrem
SINTERSTORE user:id:rem user:id:20200803 user:id:20200804
Set集合聚合统计风险: 计算复杂度高,数据量大时直接执行计算会导致Redis实例阻塞。
解决方案:
- 选一个从库负责聚合运算
- 把数据读取到客户端,在客户端完成聚合统计
排序统计
集合中的元素可以按序排列,这种对元素保序的集合类型叫作有序集合。
Redis集合类型:List,Hash,Set,Sorted Set
有序集合类型:
- List:按照元素进入 List 的顺序进行排序
- Sorted Set:根据元素权重排序
List问题:List是通过元素位置来排序的,新元素插入后元素位置会改变,分页操作时,Lrange可能读取到旧数据。
所以,在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,建议你优先考虑使用 Sorted Set。
二值状态统计
在签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。这个时候,我们就可以选择 Bitmap。
Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。
Bitmap可以看做一个bit数组,提供GETBIT/SETBIT操作,使用偏移值offset对bitmap数组进行读写,offset最小值为0。
- BITSET后该bit位设置为1;
- BITCOUNT统计所有为1的个数
- BITOP按位与
示例:统计8月3日签到数(3日:0-1-2)
SETBIT uid:sign:3000:202008 2 1
GETBIT uid:sign:3000:202008 2
BITCOUNT uid:sign:3000:202008
如果只需要统计数据的二值状态,例如商品有没有、用户在不在等,就可以使用 Bitmap,因为它只用一个 bit 位就能表示 0 或 1。在记录海量数据时,Bitmap 能够有效地节省内存空间。
基数统计
基数统计就是指统计一个集合中不重复的元素个数。
在 Redis 的集合类型中,Set 类型默认支持去重。
HyperLogLog 是一种用于统计基数的数据集合类型,它的最大优势就在于,当集合元素数量非常多时,它计算基数所需的空间总是固定的,而且还很小。
示例:统计访问页面UV
PFADD page1:uv user1 user2 user3 user4 user5
PFCOUNT page1:uv
HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。
小结
集合类型优缺点:
其他统计场景:
使用Sorted Set统计在线用户数:
- 用户上线时使用zadd online_users $timestamp $user_id把用户添加到Sorted Set中
- 使用zcount online_users $starttime $endtime就可以得出指定时间段内的在线用户数
- 使用Set记录用户喜欢水果,如
sadd user1 apple banana
,再使用zunionstore fruits_union 2 user1 user2
把结果存储到fruits_union这个key中,zrange fruits_union 0 -1 withscores
可以得出每种水果被喜欢的次数。
13 GEO
位置信息服务(Location-Based Service,LBS)应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中。
对于一个 LBS 应用来说,除了记录经纬度信息,还需要根据用户的经纬度信息在车辆的 Hash 集合中进行范围查询。
实际上,GEO 类型的底层数据结构就是用 Sorted Set 来实现的,元素是车辆 ID,元素的权重分数是GeoHash编码过的经纬度信息。
GeoHash编码
二分区间,区间编码。
即先对经度和纬度分别编码,然后再把经纬度各自的编码组合成一个最终编码。
GeoHash 编码会把值编码成一个 N 位的二进制值,经度范围[-180,180],纬度范围[-90,90]做 N 次的二分区操作,其中 N 可以自定义。编码值落在左分区,我们就用 0 表示;如果落在右分区,就用 1 表示。每做完一次二分区,我们就可以得到 1 位编码值。
示例:经度值116.37,纬度值39.86的编码过程
当一组经纬度值都编完码后,我们再把它们的各自编码值组合在一起,组合的规则是:最终编码值的偶数位上依次是经度的编码值,奇数位上依次是纬度的编码值,其中,偶数位从 0 开始,奇数位从 1 开始。
使用 GeoHash 编码后,我们相当于把整个地理空间划分成了一个个方格,每个方格对应了 GeoHash 中的一个分区。所以使用 Sorted Set 范围查询得到的相近编码值,在实际的地理空间上,也是相邻的方格,这就可以实现 LBS 应用“搜索附近的人或物”的功能了。
GEO其实是把经纬度编码合并作为sorted set的key,但有时编码相近并不一定是相邻方格,一般的做法是同时查询周围的4或8个方格
操作方法
- GEOADD 命令:用于把一组经纬度信息和相对应的一个 ID 记录到 GEO 类型集合中
- GEORADIUS 命令:会根据输入的经纬度位置,查找以这个经纬度为中心的一定范围内的其他元素。当然,我们可以自己定义这个范围。
GEOADD cars:locations 116.034579 39.030452 33
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
自定义数据类型
Redis 键值对中的每一个值都是用 RedisObject 保存的。
基本结构
RedisObject 的内部组成包括了 type、encoding、lru 和 refcount 4 个元数据,以及 1 个*ptr
指针。
- type:表示值的类型,涵盖了我们前面学习的五大基本类型;
- encoding:是值的编码方式,用来表示 Redis 中实现各个基本类型的底层数据结构,例如 SDS、压缩列表、哈希表、跳表等;
- lru:记录了这个对象最后一次被访问的时间,用于淘汰过期的键值对;
- refcount:记录了对象的引用计数;
*ptr
:是指向数据的指针。
我们在定义了新的数据类型后,也只要在 RedisObject 中设置好新类型的 type 和 encoding,再用*ptr
指向新类型的实现,就行了。
Redis其他数据类型和应用
List队列:
rpush入栈 + lpop出栈。
缺点:不支持ack,不支持多消费者。
PubSub:
支持多消费者。
缺点:PubSub只能发给在线消费者,消费者下线会丢失数据。
Stream数据结构:
可以持久化、支持ack机制、支持多个消费者、支持回溯消费。
布隆过滤器:
解决业务层内存穿透。
14 时间序列数据
与发生时间相关的一组数据,就是时间序列数据。
这些数据的特点是没有严格的关系模型,记录的信息可以表示成键和值的关系。
读写特点
写入特点:写入要快,数据类型在进行数据插入时,复杂度要低,尽量不要阻塞。
- 时间序列数据通常是持续高并发写入的
- 一个时间序列数据被记录后通常就不会变了
读取特点:查询模式多,单条记录查询,范围查询,聚合计算等。
解决方案:基于 Hash 和 Sorted Set 实现,以及基于 RedisTimeSeries 模块实现。
Hash 和 Sorted Set组合
保存时间序列数据,同时存储Hash 和 Sorted Set两种类型。
Hash 类型
优点:可以实现对单键的快速查询,满足了时间序列数据的单键查询需求。
缺点:无法范围查找。
HGET device:temperature 202008030905
"25.1"
HMGET device:temperature 202008030905 202008030907 202008030908
1) "25.1"
2) "25.9"
3) "24.9"
Sorted Set
把时间戳作为 Sorted Set 集合的元素分数,把时间点上记录的数据作为元素本身。
ZRANGEBYSCORE device:temperature 202008030907 202008030910
1) "25.9"
2) "24.9"
3) "25.3"
4) "25.2"
保证原子性
当多个命令及其参数本身无误时,MULTI 和 EXEC 命令可以保证执行这些命令时的原子性。
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> HSET device:temperature 202008030911 26.8
QUEUED
127.0.0.1:6379> ZADD device:temperature 202008030911 26.8
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 1
2) (integer) 1
聚合计算
RedisTimeSeries 支持直接在 Redis 实例上进行聚合计算。
RedisTimeSeries 是 Redis 的一个扩展模块。它专门面向时间序列数据提供了数据类型和访问接口,并且支持在 Redis 实例上直接对数据进行按时间范围的聚合计算。
问题:如果你是Redis的开发维护者,你会把聚合计算也设计为Sorted Set的内在功能吗?
解答:
不会。因为聚合计算是CPU密集型任务,Redis在处理请求时是单线程的,也就是它在做聚合计算时无法利用到多核CPU来提升计算速度,如果计算量太大,这也会导致Redis的响应延迟变长,影响Redis的性能。
Redis的定位就是高性能的内存数据库,要求访问速度极快。所以对于时序数据的存储和聚合计算,我觉得更好的方式是交给时序数据库去做,时序数据库会针对这些存储和计算的场景做针对性优化。
15 消息队列
消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性。
Redis 的 List 和 Streams 两种数据类型,就可以满足消息队列的这三个需求。
List解决方案
消息保序
生产者可以使用 LPUSH 命令把要发送的消息依次写入 List,而消费者则可以使用 RPOP 命令,从 List 的另一端按照消息的写入顺序,依次读取消息并进行处理。
性能风险:消费者需要不停执行RPOP,造成性能损失。
解决方案:阻塞式读取 BRPOP,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。
重复消费
要求:消费者程序本身能对重复消息进行判断。
方案:消息队列给每一个消息提供全局唯一的 ID 号;消费者程序要把已经处理过的消息的 ID 号记录下来。
幂等性:对于同一条消息,消费者收到一次的处理结果和收到多次的处理结果是一致的。
可靠性
List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。
这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。
处理过程:生产者先用 LPUSH 把消息插入到消息队列 mq 中。消费者程序使用 BRPOPLPUSH 命令读取消息,同时消息还会被 Redis 插入到 mqback 队列中。如果消费者程序处理消息时宕机了,等它重启后,可以从 mqback 中再次读取消息,继续处理。
问题:生产者消息发送很快,而消费者处理消息的速度比较慢,这就导致 List 中的消息越积越多,给 Redis 的内存带来很大压力。
解决:使用streams方案,启动多个消费者程序组成一个消费组,一起分担处理 List 中的消息。
Streams解决方案
Streams 是 Redis 专门为消息队列设计的数据类型,它提供了丰富的消息队列操作命令。
- XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
- XREAD:用于读取消息,可以按 ID 读取数据;
- XREADGROUP:按消费组形式读取消息;
- XPENDING:用来查询每个消费组内所有消费者已读取但尚未确认的消息;
- XACK:用于向消息队列确认消息处理已完成。
XADD
XADD 命令可以往消息队列中插入新消息,消息的格式是键 - 值对形式。对于插入的每一条消息,Streams 可以自动为其生成一个全局唯一的 ID。
XADD mqstream * repo 5
"1599203861727-0"
*
表示插入数据自动生成全局唯一ID,也可以自行设定。
XREAD
XREAD 在读取消息时,可以指定一个消息 ID,并从这个消息 ID 的下一条消息开始进行读取。
XREAD BLOCK 100 STREAMS mqstream 1599203861727-0
调用 XRAED 时设定 block 配置项,实现类似于 BRPOP 的阻塞读取操作。
XREAD block 10000 streams mqstream $
(nil)
(10.00s)
“$”符号表示读取最新的消息,XREAD没有新消息时阻塞 10000 毫秒(即 10 秒),然后返回nil。
XGROUP创建消费组
Streams 本身可以使用 XGROUP 创建消费组,创建消费组之后,Streams 可以使用 XREADGROUP 命令让消费组内的消费者读取消息。
XGROUP create mqstream group1 0
XREADGROUP group group1 consumer1 streams mqstream >
让 group1 消费组里的消费者 consumer1 从 mqstream 中读取所有消息,其中,命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。
注意:消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了。
XPENDING
为了保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息,Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”。
如果消费者没有成功处理消息,它就不会给 Streams 发送 XACK 命令,消息仍然会留存。此时,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。
查看group2中消费者组已读取、但未确认的消息
XPENDING mqstream group2
查询指定消费者
XPENDING mqstream group2 - + 10 consumer2
XACK
处理消息后,消费者可以使用 XACK 命令通知 Streams,然后这条消息就会被删除。