《Redis入门指南》笔记及Python操作Redis汇总

自己总结的博客

个人总结合集

Mac中redis的安装配置及图形化工具的下载与使用

概念及其他

基本数据类型

到目前为止Redis支持的键值数据类型如下:

  • 字符串类型
  • 哈希(散列)类型
  • 列表类型
  • 集合类型
  • 有序集合类型

安装Redis

Linux、OS X、Windows中安装...

启动与停止Redis

使用配置文件的方式启动

redis-server /etc/my_redis_conf.cfg

覆盖配置文件中对应的参数

redis-server /etc/my_redis_conf.cfg --loglevel warning

除此之外还可以在Redis运行时通过CONFIG SET命令在不重新启动Redis的情况下动态修改部分Redis配置。就像这样:

redis> CONFIG SET loglevel warning
OK

并不是所有的配置都可以使用CONFIG SET命令修改,附录B列出了哪些配置能够使用该命令修改。同样在运行的时候也可以使用CONFIG GET命令获得Redis当前的配置情况,例如:

redis> CONFIG GET loglevel
1) "loglevel"
2) "warning"

其中第一行字符串表示的是选项名,第二行即是选项值。

直接指定端口启动:

redis-server --port 6380  

停止Redis

ps -ef |grep redis # 找出所有redis进程,下面那个是查询的,第一个才是
'''
501 11101     1   0  6:15下午 ??         0:00.04 redis-server 127.0.0.1:6379
  501 11129  6307   0  6:15下午 ttys002    0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox redis
'''

kill -9 11101 # 删除想停止的redis进程的process_id

客户端连接redis

redis-cli -h 127.0.0.1 -p 6800

多数据库

Redis是一个字典结构的存储服务器,而实际上一个Redis实例提供了多个用来存储数据的字典,客户端可以指定将数据存储在哪个字典中。这与我们熟知的在一个关系数据库实例中可以创建多个数据库类似,所以可以将其中的每个字典都理解成一个独立的数据库。

每个数据库对外都是以一个从0开始的递增数字命名,Redis默认支持16个数据库,可以通过配置参数databases来修改这一数字。客户端与Redis建立连接后会自动选择0号数据库,不过可以随时使用SELECT命令更换数据库,如要选择1号数据库:

redis> SELECT 1
OK
redis[1] GET mame 
(nil)

然而这些以数字命名的数据库又与我们理解的数据库有所区别。

首先Redis不支持自定义数据库的名字,每个数据库都以编号命名,开发者必须自己记录哪些数据库存储了哪些数据。

另外Redis也不支持为每个数据库设置不同的访问密码,所以一个客户端要么可以访问全部数据库,要么连一个数据库也没有权限访问。

最重要的一点是多个数据库之间并不是完全隔离的,比如FLUSHALL命令可以清空一个Redis实例中所有数据库中的数据。

综上所述,这些数据库更像是一种命名空间,而不适宜存储不同应用程序的数据。比如可以使用0号数据库存储某个应用生产环境中的数据,使用1号数据库存储测试环境中的数据,但不适宜使用0号数据库存储A应用的数据而使用1号数据库存储B应用的数据,不同的应用应该使用不同的Redis实例存储数据。由于Redis非常轻量级,一个空Redis实例占用的内存只有1MB左右,所以不用担心多个Redis实例会额外占用很多内存。

Redis的5大数据类型

入门操作

下面的操作都是在redis-cli中执行的:

匹配

keys (pattern)

pattern支持glob风格通配符格式,具体规则为:

144930c92e1023fde03c4fa0f3cfca1d.png

下面是一个例子:

127.0.0.1:6379> mset name1 whw1 name2 whw2 age1 18
OK
127.0.0.1:6379> keys *
1) "age1"
2) "name2"
3) "name1"
127.0.0.1:6379>
127.0.0.1:6379> keys name*
1) "name2"
2) "name1"
127.0.0.1:6379>
127.0.0.1:6379> keys age*
1) "age1"

判断一个key是否存在

exists (key)

删除key

# 第二次执行DEL命令时因为bar键已经被删除了,实际上并没有删除任何键,所以返回0。
redis> del name1
(integer) 1
redis> del name1
(integer) 0
技巧

DEL命令的参数不支持通配符,但我们可以结合Linux的管道和xargs命令自己实现删除所有符合规则的键。

比如要删除所有以“user:”开头的键,就可以执行
redis-cliKEYS "user:*" | xargs redis-cli DEL

另外由于DEL命令支持多个键作为参数,所以还可以执行
redis-cli DEL 'redis-cli KEYS"user:*"' 来达到同样的效果,但是性能更好。

获得键值的数据类型

# type (key)
127.0.0.1:6379> type name2
string

字符串类型

字符串类型是Redis中最基本的数据类型,它能存储任何形式的字符串,包括二进制数据。你可以用其存储用户的邮箱、JSON化的对象甚至是一张图片。一个字符串类型键允许存储的数据的最大容量是512MB。

字符串类型是其他4种数据类型的基础,其他数据类型和字符串类型的差别从某种角度来说只是组织字符串的形式不同。例如,列表类型是以列表的形式组织字符串,而集合类型是以集合的形式组织字符串。

命令

  • incr
  • decr
  • incrby
  • decrby
  • incrbyfloat
  • append:追加到值的末尾
  • getrange: 获取子串
  • setrange
  • getbit: 将字节串看作是二进制位串(bit string),并返回串中偏移量为offset的二进制位的值
  • setbit
  • bitcount: 统计二进制串位里值为1的二进制位的数量
  • bitop: 对一个或者多个二进制位串执行位运算操作
1. 赋值与取值
set name1 whw1
mset name1 whw1 name2 whw2 age1 18 # 批量设置
...
get name1
mget name1 name2 name666 # 批量获取,没有的话相应的位置返回(nil)
2. 递增数字
incr (key)

前面说过字符串类型可以存储任何形式的字符串,当存储的字符串是整数形式时,Redis提供了一个实用的命令INCR,其作用是让当前键值递增,并返回递增后的值.

当要操作的键不存在时会默认键值为0,所以第一次递增后的结果是1。当键值不是整数时Redis会提示错误:

127.0.0.1:6379> incr age
(integer) 1
127.0.0.1:6379> incr name1
(error) ERR value is not an integer or out of range

字符串实践

1.文章访问量统计

博客的一个常见的功能是统计文章的访问量,我们可以为每篇文章使用一个名为post:文章ID:page.view的键来记录文章的访问量,每次访问文章的时候使用INCR命令使相应的键值递增

提示

Redis对于键的命名并没有强制的要求,但比较好的实践是用“对象类型:对象ID:对象属性”来命名一个键,如使用键user:1:friends来存储ID为1的用户的好友列表对于多个单词则推荐使用“.”分隔,一方面是沿用以前的习惯(Redis以前版本的键名不能包含空格等特殊字符),另一方面是在redis-cli中容易输入,无需使用双引号包裹。另外为了日后维护方便,键的命名一定要有意义,如u:1:f的可读性显然不如user:1:friends好(虽然采用较短的名称可以节省存储空间,但由于键值的长度往往远远大于键名的长度,所以这部分的节省大部分情况下并不如可读性来得重要)。

2.生成自增ID

那么怎么为每篇文章生成一个唯一ID呢?在关系数据库中我们通过设置字段属性为AUTO_INCREMENT来实现每增加一条记录自动为其生成一个唯一的递增ID的目的,而在Redis中可以通过另一种模式来实现:对于每一类对象使用名为对象类型(复数形式):count[插图]的键(如users:count)来存储当前类型对象的数量,每增加一个新对象时都使用INCR命令递增该键的值。由于使用INCR命令建立的键的初始键值是1,所以可以很容易得知,INCR命令的返回值既是加入该对象后的当前类型的对象总数,又是该新增对象的ID。

3.存储文章数据

由于每个字符串类型键只能存储一个字符串,而一篇博客文章是由标题、正文、作者与发布时间等多个元素构成的。为了存储这些元素,我们需要使用序列化函数(如PHP中的serialize和JavaScript中的JSON.stringify)将它们转换成一个字符串。除此之外因为字符串类型键可以存储二进制数据,所以也可以使用MessagePack进行序列化,速度更快,占用空间也更小。

命令拾遗

1.增加指定的整数

incrby

127.0.0.1:6379> incrby score 9
(integer) 9
127.0.0.1:6379> incrby score 10
(integer) 19
2.减少与减少指定的整数

decr 与 decrby

127.0.0.1:6379> decr score
(integer) 18
127.0.0.1:6379> decr score
(integer) 17
127.0.0.1:6379> decr score
(integer) 16
127.0.0.1:6379> decrby score 6
(integer) 10
127.0.0.1:6379> decrby score 5
(integer) 5
3.增加指定浮点数

incrbyfloat

127.0.0.1:6379> incrbyfloat score 1.2
"6.2"
127.0.0.1:6379> incrbyfloat score 1.6
"7.8"
4.向尾部追加

append:APPEND作用是向键值的末尾追加value。如果键不存在则将该键的值设置为value,即相当于SET key value。返回值是追加后字符串的总长度。

127.0.0.1:6379> append key1 hello
(integer) 5
127.0.0.1:6379> get key1
"hello"
127.0.0.1:6379> append key1 " world"
(integer) 11
127.0.0.1:6379> get key1
"hello world"
5.获取字符串长度

strlen:STRLEN命令返回键值的长度,如果键不存在则返回0。

前面提到了字符串类型可以存储二进制数据,所以它可以存储任何编码的字符串。例子中Redis接收到的是使用UTF-8编码的中文,由于“你”和“好”两个字的UTF-8编码的长度都是3,所以此例中会返回6。

127.0.0.1:6379> strlen key1
(integer) 11
127.0.0.1:6379> strlen kkk
(integer) 0

127.0.0.1:6379> set words "你好"
OK
127.0.0.1:6379> strlen words
(integer) 6
6.同时获取/设置多个键值

mget mset

127.0.0.1:6379> mset name1 whw1 name2 whw2 name3 whw3
OK
127.0.0.1:6379>
127.0.0.1:6379> mget name1 name2 name3 name4
1) "whw1"
2) "whw2"
3) "whw3"
4) (nil) # 不存在的返回空
7.位操作
getbit key offset
setbit key offset value
bitcount key [start] [end]
bitop operation destkey key [key ...]

一个字节由8个二进制位组成,Redis提供了4个命令可以直接对二进制位进行操作。为了演示,我们首先将foo键赋值为bar:

127.0.0.1:6379> set foo bar
OK

bar的3个字母对应的ASCII码分别为98、97和114,转换成二进制后分别为1100010、1100001和1110010,所以foo键中的二进制位结构如图:

d2c416e2bf9476834c79e5aa906b2e16.png

GETBIT命令可以获得一个字符串类型键指定位置的二进制位的值(0或1),索引从0开始:

127.0.0.1:6379> getbit foo 0
(integer) 0
127.0.0.1:6379> getbit foo 6
(integer) 1
# 如果需要获取的二进制位的索引超出了键值的二进制位的实际长度则默认位值是0:
127.0.0.1:6379> getbit foo 10000000 
(integer) 0

SETBIT命令可以设置字符串类型键指定位置的二进制位的值,返回值是该位置的旧值。如我们要将foo键值设置为aar,可以通过位操作将foo键的二进制位的索引第6位设为0,第7位设为1:

127.0.0.1:6379> setbit foo 6 0
(integer) 1
127.0.0.1:6379> setbit foo 7 1
(integer) 0
127.0.0.1:6379> get foo
"aar"

如果要设置的位置超过了键值的二进制位的长度,SETBIT命令会自动将中间的二进制位设置为0,同理设置一个不存在的键的指定二进制位的值会自动将其前面的位赋值为0:

127.0.0.1:6379> setbit foo1 10 1
(integer) 0
127.0.0.1:6379> getbit foo1 5
(integer) 0
127.0.0.1:6379> get foo1
"\x00 "

BITCOUNT命令可以获得字符串类型键中值是1的二进制位个数,例如:

127.0.0.1:6379> bitcount foo
(integer) 10

可以通过参数来限制统计的字节范围,如我们只希望统计前两个字节(即"aa"):

127.0.0.1:6379> bitcount foo 0 1
(integer) 6

BITOP命令可以对多个字符串类型键进行位运算,并将结果存储在destkey参数指定的键中。BITOP命令支持的运算操作有AND、OR、XOR和NOT。如我们可以对bar和aar进行OR运算:

127.0.0.1:6379> set f1 bar
OK
127.0.0.1:6379> set f2 aar
OK
127.0.0.1:6379> bitop OR res f1 f2
(integer) 3
127.0.0.1:6379> get res
"car"

OR运算过程如下:

c187f9f04b31cf4413a5139672b29ef0.png

利用位操作命令可以非常紧凑地存储布尔值。比如某网站的每个用户都有一个递增的整数ID,如果使用一个字符串类型键配合位操作来记录每个用户的性别(用户ID作为索引,二进制位值1和0表示男性和女性),那么记录100万个用户的性别只需占用100 KB多的空间,而且由于GETBIT和SETBIT的时间复杂度都是O(1),所以读取二进制位值性能很高。

哈希(散列)类型

我们现在已经知道Redis是采用字典结构以键值对的形式存储数据的,而散列类型(hash)的键值也是一种字典结构,其存储了字段(field)和字段值的映射,但字段值只能是字符串,不支持其他数据类型,换句话说,散列类型不能嵌套其他的数据类型。一个散列类型键可以包含至多232-1个字段。

除了散列类型,Redis的其他数据类型同样不支持数据类型嵌套。比如集合类型的每个元素都只能是字符串,不能是另一个集合或散列表等。

散列类型适合存储对象:使用对象类别和ID构成键名,使用字段表示对象的属性,而字段值则存储属性值。例如要存储ID为2的汽车对象,可以分别使用名为color、name和price的3个字段来存储该辆汽车的颜色、名称和价格。存储结构如图

ab3b5bcb3350d2887ab68451a1e73c10.png

美中不足的一点是散列类型没有类似字符串类型的MGET命令那样可以通过一条命令同时获得多个键的键值的版本,所以对于每个文章ID都需要请求一次数据库,也就都会产生一次往返时延(round-trip delay time)[插图],之后我们会介绍使用管道和脚本来优化这个问题。

命令

  • hmget
  • hmset
  • hdel
  • hlen
  • hexists
  • hkeys
  • hvals
  • hgetall
  • hincrby
  • hincrybyfloat
1.赋值与取值
127.0.0.1:6379> hset car:1 price 500
(integer) 1
127.0.0.1:6379> hset car:1 color black
(integer) 1

127.0.0.1:6379> hget car:1 color
"black"

127.0.0.1:6379> hgetall car:1
1) "price"
2) "500"
3) "color"
4) "black"

HSET命令的方便之处在于不区分插入和更新操作,这意味着修改数据时不用事先判断字段是否存在来决定要执行的是插入操作(update)还是更新操作(insert)。当执行的是插入操作时(即之前字段不存在)HSET命令会返回1,当执行的是更新操作时(即之前字段已经存在)HSET命令会返回0。更进一步,当键本身不存在时,HSET命令还会自动建立它。

提示
在Redis中每个键都属于一个明确的数据类型,如通过HSET命令建立的键是散列类型,通过SET命令建立的键是字符串类型等。使用一种数据类型的命令操作另一种数据类型的键会提示错误:"ERR Operation against a key holding the wrong kind of value"

批量操作

127.0.0.1:6379> hmset stu:1 name whw age 26 score 99
OK

127.0.0.1:6379> hmget stu:1 name age score gender
1) "whw"
2) "26"
3) "99"
4) (nil)

127.0.0.1:6379> hgetall stu:1 # 全部获取
1) "name"
2) "whw"
3) "age"
4) "26"
5) "score"
6) "99"
2.判断字段是否存在

HEXISTS命令用来判断一个字段是否存在。如果存在则返回1,否则返回0(如果键不存在也会返回0)。

127.0.0.1:6379> hexists stu:1 name
(integer) 1
127.0.0.1:6379> hexists stu:1 gender
(integer) 0
127.0.0.1:6379>
3.当字段不存在时才赋值,存在的话就不赋值了

HSETNX命令与HSET命令类似,区别在于如果字段已经存在,HSETNX命令将不执行任何操作。不过HSETNX命令是原子操作,不用担心竞态条件。

127.0.0.1:6379> hsetnx stu:1 gender male
(integer) 1
127.0.0.1:6379> hsetnx stu:1 age 100
(integer) 0
127.0.0.1:6379> hgetall stu:1
1) "name"
2) "whw"
3) "age"
4) "26"
5) "score"
6) "99"
7) "gender"
8) "male"
4.增加数字
127.0.0.1:6379> hincrby stu:1 score 12
(integer) 111
127.0.0.1:6379> hincrby stu:1 score 13
(integer) 124
# 不是数字类型的会报错
127.0.0.1:6379> hincrby stu:1 name 1
(error) ERR hash value is not an integer 
5.删除字段
127.0.0.1:6379> hdel stu:1 score
(integer) 1
127.0.0.1:6379> hdel stu:1 score
(integer) 0
127.0.0.1:6379> hgetall stu:1
1) "name"
2) "whw"
3) "age"
4) "26"
5) "gender"
6) "male"

实践

1.存储文章数据

之前介绍了可以将文章对象序列化后使用一个字符串类型键存储,可是这种方法无法提供对单个字段的原子读写操作支持,从而产生竞态条件,如两个客户端同时获得并反序列化某个文章的数据,然后分别修改不同的属性后存入,显然后存入的数据会覆盖之前的数据,最后只会有一个属性被修改。另外如小白所说,即使只需要文章标题,程序也不得不将包括文章内容在内的所有文章数据取出并反序列化,比较消耗资源。

除此之外,还有一种方法是组合使用多个字符串类型键来存储一篇文章的数据,如图

fe2f9250a02e80d48295711967b39df3.png

使用这种方法的好处在于无论获取还是修改文章数据,都可以只对某一属性进行操作,十分方便。而本章介绍的散列类型则更适合此场景,使用散列类型的存储结构如图

a21014c01c90f8fb98e5245e32b3eb21.png

从下面的图可以看出使用散列类型存储文章数据比第一个图所示的方法看起来更加直观也更容易维护(比如可以使用HGETALL命令获得一个对象的所有字段,删除一个对象时只需要删除一个键),另外存储同样的数据散列类型往往比字符串类型更加节约空间,

2.存储文章缩略名

使用过WordPress的读者可能会知道发布文章时一般需要指定一个缩略名(slug)来构成该篇文章的网址的一部分,缩略名必须符合网址规范且最好可以与文章标题含义相似,如“This Is AGreat Post!”的缩略名可以为“this-is-a-great-post”。每个文章的缩略名必须是唯一的,所以在发布文章时程序需要验证用户输入的缩略名是否存在,同时也需要通过缩略名获得文章的ID。

我们可以使用一个散列类型的键slug.to.id来存储文章缩略名和ID之间的映射关系。其中slug字段用来记录缩略名,字段值用来记录缩略名对应的ID。这样就可以使用HEXISTS命令来判断缩略名是否存在,使用HGET命令来获得缩略名对应的文章ID了。

发布文章可以进行下面的伪代码实现:

post_id = INCR posts:count

# 判断用户的slug是否可用,如可用则记录
is_slug_available = HSETNX slug.to.id slug post_id
if is_slug_available == 0:
    # slug之前存在,已经用过了,提示用户更换slig
    exit(slug已经用过了)

HMSET post:post_id slug (slug) ........

上面这段代码使用了HSETNX命令原子地实现了HEXISTS和HSET两个命令以避免竞态条件。

当用户访问文章时,我们从网址中得到文章的缩略名,并查询slug.to.id键来获取文章ID:

post_id = HGET slug.to.id slug
if not post_id:
    exit("文章不存在")
else:
    post = HGETALL post:(post_id) # 这里的post_id时变量
    title = post.title 

需要注意的是如果要修改文章的缩略名一定不能忘了修改slug.to.id键对应的字段。如要修改ID为42的文章的缩略名为newSlug变量的值:

# 判断slug可用的话才记录
is_slug_available = HSETNX slug.to.id (new_slug) 42
if is_siug_available == 0:
    exit("slug已经存在了")

# 旧缩略名
old_slug = HGET post:42 slug
# 设置新的缩略名
HSET post:42 slug (new_slug)
# 删除旧的缩略名
HDEL slug.to.id (old_slug)

命令拾遗

1.只获取字段名或字段值

有时仅仅需要获取键中所有字段的名字而不需要字段值,那么可以使用HKEYS命令,就像这样:

127.0.0.1:6379> hkeys stu:1
1) "name"
2) "age"
3) "gender"
127.0.0.1:6379>
127.0.0.1:6379> hvals stu:1
1) "whw"
2) "26"
3) "male"
127.0.0.1:6379>
2.获得字段数量
127.0.0.1:6379> hlen stu:1
(integer) 3

列表类型

列表类型(list)可以存储一个有序的字符串列表,常用的操作是向列表两端添加元素,或者获得列表的某一个片段。

列表类型内部是使用双向链表(double linked list)实现的,所以向列表两端添加元素的时间复杂度为O(1),获取越接近两端的元素速度就越快。这意味着即使是一个有几千万个元素的列表,获取头部或尾部的10条记录也是极快的(和从只有20个元素的列表中获取头部或尾部的10条记录的速度是一样的)。

不过使用链表的代价是通过索引访问元素比较慢,设想在iPad mini发售当天有1000个人在三里屯的苹果店排队等候购买,这时苹果公司宣布为了感谢大家的排队支持,决定奖励排在第486位的顾客一部免费的iPad mini。为了找到这第486位顾客,工作人员不得不从队首一个一个地数到第486个人。但同时,无论队伍多长,新来的人想加入队伍的话直接排到队尾就好了,和队伍里有多少人没有任何关系。这种情景与列表类型的特性很相似。

这种特性使列表类型能非常快速地完成关系数据库难以应付的场景:如社交网站的新鲜事,我们关心的只是最新的内容,使用列表类型存储,即使新鲜事的总数达到几千万个,获取其中最新的100条数据也是极快的。同样因为在两端插入记录的时间复杂度是O(1),列表类型也适合用来记录日志,可以保证加入新日志的速度不会受到已有日志数量的影响。

借助列表类型,Redis还可以作为队列使用.

与散列类型键最多能容纳的字段数量相同,一个列表类型键最多能容纳232-1个元素。

命令

  • rpush
  • lpush
  • rpop
  • lpop
  • lindex
  • lrange
  • ltrim

阻塞式的列表弹出命令以及在列表之间移动元素的命令。常用在消息传递(messaging)和任务队列
(task queue)

  • blpop
  • brpop
  • rpoplpush
  • brpoplpush
1.两端增加元素

lpush rpush

127.0.0.1:6379> lpush lst1 1
(integer) 1
127.0.0.1:6379> lpush lst1 2
(integer) 2
127.0.0.1:6379> rpush lst1 3
(integer) 3

127.0.0.1:6379> lrange lst1 0 -1
1) "2"
2) "1"
3) "3"
2.两端弹出元素

lpop rpop

有进有出,LPOP命令可以从列表左边弹出一个元素。LPOP命令执行两步操作:第一步是将列表左边的元素从列表中移除,第二步是返回被移除的元素值。

127.0.0.1:6379> lpop lst1
"2"
127.0.0.1:6379> lrange lst1 0 -1
1) "1"
2) "3"

可以使用列表类型来模拟栈和队列的操作:如果想把列表当做栈,则搭配使用LPUSH和LPOP或RPUSH和RPOP,如果想当成队列,则搭配使用LPUSH和RPOP或RPUSH和LPOP。

3.获取列表中元素的个数

LLEN命令的功能类似SQL语句SELECT COUNT(*) FROM table_name,但是LLEN的时间复杂度为O(1),使用时Redis会直接读取现成的值,而不需要像部分关系数据库(如使用InnoDB存储引擎的MySQL表)那样需要遍历一遍数据表来统计条目数量。

127.0.0.1:6379> lpush lst2 1 2 3 4 5 6
(integer) 6
127.0.0.1:6379> llen lst2
(integer) 6
127.0.0.1:6379>
4.获得列表片段
# 闭区间!返回的值包含最右边的元素
lrange key start stop
127.0.0.1:6379> lrange lst2 2 4
1) "4"
2) "3"
3) "2"
127.0.0.1:6379>

LRANGE命令在取得列表片段的同时不会像LPOP一样删除该片段,另外LRANGE命令与很多语言中用来截取数组片段的方法slice有一点区别是LRANGE返回的值包含最右边的元素

LRANGE命令也支持负索引,表示从右边开始计算序数,如"-1"表示最右边第一个元素,"-2"表示最右边第二个元素,依次类推:

127.0.0.1:6379> lrange lst2 1 -2
1) "5"
2) "4"
3) "3"
4) "2"

显然,LRANGE numbers 0-1可以获取列表中的所有元素。

另外一些特殊情况如下:
(1)如果start的索引位置比stop的索引位置靠后,则会返回空列表。
(2)如果stop大于实际的索引范围,则会返回到列表最右边的元素:

5.删除列表指定的值
lrem key count value

LREM命令会删除列表中前count个值为value的元素,返回值是实际删除的元素个数。根据count值的不同,LREM命令的执行方式会略有差异:

  • 当count > 0时LREM命令会从列表左边开始删除前 count个值为 value的元素;
  • 当count < 0时LREM命令会从列表右边开始删除前|count|个值为value的元素;
  • 当count = 0是LREM命令会删除所有值为value的元素。
127.0.0.1:6379> rpush num2 1 2 3 4 5 4
(integer) 6
127.0.0.1:6379> lrange num2 0 -1
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
6) "4"
# 从右边开始删除第一个值为4的元素
127.0.0.1:6379> lrem num2 -1 4
(integer) 1
127.0.0.1:6379> lrange num2 0 -1
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"

实践

1.存储文章ID列表实现文章分页显示

我们使用列表类型键posts:list记录文章ID列表。当发布新文章时使用LPUSH命令把新文章的ID加入这个列表中,另外删除文章时也要记得把列表中的文章ID删除,就像这样:LREM posts:list 1要删除的文章ID。有了文章ID列表,就可以使用LRANGE命令来实现文章的分页显示了。伪代码如下:

size = 10
start = (current_page - 1) * size
end = current_page * size - 1
posts_id_lst = lrange posts:list (start) (end)
for if in posts_id_lst:
    post = hgetall post:(id)
    print(post.title)

这样显示的文章列表是根据加入列表的顺序倒序的(即最新发布的文章显示在前面),如果想让最旧的文章显示在前面,可以使用LRANGE命令获取需要的部分并在客户端中将顺序反转显示出来

另外使用列表类型键存储文章ID列表有以下两个问题。
(1)文章的发布时间不易修改:修改文章的发布时间不仅要修改post:文章ID中的time字段,还需要按照实际的发布时间重新排列posts:list中的元素顺序,而这一操作相对比较繁琐。
(2)当文章数量较多时访问中间的页面性能较差:前面已经介绍过,列表类型是通过链表实现的,所以当列表元素非常多时访问中间的元素效率并不高。

但如果博客不提供修改文章时间的功能并且文章数量也不多时,使用列表类型也不失为一种好办法。对于小白要做的博客系统来讲,现阶段的成果已经足够实用且值得庆祝了。3.6节将介绍使用有序集合类型存储文章ID列表的方法。

2.存储评论列表

在博客中还可以使用列表类型键存储文章的评论。由于小白的博客不允许访客修改自己发表的评论,而且考虑到读取评论时需要获得评论的全部数据(评论者姓名,联系方式,评论时间和评论内容),不像文章一样有时只需要文章标题而不需要文章正文。所以适合将一条评论的各个元素序列化成字符串后作为列表类型键中的元素来存储。我们使用列表类型键post:文章ID:comments来存储某个文章的所有评论。发布评论的伪代码如下(以ID为42的文章为例):

lst1 = [author,email,time,content]
serialzed_comment = json.dumps(lst1)
LPUSH post:421:comments serialed_comment

读取评论时同样使用LRANGE命令即可,具体的实现在此不再赘述。

命令拾遗

1.获取/设置指定索引的元素的值
LINDEX key index
LSET key index value 

实例

127.0.0.1:6379> rpush n1 1 2 3
(integer) 3
127.0.0.1:6379> lindex n1 1
"2"
127.0.0.1:6379> lset n1 1 666
OK
127.0.0.1:6379> lindex n1 1
"666"
# -1 表示右边第一个元素
127.0.0.1:6379> lindex n1 -1
"3"
# 不存在的index会报错
127.0.0.1:6379> lset n1 12 123
(error) ERR index out of range
2.只保留指定片段
LTRIM key start end

LTRIM命令可以删除指定索引范围之外的所有元素,其指定列表范围的方法和LRANGE命令相同。就像这样:

127.0.0.1:6379> lrange n1 0 -1
1) "1"
2) "666"
3) "3"
# 包含最右边的索引
127.0.0.1:6379> ltrim n1 0 1
OK
127.0.0.1:6379> lrange n1 0 -1
1) "1"
2) "666"

LTRIM命令常和LPUSH命令一起使用来限制列表中元素的数量,比如记录日志时我们希望只保留最近的100条日志,则每次加入新元素时调用一次LTRIM命令即可:

LPUSH logs (new_log)
LTRIM logs 0 99
3.向列表中插入元素
LINSERT key BEFORE|AFTER pivot value

LINSERT命令首先会在列表中从左到右查找值为pivot的元素,然后根据第二个参数是BEFORE还是AFTER来决定将value插入到该元素的前面还是后面。LINSERT命令的返回值是插入后列表的元素个数。示例如下:

127.0.0.1:6379> lpush n2 1 2 3 4 5
(integer) 5

127.0.0.1:6379> lrange n2 0 -1
1) "5"
2) "4"
3) "3"
4) "2"
5) "1"

###
127.0.0.1:6379> linsert n2 before 1 whw
(integer) 6

1) "5"
2) "4"
3) "3"
4) "2"
5) "whw"
6) "1"
4.将元素从一个列表转到另一个列表
RPOPLPUSH source destination

RPOPLPUSH是个很有意思的命令,从名字就可以看出它的功能:先执行RPOP命令再执行LPUSH命令。RPOPLPUSH命令会先从source列表类型键的右边弹出一个元素,然后将其加入到destination列表类型键的左边,并返回这个元素的值,整个过程是原子的。

127.0.0.1:6379> rpush n3 1 2 3
(integer) 3

127.0.0.1:6379> lrange n3 0 -1
1) "1"
2) "2"
3) "3"

127.0.0.1:6379> rpush n4 111 222 333
(integer) 3

127.0.0.1:6379> lrange n3 0 -1
1) "1"
2) "2"
3) "3"

127.0.0.1:6379> rpoplpush n3 n4
"3"

127.0.0.1:6379> lrange n3 0 -1
1) "1"
2) "2"

127.0.0.1:6379> lrange n4 0 -1
1) "3"
2) "111"
3) "222"
4) "333"

当把列表类型作为队列使用时,RPOPLPUSH命令可以很直观地在多个队列中传递数据。

当source和destination相同时,RPOPLPUSH命令会不断地将队尾的元素移到队首.

借助这个特性我们可以实现一个网站监控系统:使用一个队列存储需要监控的网址,然后监控程序不断地使用RPOPLPUSH命令循环取出一个网址来测试可用性。这里使用RPOPLPUSH命令的好处在于在程序执行过程中仍然可以不断地向网址列表中加入新网址,而且整个系统容易扩展,允许多个客户端同时处理队列。

集合类型

集合的概念高中的数学课就学习过。在集合中的每个元素都是不同的,且没有顺序。一个集合类型(set)键可以存储至多232 -1个(相信这个数字对大家来说已经很熟悉了)字符串。

集合类型和列表类型有相似之处,但很容易将它们区分开来,如表

b23fc055f1856fd61284e35778b99e90.png

集合类型的常用操作是向集合中加入或删除元素、判断某个元素是否存在等,由于集合类型在Redis内部是使用值为空的散列表(hash table)实现的,所以这些操作的时间复杂度都是O(1)。最方便的是多个集合类型键之间还可以进行并集、交集和差集运算,稍后就会看到灵活运用这一特性带来的便利。

命令

无序存储多个不同的元素

  • sadd
  • srem
  • sismember
  • scard
  • smembers
  • srandmember
  • spop
  • smove

组合和关联多个集合

  • sdiff
  • sdiffstore: 差集
  • sinter
  • sinterstore
  • sunion
  • sunionstore
1.增加/删除元素
SADD key member [member ...]
SERM key member [member ...]

SADD命令用来向集合中增加一个或多个元素,如果键不存在则会自动创建。因为在一个集合中不能有相同的元素,所以如果要加入的元素已经存在于集合中就会忽略这个元素。本命令的返回值是成功加入的元素数量(忽略的元素不计算在内)。

127.0.0.1:6379> sadd s1 whw naruto sasuke
(integer) 3
127.0.0.1:6379> smembers s1
1) "naruto"
2) "sasuke"
3) "whw"
127.0.0.1:6379> srem s1 sasuke
(integer) 1
127.0.0.1:6379> smembers s1
1) "naruto"
2) "whw"
2.获取集合中的所有元素

smembers

127.0.0.1:6379> smembers s1
1) "naruto"
2) "whw"
3.判断元素是否在集合中

判断一个元素是否在集合中是一个时间复杂度为O(1)的操作,无论集合中有多少个元素,SISMEMBER命令始终可以极快地返回结果。当值存在时SISMEMBER命令返回1,当值不存在或键不存在时返回0,例如:

127.0.0.1:6379> sismember s1 whw
(integer) 1
127.0.0.1:6379> sismember s1 sasuke
(integer) 0
4.集合间运算
SDIFF key [key ...]
SINTER key [key ...]
SUNION key [key ...]

(1)SDIFF命令用来对多个集合执行差集运算。集合A与集合B的差集表示为A-B,代表所有属于A且不属于B的元素构成的集合 即A-B = {x| x∈A且x∈/ B}。例如:

e8b01da430f225e63b4448fde8941980.png

SDIFF命令支持同时传入多个键,例如:

redis> SDIFF s1 s2 s3

计算顺序是先计算setA - setB,再计算结果与setC的差集。

(2)SINTER命令用来对多个集合执行交集运算。集合A与集合B的交集表示为A ∩ B,代表所有属于A且属于B的元素构成的集合(如图3-14所示),即A ∩ B = {x | x ∈ A且x∈B}。例如:

084e0665cc9613da6f2157dc0f57e286.png

(3)SUNION命令用来对多个集合执行并集运算。集合A与集合B的并集表示为A∪B,代表所有属于A或属于B的元素构成的集合(如图3-15所示),即A∪B = {x | x∈A或x ∈B}。例如:

731dfcbec9fbdbccf111e96f8ef2eaf6.png

实践

1.存储文章标签

考虑到一个文章的所有标签都是互不相同的,而且展示时对这些标签的排列顺序并没有要求,我们可以使用集合类型键存储文章标签。

对每篇文章使用键名为“post:文章ID:tags”的键存储该篇文章的标签。具体操作如伪代码:

# 给id为42的文章增加标签
SADD post:42:tags python 技术 Django
# 删除标签
SEDM post:42:tags Django
# 显示所有标签
tags = SMEMBERS post:42:tags
print(tags)

使用集合类型键存储标签适合需要单独增加或删除标签的场合。如在WordPress博客程序中无论是添加还是删除标签都是针对单个标签的(图3-16),可以直观地使用SADD和SREM命令完成操作。

12f3bfd04f84663c12e080e6b27d0ed9.png

另一方面,有些地方需要用户直接设置所有标签后一起上传修改,图3-17所示是某网站的个人资料编辑页面,用户编辑自己的爱好后提交,程序直接覆盖原来的标签数据,整个过程没有针对单个标签的操作,并未利用到集合类型的优势,所以此时也可以直接使用字符串类型键存储标签数据。

506a928c70758c7e35f1ff92fed9acd7.png

之所以特意提到这个在实践中的差别是想说明对于Redis存储方式的选择并没有绝对的规则,比如3.4节介绍过使用列表类型存储访客评论,但是在一些特定的场合下散列类型甚至字符串类型可能更适合。

2.通过标签搜索文章

有时我们还需要列出某个标签下的所有文章,甚至需要获得同时属于某几个标签的文章列表,这种需求在传统关系数据库中实现起来比较复杂!

4c01991bfe94a2fe364147a11197a32f.png

为了找到同时属于“Java”、“MySQL”和“Redis”这3个标签的文章,需要使用如下的SQL语句:

select p.post_title
from post_tags pt,posts p,tags t
where pt.tag_id = t.tag_id
and (t.tag_name in ("JAVA","MYSQL","Redis"))
and p.post_id = pt.post_id
group by p.post_id having count(p.post_id)=3;

可以很明显看到这样的SQL语句不仅效率相对较低,而且不易阅读和维护。而使用Redis可以很简单直接地实现这一需求。

具体做法是为每个标签使用一个名为“tag:标签名称:posts”的集合类型键存储标有该标签的文章ID列表。假设现在有3篇文章,ID分别为1、2、3,其中ID为1的文章标签是“Java”,ID为2的文章标签是“Java”、“MySQL”,ID为3的文章标签是“Java”、“MySQL”和“Redis”,则有关标签部分的存储结构如所示

8518faff09fdec8e16f37e98f9fb4799.png

最简单的,当需要获取标记“MySQL”标签的文章时只需要使用命令SMEMBERStag:MySQL:posts即可。如果要实现找到同时属于Java、MySQL和Redis这3个标签的文章,只需要将tag:Java:posts、tag:MySQL:posts和tag:Redis:posts这3个键取交集,借助SINTER命令即可轻松完成。

命令拾遗

1.获取集合中元素个数
127.0.0.1:6379> scard s1
(integer) 2
2.进行集合运算并将结果存储
SDIFFSTORE destination key [key ...]
SINTERSTORE destination key [key ...]
SUNIONSTORE destination key [key ...]

SDIFFSTORE命令和SDIFF命令功能一样,唯一的区别就是前者不会直接返回运算结果,而是将结果存储在destination键中。

SDIFFSTORE命令常用于需要进行多步集合运算的场景中,如需要先计算差集再将结果和其他键计算交集。

SINTERSTORE和SUNIONSTORE命令与之类似,不再赘述。

127.0.0.1:6379> sadd ss1 whw naruro sasuke
(integer) 3
127.0.0.1:6379> sadd ss2 whw sasuke
(integer) 2
# ss3之前没有定义 会自动生成
127.0.0.1:6379> sdiffstore ss3 ss1 ss2
(integer) 1
127.0.0.1:6379> smembers ss3
1) "naruro"
3.随机获取集合中的元素
SRANDMEMBER key [count]

SRANDMEMBER命令用来随机从集合中获取一个元素,如:

# 随机获取1个
127.0.0.1:6379> srandmember ss1 1
1) "sasuke"
# 随机获取2个
127.0.0.1:6379> srandmember ss1 2
1) "naruro"
2) "whw"

(1)当count为正数时,SRANDMEMBER会随机从集合里获得count个不重复的元素。如果count的值大于集合中的元素个数,则SRANDMEMBER会返回集合中的全部元素。

(2)当 count 为负数时,SRANDMEMBER会随机从集合里获得|count|个的元素,这些元素有可能相同

细心的读者可能会发现SRANDMEMBER命令返回的数据似乎并不是非常的随机,从SRANDMEMBER letters -10这个结果中可以很明显地看出这个问题(b元素出现的次数相对较多),出现这种情况是由集合类型采用的存储结构(散列表)造成的。散列表使用散列函数将元素映射到不同的存储位置(桶)上以实现O(1)时间复杂度的元素查找,举个例子,当使用散列表存储元素b时,使用散列函数计算出b的散列值是0,所以将b存入编号为0的桶(bucket)中,下次要查找b时就可以用同样的散列函数再次计算b的散列值并直接到相应的桶中找到b。当两个不同的元素的散列值相同时会出现冲突,Redis使用拉链法来解决冲突,即将散列值冲突的元素以链表的形式存入同一桶中,查找元素时先找到元素对应的桶,然后再从桶中的链表中找到对应的元素。使用SRANDMEMBER命令从集合中获得一个随机元素时,Redis首先会从所有桶中随机选择一个桶,然后再从桶中的所有元素中随机选择一个元素,所以元素所在的桶中的元素数量越少,其被随机选中的可能性就越大,如图所示。

6694f9ac63fa42d3378792f60d412a93.png

4.从集合中弹出一个元素
SPOP key

我们学习过LPOP命令,作用是从列表左边弹出一个元素(即返回元素的值并删除它)。SPOP命令的作用与之类似,但由于集合类型的元素是无序的,所以SPOP命令会从集合中随机选择一个元素弹出。例如:

127.0.0.1:6379> smembers ss1
1) "naruro"
2) "sasuke"
3) "whw"
127.0.0.1:6379> spop ss1
"sasuke"
127.0.0.1:6379>
127.0.0.1:6379> smembers ss1
1) "naruro"
2) "whw"

有序集合类型

为博客加上文章访问量排序功能:将热门文章排在最前。

在集合类型的基础上有序集合类型为集合中的每个元素都关联了一个分数,这使得我们不仅可以完成插入、删除和判断元素是否存在等集合类型支持的操作,还能够获得分数最高(或最低)的前N个元素、获得指定分数范围内的元素等与分数有关的操作。虽然集合中每个元素都是不同的,但是它们的分数却可以相同。

有序集合类型在某些方面和列表类型有些相似:
(1)二者都是有序的。
(2)二者都可以获得某一范围的元素。

但是二者有着很大的区别,这使得它们的应用场景也是不同的:
(1)列表类型是通过链表实现的,获取靠近两端的数据速度极快,而当元素增多后,访问中间数据的速度会较慢,所以它更加适合实现如“新鲜事”或“日志”这样很少访问中间元素的应用。
(2)有序集合类型是使用散列表和跳跃表(Skip list)实现的,所以即使读取位于中间部分的数据速度也很快(时间复杂度是O(log(N)))。
(3)列表中不能简单地调整某个元素的位置,但是有序集合可以(通过更改这个元素的分数)。(4)有序集合要比列表类型更耗费内存。

有序集合类型算得上是Redis的5种数据类型中最高级的类型了,在学习时可以与列表类型和集合类型对照理解。

命令

根据分值大小有序获取(fetch)或扫描(scan)成员和分值

  • zadd
  • zrem
  • zcard
  • zincrby
  • zcount
  • zrank
  • zscore
  • zrange

范围命令,并集和交集命令。rev 逆序表示分值从大到小排列

  • zrevrank: 分值从大到小排列
  • zrevrange
  • zrangebyscore
  • zrevrangebyscore
  • zremrangebyrank
  • zremrangebyscore
  • zinterstore
  • zunionstore
1.增加/获取元素
ZADD key score member [score member ...]

ZSCORE key member

ZADD命令用来向有序集合中加入一个元素和该元素的分数,如果该元素已经存在则会用新的分数替换原有的分数。ZADD命令的返回值是新加入到集合中的元素个数(不包含之前已经存在的元素)。

127.0.0.1:6379> zadd z1 89 tom 67 peter 100 david
(integer) 3
127.0.0.1:6379> zadd z1 76 peter
(integer) 0
127.0.0.1:6379> zscore z1 peter
"76"
2.获得排名在某个范围的元素列表
# 从小到大
ZRANGE key start stop [WITHSCOERS]
# 从大到小
ZREVRANGE key start stop [WITHSCOERS]

从小到大

127.0.0.1:6379> zadd z2 12 whw 13 naruto 14 sasuke
(integer) 3
127.0.0.1:6379>
127.0.0.1:6379> zrange z2 0 1
1) "whw"
2) "naruto"
# 带上score
127.0.0.1:6379> zrange z2 0 1 withscores
1) "whw"
2) "12"
3) "naruto"
4) "13"
127.0.0.1:6379>

从大到小

127.0.0.1:6379> zrevrange z2 0 1
1) "sasuke"
2) "naruto"
# 带上score
127.0.0.1:6379> zrevrange z2 0 1 withscores
1) "sasuke"
2) "14"
3) "naruto"
4) "13"
3.获得指定分数范围的元素
# 从小到大
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

# 从大到小
ZREVRANGEBYSCORE max min [WITHSCORES] [LIMIT offset count]

ZRANGEBYSCORE命令参数虽然多,但是都很好理解。该命令按照元素分数从小到大的顺序返回分数在min和max之间(包含min和max)的元素:

# 默认包含端点值
127.0.0.1:6379> zrangebyscore z2 13 14
1) "naruto"
2) "sasuke"

# 不包含端点值,可以在 端点前面 加上( 
127.0.0.1:6379> zrangebyscore z2 13 (14
1) "naruto"
# +inf表示正无穷(-inf表示负无穷) —— 找出权重大于(不含)13的
127.0.0.1:6379> zrangebyscore z2 (13 +inf
1) "sasuke"

WITHSCORES参数的用法与ZRANGE命令一样,不再赘述。

了解SQL语句的读者对LIMIT offset count 应该很熟悉,在本命令中LIMIT offset count 与SQL中的用法基本相同,即在获得的元素列表的基础上向后偏移offset 个元素,并且只获取前 count个元素。

127.0.0.1:6379> zadd z3 1 whw1 2 whw2 3 whw3 4 whw4 5 whw5 6 whw6 7 whw6 8 whw8
(integer) 7

# 获取权重大于3(不包含)的从第二个人开始的3个人
# 把4跳过去了
127.0.0.1:6379> zrangebyscore z3 (3 +inf limit 1 3
1) "whw5"
2) "whw6"
3) "whw8"

那么想获取权重小于或者等于6的前三个人,需要用到ZREVRANGEBYSCORE:

127.0.0.1:6379> zrevrangebyscore z3 6 -inf limit 0 3
1) "whw5"
2) "whw4"
3) "whw3"
4.增加某个元素的分数
ZINCRBY key increment member

ZINCRBY命令可以增加一个元素的分数,返回值是更改后的分数。

# 注意是 在原基础上增加
127.0.0.1:6379> zincrby z3 555 whw5
"560"

127.0.0.1:6379> zrange z3 0 -1 withscores
 1) "whw1"
 2) "1"
 3) "whw2"
 4) "2"
 5) "whw3"
 6) "3"
 7) "whw4"
 8) "4"
 9) "whw6"
10) "7"
11) "whw8"
12) "8"
13) "whw5"
14) "560"

实践

1.实现按照点击量排序

要按照文章的点击量排序,就必须再额外使用一个有序集合类型的键来实现。在这个键中以文章的ID作为元素以该文章的点击量作为该元素的分数将该键命名为posts:page.view,每次用户访问一篇文章时,博客程序就通过ZINCRBY posts:page.view 1 文章ID更新访问量。

size = 10
start = (current_page -1) * size
end = current_page * suze - 1
# 从集合中拿指定数量个post_id
post_id = ZREVRANGE posts:page.view start end 
for if in post_id:
    post_data = HGETALL post:(id)
    print(post_data.title)
2.改进按时间排序文章

上面介绍了每次发布新文章时都将文章的ID加入到名为posts:list的列表类型键中来获得按照时间顺序排列的文章列表,但是由于列表类型更改元素的顺序比较麻烦,而如今不少博客系统都支持更改文章的发布时间,为了让小白的博客同样支持该功能,我们需要一个新的方案来实现按照时间顺序排列文章的功能。

为了能够自由地更改文章发布时间,可以采用有序集合类型代替列表类型。自然地,元素仍然是文章的ID,而此时元素的分数则是文章发布的Unix时间。通过修改元素对应的分数就可以达到更改时间的目的。

另外借助ZREVRANGEBYSCORE命令还可以轻松获得指定时间范围的文章列表,借助这个功能可以实现类似WordPress的按月份查看文章的功能。

命令拾遗

1.获取集合中元素的数量
zcard key
127.0.0.1:6379> zcard z3
(integer) 7
2.获取指定分数范围内的元素个数
# 包含边界值
127.0.0.1:6379> zcount z3 1 3
(integer) 3

# 前面加 ( 不包含
127.0.0.1:6379> zcount z3  (1 3
(integer) 2
# 前面加 ( 不包含
127.0.0.1:6379> zcount z3  (1 +inf
(integer) 6
3.删除一个或多个元素
127.0.0.1:6379> zrem z3 whw2 whw3
(integer) 2
127.0.0.1:6379> zrange z3 0 -1
1) "whw1"
2) "whw4"
3) "whw6"
4) "whw8"
5) "whw5"
4.按照排名范围删除元素
ZREMRANGEBYRANK key start stop

ZREMRANGEBYRANK命令按照元素分数从小到大的顺序(即索引0表示最小的值)删除处在指定排名范围内的所有元素,并返回删除的元素数量。

# 包含 ———— 这里的0与1表示 索引下标
127.0.0.1:6379> zremrangebyrank z3 0 1
(integer) 2
127.0.0.1:6379>
127.0.0.1:6379> zrange z3 0 -1
1) "whw6"
2) "whw8"
3) "whw5"
5.按照分数范围删除元素
ZREMRANGEBYSCORE key min max

ZREMRANGEBYSCORE命令会删除指定分数范围内的所有元素,参数min和max的特性和ZRANGEBYSCORE命令中的一样。返回值是删除的元素数量。如:

127.0.0.1:6379> zadd z4 34 whw 12 naruto 55 sasuke 66 haha
(integer) 4
127.0.0.1:6379> zrange z4 0 -1
1) "naruto"
2) "whw"
3) "sasuke"
4) "haha"
# 包含边界值
127.0.0.1:6379> zremrangebyscore z4 55 77
(integer) 2
127.0.0.1:6379> zrange z4 0 -1
1) "naruto"
2) "whw"
6.获得元素排名
# 从小到大
ZRANK key member
# 从大到小
ZREVRANK key member

ZRANK命令会按照元素分数从小到大的顺序获得指定的元素的排名(从0开始,即分数最小的元素排名为0),ZREVRANK命令则相反(分数最大的元素排名为0):

127.0.0.1:6379> zadd z5 34 whw 12 naruto 55 sasuke 66 haha
(integer) 4

127.0.0.1:6379> zrange z5 0 -1
1) "naruto"
2) "whw"
3) "sasuke"
4) "haha"

127.0.0.1:6379> zrank z5 whw
(integer) 1

127.0.0.1:6379> zrevrank z5 whw
(integer) 2
7.计算有序集合的交集
ZINTERSTORE destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE|SUM|MIN|MAX]

ZINTERSTORE命令用来计算多个有序集合的交集并将结果存储在destination键中(同样以有序集合类型存储),返回值为destination键中的元素个数。

destination键中元素的分数是由AGGREGATE参数决定的:

(1)当AGGREGATE是SUM时(也就是默认值),destination键中元素的分数是每个参与计算的集合中该元素分数的和。例如:

127.0.0.1:6379> zadd so1 1 a 2 b
(integer) 2
127.0.0.1:6379> zadd so2 10 a 20 b
(integer) 2
127.0.0.1:6379> zadd so3 100 a 200 b
(integer) 2
127.0.0.1:6379>
127.0.0.1:6379>
127.0.0.1:6379>
127.0.0.1:6379> zinterstore so4 3 so1 so2 so3
(integer) 2

127.0.0.1:6379> zrange so4 0 -1 withscores
1) "a"
2) "111"
3) "b"
4) "222"

(2)当AGGREGATE是MIN时,destination键中元素的分数是每个参与计算的集合中该元素分数的最小值。例如:

# 把之前的so4覆盖了
127.0.0.1:6379> zinterstore so4 2 so1 so3 aggregate min
(integer) 2
127.0.0.1:6379> zrange so4 0 -1 withscores
1) "a"
2) "1"
3) "b"
4) "2"

(3)当AGGREGATE是MAX时,destination键中元素的分数是每个参与计算的集合中该元素分数的最大值。例如:

127.0.0.1:6379> zinterstore so4 3 so1 so2 so3 aggregate max
(integer) 2
127.0.0.1:6379> zrange so4 0 -1 withscores
1) "a"
2) "100"
3) "b"
4) "200"

ZINTERSTORE命令还能够通过WEIGHTS参数设置每个集合的权重,每个集合在参与计算时元素的分数会被乘上该集合的权重。例如:

127.0.0.1:6379> zrange so1 0  -1 withscores
1) "a"
2) "1"
3) "b"
4) "2"
127.0.0.1:6379> zrange so2 0  -1 withscores
1) "a"
2) "10"
3) "b"
4) "20"
# 使用weights为对应的几个加上 权重乘的那个值
127.0.0.1:6379> zinterstore so4 2 so1 so2 weights 10 1
(integer) 2
127.0.0.1:6379> zrange so4 0 -1 withscores
1) "a"
2) "20"
3) "b"
4) "40"
8.计算有序集合的并集

另外还有一个命令与ZINTERSTORE命令的用法一样,名为ZUNIONSTORE,它的作用是计算集合间的并集,这里不再赘述。

Redis进阶

事物

在微博中,用户之间是“关注”和“被关注”的关系。如果要使用Redis存储这样的关系可以使用集合类型。思路是对每个用户使用两个集合类型键,分别名为user:用户ID:followersuser:用户ID:following,用来存储关注该用户的用户集合和该用户关注的用户集合。
然后使用一个函数来实现关注操作,伪代码如下:

def follow(current_user,target_user):
    SADD user:current_user:following terget_user
    SADD user:target_user:followers current_user

如ID为1的用户A想关注ID为2的用户B,只需要执行follow(1, 2)即可。

然而在实现该功能的时候我发现了一个问题:完成关注操作需要依次执行两条Redis命令,如果在第一条命令执行完后因为某种原因导致第二条命令没有执行,就会出现一个奇怪的现象:A查看自己关注的用户列表时会发现其中有B,而B查看关注自己的用户列表时却没有A,换句话说就是,A虽然关注了B,却不是B的“粉丝”。

事物概述

redis基本事务(basic transaction):让一个客户端在不被其他客户端打断的情况下执行多个命令,和关系数据库可以执行过程中回滚的事务不同, redis 里被 multi 命令和exec 命令包围的所有命令会一个接一个执行,直到所有命令执行完毕,redis 才会处理其他客户端命令。

redis事务在python client 上使用 pipeline 实现,客户端自动使用multi和exec,客户端会存储事务包含的多个命令,一次性把所有命令发送给redis。 移除竞争条件;减少通信次数提升性能。redis 原子操作指的是在读取或者修改数据的时候,其他客户端不能读取或修改相同数据。

Redis中的事务(transaction)是一组命令的集合。事务同命令一样都是Redis的最小执行单位,一个事务中的命令要么都执行,要么都不执行。事务的应用非常普遍,如银行转账过程中A给B汇款,首先系统从A的账户中将钱划走,然后向B的账户增加相应的金额。这两个步骤必须属于同一个事务,要么全执行,要么全不执行。否则只执行第一步,钱就凭空消失了,这显然让人无法接受。

事务的原理是先将属于一个事务的命令发送给Redis,然后再让Redis依次执行这些命令。例如:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> sadd user:1:following 1
QUEUED
127.0.0.1:6379> sadd user:2:follower 1
QUEUED
127.0.0.1:6379> exec
1) (integer) 1
2) (integer) 1

上面的代码演示了事务的使用方式。首先使用MULTI命令告诉Redis:“下面我发给你的命令属于同一个事务,你先不要执行,而是把它们暂时存起来。”Redis回答:“OK。”

当把所有要在同一个事务中执行的命令都发给Redis后,我们使用EXEC命令告诉Redis将等待执行的事务队列中的所有命令(即刚才所有返回QUEUED的命令)按照发送顺序依次执行。EXEC命令的返回值就是这些命令的返回值组成的列表,返回值顺序和命令的顺序相同。

Redis保证一个事务中的所有命令要么都执行,要么都不执行。如果在发送EXEC命令前客户端断线了,则Redis会清空事务队列,事务中的所有命令都不会执行。而一旦客户端发送了EXEC命令,所有的命令就都会被执行,即使此后客户端断线也没关系,因为Redis中已经记录了所有要执行的命令。

除此之外,Redis的事务还能保证一个事务内的命令依次执行而不被其他命令插入。试想客户端A需要执行几条命令,同时客户端B发送了一条命令,如果不使用事务,则客户端B的命令可能会插入到客户端A的几条命令中执行。如果不希望发生这种情况,也可以使用事务。

错误处理

如果一个事务中的某个命令执行出错,Redis会怎样处理呢?要回答这个问题,首先需要知道什么原因会导致命令执行出错。

(1)语法错误。语法错误指命令不存在或者命令参数的个数不对。比如:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name 1
QUEUED
127.0.0.1:6379> set name
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> eerrra
(error) ERR unknown command `eerrra`, with args beginning with:
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.

跟在MULTI命令后执行了3个命令:一个是正确的命令,成功地加入事务队列;其余两个命令都有语法错误。而只要有一个命令有语法错误,执行EXEC命令后Redis就会直接返回错误,连语法正确的命令也不会执行。

(2)运行错误。运行错误指在命令执行时出现的错误,比如使用散列类型的命令操作集合类型的键,这种错误在实际执行之前Redis是无法发现的,所以在事务里这样的命令是会被Redis接受并执行的。如果事务里的一条命令出现了运行错误,事务里其他的命令依然会继续执行(包括出错命令之后的命令),示例如下:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set n1 ww
QUEUED
127.0.0.1:6379> sadd n1 123
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379>
# 可以看到 事物里面的代码执行成功了
127.0.0.1:6379> get n1
"ww"

可见虽然SADD n1 123出现了错误,但是SET n1 whw依然执行了。

Redis的事务没有关系数据库事务提供的回滚(rollback)功能。为此开发者必须在事务执行出错后自己收拾剩下的摊子(将数据库复原回事务执行前的状态等)。

不过由于Redis不支持回滚功能,也使得Redis在事务上可以保持简洁和快速。另外回顾刚才提到的会导致事务执行失败的两种错误,其中语法错误完全可以在开发时找出并解决,另外如果能够很好地规划数据库(保证键名规范等)的使用,是不会出现如命令与数据类型不匹配这样的运行错误的。

watch命令介绍

我们已经知道在一个事务中只有当所有命令都依次执行完后才能得到每个结果的返回值,可是有些情况下需要先获得一条命令的返回值,然后再根据这个值执行下一条命令。例如,介绍INCR命令时曾经说过使用GET和SET命令自己实现incr函数会出现竞态条件,伪代码如下:

def incr(key):
    value = GET key
    if not value:
        valur = 0
    value = value + 1
    SET key value 
    return value

因为事务中的每个命令的执行结果都是最后一起返回的,所以无法将前一条命令的结果作为下一条命令的参数,即在执行SET命令时无法获得GET命令的返回值,也就无法做到增1的功能了。

为了解决这个问题,我们需要换一种思路。即在GET获得键值后保证该键值不被其他客户端修改,直到函数执行完成后才允许其他客户端修改该键键值,这样也可以防止竞态条件。

要实现这一思路需要请出事务家族的另一位成员:`WATCH。WATCH命令可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行。监控一直持续到EXEC命令1(事务中的命令是在EXEC之后才执行的,所以在MULTI命令后可以修改WATCH监控的键值),如:

127.0.0.1:6379> set key 1
OK
127.0.0.1:6379> watch key
OK
127.0.0.1:6379> set key 2
OK
127.0.0.1:6379> set key 3
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set key whw
QUEUED
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get key
"3"

上例中在执行WATCH命令后、事务执行前修改了key的值(即SET key 2),所以最后事务中的命令SET key 3没有执行,EXEC命令返回空结果。

学会了WATCH命令就可以通过事务自己实现incr函数了,伪代码如下:

def incr(key):
    WATCH key
    vaue = GET key
    if not value:
        value = 0
    value = value + 1
    MULTI
    SET key value 
    result = EXEC
    return redult[0]

因为EXEC命令返回值是多行字符串类型,所以代码中使用result[0]来获得其中第一个结果。

提示

由于WATCH命令的作用只是当被监控的键值被修改后阻止之后一个事务的执行,而不能保证其他客户端不修改这一键值,所以我们需要在EXEC执行失败后重新执行整个函数。

执行EXEC命令后会取消对所有键的监控,如果不想执行事务中的命令也可以使用UNWATCH命令来取消监控。比如,我们要实现hsetxx函数,作用与HSETNX命令类似,只不过是仅当字段存在时才赋值。为了避免竞态条件我们使用事务来完成这一功能:

def hsetxx(key, field,value):
    WATCH key
    is_field_exists = HEXISTS key field
    if is_field_exists == 1:
        MULTI
        HSET key fkeld value
        EXEC
    else:
        UNWATCH
    return is_field_exists

在代码中会判断要赋值的字段是否存在,如果字段不存在的话就不执行事务中的命令,但需要使用UNWATCH命令来保证下一个事务的执行不会受到影响。

生存时间

常用命令

在实际的开发中经常会遇到一些有时效的数据,比如限时优惠活动、缓存或验证码等,过了一定的时间就需要删除这些数据。在关系数据库中一般需要额外的一个字段记录到期时间,然后定期检测删除过期数据。而在Redis中可以使用EXPIRE命令设置一个键的生存时间,到时间后Redis会自动删除它。

127.0.0.1:6379> set session:1 uid314
OK
# 返回1表示设置成功
127.0.0.1:6379> expire session:1 60
(integer) 1
# 返回0则表示键不存在或设置失败
127.0.0.1:6379> del session:1
(integer) 1
127.0.0.1:6379> expire session:1 54
(integer) 0

如果想知道一个键还有多久的时间会被删除,可以使用TTL命令。返回值是键的剩余时间(单位是秒):

127.0.0.1:6379> set session:2 uid11
OK
127.0.0.1:6379> expire session:2 56
(integer) 1
127.0.0.1:6379> ttl session:2
(integer) 48

当键不存在时TTL命令会返回-1。另外同样会返回-1的情况是没有为键设置生存时间(即永久存在,这是建立一个键后的默认情况):

127.0.0.1:6379> set name whw
OK
127.0.0.1:6379> ttl name
(integer) -1

如果想取消键的生存时间设置(即将键恢复成永久的),可以使用PERSIST命令。如果生存时间被成功清除则返回1;否则返回0(因为键不存在或键本来就是永久的):

127.0.0.1:6379> set age 18
OK
127.0.0.1:6379> expire age 200
(integer) 1
127.0.0.1:6379> ttl age
(integer) 197
127.0.0.1:6379> persist age
(integer) 1
127.0.0.1:6379> ttl age
(integer) -1

除了PERSIST命令之外,使用SET或GETSET命令为键赋值也会同时清除键的生存时间,例如:

127.0.0.1:6379> set foo www
OK

127.0.0.1:6379> expire foo 20
(integer) 1
127.0.0.1:6379> ttl foo
(integer) 9
127.0.0.1:6379>
127.0.0.1:6379>
127.0.0.1:6379> set foo bar
OK
127.0.0.1:6379> ttl foo
(integer) -1

使用EXPIRE命令会重新设置键的生存时间,就像这样:

127.0.0.1:6379> set foo bar
OK
127.0.0.1:6379> expire foo 20
(integer) 1
127.0.0.1:6379> ttl foo
(integer) 17
127.0.0.1:6379> expire foo 50
(integer) 1
127.0.0.1:6379> ttl foo
(integer) 48

注意:其他只对键值进行操作的命令(如INCR、LPUSH、HSET、ZREM)均不会影响键的生存时间。

EXPIRE命令的seconds参数必须是整数,所以最小单位是1秒。如果想要更精确的控制键的生存时间应该使用PEXPIRE命令,PEXPIRE命令与EXPIRE的唯一区别是前者的时间单位是毫秒,即PEXPIRE key 1000与EXPIRE key 1等价。对应地可以用PTTL命令以毫秒为单位返回键的剩余时间。

如果使用WATCH命令监测了一个拥有生存时间的键,该键时间到期自动删除并不会被WATCH命令认为该键被改变。

EXPIREAT 与 PEXPIREAT

EXPIREAT命令与EXPIRE命令的差别在于前者使用Unix时间作为第二个参数表示键的生存时间的截止时间。PEXPIREAT命令与EXPIREAT命令的区别是前者的时间单位是毫秒。例如:

127.0.0.1:6379> set foo whw
OK
127.0.0.1:6379> expireat foo 1352222222222
(integer) 1
127.0.0.1:6379> ttl foo
(integer) 1350626666965
127.0.0.1:6379> ttl foo
(integer) 1350626666963

实现访问频率限制之一

为了减轻服务器的压力,需要限制每个用户(以IP计)一段时间的最大访问量。与时间有关的操作很容易想到EXPIRE命令。

例如要限制每分钟每个用户最多只能访问100个页面,思路是对每个用户使用一个名为“rate.limiting:用户IP”的字符串类型键,每次用户访问则使用INCR命令递增该键的键值,如果递增后的值是1(第一次访问页面),则同时还要设置该键的生存时间为1分钟。这样每次用户访问页面时都读取该键的键值,如果超过了100就表明该用户的访问频率超过了限制,需要提示用户稍后访问。该键每分钟会自动被删除,所以下一分钟用户的访问次数又会重新计算,也就达到了限制访问频率的目的。

# 使用事物实现
is_key_exists = EXISTS rate.limiting:(IP)
if is_key_exists == 1:
    tiems = INCR rate.limiting:(IP)
    if times > 10:
        exit("访问频率超出限制")
    # 不存在的话 使用事物处理,防止没有刷新生存时间的情况
    else:
        MULTI
        INCR rate.limiting:(IP)
        EXPIRE (key_name) 60
        EXEC

实现访问频率之二

可以使用列表:对每个用户,我们使用一个列表类型的键来记录他最近10次访问博客的时间。一旦键中的元素超过10个,就判断时间最早的元素距现在的时间是否小于1分钟。如果是则表示用户最近1分钟的访问次数超过了10次;如果不是就将现在的时间加入到列表中,同时把最早的元素删除。

实现缓存与缓存淘汰策略的配置

为了提高网站的负载能力,常常需要将一些访问频率较高但是对CPU或IO资源消耗较大的操作的结果缓存起来,并希望让这些缓存过一段时间自动过期。比如教务网站要对全校所有学生的各个科目的成绩汇总排名,并在首页上显示前10名的学生姓名,由于计算过程较耗资源,所以可以将结果使用一个Redis的字符串键缓存起来。由于学生成绩总在不断地变化,需要每隔两个小时就重新计算一次排名,这可以通过给键设置生存时间的方式实现。每次用户访问首页时程序先查询缓存键是否存在,如果存在则直接使用缓存的值;否则重新计算排名并将计算结果赋值给该键并同时设置该键的生存时间为两个小时。

然而在一些场合中这种方法并不能满足需要。当服务器内存有限时,如果大量地使用缓存键且生存时间设置得过长就会导致Redis占满内存;另一方面如果为了防止Redis占用内存过大而将缓存键的生存时间设得太短,就可能导致缓存命中率过低并且大量内存白白地闲置。实际开发中会发现很难为缓存键设置合理的生存时间,为此可以限制Redis能够使用的最大内存,并让Redis按照一定的规则淘汰不需要的缓存键,这种方式在只将Redis用作缓存系统时非常实用。

具体的设置方法为:修改配置文件的maxmemory参数,限制Redis最大可用内存大小(单位是字节),当超出了这个限制时Redis会依据maxmemory-policy参数指定的策略来删除不需要的键,直到Redis占用的内存小于指定内存。

maxmemory-policy支持的规则如表所示。其中的LRU(Least RecentlyUsed)算法即“最近最少使用”,其认为最近最少使用的键在未来一段时间内也不会被用到,即当需要空间时这些键是可以被删除的。

8b73efa50742bd165fd04907b6042a6f.png

如当maxmemory-policy设置为allkeys-lru时,一旦Redis占用的内存超过了限制值,Redis会不断地删除数据库中最近最少使用的键,直到占用的内存小于限制值。

排序 SORT命令(复杂)

有序集合的集合操作

不过昨天我收到一个访客的邮件,他向我反映了一个问题:查看一个标签下的文章列表时文章不是按照时间顺序排列的,找起来很麻烦。我看了一下代码,发现程序中是使用SMEMBERS命令获取标签下的文章列表,因为集合类型是无序的,所以不能实现按照文章的发布时间排列。我考虑过使用有序集合类型存储标签,但是有序集合类型的集合操作不如集合类型强大。您有什么好方法来解决这个问题吗?

集合类型提供了强大的集合操作命令,但是如果需要排序就要用到有序集合类型。Redis的作者在设计Redis的命令时考虑到了不同数据类型的使用场景,对于不常用到的或者在不损失过多性能的前提下可以使用现有命令来实现的功能,Redis就不会单独提供命令来实现。这一原则使得Redis在拥有强大功能的同时保持着相对精简的命令。

有序集合常见的使用场景是大数据排序,如游戏的玩家排行榜,所以很少会需要获得键中的全部数据。同样Redis认为开发者在做完交集、并集运算后不需要直接获得全部结果,而是会希望将结果存入新的键中以便后续处理。这解释了为什么有序集合只有ZINTERSTORE和ZUNIONSTORE命令而没有ZINTER和ZUNION命令。

当然实际使用中确实会遇到像小白那样需要直接获得集合运算结果的情况,除了等待Redis加入相关命令,我们还可以使用MULTI,ZINTERSTORE,ZRANGE,DEL和EXEC这5个命令自己实现ZINTER:

MULTI
# 将结果存在temKey里
ZINTERSTORE tempkey ...
# 拿到结果
ZRANGE tempKey ...
# 删除
DEL tempKey
EXEC

SORT命令实例

默认从小到大排序,后面加 DESC 参数可以按照从大到小排序

除了使用有序集合外,我们还可以借助Redis提供的SORT命令来解决小白的问题。SORT命令可以对列表类型、集合类型和有序集合类型键进行排序,并且可以完成与关系数据库中的连接查询相类似的任务。

集合类型排序

小白的博客中标有“ruby”标签的文章的ID分别是:“2”,“6”,“12”,“26”。由于在集合类型中所有元素是无序的,所以使用SMEMBERS命令并不能获得有序的结果。为了能够让博客的标签页面下的文章也能按照发布的时间顺序排列(如果不考虑发布后再修改文章发布时间,就是按照文章ID的顺序排列),可以借助SORT命令实现,方法如下所示:(注意这种方式不好)

redis> SORT tag:ruby:posts
1) "2"
2) "6"
3) "12"
4) "26"
列表类型排序

除了集合类型,SORT命令还可以对列表类型和有序集合类型进行排序:

lpush lst1 4 2 5 1 3 7
(integer) 6
127.0.0.1:6379> sort lst1
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
6) "7"
有序集合类型排序

在对有序集合类型排序时会忽略元素的分数,只针对元素自身的值进行排序。例如:

127.0.0.1:6379> zadd z1 50 22  40 11 60 6
(integer) 3
127.0.0.1:6379> sort z1
1) "6"
2) "11"
3) "22"

ALPHA参数 按照acsii顺序排序

除了可以排列数字外,SORT命令还可以通过ALPHA参数实现按照字典顺序排列非数字元素,就像这样:

127.0.0.1:6379> lpush lst2 c a b A CC DS
(integer) 6
# 不实用 ALPHA 参数会报错
127.0.0.1:6379> sort lst2
(error) ERR One or more scores can't be converted into double
127.0.0.1:6379>
127.0.0.1:6379> sort lst2 ALPHA
1) "A"
2) "CC"
3) "DS"
4) "a"
5) "b"
6) "c"

从大到小 后面加DESC参数

127.0.0.1:6379> rpush lst3 2 22 13 56
(integer) 4
127.0.0.1:6379> lrange lst3 0 -1
1) "2"
2) "22"
3) "13"
4) "56"
127.0.0.1:6379> sort lst3 DESC
1) "56"
2) "22"
3) "13"
4) "2"

分页参数 LIMIT offset count

那么如果文章数量过多需要分页显示呢?SORT命令还支持LIMIT参数来返回指定范围的结果。用法和SQL语句一样,LIMIT offset count,表示跳过前offset个元素并获取之后的count个元素。

127.0.0.1:6379> lrange lst3 0 -1
1) "2"
2) "22"
3) "13"
4) "56"

127.0.0.1:6379> sort lst3 DESC LIMIT 0 3
1) "56"
2) "22"
3) "13"

BY参数 ***

很多情况下列表(或集合、有序集合)中存储的元素值代表的是对象的ID(如标签集合中存储的是文章对象的ID),单纯对这些ID自身排序有时意义并不大。更多的时候我们希望根据ID对应的对象的某个属性进行排序。

回想3.6节,我们通过使用有序集合键来存储文章ID列表(分数是Unix时间,值是ID),使得小白的博客能够支持修改文章时间,所以文章ID的顺序和文章的发布时间的顺序并不完全一致,因此4.3.2节介绍的对文章ID本身排序就变得没有意义了。

小白的博客是使用散列类型键存储文章对象的,其中time字段存储的就是文章的发布时间。现在我们知道ID为“2”,“6”,“12”和“26”的四篇文章的time字段的值分别为“1352619200”,“1352619600”,“1352620100”和“1352620000”(Unix时间)。如果要按照文章的发布时间递减排列结果应为“12”,“26”,“6”,“2”。为了获得这样的结果,需要使用SORT命令的另一个强大的参数BY

BY参数的语法为“BY 参考键”。其中参考键可以是字符串类型键或者是散列类型键的某个字段(表示为键名->字段名)。如果提供了BY参数,SORT命令将不再依据元素自身的值进行排序,而是对每个元素使用元素的值替换参考键中的第一个“*”并获取其值,然后依据该值对元素排序.

在下例中SORT命令会读取post:2、post:6、post:12、post:26几个散列键中的time字段的值并以此决定tag:python:posts键中各个文章ID的顺序。

127.0.0.1:6379> hmset post:2 time 103
OK
127.0.0.1:6379> hmset post:6 time 101
OK
127.0.0.1:6379> hmset post:12 time 112
OK
127.0.0.1:6379> hmset post:26 time 123
OK

127.0.0.1:6379> rpush tag:python:posts 2 6 12 26
(integer) 4

### 按照散列类型的一个键来排序
127.0.0.1:6379> sort tag:python:posts BY post:*->time DESC
1) "26"
2) "12"
3) "2"
4) "6"

除了散列类型之外,参考键还可以是字符串类型,比如:

127.0.0.1:6379> lpush ssl 2 1 3
(integer) 3
127.0.0.1:6379> set t:1 50
OK
127.0.0.1:6379> set t:2 100
OK
127.0.0.1:6379> set t:3 -10
OK
127.0.0.1:6379> sort ssl BY t:* DESC
1) "2"
2) "1"
3) "3"
注意

当参考键名不包含“*”时(即常量键名,与元素值无关),SORT命令将不会执行排序操作,因为Redis认为这种情况是没有意义的(因为所有要比较的值都一样)。例如:

127.0.0.1:6379> rpush r1 12 2 56
(integer) 3
127.0.0.1:6379> sort r1 BY anytext
1) "12"
2) "2"
3) "56"

例子中anytext是常量键名(甚至anytext键可以不存在),此时SORT的结果与LRANGE的结果相同,没有执行排序操作。在不需要排序但需要借助SORT命令获得与元素相关联的数据时(见4.3.4节),常量键名是很有用的。

参考键的值相同时 比较元素本身的值决定顺序
127.0.0.1:6379> rpush sl1 4 2 1 3
(integer) 4

127.0.0.1:6379> set item:4 50
OK
127.0.0.1:6379> set item:1 50
OK
127.0.0.1:6379> set item:2 60
OK
127.0.0.1:6379> set item:3 70
OK

127.0.0.1:6379> sort sl1 BY item:* DESC
1) "3"
2) "2"
3) "4"
4) "1"

从上面的例子可以看出:item:1与item:4的值相同,当使用他们作为参考键时一样,会比较本身的值!

BY的补充知识

参考键虽然支持散列类型,但是“*”只能在“->”符号前面(即键名部分)才有用,在“->”后(即字段名部分)会被当成字段名本身而不会作为占位符被元素的值替换,即常量键名。但是实际运行时会发现一个有趣的结果:

redis> SORT sor1 BY somekey->somefield:*
1) "1"
2) "2"
3) "3"

上面提到了当参考键名是常量键名时SORT命令将不会执行排序操作,然而上例中确进行了排序,而且只是对元素本身进行排序。这是因为Redis判断参考键名是不是常量键名的方式是判断参考键名中是否包含“*”,而somekey->somefield:*中包含“*”所以不是常量键名。所以在排序的时候Redis对每个元素都会读取键somekey中的somefield:*字段(“*”不会被替换),无论能否获得其值,每个元素的参考键值是相同的,所以Redis会按照元素本身的大小排列。

GET参数

现在小白的博客已经可以按照文章的发布顺序获得一个标签下的文章ID列表了,接下来要做的事就是对每个ID都使用HGET命令获取文章的标题以显示在博客列表页中。有没有觉得很麻烦?不论你的答案如何,都有一种更简单的方式来完成这个操作,那就是借助SORT命令的GET参数。

GET参数不影响排序,它的作用是使SORT命令的返回结果不再是元素自身的值,而是GET参数中指定的键值。

GET参数的规则和BY参数一样,GET参数也支持字符串类型和散列类型的键,并使用“*”作为占位符。要实现在排序后直接返回ID对应的文章标题,可以这样写:

redis> SORT tag:ruby:posts BY post:*->time DESC GET post:*->title

1) title1
...

在一个SORT命令中可以有多个GET参数(而BY参数只能有一个),所以还可以这样用:

redis> SORT tag:ruby:posts BY post:*->time DESC GET post:*->title GET post:*->time

1) title1
2) time1
....

这时有个问题:如果还需要返回文章ID该怎么办?答案是使用GET #。就像下面这样:

redis> SORT tag:ruby:posts BY post:*->time DESC GET post:*->title GET post:*->time GET #

1) title1
2) time1
3) id1
....

也就是说,GET # 会返回元素本身的值。

STORE参数存储结果

默认情况下SORT会直接返回排序结果,如果希望保存排序结果,可以使用STORE参数。如希望把结果保存到sort.result键中:

127.0.0.1:6379> sort sl1 BY item:* DESC STORE sort.result
(integer) 4

127.0.0.1:6379> lrange sort.result 0 -1
1) "3"
2) "2"
3) "4"
4) "1"

保存后的键的类型为列表类型,如果键已经存在则会覆盖它。加上STORE参数后SORT命令的返回值为结果的个数。

STORE参数常用来结合EXPIRE命令缓存排序结果,如下面的伪代码:

# 判断是否存在之前排序结果的缓存
is_cacne_exists = EXISTS cache.sort
if is_cache_exists == 1:
    # 如果存在直接返回
    return LRANGE cache.sort 0 -1
# 不存在 则使用SORT命令排序并将结果存入cache.sort键中作为缓存
else:
    # 注意这里的sort_result为结果数量
    sort_result = SORT some.lst STORE cache.sort
    # 设置缓存时间为10分
    EXPIRE cache.sort 600
    # 返回排序结果的数量	
    return LRANGE cache.sort 0 -1

性能优化

SORT是Redis中最强大最复杂的命令之一,如果使用不好很容易成为性能瓶颈。SORT命令的时间复杂度是O(n+mlogm),其中n表示要排序的列表(集合或有序集合)中的元素个数,m表示要返回的元素个数。当n较大的时候SORT命令的性能相对较低,并且Redis在排序前会建立一个长度为n的容器来存储待排序的元素,虽然是一个临时的过程,但如果同时进行较多的大数据量排序操作则会严重影响性能。

所以开发中使用SORT命令时需要注意以下几点:
(1)尽可能减少待排序键中元素的数量(使n尽可能小)。
(2)使用LIMIT参数只获取需要的数据(使m尽可能小)。
(3)如果要排序的数据数量较大,尽可能使用STORE参数将结果缓存。

消息通知--任务队列

邮件订阅功能太好实现了,无非是在博客首页放一个文本框供访客输入自己的邮箱地址,提交后博客会将该地址存入Redis的一个集合类型键中(使用集合类型是为了保证同一邮箱地址不会存储多个)。每当发布新文章时,就向收集到的邮箱地址发送通知邮件。想的简单,可是做出来后小白却发现了一个问题:输入邮箱地址提交后,页面需要很久时间才能载入完。原来小白为了确保用户没有输入他人的邮箱,在提交之后程序会向用户输入的邮箱发送一封包含确认链接的邮件,只有用户单击这个链接后对应的邮箱地址才会被程序记录。可是由于发送邮件需要连接到一个远程的邮件发送服务器,网络好的情况下也得花上2秒左右的时间,赶上网络不好10秒都未必能发完。所以每次用户提交邮箱后页面都要等待程序发送完邮件才能加载出来,而加载出来的页面上显示的内容只是提示用户查看自己的邮箱单击确认链接。“完全可以等页面加载出来后再发送邮件,这样用户就不需要等了。”小白喃喃道。

任务队列

当页面需要进行如发送邮件、复杂数据运算等耗时较长的操作时会阻塞页面的渲染。为了避免用户等待太久,应该使用独立的线程来完成这类操作。不过一些编程语言或框架不易实现多线程,这时很容易就会想到通过其他进程来实现。就小白的例子来说,设想有一个进程能够完成发邮件的功能,那么在页面中只需要想办法通知这个进程向指定的地址发送邮件就可以了。

通知的过程可以借助任务队列来实现。任务队列顾名思义,就是“传递任务的队列”。与任务队列进行交互的实体有两类,一类是生产者(producer),一类是消费者(consumer)。生产者会将需要处理的任务放入任务队列中,而消费者则不断地从任务队列中读入任务信息并执行。

对于发邮件这个操作来说页面程序就是生产者,而发邮件的进程就是消费者。当需要发送邮件时,页面程序会将收件地址、邮件主题和邮件正文组装成一个任务后存入任务队列中。同时发邮件的进程会不断检查任务队列,一旦发现有新的任务便会将其从队列中取出并执行。由此实现了进程间的通信。

使用任务队列有如下好处。
(1)松耦合。生产者和消费者无需知道彼此的实现细节,只需要约定好任务的描述格式。这使得生产者和消费者可以由不同的团队使用不同的编程语言编写。
(2)易于扩展消费者可以有多个,而且可以分布在不同的服务器中,如图4-1所示。借此可以轻易地降低单台服务器的负载。

19f600efe790d191dca25f5fcf4982b4.png

使用Redis实现任务队列

说到队列很自然就能想到Redis的列表类型,3.4.2节介绍了使用LPUSH和RPOP命令实现队列的概念。如果要实现任务队列,只需要让生产者将任务使用LPUSH命令加入到某个键中,另一边让消费者不断地使用RPOP命令从该键中取出任务即可。

在小白的例子中,完成发邮件的任务需要知道收件地址、邮件主题和邮件正文。所以生产者需要将这三个信息组成对象并序列化成字符串,然后将其加入到任务队列中。而消费者则循环从队列中拉取任务。

到此一个使用Redis实现的简单的任务队列就写好了。不过还有一点不完美的地方:当任务队列中没有任务时消费者每秒都会调用一次RPOP命令查看是否有新任务。如果可以实现一旦有新任务加入任务队列就通知消费者就好了。其实借助BRPOP命令就可以实现这样的需求。

BRPOP命令和RPOP命令相似,唯一的区别是当列表中没有元素时BRPOP命令会一直阻塞住连接,直到有新元素加入。

BRPOP命令接收两个参数,第一个是键名,第二个是超时时间,单位是秒。当超过了此时间仍然没有获得新元素的话就会返回nil。上例中超时时间为“0”,表示不限制等待的时间,即如果没有新元素加入列表就会永远阻塞下去。

优先级队列

前面说到了小白博客需要在发布文章的时候向每个订阅者发送邮件,这一步骤同样可以使用任务队列实现。由于要执行的任务和发送确认邮件一样,所以二者可以共用一个消费者。然而设想这样的情况:假设订阅小白博客的用户有1000人,那么当发布一篇新文章后博客就会向任务队列中添加1000个发送通知邮件的任务。如果每发一封邮件需要10秒,全部完成这1000个任务就需要近3个小时。问题来了,假如这期间有新的用户想要订阅小白博客,当他提交完自己的邮箱并看到网页提示他查收确认邮件时,他并不知道向自己发送确认邮件的任务被加入到了已经有1000个任务的队列中。要收到确认邮件,他不得不等待近3个小时。多么糟糕的用户体验!而另一方面发布新文章后通知订阅用户的任务并不是很紧急,大多数用户并不要求有新文章后马上就能收到通知邮件,甚至延迟一天的时间在很多情况下也是可以接受的。

所以可以得出结论当发送确认邮件和发送通知邮件两种任务同时存在时,应该优先执行前者。为了实现这一目的,我们需要实现一个优先级队列。

(略) —— 详见 4.4.3 中的说明。

"发布/订阅"模式

除了实现任务队列外,Redis还提供了一组命令可以让开发者实现“发布/订阅”(publish/subscribe)模式。“发布/订阅”模式同样可以实现进程间的消息传递,其原理是这样的:

“发布/订阅”模式中包含两种角色,分别是发布者和订阅者。订阅者可以订阅一个或若干个频道(channel),而发布者可以向指定的频道发送消息,所有订阅此频道的订阅者都会收到此消息。

发布者发布消息的命令是PUBLISH,用法是PUBLISH channel message,如向channel.1说一声“hi”:

redis> PUBLISH channel.1 hi
(integer) 0

这样消息就发出去了。PUBLISH命令的返回值表示接收到这条消息的订阅者数量。因为此时没有客户端订阅channel.1,所以返回0。发出去的消息不会被持久化,也就是说当有客户端订阅channel.1后只能收到后续发布到该频道的消息,之前发送的就收不到了。

订阅频道的命令是SUBSCRIBE,可以同时订阅多个频道,用法是SUBSCRIBE channel [channel …]。现在新开一个redis-cli实例A,用它来订阅channel.1:

redis A> SUBSCRIBE chennel.1
Reading messages ... (press Ctrl-C to quit)
1) "xxx"
...

执行SUBSCRIBE命令后客户端会进入订阅状态,处于此状态下客户端不能使用除SUBSCRIBE/UNSUBSCRIBE/PSUBSCRIBE/PUNSUBSCRIBE这4个属于“发布/订阅”模式的命令之外的命令(后面3个命令会在下面介绍),否则会报错。

进入订阅状态后客户端可能收到三种类型的回复。每种类型的回复都包含3个值,第一个值是消息的类型,根据消息类型的不同,第二、三个值的含义也不同。消息类型可能的取值有:

(1)Subscribe。表示订阅成功的反馈信息。第二个值是订阅成功的频道名称,第三个值是当前客户端订阅的频道数量。

(2)message。这个类型的回复是我们最关心的,它表示接收到的消息。第二个值表示产生消息的频道名称,第三个值是消息的内容。

(3)unsubscribe。表示成功取消订阅某个频道。第二个值是对应的频道名称,第三个值是当前客户端订阅的频道数量,当此值为0时客户端会退出订阅状态,之后就可以执行其他非“发布/订阅”模式的命令了。

按照规则订阅

(略)

管道

客户端和Redis使用TCP协议连接。不论是客户端向Redis发送命令还是Redis向客户端返回命令的执行结果,都需要经过网络传输,这两个部分的总耗时称为往返时延。根据网络性能不同,往返时延也不同,大致来说到本地回环地址(loop backaddress)的往返时延在数量级上相当于Redis处理一条简单命令(如LPUSH list 12 3)的时间。如果执行较多的命令,每个命令的往返时延累加起来对性能还是有一定影响的。

在执行多个命令时每条命令都需要等待上一条命令执行完(即收到Redis的返回结果)才能执行,即使命令不需要上一条命令的执行结果。如要获得post:1、post:2和post:3这3个键中的title字段,需要执行三条命令,如图

399e7cb120ec7b8ddffaa847255091ac.png

Redis的底层通信协议对管道(pipelining)提供了支持。通过管道可以一次性发送多条命令并在执行完后一次性将结果返回,当一组命令中每条命令都不依赖于之前命令的执行结果时就可以将这组命令一起通过管道发出。管道通过减少客户端与Redis的通信次数来实现降低往返时延累计值的目的,如图

9275455fa11030595d48587b0c849d14.png

节省空间

Jim Gray曾经说过:“内存是新的硬盘,硬盘是新的磁带。”内存的容量越来越大,价格也越来越便宜。

精简键名与键值

精简键名和键值是最直观的减少内存占用的方式,如将键名very.important.person:20改成VIP:20。当然精简键名一定要把握好尺度,不能单纯为了节约空间而使用不易理解的键名(比如将VIP:20修改为V:20,这样既不易维护,还容易造成命名冲突)。又比如一个存储用户性别的字符串类型键的取值是male和female,我们可以将其修改成m和f来为每条记录节约几个字节的空间(更好的方法是使用0和1来表示性别)

内部编码优化

(略) —— 详键书中说明。

Redis实践

Python操作Redis

个人总结合集

个人总结合集

书中内容

具体见书中的实例:在线好友

脚本

Lua与Redis

(略)

管理

Redis持久化

Redis的强劲性能很大程度上是由于其将所有数据都存储在了内存中,为了使Redis在重启之后仍能保证数据不丢失,需要将数据从内存中以某种形式同步到硬盘中,这一过程就是持久化。Redis支持两种方式的持久化,一种是RDB方式,一种是AOF方式。可以单独使用其中一种或将二者结合使用。

RDB方式

RDB方式的持久化是通过快照(snapshotting)完成的,当符合一定条件时Redis会自动将内存中的所有数据进行快照并存储在硬盘上。进行快照的条件可以由用户在配置文件中自定义,由两个参数构成:时间改动的键的个数。当在指定的时间内被更改的键的个数大于指定的数值时就会进行快照。RDB是Redis默认采用的持久化方式,在配置文件中已经预置了3个条件:

save 900 1
save 300 10
save 6010000

save参数指定了快照条件,可以存在多个条件,条件之间是“或”的关系。如上所说,save 900 1的意思是在15分钟(900秒钟)内有至少一个键被更改则进行快照。如果想要禁用自动快照,只需要将所有的save参数删除即可。

Redis默认会将快照文件存储在当前目录的dump.rdb文件中,可以通过配置dirdbfilename两个参数分别指定快照文件的存储路径和文件名。

理清Redis实现快照的过程对我们了解快照文件的特性有很大的帮助。

快照的过程如下:

(1)Redis使用fork函数复制一份当前进程(父进程)的副本(子进程);

(2)父进程继续接收并处理客户端发来的命令,而子进程开始将内存中的数据写入硬盘中的临时文件;

(3)当子进程写入完所有数据后会用该临时文件替换旧的RDB文件,至此一次快照操作完成。

<详细解释见书中讲解>

除了自动快照,还可以手动发送SAVE或BGSAVE命令让Redis执行快照,两个命令的区别在于,前者是由主进程进行快照操作,会阻塞住其他请求,后者会通过fork子进程进行快照操作。

AOF方式

默认情况下Redis没有开启AOF(append only file)方式的持久化,可以通过appendonly参数开启:

appendonly yes

开启AOF持久化后每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入硬盘中的AOF文件。AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的,默认的文件名是appendonly.aof,可以通过appendfilename参数修改:

appendfilename my_aof.aof
下面讲解AOF持久化的具体实现

假设在开启AOF持久化的情况下执行了如下4个命令:

set foo 1
set foo 2
set foo 3
get foo

Redis会将前3条命令写入AOF文件中,此时AOF文件中的内容如下:

xxx

可见AOF文件是纯文本文件,其内容正是Redis客户端向Redis发送的原始通信协议的内容(Redis的通信协议会在7.4节中介绍,为了便于阅读,这里将实际的命令部分以粗体显示),从中可见Redis确实只记录了前3条命令。

然而这时有一个问题是前2条命令其实都是冗余的,因为这两条的执行结果会被第三条命令覆盖。随着执行的命令越来越多,AOF文件的大小也会越来越大,即使内存中实际的数据可能并没有多少。

很自然地,我们希望Redis可以自动优化AOF文件,就上例而言,就是将前两条无用的记录删除,只保留第三条。实际上Redis也正是这样做的,每当达到一定条件时Redis就会自动重写AOF文件,这个条件可以在配置文件中设置:

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

auto-aof-rewrite-percentage参数的意义是当目前的AOF文件大小超过上一次重写时的AOF文件大小的百分之多少时会再次进行重写,如果之前没有重写过,则以启动时的AOF文件大小为依据。

auto-aof-rewrite-min-size参数限制了允许重写的最小AOF文件大小,通常在AOF文件很小的情况下即使其中有很多冗余的命令我们也并不太关心。除了让Redis自动执行重写外,我们还可以主动使用BGREWRITEAOF命令手动执行AOF重写。

可见冗余的命令已经被删除了。重写的过程只和内存中的数据有关,和之前的AOF文件无关,这与RDB很相似,只不过二者的文件格式完全不同。

在启动时Redis会逐个执行AOF文件中的命令来将硬盘中的数据载入到内存中,载入的速度相较RDB会慢一些。

需要注意的是虽然每次执行更改数据库内容的操作时,AOF都会将命令记录在AOF文件中,但是事实上,由于操作系统的缓存机制,数据并没有真正地写入硬盘,而是进入了系统的硬盘缓存。在默认情况下系统每30秒会执行一次同步操作,以便将硬盘缓存中的内容真正地写入硬盘,在这30秒的过程中如果系统异常退出则会导致硬盘缓存中的数据丢失。一般来讲启用AOF持久化的应用都无法容忍这样的损失,这就需要Redis在写入AOF文件后主动要求系统将缓存内容同步到硬盘中。在Redis中我们可以通过appendfsync参数设置同步的时机:

# appendfsync always
appendfsync everysec
# appendfdync no

默认情况下Redis采用everysec规则,即每秒执行一次同步操作。always表示每次执行写入都会执行同步,这是最安全也是最慢的方式。no表示不主动进行同步操作,而是完全交由操作系统来做(即每30秒一次),这是最快但最不安全的方式。一般情况下使用默认值everysec就足够了,既兼顾了性能又保证了安全。

Redis允许同时开启AOF和RDB,既保证了数据安全又使得进行备份等操作十分容易。此时重新启动Redis后Redis会使用AOF文件来恢复数据,因为AOF方式的持久化可能丢失的数据更少。

Redis主从复制

(配置与原理详细见书中介绍————运维相关)

Redis安全

可信的环境、数据库密码、重命名命令(比如重命名flushall命令)

Redia通信协议

(略)

Redis管理工具

Mac中redis的安装配置及图形化工具的下载与使用

posted on 2020-07-24 14:49  江湖乄夜雨  阅读(327)  评论(0编辑  收藏  举报