BITFIELD:在位图中存储整数值

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

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

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

通过使用 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展示了执行设置命令之后的位图。

image 2025 01 03 22 38 32 067
Figure 1. 图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 个整数值在位图中所处的位偏移量。

image 2025 01 03 22 41 07 169
Figure 2. 图8-14 各个整数在位图中所处的位偏移量

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

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

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

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

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

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

根据索引对区域进行设置

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

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

假设现在有一个位图,它存储着多个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。

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

# 对位图的第676个4位无符号整数进行设置
BITFIELD bitmap SET u4 #675 7
# 对位图的第2530个16位有符号整数进行设置
BITFIELD bitmap SET i16 #2529 123
# 对位图的第10086个32位有符号整数进行设置
BITFIELD bitmap SET i32 #10085 7892

获取区域存储的值

通过使用 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

执行加法操作或减法操作

除了设置和获取整数值之外,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

处理溢出

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。

使用位图存储整数的原因

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

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

其他信息

  • 复杂度: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