Redis的9种数据类型用法解读
作者:@航空母舰
在具体描述这几种数据类型之前,我们先通过一张图了解下 Redis 内部内存管理中是如何描述这些不同数据类型的:
首先Redis内部使用一个redisObject对象来表示所有的key和value,redisObject最主要的信息如上图所示:type代表一个value对象具体是何种数据类型,encoding是不同数据类型在redis内部的存储方式,
比如:type=string代表value存储的是一个普通字符串,那么对应的encoding可以是raw或者是int,如果是int则代表实际redis内部是按数值型类存储和表示这个字符串的,当然前提是这个字符串本身可以用数值表示,比如:"123" "456"这样的字符串。
这需要特殊说明一下vm字段,只有打开了Redis的虚拟内存功能,此字段才会真正的分配内存,该功能默认是关闭状态的,该功能会在后面具体描述。
通过上图我们可以发现Redis使用redisObject来表示所有的key/value数据是比较浪费内存的,当然这些内存管理成本的付出主要也是为了给Redis不同数据类型提供一个统一的管理接口,实际作者也提供了多种方法帮助我们尽量节省内存使用,我们随后会具体讨论。
redis支持丰富的数据类型
不同的场景使用合适的数据类型可以有效的优化内存数据的存放空间:
- string:最基本的数据类型,二进制安全的字符串,最大512M。
- list:按照添加顺序保持顺序的字符串列表。
- set:无序的字符串集合,不存在重复的元素。
- sorted set:已排序的字符串集合。
- hash:key-value对的一种集合。
- bitmap:更细化的一种操作,以bit为单位。
- hyperloglog:基于概率的数据结构。 # 2.8.9新增
- Geo:地理位置信息储存起来, 并对这些信息进行操作 # 3.2新增
- 流(Stream)# 5.0新增
String 字符串
常用命令:
setnx,set,get,decr,incr,mget 等。
应用场景
字符串是最常用的数据类型,他能够存储任何类型的字符串,当然也包括二进制、JSON化的对象、甚至是Base64编码之后的图片。
在Redis中一个字符串最大的容量为512MB,可以说是无所不能了。redis的key和string类型value限制均为512MB。
虽然Key的大小上限为512M,但是一般建议key的大小不要超过1KB,这样既可以节约存储空间,又有利于Redis进行检索
- 缓存,热点数据
- 分布式session
- 分布式锁
- INCR计数器
- 文章的阅读量,微博点赞数,允许一定的延迟,先写入 Redis 再定时同步到数据库
- 全局ID
- INT 类型,INCRBY,利用原子性
- INCR 限流
- 以访问者的 IP 和其他信息作为 key,访问一次增加一次计数,超过次数则返回 false。
- setbit 位操作
内部编码
- int:8 个字节的长整型(long,2^63-1)
- embstr:小于等于44个字节的字符串,embstr格式的SDS(Simple Dynamic String)
- raw:SDS大于 44 个字节的字符串
接下来就是ebmstr和raw两种内部编码的长度界限,请看下面的源码
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44 robj *createStringObject(const char *ptr, size_t len) { if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT) return createEmbeddedStringObject(ptr,len); else return createRawStringObject(ptr,len); }
通过下图可以直观感受一下字符串类型和哈希类型的区别:
redis 为什么要自己写一个SDS的数据类型,主要是为了解决C语言 char[] 的四个问题
- 字符数组必须先给目标变量分配足够的空间,否则可能会溢出
- 查询字符数组长度 时间复杂度O(n)
- 长度变化,需要重新分配内存
- 通过从字符串开始到结尾碰到的第一个\0来标记字符串的结束,因此不能保存图片、音频、视频、压缩文件等二进制(bytes)保存的内容,二进制不安全
redis SDS
- 不用担心内存溢出问题,如果需要会对 SDS 进行扩容
- 因为定义了 len 属性,查询数组长度时间复杂度O(1) 固定长度
- 空间预分配,惰性空间释放
- 根据长度 len来判断是结束,而不是 \0
为什么要有embstr编码呢?他比raw的优势在哪里?
embstr编码将创建字符串对象所需的空间分配的次数从raw编码的两次降低为一次。
因为emstr编码字符串的素有对象保持在一块连续的内存里面,所以那个编码的字符串对象比起raw编码的字符串对象能更好的利用缓存。
并且释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码对象的字符串对象需要调用两次内存释放函数,如图所示,左边emstr编码,右边是raw编码:
Hash 哈希表
常用命令:
hget,hsetnx,hset,hvals,hgetall,hmset,hmget 等。
redis> HSET phone myphone nokia-1110 (integer) 1 redis> HEXISTS phone myphone (integer) 1 redis> HSET people jack "Jack Sparrow" (integer) 1 redis> HSET people gump "Forrest Gump" (integer) 1 redis> HGETALL people 1) "jack" # 域 2) "Jack Sparrow" # 值 3) "gump" 4) "Forrest Gump"
应用场景
我们简单举个实例来描述下 Hash 的应用场景,比如我们要存储一个用户信息对象数据,包含以下信息:用户 ID 为查找的 key,存储的 value 用户对象包含姓名,年龄,生日等信息,如果用普通的 key/value 结构来存储,主要有以下2种存储方式:
第一种方式将用户 ID 作为查找 key,把其他信息封装成一个对象以序列化的方式存储,这种方式的缺点是,增加了序列化/反序列化的开销,并且在需要修改其中一项信息时,需要把整个对象取回,并且修改操作需要对并发进行保护,引入CAS等复杂问题。
第二种方法是这个用户信息对象有多少成员就存成多少个 key-value 对儿,用用户 ID +对应属性的名称作为唯一标识来取得对应属性的值,虽然省去了序列化开销和并发问题,但是用户 ID 为重复存储,如果存在大量这样的数据,内存浪费还是非常可观的。
那么 Redis 提供的 Hash 很好的解决了这个问题,Redis 的 Hash 实际是内部存储的 Value 为一个 HashMap,并提供了直接存取这个 Map 成员的接口,如下图:
也就是说,Key 仍然是用户 ID,value 是一个 Map,这个 Map 的 key 是成员的属性名,value 是属性值,这样对数据的修改和存取都可以直接通过其内部 Map 的 Key(Redis 里称内部 Map 的 key 为 field),也就是通过 key(用户 ID) + field(属性标签)就可以操作对应属性数据了,既不需要重复存储数据,也不会带来序列化和并发修改控制的问题。很好的解决了问题。
这里同时需要注意,Redis 提供了接口(hgetall)可以直接取到全部的属性数据,但是如果内部 Map 的成员很多,那么涉及到遍历整个内部 Map 的操作,由于 Redis 单线程模型的缘故,这个遍历操作可能会比较耗时,而另其它客户端的请求完全不响应,这点需要格外注意。
购物车
内部编码
- ziplist(压缩列表):当哈希类型中元素个数小于 hash-max-ziplist-entries 配置(默认 512 个),同时所有值都小于 hash-max-ziplist-value 配置(默认 64 字节)时,Redis 会使用 ziplist 作为哈希的内部实现。
- hashtable(哈希表):当上述条件不满足时,Redis 则会采用 hashtable 作为哈希的内部实现。
下面我们通过以下命令来演示一下 ziplist 和 hashtable 这两种内部编码。
当 field 个数比较少并且 value 也不是很大时候 Redis 哈希类型的内部编码为 ziplist:
当 value 中的字节数大于 64 字节时(可以通过 hash-max-ziplist-value 设置),内部编码会由 ziplist 变成 hashtable。
当 field 个数超过 512(可以通过 hash-max-ziplist-entries 参数设置),内部编码也会由 ziplist 变成 hashtable
List 列表
常用命令:
lpush,rpush,lpop,rpop,lrange等。
127.0.0.1:6379> lpush list one # 将一个值或者多个值,插入到列表的头部(左)(integer) 1 127.0.0.1:6379> lpush list two (integer) 2 127.0.0.1:6379> lpush list three (integer) 3 127.0.0.1:6379> lrange list 0 -1 # 查看全部元素 1) "three" 2) "two" 3) "one" 127.0.0.1:6379> lrange list 0 1 # 通过区间获取值 1) "three" 2) "two" 127.0.0.1:6379> rpush list right # 将一个值或者多个值,插入到列表的尾部(右)(integer) 4 127.0.0.1:6379> lrange list 0 -1 1) "three" 2) "two" 3) "one" 4) "right" 127.0.0.1:6379>
列表(list)用来存储多个有序的字符串,每个字符串称为元素;一个列表可以存储2^32-1个元素。Redis中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等
应用场景
比如 twitter 的关注列表,粉丝列表等都可以用 Redis 的 list 结构来实现,可以利用lrange命令,做基于Redis的分页功能,性能极佳,用户体验好。
消息队列
列表类型可以使用 rpush 实现先进先出的功能,同时又可以使用 lpop 轻松的弹出(查询并删除)第一个元素,所以列表类型可以用来实现消息队列
发红包的场景
在发红包的场景中,假设发一个10元,10个红包,需要保证抢红包的人不会多抢到,也不会少抢到
下面我们通过下图来看一下 Redis 中列表类型的插入和弹出操作:
下面我们看一下 Redis 中列表类型的获取与删除操作:
Redis 列表类型的特点如下:
- 列表中所有的元素都是有序的,所以它们是可以通过索引获取的lindex 命令。并且在 Redis 中列表类型的索引是从 0 开始的。
- 列表中的元素是可以重复的,也就是说在 Redis 列表类型中,可以保存同名元素
内部编码
- ziplist(压缩列表):当列表中元素个数小于 512(默认)个,并且列表中每个元素的值都小于 64(默认)个字节时,Redis 会选择用 ziplist 来作为列表的内部实现以减少内存的使用。当然上述默认值也可以通过相关参数修改:list-max-ziplist-entried(元素个数)、list-max-ziplist-value(元素值)。
- linkedlist(链表):当列表类型无法满足 ziplist 条件时,Redis 会选择用 linkedlist 作为列表的内部实现。
Set 集合
常用命令:
sadd,spop,smembers,sunion,scard,sscan,sismember等。
# 初始化 sadd poker T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 TJ TQ TK X1 X2 X3 X4 X5 X6 X7 X8 X9 X10 XJ XQ XK M1 M2 M3 M4 M5 M6 M7 M8 M9 M10 MJ MQ MK F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 FJ FQ FK XW DW # 数量 scard poker # 复用扑克牌 sunionstore pokernew poker # 成员 smembers poker # 是否包含 sismember poker T1 # 随机读取 spop poker # 设置 sadd user1tag tagID1 tagID2 tagID3 sadd user2tag tagID2 tagID3 sadd user3tag tagID2 tagID4 tagID5 # 获取共同拥有的tag(交集) sinter user1tag user2tag user3tag # 获取拥有的所有tag(并集) sunion user1tag user2tag user3tag # 获取两个之间的区别(差集) sdiff user2tag user3tag
应用场景
Redis set 对外提供的功能与 list 类似是一个列表的功能,特殊之处在于 set 是可以自动排重的,当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。
1知乎点赞数
2京东的商品筛选
127.0.0.1:6379> sadd brand:apple iPhone11 (integer) 1 127.0.0.1:6379> sadd brand:ios iPhone11 (integer) 1 127.0.0.1:6379> sadd screensize:6.0-6.24 iPhone11 (integer) 1 127.0.0.1:6379> sadd memorysize:256GB iPhone11 (integer) 1 127.0.0.1:6379> sinter brand:apple brand:ios screensize:6.0-6.24 memorysize:256GB 1) "iPhone11"
筛选商品,苹果,IOS,屏幕6.0-6.24,内存大小256G
sinter brand:apple brand:ios screensize:6.0-6.24 memorysize:256GB
3.存储社交关系
用户(编号user001)关注
sadd focus:user001 user003
sadd focus:user002 user003 user004
相互关注
sadd focus:user001 user002
sadd focus:user002 user001
#判断用户2是否关注了用户1 127.0.0.1:6379> SISMEMBER focus:user002 user001 (integer) 1
我关注得到人也关注了他(共同关注)
#获取关注的交集 127.0.0.1:6379> sinter focus:user001 focus:user002 1) "user003"
可能认识的人
#将所有的人存放到allusers集合 127.0.0.1:6379> SUNIONSTORE alluser:user001 focus:user001 focus:user002 (integer) 4 127.0.0.1:6379> SDIFF alluser:user001 focus:user001 1) "user004" 2) "user001" #剔除掉自己 127.0.0.1:6379> SREM alluser:user001 user001 (integer) 1 127.0.0.1:6379> SDIFF alluser:user001 focus:user001 1) "user004"
实现方式:
set 的内部实现是一个 value 永远为 null 的 HashMap,实际就是通过计算 hash 的方式来快速排重的,这也是 set 能提供判断一个成员是否在集合内的原因。
Redis 中的集合类型,也就是 set。在 Redis 中 set 也是可以保存多个字符串的,经常有人会分不清 list 与 set,下面我们重点介绍一下它们之间的不同:
- set 中的元素是不可以重复的,而 list 是可以保存重复元素的。
- set 中的元素是无序的,而 list 中的元素是有序的。
- set 中的元素不能通过索引下标获取元素,而 list 中的元素则可以通过索引下标获取元素。
- 除此之外 set 还支持更高级的功能,例如多个 set 取交集、并集、差集等
为什么 Redis 要提供 sinterstore、sunionstore、sdiffstore 命令来将集合的交集、并集、差集的结果保存起来呢?这是因为 Redis 在进行上述比较时,会比较耗费时间,所以为了提高性能可以将交集、并集、差集的结果提前保存起来,这样在需要使用时,可以直接通过 smembers 命令获取。
内部编码
- intset(整数集合):当集合中的元素都是整数,并且集合中的元素个数小于set-max-intset-entries 参数时,默认512,Redis 会选用 intset 作为底层内部实现。
- hashtable(哈希表):当上述条件不满足时,Redis 会采用 hashtable 作为底层实现。
Sorted set 有序集合
常用命令:
zadd,zrange,zrem,zcard,zscore,zcount,zlexcount等
redis> ZRANGE salary 0 -1 WITHSCORES # 测试数据 1) "tom" 2) "2000" 3) "peter" 4) "3500" 5) "jack" 6) "5000" redis> ZSCORE salary peter # 注意返回值是字符串 "3500" redis> ZCOUNT salary 2000 5000 # 计算薪水在 2000-5000 之间的人数 (integer) 3 # rank:key 100: 分数 u1: ID # 初始化 时间复杂度: O(log(N)) zadd rank 100 u1 zadd rank 200 u2 zadd rank 300 u3 zadd rank 400 u4 zadd rank 500 u5 # 数量 zcard rank # 内容(正序、倒序)时间复杂度: O(log(N)+M) zrange rank 0 -1 zrange rank 0 -1 withscores zrevrange rank 0 -1 withscores zrevrange rank 0 -2 withscores zrevrange rank 0 -1 # 获取用户分数(不存在返回空) 时间复杂度: O(1) zscore rank u1 # 修改分数 zadd rank 800 u3 # 修改分数(累加:新增或减少,返回修改后的分数) zincrby rank 100 u3 zincrby rank -100 u3 # 查询分数范围内容(正序、倒序) zrangebyscore rank (200 800 withscores zrevrangebyscore rank (200 800 withscores # 移除元素 zrem rank u3
下面先看一下列表、集合、有序集合三种数据类型之间的区别:
内部编码
- ziplist(压缩列表):当有序集合的元素个数小于 128 个(默认设置),同时每个元素的值都小于 64 字节(默认设置),Redis 会采用 ziplist 作为有序集合的内部实现。
- skiplist(跳跃表):当上述条件不满足时,Redis 会采用 skiplist 作为内部编码。
备注:上述中的默认值,也可以通过以下参数设置:zset-max-ziplist-entries 和 zset-max-ziplist-value。
应用场景
Redis sorted set 的使用场景与 set 类似,区别是 set 不是自动有序的,而 sorted set 可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。
当你需要一个有序的并且不重复的集合列表,那么可以选择 sorted set 数据结构,比如 twitter 的 public timeline 可以以发表时间作为 score 来存储,这样获取时就是自动按时间排好序的。点击数做出排行榜。
1.商品的评价标签,可以记录商品的标签,统计标签次数,增加标签次数,按标签的分值进行排序
#添加商品(编号i5001)的标签tag和对应标签的评价次数 127.0.0.1:6379> zadd goods_tag:i5001 442 tag1 265 tag2 264 tag3 (integer) 3 #不带分数 127.0.0.1:6379> zrange goods_tag:i5001 0 -1 1) "tag3" 2) "tag2" 3) "tag1" #带分数 127.0.0.1:6379> zrange goods_tag:i5001 0 -1 withscores 1) "tag3" 2) "264" 3) "tag2" 4) "265" 5) "tag1" 6) "442"
2.百度搜索热点
#维护2020年1月21号的热点新闻 127.0.0.1:6379> zadd hotspot:20200121 520 pot1 263 pot2 244 pot3 (integer) 3 127.0.0.1:6379> zrange hotspot:20200121 0 -1 withscores 1) "pot3" 2) "244" 3) "pot2" 4) "263" 5) "pot1" 6) "520" #增加点击次数 127.0.0.1:6379> ZINCRBY hotspot 1 pot1 "521"
3.反spam系统
作为一个电商网站被各种spam攻击是少不免(垃圾评论、发布垃圾商品、广告、刷自家商品排名等)针对这些spam制定一系列anti-spam规则,其中有些规则可以利用redis做实时分析
譬如:1分钟评论不得超过2次、5分钟评论少于5次等
#获取5秒内操作记录 $res = $redis->zRangeByScore('user:1000:comment', time() - 5, time()); #判断5秒内不能评论 if (!$res) { $redis->zAdd('user:1000:comment', time(), '评论内容'); } else { echo '5秒之内不能评论'; } #5秒内评论不得超过2次 if($redis->zRangeByScore('user:1000:comment',time()-5 ,time())==1) echo '5秒之内不能评论2次'; #5秒内评论不得少于2次 if(count($redis->zRangeByScore('user:1000:comment',time()-5 ,time()))<2) echo '5秒之内不能评论2次';
BitMap 位图
在应用场景中,有一些数据只有两个属性,比如是否是学生,是否是党员等等,对于这些数据,最节约内存的方式就是用bit去记录,以是否是学生为例,1代表是学生,0代表不是学生。那么1000110就代表7个人中3个是学生,这就是BitMaps的存储需求。
Bitmaps是一个可以对位进行操作的字符串,我们可以把Bitmaps想象成是一串二进制数字,每个位置只存储0和1。下标是Bitmaps的偏移量。
BitMap 就是通过一个 bit 位来表示某个元素对应的值或者状态, 其中的 key 就是对应元素本身,实际上底层也是通过对字符串的操作来实现。Redis从2.2.0版本开始新增了setbit
,getbit
,bitcount
等几个bitmap相关命令。
虽然是新命令,但是并没有新增新的数据类型,因为setbit
等命令只不过是在set
上的扩展。
使用场景一:用户签到
很多网站都提供了签到功能(这里不考虑数据落地事宜),并且需要展示最近一个月的签到情况
<?php $redis = new Redis(); $redis->connect('127.0.0.1'); //用户uid $uid = 1; //记录有uid的key $cacheKey = sprintf("sign_%d", $uid); //开始有签到功能的日期 $startDate = '2017-01-01'; //今天的日期 $todayDate = '2017-01-21'; //计算offset $startTime = strtotime($startDate); $todayTime = strtotime($todayDate); $offset = floor(($todayTime - $startTime) / 86400); echo "今天是第{$offset}天" . PHP_EOL; //签到 //一年一个用户会占用多少空间呢?大约365/8=45.625个字节,好小,有木有被惊呆? $redis->setBit($cacheKey, $offset, 1); //查询签到情况 $bitStatus = $redis->getBit($cacheKey, $offset); echo 1 == $bitStatus ? '今天已经签到啦' : '还没有签到呢'; echo PHP_EOL; //计算总签到次数 echo $redis->bitCount($cacheKey) . PHP_EOL; /** * 计算某段时间内的签到次数 * 很不幸啊,bitCount虽然提供了start和end参数,但是这个说的是字符串的位置,而不是对应"位"的位置 * 幸运的是我们可以通过get命令将value取出来,自己解析。并且这个value不会太大,上面计算过一年一个用户只需要45个字节 * 给我们的网站定一个小目标,运行30年,那么一共需要1.31KB(就问你屌不屌?) */ //这是个错误的计算方式 echo $redis->bitCount($cacheKey, 0, 20) . PHP_EOL;
使用场景二:统计活跃用户
使用时间作为cacheKey,然后用户ID为offset,如果当日活跃过就设置为1
那么我该如果计算某几天/月/年的活跃用户呢(暂且约定,统计时间内只有有一天在线就称为活跃),有请下一个redis的命令
命令 BITOP operation destkey key [key ...]
说明:对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。
说明:BITOP 命令支持 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种参数
bitop and destKey key1 key2.... //交 bitop or destKey key1 key2.... //并 bitop not destKey key1 key2.... //非 bitop xor destKey key1 key2.... //异或
<?php //日期对应的活跃用户 $data = array( '2017-01-10' => array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), '2017-01-11' => array(1, 2, 3, 4, 5, 6, 7, 8), '2017-01-12' => array(1, 2, 3, 4, 5, 6), '2017-01-13' => array(1, 2, 3, 4), '2017-01-14' => array(1, 2) ); //批量设置活跃状态 foreach ($data as $date => $uids) { $cacheKey = sprintf("stat_%s", $date); foreach ($uids as $uid) { $redis->setBit($cacheKey, $uid, 1); } } $redis->bitOp('AND', 'stat', 'stat_2017-01-10', 'stat_2017-01-11', 'stat_2017-01-12') . PHP_EOL; //总活跃用户:6 echo "总活跃用户:" . $redis->bitCount('stat') . PHP_EOL; $redis->bitOp('AND', 'stat1', 'stat_2017-01-10', 'stat_2017-01-11', 'stat_2017-01-14') . PHP_EOL; //总活跃用户:2 echo "总活跃用户:" . $redis->bitCount('stat1') . PHP_EOL; $redis->bitOp('AND', 'stat2', 'stat_2017-01-10', 'stat_2017-01-11') . PHP_EOL; //总活跃用户:8 echo "总活跃用户:" . $redis->bitCount('stat2') . PHP_EOL;
使用场景三:用户在线状态
前段时间开发一个项目,对方给我提供了一个查询当前用户是否在线的接口。不了解对方是怎么做的,自己考虑了一下,使用bitmap是一个节约空间效率又高的一种方法,只需要一个key,然后用户ID为offset,如果在线就设置为1,不在线就设置为0,和上面的场景一样,5000W用户只需要6MB的空间。
<?php //批量设置在线状态 $uids = range(1, 500000); foreach ($uids as $uid) { $redis->setBit('online', $uid, $uid % 2); } //一个一个获取状态 $uids = range(1, 500000); $startTime = microtime(true); foreach ($uids as $uid) { echo $redis->getBit('online', $uid) . PHP_EOL; } $endTime = microtime(true); //在我的电脑上,获取50W个用户的状态需要25秒 echo "total:" . ($endTime - $startTime) . "s";
内部编码
这个就是Redis实现的BloomFilter,BloomFilter非常简单,如下图所示,假设已经有3个元素a、b和c,分别通过3个hash算法h1()、h2()和h2()计算然后对一个bit进行赋值,接下来假设需要判断d是否已经存在,那么也需要使用3个hash算法h1()、h2()和h2()对d进行计算,然后得到3个bit的值,恰好这3个bit的值为1,这就能够说明:d可能存在集合中。再判断e,由于h1(e)算出来的bit之前的值是0,那么说明:e一定不存在集合中:
需要说明的是,bitmap并不是一种真实的数据结构,它本质上是String数据结构,只不过操作的粒度变成了位,即bit。因为String类型最大长度为512MB,所以bitmap最多可以存储2^32个bit。
HyperLogLog 基数统计
HyperLogLog算法时一种非常巧妙的近似统计大量去重元素数量的算法,它内部维护了16384个桶来记录各自桶的元素数量,当一个元素过来,它会散列到其中一个桶。
当元素到来时,通过 hash 算法将这个元素分派到其中的一个小集合存储,同样的元素总是会散列到同样的小集合。这样总的计数就是所有小集合大小的总和。
使用这种方式精确计数除了可以增加元素外,还可以减少元素
一个HyperLogLog实际占用的空间大约是 13684 * 6bit / 8 = 12k 字节。但是在计数比较小的时候,大多数桶的计数值都是零。如果 12k 字节里面太多的字节都是零,那么这个空间是可以适当节约一下的。Redis 在计数值比较小的情况下采用了稀疏存储,稀疏存储的空间占用远远小于 12k 字节。相对于稀疏存储的就是密集存储,密集存储会恒定占用 12k 字节。
内部编码
HyperLogLog 整体的内部结构就是 HLL 对象头 加上 16384 个桶的计数值位图。它在 Redis 的内部结构表现就是一个字符串位图。你可以把 HyperLogLog 对象当成普通的字符串来进行处理。
应用场景
Redis 的基数统计,这个结构可以非常省内存的去统计各种计数,比如注册 IP 数、每日访问 IP 数、页面实时UV)、在线用户数等。但是它也有局限性,就是只能统计数量,而没办法去知道具体的内容是什么。当然用集合也可以解决这个问题。但是一个大型的网站,每天 IP 比如有 100 万,粗算一个 IP 消耗 15 字节,那么 100 万个 IP 就是 15M。而 HyperLogLog 在 Redis 中每个键占用的内容都是 12K,理论存储近似接近 2^64 个值,不管存储的内容是什么,它一个基于基数估算的算法,只能比较准确的估算出基数,可以使用少量固定的内存去存储并识别集合中的唯一元素。而且这个估算的基数并不一定准确,是一个带有 0.81% 标准错误的近似值。
HyperLogLog 主要的应用场景就是进行基数统计。这个问题的应用场景其实是十分广泛的。例如:对于 Google 主页面而言,同一个账户可能会访问 Google 主页面多次。于是,在诸多的访问流水中,如何计算出 Google 主页面每天被多少个不同的账户访问过就是一个重要的问题。那么对于 Google 这种访问量巨大的网页而言,其实统计出有十亿 的访问量或者十亿零十万的访问量其实是没有太多的区别的,因此,在这种业务场景下,为了节省成本,其实可以只计算出一个大概的值,而没有必要计算出精准的值
这个数据结构的命令有三个:PFADD、PFCOUNT、PFMERGE
redis> PFADD databases "Redis" "MongoDB" "MySQL" (integer) 1 redis> PFADD databases "Redis" # Redis 已经存在,不必对估计数量进行更新 (integer) 0 redis> PFCOUNT databases (integer) 3
Geo 地理位置
Redis 的 GEO 特性在 Redis 3.2 版本中推出, 这个功能可以将用户给定的地理位置信息储存起来, 并对这些信息进行操作。
GEO的数据结构总共有六个命令:geoadd、geopos、geodist、georadius、georadiusbymember、gethash,GEO使用的是国际通用坐标系WGS-84。
- 1.GEOADD:添加地理位置
- 2.GEOPOS:查询地理位置(经纬度),返回数组
- 3.GEODIST:计算两位位置间的距离
- 4.GEORADIUS:以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素。
- 5.GEORADIUSBYMEMBER:以给定的地理位置为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素。
127.0.0.1:6379> geoadd kcityGeo 116.405285 39.904989 "beijing" (integer) 1 127.0.0.1:6379> geoadd kcityGeo 121.472644 31.231706 "shanghai" (integer) 1 127.0.0.1:6379> geodist kcityGeo beijing shanghai km "1067.5980" 127.0.0.1:6379> geopos kcityGeo beijing 1) 1) "116.40528291463851929" 2) "39.9049884229125027" 127.0.0.1:6379> geohash kcityGeo beijing 1) "wx4g0b7xrt0" 127.0.0.1:6379> georadiusbymember kcityGeo beijing 1200 km withdist withcoord asc count 5 1) 1) "beijing" 2) "0.0000" 3) 1) "116.40528291463851929" 2) "39.9049884229125027" 2) 1) "shanghai" 2) "1067.5980" 3) 1) "121.47264629602432251" 2) "31.23170490709807012"
内部编码
但是,需要说明的是,Geo本身不是一种数据结构,它本质上还是借助于Sorted Set(ZSET),并且使用GeoHash技术进行填充。Redis中将经纬度使用52位的整数进行编码,放进zset中,score就是GeoHash的52位整数值。在使用Redis进行Geo查询时,其内部对应的操作其实就是zset(skiplist)的操作。通过zset的score进行排序就可以得到坐标附近的其它元素,通过将score还原成坐标值就可以得到元素的原始坐标。
总之,Redis中处理这些地理位置坐标点的思想是:二维平面坐标点 --> 一维整数编码值 --> zset(score为编码值) --> zrangebyrank(获取score相近的元素)、zrangebyscore --> 通过score(整数编码值)反解坐标点 --> 附近点的地理位置坐标。
应用场景
比如现在比较火的直播业务,我们需要检索附近的主播,那么GEO就可以很好的实现这个功能。
- 一是主播开播的时候写入主播Id的经纬度
- 二是主播关播的时候删除主播Id元素,这样就维护了一个具有位置信息的在线主播集合提供给线上检索
Streams 流
这是Redis5.0引入的全新数据结构,用一句话概括Streams就是Redis实现的内存版kafka。支持多播的可持久化的消息队列,用于实现发布订阅功能,借鉴了 kafka 的设计。
Redis Stream的结构有一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的ID和对应的内容。消息是持久化的,Redis重启后,内容还在。
每个Stream都有唯一的名称,它就是Redis的key,在我们首次使用xadd指令追加消息时自动创建。
每个Stream都可以挂多个消费组,每个消费组会有个游标last_delivered_id在Stream数组之上往前移动,表示当前消费组已经消费到哪条消息了。每个消费组都有一个Stream内唯一的名称,消费组不会自动创建,它需要单独的指令xgroup create进行创建,需要指定从Stream的某个消息ID开始消费,这个ID用来初始化last_delivered_id变量。
每个消费组(Consumer Group)的状态都是独立的,相互不受影响。也就是说同一份Stream内部的消息会被每个消费组都消费到。
同一个消费组(Consumer Group)可以挂接多个消费者(Consumer),这些消费者之间是竞争关系,任意一个消费者读取了消息都会使游标last_delivered_id往前移动。每个消费者者有一个组内唯一名称。
消费者(Consumer)内部会有个状态变量pending_ids,它记录了当前已经被客户端读取的消息,但是还没有ack。如果客户端没有ack,这个变量里面的消息ID会越来越多,一旦某个消息被ack,它就开始减少。这个pending_ids变量在Redis官方被称之为PEL,也就是Pending Entries List,这是一个很核心的数据结构,它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。
- 消息ID:消息ID的形式是timestampInMillis-sequence,例如1527846880572-5,它表示当前的消息在毫米时间戳1527846880572时产生,并且是该毫秒内产生的第5条消息。消息ID可以由服务器自动生成,也可以由客户端自己指定,但是形式必须是整数-整数,而且必须是后面加入的消息的ID要大于前面的消息ID。
- 消息内容:消息内容就是键值对,形如hash结构的键值对,这没什么特别之处。
127.0.0.1:6379> XADD mystream * field1 value1 field2 value2 field3 value3 "1588491680862-0" 127.0.0.1:6379> XADD mystream * username lisi age 18 "1588491854070-0" 127.0.0.1:6379> xlen mystream (integer) 2 127.0.0.1:6379> XADD mystream * username lisi age 18 "1588491861215-0" 127.0.0.1:6379> xrange mystream - + 1) 1) "1588491680862-0" 2) 1) "field1" 2) "value1" 3) "field2" 4) "value2" 5) "field3" 6) "value3" 2) 1) "1588491854070-0" 2) 1) "username" 2) "lisi" 3) "age" 4) "18" 3) 1) "1588491861215-0" 2) 1) "username" 2) "lisi" 3) "age" 4) "18" 127.0.0.1:6379> xdel mystream 1588491854070-0 (integer) 1 127.0.0.1:6379> xrange mystream - + 1) 1) "1588491680862-0" 2) 1) "field1" 2) "value1" 3) "field2" 4) "value2" 5) "field3" 6) "value3" 2) 1) "1588491861215-0" 2) 1) "username" 2) "lisi" 3) "age" 4) "18" 127.0.0.1:6379> xlen mystream (integer) 2
内部编码
streams底层的数据结构是radix tree:Radix Tree(基数树) 事实上就几乎相同是传统的二叉树。仅仅是在寻找方式上,以一个unsigned int类型数为例,利用这个数的每个比特位作为树节点的推断。
能够这样说,比方一个数10001010101010110101010,那么依照Radix 树的插入就是在根节点,假设遇到0,就指向左节点,假设遇到1就指向右节点,在插入过程中构造树节点,在删除过程中删除树节点。如下是一个保存了7个单词的Radix Tree:
应用场景总结
实际上,所谓的应用场景,其实就是合理的利用Redis本身的数据结构的特性来完成相关业务功能
常用内存优化手段与参数
通过我们上面的一些实现上的分析可以看出 redis 实际上的内存管理成本非常高,即占用了过多的内存,作者对这点也非常清楚,所以提供了一系列的参数和手段来控制和节省内存,我们分别来讨论下。
首先最重要的一点是不要开启 Redis 的 VM 选项,即虚拟内存功能,这个本来是作为 Redis 存储超出物理内存数据的一种数据在内存与磁盘换入换出的一个持久化策略,但是其内存管理成本也非常的高,并且我们后续会分析此种持久化策略并不成熟,所以要关闭 VM 功能,请检查你的 redis.conf 文件中 vm-enabled 为 no。
其次最好设置下 redis.conf 中的 maxmemory 选项,该选项是告诉 Redis 当使用了多少物理内存后就开始拒绝后续的写入请求,该参数能很好的保护好你的 Redis 不会因为使用了过多的物理内存而导致 swap,最终严重影响性能甚至崩溃。
另外 Redis 为不同数据类型分别提供了一组参数来控制内存使用,我们在前面详细分析过 Redis Hash 是 value 内部为一个 HashMap,如果该 Map 的成员数比较少,则会采用类似一维线性的紧凑格式来存储该 Map,即省去了大量指针的内存开销,这个参数控制对应在 redis.conf 配置文件中下面2项:
hash-max-ziplist-entries 64 hash-max-zipmap-value 512
含义是当 value 这个 Map 内部不超过多少个成员时会采用线性紧凑格式存储,默认是64,即 value 内部有64个以下的成员就是使用线性紧凑存储,超过该值自动转成真正的 HashMap。
hash-max-zipmap-value 含义是当 value 这个 Map 内部的每个成员值长度不超过多少字节就会采用线性紧凑存储来节省空间。
以上2个条件任意一个条件超过设置值都会转换成真正的 HashMap,也就不会再节省内存了,那么这个值是不是设置的越大越好呢,答案当然是否定的,HashMap 的优势就是查找和操作的时间复杂度都是 O(1) 的,而放弃 Hash 采用一维存储则是 O(n) 的时间复杂度,如果成员数量很少,则影响不大,否则会严重影响性能,所以要权衡好这个值的设置,总体上还是最根本的时间成本和空间成本上的权衡。
同样类似的参数还有
list-max-ziplist-entries 512
说明:list 数据类型多少节点以下会采用去指针的紧凑存储格式。
list-max-ziplist-value 64
说明:list 数据类型节点值大小小于多少字节会采用紧凑存储格式。
set-max-intset-entries 512
说明:set 数据类型内部数据如果全部是数值型,且包含多少节点以下会采用紧凑格式存储。
最后想说的是 Redis 内部实现没有对内存分配方面做过多的优化,在一定程度上会存在内存碎片,不过大多数情况下这个不会成为 Redis 的性能瓶 颈,不过如果在 Redis 内部存储的大部分数据是数值型的话,Redis 内部采用了一个 shared integer 的方式来省去分配内存的开销,即在系统启动时先分配一个从 1~n 那么多个数值对象放在一个池子中,如果存储的数据恰好是这个数值范围内的数据,则直接从池子里取出该对象,并且通过引用计数的方式来共享,这样在系统存储了大量数值下,也能一定程度上节省内存并且提高性能,这个参数值 n 的设置需要修改源代码中的一行宏定义 REDIS_SHARED_INTEGERS,该值 默认是 10000,可以根据自己的需要进行修改,修改后重新编译就可以了。
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。