Redis 位图是把字符串按单个位解释的用法,并不是一种独立的键类型。它适合稠密的是非标记,例如按用户记录每日活跃状态。一千万个连续标志需要一百二十五万个载荷字节,但这个估算不包括键名、对象元数据、分配器额外开销,也不包括业务标识到位偏移的映射,不能直接当成实例总内存。

先定义偏移的契约

SETBIT 在从零开始的偏移处写入零或一,并返回写入前的位值。已经为一的位置再次写一,不会增加成员数量。偏移零对应第一个字节的最高位,偏移八才进入第二个字节。对同一个键执行 GET,读到的是底层字节,它们未必是有效文本,所以二进制数据不应默认按 UTF-8 解码。

GETBIT 对缺失键或超出现有字符串长度的位置返回零,而且不会因为读取就扩容。因此,零不能区分“明确不活跃”与“尚未记录”,需要先决定业务是否允许合并这两种状态。SETBIT 与 GETBIT 要求键的值是字符串;如果键实际是哈希,会报类型错误,不会自动把哈希字段转换成位。BITOP 则会在目标键写入新的字符串。

内存取决于写入过的最高偏移,而不是当前有多少个位为一。写偏移 n 至少需要 floor(n / 8) + 1 个载荷字节,即使要写入的是零也一样。后来把高位清零,并不会缩短字符串。稀疏外部编号因此可能用极少的有效标记触发巨大分配。应验证允许的偏移范围,并采用稳定且足够稠密的编号映射,而不是直接信任任意业务 ID。SETBIT 要求偏移小于二的三十二次方,对应最大五百一十二 MiB 字符串。这个协议上限不是合理的单次请求分配预算,应用应按业务规模设置更小的允许范围。

计数和组合都要明确范围

BITCOUNT 默认检查全部字节。可选的 start 和 end 是包含两端的字节位置;从 Redis 7.0 开始,可以用 BIT 修饰符把它们改为位位置。切换单位只改变查询区间,不会改变键里实际存储的表示。返回值是为一的位数,不是最大的成员编号,也不是位图所能容纳的全部用户数。

BITOP OR 计算成员并集,AND 计算交集。较短的输入和不存在的键,会被视为补零到最长输入的长度。目标键会被新字符串覆盖,原本附在目标键上的过期时间也会移除。因此,派生结果需要明确的寿命策略,不能以为之前给结果键设置过一次期限,以后的重算就会自动继承。

坏掉的样子

BITCOUNT、BITPOS 和 BITOP 都可能遍历大量数据。底层位运算即使经过优化,也不代表全量计算是常数时间;把字符串扩展到远端偏移,还需要分配中间的空间。评估时要同时看偏移跨度、查询频率、派生结果保留多久以及峰值分配。活跃用户很少,并不能证明稀疏位图占用的资源很少。

BITPOS 即使只搜索一个子区间,返回的仍是相对于整个位图的绝对位偏移。不指定结束位置而查找零时,如果现有字符串全是一,就可能返回字符串右侧的第一个位置,相当于认为末尾还有补零。明确同时给出起止边界后,范围内找不到目标位才返回 -1。如果用位图表示固定容量的资源池,这两种查询语义不能混用。

找到一个零也不等于已经预约该位置。两个调用方可能先后读到同一空位,再各自写入;单独的 BITPOS 加 SETBIT 不是一个原子占用动作。如果忽略 SETBIT 返回的旧值,就可能把同一位置分给两个人。同样,合并两天的活跃位图,前提是两份数据使用完全相同的编号映射,否则并集结果即使数值正常,也不再代表同一组用户。

快速验证

原有实验记录使用 macOS、Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0;本轮审阅没有重新执行服务实验,下面的结果说明保留原有观察范围。准备 POSIX 环境、C 编译器、make、curl、tar 与 Python 3.10 或更新版本。安装步骤仅用 libc 构建 Redis 核心,并把客户端装入临时虚拟环境。

redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0

在同一个终端中用 "$redis_lab/venv/bin/python" 执行下面代码。程序仅连接自己在 /tmp 下创建的 Redis Unix socket,关闭 TCP 与持久化,终止自身服务进程后只移除本次运行目录。下载依赖需要网络。实验中最大的位图载荷只有一 MiB,不会为了验证原理而分配前面讨论的巨大偏移。

from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time

import redis
from redis.backoff import NoBackoff
from redis.retry import Retry


@contextmanager
def instance():
    with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
        endpoint = str(Path(directory) / "redis.sock")
        process = subprocess.Popen([
            os.environ["REDIS_SERVER"], "--port", "0",
            "--unixsocket", endpoint, "--unixsocketperm", "700",
            "--save", "", "--appendonly", "no", "--dir", directory,
            "--maxmemory-policy", "noeviction",
        ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
        client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
                             socket_timeout=2, retry=Retry(NoBackoff(), 0))
        try:
            deadline = time.monotonic() + 5
            while True:
                assert process.poll() is None, "Redis exited during startup"
                try:
                    if client.ping():
                        break
                except redis.ConnectionError:
                    if time.monotonic() >= deadline:
                        raise
                    time.sleep(0.02)
            yield client
        finally:
            client.close()
            if process.poll() is None:
                process.terminate()
            try:
                _, errors = process.communicate(timeout=5)
            except subprocess.TimeoutExpired:
                process.kill()
                process.communicate()
                raise
            assert process.returncode == 0, errors.decode()


with instance() as client:
    assert client.getbit("missing", 100) == 0
    assert client.exists("missing") == 0
    assert client.setbit("active", 0, 1) == 0
    assert client.setbit("active", 2, 1) == 0
    assert client.setbit("active", 2, 1) == 1
    assert client.type("active") == b"string"
    assert client.get("active") == b"\xa0"
    assert client.bitcount("active") == 2
    print("offset zero is the high bit; repeated SETBIT returns old 1 without adding membership")

    highest = 8 * 1024 * 1024 - 1
    assert client.setbit("sparse", highest, 0) == 0
    assert client.strlen("sparse") == 1024 * 1024
    assert client.bitcount("sparse") == 0
    assert client.setbit("sparse", highest, 1) == 0
    assert client.setbit("sparse", highest, 0) == 1
    assert client.strlen("sparse") == 1024 * 1024
    print("writing a distant zero allocates 1048576 payload bytes; clearing does not shrink")

    client.set("range", b"\x80\x01")
    assert client.bitcount("range", 0, 1) == 2
    assert client.bitcount("range", 0, 1, mode="BIT") == 1
    assert client.bitpos("range", 1, 1, 1) == 15
    client.set("full", b"\xff")
    assert client.bitpos("full", 0) == 8
    assert client.bitpos("full", 0, 0, 0) == -1
    print("ranges default to bytes; BIT changes units; BITPOS returns absolute bit offsets")

    client.set("other", b"\x50\x01")
    client.set("union", b"obsolete", px=60000)
    assert client.bitop("OR", "union", "active", "other") == 2
    assert client.get("union") == b"\xf0\x01"
    assert client.bitcount("union") == 5
    assert client.ttl("union") == -1
    assert client.bitop("AND", "intersection", "active", "other") == 2
    assert client.get("intersection") == b"\x00\x00"
    print("BITOP zero-pads shorter inputs; OR has 5 flags; destination TTL is removed")

    client.hset("hash", "field", b"value")
    try:
        client.setbit("hash", 0, 1)
    except redis.ResponseError as error:
        assert "WRONGTYPE" in str(error)
    else:
        raise AssertionError("a hash is not converted into a bitmap")
    assert client.hget("hash", "field") == b"value"
    print("bitmap commands reject a hash without converting or replacing it")

偏移零和二组合成字节 0xa0,置位计数为二。对远端位置写零仍然生成一百零四万八千五百七十六个载荷字节;把该位写一再清零,长度保持不变。按字节范围与按位范围计数分别得到二和一,带边界的 BITPOS 结果则仍是绝对偏移,并不会从查询区间起点重新编号。

一个全一字节在无界零查询中返回八,在限定到该字节的查询中返回 -1。OR 得到跨两个字节的五个标记,同时目标键的 TTL 被清除;AND 得到两个全零字节。错误类型访问没有改变原来的哈希。这些结果验证存储表示与命令语义,不代表总进程内存测量,也没有证明并发资源分配正确性或生产吞吐量。

来源

  • Redis SETBIT,旧位值返回和字符串扩容,已核验 2026-09-08。
  • Redis GETBIT,缺失键与越界位置的读取,已核验 2026-09-08。
  • Redis BITCOUNT,置位计数及字节与位区间,已核验 2026-09-08。
  • Redis BITPOS,绝对偏移与有界零查询,已核验 2026-09-08。
  • Redis BITOP,结果字符串、补零和操作成本,已核验 2026-09-08。
  • Redis 8.10.1 构建说明,只编译核心及分配器选择,已核验 2026-09-08。