Redis客户端执行一条命令分为如下四个过程:
其中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。
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-1给出了在不同网络环境下非Pipeline和Pipeline执行10000次set操作的效果,可以得到如下两个结论:
Pipeline虽然好用,但是每次Pipeline组装的命令个数不能没有节制,否则一次组装Pipeline数据量过大,
一方面会增加客户端的等待时间,另一方面会造成一定的网络阻塞,可以将一次包含大量命令的Pipeline拆分成多次较小的Pipeline来完成。
Pipeline只能操作一个Redis实例,但是即使在分布式Redis场景中,也可以作为批量操作的重要优化手段,
熟悉关系型数据库的读者应该对事务比较了解,简单地说,事务表示一 组动作,要么全部执行,要么全部不执行。例如在社交网站上用户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的处理机制也不尽相同。
例如下面操作错将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"
例如用户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没有被其他客户端修 改过,才执行事务,否则不执行(类似乐观锁)。
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"
Redis将Lua作为脚本语言可帮助开发 者定制自己的Redis命令,在这之前,必须修改源码。在介绍如何在Redis中 使用Lua脚本之前,有必要对Lua语言的使用做一个基本的介绍。
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
在Lua中,函数以function开头,以end结尾,funcName是函数名,中间部分是函数体:
luafunction funcName()
...
end
contact函数将两个字符串拼接:
function contact(str1, str2)
return str1 .. str2
end
--"hello world"
print(contact("hello ", "world"))
注意 本书只是介绍了Lua部分功能,因为Lua的全部功能已经超出本书的范 围,读者可以购买相应的书籍或者到Lua的官方网站(http://www.lua.org/) 进行学习。
在Redis中执行Lua脚本有两种方法:eval和evalsha。
(1)eval
eval 脚本内容 key个数 key列表 参数列表 下面例子使用了key列表和参数列表来为Lua脚本提供更多的灵活性:
redis127.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所示。
(2)evalsha
除了使用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"
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。
Lua脚本功能为Redis开发和运维人员带来如下三个好处:
下面以一个例子说明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"
本节给出的只是一个简单的例子,在实际开发中,开发人员可以发挥自己的想象力创造出更多新的命令。
Redis提供了4个命令实现对Lua脚本的管理,下面分别介绍。
script load script
此命令用于将Lua脚本加载到Redis内存中,前面已经介绍并使用过了,这里不再赘述。
scripts exists sha1 [sha1 …]
此命令用于判断sha1是否已经加载到Redis内存中:
127.0.0.1:6379> script exists a5260dd66ce02462c5b5231c727b3f7772c0bcc5 1) (integer) 1
返回结果代表sha1[sha1…]被加载到Redis内存的个数。
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
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脚本虽然好用,但是使用不当破坏性也是难以想象的。
现代计算机用二进制(位)作为信息的基础单位,1个字节等于8位,
例如“big”字符串是由3个字节组成,但实际在计算机存储时将其用二进制表示,“big”分别对应的ASCII码分别是98、105、103,对应的二进制分别是01100010、01101001和01100111,如图3-9所示。
许多开发语言都提供了操作位的功能,合理地使用位能够有效地提高内存使用率和开发效率。
Redis提供了Bitmaps这个“数据结构”可以实现对位的操作。把数据结构加上引号主要因为:
可以把Bitmaps想象成一个以位为单位的数组,数组的每个单元只能存储0和1,数组的下标在Bitmaps中叫做偏移量。
本节将每个独立用户是否访问过网站存放在Bitmaps中,将访问的用户记做1,没有访问的用户记做0,用偏移量作为用户的id。
setbit key offset value
设置键的第offset个位的值(从0算起),假设现在有20个用户, userid=0,5,11,15,19的用户对网站进行了访问,那么当前Bitmaps初始化结果如图3-11所示。
具体操作过程如下,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。
很多应用的用户id以一个指定数字(例如10000)开头,直接将用户id和Bitmaps的偏移量对应势必会造成一定的浪费,
通常的做法是每次做setbit操作时将用户id减去这个指定数字。
在第一次初始化Bitmaps时,假如偏移量非常大,那么整个初始化过程执行会比较慢,可能会造成Redis的阻塞。
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
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
bitop op destkey key[key....]
bitop是一个复合操作,它可以做多个Bitmaps的and(交集)、or(并集)、not(非)、xor(异或)操作并将结果保存在destkey中。
假设2016-04-04访问网站的userid=1,2,5,9,如图3-13所示。
下面操作计算出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
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
假设网站有1亿用户,每天独立访问的用户有5千万,如果每天用集合类型和Bitmaps分别存储活跃用户可以得到表3-3。
很明显,这种情况下使用Bitmaps能节省很多的内存空间,尤其是随着时间推移节省的内存还是非常可观的,见表3-4。
但Bitmaps并不是万金油,假如该网站每天的独立访问用户很少,例如只有10万(大量的僵尸用户),
那么两者的对比如表3-5所示,很显然,这时候使用Bitmaps就不太合适了,因为基本上大部分位都是0。
为什么bitmap,但Bitmaps并不是万金油,假如该网站每天的独立访问用户很少,例如只有10万(大量的僵尸用户)
虽然位图(bitmap)是一种有效的数据结构,但它并不是适合所有场景的解决方案。对于每天只有10万独立访问用户的网站,位图可能不是最佳选择,原因如下:
内存消耗:位图需要消耗大量的内存来表示每个用户的访问情况。对于大量的僵尸用户,如果每个用户都有一个位来表示其访问情况,那么会浪费大量的内存空间。
稀疏性:如果大部分用户都是僵尸用户,只有少数用户实际访问网站,那么位图会非常稀疏,大部分位都是零。在这种情况下,使用位图会浪费大量的空间。
查询效率:虽然位图可以快速地进行与、或、非等位运算,但对于稀疏的位图来说,查询效率可能不如其他数据结构,因为需要跳过大量的零位。
对于这种情况,可能更适合使用其他数据结构或算法来记录和处理用户访问情况,例如使用布隆过滤器来快速判断一个用户是否是僵尸用户,或者使用压缩算法来减少稀疏位图的存储空间。同时,还可以考虑使用数据库或分布式存储系统来存储用户访问日志,并针对实际需求设计合适的查询和分析方法。
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所示。
注
HyperLogLog的算法是由Philippe Flajolet(https://en.wikipedia.org/wiki/Philippe_Flajolet)在The analysis of a near-optimal cardinality estimation algorithm这篇论文中提出,读者如果有兴趣 可以自行阅读。
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
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统计百万级用户的占用空间对比。
可以看到,HyperLogLog内存占用量小得惊人,但是用如此小空间来估算如此巨大的数据,必然不是100%的正确,其中一定存在误差率。
Redis官方给出的数字是0.81%的失误率。
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内存占用量非常小,但是存在错误率,开发者在进行数据结构选型时只需要确认如下两条即可:
Redis提供了基于“发布/订阅”模式的消息机制,此种模式下,消息发布者和订阅者不进行直接通信,发布者客户端向指定的频道(channel)发布消息,
订阅该频道的每个客户端都可以收到该消息,如图3-16所示。Redis提供了若干命令支持该功能,在实际应用开发时,能够为此类问题提供实现方法。
Redis主要提供了发布消息、订阅频道、取消订阅以及按照模式订阅和取消订阅等命令。
publish channel message
下面操作会向channel:sports频道发布一条消息“Tim won the championship”,返回结果为订阅者个数,因为此时没有订阅,所以返回结果为0: 127.0.0.1:6379> publish channel
"Tim won the championship" (integer) 0subscribe 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所示。 有关订阅命令有两点需要注意:
unsubscribe [channel [channel ...]]
客户端可以通过unsubscribe命令取消对指定频道的订阅,取消成功后,不会再收到该频道的发布消息:
127.0.0.1:6379> unsubscribe channel:sports 1) "unsubscribe" 2) "channel:sports" 3) (integer) 0
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
(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-18所示,图中有两套业务,上面为视频管理系统,负责管理视频信息;下面为视频服务面向客户,用户可以通过各种客户端(手机、浏览器、接口)获取到视频信息。
假如视频管理员在视频管理系统中对视频信息进行了变更,希望及时通知给视频服务端,就可以采用发布订阅的模式,发布视频信息变化的消息到指定频道,视频服务订阅这个频道及时更新视频信息,通过这种方式可以有效解决两个业务的耦合性。
subscribe video:changes
publish video:changes "video1,video3,video5"
for video in video1,video3,video5 update {video}
Redis3.2版本提供了GEO(地理信息定位)功能,支持存储地理位置信息用来实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能,对于需要实现这些功能的开发者来说是一大福音。
GEO功能是Redis的另一位作者 Matt Stancliff [1]借鉴NoSQL数据库Ardb [2]实现的,Ardb的作者来自中国,它提供了优秀的GEO功能。
geoadd key longitude latitude member [longitude latitude member ...]
longitude、latitude、member分别是该地理位置的经度、纬度、成员,表3-7展示5个城市的经纬度。
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
geopos key member [member ...]
下面操作会获取天津的经维度:
127.0.0.1:6379> geopos cities:locations tianjin 1) 1) "117.12000042200088501" 2) "39.0800000535766543"
geodist key member1 member2 [unit]
其中unit代表返回结果的单位,包含以下四种:
127.0.0.1:6379> geodist cities:locations tianjin beijing km "89.2061"
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是必需参数,指定了半径(带单位),这两个命令有很多可选参数,如下所示:
127.0.0.1:6379> georadiusbymember cities:locations beijing 150 km 1) "beijing" 2) "tianjin" 3) "tangshan" 4) "baoding"
geohash key member [member ...]
Redis使用geohash[3]将二维经纬度转换为一维字符串,下面操作会返回beijing的geohash值。
127.0.0.1:6379> geohash cities:locations beijing 1) "wx4ww02w070"
geohash有如下特点:
127.0.0.1:6379> type cities:locations zset
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 许可协议。转载请注明出处!