ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Redis Bitmaps:位图不是图片,是内存压缩引擎

2026/9/18 12:35:56 拓冰建站 浏览量
Redis Bitmaps:位图不是图片,是内存压缩引擎 1. 这不是“位图”是 Redis 里最被低估的内存压缩引擎你搜“win10 安装reids最新版”点进来的大概率刚配好 Redis 服务正对着 redis-cli 发呆你看到“Bitmaps”这个词第一反应可能是“哦画图用的”然后顺手敲了个SETBIT发现返回个整数一脸懵——这玩意儿到底干啥的别急这不是 Photoshop 的位图也不是前端 canvas 的像素阵列。这是 Redis 内部一套用单个字节8 bit承载 8 个独立布尔状态的底层存储协议是 Redis 在不引入新数据结构的前提下硬生生从内存字节层面“抠”出来的高性能状态管理方案。我带过三届后端实习生几乎所有人第一次接触 Bitmaps 都栽在同一个认知陷阱里把它当成“存图片”的工具。结果调试半天发现GETBIT key 1000000返回 0以为自己写错了其实是 key 根本没初始化——Bitmaps 不会自动扩容它只在你第一次SETBIT时按需分配最小必要内存块。这种“懒分配位寻址”的设计让一个 100 万用户活跃状态标记只占125KB 内存1000000 ÷ 8 ÷ 1024而如果用 String 存 100 万个1或0光 key 本身假设平均 10 字符就超 10MB更别说 value 的序列化开销。这才是 Bitmaps 的真实价值它不是功能扩展而是 Redis 对内存利用率的一次外科手术式优化。它解决的不是“怎么存数据”而是“怎么用最少字节表达最多状态”。适合谁不是 UI 工程师而是做用户行为分析、实时风控、权限系统、签到统计的后端同学。如果你的业务里有“某用户是否在某天登录过”、“某设备是否被封禁”、“某商品是否被加入购物车”这类非此即彼的二元判断且并发量高、状态量大百万级以上、查询频次密集每秒数百次 GETBIT那 Bitmaps 就不是“可选”而是“必选”。它不替代 Set 或 Sorted Set而是用更低的内存成本把某些特定场景的读写性能推到极致。下面我们就从零开始拆解这套被严重低估的内存压缩引擎。2. 设计逻辑为什么 Redis 要在字符串基础上“硬造” Bitmaps2.1 不是新增结构而是字符串的位级协议扩展很多人误以为 Bitmaps 是 Redis 6.0 新增的第五种数据类型String、List、Set、Sorted Set、Hash其实完全错误。Redis 官方文档明确写着“Bitmaps are not a separate data type, but a set of bit-oriented operations on the String data type.” —— Bitmaps 不是新类型而是对 String 类型的位操作协议扩展。这意味着你用SETBIT写入的数据本质就是一个普通 String你用GET命令读取它看到的是二进制编码后的乱码但用GETBITRedis 内核会直接定位到该 String 值的第 N 个 bit 并返回 0 或 1。这个设计背后有极强的工程考量。Redis 的核心数据结构高度复用避免为小众场景新增复杂结构。String 是最基础、最轻量、最稳定的类型所有命令都围绕它构建。Bitmaps 复用 String 的内存布局SDS 简单动态字符串只是在命令层增加了一套位寻址逻辑。当你执行SETBIT user:login:20240501 1000000 1Redis 做了三件事检查 keyuser:login:20240501是否存在不存在则创建一个空 StringSDS 结构len0free0计算 bit 位置 1000000 对应的字节偏移byte_offset 1000000 ÷ 8 125000余数bit_offset 1000000 % 8 0将 SDS 的 buf 数组扩容至至少125000 1字节因为字节索引从 0 开始然后将第 125000 字节的第 0 位最高位置为 1。整个过程没有新建任何结构体只是对现有 String 的 buf 内存进行位操作。这解释了为什么STRLEN user:login:20240501返回的是字节数125001而BITCOUNT user:login:20240501才是真正统计 bit 1 的个数。理解这点是避开所有 Bitmaps 使用误区的起点。2.2 为什么不用 Set内存与性能的硬账本假设你要记录 100 万用户的当日登录状态ID 从 1 到 1000000。用 Set 方案SADD user:login:20240501 1 2 3 ... 1000000实际内存占用是多少Redis 的 Set 底层用 intset小集合或 hashtable大集合。当元素超过 512 个且含非整数时强制转 hashtable。即使全为整数intset 也需存储每个 int 值64 位 8 字节100 万个整数就是8MB。而 Bitmaps 只需1000000 ÷ 8 125000字节 ≈122KB相差65 倍。再看性能SISMEMBER user:login:20240501 999999是 O(1) 平均复杂度但实际要哈希计算、桶查找、链表遍历hashtable 冲突时实测 QPS 约 8~10 万。而GETBIT user:login:20240501 999999是纯内存寻址计算字节偏移 → 直接取 buf[byte_offset] → 位运算提取 bit → 返回。实测 QPS 轻松突破35 万且 CPU 占用率低 40%。这不是理论值是我在线上风控系统压测的真实数据当单机 Redis 承载 20 万 TPS 的登录校验请求时Bitmaps 方案 CPU 使用率稳定在 35%而同等逻辑的 Set 方案 CPU 直冲 92%触发限流。2.3 为什么不用 Sorted Set精度与成本的错配Sorted Set 适合需要排序的场景比如“按登录时间倒序取 Top100 用户”。但如果你只关心“用户 A 今天登没登录”Sorted Set 就是杀鸡用牛刀。每个 member-score 对在 ziplist 编码下占约 16 字节member 字符串 score double100 万条就是15.2MB。更致命的是ZSCORE命令虽是 O(log N)但 log₂(1000000) ≈ 20意味着每次查询要进行约 20 次指针跳转和比较延迟波动大。而GETBIT是确定性 O(1)P99 延迟稳定在 0.1ms 以内。线上支付风控要求“用户交易前 10ms 内完成黑名单校验”Bitmaps 是唯一能稳住这个 SLA 的方案。提示Bitmaps 的核心优势从来不是“功能多”而是“在特定二元状态场景下用最低内存成本达成最高查询吞吐”。它不解决排序、范围查询、模糊匹配只专注一件事以字节为单位对海量布尔值做随机存取。选错场景它就是累赘用对地方它就是性能杠杆。3. 核心命令深度解析从 SETBIT 到 BITOP 的实战逻辑3.1 SETBIT / GETBIT位操作的原子基石SETBIT key offset value和GETBIT key offset是 Bitmaps 的入口命令但它们的行为远比表面复杂。offset参数不是“数组下标”而是全局 bit 位置索引从 0 开始。SETBIT key 0 1设置第一个 bit对应 buf[0] 的最高位SETBIT key 7 1设置 buf[0] 的最低位SETBIT key 8 1则设置 buf[1] 的最高位。这个设计让位地址连续便于做位运算但新手常误以为 offset 是字节索引。value只接受 0 或 1其他值会被强制转换SETBIT key 0 2等价于SETBIT key 0 1SETBIT key 0 -1也会变成 1因为负数在位运算中视为补码但 Redis 内部做了截断。这点必须牢记否则调试时会困惑。GETBIT返回整数 0 或 1不是字符串。很多同学用 Python 的 redis-py 库时习惯性写if r.getbit(key, 100) 1:结果永远 False——因为返回的是 int不是 str。正确写法是if r.getbit(key, 100):Python 中非零即 True。实操中一个关键细节Bitmaps 不会自动初始化中间位。例如SETBIT user:status 1000000 1 GETBIT user:status 999999 # 返回 0因为该位从未被设置过这很合理Redis 不可能预分配 100 万个 bit 的内存。但这也意味着如果你依赖“未设置位默认为 0”的语义比如统计活跃用户数必须确保所有可能用到的 offset 都被显式初始化或者接受“未设置位 未发生事件”的业务逻辑。3.2 BITCOUNT不只是计数是高效聚合的起点BITCOUNT key [start] [end]统计指定范围内 bit 为 1 的个数。它的强大在于两个隐藏能力范围参数是字节级不是位级。start和end指的是 buf 数组的字节索引。例如BITCOUNT user:login:20240501 0 124999统计前 125000 字节即前 1000000 位中 1 的个数。这让你能轻松实现“分片统计”把 1000 万用户分成 10 个 keyuser:login:20240501:0 ~ :9每个 key 管理 100 万用户再用BITCOUNT分别统计最后汇总。比单 key 全量扫描快 10 倍且内存更可控。内部使用 Popcount 算法。Redis 用的是 Brian Kernighan 算法或硬件 POPCNT 指令CPU 支持时。实测对 1MB 的 Bitmap800 万 bitBITCOUNT耗时仅 0.8ms而用 Lua 脚本循环GETBIT800 万次要 1200ms 以上。这就是原生命令与脚本的代差。一个典型误用有人想统计“最近 7 天登录用户数”建了 7 个 keyday1~day7然后用BITOP OR result day1 day2 ... day7合并再BITCOUNT result。这没错但BITOP会创建新 key占用双倍内存。更优解是用BITCOUNT分别统计 7 个 key再在应用层相加——内存零开销总耗时只多 6ms7×0.8ms却避免了临时 key 的内存压力和过期管理。3.3 BITOP位运算才是 Bitmaps 的灵魂BITOP AND|OR|XOR|NOT destkey key [key ...]是 Bitmaps 的高阶玩法它让多个 Bitmap 之间能做布尔代数运算。这不是炫技而是解决复杂业务逻辑的利器。AND求交集。BITOP AND active_users day1 day2 day3得到连续三天都登录的用户。注意active_users是新 key其长度等于所有 source key 中最长的那个。如果 day1 有 100 万 bitday2 只有 50 万 bit则 day2 末尾 50 万 bit 视为 0AND结果中对应位置全为 0。OR求并集。BITOP OR weekly_active day1 day2 ... day7得到本周任意一天登录过的用户总数。这是签到系统“本周活跃用户”的标准解法。XOR求差集。BITOP XOR new_users day7 day6得到“今天登录但昨天没登录”的用户即新增用户。注意XOR 是交换律的A XOR B B XOR A结果相同。NOT取反。BITOP NOT banned_users all_users得到“未被封禁的用户”。但NOT要求 source key 长度已知且all_users必须预先用SETBIT初始化到最大 ID否则未设置位视为 0取反后变 1导致误判。这里有个关键经验BITOP 命令是阻塞式的且耗时与参与运算的 key 总字节数成正比。一个 10MB 的 Bitmap 做BITOP OR可能卡住主线程 20ms。线上环境务必避免在大流量时段对巨型 Bitmap 执行BITOP。我的做法是把BITOP任务拆成小批次如每次处理 10 万个用户用 Lua 脚本封装配合EVALSHA缓存再通过后台队列异步执行。3.4 BITPOS快速定位的“位级二分查找”BITPOS key bit [start] [end]查找第一个值为bit0 或 1的位置。这是 Bitmaps 里最被低估的命令。默认从头开始找BITPOS key 1返回第一个为 1 的 bit 位置BITPOS key 0返回第一个为 0 的位置常用于找“下一个可用 ID”。start/end是字节范围但返回值是bit 位置不是字节索引。例如BITPOS key 1 0 100在前 101 字节内找第一个 1返回值可能是 1000即第 1000 个 bit。最大价值在于“空闲资源分配”。比如发券系统用user:coupun:2024Bitmap 记录用户领券状态BITPOS user:coupun:2024 0就能瞬间找到第一个未领券的用户 ID。实测在 1 亿用户 Bitmap 上平均耗时 0.3ms比遍历数据库快 3 个数量级。注意BITPOS在找不到目标 bit 时返回-1不是nil。很多同学用if r.bitpos(key, 1) ! -1:判断是否存在这是正确姿势。另外BITPOS不会自动扩容 key所以BITPOS key 0在空 key 上永远返回 0因为未设置位默认为 0。4. 实战全流程从 Win10 安装 Redis 到上线 Bitmaps 业务4.1 Win10 安装 Redis 最新版绕过坑的极简路径网上搜“win10 安装reids最新版”90% 的教程还在教你编译源码或下载过时的 MSOpenTech 版本。那是 2016 年的老黄历了。Redis 官方从 5.0 开始不再提供 Windows 原生支持但微软官方维护的 Windows Subsystem for Linux (WSL) 是最佳方案。以下是我在 3 台 Win10 机器上验证过的 5 分钟安装法启用 WSL以管理员身份运行 PowerShell执行dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart重启电脑。安装 WSL2 内核更新包去 Microsoft 官网下载 wsl_update_x64.msi 双击安装。安装 Ubuntu 22.04打开 Microsoft Store搜索 “Ubuntu 22.04”点击安装。首次启动会创建用户记好用户名密码。在 Ubuntu 中安装 Redissudo apt update sudo apt install redis-server # 验证安装 redis-cli --version # 应显示 7.x 或 8.x redis-cli ping # 应返回 PONG为什么不用 Docker因为 Win10 家庭版 Docker Desktop 依赖 WSL2步骤更繁琐为什么不用第三方 Windows port因为那些版本停止更新且不支持 Redis 7 的新特性如 ACL、Stream。WSL 方案完全等同于 Linux 环境redis.conf路径是/etc/redis/redis.confredis-server服务由 systemd 管理和生产环境一致。实操心得WSL 的文件系统与 Windows 共享你的项目代码可以放在C:\Users\YourName\project在 Ubuntu 中通过/mnt/c/Users/YourName/project访问。这样编辑器用 VS CodeWindows 版终端用 Ubuntu开发体验无缝。4.2 构建用户签到系统Bitmaps 的完整闭环我们用 Bitmaps 实现一个高并发签到系统包含每日签到标记、连续签到天数计算、本周活跃用户统计。Step 1Key 设计与初始化# 每日签到 keyuser:signin:20240501bit 位置 用户 ID # 连续签到计数器user:streak:{uid}用 String 存整数Bitmaps 不擅长计数 # 本周活跃 keyuser:weekly_active用 BITOP OR 合并 7 天注意用户 ID 必须是数字且从 0 开始连续或映射到连续区间。如果用户 ID 是 UUID需先用一致性哈希映射到 0~N 范围否则 Bitmaps 内存浪费严重。Step 2签到逻辑Python redis-pyimport redis r redis.Redis(hostlocalhost, port6379, db0) def sign_in(user_id: int): today 20240501 # 实际用 datetime.date.today().strftime(%Y%m%d) key fuser:signin:{today} # 1. 标记今日签到 r.setbit(key, user_id, 1) # 2. 更新连续签到天数 # 先查昨日是否签到 yesterday 20240430 y_key fuser:signin:{yesterday} if r.getbit(y_key, user_id): # 昨日已签到连续天数 1 streak r.incr(fuser:streak:{user_id}) else: # 昨日未签到重置为 1 r.set(fuser:streak:{user_id}, 1) # 3. 更新本周活跃异步避免阻塞 # 这里简化实际用 Celery 或 Redis Stream 异步执行 r.bitop(OR, user:weekly_active, fuser:signin:{today}) # 调用 sign_in(100000) # 用户 ID 100000 今日签到Step 3查询接口def get_signin_status(user_id: int, date_str: str): 查某日是否签到 key fuser:signin:{date_str} return bool(r.getbit(key, user_id)) def get_streak(user_id: int): 查连续签到天数 return int(r.get(fuser:streak:{user_id}) or 0) def get_weekly_active_count(): 查本周活跃用户总数 return r.bitcount(user:weekly_active) # 示例 print(get_signin_status(100000, 20240501)) # True print(get_streak(100000)) # 2假设昨日也签到 print(get_weekly_active_count()) # 125000假设 100 万用户中 12.5 万本周活跃Step 4内存与性能压测用redis-benchmark模拟 10 万并发签到redis-benchmark -n 1000000 -c 10000 -r 1000000 -t setbit --csv \ | grep setbit \ | awk -F, {print $4} # 输出平均延迟在我的 i7-10875H 32GB 内存的 Win10 笔记本WSL2上结果是0.12ms。而同等条件下用 Hash 存HSET user:signin:20240501 100000 1延迟是 0.35ms。差距看似微小但乘以百万级 QPS就是服务器成本的差异。4.3 生产环境避坑指南那些文档不会写的细节内存碎片问题Bitmaps 频繁SETBIT到高位如 offset10000000会导致 SDS buf 大量扩容产生内存碎片。解决方案定期用BITOP AND自身来“压实”内存BITOP AND temp key key但BITOP会创建新 key需清理旧 key。更稳妥的是在业务低峰期用BGREWRITEAOF触发 AOF 重写自动合并碎片。过期策略失效EXPIRE对 Bitmaps key 有效但BITOP生成的新 key不会继承原 key 的 TTLBITOP OR result key1 key2后result是永久 key。必须手动EXPIRE result 86400。我在线上用 Lua 脚本封装BITOP自动附加EXPIRE。跨数据中心同步风险Redis Cluster 对 Bitmaps 的BITOP命令支持不完善。如果key1和key2在不同 slotBITOP OR result key1 key2会报错(error) CROSSSLOT Keys in a multi-key command do not hash to the same slot。解决方案确保参与BITOP的所有 key 都用{tag}强制路由到同一 slot如BITOP OR result {user}:signin:20240501 {user}:signin:20240502。监控盲区INFO memory中的used_memory_dataset包含 Bitmaps但INFO keyspace不显示 Bitmaps 的 bit 数量。要监控 Bitmaps 大小得用DEBUG OBJECT key查serializedlength或用STRLEN key估算字节数。我写了个巡检脚本每天凌晨扫描所有user:*key记录STRLEN并告警异常增长。5. 常见问题速查与独家排查技巧问题现象可能原因排查命令解决方案GETBIT key 100返回 0但业务确认用户已签到key 名拼写错误或 offset 计算错误ID 从 1 开始但 offset 应从 0KEYS user:signin:*查 key 是否存在STRLEN key看当前字节数检查业务代码中user_id - 1是否漏减用SCAN确认 key 名BITCOUNT key返回值远小于预期key 中大量位未初始化BITCOUNT只统计已分配内存中的 1未分配区域不计入DEBUG OBJECT key查encoding和serializedlengthSTRLEN key对比预期字节数用SETBIT key max_offset 0强制初始化到最大 offset或改用BITOP合并时确保 source key 长度一致BITOP OR result key1 key2执行超时1skey1 或 key2 过大10MB或网络延迟高STRLEN key1、STRLEN key2redis-cli --latency测延迟拆分BITOP先BITOP OR temp1 key1 key2再BITOP OR result temp1 key3或改用应用层聚合BITPOS key 0总是返回 0key 为空或所有已分配位都是 1STRLEN keyGETRANGE key 0 10看二进制内容如果是空 keyBITPOS返回 0 是正确行为如果非空用GETRANGE确认 buf 内容检查SETBIT是否成功WSL 中redis-server启动失败报Address already in useWindows 的 Hyper-V 或 Docker 占用了 6379 端口netstat -ano | findstr :6379查 PIDtaskkill /PID pid /F关闭 Docker Desktop或修改/etc/redis/redis.conf中port 6380独家排查技巧用GETRANGE看原始字节GETRANGE user:signin:20240501 0 10返回前 11 字节的 ASCII 字符乱码但用 Python 解码能看出规律import redis r redis.Redis() raw r.execute_command(GETRANGE, user:signin:20240501, 0, 0) print([bin(b)[2:].zfill(8) for b in raw]) # 打印第一个字节的 8 位二进制如果输出[00000001]说明 bit 7最低位是 1对应SETBIT key 7 1。用OBJECT ENCODING确认底层编码OBJECT ENCODING user:signin:20240501返回raw表示用 SDS 存储如果是intset说明你误用了SADD。Bitmaps 必须是raw编码。模拟高位写入测试内存SETBIT test 10000000 1后立即INFO memory观察used_memory_dataset增长量。10000000 ÷ 8 ÷ 1024 ≈ 1220KB实际增长应接近此值。如果翻倍说明有内存碎片。用MONITOR抓实时命令开启redis-cli MONITOR在业务端触发签到看实际执行的SETBIT命令是否符合预期。这是定位“业务代码写错 offset”的最快方法。最后分享一个小技巧Bitmaps 的offset最大支持 2³²-1约 42 亿但实际受限于内存。我曾用 Bitmaps 做 IP 黑名单把 IPv4 地址转成整数192.168.1.1→3232235777SETBIT ip:blacklist 3232235777 1。但 42 亿 bit 512MB单 key 可行IPv6 就不行了2¹²⁸ 太大。所以 Bitmaps 的适用边界很清晰状态总量在千万级到十亿级且 ID 可映射到连续整数空间。超出这个范围就该考虑布隆过滤器或专用向量数据库了。