在具体描述这几种数据类型之前,我们先通过一张图了解下 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
常用命令:
set,get,decr,incr,mget 等。
应用场景:
字符串是最常用的数据类型,他能够存储任何类型的字符串,当然也包括二进制、JSON化的对象、甚至是Base64编码之后的图片。在Redis中一个字符串最大的容量为512MB,可以说是无所不能了。
在 Redis 中字符串类型的内部编码有 3 种:
Hash
常用命令:
hget,hset,hgetall 等。
应用场景:
我们简单举个实例来描述下 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 单线程模型的缘故,这个遍历操作可能会比较耗时,而另其它客户端的请求完全不响应,这点需要格外注意。
Redis 哈希类型的内部编码有两种,它们分别是:
下面我们通过以下命令来演示一下 ziplist 和 hashtable 这两种内部编码。
当 field 个数比较少并且 value 也不是很大时候 Redis 哈希类型的内部编码为 ziplist:
当 value 中的字节数大于 64 字节时(可以通过 hash-max-ziplist-value 设置),内部编码会由 ziplist 变成 hashtable。
当 field 个数超过 512(可以通过 hash-max-ziplist-entries 参数设置),内部编码也会由 ziplist 变成 hashtable
$redis = new \Predis\Client(['host' => '127.0.0.1', 'port' => 6379, 'dataBase' => 15], [ 'prefix' => 'phone:common:' ]);
$key = 'phone_monthly_'.$month;
if ($redis->hexists($key, $field)) {
$result = json_decode($redis->hget($key, $field), true);
// 第一遍不存在更新缓存 再查
$reSetRedis = $result ? false : true;
} else {
$reSetRedis = true;
}
if ($reSetRedis) {
$result = $this->fetchAll($sql);
$redis->hset($key, $field, json_encode($result));
$redis->quit();
}
List
常用命令:
lpush,rpush,lpop,rpop,lrange等。
应用场景:
列表(list)用来存储多个有序的字符串,每个字符串称为元素;一个列表可以存储2^32-1个元素。Redis中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等
Redis list 的应用场景非常多,也是 Redis 最重要的数据结构之一,比如 twitter 的关注列表,粉丝列表等都可以用 Redis 的 list 结构来实现,可以利用lrange命令,做基于Redis的分页功能,性能极佳,用户体验好。
下面我们通过下图来看一下 Redis 中列表类型的插入和弹出操作:
下面我们看一下 Redis 中列表类型的获取与删除操作:
Redis 列表类型的特点如下:
列表中的内部编码有两种,它们分别是:
Set
常用命令:
sadd,spop,smembers,sunion 等。
应用场景:
Redis set 对外提供的功能与 list 类似是一个列表的功能,特殊之处在于 set 是可以自动排重的,当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。
实现方式:
set 的内部实现是一个 value 永远为 null 的 HashMap,实际就是通过计算 hash 的方式来快速排重的,这也是 set 能提供判断一个成员是否在集合内的原因。
Redis 中的集合类型,也就是 set。在 Redis 中 set 也是可以保存多个字符串的,经常有人会分不清 list 与 set,下面我们重点介绍一下它们之间的不同:
备注:我们可以通过 set-max-intset-entries 参数来设置上述中的默认参数。
Sorted set
常用命令:
zadd,zrange,zrem,zcard等
使用场景:
Redis sorted set 的使用场景与 set 类似,区别是 set 不是自动有序的,而 sorted set 可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。当你需要一个有序的并且不重复的集合列表,那么可以选择 sorted set 数据结构,比如 twitter 的 public timeline 可以以发表时间作为 score 来存储,这样获取时就是自动按时间排好序的。点击数做出排行榜。
下面先看一下列表、集合、有序集合三种数据类型之间的区别:备注:上述中的默认值,也可以通过以下参数设置:zset-max-ziplist-entries 和 zset-max-ziplist-value。
BitMap
BitMap 就是通过一个 bit 位来表示某个元素对应的值或者状态, 其中的 key 就是对应元素本身, 实际上底层也是通过对字符串的操作来实现。Redis从2.2.0版本开始新增了setbit
,
getbit
,
bitcount
等几个bitmap相关命令。虽然是新命令,但是并没有新增新的数据类型,因为
setbit
等命令只不过是在
set
上的扩展。
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 这四种操作中的任意一种参数
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的空间。
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";
HyperLogLog
Redis 的基数统计,这个结构可以非常省内存的去统计各种计数,比如注册 IP 数、每日访问 IP 数、页面实时UV)、在线用户数等。但是它也有局限性,就是只能统计数量,而没办法去知道具体的内容是什么。
当然用集合也可以解决这个问题。但是一个大型的网站,每天 IP 比如有 100 万,粗算一个 IP 消耗 15 字节,那么 100 万个 IP 就是 15M。而 HyperLogLog 在 Redis 中每个键占用的内容都是 12K,理论存储近似接近 2^64 个值,不管存储的内容是什么,它一个基于基数估算的算法,只能比较准确的估算出基数,可以使用少量固定的内存去存储并识别集合中的唯一元素。而且这个估算的基数并不一定准确,是一个带有 0.81% 标准错误的近似值。
这个数据结构的命令有三个:PFADD、PFCOUNT、PFMERGEredis> 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。
通过我们上面的一些实现上的分析可以看出 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,可以根据自己的需要进行修改,修改后重新编译就可以了。