跳到主内容

从 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 字节的布局:

user:1001:flags(string,4 字节)bit 0 已实名u1 @0bit 1 已绑手机u1 @1bit 2 推送开关u1 @2bit 3 风控标记u1 @3bit 4~7:会员等级(u4,0~15)演示多 bit 字段,可存枚举而非仅布尔bit 8~31:预留扩展标志新增状态只加映射,不重建数据总计 32 bit = 4 字节,单 key 原子读写

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 #1

Lua 封装:按字段名批量更新 ​

生产环境不建议让业务方记忆 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 1

set_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 字节的稳定模型。只要守住位映射表和溢出策略这两条底线,它在高并发用户状态场景下是性价比极高的选择。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。