外观
从 128 字节到 4 字节:Redis Bitfield 用户状态压缩实战
为什么用户状态需要压缩
在用户中心、风控、增长系统中,一个账号往往挂着几十个布尔状态:是否实名、是否绑手机、是否开启消息推送、是否被风控、是否新人、是否会员……最直觉的做法是用 Redis Hash 为每个字段存一个 "1"/"0"。字段少时没问题,但当状态膨胀到 32 个、用户量达到千万级时,内存浪费会非常可观。
更隐蔽的成本在于网络与命令数:一次读取用户完整画像要 HMGET 32 个 field,一次更新多个状态要 HMSET 一大串键值,客户端还要做字符串到布尔的反序列化。
Redis 的 Bitfield(位域)提供了另一种思路:把多个标志位塞进一个字符串的二进制位中。32 个布尔标志恰好占 32 bit,也就是 4 字节。配合 BITFIELD 命令,可以按任意位宽、任意偏移原子地读写。
三种方案实测对比
| 方案 | 单用户内存(32 标志) | 读写粒度 | 原子性 | 可维护性 |
|---|---|---|---|---|
| Hash(每字段一键) | 约 120~200 字节(含 ziplist/listpack 头与字段名) | 单字段 | 单命令原子 | 字段名直观 |
| 独立 Bitmap(每状态一个 key) | 每 key 随 uid 增长,约 N/8 字节 | 单用户单位 | SETBIT 原子 | key 数量爆炸 |
| Bitfield(单 key 打包) | 固定 4 字节 | 任意位宽、多字段一次取 | 整条 BITFIELD 原子 | 需要维护位映射表 |
独立 Bitmap 适合「某状态下有哪些人」这类反向统计(如 BITCOUNT、BITOP AND);而 Bitfield 适合「某个人的全部状态」这类正向读取。本文聚焦后者。
下图展示 Bitfield 把 32 个标志映射进 4 字节的布局:
BITFIELD 核心语法速通
BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP|SAT|FAIL]
type:u表示无符号、i表示有符号,后接位宽,如u1、u4、i8;offset:从 0 开始的位偏移,不是字节偏移;加#前缀时表示「第几个该类型字段」,Redis 会自动乘位宽;- 一条命令内多个子操作按顺序执行,整体具有原子性;
OVERFLOW控制INCRBY溢出行为:回绕、饱和截断或失败。
可运行的命令实战
以下示例可直接在 redis-cli 中执行。先清空测试 key,再写入用户 1001 的初始状态:
bash
# 准备:定义位映射
# bit0 实名 bit1 绑手机 bit2 推送 bit3 风控
# bit4-7 会员等级(0~15)
DEL user:1001:flags
# 初始化:实名=1,绑手机=1,推送=1,风控=0,会员等级=3(二进制 0011 放在 bit4 起)
# u1 写布尔;u4 写 4 位枚举
BITFIELD user:1001:flags \
SET u1 #0 1 \
SET u1 #1 1 \
SET u1 #2 1 \
SET u1 #3 0 \
SET u4 #1 3
# 一次读出全部状态,返回数组:1 1 1 0 3
BITFIELD user:1001:flags \
GET u1 #0 \
GET u1 #1 \
GET u1 #2 \
GET u1 #3 \
GET u4 #1
# 查看实际占用:4 字节
STRLEN user:1001:flags
# 查看底层二进制(Redis 会把 string 当原始字节处理)
GETBIT user:1001:flags 0 # 1,已实名
GETBIT user:1001:flags 3 # 0,未风控注意 #1 配合 u4 时,实际偏移是 1 × 4 = 4,正好接在四个布尔位之后,这就是 # 索引的便利之处。
业务更新:关闭推送、提升等级
bash
# 关闭推送(bit2 置 0),会员等级 +1(4 位无符号整数自增)
# OVERFLOW SAT 表示等级封顶 15,不会回绕到 0
BITFIELD user:1001:flags \
OVERFLOW SAT \
SET u1 #2 0 \
INCRBY u4 #1 1
# 再次读取:1 1 0 0 4
BITFIELD user:1001:flags GET u1 #0 GET u1 #1 GET u1 #2 GET u1 #3 GET u4 #1Lua 封装:按字段名批量更新
生产环境不建议让业务方记忆 bit 编号,应在服务端维护映射表,并用 Lua 保证「校验 + 多位置更新」原子执行:
bash
# KEYS[1] = user:1001:flags
# ARGV 为成对的 [偏移, 值],如 "2 0 3 1" 表示关推送、置风控
redis-cli --eval set_flags.lua user:1001:flags , 2 0 3 1set_flags.lua:
lua
-- 批量设置用户标志位,全部为 u1(单比特布尔)
-- 入参校验:偏移必须在 0~31,值必须为 0/1
local argv = ARGV
if #argv % 2 ~= 0 then
return redis.error_reply("args must be pairs of offset,value")
end
local ops = {}
for i = 1, #argv, 2 do
local off = tonumber(argv[i])
local val = tonumber(argv[i + 1])
if off < 0 or off > 31 then
return redis.error_reply("offset out of range: " .. off)
end
if val ~= 0 and val ~= 1 then
return redis.error_reply("u1 value must be 0 or 1")
end
-- 使用绝对位偏移,避免 # 索引与混合位宽时算错
table.insert(ops, "SET")
table.insert(ops, "u1")
table.insert(ops, off)
table.insert(ops, val)
end
return redis.call("BITFIELD", KEYS[1], unpack(ops))与 Hash 方案的内存对照
bash
# 传统 Hash:字段名本身也要占内存
HSET user:1002:hash verified 1 phone 1 push 0 risk 1 level 4
MEMORY USAGE user:1002:hash # 实测约 80+ 字节
MEMORY USAGE user:1001:flags # 约 40 字节上下(含 redisObject 头),数据区仅 4 字节Redis 对象头(约 16 字节 + SDS 开销)是省不掉的,但数据区从近百字节降到 4 字节,在千万用户规模下差异显著;更关键的是一次 BITFIELD 即可取回整个状态字,省掉多次命令往返。
避坑总结
1. 位偏移与字节偏移混淆,导致读出来全是错的值。 常见现象是 GET u1 4 以为取第 4 个标志,实际取的是 bit4,也就是会员等级的最低位。修正方法:布尔标志统一用 #n 索引(u1 #n 等价偏移 n),混合位宽字段在代码中维护一张「字段名 → 类型 → 索引」的常量表,禁止裸写数字偏移,Code Review 时重点核对。
2. INCRBY 溢出策略默认为 WRAP,等级自增到 16 会回绕成 0。 现象是会员等级突然从 15 变 0,被当成降级甚至异常账号。对枚举/等级类字段必须显式加 OVERFLOW SAT(封顶在最大值)或 OVERFLOW FAIL(返回 nil 由业务拒绝),并在写入前做范围校验;纯布尔位不要用 INCRBY。
3. 用 GET 直接看值得到乱码,误以为数据损坏。 Bitfield 底层是 string,存的是原始字节,GET user:1001:flags 在终端可能显示不可见字符。排查时应使用 BITFIELD ... GET 按类型拆解,或用 STRLEN、GETBIT 逐位确认,不要凭 GET 的文本输出判断。
4. 字段只增不复用、随意插队,造成新旧版本位含义错位。 位域最大的维护风险是「bit3 今天是风控、下周改成新人标识」,老数据会被错误解读。修正方法:位映射表纳入版本管理,新状态只能追加到高位,废弃位标记 reserved 永不重用;跨服务消费时由统一 SDK 解码,禁止各业务自行数 bit。
5. 该反向统计时却硬用 Bitfield。 如果需求是「统计所有实名用户数」「筛选同时满足 A 且 B 的用户」,按用户存的 Bitfield 无法直接 BITCOUNT,需要遍历。这类场景应使用按状态分散的独立 Bitmap(key 为状态、offset 为 uid),或两者并存:Bitmap 负责人群聚合,Bitfield 负责单人画像,按读写方向选型,而不是一把梭。
Bitfield 的价值不只是省下那一百多字节,更是把「状态读取」收敛为单 key、单命令、固定 4 字节的稳定模型。只要守住位映射表和溢出策略这两条底线,它在高并发用户状态场景下是性价比极高的选择。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。