大家好,我是腾意。

Redis的位图(bitmap)是由多个二进制位组成的数组,数组中的每个二进制位都有与之对应的偏移量(也称索引),用户通过这些偏移量可以对位图中指定的一个或多个二进制位进行操作。

图8-1展示了一个包含8个二进制位的位图示例,这个位图存储的值为10010100。

img

图8-1 位图示例

Redis为位图提供了一系列操作命令,通过这些命令,用户可以:

  • 为位图指定偏移量上的二进制位设置值,或者获取位图指定偏移量上的二进制位的值。

  • 统计位图中有多少个二进制位被设置成了1。

  • 查找位图中第一个被设置为指定值的二进制位并返回它的偏移量。

  • 对一个或多个位图执行逻辑并、逻辑或、逻辑异或以及逻辑非运算。

  • 将指定类型的整数存储到位图中。

接下来将对以上提到的各个位图命令进行介绍,并展示如何使用位图去实现用户行为记录器、0-1矩阵存储程序以及能够有效地节约内存的整数计数器。

1.1 SETBIT:设置二进制位的值

通过使用SETBIT命令,用户可以为位图指定偏移量上的二进制位设置值:

SETBIT bitmap offset value

SETBIT命令在对二进制位进行设置之后,将返回二进制位被设置之前的旧值作为结果。

举个例子,如果我们想要将位图bitmap001的值设置成10010100,那么可以执行以下3个命令:

redis> SETBIT bitmap001 0 1
(integer) 0    -- 二进制位原来的值为0

redis> SETBIT bitmap001 3 1
(integer) 0

redis> SETBIT bitmap001 5 1
(integer) 0

图8-2展示了以上3个命令的执行过程。

img

图8-2 SETBIT命令对位图的修改过程

1.1.1 位图的扩展

当用户执行SETBIT命令尝试对一个位图进行设置的时候,如果位图不存在,或者位图当前的大小无法满足用户想要执行的设置操作,那么Redis将对被设置的位图进行扩展,使得位图可以满足用户的设置请求。

因为Redis对位图的扩展操作是以字节为单位进行的,所以扩展之后的位图包含的二进制位数量可能会比用户要求的稍微多一些,并且在扩展位图的同时,Redis还会将所有未被设置的二进制位的值初始化为0。

比如,如果用户执行以下命令,对尚未存在的位图bitmap002在偏移量10之上的二进制位进行设置:

redis> SETBIT bitmap002 10 1
(integer) 0

那么Redis创建出的位图并不会只有11个二进制位,而是有两个字节共16个二进制位,如图8-3所示。

img

图8-3 包含16个二进制位的位图bitmap002

从这个图我们也可以看到,除了偏移量为10的二进制位之外,其他所有未被设置的二进制位都被初始化成了0。

1.1.2 偏移量只能为正数

与一些Redis命令可以使用负数作为偏移量的做法不同,SETBIT命令只能使用正数偏移量,尝试输入负数作为偏移量将引发一个错误:

redis> SETBIT bitmap001 -1 1
(error) ERR bit offset is not an integer or out of range

1.1.3 其他信息

复杂度:O(1)。

版本要求:SETBIT命令从Redis 2.2.0版本开始可用。

1.2 GETBIT:获取二进制位的值

使用GETBIT命令,用户可以获取位图指定偏移量上的二进制位的值:

GETBIT bitmap offset

与SETBIT命令一样,GETBIT命令也只能接受正数作为偏移量。

举个例子,对于值为10010100的位图bitmap001来说,可以通过执行以下命令,分别获取它在偏移量0、偏移量3、偏移量5以及偏移量7上的二进制位的值:

redis> GETBIT bitmap001 0
(integer) 1

redis> GETBIT bitmap001 3
(integer) 1

redis> GETBIT bitmap001 5
(integer) 1

redis> GETBIT bitmap001 7
(integer) 0

图8-4展示了这4个GETBIT命令对bitmap001进行取值的过程。

img

图8-4 GETBIT命令的执行过程

1.2.1 处理范围之外的偏移量

如果用户输入的偏移量超过了位图目前拥有的最大偏移量,那么GETBIT命令将返回0作为结果:

redis> GETBIT bitmap001 100    # bitmap001只包含8个二进制位
(integer) 0                    # 100不在它的有效偏移量范围之内

换句话说,GETBIT命令会把位图中所有不存在的二进制位的值都看作0。

1.2.2 其他信息

复杂度:O(1)。

版本要求:GETBIT命令从Redis 2.2.0版本开始可用。

1.3 BITCOUNT:统计被设置的二进制位数量

用户可以通过执行BITCOUNT命令统计位图中值为1的二进制位数量:

BITCOUNT key

比如,对于值为10010100的位图bitmap001,可以通过执行以下命令来统计它有多少个二进制位被设置成了1:

redis> BITCOUNT bitmap001
(integer) 3    -- 这个位图有3个二进制位被设置成了1

而对于值为0000000000100000的位图bitmap002,可以通过执行以下命令来统计它有多少个二进制位被设置成了1:

redis> BITCOUNT bitmap002
(integer) 1    -- 这个位图只有1个二进制位被设置成了1

1.3.1 只统计位图指定字节范围内的二进制位

在默认情况下,BITCOUNT命令将对位图包含的所有字节中的二进制位进行统计,但在有需要的情况下,用户也可以通过可选的start参数和end参数,让BITCOUNT只对指定字节范围内的二进制位进行统计:

BITCOUNT bitmap [start end] 

注意start参数和end参数与本章之前介绍的SETBIT命令和GETBIT命令的offset参数并不相同,这两个参数是用来指定字节偏移量而不是二进制位偏移量的。位图的字节偏移量与Redis其他数据结构的偏移量一样,都是从0开始的:位图第一个字节的偏移量为0,第二个字节的偏移量为1,第三个字节的偏移量为2,以此类推。

img

图8-5 包含24个二进制位的位图bitmap003

举个例子,对于图8-5所示的包含3个字节共24个二进制位的位图bitmap003来说,我们可以通过执行以下命令统计出它的第一个字节里面有多少个二进制位被设置成了1:

redis> BITCOUNT bitmap003 0 0
(integer) 6

如果我们想要知道bitmap003的第一个字节和第二个字节中有多少个二进制位被设置成了1,那么可以执行以下命令:

redis> BITCOUNT bitmap003 0 1
(integer) 9

如果我们想要知道bitmap003的第三个字节中有多少个二进制位被设置成了1,那么可以执行以下命令:

redis> BITCOUNT bitmap003 2 2
(integer) 4 

图8-6展示了以上3个BITCOUNT命令在执行期间都统计了哪些二进制位。

不要把BITCOUNT的字节偏移量当作二进制位偏移量

再次提醒,BITCOUNT命令的start参数和end参数定义的是字节偏移量范围,而不是二进制位偏移量范围。

很多Redis用户在刚开始使用BITCOUNT命令的时候,都会误以为BITCOUNT接受的是二进制位偏移量范围,比如想要使用BITCOUNT bitmap 02去统计位图的前3个二进制位,但实际上统计的却是位图前3个字节包含的所有二进制位,诸如此类。

如果不认真地了解BITCOUNT命令的作用,就很容易出现上述的问题。

img

图8-6 3个BITCOUNT命令执行期间统计的二进制位

1.3.2 使用负数偏移量定义统计范围

BITCOUNT命令的start参数和end参数的值除了可以是正数之外,还可以是负数。

以下是一些使用负数偏移量对位图bitmap003的指定字节进行统计的例子:

redis> BITCOUNT bitmap003 -1 -1    -- 统计最后一个字节
(integer) 4

redis> BITCOUNT bitmap003 -2 -2    -- 统计倒数第二个字节
(integer) 3

redis> BITCOUNT bitmap003 -3 -3    -- 统计倒数第三个字节
(integer) 6

图8-7分别以正数和负数两种形式展示了位图bitmap003的字节索引。

img

图8-7 带有正数字节索引和负数字节索引的位图bitmap003

1.3.3 其他信息

复杂度:O(N),其中N为被统计字节的数量。

版本要求:BITCOUNT命令从Redis 2.6.0版本开始可用。

示例:用户行为记录器

为了对用户的行为进行分析并借此改善服务质量,很多网站都会对用户在网站上的一举一动进行记录。比如记录哪些用户登录了网站,哪些用户发表了新的文章,哪些用户进行了消费,诸如此类。

为此,我们可以使用本书前面介绍过的集合或者HyperLogLog来记录所有执行了指定行为的用户,但这两种做法都有相应的缺陷:

  • 如果使用集合来记录执行了指定行为的用户,那么集合的体积就会随着用户数量的增多而变大,从而消耗大量内存。

  • 虽然使用HyperLogLog来记录用户行为这一做法可以节约大量内存,但由于Hyper-LogLog是一个概率算法,所以它只能给出执行了指定行为的人数的估算值,并且无法准确地判断一个用户是否执行了指定行为,这会给一些需要精确结果的分析算法带来麻烦。

为了尽可能地节约内存,并且精确地记录特定用户是否执行了指定的行为,我们可以使用以下方法:

  • 对于每项行为,一个用户要么执行了该行为,要么没有执行该行为,只有两种可能,因此用户是否执行了指定行为这一信息可以通过一个二进制位来记录。

  • 通过将用户ID与位图中的二进制位偏移量进行一对一映射,我们可以使用一个位图来记录所有执行了指定行为的用户:比如偏移量为10086的二进制位就负责记录ID为10086的用户信息,而偏移量为12345的二进制位则负责记录ID为12345的用户信息,以此类推。

  • 每当用户执行指定行为时,我们就调用SETBIT命令,将用户在位图中对应的二进制位的值设置为1。

  • 通过调用GETBIT命令并判断用户对应的二进制位的值是否为1,我们可以知道用户是否执行了指定的行为。

  • 通过对位图执行BITCOUNT命令,我们可以知道有多少用户执行了指定行为。

代码清单8-1展示了使用这一原理实现的用户行为记录器程序。

代码清单8-1 用户行为记录器:/bitmap/action_recorder.py

def make_action_key(action):
    return "action_recorder::" + action

class ActionRecorder:

    def __init__(self, client, action):
        self.client = client
        self.bitmap = make_action_key(action)
    def perform_by(self, user_id):
        """
        记录执行了指定行为的用户
        """
        self.client.setbit(self.bitmap, user_id, 1)

    def is_performed_by(self, user_id):
        """
        检查给定用户是否执行了指定行为,执行则返回True,未执行则返回False
        """
        return self.client.getbit(self.bitmap, user_id) == 1

    def count_performed(self):
        """
        返回执行了指定行为的用户人数
        """
        return self.client.bitcount(self.bitmap)

这个使用位图实现的行为记录器同时具备了集合和HyperLogLog的优点,既可以像集合那样准确地判断特定用户是否执行了指定行为,又可以像HyperLogLog那样大量减少内存消耗:对于每项行为,使用这个程序去记录100万个用户的信息只需要耗费125KB内存,而记录1000万个用户的信息也只需要1.25MB内存。

作为例子,以下代码展示了如何使用这个程序去记录用户的登录行为:

>>> from redis import Redis
>>> from action_recorder import ActionRecorder
>>> client = Redis()
>>> login_action = ActionRecorder(client, "login")
>>> login_action.perform_by(10086)      # 对已登录用户进行记录
>>> login_action.perform_by(255255)
>>> login_action.perform_by(987654321)
>>> login_action.is_performed_by(10086) # ID为10086的用户登录了
True
>>> login_action.is_performed_by(555)   # ID为555的用户没有登录
False
>>> login_action.count_performed()      # 共有3个用户执行了登录操作
3

1.4 BITPOS:查找第一个指定的二进制位值

用户可以通过执行BITPOS命令,在位图中查找第一个被设置为指定值的二进制位,并返回这个二进制位的偏移量:

BITPOS bitmap value 

比如,通过执行以下命令,我们可以知道位图bitmap003第一个被设置为1的二进制位所在的偏移量:

redis> BITPOS bitmap003 1
(integer) 0    -- 位图第一个被设置为1的二进制位的偏移量为0

而执行以下命令则可以知道bitmap003第一个被设置为0的二进制位所在的偏移量:

redis> BITPOS bitmap003 0
(integer) 3    -- 位图第一个被设置为0的二进制位的偏移量为3

图8-8展示了以上两个命令的执行图示。

img

图8-8 两个BITPOS命令的执行图示

1.4.1 只在指定的字节范围内进行查找

在默认情况下,BITPOS命令的查找范围将覆盖位图包含的所有二进制位,但在有需要的情况下,用户也可以通过可选的start参数和end参数,让BITPOS命令只在指定字节范围内的二进制位中进行查找:

BITPOS bitmap value [start end]

举个例子,如果我们想要在位图bitmap003的第二个字节中找到第一个值为1的二进制位所处的偏移量,那么可以执行以下命令:

redis> BITPOS bitmap003 1 1 1
(integer) 12

注意,BITPOS命令返回的偏移量为12,这个偏移量是被找到的二进制位在整个位图中所处的偏移量,而不是它在第二个字节中所处的偏移量。换句话说,即使我们指定了BITPOS命令要查找的字节范围,BITPOS命令在返回结果时也只会返回目标二进制位在整个位图中所处的绝对偏移量,而不是这个二进制位在指定字节范围内的相对偏移量。

图8-9展示了BITPOS bitmap003111命令的执行图示,以及这个命令在执行时查找的范围。

1.4.2 使用负数偏移量定义查找范围

与BITCOUNT命令一样,BITPOS命令的start参数和end参数也可以是负数。

img

图8-9 BITPOS bitmap003111命令的执行图示

比如,以下代码就展示了如何在位图bitmap003的倒数第一个字节中,查找第一个值为0的二进制位:

redis> BITPOS bitmap003 0 -1 -1
(integer) 17

图8-10展示了这个命令的执行图示,以及它在执行时的查找范围。

img

图8-10 BITPOS bitmap0030-1-1的执行图示

1.4.3 边界情况处理

当用户尝试对一个不存在的位图或者一个所有位都被设置成了0的位图中查找值为1的二进制位时,BITPOS命令将返回-1作为结果:

redis> BITPOS not-exists-bitmap 1    -- 在一个不存在的位图中查找
(integer) -1

redis> BITPOS all-0-bitmap 1         -- 在一个所有位都被设置成了0的位图中查找
(integer) -1

如果用户在一个所有位都被设置成1的位图中查找值为0的二进制位,那么BITPOS命令将返回位图最大偏移量加上1作为结果。

举个例子,如果我们在一个包含8个二进制位,并且所有二进制位都被设置成1的位图bitmap-8bits-all-1中查找值为0的二进制位,那么BITPOS命令将返回以下结果:

redis> BITPOS bitmap-8bits-all-1 0
(integer) 8

这个BITPOS命令之所以会返回8,是因为它在对位图中偏移量从0到7的8个二进制位进行检查之后,都没有找到值为0的二进制位,于是它继续移动指针,尝试去检查偏移量为8的二进制位,但是由于偏移量8已经超出了位图的有效偏移量范围,而Redis又会把位图中不存在的二进制位的值看作0,所以BITPOS命令最后就把偏移量8作为结果返回给用户。

1.4.4 其他信息

复杂度:O(N),其中N为查找涉及的字节数量。

版本要求:BITPOS命令从Redis 2.1.7版本开始可用。

1.5 BITOP:执行二进制位运算

用户可以通过BITOP命令,对一个或多个位图执行指定的二进制位运算,并将运算结果存储到指定的键中:

BITOP operation result_key bitmap [bitmap ...]

operation参数的值可以是AND、OR、XOR、NOT中的任意一个,这4个值分别对应逻辑并、逻辑或、逻辑异或和逻辑非4种运算,其中AND、OR、XOR这3种运算允许用户使用任意数量的位图作为输入,而NOT运算只允许使用一个位图作为输入。BITOP命令在将计算结果存储到指定键中之后,会返回被存储位图的字节长度。

举个例子,通过执行以下命令,我们可以对位图bitmap001、bitmap002和bitmap003执行逻辑并运算,并将结果存储到键and_result中:

redis> BITOP AND and_result bitmap001 bitmap002 bitmap003
(integer) 3    -- 运算结果and_result位图的长度为3字节

而执行以下命令则可以将bitmap001、bitmap002和bitmap003的逻辑或运算结果和逻辑异或运算结果分别存储到指定的键中:

redis> BITOP OR or_result bitmap001 bitmap002 bitmap003
(integer) 3

redis> BITOP XOR xor_result bitmap001 bitmap002 bitmap003
(integer) 3

最后,通过执行以下命令,我们可以计算出bitmap001的逻辑非结果,并将它存储到not_result键中:

redis> BITOP NOT not_result bitmap001
(integer) 1

1.5.1 处理不同长度的位图

当BITOP命令在对两个长度不同的位图执行运算时,会将长度较短的那个位图中不存在的二进制位的值看作0。

比如,当用户使用BITOP命令对值为1001110001011101的位图和值为10100001的位图执行逻辑并运算时,命令将把较短的后一个位图看作0000000010100001,然后再执行运算。

1.5.2 其他信息

复杂度:O(N),其中N为计算涉及的字节总数量。

版本要求:BITOP命令从Redis 2.6.0版本开始可用。

示例:0-1矩阵

0-1矩阵(又称逻辑矩阵或者二进制矩阵)是由0和1组成的矩阵,这种矩阵通常用于表示离散结构。图8-11展示了一个0-1矩阵的例子。

img

图8-11 一个0-1矩阵

Redis的位图也可以用于存储0-1矩阵,只要我们把0-1矩阵中的各个元素与位图中的各个二进制位一对一地关联起来就可以了。图8-12就展示了如何将图8-11所示的0-1矩阵存储到一个包含16个二进制位的位图上面,这16个二进制位被划分成了4个部分,每个部分存储着矩阵中的一个行。

img

图8-12 使用位图存储0-1矩阵

代码清单8-2展示了一个使用以上原理实现的0-1矩阵存储程序:

  • 在初始化矩阵对象时,程序会要求用户输入矩阵的行数和列数,然后把这两个值分别存储到对象的row_num属性和col_num属性中。

  • 当用户调用set(row,col,value)方法对矩阵第row行第col列的元素进行设置的时候,程序会根据公式row*col_num+col找出被设置元素在位图中对应的二进制位偏移量,然后执行SETBIT命令对该二进制位进行设置。

  • 与此类似,当用户调用get(row,col)方法尝试获取矩阵在指定位置上的元素时,程序会使用相同的公式,找出指定元素在位图中对应的二进制位,然后返回它的值。

代码清单8-2 0-1矩阵存储程序:/bitmap/zero_one_matrix.py

def make_matrix_key(matrix_name):
    return "matrix::" + matrix_name

def calculate_index(row, col, row_num, col_num):
    if not (row < row_num):
        raise ValueError("row out of range")
    if not (col < col_num):
        raise ValueError("col out of range")
    return row*row_num+col

class ZeroOneMatrix:

    def __init__(self, client, name, row_num, col_num):
        self.client = client
        self.bitmap = make_matrix_key(name)
        self.row_num = row_num
        self.col_num = col_num

    def set(self, row, col, value):
        """
        对矩阵的指定位置进行设置
        """
        index = calculate_index(row, col, self.row_num, self.col_num)
        self.client.setbit(self.bitmap, index, value)

    def get(self, row, col):
        """
        获取矩阵在指定位置上的值
        """
        index = calculate_index(row, col, self.row_num, self.col_num)
        return self.client.getbit(self.bitmap, index)

    def show(self):
        """
        打印出整个矩阵
        """
        for row in range(self.row_num):
            elements = []
            for col in range(self.col_num):
                elements.append(self.get(row, col))
            print("matrix[{0}]: {1}".format(row, elements))

以下代码演示了这个矩阵程序的使用方法:

>>> from redis import Redis
>>> from zero_one_matrix import ZeroOneMatrix
>>> client = Redis()
>>> matrix = ZeroOneMatrix(client, "test-matrix", 4, 4)
>>> matrix.set(0, 0, 1)   # 设置矩阵元素
>>> matrix.set(0, 1, 1)
>>> matrix.set(0, 2, 1)
>>> matrix.set(0, 3, 1)
>>> matrix.set(1, 1, 1)
>>> matrix.set(1, 3, 1)
>>> matrix.set(2, 2, 1)
>>> matrix.set(3, 3, 1)
>>> matrix.get(0, 0)      # 获取矩阵元素
1
>>> matrix.get(1, 0)
0
>>> matrix.show()         # 打印整个矩阵
matrix[0]: [1, 1, 1, 1]
matrix[1]: [0, 1, 0, 1]
matrix[2]: [0, 0, 1, 0]
matrix[3]: [0, 0, 0, 1]

1.6 BITFIELD:在位图中存储整数值

BITFIELD命令允许用户在位图中的任意区域(field)存储指定长度的整数值,并对这些整数值执行加法或减法操作。

BITFIELD命令支持SET、GET、INCRBY、OVERFLOW这4个子命令,接下来将分别介绍这些子命令。

1.6.1 根据偏移量对区域进行设置

通过使用BITFIELD命令的SET子命令,用户可以在位图的指定偏移量offset上设置一个type类型的整数值value:

BITFIELD bitmap SET type offset value

其中:

  • offset参数用于指定设置的起始偏移量。这个偏移量从0开始计算,偏移量为0表示设置从位图的第1个二进制位开始,偏移量为1则表示设置从位图的第2个二进制位开始,以此类推。如果被设置的值长度不止一位,那么设置将自动延伸至之后的二进制位。

  • type参数用于指定被设置值的类型,这个参数的值需要以i或者u为前缀,后跟被设置值的位长度,其中i表示被设置的值为有符号整数,而u则表示被设置的值为无符号整数。比如i8表示被设置的值为有符号8位整数,而u16则表示被设置的值为无符号16位整数,诸如此类。BITFIELD的各个子命令目前最大能够对64位长的有符号整数(i64)和63位长的无符号整数(u63)进行操作。

  • value参数用于指定被设置的整数值,这个值的类型应该和type参数指定的类型一致。如果给定值的长度超过了type参数指定的类型,那么SET命令将根据type参数指定的类型截断给定值。比如,如果用户尝试将整数123(二进制表示为01111011)存储到一个u4类型的区域中,那么命令会先将该值截断为4位长的二进制数字1011(即十进制数字11),然后再进行设置。

SET子命令会返回指定区域被设置之前的旧值作为执行结果。

举个例子,通过执行以下命令,我们可以从偏移量0开始,设置一个8位长的无符号整数值198(二进制表示为11000110):

redis> BITFIELD bitmap SET u8 0 198
1) (integer) 0

从子命令返回的结果可以看出,该区域被设置之前存储的整数值为0。图8-13展示了执行设置命令之后的位图。

img

图8-13 执行设置命令之后的位图

BITFIELD命令允许用户在一次调用中执行多个子命令,比如,通过在一次BITFIELD调用中使用多个SET子命令,我们可以同时对位图的多个区域进行设置:

redis> BITFIELD bitmap SET u8 0 123 SET i32 20 10086 SET i64 188 123456789
1) (integer) 198
2) (integer) 0
3) (integer) 0

上面这条BITFIELD命令使用3个SET子命令,分别对位图的3个区域进行了设置,其中:

  • 第1个子命令SET u80123从偏移量0开始,设置一个8位长无符号整数值123。

  • 第2个子命令SET i322010086从偏移量20开始,设置一个32位长有符号整数值10086。

  • 第3个子命令SET i64188123456789从偏移量188开始,设置一个64位长有符号整数值123456789。

对于这3个子命令,BITFIELD命令返回了一个包含3个元素的数组作为命令的执行结果,这3个元素分别代表3个指定区域被设置之前存储的整数值,比如第一个子命令返回的结果就是我们之前为该区域设置的值198。图8-14展示了这个BITFIELD命令创建出的位图以及被设置的3个整数值在位图中所处的位偏移量。

img

图8-14 各个整数在位图中所处的位偏移量

图8-14也展示了SET子命令的两个特点:

  • 设置可以在位图的任意偏移量上进行,被设置区域之间不必是连续的,也不需要进行对齐(align)。各个区域之间可以有空洞,即未被设置的二进制位,这些二进制位会自动被初始化为0。

  • 在同一个位图中可以存储多个不同类型和不同长度的整数。

  • 虽然这两个特点可以带来很大的灵活性,但是从节约内存、避免发生错误等情况考虑,我们一般还是应该:

  • 以对齐的方式使用位图,并且让位图尽可能地紧凑,避免包含过多空洞。

  • 每个位图只存储同一种类型的整数,并使用int-8bit、unsigned-16bit这样的键名前缀来标识位图中存储的整数类型。

1.6.2 根据索引对区域进行设置

除了根据偏移量对位图进行设置之外,SET子命令还允许用户根据给定类型的位长度,对位图在指定索引上存储的整数值进行设置:

BITFIELD bitmap SET type #index value

当位图中存储的都是相同类型的整数值时,使用这种设置方法将给用户带来非常大的便利,因为这种方法允许用户直接对位图指定索引上的整数值进行设置,而不必知道这个整数值具体存储在位图的哪个偏移量上。

假设现在有一个位图,它存储着多个8位长的无符号整数,而我们想要把它的第133个8位无符号整数的值设置为22。如果使用SET子命令的偏移量设置格式,就需要先使用算式(133-1)*8计算出第133个8位无符号整数在位图中的起始偏移量1056,然后再执行以下命令:

BITFIELD bitmap SET u8 1056 22

很明显,这种手动计算偏移量然后进行设置的做法非常麻烦也很容易出错。与此相反,如果我们使用的是SET子命令的索引设置格式,那么只需要执行以下命令就可以对位图的第133个8位无符号整数进行设置了:

BITFIELD bitmap SET u8 #132 22

注意,因为SET子命令接受的索引是从0开始计算的,所以上面的子命令使用的索引是132而不是133。

以下是其他使用索引格式对位图进行设置的例子:

# 对位图的第6764位无符号整数进行设置
BITFIELD bitmap SET u4 #675 7

# 对位图的第253016位有符号整数进行设置
BITFIELD bitmap SET i16 #2529 123

# 对位图的第1008632位有符号整数进行设置
BITFIELD bitmap SET i32 #10085 7892

1.6.3 获取区域存储的值

通过使用BITFIELD命令的GET子命令,用户可以从给定的偏移量或者索引中取出指定类型的整数值:

BITFIELD bitmap GET type offset

BITFIELD bitmap GET type #index

GET子命令各个参数的意义与SET子命令中同名参数的意义完全一样。

以下是一些使用GET子命令获取被存储整数值的例子:

redis> BITFIELD bitmap SET u8 0 123 SET i32 20 10086 SET i64 188 123456789
1) (integer) 0
2) (integer) 0
3) (integer) 0

redis> BITFIELD bitmap GET u8 0 GET i32 20 GET i64 188
1) (integer) 123
2) (integer) 10086
3) (integer) 123456789

redis> BITFIELD unsigned-8bits SET u8 #0 13 SET u8 #1 100 SET u8 #7 73
1) (integer) 0
2) (integer) 0
3) (integer) 0

redis> BITFIELD unsigned-8bits GET u8 #0 GET u8 #1 GET u8 #7
1) (integer) 13
2) (integer) 100
3) (integer) 73

如果用户给定的偏移量或者索引超出了位图的边界,或者给定的位图并不存在,那么GET子命令将返回0作为结果:

redis> BITFIELD unsigned-8bits GET u8 #999
1) (integer) 0

redis> BITFIELD not-exists-bitmap GET u8 #0
1) (integer) 0

1.6.4 执行加法操作或减法操作

除了设置和获取整数值之外,BITFIELD命令还可以对位图存储的整数值执行加法操作或者减法操作,这两个操作都可以通过INCRBY子命令实现:

BITFIELD bitmap INCRBY type offset increment

BITFIELD bitmap INCRBY type #index increment

BITFIELD命令并没有提供与INCRBY子命令相对应的DECRBY子命令,但是用户可以通过向INCRBY子命令传入负数增量来达到执行减法操作的效果。INCRBY子命令在执行完相应的操作之后会返回整数的当前值作为结果。

以下代码演示了如何对一个存储着整数值10的区域执行加法操作:

redis> BITFIELD numbers SET u8 #0 10       -- 将区域的值设置为整数10
1) (integer) 0

redis> BITFIELD numbers GET u8 #0
1) (integer) 10

redis> BITFIELD numbers INCRBY u8 #0 15    -- 将整数的值加上15
1) (integer) 25

redis> BITFIELD numbers INCRBY u8 #0 30    -- 将整数的值加上30
1) (integer) 55

以下代码则演示了如何对区域存储的整数值执行减法操作:

redis> BITFIELD numbers INCRBY u8 #0 -25    -- 将整数的值减去25
1) (integer) 30

redis> BITFIELD numbers INCRBY u8 #0 -10    -- 将整数的值减去10
1) (integer) 20

1.6.5 处理溢出

BITFIELD命令除了可以使用INCRBY子命令来执行加法操作和减法操作之外,还可以使用OVERFLOW子命令去控制INCRBY子命令在发生计算溢出时的行为:

BITFIELD bitmap [...] OVERFLOW WRAP|SAT|FAIL [...]

OVERFLOW子命令的参数可以是WRAP、SAT或者FAIL中的一个:

  • WRAP表示使用回绕(wrap around)方式处理溢出,这也是C语言默认的溢出处理方式。在这一模式下,向上溢出的整数值将从类型的最小值开始重新计算,而向下溢出的整数值则会从类型的最大值开始重新计算。

  • SAT表示使用饱和运算(saturation arithmetic)方式处理溢出,在这一模式下,向上溢出的整数值将被设置为类型的最大值,而向下溢出的整数值则会被设置为类型的最小值。

  • FAIL表示让INCRBY子命令在检测到计算会引发溢出时拒绝执行计算,并返回空值表示计算失败。

与之前介绍的SET、GET和INCRBY子命令不同,OVERFLOW子命令在执行时将不产生任何回复。此外,如果用户在执行BITFIELD命令时没有指定具体的溢出处理方式,那么INCRBY子命令默认使用WRAP方式处理计算溢出。

需要注意的是,因为OVERFLOW子命令只会对同一个BITFIELD调用中排在它之后的那些INCRBY子命令产生效果,所以用户必须把OVERFLOW子命令放到它想要影响的INCRBY子命令之前。比如对于以下BITFIELD调用来说,最开始的两个INCRBY子命令将使用默认的WRAP方式处理溢出,而之后的两个INCRBY子命令才会使用用户指定的SAT方式处理溢出。

BITFIELD bitmap INCRBY ... INCRBY ... OVERFLOW SAT INCRBY ... INCRBY ...

此外,因为Redis允许在同一个BITFIELD调用中使用多个OVERFLOW子命令,所以用户可以在有需要的情况下,通过使用多个OVERFLOW子命令来灵活地设置计算的溢出处理方式。比如在以下调用中,第一个INCRBY子命令将使用SAT方式处理溢出,第二个INCRBY子命令将使用WRAP方式处理溢出,而最后的INCRBY子命令则会使用FAIL方式处理溢出:

BITFIELD bitmap OVERFLOW SAT INCRBY ... OVERFLOW WRAP INCRBY ... OVERFLOW FAIL INCRBY ...

现在,让我们来看一个使用OVERFLOW子命令处理计算溢出的例子。在unsigned-4bits这个位图中,存储了3个4位长无符号整数,并且它们的值都被设置成了该类型的最大值15:

redis> BITFIELD unsigned-4bits GET u4 #0 GET u4 #1 GET u4 #2
1) (integer) 15
2) (integer) 15
3) (integer) 15

如果我们使用INCRBY命令对这3个整数值执行加1计算,那么这3个加1计算都将发生溢出,但是通过为这些计算设置不同的溢出处理方式,这些计算最终将产生不同的结果:

redis> BITFIELD unsigned-4bits OVERFLOW WRAP INCRBY u4 #0 1 OVERFLOW SAT INCRBY u4 #1 1 OVERFLOW FAIL INCRBY u4 #2 1
1) (integer) 0
2) (integer) 15
3) (nil)

在上面这个BITFIELD调用中:

第1个INCRBY子命令以回绕的方式处理溢出,因此整数的值在执行加1操作之后,从原来的类型最大值15回绕到了类型最小值0。因为INCRBY子命令默认就使用WRAP方式处理溢出,所以这里的第一个OVERFLOW子命令实际上是可以省略的。

第2个INCRBY子命令以饱和运算的方式处理溢出,因此整数的值在执行加1操作之后,仍然是类型的最大值15。

第3个INCRBY子命令以FAIL方式处理溢出,因此INCRBY子命令在检测到计算会引发溢出之后就放弃了执行这次计算,并返回一个空值表示计算失败。因为此次INCRBY命令未能成功执行,所以这个整数的值仍然是15。

1.6.6 使用位图存储整数的原因

在一般情况下,当用户使用字符串或者散列去存储整数的时候,Redis都会为被存储的整数分配一个long类型的值(通常为32位长或者64位长),并使用对象去包裹这个值,然后再把对象关联到数据库或者散列中。

与此相反,BITFIELD命令允许用户自行指定被存储整数的类型,并且不会使用对象去包裹这些整数,因此当我们想要存储长度比long类型短的整数,并且希望尽可能地减少对象包裹带来的内存消耗时,就可以考虑使用位图来存储整数。

1.6.7 其他信息

复杂度:O(N),其中N为用户给定的子命令数量。

版本要求:BITFIELD命令从Redis 3.2.0版本开始可用。

示例:紧凑计数器

代码清单8-3展示了一个使用BITFIELD命令实现的计数器程序,这个程序提供的API与我们之前在第2章、第3章介绍过的计数器程序的API非常相似,但它也有一些与众不同的地方:

  • 这个计数器允许用户自行指定计数器值的位长以及类型(有符号整数或无符号整数),而不是使用Redis默认的long类型来存储计数器值,如果用户想要在计数器中存储比long类型要短的整数,那么使用这个计数器将比使用其他计数器更节约内存。

  • 与字符串或者散列实现的计数器不同,这个计数器只能使用整数作为索引(键),因此它只适合存储一些与数字ID相关联的计数数据。

代码清单8-3 使用BITFIELD命令实现的紧凑计数器:/bitmap/compact_counter.py

def get_bitmap_index(index):
    return "#"+str(index)

class CompactCounter:

    def __init__(self, client, key, bit_length, signed=True):
        """
        初始化紧凑计数器,
        其中client参数用于指定客户端,
        key参数用于指定计数器的键名,
        bit_length参数用于指定计数器存储的整数位长,
        signed参数用于指定计数器存储的是有符号整数还是无符号整数
        """
        self.client = client
        self.key = key
        if signed:
            self.type = "i" + str(bit_length)
        else:
            self.type = "u" + str(bit_length)

    def increase(self, index, n=1):
        """
        对索引index上的计数器执行加法操作,然后返回计数器的当前值
        """
        bitmap_index = get_bitmap_index(index)
        result = self.client.execute_command("BITFIELD", self.key, "OVERFLOW", 
        "SAT", "INCRBY", self.type, bitmap_index, n)
        return result[0]

    def decrease(self, index, n=1):
        """
        对索引index上的计数器执行减法操作,然后返回计数器的当前值
        """
        bitmap_index = get_bitmap_index(index)
        decrement = -n
        result = self.client.execute_command("BITFIELD", self.key, "OVERFLOW", 
        "SAT", "INCRBY", self.type, bitmap_index, decrement)
        return result[0]

    def get(self, index):
        """
        获取索引index上的计数器的当前值
        """
        bitmap_index = get_bitmap_index(index)
        result = self.client.execute_command("BITFIELD", self.key, "GET", self.
        type, bitmap_index)
        return result[0]

作为例子,假设我们现在是一间游戏公司的程序员,并且打算为每个玩家创建一个计数器,用于记录玩家一个月登录游戏的次数。按照一个月30天,一天登录2~3次的频率来计算,一个普通玩家一个月的登录次数通常不会超过100次。对于这么小的数值,使用long类型进行存储将浪费大量的空间,考虑到这一点,我们可以使用上面展示的紧凑计数器来存储用户的登录次数:

  • 因为每个玩家都有一个整数类型的用户ID,所以我们可以使用这个ID作为计数器的索引(键)。

  • 对于每位玩家,我们使用一个16位长的无符号整数来存储其一个月内的登录次数。

  • 16位无符号整数计数器能够存储的最大值为65536,对于我们的应用来说,这个值已经非常大,不太可能达到。与此同时,因为紧凑计数器使用饱和运算方式处理计算溢出,所以即使玩家的登录次数超过了65536次,计数器的值也只会被设置为65536,而不会真的造成溢出。这种处理方式非常安全,不会给程序带来bug或者其他奇怪的问题。

以下代码展示了如何使用紧凑计数器去存储用户ID为10086的玩家的登录次数:

>>> from redis import Redis
>>> from compact_counter import CompactCounter
>>> client = Redis()
>>> counter = CompactCounter(client, "login_counter", 16, False)  # 创建计数器
>>> counter.increase(10086)  # 记录第1次登录
1
>>> counter.increase(10086)  # 记录第2次登录
2
>>> counter.get(10086)       # 获取登录次数
2

1.7 使用字符串命令对位图进行操作

因为Redis的位图是在字符串的基础上实现的,所以它会把位图键看作一个字符串键:

redis> SETBIT bitmap 0 1
(integer) 0

redis> TYPE bitmap
string

因此用户除了可以使用前面介绍的位图命令对位图进行操作之外,还可以使用字符串命令对位图进行操作。

比如,我们可以通过执行GET命令来获取整个位图:

redis> GET 8bit-int
"\x04"

redis> GET 32bit-ints
"\x00\x00\x00{\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'f"

也可以使用STRLEN命令获取位图的字节长度:

redis> STRLEN 8bit-int            -- 这个位图长1字节
(integer) 1

redis> STRLEN 32bit-ints          -- 这个位图长16字节
(integer) 16

还可以使用GETRANGE命令去获取位图的其中一部分字节:

redis> GETRANGE 32bit-ints 0 3    -- 获取位图的前4个字节
"\x00\x00\x00{"

诸如此类。

正如上面的GET命令和GETRANGE命令所示,当我们使用字符串命令获取位图的值时,命令返回的是一个字符串,而不是一个二进制形式的位图:比如GET命令返回的就是字符串"\x04"而不是二进制位图00000100。因此我们在使用字符串命令操作位图的时候,必须先将命令返回的字符串转换回二进制形式,然后再执行具体的二进制操作。

1.8 重点回顾

  • Redis的位图是由多个二进制位组成的数组,数组中的每个二进制位都有与之对应的偏移量(也称索引),用户通过这些偏移量可以对位图中指定的一个或多个二进制位进行操作。

  • BITCOUNT命令接受的是字节索引范围,而不是二进制位索引范围,忽略这一点很容易引发程序错误。

  • BITFIELD命令允许用户自行指定被存储整数的类型,并且不会使用对象去包裹这些整数,因此当我们想要存储长度比long类型短的整数,并且希望尽可能地减少对象包裹带来的内存消耗时,就可以考虑使用位图来存储整数。

  • 因为位图是使用字符串实现的,所以字符串命令也可以用于处理位图命令。但是在使用字符串命令操作位图时,用户必须先把命令返回的字符串值转换成二进制值,然后再进行后续处理。

版权声明:如无特殊说明,文章均为本站原创,版权所有,转载需注明本文链接

本文链接:http://www.bianchengvip.com/article/redis-bitmap/