SCAN:以渐进方式迭代数据库中的键
因为KEYS命令需要检查数据库包含的所有键,并一次性将符合条件的所有键全部返回给客户端,所以当数据库包含的键数量比较大时,使用KEYS命令可能会导致服务器被阻塞。
为了解决这个问题,Redis从2.8.0版本开始提供SCAN命令,该命令是一个迭代器,它每次被调用的时候都会从数据库中获取一部分键,用户可以通过重复调用SCAN命令来迭代数据库包含的所有键:
SCAN cursor
SCAN命令的cursor参数用于指定迭代时使用的游标,游标记录了迭代的轨迹和进度。在开始一次新的迭代时,用户需要将游标设置为0:
SCAN 0
SCAN命令的执行结果由两个元素组成:
-
第一个元素是进行下一次迭代所需的游标,如果这个游标为0,那么说明客户端已经对数据库完成了一次完整的迭代。
-
第二个元素是一个列表,这个列表包含了本次迭代取得的数据库键;如果SCAN命令在某次迭代中没有获取到任何键,那么这个元素将是一个空列表。
关于SCAN命令返回的键列表,有两点需要注意:
-
SCAN命令可能会返回重复的键,用户如果不想在结果中包含重复的键,那么就需要自己在客户端中进行检测和过滤。
-
SCAN命令返回的键数量是不确定的,有时甚至会不返回任何键,但只要命令返回的游标不为0,迭代就没有结束。
一次简单的迭代示例
在对SCAN命令有了基本的了解之后,让我们来试试使用SCAN命令去完整地迭代一个数据库。
为了开始一次新的迭代,我们将以0作为游标,调用SCAN命令:
redis> SCAN 0
1) "25" -- 进行下次迭代的游标
2) 1) "key::16" -- 本次迭代获取到的键
2) "key::2"
3) "key::6"
4) "key::8"
5) "key::13"
6) "key::22"
7) "key::10"
8) "key::24"
9) "key::23"
10) "key::21"
11) "key::5"
这个SCAN调用告知我们下次迭代应该使用25作为游标,并返回了11个键的键名。
为了继续对数据库进行迭代,我们使用25作为游标,再次调用SCAN命令:
redis> SCAN 25
1) "31"
2) 1) "key::20"
2) "key::18"
3) "key::19"
4) "key::7"
5) "key::1"
6) "key::9"
7) "key::12"
8) "key::11"
9) "key::17"
10) "key::15"
11) "key::14"
12) "key::3"
这次的SCAN调用返回了12个键,并告知我们下次迭代应该使用31作为游标。
与之前的情况类似,这次我们使用31作为游标,再次调用SCAN命令:
redis> SCAN 31
1) "0"
2) 1) "key::0"
2) "key::4"
这次的SCAN调用只返回了两个键,并且它返回的下次迭代游标为0,这 说明本次迭代已经结束,整个数据库已经被迭代完毕。
SCAN命令的迭代保证
针对数据库的一次完整迭代(full iteration)以用户给定游标0调用 SCAN命令开始,直到SCAN命令返回游标0结束。SCAN命令为完整迭代提供以下保证:
-
从迭代开始到迭代结束的整个过程中,一直存在于数据库中的键总会被返回。
-
如果一个键在迭代的过程中被添加到数据库中,那么这个键是否会被返回是不确定的。
-
如果一个键在迭代的过程中被移除了,那么SCAN命令在它被移除之后将不再返回这个键,但是这个键在被移除之前仍然有可能被SCAN命令返回。
-
无论数据库如何变化,迭代总是有始有终的,不会出现循环迭代或者其他无法终止迭代的情况。
游标的使用
在很多数据库中,使用游标都要显式地申请,并在迭代完成之后释放游标,否则就会造成内存泄漏。
与此相反,SCAN命令的游标不需要申请,也不需要释放,它们不占用任何资源,每个客户端都可以使用自己的游标独立地对数据库进行迭代。
此外,用户可以随时在迭代的过程中停止迭代,或者随时开始一次新的迭代,这不会浪费任何资源,也不会引发任何问题。
迭代与给定匹配符相匹配的键
在默认情况下,SCAN 命令会向客户端返回数据库包含的所有键,它就像 KEYS* 命令调用的一个迭代版本。但是通过使用可选的MATCH选项,我们同样可以让SCAN命令只返回与给定全局匹配符相匹配的键:
SCAN cursor [MATCH pattern]
带有MATCH选项的SCAN命令就像是KEYS pattern命令调用的迭代版本。
举个例子,假设我们想要获取数据库中所有以 user::
开头的键,但是因为这些键的数量比较多,直接使用 KEYS user::*
有可能会造成服务器阻塞,所以我们可以使用SCAN命令来代替KEYS命令,对符合 user::*
匹配符的键进行迭代:
redis> SCAN 0 MATCH user::*
1) "208"
2) 1) "user::1"
2) "user::65"
3) "user::99"
4) "user::51"
redis> SCAN 208 MATCH user::*
1) "232"
2) 1) "user::13"
2) "user::28"
3) "user::83"
4) "user::14"
5) "user::61"
-- 省略后续的其他迭代……
指定返回键的期望数量
一般情况下,SCAN命令返回的键数量是不确定的,但是我们可以通过使用可选的COUNT选项,向SCAN命令提供一个期望值,以此来说明我们希望得到多少个键:
SCAN cursor[COUNT number]
这里需要特别注意的是,COUNT选项向命令提供的只是期望的键数量,但并不是精确的键数量。比如,执行SCAN cursor COUNT 10并不意味着SCAN命令最多只能返回10个键,或者一定要返回10个键:
-
COUNT选项只是提供了一个期望值,告诉SCAN命令我们希望返回多少个键,但每次迭代返回的键数量仍然是不确定的。
-
不过在通常情况下,设置一个较大的COUNT值将有助于获得更多键,这一点是可以肯定的。
以下代码展示了几个使用 COUNT 选项的例子:
redis> SCAN 0 COUNT 5
1) "160"
2) 1) "key::43"
2) "key::s"
3) "user::1"
4) "key::83"
5) "key::u"
redis> SCAN 0 MATCH user::* COUNT 10
1) "208"
2) 1) "user::1"
2) "user::65"
3) "user::99"
4) "user::51"
redis> SCAN 0 MATCH key::* COUNT 100
1) "214"
2) 1) "key::43"
2) "key::s"
3) "key::83"
-- 其他键……
50) "key::28"
51) "key::34"
在用户没有显式地使用 COUNT 选项的情况下,SCAN 命令将使用 10 作为 COUNT 选项的默认值,换句话说,以下两条命令的作用是相同的:
SCAN cursor
SCAN cursor COUNT 10
数据结构迭代命令
与获取数据库键的KEYS命令一样,Redis的各个数据结构也存在一些可能导致服务器阻塞的命令:
-
散列的HKEYS命令、HVALS命令和HGETALL命令在处理包含键值对较多的散列时,可能会导致服务器阻塞。
-
集合的SMEMBERS命令在处理包含元素较多的集合时,可能会导致服务器阻塞。
-
有序集合的一些范围型获取命令,比如ZRANGE,也有阻塞服务器的可能。比如,为了获取有序集合包含的所有元素,用户可能会执行命令调用ZRANGE key 0-1,这时如果有序集合包含的成员数量较多,那么这个ZRANGE命令就可能会导致服务器阻塞。
为了解决以上问题,Redis为散列、集合和有序集合也提供了与SCAN命令类似的游标迭代命令,分别是HSCAN命令、SSCAN命令和ZSCAN命令,下面将分别介绍这3个命令的用法。
-
散列迭代命令
HSCAN命令可以以渐进的方式迭代给定散列包含的键值对:
HSCAN hash cursor [MATCH pattern] [COUNT number]
除了需要指定被迭代的散列之外,HSCAN 命令的其他参数与 SCAN 命令的参数保持一致,并且作用也一样。
作为例子,以下代码展示了如何使用 HSCAN 命令去迭代 user::10086::profile 散列:
redis> HSCAN user::10086::profile 0
1) "0" -- 下次迭代的游标
2) 1) "name" -- 键
2) "peter" -- 值
3) "age"
4) "32"
5) "gender"
6) "male"
7) "blog"
8) "peter123.whatpress.com"
9) "email"
10) "peter123@example.com"
当散列包含较多键值对的时候,应该尽量使用HSCAN代替HKEYS、HVALS 和HGETALL,以免造成服务器阻塞。
-
渐进式集合迭代命令 SSCAN命令可以以渐进的方式迭代给定集合包含的元素:
SSCAN set cursor [MATCH pattern] [COUNT number]
除了需要指定被迭代的集合之外,SSCAN命令的其他参数与SCAN命令的参数保持一致,并且作用也一样。
举个例子,假设我们想要对fruits集合进行迭代,那么可以执行以下命令:
redis> SSCAN fruits 0
1) "0" -- 下次迭代的游标
2) 1) "apple" -- 集合元素
2) "watermelon"
3) "mango"
4) "cherry"
5) "banana"
6) "dragon fruit"
当集合包含较多元素的时候,我们应该尽量使用SSCAN代替SMEMBERS,以免造成服务器阻塞。
-
渐进式有序集合迭代命令 ZSCAN命令可以以渐进的方式迭代给定有序集合包含的成员和分值:
ZSCAN sorted_set cursor [MATCH pattern] [COUNT number]
除了需要指定被迭代的有序集合之外,ZSCAN命令的其他参数与SCAN命令的参数保持一致,并且作用也一样。
比如,通过执行以下命令,我们可以对fruits-price有序集合进行迭代
redis> ZSCAN fruits-price 0
1) "0" -- 下次迭代的游标
2) 1) "watermelon" -- 成员
2) "3.5" -- 分值
3) "banana"
4) "4.5"
5) "mango"
6) "5"
7) "dragon fruit"
8) "6"
9) "cherry"
10) "7"
11) "apple"
12) "8.5"
当有序集合包含较多成员的时候,我们应该尽量使用ZSCAN去代替 ZRANGE以及其他可能会返回大量成员的范围型获取命令,以免造成服务器阻塞。
-
迭代命令的共通性质
HSCAN、SSCAN、ZSCAN这3个命令除了与SCAN命令拥有相同的游标参数以及可选项之外,还与SCAN命令拥有相同的迭代性质:
-
SCAN命令对于完整迭代所做的保证,其他3个迭代命令也能够提供。比如,使用HSCAN命令对散列进行一次完整迭代,在迭代过程中一直存在的键值对总会被返回,诸如此类。
-
与SCAN命令一样,其他3个迭代命令的游标也不耗费任何资源。用户可以在这3个命令中随意地使用游标,比如随时开始一次新的迭代,又或者随时放弃正在进行的迭代,这不会浪费任何资源,也不会引发任何问题。
-
与SCAN命令一样,其他3个迭代命令虽然也可以使用COUNT选项设置返回元素数量的期望值,但命令具体返回的元素数量仍然是不确定的
其他信息
-
复杂度:SCAN命令、HSCAN命令、SSCAN命令和ZSCAN命令单次执行的复杂度为O(1),而使用这些命令进行一次完整迭代的复杂度则为 O(N),其中N为被迭代的元素数量。
-
版本要求:SCAN命令、HSCAN命令、SSCAN命令和ZSCAN命令从Redis 2.8.0版本开始可用。
示例:构建数据库迭代器
SCAN命令虽然可以以迭代的形式访问数据库,但它使用起来并不是很方便,比如:
-
SCAN命令每次迭代都会返回一个游标,而用户需要手动地将这个游标用作下次迭代时的输入参数,如果用户不小心丢失或者弄错了这个游标,那么就可能会给迭代带来错误或者麻烦。
-
SCAN命令每次都会返回一个包含两个元素的结果,其中第一个元素为游标,而第二个元素才是当前被迭代的键,如果迭代器能够直接返回被迭代的键,那么它使用起来就会更加方便。
为了解决以上这两个问题,我们可以在SCAN命令的基础上进行一些修改,实现代码清单11-1所示的迭代器:这个迭代器不仅会自动记录每次迭代的游标以防丢失,还可以直接返回被迭代的数据库键以供用户使用。
class DbIterator:
def __init__(self, client, match=None, count=None):
"""
创建一个新的迭代器。
可选的 match 参数用于指定迭代的匹配模式,
而可选的 count 参数则用于指定我们期待每次迭代能够返回的键数量。
"""
self.client = client
self.match = match
self.count = count
# 当前迭代游标
self.current_cursor = 0
# 记录迭代是否已经完成的状态变量
self.iteration_is_over = False
def next(self):
"""
以列表形式返回当前被迭代到的数据库键,
返回 None 则表示本次迭代已经完成。
"""
if self.iteration_is_over:
return None
# 获取下次迭代的游标以及当前被迭代的数据库键
next_cursor, keys = self.client.scan(self.current_cursor, self.match, self.count)
# 如果下次迭代的游标为 0 ,那么表示迭代已完成
if next_cursor == 0:
self.iteration_is_over = True
# 更新游标
self.current_cursor = next_cursor
# 返回当前被迭代的数据库键
return keys
作为例子,以下代码展示了如何使用这个迭代器去迭代一个数据库:
>>> from redis import Redis
>>> from db_iterator import DbIterator
>>> client = Redis(decode_responses=True)
>>> for i in range(50): # 向数据库插入50个键
... key = "key{0}".format(i)
... value = i
... client.set(key, value)
...
True
True
...
True
>>> iterator = DbIterator(client)
>>> iterator.next() # 开始迭代
['key46', 'key1', 'key27', 'key39', 'key15', 'key0', 'key43', 'key12', 'key49', 'key41', 'key10'
]
>>> iterator.next()
['key23', 'key7', 'key9', 'key20', 'key18', 'key3', 'key5', 'key34', 'key32', 'key40']
>>> iterator.next()
['key4', 'key33', 'key30', 'key45', 'key38', 'key31', 'key6', 'key16', 'key25', 'key14', 'key13'
]
>>> iterator.next()
['key29', 'key2', 'key42', 'key11', 'key48', 'key28', 'key8', 'key44', 'key21', 'key26']
>>> iterator.next()
['key22', 'key47', 'key36', 'key17', 'key19', 'key24', 'key35', 'key37']
>>> iterator.next() # 迭代结束
>>>
redis-py提供的迭代器
实际上,redis-py 客户端也为 SCAN 命令实现了一个迭代器——用户只需要调用redis-py的scan_iter()方法,就会得到一个Python迭代器,然后就可以通过这个迭代器对数据库中的键进行迭代:
scan_iter(self, match=None, count=None) unbound redis.client.Redis method
Make an iterator using the SCAN command so that the client doesn't
need to remember the cursor position.
"match" allows for filtering the keys by pattern
"count" allows for hint the minimum number of returns
redis-py提供的迭代器跟DbIterator一样,都可以让用户免去手动输入游标的麻烦,但它们之间也有很多区别:
-
edis-py的迭代器每次迭代只返回一个元素。
-
因为redis-py的迭代器是通过Python的迭代器特性实现的,所以用户可以直接以for key in redis.scan_iter()的形式进行迭代(DbIterator实际上也可以实现这样的特性,但是由于Python迭代器的相关知识并不在本书的介绍范围之内,所以我们这个自制的迭代器才没有配备这一特性。)
-
redis-py的迭代器也拥有next()方法,但这个方法每次被调用时只会返回单个元素,并且它在所有元素都被迭代完毕时将抛出一个StopIteration异常。
以下是一个redis-py迭代器的使用示例:
>>> from redis import Redis
>>> client = Redis(decode_responses=True)
>>> client.mset({"k1":"v1", "k2":"v2", "k3":"v3"})
True
>>> for key in client.scan_iter():
... print(key)
...
k1
k3
k2
因为redis-py为scan_iter()提供了直接支持,它比需要额外引入的 DbIterator更方便,所以本书之后展示的所有迭代程序都将使用 scan_iter()而不是DbIterator。不过由于这两个迭代器的底层实现是相仿的,所以使用哪个差别并不大。