编辑
2024-06-06
💌中间键
00
请注意,本文编写于 377 天前,最后修改于 225 天前,其中某些信息可能已经过时。

目录

3.3 Pipeline
3.3.1 Pipeline概念
3.3.2 性能测试
3.3.4 最佳实践
3.4 事务与Lua
3.4.1 事务
1.命令错误
2.运行时错误
3.4.2 Lua用法简述
1.数据类型及其逻辑处理
2.函数定义
3.4.3 Redis与Lua
1.在Redis中使用Lua
2.Lua的Redis API
3.4.4 案例
3.4.5 Redis如何管理Lua脚本
(1)script load
(2)script exists
(3)script flush
(4)script kill
3.5 Bitmaps
3.5.1 数据结构模型
3.5.2 命令
1.设置值
2.获取值
3.获取Bitmaps指定范围值为1的个数
4.Bitmaps间的运算
5.计算Bitmaps中第一个值为targetBit的偏移量
3.5.3 Bitmaps分析
3.6 HyperLogLog
1.添加
2.计算独立用户数
3.合并
3.7 发布订阅
3.7.1 命令
1.发布消息
2.订阅消息
3.取消订阅
4.按照模式订阅和取消订阅
5.查询订阅
3.7.2 使用场景
1.增加地理位置信息
2.获取地理位置信息
3.获取两个地理位置的距离。
4.获取指定位置范围内的地理信息位置集合
5.获取geohash
6.删除地理位置信息

3.3 Pipeline

3.3.1 Pipeline概念

Redis客户端执行一条命令分为如下四个过程:

  1. 发送命令
  2. 命令排队
  3. 命令执行
  4. 返回结果

其中1)+4)称为Round Trip Time(RTT,往返时间)。

Redis提供了批量操作命令(例如mget、mset等),有效地节约RTT。

但大部分命令是不支持批量操作的,例如要执行n次hgetall命令,并没有mhgetall命令存在,需要消耗n次RTT。

Redis的客户端和服务端可能部署在不同的机器上。

例如客户端在北京,Redis服务端在上海,两地直线距离约为\1300公里,那么1次RTT时间=1300×2/(300000×2/3)=13毫秒(光在真空中传输速度为每秒30万公里,这里假设光纤为光速的2/3),那么客户端在1秒内大约只能执行80次左右的命令,这个和Redis的高并发高吞吐特性背道而驰。

Pipeline(流水线)机制能改善上面这类问题,它能将一组Redis命令进行组装,通过一次RTT传输给Redis,再将这组Redis命令的执行结果按顺序返回给客户端,图3-5为没有使用Pipeline执行了n条命令,整个过程需要n次RTT。

image.png

redis-cli的--pipe选项实际上就是使用Pipeline机制,例如下面操作将set hello world和incr counter两条命令组装:

echo -en '*3\r\n$3\r\nSET\r\n$5\r\nhello\r\n$5\r\nworld\r\n*2\r\n$4\r\nincr\r\ n$7\r\ncounter\r\n' | redis-cli --pipe

3.3.2 性能测试

表3-1给出了在不同网络环境下非Pipeline和Pipeline执行10000次set操作的效果,可以得到如下两个结论:

  • Pipeline执行速度一般比逐条执行要快。
  • 客户端和服务端的网络延时越大,Pipeline的效果越明显。

image.png

image.png

3.3.4 最佳实践

Pipeline虽然好用,但是每次Pipeline组装的命令个数不能没有节制,否则一次组装Pipeline数据量过大,

一方面会增加客户端的等待时间,另一方面会造成一定的网络阻塞,可以将一次包含大量命令的Pipeline拆分成多次较小的Pipeline来完成。

Pipeline只能操作一个Redis实例,但是即使在分布式Redis场景中,也可以作为批量操作的重要优化手段,

3.4 事务与Lua

3.4.1 事务

熟悉关系型数据库的读者应该对事务比较了解,简单地说,事务表示一 组动作,要么全部执行,要么全部不执行。例如在社交网站上用户A关注了 用户B,那么需要在用户A的关注表中加入用户B,并且在用户B的粉丝表中 添加用户A,这两个行为要么全部执行,要么全部不执行,否则会出现数据 不一致的情况。

Redis提供了简单的事务功能,将一组需要一起执行的命令放到multi和 exec两个命令之间。

multi命令代表事务开始,exec命令代表事务结束,它们 之间的命令是原子顺序执行的,例如下面操作实现了上述用户关注问题。

127.0.0.1:6379> multi OK 127.0.0.1:6379> sadd user:a:follow user:b QUEUED 127.0.0.1:6379> sadd user:b:fans user:a QUEUED

可以看到sadd命令此时的返回结果是QUEUED,代表命令并没有真正执 行,而是暂时保存在Redis中。如果此时另一个客户端执行sismember user: a:follow user:b返回结果应该为0。

127.0.0.1:6379> sismember user:a:follow user:b (integer) 0

只有当exec执行后,用户A关注用户B的行为才算完成,如下所示返回 的两个结果对应sadd命令。

127.0.0.1:6379> exec 1) (integer) 1 2) (integer) 1 127.0.0.1:6379> sismember user:a:follow user:b (integer) 1

如果要停止事务的执行,可以使用discard命令代替exec命令即可。

127.0.0.1:6379> discard OK 127.0.0.1:6379> sismember user:a:follow user:b (integer) 0

如果事务中的命令出现错误,Redis的处理机制也不尽相同。

1.命令错误

例如下面操作错将set写成了sett,属于语法错误,会造成整个事务无法 执行,key和counter的值未发生变化:

127.0.0.1:6388> mget key counter 1) "hello" 2) "100" 127.0.0.1:6388> multi OK 127.0.0.1:6388> sett key world (error) ERR unknown command 'sett' 127.0.0.1:6388> incr counter QUEUED 127.0.0.1:6388> exec (error) EXECABORT Transaction discarded because of previous errors. 127.0.0.1:6388> mget key counter 1) "hello" 2) "100"

2.运行时错误

例如用户B在添加粉丝列表时,误把sadd命令写成了zadd命令,这种就 是运行时命令,因为语法是正确的:

127.0.0.1:6379> multi OK 127.0.0.1:6379> sadd user:a:follow user:b QUEUED 201 127.0.0.1:6379> zadd user:b:fans 1 user:a QUEUED 127.0.0.1:6379> exec 1) (integer) 1 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value 127.0.0.1:6379> sismember user:a:follow user:b (integer) 1

可以看到Redis并不支持回滚功能,sadd user:a:follow user:b命令已 经执行成功,开发人员需要自己修复这类问题。 有些应用场景需要在事务之前,确保事务中的key没有被其他客户端修 改过,才执行事务,否则不执行(类似乐观锁)。

image.png

Redis提供了watch命令来解决这类问题,表3-2展示了两个客户端执行命令的时序。 表3-2 事务中watch命令演示时序 可以看到“客户端-1”在执行multi之前执行了watch命令,“客户 端-2”在“客户端-1”执行exec之前修改了key值,造成事务没有执行(exec结果 为nil),整个代码如下所示:

#T1:客户端1 127.0.0.1:6379> set key "java" OK #T2:客户端1 127.0.0.1:6379> watch key OK #T3:客户端1 127.0.0.1:6379> multi OK #T4:客户端2 127.0.0.1:6379> append key python (integer) 11 #T5:客户端1 202 127.0.0.1:6379> append key jedis QUEUED #T6:客户端1 127.0.0.1:6379> exec (nil) #T7:客户端1 127.0.0.1:6379> get key "javapython"

3.4.2 Lua用法简述

Redis将Lua作为脚本语言可帮助开发 者定制自己的Redis命令,在这之前,必须修改源码。在介绍如何在Redis中 使用Lua脚本之前,有必要对Lua语言的使用做一个基本的介绍。

1.数据类型及其逻辑处理

Lua语言提供了如下几种数据类型:booleans(布尔)、numbers(数 值)、strings(字符串)、tables(表格),和许多高级语言相比,相对简 单。下面将结合例子对Lua的基本数据类型和逻辑处理进行说明。

(1)字符串

下面定义一个字符串类型的数据:

local strings val = "world"

其中,local代表val是一个局部变量,如果没有local代表是全局变量。 print函数可以打印出变量的值,例如下面代码将打印world,其中"--"是Lua 语言的注释。 -- 结果是"world"

print(hello)

(2)数组

在Lua中,如果要使用类似数组的功能,可以用tables类型,下面代码使 用定义了一个tables类型的变量myArray,但和大多数编程语言不同的是, Lua的数组下标从1开始计算:

local tables myArray = {"redis", "jedis", true, 88.0} --true print(myArray[3])

如果想遍历这个数组,可以使用for和while,这些关键字和许多编程语 言是一致的。

  • (a)for

    下面代码会计算1到100的和,关键字for以end作为结束符:

    local int sum = 0 for i = 1, 100 do sum = sum + i end -- 输出结果为5050 print(sum)

    要遍历myArray,首先需要知道tables的长度,只需要在变量前加一个#号即可:

    for i = 1, #myArray do print(myArray[i]) end

    除此之外,Lua还提供了内置函数ipairs,使用for index,valueipairs(tables)可以遍历出所有的索引下标和值:

    for index,value in ipairs(myArray) do print(index) print(value) end
  • (b)while

    下面代码同样会计算1到100的和,只不过使用的是while循环,while循环同样以end作为结束符。

    local int sum = 0 local int i = 0 while i <= 100 do sum = sum +i i = i + 1 end --输出结果为5050 print(sum)
  • (c)if else

    要确定数组中是否包含了jedis,有则打印true,注意if以end结尾,if后 紧跟then:

    local tables myArray = {"redis", "jedis", true, 88.0} for i = 1, #myArray do if myArray[i] == "jedis" then print("true") break else --do nothing end end

(3)哈希

如果要使用类似哈希的功能,同样可以使用tables类型,例如下面代码 定义了一个tables,每个元素包含了key和value,其中strings1..string2是将两 个字符串进行连接:

local tables user_1 = {age = 28, name = "tome"} --user_1 age is 28 print("user_1 age is " .. user_1["age"])

如果要遍历user_1,可以使用Lua的内置函数pairs:

for key,value in pairs(user_1) do print(key .. value) end

2.函数定义

在Lua中,函数以function开头,以end结尾,funcName是函数名,中间部分是函数体:

lua
function funcName() ... end contact函数将两个字符串拼接: function contact(str1, str2) return str1 .. str2 end --"hello world" print(contact("hello ", "world"))

注意 本书只是介绍了Lua部分功能,因为Lua的全部功能已经超出本书的范 围,读者可以购买相应的书籍或者到Lua的官方网站(http://www.lua.org/) 进行学习。

3.4.3 Redis与Lua

1.在Redis中使用Lua

在Redis中执行Lua脚本有两种方法:eval和evalsha。

(1)eval

eval 脚本内容 key个数 key列表 参数列表 下面例子使用了key列表和参数列表来为Lua脚本提供更多的灵活性:

redis
127.0.0.1:6379> eval 'return "hello " .. KEYS[1] .. ARGV[1]' 1 redis world "hello redisworld"

此时KEYS[1]="redis",ARGV[1]="world",所以最终的返回结果 是"hello redisworld"。

如果Lua脚本较长,还可以使用redis-cli--eval直接执行文件。

eval命令和--eval参数本质是一样的,客户端如果想执行Lua脚本,

首先在客户端编写好Lua脚本代码,然后把脚本作为字符串发送给服务端,服务端会将执行结果返回给客户端,整个过程如图3-7所示。

image.png

(2)evalsha

image.png

除了使用eval,Redis还提供了evalsha命令来执行Lua脚本。如图3-8所示,首先要将Lua脚本加载到Redis服务端,得到该脚本的SHA1校验和,evalsha命令使用SHA1作为参数可以直接执行对应Lua脚本,避免每次发送Lua脚本的开销。

这样客户端就不需要每次执行脚本内容,而脚本也会常驻在服务端,脚本功能得到了复用。

加载脚本:script load命令可以将脚本内容加载到Redis内存中,例如下面将lua_get.lua加载到Redis中,得到SHA1 为:"7413dc2440db1fea7c0a0bde841fa68eefaf149c"

# redis-cli script load "$(cat lua_get.lua)" "7413dc2440db1fea7c0a0bde841fa68eefaf149c"

执行脚本:evalsha的使用方法如下,参数使用SHA1值,执行逻辑和 eval一致。

evalsha 脚本SHA1值 key个数 key列表 参数列表

所以只需要执行如下操作,就可以调用lua_get.lua脚本:

127.0.0.1:6379> evalsha 7413dc2440db1fea7c0a0bde841fa68eefaf149c 1 redis world "hello redisworld"

2.Lua的Redis API

Lua可以使用redis.call函数实现对Redis的访问,例如下面代码是Lua使用redis.call调用了Redis的set和get操作:

redis.call("set", "hello", "world") redis.call("get", "hello")

放在Redis的执行效果如下:

127.0.0.1:6379> eval 'return redis.call("get", KEYS[1])' 1 hello "world"

除此之外Lua还可以使用redis.pcall函数实现对Redis的调用,redis.call和redis.pcall的不同在于,

如果redis.call执行失败,那么脚本执行结束会直接返回错误,

而redis.pcall会忽略错误继续执行脚本,所以在实际开发中要根据 具体的应用场景进行函数的选择。

开发提示

Lua可以使用redis.log函数将Lua脚本的日志输出到Redis的日志文件中,但是一定要控制日志级别。

Redis3.2提供了Lua Script Debugger功能用来调试复杂的Lua脚本,具体可以参考:http://redis.io/topics/ldb。

3.4.4 案例

Lua脚本功能为Redis开发和运维人员带来如下三个好处:

  • Lua脚本在Redis中是原子执行的,执行过程中间不会插入其他命令。
  • Lua脚本可以帮助开发和运维人员创造出自己定制的命令,并可以将这些命令常驻在Redis内存中,实现复用的效果。
  • Lua脚本可以将多条命令一次性打包,有效地减少网络开销。

下面以一个例子说明Lua脚本的使用,当前列表记录着热门用户id, 假设这个列表有5个元素,如下所示:

127.0.0.1:6379> lrange hot:user:list 0 -1 1) "user:1:ratio" 2) "user:8:ratio" 3) "user:3:ratio" 4) "user:99:ratio" 5) "user:72:ratio"

user:{id}:ratio代表用户的热度,它本身又是一个字符串类型的键:

127.0.0.1:6379> mget user:1:ratio user:8:ratio user:3:ratio user:99:ratio user:72:ratio 1) "986" 2) "762" 3) "556" 4) "400" 5) "101"

现要求将列表内所有的键对应热度做加1操作,并且保证是原子执行,此功能可以利用Lua脚本来实现。

1)将列表中所有元素取出,赋值给mylist:

local mylist = redis.call("lrange", KEYS[1], 0, -1)

2)定义局部变量count=0,这个count就是最后incr的总次数:

local count = 0

3)遍历mylist中所有元素,每次做完count自增,最后返回

count: for index,key in ipairs(mylist) do redis.call("incr",key) count = count + 1 end return count

将上述脚本写入lrange_and_mincr.lua文件中,并执行如下操作,返回结果为5。

redis-cli --eval lrange_and_mincr.lua hot:user:list (integer) 5

执行后所有用户的热度自增1:

127.0.0.1:6379> mget user:1:ratio user:8:ratio user:3:ratio user:99:ratio user:72:ratio 1) "987" 2) "763" 3) "557" 4) "401" 5) "102"

本节给出的只是一个简单的例子,在实际开发中,开发人员可以发挥自己的想象力创造出更多新的命令。

3.4.5 Redis如何管理Lua脚本

Redis提供了4个命令实现对Lua脚本的管理,下面分别介绍。

(1)script load

script load script

此命令用于将Lua脚本加载到Redis内存中,前面已经介绍并使用过了,这里不再赘述。

(2)script exists

scripts exists sha1 [sha1 …]

此命令用于判断sha1是否已经加载到Redis内存中:

127.0.0.1:6379> script exists a5260dd66ce02462c5b5231c727b3f7772c0bcc5 1) (integer) 1

返回结果代表sha1[sha1…]被加载到Redis内存的个数。

(3)script flush

script flush

此命令用于清除Redis内存已经加载的所有Lua脚本,在执行script flush后,a5260dd66ce02462c5b5231c727b3f7772c0bcc5不再存在:

127.0.0.1:6379> script exists a5260dd66ce02462c5b5231c727b3f7772c0bcc5 1) (integer) 1 127.0.0.1:6379> script flush OK 127.0.0.1:6379> script exists a5260dd66ce02462c5b5231c727b3f7772c0bcc5 1) (integer) 0

(4)script kill

script kill

此命令用于杀掉正在执行的Lua脚本。

如果Lua脚本比较耗时,甚至Lua脚本存在问题,那么此时Lua脚本的执行会阻塞Redis,直到脚本执行完毕或者外部进行干预将其结束。

下面我们模拟一个Lua脚本阻塞的情况进行说明。

下面的代码会使Lua进入死循环:

while 1 == 1 do end

执行Lua脚本,当前客户端会阻塞:

127.0.0.1:6379> eval 'while 1==1 do end' 0

Redis提供了一个lua-time-limit参数,默认是5秒,它是Lua脚本的“超时时间”,但这个超时时间仅仅是当Lua脚本时间超过lua-time-limit后,向其他命令调用发送BUSY的信号,但是并不会停止掉服务端和客户端的脚本执行,

所以当达到lua-time-limit值之后,其他客户端在执行正常的命令时,将会收到“Busy Redis is busy running a script”错误,

并且提示使用script kill或者shutdown nosave命令来杀掉这个busy的脚本:

127.0.0.1:6379> get hello (error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

此时Redis已经阻塞,无法处理正常的调用,这时可以选择继续等待,

但更多时候需要快速将脚本杀掉。使用shutdown save显然不太合适,所以选择script kill,当script kill执行之后,客户端调用会恢复:

127.0.0.1:6379> script kill OK 127.0.0.1:6379> get hello "world"

但是有一点需要注意,如果当前Lua脚本正在执行写操作,那么script kill将不会生效。例如,我们模拟一个不停的写操作:

while 1==1 do redis.call("set","k","v") end

此时如果执行script kill,会收到如下异常信息:

(error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.

上面提示Lua脚本正在向Redis执行写命令,要么等待脚本执行结束要么使用shutdown save停掉Redis服务。

可见Lua脚本虽然好用,但是使用不当破坏性也是难以想象的。

3.5 Bitmaps

3.5.1 数据结构模型

现代计算机用二进制(位)作为信息的基础单位,1个字节等于8位,

例如“big”字符串是由3个字节组成,但实际在计算机存储时将其用二进制表示,“big”分别对应的ASCII码分别是98、105、103,对应的二进制分别是01100010、01101001和01100111,如图3-9所示。

image.png

许多开发语言都提供了操作位的功能,合理地使用位能够有效地提高内存使用率和开发效率。

Redis提供了Bitmaps这个“数据结构”可以实现对位的操作。把数据结构加上引号主要因为:

image.png

  • Bitmaps本身不是一种数据结构,实际上它就是字符串(如图3-10所示),但是它可以对字符串的位进行操作。
  • Bitmaps单独提供了一套命令,所以在Redis中使用Bitmaps和使用字符串的方法不太相同。

可以把Bitmaps想象成一个以位为单位的数组,数组的每个单元只能存储0和1,数组的下标在Bitmaps中叫做偏移量。

3.5.2 命令

本节将每个独立用户是否访问过网站存放在Bitmaps中,将访问的用户记做1,没有访问的用户记做0,用偏移量作为用户的id。

1.设置值

setbit key offset value

设置键的第offset个位的值(从0算起),假设现在有20个用户, userid=0,5,11,15,19的用户对网站进行了访问,那么当前Bitmaps初始化结果如图3-11所示。

image.png

具体操作过程如下,unique:users:2016-04-05代表2016-04-05这天的 独立访问用户的Bitmaps:

127.0.0.1:6379> setbit unique:users:2016-04-05 0 1 (integer) 0 127.0.0.1:6379> setbit unique:users:2016-04-05 5 1 (integer) 0 127.0.0.1:6379> setbit unique:users:2016-04-05 11 1 (integer) 0 127.0.0.1:6379> setbit unique:users:2016-04-05 15 1 (integer) 0 127.0.0.1:6379> setbit unique:users:2016-04-05 19 1 (integer) 0

如果此时有一个userid=50的用户访问了网站,那么Bitmaps的结构变成了图3-12,第20位~49位都是0。

image.png

很多应用的用户id以一个指定数字(例如10000)开头,直接将用户id和Bitmaps的偏移量对应势必会造成一定的浪费,

通常的做法是每次做setbit操作时将用户id减去这个指定数字。

在第一次初始化Bitmaps时,假如偏移量非常大,那么整个初始化过程执行会比较慢,可能会造成Redis的阻塞。

2.获取值

gitbit key offset

获取键的第offset位的值(从0开始算),下面操作获取id=8的用户是否在2016-04-05这天访问过,返回0说明没有访问过:

127.0.0.1:6379> getbit unique:users:2016-04-05 8 (integer) 0

由于offset=1000000根本就不存在,所以返回结果也是0: 127.0.0.1:6379> getbit unique:users:2016-04-05 1000000 (integer) 0

3.获取Bitmaps指定范围值为1的个数

bitcount [start][end]

下面操作计算2016-04-05这天的独立访问用户数量:

127.0.0.1:6379> bitcount unique:users:2016-04-05 (integer) 5

[start]和[end]代表起始和结束字节数,下面操作计算用户id在第1个字节到第3个字节之间的独立访问用户数,对应的用户id是11,15,19。

127.0.0.1:6379> bitcount unique:users:2016-04-05 1 3 (integer) 3

4.Bitmaps间的运算

bitop op destkey key[key....]

bitop是一个复合操作,它可以做多个Bitmaps的and(交集)、or(并集)、not(非)、xor(异或)操作并将结果保存在destkey中。

假设2016-04-04访问网站的userid=1,2,5,9,如图3-13所示。

image.png

下面操作计算出2016-04-04和2016-04-03两天都访问过网站的用户数量,如图3-14所示。

127.0.0.1:6379> bitop and unique:users:and:2016-04-04_03 unique: users:2016-04-03 unique:users:2016-04-03 (integer) 2 127.0.0.1:6379> bitcount unique:users:and:2016-04-04_03 (integer) 2

如果想算出2016-04-04和2016-04-03任意一天都访问过网站的用户数量 (例如月活跃就是类似这种),可以使用or求并集,具体命令如下:

127.0.0.1:6379> bitop or unique:users:or:2016-04-04_03 unique: users:2016-04-03 unique:users:2016-04-03 (integer) 2 127.0.0.1:6379> bitcount unique:users:or:2016-04-04_03 (integer) 6

image.png

5.计算Bitmaps中第一个值为targetBit的偏移量

bitpos key targetBit [start] [end]

下面操作计算2016-04-04当前访问网站的最小用户id:

127.0.0.1:6379> bitpos unique:users:2016-04-04 1 (integer) 1

除此之外,bitops有两个选项[start]和[end],分别代表起始字节和结束字节,例如计算第0个字节到第1个字节之间,第一个值为0的偏移量,从图3-13可以得知结果是id=0的用户。

127.0.0.1:6379> bitpos unique:users:2016-04-04 0 0 1 (integer) 0

3.5.3 Bitmaps分析

假设网站有1亿用户,每天独立访问的用户有5千万,如果每天用集合类型和Bitmaps分别存储活跃用户可以得到表3-3。

image.png

很明显,这种情况下使用Bitmaps能节省很多的内存空间,尤其是随着时间推移节省的内存还是非常可观的,见表3-4。

image.png

但Bitmaps并不是万金油,假如该网站每天的独立访问用户很少,例如只有10万(大量的僵尸用户),

那么两者的对比如表3-5所示,很显然,这时候使用Bitmaps就不太合适了,因为基本上大部分位都是0。

image.png

为什么bitmap,但Bitmaps并不是万金油,假如该网站每天的独立访问用户很少,例如只有10万(大量的僵尸用户)

虽然位图(bitmap)是一种有效的数据结构,但它并不是适合所有场景的解决方案。对于每天只有10万独立访问用户的网站,位图可能不是最佳选择,原因如下:

  1. 内存消耗:位图需要消耗大量的内存来表示每个用户的访问情况。对于大量的僵尸用户,如果每个用户都有一个位来表示其访问情况,那么会浪费大量的内存空间。

  2. 稀疏性:如果大部分用户都是僵尸用户,只有少数用户实际访问网站,那么位图会非常稀疏,大部分位都是零。在这种情况下,使用位图会浪费大量的空间。

  3. 查询效率:虽然位图可以快速地进行与、或、非等位运算,但对于稀疏的位图来说,查询效率可能不如其他数据结构,因为需要跳过大量的零位。

对于这种情况,可能更适合使用其他数据结构或算法来记录和处理用户访问情况,例如使用布隆过滤器来快速判断一个用户是否是僵尸用户,或者使用压缩算法来减少稀疏位图的存储空间。同时,还可以考虑使用数据库或分布式存储系统来存储用户访问日志,并针对实际需求设计合适的查询和分析方法。

3.6 HyperLogLog

HyperLogLog并不是一种新的数据结构(实际类型为字符串类型),而是一种基数算法,通过HyperLogLog可以利用极小的内存空间完成独立总数的统计,数据集可以是IP、Email、ID等。

HyperLogLog提供了3个命令:pfadd、pfcount、pfmerge。

例如2016-03-06的访问用户是uuid-1、uuid-2、uuid-3、uuid-4,

2016-03-05的访问用户是uuid-4、uuid-5、uuid-6、uuid-7,如 图3-15所示。

image.png

HyperLogLog的算法是由Philippe Flajolet(https://en.wikipedia.org/wiki/Philippe_Flajolet)在The analysis of a near-optimal cardinality estimation algorithm这篇论文中提出,读者如果有兴趣 可以自行阅读。

1.添加

pfadd key element [element …]

pfadd用于向HyperLogLog添加元素,如果添加成功返回1:

127.0.0.1:6379> pfadd 2016_03_06:unique:ids "uuid-1" "uuid-2" "uuid-3" "uuid-4" (integer) 1

2.计算独立用户数

pfcount key [key …]

pfcount用于计算一个或多个HyperLogLog的独立总数,例如 2016_03_06:unique:ids的独立总数为4:

127.0.0.1:6379> pfcount 2016_03_06:unique:ids (integer) 4

如果此时向2016_03_06:unique:ids插入uuid-1、uuid-2、uuid-3、uuid-90,结果是5(新增uuid-90):

127.0.0.1:6379> pfadd 2016_03_06:unique:ids "uuid-1" "uuid-2" "uuid-3" "uuid-90" (integer) 1 127.0.0.1:6379> pfcount 2016_03_06:unique:ids (integer) 5

当前这个例子内存节省的效果还不是很明显,下面使用脚本向 HyperLogLog插入100万个id,插入前记录一下info memory:

127.0.0.1:6379> info memory # Memory used_memory:835144 used_memory_human:815.57K ...向2016_05_01:unique:ids插入100万个用户,每次插入1000条: elements="" key="2016_05_01:unique:ids" for i in `seq 1 1000000` 227 do elements="${elements} uuid-"${i} if [[ $((i%1000)) == 0 ]]; then redis-cli pfadd ${key} ${elements} elements="" fi done

当上述代码执行完成后,可以看到内存只增加了15K左右:

127.0.0.1:6379> info memory # Memory used_memory:850616 used_memory_human:830.68K

但是,同时可以看到pfcount的执行结果并不是100万:

127.0.0.1:6379> pfcount 2016_05_01:unique:ids (integer) 1009838

可以对100万个uuid使用集合类型进行测试,代码如下:

elements="" key="2016_05_01:unique:ids:set" for i in `seq 1 1000000` do elements="${elements} "${i} if [[ $((i%1000)) == 0 ]]; then redis-cli sadd ${key} ${elements} elements="" fi done

可以看到内存使用了84MB:

127.0.0.1:6379> info memory # Memory used_memory:88702680 used_memory_human:84.59M

但独立用户数为100万:

127.0.0.1:6379> scard 2016_05_01:unique:ids:set (integer) 1000000

表3-6列出了使用集合类型和HperLogLog统计百万级用户的占用空间对比。

image.png

可以看到,HyperLogLog内存占用量小得惊人,但是用如此小空间来估算如此巨大的数据,必然不是100%的正确,其中一定存在误差率。

Redis官方给出的数字是0.81%的失误率。

3.合并

pfmerge destkey sourcekey [sourcekey ...]

pfmerge可以求出多个HyperLogLog的并集并赋值给destkey,例如要计算2016年3月5日和3月6日的访问独立用户数,可以按照如下方式来执行,可以看到最终独立用户数是7:

127.0.0.1:6379> pfadd 2016_03_06:unique:ids "uuid-1" "uuid-2" "uuid-3" "uuid-4" (integer) 1 127.0.0.1:6379> pfadd 2016_03_05:unique:ids "uuid-4" "uuid-5" "uuid-6" "uuid-7" (integer) 1 127.0.0.1:6379> pfmerge 2016_03_05_06:unique:ids 2016_03_05:unique:ids 2016_03_06:unique:ids OK 127.0.0.1:6379> pfcount 2016_03_05_06:unique:ids (integer) 7

HyperLogLog内存占用量非常小,但是存在错误率,开发者在进行数据结构选型时只需要确认如下两条即可:

  • 只为了计算独立总数,不需要获取单条数据。
  • 可以容忍一定误差率,毕竟HyperLogLog在内存的占用量上有很大的优势。

3.7 发布订阅

Redis提供了基于“发布/订阅”模式的消息机制,此种模式下,消息发布者和订阅者不进行直接通信,发布者客户端向指定的频道(channel)发布消息,

订阅该频道的每个客户端都可以收到该消息,如图3-16所示。Redis提供了若干命令支持该功能,在实际应用开发时,能够为此类问题提供实现方法。

image.png

3.7.1 命令

Redis主要提供了发布消息、订阅频道、取消订阅以及按照模式订阅和取消订阅等命令。

1.发布消息

publish channel message

下面操作会向channel:sports频道发布一条消息“Tim won the championship”,返回结果为订阅者个数,因为此时没有订阅,所以返回结果为0: 127.0.0.1:6379> publish channel

"Tim won the championship" (integer) 0

2.订阅消息

subscribe channel [channel ...]

订阅者可以订阅一个或多个频道,下面操作为当前客户端订阅了 channel:sports频道:

127.0.0.1:6379> subscribe channel:sports Reading messages... (press Ctrl-C to quit) 1) "subscribe" 2) "channel:sports" 3) (integer) 1

此时另一个客户端发布一条消息:

127.0.0.1:6379> publish channel:sports "James lost the championship" (integer) 1

当前订阅者客户端会收到如下消息:

127.0.0.1:6379> subscribe channel:sports Reading messages... (press Ctrl-C to quit) ... 1) "message" 2) "channel:sports" 3) "James lost the championship"

如果有多个客户端同时订阅了channel:sports,整个过程如图3-17所示。 有关订阅命令有两点需要注意:

  • 客户端在执行订阅命令之后进入了订阅状态,只能接收subscribe、psubscribe、unsubscribe、punsubscribe的四个命令。
  • 新开启的订阅客户端,无法收到该频道之前的消息,因为Redis不会对发布的消息进行持久化。

image.png

3.取消订阅

unsubscribe [channel [channel ...]]

客户端可以通过unsubscribe命令取消对指定频道的订阅,取消成功后,不会再收到该频道的发布消息:

127.0.0.1:6379> unsubscribe channel:sports 1) "unsubscribe" 2) "channel:sports" 3) (integer) 0

4.按照模式订阅和取消订阅

psubscribe pattern [pattern...] punsubscribe [pattern [pattern ...]]

除了subcribe和unsubscribe命令,Redis命令还支持glob风格的订阅命令psubscribe和取消订阅命令punsubscribe,例如下面操作订阅以it开头的所有频道:

127.0.0.1:6379> psubscribe it* Reading messages... (press Ctrl-C to quit) 1) "psubscribe" 2) "it*" 3) (integer) 1

5.查询订阅

(1)查看活跃的频道

pubsub channels [pattern]

所谓活跃的频道是指当前频道至少有一个订阅者,其中[pattern]是可以指定具体的模式:

127.0.0.1:6379> pubsub channels 1) "channel:sports" 2) "channel:it" 3) "channel:travel" 127.0.0.1:6379> pubsub channels channel:*r* 1) "channel:sports" 2) "channel:travel"

(2)查看频道订阅数

pubsub numsub [channel ...]

当前channel:sports频道的订阅数为2:

127.0.0.1:6379> pubsub numsub channel:sports 1) "channel:sports" 2) (integer) 2

(3)查看模式订阅数

pubsub numpat

当前只有一个客户端通过模式来订阅:

127.0.0.1:6379> pubsub numpat (integer) 1

3.7.2 使用场景

聊天室、公告牌、服务之间利用消息解耦都可以使用发布订阅模式,下面以简单的服务解耦进行说明。如图3-18所示,图中有两套业务,上面为视频管理系统,负责管理视频信息;下面为视频服务面向客户,用户可以通过各种客户端(手机、浏览器、接口)获取到视频信息。

image.png

假如视频管理员在视频管理系统中对视频信息进行了变更,希望及时通知给视频服务端,就可以采用发布订阅的模式,发布视频信息变化的消息到指定频道,视频服务订阅这个频道及时更新视频信息,通过这种方式可以有效解决两个业务的耦合性。

  • 视频服务订阅video:changes频道如下:
    subscribe video:changes
  • 视频管理系统发布消息到video:changes频道如下:
    publish video:changes "video1,video3,video5"
  • 当视频服务收到消息,对视频信息进行更新,如下所示:
    for video in video1,video3,video5 update {video}

3.8 GEO

Redis3.2版本提供了GEO(地理信息定位)功能,支持存储地理位置信息用来实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能,对于需要实现这些功能的开发者来说是一大福音。

GEO功能是Redis的另一位作者 Matt Stancliff [1]借鉴NoSQL数据库Ardb [2]实现的,Ardb的作者来自中国,它提供了优秀的GEO功能。

1.增加地理位置信息

geoadd key longitude latitude member [longitude latitude member ...]

longitude、latitude、member分别是该地理位置的经度、纬度、成员,表3-7展示5个城市的经纬度。

image.png cities:locations是上面5个城市地理位置信息的集合,现向其添加北京的地理位置信息:

127.0.0.1:6379> geoadd cities:locations 116.28 39.55 beijing (integer) 1

返回结果代表添加成功的个数,如果cities:locations没有包含beijing,

那么返回结果为1,如果已经存在则返回0:

127.0.0.1:6379> geoadd cities:locations 116.28 39.55 beijing (integer) 0

如果需要更新地理位置信息,仍然可以使用geoadd命令,虽然返回结果为0。

geoadd命令可以同时添加多个地理位置信息:

127.0.0.1:6379> geoadd cities:locations 117.12 39.08 tianjin 114.29 38.02 shijiazhuang 118.01 39.38 tangshan 115.29 38.51 baoding (integer) 4

2.获取地理位置信息

geopos key member [member ...]

下面操作会获取天津的经维度:

127.0.0.1:6379> geopos cities:locations tianjin 1) 1) "117.12000042200088501" 2) "39.0800000535766543"

3.获取两个地理位置的距离。

geodist key member1 member2 [unit]

其中unit代表返回结果的单位,包含以下四种:

  • m(meters)代表米。
  • km(kilometers)代表公里。
  • mi(miles)代表英里。
  • ft(feet)代表尺。 下面操作用于计算天津到北京的距离,并以公里为单位:
127.0.0.1:6379> geodist cities:locations tianjin beijing km "89.2061"

4.获取指定位置范围内的地理信息位置集合

georadius key longitude latitude radiusm|km|ft|mi [withcoord] [withdist] [withhash] [COUNT count] [asc|desc] [store key] [storedist key] georadiusbymember key member radiusm|km|ft|mi [withcoord] [withdist] [withhash] [COUNT count] [asc|desc] [store key] [storedist key]

georadius和georadiusbymember两个命令的作用是一样的,都是以一个地理位置为中心算出指定半径内的其他地理信息位置,

不同的是georadius命令的中心位置给出了具体的经纬度,georadiusbymember只需给出成员即可。

其中radiusm|km|ft|mi是必需参数,指定了半径(带单位),这两个命令有很多可选参数,如下所示:

  • withcoord:返回结果中包含经纬度。
  • withdist:返回结果中包含离中心节点位置的距离。
  • withhash:返回结果中包含geohash,有关geohash后面介绍。
  • COUNT count:指定返回结果的数量。
  • asc|desc:返回结果按照离中心节点的距离做升序或者降序。
  • store key:将返回结果的地理位置信息保存到指定键。
  • storedist key:将返回结果离中心节点的距离保存到指定键。 下面操作计算五座城市中,距离北京150公里以内的城市:
127.0.0.1:6379> georadiusbymember cities:locations beijing 150 km 1) "beijing" 2) "tianjin" 3) "tangshan" 4) "baoding"

5.获取geohash

geohash key member [member ...]

Redis使用geohash[3]将二维经纬度转换为一维字符串,下面操作会返回beijing的geohash值。

127.0.0.1:6379> geohash cities:locations beijing 1) "wx4ww02w070"

geohash有如下特点:

  • GEO的数据类型为zset,Redis将所有地理位置信息的geohash存放在zset中。
127.0.0.1:6379> type cities:locations zset
  • 字符串越长,表示的位置更精确,表3-8给出了字符串长度对应的精度,例如geohash长度为9时,精度在2米左右。

image.png

  • 两个字符串越相似,它们之间的距离越近,Redis利用字符串前缀匹配算法实现相关的命令。
  • geohash编码和经纬度是可以相互转换的。Redis正是使用有序集合并结合geohash的特性实现了GEO的若干命令。

6.删除地理位置信息

zrem key member

GEO没有提供删除成员的命令,但是因为GEO的底层实现是zset,所以可以借用zrem命令实现对地理位置信息的删除。

[1] https://matt.sh/ [2] https://github.com/yinqiwen/ardb 243 [3] https://en.wikipedia.org/wiki/Geohash

本文作者:Eric

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!