《Redis开发与运维》读书笔记(三)功能

第三章 功能

本章内容:

  • 慢查询分析:通过慢查询分析,找到问题并进行优化。
  • Redis Shell:功能强大。
  • Pipeline:通过管道/流水线机制有效提高客户端性能。
  • 事务与Lua:制作自己的专属原子命令。
  • Bitmaps:通过在字符串数据结构上使用位操作,有效节省内存,为开发提供新思路。
  • HyperLogLog:一种基于概率的新算法,难以想象的节省内存空间。
  • 发布订阅:基于发布订阅模式的消息通信机制。
  • GEO:Redis 3.2版本时提供了基于地理位置信息的功能。

3.1 慢查询分析

慢查询日志就是系统在命令执行前后计算每条命令的执行时间,当超过预设阈值时就将此命令的相关信息(如发生时间、耗时、命令详细信息等)记录下来。

如下图,Redis客户端执行一条命令分为四个部分:(慢查询只会统计步骤3的时间,所以可能会忽略其他步骤导致的超时问题)

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

3.1.1 慢查询的两个配置参数

慢查询需要搞清楚两点:

  1. 预设阈值怎么设置?
  2. 慢查询记录存放位置?

Redis提供了 slowlog-log-slower-thanslowlog-max-len 这两个配置来解决这两个问题。

(1)预设阈值怎么设置?

slowlog-log-slower-than 即预设阈值,单位为微秒,默认值为10 000(即10毫秒),假设执行了一条很慢的命令(如key *),当其执行时间超过了10毫秒,就会被记录在慢查询日志中。

(2)慢查询记录存放位置?

slowlog-max-len 好像只是表示慢查询日志最多存储条数,实际上Redis使用了一个列表来存储慢查询日志,此配置参数即列表最大值。若当前列表已放满,此时新增的命令会挤出最早进入的命令(先进先出)。

Redis有两种修改配置的方法,一种是修改配置文件,另一种是使用 config set命令动态修改。

1
2
3
4
# 设置预设阈值为20 000微秒,列表最大值为1000
config set slowlog-log-slower-than 20000
config set slowlog-max-len 1000
config rewrite

如果要Redis将配置持久化到本地配置文件,需要执行 config rewrite 命令,如下图所示。

慢查询日志的键并未暴露,而是提供了一组命令来实现对日志的访问和管理。

(3)获取慢查询日志

1
slowlog get [n]

慢查询日志由4个属性组成,分别是标识id、发生时间戳、命令耗时、执行命令和参数,如下图所示。

(4)获取慢查询日志列表当前的长度

1
slowlog len

(5)慢查询日志重置

1
slowlog reset

3.1.2 最佳实践

使用慢查询时要注意几点:

  • slowlog-max-len 配置建议:线上建议调大慢查询列表,记录慢查询时Redis会对长命令做截断操作,并不会占用大量内存。增大慢查询列表可以减缓慢查询被剔除的可能,例如线上可设置为1000以上。
  • slowlog-log-slower-than 配置建议:默认值超过10毫秒判定为慢查询,需要根据Redis并发量调整该值。由于Redis采用单线程响应命令,对于高流量的场景,如果命令执行时间在1毫秒以上,那么Redis最多可以支撑OPS不到1000。因此对于高OPS场景的Redis建议设置为1毫秒。
  • 慢查询只记录命令执行时间,并不包括命令排队和网络传输时间。因此客户端执行命令的时间会大于命令实际执行时间。因为命令执行排队机制,慢查询会导致其他命令级联阻塞,因此当客户端出现请求超时,需要检查该时间点是否有对应的慢查询,从而分析出是否为慢查询导致的命令级阻塞。
  • 由于慢查询日志是一个先进先出的队列,也就是说如果慢查询比较多的情况下,可能会丢失部分慢查询命令,为了防止这种情况发生,可以定期执行 slow get 命令将慢查询日志持久化到其他存储中(例如MySql),然后可以制作可视化界面进行查询。

3.2 Redis Shell

3.2.1 redis-cli详解

1
$redis-cli -help

(1)-r

repeat选项代表将命令执行多次。

1
2
3
4
$redis-cli -r 3 ping
PONG
PONG
PONG

(2)-i

interval选项每隔几秒执行一次命令,但必须和repeat一起使用,单位为秒。

1
2
3
4
5
6
$redis-cli -r 5 -i 1 ping
PONG
PONG
PONG
PONG
PONG

(3)-x

此选项代表从标准输入(stdin)读取数据作为 redis-cli 的最后一个参数。

1
2
$echo "world" | redis-cli -x set hello
OK

(4)-c

cluster选项是连接 Redis Cluster 节点时需要使用的,此选项可以防止moved和ask异常。

(5)-a

auth选项为配置账号密码时使用,可以不用再手动输入 auth 命令。

(6)-scan 和 –pattern

这两个选项用于扫描指定模式的键,相当于使用 scan 命令。

(7)–slave

此选项是把当前客户端模拟成当前Redis节点的从节点,可以用来获取当前Redis节点的更新操作。合理的利用这个选项可以记录当前连接Redis节点的一些更新操作,这些更新操作很可能是实际开发业务时需要的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 开启第一个客户端,看到同步已完成
$redis-cli --slave
SYNC with master, discarding 72 bytes of bulk transfer...
SYNC done. Logging commands from master.
# 再开启另一个客户端做一些更新操作
$redis-cli
127.0.0.1:6379> set hello world
OK
127.0.0.1:6379> set a b
OK
127.0.0.1:6379> incr count
1
127.0.0.1:6379> get hello
"world"
# 第一个客户端会收到Redis节点的更新操作
$redis-cli --slave
SYNC with master, discarding 72 bytes of bulk transfer...
SYNC done. Logging commands from master.
"PING"
"PING"
"PING"
"PING"
"PING"
"SELECT","0"
"set","hello","world"
"set","a","b"
"PING"
"incr","count"

(8)–rdb

此选项请求Redis实例生成并发送RDB持久化文件,保存在本地。可以用来做持久化文件的定期备份。

(9)–pipe

此选项用于将命令封装成Redis通信协议定义的数据格式,批量发送给Redis执行。

1
2
# 同时执行两条命令: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*2\r\n$4\r\nincr\r\n$7\r\ncounter\r\n' | redis-cli --pipe

(10)–bigkeys

此选项使用scan命令对Redis的键进行采样,从中找到内存占用比较大的键值,这些键可能是系统的瓶颈。

(11)–eval

此选项用于执行指定Lua脚本。

(12)–latency

latency有三个选项:–latency、–latency-history、–latency-dist。都可以检测网络延迟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# --latency选项可以测试客户端到目标Redis的网络延迟,执行结果只有一条
# 当前网络拓扑结构如下图所示,机房A和B是跨地区的
# 客户端B
$redis-cli -h {machineB} --latency
min: 0, max: 1, avg 0.07 (4211 samples)
# 客户端A
$redis-cli -h {machineA} --latency
min: 0, max: 2, avg 1.04 (2096 samples)
# 客户端A由于距离远,网络延迟会高一些

# --latency-history的执行结果可以分时段,如下所示每15秒输出一次,可以通过-i参数控制间隔时间
$redis-cli -h 10.10.xx.xx --latency-history
min: 0, max: 1, avg: 0.28 (1330 samples) -- 15.01 seconds range
...
min: 0, max: 1, avg: 0.05 (1364 samples) -- 15.01 seconds range

# --latency-dist会使用统计图表输出延迟统计信息

(13)–stat

此选项可以实时获取Redis的重要统计信息,虽然info命令中的统计信息更安全,但实时的看到一些增量的数据还是很有帮助的。

1
2
3
4
5
6
7
$redis-cli --stat
------ data ------ ------------------ load ------------------ - child-
keys mem clients blocked requests connections
2451959 3.43G 1162 0 7426132839 (+0) 1337356
2451958 3.42G 1162 0 7426133645 (+806) 1337356
...
2452182 3.43G 1161 0 7426150275 (+1303) 1337356

(14)–raw 和 –no-raw

–no-raw此选项是要求命令的返回结果必须是原始的格式,–raw恰恰相反,返回格式化后的结果。

1
2
3
4
5
6
7
8
9
10
11
# 设置一个中文的value
$redis-cli set hello " 你好 "
OK
# 如果正常执行get或使用--no-raw选项,返回的结果是二进制格式
$redis-cli get hello
"\xe4\xbd\xa0\xe5\xa5\xbd"
$redis-cli --no-raw get hello
"\xe4\xbd\xa0\xe5\xa5\xbd"
# 使用--raw选项,会返回中文
$redis-cli --raw get hello
你好

3.2.2 redis-server详解

除了启动Redis外,redis-server还有一个选项 --test-memory 。可以用来检测当前操作系统能否稳定地分配指定容量的内存给Redis,通过这种检测可以有效避免因为内存问题造成Redis崩溃。

1
2
# 检测当前操作系统是否可以提供1G内存
$redis-server --test-memory 1024

内存检测的时间比较长,输出 passed this test 时说明内存检测完毕,最后会提示 --test-memory 只是简单检测,可以使用更加专业的内存检测工具。此功能比较偏向于调试和测试。

3.2.3 redis-benchmark详解

redis-benchmark作为基准性能测试,帮助开发和运维人员测试Redis的性能。

(1)-c

clients选项代表客户端的并发数量(默认50)。

(2)-n <requests>

num选项代表客户端请求总量(默认是100 000)。

1
2
3
4
5
6
7
8
9
10
11
# 100个客户端同时请求Redis一共执行20 000次。
$redis-benchmark -c 100 -n 20000
====== GET ======
20000 requests completed in 0.27 seconds
100 parallel clients
3 bytes payload
keep alive: 1
99.11% <= 1 milliseconds
100.00% <= 1 milliseconds
73529.41 requests per second
# 一共执行了20000次GET操作,在0.27秒完成,每个请求数据量是3个字节,99.11%的命令执行时间小于1毫秒,Redis每秒可以处理73529.41次GET请求。

(3)-q

此选项仅仅显示redis-benchmark的 requests per second 信息。

1
2
3
4
5
6
7
8
$redis-benchmark -c 100 -n 20000 -q
PING_INLINE: 74349.45 requests per second
PING_BULK: 68728.52 requests per second
SET: 71174.38 requests per second
...
LRANGE_500 (first 450 elements): 11299.44 requests per second
LRANGE_600 (first 600 elements): 9319.67 requests per second
MSET (10 KEYS): 70671.38 requests per second

(4)-r

在一个空的Redis上执行了redis-benchmark会发现只有三个键。

1
2
3
4
5
6
7
8
127.0.0.1:6379> dbsize
(integer) 3
127.0.0.1:6379> keys *
1) "counter:__rand_int__"
2) "mylist"
3) "key:__rand_int__"
# -r选项可以向Redis插入更多随机的键
$redis-benchmark -c 100 -n 20000 -r 10000

(5)-p

此选项表示每个请求pipeline的数据量(默认为1)。

(6)-k <boolean>

此选项代表客户端是否使用keepalive,1为使用,0为不使用,默认值为1。

(7)-t

此选项可以对指定命令进行基准测试。

1
2
3
4
$redis-benchmark - get,set -q
SET: 98619.32 requests per second
GET: 98619.32 requests per second

(8)-csv

此选项会将结果按照csv格式输出,便于导出Excel等。

1
2
3
$redis-benchmark -t get,set --csv
"SET","81300.81"
"GET","79051.38"

3.3 Pipeline

3.3.1 pipeline概念

Redis客户端执行一条命令过程如下:

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

1+4被称为 Round Trip Time(RTT,往返时间)。Redis虽然提供了批量操作指令(如mget、mset)来节约RTT,但大部分命令并不支持批量操作。Redis的客户端和服务端很有可能部署在不同的机器上,当一次往返时间过长时就会限制客户端命令执行效率,这不符合Redis的高并发高吞吐

Pipeline(流水线)机制可以改善上述情况,它能将一组Redis命令进行组装,通过一次RTT传输给Redis,再将这组Redis命令的执行结果按顺序返回给客户端

下图没有使用Pipeline,所以需要n次RTT。

如果使用Pipeline,则只需1次RTT。

Pipeline是常见的技术,RTT在不同的环境下会有所不同,而Redis命令的真实执行时间一般在微秒级别,所以有网络是Redis性能瓶颈的说法。

redis-cli--pipe 选项实际上就是使用Pipeline机制。

1
2
# 组装 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-ci --pipe

大部分语言Redis客户端都支持Pipeline。

3.3.2 性能测试

如图所示两种情况10000次set操作的效果。可以发现Pipeline执行速度相比逐条执行要快,且网络延时越大越明显。

3.3.3 原生批量命令和Pipeline对比

Pipeline与原生批量命令的区别:

  • 原生批量命令是原子的,Pipeline是非原子的。
  • 原生批量命令是一个命令对应多个key,Pipeline支持多个命令。
  • 原生批量命令是Redis服务端支持实现的,而Pipeline需要服务端和客户端共同实现。

3.3.4 最佳实践

Pipeline虽然好用,但每次组装的命令个数需要有节制,否则数据量过大,一方面会增加客户端的等待时间,另一方面造成一定的网络阻塞,可以拆分成多个Pipeline。

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


3.4 事务与Lua

Redis提供了简单的事务功能和Lua脚本来保证多条命令组合的原子性

3.4.1 事务

简单的说,事务表示一组动作,要么全执行,要么全不执行,避免出现数据不一致的情况。

Redis提供了简单的事务功能,将一组需要一起执行的命令放到 miltiexec 两个命令之间。分别表示事务开始和结束,之间的命令原子顺序执行。

1
2
3
4
5
6
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。

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

只有 exec 命令执行后,用户A关注用户B的行为才完成。

1
2
3
4
5
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 即可。

1
2
3
4
127.0.0.1:6379> discard
OK
127.0.0.1:6379> sismember user:a:follow user:b
(integer) 0

(1) 命令错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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 bacause of previous errors
127.0.0.1:6388> mget key counter
1) "hello"
2) "100"

(2) 运行时错误

1
2
3
4
5
6
7
8
9
10
11
12
# 用户B添加粉丝列表时,误把sadd命令写作zadd命令,语法是正确的
127.0.0.1:6379> multi
OK
127.0.0.1:6379> sadd user:a:follow user:b
QUEUED
127.0.0.1:6379> zadd user:b:fans 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 命令已执行成功,开发人员需要自行修复这类问题。

有些应用场景要在事务执行前,检查key是否被其他客户端修改,若已修改则不执行(类似乐观锁)。Redis提供了 watch 命令来解决此类问题。

客户端1在执行 multi 之前执行了 watch 命令。客户端2在客户端1执行 exec 前修改了key值,造成事务未执行(exec结果为nil)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#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
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用法简述

Lua语言诞生于1993年的巴西,设计目标是作为嵌入式程序移植到其他应用程序,由C语言实现。比较知名的应用,如魔兽世界、愤怒的小鸟,Nginx将Lua语言作为扩展,Redis将Lua作为脚本语言来帮助开发者定制Redis命令。

1. 数据类型及逻辑处理

(1) 字符串
1
2
3
4
-- 注释
local strings val = "world"
-- 打印world
print(hello)

local 表示局部变量,否则表示全局变量。print 打印变量值。

(2) 数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
-- tables表示数组,下标从1开始
local tables myArray = {"redis", "jedis", true, 88.0}
-- 打印true
print(myArray[3])
-- 可以用for或while遍历
local int sum = 0;
for i = 1, 100
do
sum = sum + 1
end
-- 打印5050
print(sum)
-- 遍历数组需要用#获取数组长度
for i = 1, #myArray
do
print(myArray[i])
end
-- 内置函数ipairs遍历所有索引下标和值
for index, value in ipairs(myArray)
do
print(index)
prinx(value)
end
-- while循环
local int sum = 0;
local int 1 = 0;
while i <= 100
do
sum = sum + 1
i = i + 1
end
-- if else
for i = 1, #myArray
do
if myArray[i] == "jedis"
then
print("true")
break
else
--do nothing
end
end
(3) 哈希
1
2
3
4
5
6
7
8
9
10
-- tables 同样可以提供类似哈希的功能
local tables user_1 = {age = 28, name = "tom"}
-- user_1 age is 28
print("user_1 age is" .. user_1["age"])
-- string1 .. string2 将两个字符串连接
-- 内置函数pairs可以用来遍历哈希
for key, value in pairs(user_1)
do
print(key .. value)
end

2. 函数定义

function 表示函数,end 表示结尾,funcName 表示函数名,中间是函数体。

1
2
3
4
5
6
7
function funcName()
...
end

function contact(str1, str2)
return str1 .. str2
end

3.4.3 Redis与Lua

Redis中执行Lua脚本的两种方法:evalevalsha

1. eval

1
eval {脚本内容} {key个数} {key列表} {参数列表}

如下所示,KEYS[1]=”reids”,ARGV[1]=”world”。若Lua脚本过长,可以直接使用 reids-cli--eval 执行文件。

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

eval 命令和 --eval 本质一样。客户端想要执行Lua脚本,先在客户端编写脚本代码,然后将脚本作为字符串发送至服务端,服务端执行完将结果返回给客户端。

2. evalsha

evalsha命令如下所示,首先将Lua脚本加载到服务端,得到脚本的SHA1校验和,evalsha命令使用SHA1作为参数可以直接执行对应的Lua脚本,避免每次发送Lua脚本的开销。这样客户端不需要每次执行脚本内容,脚本可以常驻在服务端,使脚本得到复用。

  • 加载脚本script load命令可以将基本内容加载到Redis内存中。

    1
    2
    $ redis-cli script load "${cat lua_get.lua}"
    "7413......"
  • 执行脚本:evalsha的使用方法如下,参数使用SHA1值,执行逻辑和eval一致。

    1
    2
    3
    4
    evalsha {脚本SHA1值} {key个数} {key列表} {参数列表}

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

3. call函数

Lua可以使用Redis的call函数实现对Redis的访问,当然还有pcall函数。如果redis.call执行失败,脚本执行结束时会直接返回错误;而redis.pcall会忽略错误继续执行脚本。

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

在Redis中效果如下。

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

3.4.4 案例

Lua脚本带来的好处:

  • Lua脚本在Redis中是原子执行的,执行过程中间不会插入命令。
  • Lua脚本可以帮助开发和运维人员制作自定义命令,并且可以存到Redis内存中进行复用。
  • Lua脚本可以将多条命令一次性打包,有效的减少了网络开销。

假设当前列表记录着5条热门用户的id

1
2
3
4
5
6
7
8
9
10
11
12
13
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,并保证是原子执行。

1
2
3
4
5
6
7
8
9
10
11
-- 首先将列表所有元素取出,赋值给mylist
local mylist = redis.call("lrange", KEYS[1], 0, -1)
-- 定义局部变量count=0,count即最后incr的总次数
local count = 0
-- 遍历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。

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

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

1
2
3
4
5
6
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提供了四个命令来管理Lua脚本。

(1) script load

1
script load script

此命令用于将Lua脚本加载到Redis内存中。

(2) script exists

1
script exists sha1 [sha1 ...]

此命令用于判断sha1是否已加载到Redis内存,返回结果代表被加载到内存的个数。

(3) script flush

1
script flush

此命令用于清除Redis内存已加载的所有Lua脚本。

(4) script kill

1
script kill

此命令用于杀掉正在执行的Lua脚本。如果Lua脚本比较耗时,或是脚本本身有问题,执行此脚本会导致阻塞Redis直到脚本执行完毕或被外部干预结束。

(5)案例

如下Lua脚本代码,死循环。

1
2
3
4
while 1 == 1
do

end

执行此脚本,导致当前客户端阻塞。

1
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 killshutdown nosave 来杀掉此脚本。

1
2
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 nosavescript kill 显然是更好的选择。

1
2
3
4
127.0.0.1:6379> script kill
OK
127.0.0.1:6379> get hello
"world"

要注意的是:如果当前脚本已执行过写操作,script kill 命令将不会生效。

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

这种情况下执行 script kill 会收到如下异常。

1
2
127.0.0.1:6379> 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.

很明显,此时只能等待或使用 shutdown nosave 停掉Redis服务。


3.5 Bitmaps

3.5.1 数据结构模型

现代计算机使用二进制作为信息的基础单位,1个字节等于8位,如big字符串由3个字节组成,在计算机存储时将其用二进制表示(如图),big分别对应ASCII码的98、105、103,对应二进制01100010、01101001、01100111。

许多开发语言都提供了操作的功能,合理的使用位能有效提高内存使用率和开发效率。Redis提供了Bitmaps这个”数据结构”来实现对位的操作。

加引号的原因:

  • Bitmaps本身不是一种数据结构,实际上就是字符串(如下图),但可以对字符串的位进行操作。
  • Bitmaps单独提供了一套命令,所以其使用方法和字符串不同。可以把Bitmaps当作一个以位为单位的数组,数组的每个单元只能存储0或1,数组的下标叫偏移量

3.5.2 命令

1. 设置值

1
2
# 设置键的第offset个位的值(从0开始)
setbit key offset value

假设现有20个用户,userid分别为0、5、11、15、19的用户对网站进行了访问,当前Bitmaps初始化如图。

操作过程如下。

1
2
3
4
5
6
7
8
9
10
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的用户访问了网站,此时结构如下。

很多应用的用户id会以指定数字开头(如1000XXXX),直接将用户id与Bitmaps的偏移量对应会导致空间的浪费,通常做法是每次 setbit 操作将用户id减去指定数字。第一次初始化Bitmaps时,假如偏移量很大,那么整个初始化过程会执行较慢,可能会导致阻塞。

2. 获取值

1
2
# 获取键的第offset个位的值(从0开始)
getbit key offset

获取id=8的用户是否在2016_04_05当天访问。

1
2
127.0.0.1:6379> getbit unique:users:2016_04_05 8
(integer) 0

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

1
2
# [start] [end]表示起始和结束字节数
bitcount [start] [end]

计算一下2016_04_05这天的访问用户数量。

1
2
127.0.0.1:6379> bitcount unique:users:2016_04_05
(integer) 5

计算用户id在第1字节到第3字节间的访问用户数量,对应11、15和19(注意是字节数)。

1
2
127.0.0.1:6379> bitcount unique:users:2016_04_05 1 3
(integer) 3

4. Bitmaps间的运算

1
bitop op destkey key [key ...]

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

假设2016_04_04这天访问网站的用户如图所示。

计算2016_04_04和2016-04-03两天访问网站的用户数量。

1
2
3
4
127.0.0.1:6379> bitop and unique:users:and:2016_04_04_03 unique:users:2016-04-03 unique:users:2016_04_04
(integer) 2
127.0.0.1:6379> bitcount unique:users:and:2016_04_04_03
(integer) 2

计算2016_04_04和2016-04-03任意一天都访问网站的用户数量(月活),可以求并集。

1
2
3
4
127.0.0.1:6379> bitop or unique:users:or:2016_04_04_03 unique:users:2016-04-03 unique:users:2016_04_04
(integer) 2
127.0.0.1:6379> bitcount unique:users:or:2016_04_04_03
(integer) 6

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

1
bitpos key targetBit [start] [end]

bitpos两个选项start和end,分别表示起始和结束字节

计算2016_04_04当天访问网站的最小用户id。

1
2
127.0.0.1:6379> bitpos unique:users:2016_04_04 1
(integer) 1

计算第0个字节到第1个字节间,第一个值为0的偏移量。

1
2
127.0.0.1:6379> bitpos unique:users:2016_04_04 0 0 1
(integer) 0

3.5.3 Bitmaps分析

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

很明显,使用Bitmaps可以有效的节省内存空间,并随着时间的推移越来越可观。

但Bitmaps非万金油,如果每天独立访问用户量很少(大部分用户是僵尸用户),这种情况下使用Bitmaps就不太合适了。


3.6 HyperLogLog

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

HyperLogLog提供了三个命令:pfadd、pfcount和pfmerge。如图2016-03-05和2016_03_06的访问用户。

3.6.1 添加

1
2
3
4
5
# pfadd用于向HyperLogLog添加元素,成功时返回1
pfadd key element [element ...]

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

3.6.2 计算独立用户数

1
2
3
4
5
6
7
8
9
10
# pfcount用于计算一个或多个HyperLogLog的独立总数
pfcount key [key ...]

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
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。

1
2
3
4
5
127.0.0.1:6379> info memory
# memory
used_memory:835144
used_memory_human:815.57K
...

通过Lua脚本向2016_03_06:unique:ids插入100万个用户,每次1000条。

1
2
3
4
5
6
7
8
9
10
11
elements = ""
key = "2016_03_06:unique:ids"
for 1 in `seq 1 1000000`
do
elements = "${elements} uuid-"${i}
if [[ $((i%1000)) == 0 ]];
then
redis-cli pfadd ${key} ${elements}
elements = ""
fi
done

脚本执行完后,可以看到内存只增加了15K左右。

1
2
3
4
5
127.0.0.1:6379> info memory
# memory
used_memory:850616
used_memory_human:830.68K
...

但也可以看到pfcount的执行结果并非100万。

1
2
127.0.0.1:6379> pfcount 2016_03_06:unique:ids
(integer) 1009838

可以使用集合类型测试存储100万个id。

1
2
3
4
5
6
7
8
9
10
11
elements = ""
key = "2016_03_06:unique:ids:set"
for 1 in `seq 1 1000000`
do
elements = "${elements} uuid-"${i}
if [[ $((i%1000)) == 0 ]];
then
redis-cli sadd ${key} ${elements}
elements = ""
fi
done

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

1
2
3
4
5
127.0.0.1:6379> info memory
# memory
used_memory:88702680
used_memory_human:84.59M
...

但独立用户刚好为100万。

1
2
127.0.0.1:6379> scard 2016_03_06:unique:ids:set
(integer) 1000000

二者百万级用户占用空间对比如下图。

3.6.3 合并

1
pfmerge destkey sourcekey [sourcekey ...]

pfmerge可以求出多个HyperLogLog的并集并赋值给destkey。

计算2016年3月5日到6日的访问独立用户数。

1
2
3
4
5
6
7
8
9

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.7.1 命令

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

1. 发布消息

1
2
3
4
publish channel message
# 发送一条消息,返回结果为订阅个数
127.0.0.1:6379> publish channel:sports "Tim won the championship"
(integer) 0

2. 订阅消息

1
2
3
4
5
6
7
subscribe channel [channel ...]
# 订阅者可以订阅一个或多个频道
127.0.0.1:6379> subscribe channel:sports
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "channel:sports"
3) "(integer) 1"

使另一个客户端发布一条消息。

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

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

1
2
3
4
5
6
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"

多个客户端同时订阅频道的过程如下图。

此命令需要注意:

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

3. 取消订阅

1
2
3
4
5
6
unsubscribe [channel [channel ...]]
# 客户端可以通过此命令取消指定频道的订阅
127.0.0.1:6379> unsubscribe channel:sports
1) "unsubscribe"
2) "channel:sports"
3) "(integer) 0"

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

1
2
3
4
5
6
7
8
psubscribe pattern [pattern ...]
punsubscribe [pattern [pattern ...]]
# 这两个使glob风格的订阅和取消订阅命令,如下订阅以it开头的所有频道
127.0.0.1:6379> psubscribe it*
Reading messages... (press Ctrl-C to quit)
1) "psubscribe"
2) "it*"
3) "(integer) 1"

5. 查询订阅

(1) 查看活跃的频道
1
pubsub channels [pattern]

活跃频道是指此频道至少有一个订阅者,pattern指定具体的模式。

1
2
3
4
5
6
7
8
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) 查看频道订阅数
1
2
3
4
5
pubsub numsub [channel ...]

127.0.0.1:6379> pubsub numsub channel:sports
1) "channel:sports"
2) "(integer) 2"
(3) 查看模式订阅数
1
2
3
4
pubsub numpat
# 当前只有一个客户端通过模式订阅
127.0.0.1:6379> pubsub numpat
(integer) 1

3.7.2 使用场景

聊天室、公告牌、服务之间利用消息解耦都可以使用发布订阅模式。一个简单的服务解耦如图所示,两套业务,上面是视频管理系统,负责管理视频信息;下面为视频服务面向客户端,用户可以通过各种客户端获取到视频信息。

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

  • 视频服务订阅 video:changes 频道

    1
    subscribe video:changes
  • 视频管理系统发布消息到 video:changes 频道

    1
    publish video:changes "video1,video3,video5"
  • 视频服务收到消息,对视频信息进行更新

    1
    2
    for video in video1,video3,video5
    update (video)

3.8 GEO

Redis 3.2版本提供了GEO(地理信息定位)功能,支持存储地理信息用来实现注入附件位置摇一摇等这类依赖于地理位置信息的功能。GEO由Redis的另一位作者Matt Stancliff借鉴NoSQL数据库Ardb实现。

3.8.1 增加地理位置信息

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

longitude、latitude、member分别标识地理位置的经度、纬度、成员。

假设citites:locations是上面5个城市的集合,现在添加北京的地理位置信息,返回成功个数,可以同时增加多个地理位置信息。

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

3.8.2 获取地理位置信息

1
2
3
4
5
geopos key member [member ...]
# 获取天津的经纬度
127.0.0.1:6379> geopos citites:locations tianjin
1) 1) "117.12000042200088501"
2) "39.0800000535766543"

3.8.3 获取两个地理位置的距离

1
geodist key member1 member2 [unit]

unit代表返回结果的单位:

  • m(meters)米
  • km(kilometers)公里
  • mi(miles)英里
  • ft(feet)尺

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

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

georadiusvymember key member radiusm|km|ft|mi [withcoord] [withdist] [withhash] [COUNT count] [asc|desc] [store key] [storedist key]

georadiusgeoradiusvymember 命令的作用相同,都是以一个地理位置为中心算出指定半径内的其他地理位置信息,不通的是 georadius 给出具体的经纬度,而 georadiusvymember 只需给出成员。 radiusm|km|ft|mi 是必须参数,指定了半径(含单位)。

可选参数:

  • withcoord:返回结果中包含经纬度。
  • withdist:返回结果中包含离中心点位置的距离。
  • withhash:返回结果中包含geohash。
  • COUNT count:指定返回结果的数量。
  • asc|desc:返回结果按照离中心点的距离做升序或降序。
  • store key:将返回结果的地理位置信息保存到指定键。
  • storedist key:将返回结果中心点的距离保存到指定键。
1
2
3
4
5
6
# 计算距离北京150公里内的城市
127.0.0.1:6379> georadiusbymember citites:locations beijing 150 km
1) "beijing"
2) "tianjin"
3) "tangshan"
4) "baoding"

3.8.5 获取geohash

1
geohash key member [member ...]

Redis使用 geohash 把二维经纬度转换为一维字符串。

1
2
127.0.0.1:6379> geohash citites:locations beijing
1) "wx4ww02w070"

geohash的特点:

  • GEO的数据类型为 zset ,Redis将所有地理位置信息的 geohash 存放在 zset 中。

    1
    2
    127.0.0.1:6379> type citites:locations
    zset
  • 字符串越长,表示的位置越精确,下图给出了字符串长度对应的精度。

  • 两个字符串越相似,它们的距离越近,Redis利用字符串前缀匹配算法实现相关的命令。

  • geohash` 编码和经纬度可以相互转换。

3.8.6 删除地理位置信息

1
zrem key member

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


参考:

🔗 《Redis开发与运维》