一句话 :
SETBIT把 1 个比特当开关,BITFIELD把连续 M 个比特当小整数------同一张 String,用几位干掉几个 key。版本 :
BITFIELD3.2.0+ ·BITFIELD_RO6.2+ ·BITOP基础 2.6+ ·BITOP新操作符DIFF/DIFF1/ANDOR/ONE8.2+(无ANDNOT)。
三条最该先记住的结论 :
① 溢出默认 WRAP 且完全静默,u8 255+1 变 0、i8 127+1 变 -128,必须显式 SAT/FAIL;
② BITFIELD 原子但非事务,FAIL 也不是"什么都没发生"(key 会被预扩展且不同步);
③ 收益来自消除 per-key 元数据(每合并一个 key 省约 77 B),代价是"布局即契约"。
适合谁读 :写过
SETBIT却没敢用BITFIELD的人;被"一个 key 一个计数器"撑爆内存、又被位域溢出坑过一次的人。前置 :熟悉 String 命令、知道 SDS 与redisObject,能在redis-cli和 .NET 之间来回验证。全篇 C# 代码基于 StackExchange.Redis 2.8.16,已用dotnet build(net10.0)编译通过。
一、位域定位与命令全集
1.1 一个 String,两种视角
第 6 篇讲过:Redis 里没有 Bitmap 类型,它只是 String 的位视图。位域是同一件事的下半场:
SETBIT把第 N 个比特当一个开关;BITFIELD把从第 N 位起连续 M 个比特 当一个小整数。
两者共用同一张 String、同一套 SDS,只是视角不同。同一个 key 先 BITFIELD SET u8 0 200 写一个字节,再 GETBIT 看第 0 位,读的是同一段内存。
为什么需要下半场:SETBIT 只能存 0/1。真实业务常常要在一个对象上塞几十个小整数------8 个互动计数、32 个权限开关、一局游戏 6 个角色的血量。一个 bit 不够,一个 key 一个计数器又太浪费,因为key 自身的元数据远超数值本身。同样存 8 个计数器,8 个独立 key 约 1050 字节,一段位域约 145 字节。
三个现象说明了它的风险面:
- 数据错乱 :
u4计数器到 15 后继续INCRBY,不报错直接归零------默认WRAP。 - 统计失真 :一次 uid→offset 映射迁移后,新旧两张日活图直接
BITOP AND,交集全错------错在映射口径,不在BITOP。 - 内存堆积 :
BITOP产出的临时结果 key 忘设EXPIRE,日积月累堆上万个。
三条主线由此而来:溢出策略、映射口径一致性、结果 key 的生命周期。
1.2 命令家族:BITFIELD 与 BITOP
BITFIELD 自 3.2.0 起可用,每个子命令 O(1)。一次调用可串联多个子命令,返回一个数组,元素与子命令顺序一一对应:
redis
BITFIELD key [GET encoding offset]
[SET encoding offset value]
[INCRBY encoding offset increment]
[OVERFLOW WRAP|SAT|FAIL]

redis
# 一条命令同时读写多个字段
# 偏移 100 处的 i5 加 1,返回新值
# 偏移 0 处的 u4 读出 0
BITFIELD mykey INCRBY i5 100 1 GET u4 0
两个必须现在知道的边界:原子但非事务 (子命令按序执行、不会被并发打断,但 FAIL 只跳过当前一条,前面成功的不回滚 );先扩展后执行 (即使全部写子命令被 FAIL 拒绝,字符串的预创建/预扩展也已发生,且不写 AOF、不传播副本,见 2.6)。
1.3 类型编码与位宽选择
类型 = 符号 + 位宽,i 有符号、u 无符号。

为什么 u64 不存在而 i64 存在 :RESP 的整数是有符号 64 位。u63 的最大值 2^63-1 落在正数范围内,能正常返回;u64 的 2^64-1 超出这个容器。i64 正好塞得进去,所以支持。
redis
# u64 直接报错
BITFIELD k GET u64 0
常用位宽按业务上界选,上界拿不准才大一号,别为"整齐"超配:

记忆:无符号看"能到 2ⁿ-1",有符号看"正负各一半"------u4 到 15、i4 到 7。
两个常见反例:用 u4 存"连续签到天数",第 16 天静默归零;用 i4 存"评分"以为范围是 0~15,实际只到 7。位宽超配也有代价------8 个字段都多给 8 位,key 就从 32 字节涨到 64 字节,正好跨过一个 jemalloc 桶,"省内存"变成"多占一倍"。宽度选择的标准不是好看,而是"业务上界 + 一号余量"。
1.4 两种偏移与溢出语法

redis
# 写入 i8 数组的第 0、1 个元素,等价于 SET i8 0 100 i8 8 200
BITFIELD mystring SET i8 #0 100 i8 #1 200
辟谣 :网上说"纯数字是字节偏移、#n 才是位偏移"------错的,两者都是比特,#n 只是乘以位宽。把 SET u8 8 1 当成"从第 8 个字节写",结果差 8 倍且不报错。
# 的陷阱 :乘数是当前子命令的编码宽度 。同一个 #1,i8 下是 bit 8,i16 下是 bit 16。混用位宽时手写绝对偏移更安全。位域可以跨字节边界,但生产上建议按字节对齐,避免客户端 SDK 解析出错。
上例第二笔
200超出i8上界 127,默认WRAP会静默回绕成 -56 写入 (200−256),返回的 0 只是旧值。SET的越界同样受OVERFLOW控制。
OVERFLOW 只影响它之后、直到下一条 OVERFLOW 的 SET/INCRBY,对 GET 无效。

必须记住 :服务器层面三种策略都不抛异常,默认 WRAP 静默回绕。要封顶或显式拒绝,必须手写 SAT/FAIL。
1.5 只读变体与 BITOP 8.2 新操作符
BITFIELD_RO(6.2+)与 BITFIELD 同源,但只接受 GET ,任何 SET/INCRBY/OVERFLOW 都报错。存在的理由是:BITFIELD 整条命令带 write 标志,哪怕只含 GET,发到只读副本也会被 READONLY 拒绝 。集群下 BITFIELD_RO 可路由到副本,BITFIELD 只能落主节点------统计类查询统一走 _RO。
BITOP op destkey key [key ...] 把多个位图按位运算写入 destkey,返回结果位图的字节长度 。四个基础运算:AND(交集)、OR(并集)、XOR(对称差,不是"新增" )、NOT(补集,只接受一个输入)。
三条边界:
- 长度取最长、短图尾部补 0,但不会算错 。位图缺失的高位本来就是 0,补 0 补的正是真实内容,
AND/OR/XOR/DIFF在长度不一致时结果仍语义精确。长度差异真正影响三件事:结果 key 长度不可预测;NOT输出截断在输入长度内、NOT+AND两跳会丢高位;STRLEN差异过大是映射口径漂移的信号。 - 结果 key 不继承源 key 的 TTL ,用完记得
EXPIRE/UNLINK。
8.2 的四个新操作符把过去要写 Lua 的位集合运算变成一条命令:

redis
# DIFF:今天活跃但昨天不活跃(新增用户)
BITOP DIFF new_active active:20261103 active:20261102
# 旧写法三条命令、要临时 key、非原子;新写法一条命令原子完成
# BITOP NOT tmp b
# BITOP AND result a tmp
# UNLINK tmp
BITOP DIFF result a b
⚠️
DIFF与DIFF1方向相反,不是别名。这四个操作符需要 8.2+ ,低版本用NOT+AND模拟或用 Lua。
1.6 .NET 解析:只有 ExecuteAsync
StackExchange.Redis 至今没有 BITFIELD 高层封装 (社区 PR 未合并)。网上出现的 StringBitfieldAsync / BitfieldOperation 之类签名均不可信,所有版本都走 ExecuteAsync。
csharp
// BITFIELD:紧跟命令名的第一个参数是 key,集群下必须显式转 RedisKey
// SE.Redis 靠"参数是否为 RedisKey 类型"识别 key 并分片路由,传 string 会走默认路由
// 显式转换运算符的返回类型标了可空,服务器这里一定返回数组,用 ! 消掉可空警告
var result = ((RedisResult[])await db.ExecuteAsync("BITFIELD", (RedisKey)"user:100:stats",
"OVERFLOW", "SAT",
"INCRBY", "u32", "#0", 1,
"GET", "u32", "#2"))!;
// result.Length == 2:OVERFLOW 不产生返回元素
// BITOP:紧跟命令名的是操作符,不是 key;dest 与所有源 key 都要转 RedisKey
await db.ExecuteAsync("BITOP", "DIFF", (RedisKey)"new_active",
(RedisKey)"active:20261103", (RedisKey)"active:20261102");
await db.KeyExpireAsync((RedisKey)"new_active", TimeSpan.FromHours(1));
三个最容易踩的点:
- 返回值解析 。
RedisResult有到RedisResult[]的显式转换运算符,整条结果先(RedisResult[]);公开 API 既没有AsArray()(内部AsRedisResultArray()为 internal),也没有AsInt64()(同为 internal,实测 CS0122) ,单元素统一(long)result[i],别调.AsInt64()。解析前先判IsNull:读场景可缺省为 0,写场景必须区分nil(被FAIL拒绝)与 0(真值)。 - 参数数组一次传完 。
ExecuteAsync("BITFIELD_RO", key, argsArray)会把object[]当单个参数装箱,命令帧拼错、服务端收不到并超时。 u64与类型名 :类型字符串必须i1~i64/u1~u63,uint8、U8都会Invalid bitfield type。副本读只能BITFIELD_RO;BITOP多 key 必须同 Slot;RESP2/RESP3 响应结构可能不同,上线前两种协议各跑一遍。
1.7 一页速查


BITCOUNT/BITPOS的start/end默认按字节 ,而SETBIT/BITFIELD的 offset 按比特 ------两套单位极易记混(7.0+ 可用BIT参数统一)。
二、位序、扩展与溢出
2.1 位域住在 String 里
BITFIELD 操作的就是一段 SDS 字节串,和 SETBIT 完全一样。所以 TYPE key 返回 string;被位命令写过的 key 编码是 raw (写路径会 unshare,强制脱离 int/embstr),但由 SET 创建、只被 GET 读过的 key 仍可能是 embstr ,别拿编码判断"这是不是位域";可以对它用 STRLEN/EXPIRE/MEMORY USAGE/UNLINK;禁用 APPEND/SETRANGE,它们会改长度和内容、破坏布局。
2.2 按需扩展,但绝不缩容
SET/INCRBY 写到超过当前长度,会自动扩展并按字节补 0:
redis
# 写到第 4 个字节(第 32 位)
BITFIELD k SET u8 #4 1
# 扩展到 5 字节:第 0~3 字节补零,第 4 字节被写
STRLEN k
长度由你写入的最远位置决定 ,与真正用到的字段数无关。所以布局要按最远字段算容量------随手写 #1000000 会立刻分配约 1 MB(与第 6 篇的大 offset 陷阱同源)。
反向不成立:清零不会缩容 。把字段 SET 回 0、整段清成 0,长度也不缩短,max_offset 不回退。这直接影响"定时任务把今日字段清零"的设想:key 一旦被大 offset 撑过就永久占着那块内存。要真正释放只能 DEL/UNLINK 重建;更省心的是 key 按日切换 + TTL。
2.3 MSB-first 与符号扩展
位序高位在前(大端 / MSB-first) 。一个字节里,offset 最小的 bit 对应最高位 。SETBIT k 0 1 写入的是 \x80(1000 0000),不是 \x01。
redis
# 高位在前:第 0 位 = 字节最高位
SETBIT bo 0 1
GET bo
对你意味着什么 :在应用层自己拆原始字节时,必须用 1 << (7 - (offset & 7))。照搬 1 << offset(小端直觉),整个字节位序反转,offset 0 与 7 对调------这就是"命令行验证正确、应用层解析全错"的根因。
符号扩展 :同一段比特,按 i 读和按 u 读是两回事。i4/u4 共用 4 位:

有符号读法把最高位当符号位(补码)。所以字段类型必须"一次定型",不要在同一槽位混读。
2.4 未初始化恒 0
GET 读超出长度的位、或从未写入的区域,一律返回 0,不报错。由此带来一个语义陷阱:用 u2 存状态(0=未激活、1=激活、2=冻结、3=注销),"真的未激活"和"从未设置"返回的都是 0,分不清。
两个解法:① 额外用一个 u1 标记"该槽位是否写过";② 值从 1 起编号,让 0 专表"未设置"。
2.5 溢出三策略与业务选型
同一个起点、三种结局(每条独立 key,从 255 起加 1):
redis
# 三条各用独立 key,避免前一条改了值、后一条起点就不再是 255
DEL s1 s2 s3
# WRAP:255 加 1 回绕到 0,返回 0
BITFIELD s1 OVERFLOW WRAP INCRBY u8 0 1
# SAT:255 加 1 封顶在 255,返回 255
BITFIELD s2 OVERFLOW SAT INCRBY u8 0 1
# FAIL:不执行,返回 nil,值保持 255
BITFIELD s3 OVERFLOW FAIL INCRBY u8 0 1
i8 的三种结局同理,只是方向更隐蔽:127 加 1 在 WRAP 下变成 -128(符号翻转)。
redis
# 三条各用独立 key,从 127 起加 1
DEL i1 i2 i3
# WRAP:127 加 1 符号翻转成 -128
BITFIELD i1 OVERFLOW WRAP INCRBY i8 0 1
# SAT:封顶在 127
BITFIELD i2 OVERFLOW SAT INCRBY i8 0 1
# FAIL:返回 nil,值保持 127
BITFIELD i3 OVERFLOW FAIL INCRBY i8 0 1
SET 同样会越界 ,而且更隐蔽------调用方以为写什么就是什么,返回的旧值又让命令看起来成功了。u8 写 300:默认 WRAP 静默存 44(300 mod 256);SAT 存 255;FAIL 返回 nil 拒绝。
业务选型三句话:
- 只增不减的计数器 (签到、积分、累计消费)→
SAT或FAIL,绝不用WRAP。SAT封顶要能被感知(返回值 == 上限即信号,客户端埋点告警),否则报表静默停涨;对封顶敏感的指标改用FAIL。 - 允许负增量的短期计数 (余额、可撤销计数)→ 优先
SAT,但位宽要覆盖负界;扣减不可截断 (少扣一分都不行)就用FAIL+ 应用层拒绝重试。 - "回绕即业务语义" (哈希、环形缓冲区序号)→
WRAP才是对的。
2.6 FAIL 的边缘:全部失败,key 却已被预扩展
这是本篇最重要、最容易漏的一条。
BITFIELD 的执行顺序是先按最远触点创建/扩展字符串,再逐条执行子命令 。即使所有写子命令都因 FAIL 返回 nil,预创建/预扩展已经发生 ;但因为没有任何 bit 写入成功,changes 计数保持 0,于是它不标记 key modified、不发 keyspace 通知、不写 AOF、不传播副本。
redis
# key 原本不存在
BITFIELD created OVERFLOW FAIL SET u1 0 2
# 写被拒绝,返回 nil
EXISTS created
# 但 key 已经被创建了
STRLEN created
# 长度 1 字节(预分配)
# key 已存在但很短
SET grown ""
BITFIELD grown OVERFLOW FAIL SET u1 63 2
# 字符串被预扩展到了 8 字节
STRLEN grown
后果:主库的 key 长度/存在性已改变,但这个改变不写 AOF、不同步副本;主从从此可能不一致,AOF 重载后预扩展又消失(Redis issue #15599)。
规避 :不要依赖"FAIL = 什么都没发生",对存在性/长度敏感的场景额外校验 EXISTS/STRLEN;需要"全有或全无"用 MULTI/EXEC 或 Lua;该行为在 7.x、8.0 上同样存在,修复状态以对应版本的 release notes 为准。
2.7 原子非事务,读改写不在保护范围
单条 BITFIELD 内部不会被并发打断,但没有事务语义 :某个子命令 FAIL 只跳过它,前面成功的不回滚。
更关键的是跨命令的读-改-写 :把 8 个值读回应用层、改一个、再 SET 写回,两个并发请求会互相覆盖(lost update),且无报错。BITFIELD 的"原子"只覆盖单条命令内部。
"全有或全无"的最小 Lua 样例(两个计数必须同时成功,任一被拒则整体恢复旧值):
lua
-- KEYS[1] = 位域 key;ARGV[1]/ARGV[2] = 两个字段的增量
-- BITFIELD 的 FAIL 返回 nil,在 Lua 里收到的是 false
local old = redis.call('BITFIELD', KEYS[1], 'GET', 'u32', '0', 'GET', 'u32', '32')
local out = redis.call('BITFIELD', KEYS[1], 'OVERFLOW', 'FAIL',
'INCRBY', 'u32', '0', ARGV[1],
'INCRBY', 'u32', '32', ARGV[2])
if out[1] == false or out[2] == false then
-- 任一被拒绝:两个字段都写回旧值,实现"整体回滚"
redis.call('BITFIELD', KEYS[1],
'SET', 'u32', '0', old[1],
'SET', 'u32', '32', old[2])
return {0, old[1], old[2]}
end
return {1, out[1], out[2]}
注意这只是"失败后恢复旧值"的补偿式回滚,不是 CAS------两个并发脚本之间仍可能互相踩。要严格消除并发窗口,把"读旧值---写新值---恢复"全放进一个脚本;或用
WATCH+MULTI/EXEC乐观锁重试。
2.8 亲手验证
redis
# 1. 按最远触点扩展
DEL bf
# 第 0 字节
BITFIELD bf SET u8 #0 200
# => 1
STRLEN bf
# 跳到第 4 字节,中间补零
BITFIELD bf SET u8 #4 1
# => 5
STRLEN bf
# 2. 溢出默认静默回绕
DEL ov
BITFIELD ov SET u8 0 255
# => 0,不是报错
BITFIELD ov INCRBY u8 0 1
# 3. FAIL 返回 nil
DEL fl
BITFIELD fl SET u8 0 250
# => nil,值保持 250
BITFIELD fl OVERFLOW FAIL INCRBY u8 0 10
# 4. u64 不支持
BITFIELD u64test GET u64 0
# 5. 未初始化区返回 0
DEL ge
# => 0,key 不存在也返回 0
BITFIELD ge GET u32 0
# 6. 写过位命令的对象编码是 raw
OBJECT ENCODING bf
建议第一次读到这照着敲一遍,尤其第 2 步------亲眼看到"255 加 1 等于 0",比读十遍都记得牢。
2.9 位操作全家族总览
把本篇与前一篇的位能力放在一张图里,避免"该用哪个"反复纠结:
text
┌─ SETBIT / GETBIT 单 bit 布尔开关(第 6 篇)
┌─ Bitmap ──┼─ BITCOUNT / BITPOS 整图 / 区间计数
│ └─ BITOP AND/OR/XOR/NOT 多图集合运算(2.6+)
位操作 ─┤ BITOP DIFF/DIFF1/ANDOR/ONE(8.2+)
│
└─ Bitfield ─┬─ BITFIELD 多字段读写 + 原子自增(3.2+)
├─ OVERFLOW WRAP / SAT / FAIL
└─ BITFIELD_RO 只读,可下发副本(6.2+)
三条边界决定选型:一个 bit 表达一个布尔 用 Bitmap;多张图之间做集合关系 用 BITOP;一个对象上挂多个小整数 用 Bitfield。三者在同一系统里可以并存,而且常常并存------日活图用 Bitmap、漏斗用 BITOP、每篇帖子的互动数用 Bitfield。
三、生产五大场景
3.1 八计数器:一个 String 存 8 个 u32
内容社区每篇帖子维护 8 类互动数------浏览、点赞、收藏、评论、转发、举报、分享、打赏。一计数一 key,一篇帖子就是 8 个 key。
redis
# 方案 A:8 个独立 key
INCR post:100:view
INCR post:100:like
# ... 共 8 个 key
# 方案 B:1 个 key + 8 个 u32 位域(共 32 字节)
# 一条命令批量自增前 3 个并回读第 0 个
BITFIELD post:100:stats \
INCRBY u32 #0 1 \
INCRBY u32 #1 1 \
INCRBY u32 #2 1 \
GET u32 #0

从 ~1.05 GB 到 ~145 MB,省约 86%------这就是"内存再砍 80%"的账单。
csharp
public sealed class PostStatsService
{
private readonly IDatabase _db;
public PostStatsService(IDatabase db) => _db = db;
private const int SlotView = 0, SlotLike = 1, SlotFav = 2, SlotComment = 3,
SlotShare = 4, SlotReport = 5, SlotRepost = 6, SlotTip = 7;
/// <summary>增减计数。BITFIELD 即使只有一条 INCRBY,返回的也是数组,
/// 必须 (RedisResult[]) 转换后取 [0];把整条结果强转 long 会抛异常。
/// 这里用 SAT 封顶(SAT 永不返回 nil);若改 FAIL,先判 result[0].IsNull。</summary>
public async Task<long> IncrAsync(string postId, int slot, long delta = 1)
{
var result = ((RedisResult[])await _db.ExecuteAsync("BITFIELD",
(RedisKey)$"post:{postId}:stats",
"OVERFLOW", "SAT",
"INCRBY", "u32", $"#{slot}", delta))!;
// 单元素统一 (long) 取整
return (long)result[0];
}
public async Task<long[]> GetAllAsync(string postId)
{
// 一次往返读回全部 8 个计数器;统计查询走 BITFIELD_RO(见 1.5)
var res = ((RedisResult[])await _db.ExecuteAsync("BITFIELD_RO",
(RedisKey)$"post:{postId}:stats",
"GET", "u32", "#0", "GET", "u32", "#1", "GET", "u32", "#2", "GET", "u32", "#3",
"GET", "u32", "#4", "GET", "u32", "#5", "GET", "u32", "#6", "GET", "u32", "#7"))!;
// 未初始化位返回 nil,读场景缺省为 0
return res.Select(r => r.IsNull ? 0L : (long)r).ToArray();
}
}
边界 :8 个计数共享一个 key 的 TTL,不能单独过期;u32 上限约 42.9 亿,单篇千万级爆款够用,"累计曝光"这类指标要 u63 或拆字段。按日滚动的字段必须 key 按日切换 + TTL (如 post:100:stats:20261105)------如果用常驻 key 每天把 8 个字段 SET 0,不但多写一轮,还会因 2.2 的"清零不缩容"永久占着历史最大长度。热点帖子是单 key 热点,超热内容先在应用侧本地聚合再批量落盘,或拆 key 分散到多个分片。
3.2 权限位与多状态段:掩码与位段同一个服务
权限 :32 个功能权限用 u32 的每一位表示,一个用户全部权限只占 4 字节。
redis
# 整块设置:0b1001 = 查看 + 导出
BITFIELD user:100:perm SET u32 0 9
# 单点翻转(相当于 SETBIT),offset 是 MSB-first 的比特下标
# 打开 0b1000 那一位
SETBIT user:100:perm 28 1
# 打开 0b0001 那一位
SETBIT user:100:perm 31 1
# 读回权限掩码
BITFIELD user:100:perm GET u32 0
⚠️ 两套编号别混 :
u32整数值按1 << n编号(低位 n 小),SETBIT的 offset 是 MSB-first------value 的 bit n 对应 offset31 − n。整块写 9(0b1001)等于置位 offset 28 (bit3)与 31 (bit0),不是 offset 3。用SETBIT key 3 1去"打开查看权限",实际改的是1 << 28。
多状态段 :一局游戏 6 个角色,各有血量 u16、等级 u8、状态 u2,每角色预留 128 bit。这里刻意不用 # 相对偏移 ------#N 的乘数是当前子命令位宽,混用 u16/u8/u2 时会落进错误区间并互相覆盖,改用绝对 bit 偏移:
redis
# 角色 i 基址 base = i*128,全部手写绝对 bit 偏移
BITFIELD game:room:88:chars \
SET u16 0 65535 SET u8 32 12 SET u2 48 1 \
SET u16 128 32000 SET u8 160 5 SET u2 176 0
csharp
// 权限位(掩码)与游戏角色(位段)共用一个"掩码 + 位段"服务
public class MaskStateService
{
private readonly IDatabase _db;
public MaskStateService(IDatabase db) => _db = db;
// 权限编号按 value 的 bit n;SETBIT 的 offset = 31 - n(MSB-first)
private static int OffsetOf(int permissionBit) => 31 - permissionBit;
// 授予权限:SETBIT 幂等,重复授予无副作用
public async Task GrantAsync(long userId, int permissionBit)
=> await _db.StringSetBitAsync((RedisKey)$"user:{userId}:perm", OffsetOf(permissionBit), true);
// 判断权限:GETBIT 对未初始化位返回 0(即未授予)
public async Task<bool> HasAsync(long userId, int permissionBit)
=> await _db.StringGetBitAsync((RedisKey)$"user:{userId}:perm", OffsetOf(permissionBit));
// 读整块掩码:u32 最大约 42.9 亿,仍在 long 正数范围内,(long) 不溢出
public async Task<uint> GetMaskAsync(long userId)
{
var res = ((RedisResult[])await _db.ExecuteAsync("BITFIELD_RO",
(RedisKey)$"user:{userId}:perm", "GET", "u32", "0"))!;
// unchecked 防止 /checked+ 下 (uint)long 抛 OverflowException
return unchecked((uint)(res[0].IsNull ? 0L : (long)res[0]));
}
// 多状态段:每角色 128 bit,血量 @0、等级 @32、状态 @48
private const int BitsPerRole = 128;
private static int HpOffset(int r) => r * BitsPerRole;
private static int LevelOffset(int r) => r * BitsPerRole + 32;
private static int StatusOffset(int r) => r * BitsPerRole + 48;
// 一条 BITFIELD 三个 SET 原子生效;入口先做位宽范围校验(越界 SET 会静默回绕)
public async Task SetRoleAsync(string roomId, int roleIdx, int hp, int level, int status)
{
if (roleIdx < 0 || roleIdx >= 6) throw new ArgumentOutOfRangeException(nameof(roleIdx));
if (hp < 0 || hp > 65535 || level < 0 || level > 255 || status < 0 || status > 3)
throw new ArgumentOutOfRangeException();
await _db.ExecuteAsync("BITFIELD", (RedisKey)$"game:room:{roomId}:chars",
"SET", "u16", HpOffset(roleIdx), hp,
"SET", "u8", LevelOffset(roleIdx), level,
"SET", "u2", StatusOffset(roleIdx), status);
}
// 只读血量:读路径统一 BITFIELD_RO,可下发副本
public async Task<long> GetHpAsync(string roomId, int roleIdx)
{
var res = ((RedisResult[])await _db.ExecuteAsync("BITFIELD_RO",
(RedisKey)$"game:room:{roomId}:chars", "GET", "u16", HpOffset(roleIdx)))!;
return res[0].IsNull ? 0L : (long)res[0];
}
}
边界 :布局是硬编码约定,发布后不能随意改(改了旧数据全错);跨字节边界读取时部分 SDK 解析易出错,建议按字节对齐;权限超过 63 个改用第二个 u63,更实际是换回 SETBIT 位图(offset 上限 2^32−1)。
为什么权限不用 SETBIT 位图? 两者都只占几字节,差别在"读法":位图按用户 ID 建图、一个用户一个 bit,适合"这批用户里谁有某权限"的批量筛选;位域按用户建 key、权限落在同一个 u32 里,适合"这个用户有哪些权限"的单点查询。权限数固定、按用户查,位域更顺手;要按权限维度批量圈人,仍该用位图 + BITOP。
3.3 滑动窗口:60 个 u16 秒桶
统计"最近 60 秒请求数",用 60 个 u16 桶(每秒一个)。
先说常见错误设计 :把 60 个桶放一个常驻 key、读时全加起来------桶永不复位,算出来的是"有史以来累计"。要正确,key 必须带时间切面:按分钟切 key,桶对应这一分钟内的秒位。
redis
# key 每分钟一个:60 个 u16 秒桶(共 120 字节),TTL 2 分钟防堆积
# 槽位 = 当前秒(假设第 37 秒);SAT 封顶防单秒溢出
BITFIELD rate:user:100:202611051430 OVERFLOW SAT INCRBY u16 #37 1
csharp
public class SlidingWindowCounter
{
private readonly IDatabase _db;
public SlidingWindowCounter(IDatabase db) => _db = db;
// key 每分钟一个:60 个 u16 秒桶;TTL 2 分钟,过期即清,无需复位逻辑
private static string Key(long userId, DateTimeOffset t) =>
$"rate:user:{userId}:{t:yyyyMMddHHmm}";
// 写入 + 首次设 TTL 放进同一个脚本:两条独立命令之间失败会留下永不过期的分钟 key,
// 逐分钟累积就是内存泄漏;同时避免每次 INCR 都重复发 EXPIRE
private const string IncrScript = @"
redis.call('BITFIELD', KEYS[1], 'OVERFLOW', 'SAT', 'INCRBY', 'u16', ARGV[1], 1)
if redis.call('TTL', KEYS[1]) < 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return 1";
// 限流桶只需一致时间轴,不涉及"业务日"语义,直接用 UtcNow
public async Task IncrAsync(long userId)
{
var now = DateTimeOffset.UtcNow;
await _db.ScriptEvaluateAsync(IncrScript,
new RedisKey[] { Key(userId, now) },
new RedisValue[] { $"#{now.Second}", 120 });
}
// 最近 60 秒 = 当前分钟的 [0..nowSec] 段 + 上一分钟的 (nowSec..59] 段
public async Task<long> TotalAsync(long userId)
{
var now = DateTimeOffset.UtcNow;
var cur = await SumRangeAsync(userId, now, 0, now.Second);
var prev = await SumRangeAsync(userId, now.AddMinutes(-1), now.Second + 1, 59);
return cur + prev;
}
private async Task<long> SumRangeAsync(long userId, DateTimeOffset minute, int from, int to)
{
// now.Second == 59 时上一分钟段为空
if (from > to) return 0;
// key 必须是参数数组第一个元素、整包一次传给 ExecuteAsync;
// 写成 ExecuteAsync("BITFIELD_RO", key, args.ToArray()) 会把 object[] 当单个参数装箱,
// 命令帧拼错、服务端收不到并超时(SE.Redis 有公开的故障记录)
var args = new List<object> { (RedisKey)Key(userId, minute) };
for (int i = from; i <= to; i++) args.AddRange(new object[] { "GET", "u16", $"#{i}" });
var res = ((RedisResult[])await _db.ExecuteAsync("BITFIELD_RO", args.ToArray()))!;
return res.Sum(r => r.IsNull ? 0L : (long)r);
}
}
边界 :跨两个 key 的求和有两次往返,精确限流要用 Lua 在服务端一次完成;桶宽 u16 上限 65535,单秒 QPS 极高换 u32;TTL < 0 分支只在 key 首次创建时触发。
3.4 漏斗与分组归属:位图 + BITOP 新操作符
运营要看"访问商品页 → 加购 → 下单"的漏斗,或"这批用户谁同时属于 A 活动、又没参与 B"。这类"多张位图之间的集合关系",正是 BITOP 新操作符的主场。
redis
# 三张位图:每个 bit 对应一个 userId
SETBIT funnel:view 100100 1
SETBIT funnel:cart 100100 1
SETBIT funnel:order 100100 1
# 加购但未下单(在 cart 不在 order)
BITOP DIFF result:cart_no_order funnel:cart funnel:order
# 访问且(加购或下单)------ANDOR 一条搞定
BITOP ANDOR result:engaged funnel:view funnel:cart funnel:order
# 恰好只参与一个活动(分组互斥分析)
BITOP ONE result:only_one campaign:a campaign:b campaign:c
# 统计某一步人数
BITCOUNT result:cart_no_order
C# 端只需一条 ExecuteAsync(见 1.6 的 BITOP 示例),dest 与所有源 key 都显式转 RedisKey。
边界 :需要 userId 稠密有界 (大 offset 陷阱同第 6 篇);跨 Slot 的 key 用 Hash Tag 绑定;结果 key 务必 EXPIRE。
与 3.1~3.3 的分工 :前三节用位域 存"每个对象自己的多个小数字",本节用位图存"每个用户的一个布尔状态"再做集合运算------两者常在同一系统并存。
3.5 场景速查

共同前提只有两条:字段固定、个数已知 ;值域是小整数。一旦"字段会增删""要按字段名查",就该换 Hash。
四、内存账与选型决策
4.1 内存构成:省的是 key 元数据,不是位宽
先算位域数据本身:
text
总位数 = Σ(各字段位宽)
字节数 = ceil(总位数 / 8)
实际分配 = 向上取整到 jemalloc size class(32/48/64/80/96/112/128/256 ...)
字段宽度之和决定理论字节数,真正占多少看 jemalloc 桶。只需要 130 位(约 17 字节),jemalloc 会给 32 字节的桶------所以"省内存"必须连桶对齐一起算。
关键认知:位域省的不是"位域数据比 key 小",而是被合并掉的 key 的固定开销 。一个 key 的固定开销约 77 字节 (redisObject 16 + SDS 头约 9 + dictEntry 24 + 哈希桶 8 + key 名 20)。把 N 个独立计数 key 合并成 1 个位域 key,省下 (N−1) × 77 B,与位域数据本身多大无关。
text
每 key 固定开销 ≈ 77 B
合并 N 个计数器 → 省 (N−1) × 77 B
8 个 u32 计数器:省 7 × 77 ≈ 539 B,而位域数据本身只有 32 B
所以"100 万个 u4 位域 = 500 KB,比 100 万个独立 key 的 77 MB 省 150 倍"是错的对比:左边只算位域数据、右边算完整 key,而且 100 万字段塞进一个 key 会得到 500 KB 的 value,是 64 KB 大 key 线的 8 倍,RDB fork、主从同步、集群迁移都会被拖累。真实布局是"每用户/每帖子一个位域 key",每个 key 同样付 77 B------收益只来自"合并了多少字段"。
单个 key 的内存构成(8×u32 为例):

4.2 三方案量化对比
同样存 8 个计数器,key 名均假设约 20 字节,统一 1000 进制:

两个口径提醒:① 单对象内存的区间(Hash 约 250~350、Bitfield 约 110~180)来自 key 名长度与 jemalloc 桶波动,表中 ~1050/~300/~145 是基准估算。② Hash 一列按 hashtable 编码估 。字段少且短时 Hash 会走 listpack (
hash-max-listpack-entries默认 128,8 个短字段在限内),实际可低至约 150 B------那时 Hash 与 Bitfield 差距只剩约 1.2 倍,"为省内存选位域"的论据明显减弱。选型前先跑一次OBJECT ENCODING,按真实编码算账。
规模账:日活 500 万的内容产品,不压缩(每用户每类一行)是 4000 万个 key × 约 131 B ≈ 5.25 GB ;位域压缩(每用户一个 key 含 8 个 u32)是 500 万 × 约 145 B ≈ 725 MB ,省约 86%。省下的还有主从带宽、RDB 体积、集群迁移时间。
⚠️ 跨表引用数字先确认分母:6.2 的 1050 B 是"每对象 8 个 key 的合计",拿它乘 4000 万会高估 8 倍;77 B 是"每 key",131 B 也是"每 key",三者不能互换。
4.3 64 KB 大 key 线与字段数上界
大 key 线 :单个位域 value 超过 约 64 KB(约 51 万 bit) 就该拆分或改用 Hash------大 String 会放大 RDB fork、主从同步、UNLINK 与集群迁移压力。8×u32 只有 32 字节,离这条线很远;撞线的是"把一整张日活图塞进一个位域 key"这类误用。
每 key 放多少字段:
text
设 F = 字段数,W = 平均位宽(bit),D = ceil(F × W / 8)
约束 1(大 key):D ≤ 64 KB ⇒ F ≤ 64×1024×8 / W(W=32 时约 16384)
约束 2(单命令耗时):一次 BITFIELD 的子命令串行执行,建议 ≤ 几十条
约束 3(单 key 热点):该 key 的写 QPS 不能压垮所在分片
生产取值:约束 1 很宽松,真正卡人的是 2 和 3 ------ 建议 F ≤ 64
4.4 什么时候位域不划算

判断口诀:"少、独立过期、要浮点、要排序、要动态字段 "命中一条就别用位域。最佳射程是"几十个固定布局的小整数、共享生命周期、内存敏感"。
4.5 全套位指令选型决策树
选型不只发生在 String/Hash/Bitfield 之间,也发生在位操作的三个视角之间:
text
业务需求判断
├─ 只需要单 bit 布尔标记,统计 0/1 数量
│ └─ Bitmap:SETBIT / GETBIT / BITCOUNT
├─ 需要多 key 位图之间做 AND/OR/XOR/DIFF 集合运算
│ ├─ 仅离线定时任务执行 → BITOP
│ │ ※ 源 key 映射口径一致;destkey 务必 EXPIRE
│ └─ 业务接口禁止调用 BITOP
└─ 需要在同一个 String 内存储大量窄位宽整型,支持原子自增
├─ 指标上限可评估、schema 长期稳定 → BITFIELD
│ ※ INCRBY 显式 OVERFLOW;同宽字段才用 `#` 相对偏移,混宽手写绝对偏移
├─ 指标上限不可预估、schema 频繁变更 → 放弃 Bitfield,改用独立 String INCR
└─ 只读不写 → BITFIELD_RO(6.2+),下发副本
关键取舍 :Bitfield 是"牺牲可维护性换极致内存压缩"。schema 一变,改一个字段位宽,后面所有偏移全部错位。字段固定、个数已知、值域是小整数三条同时满足才值得用;否则 Hash 或独立 String 更稳。
落到实操,按这个顺序问三句:① 这些值要不要各自过期? 要 → 独立 String,位域做不到。② 字段会不会增删、要不要按名字查? 会 → Hash,位域布局是冻结的。③ 值域是不是 1~63 位的小整数、写入频率可控? 是 → 位域;否则回退。三句都过,再算内存账确认收益足够覆盖维护成本。
五、坑点速查与上线清单
5.1 28 条速查表

5.2 三条必须展开的坑
坑 7|溢出静默回绕(最危险) 。u8 涨到 255 后变 0、i8 涨到 127 后变 -128,日志无报错。根因是默认 WRAP,回绕是正常返回值;服务器不抛异常。规避:显式 OVERFLOW SAT 或 FAIL,绝不用默认。注意 SAT 封顶后报表会静默停涨 ------返回值等于上限也应埋点告警,否则体验和 WRAP 归零一样差。
坑 11|FAIL 全部失败但 key 已预扩展 。全部写子命令返回 nil,EXISTS 却是 1、STRLEN 不为 0,主从可能不一致,AOF 重载后 key 又消失。根因:预扩展发生在子命令执行之前,且因 changes == 0 不落 AOF、不传播(issue #15599,详见 2.6)。规避:不依赖"FAIL = 什么都没发生",对存在性/长度敏感时额外校验。
坑 17|无符号字段的负增量下溢 。库存/余额用 u32,扣到 0 后再 INCRBY u32 0 -1,值变成 4,294,967,295 ,比归零更离谱且不报错。根因:INCRBY 的 increment 可为负,无符号下溢在默认 WRAP 下回绕到最大值。规避:可能有负增量的字段用有符号位宽(i32/i64)+ SAT,或用 FAIL + 应用层处理拒绝,绝不让无符号字段在默认 WRAP 下接负增量。
5.3 两个故障复盘
故障 1[性能]:BITOP AND 合并 30 天日活,主线程卡顿数百毫秒。 30 张约 33 MB 的日活图(对应约 2.6 亿 ID 上界)≈ 1 GB 输入 ,按位运算约 1~2 GB/s 折算,单条命令要跑 0.5~1 秒 ,期间整个实例阻塞。换成 1000 万 ID 上界(1.25 MB/张)则只有 37.5 MB 输入、约 20~40 ms ------别把前者耗时套到自己规模。排查路径:P99 周期性尖刺恰好落在留存任务窗口 → SLOWLOG GET 抓到该 BITOP AND 约 0.8 s → 按输入总字节折算确认是单线程阻塞。
一个反直觉的点:BITOP 的复杂度是 O(N) ,N 是最长输入位图的字节数,不是 key 的个数。30 张图不是"30 次小操作",而是"一次 33 MB 的逐字节扫描"------耗时随单图规模线性涨,和 key 数几乎无关。这也解释了为什么"把 30 天拆成 3 批各 10 天"没有帮助:每批仍是 33 MB 输入,只是分成三次阻塞。
修复:错峰执行;副本执行需先设 replica-read-only no(全局配置,改完该副本可被任何客户端写脏,full resync 即被冲掉,只适合一次性聚合);更稳的是按 ID 分片拆小位图、各算一次再应用层相加,或用独立统计实例;结果 key 设短 TTL。
故障 2[一致性]:两张日活图映射口径不同,BITOP AND 全错。 一次 uid→offset 映射迁移后序号基准变了,新旧两张图(一张 12.5 MB、一张 1.25 MB)直接 BITOP AND,交集统计全错。排查初期怀疑"BITOP 对长度不一致的处理有 bug"------方向就错了:补 0 与缺失位的真实内容一致,BITOP 无辜 ,错的是映射。修复:映射口径变更必须升 key 版本并迁移;定期 STRLEN 抽查,把"同上界的图长度差异过大"当口径漂移的告警信号。
其余四个故障(
u4归零、FAIL预扩展、结果 key 堆积、应用层位反)的根因与修复分别对应坑 7 / 坑 11 / 坑 25 / 坑 19。
5.4 上线前 10 项自检

监控必须客户端埋点------服务端对这些没有指标,等 Redis 提供是等不到的:

六、FAQ 与小结
6.1 FAQ
Q1|BITFIELD 和 SETBIT 什么关系? 同一张 String 的两种视角:SETBIT 管单个比特,BITFIELD 管连续多位。底层都是 SDS 字节串。
Q2|为什么 u64 不支持、i64 却支持? RESP 整数是有符号 64 位;u63 最大值能塞进正数范围,u64 的 2^64-1 超出无法表达。
Q3|写溢出会报错吗? 服务器层面都不抛异常:WRAP/SAT 静默返回回绕/边界值,FAIL 返回 nil。区别在客户端能否感知------只有 FAIL 给了显式拒绝信号,但需主动判 nil。SET 的 value 越界同样走这套策略。
Q4|# 和纯数字偏移有什么区别? 纯数字是"第 n 个比特",#n 是"第 n 个类型宽度的槽位"(offset = n × 位宽)。# 用于同宽数组;混宽时手写绝对偏移更安全。
Q5|GET 读不存在区域会怎样? 返回 0,不报错。所以无法区分"真 0"与"未写"。
Q6|OVERFLOW 影响哪些子命令? 只影响它之后、直到下一条 OVERFLOW 之间的 SET/INCRBY,对 GET 无效。
Q7|一个 String 能省多少内存? 省约 86%;小规模 Hash 走 listpack 时差距缩小到约 1.2 倍,选型前实测编码。
Q8|DIFF 和 DIFF1 有什么区别? 方向相反:DIFF 是 X−Y(在第一个 key 不在其他),DIFF1 是 Y−X。
Q9|应用层自己解析位图为什么左右颠倒? Redis 位序是 MSB-first,字节最高位对应 offset 最小的 bit;正确写法 1 << (7 - (offset & 7))。
Q10|两张位图 BITOP AND 交集偏小,可能是什么原因? 几乎不会是 BITOP 本身------长度不一致时补 0 与缺失位真实内容一致,结果精确。真正要查的是两张图是否用了不同的 uid→offset 映射,STRLEN 差异过大是口径漂移信号。
Q11|同一条命令里 GET 和 INCRBY 谁先生效? 按书写顺序串行执行。想读到自增后的值,GET 必须写在 INCRBY 之后;写在前面的 GET 读到的是旧值。
Q12|位域能存负数吗? i 类型可以(补码表示);u 类型不接受负值写入------SET u8 0 -1 会走溢出策略,默认 WRAP 静默回绕成 255,FAIL 则返回 nil。
6.2 小结
Bitfield 是 Redis 位能力里**"最反直觉、也最容易静默出错"**的一块:把 String 当成可按任意位宽、任意偏移读写的位域数组,收益是内存砍 80%,代价是溢出静默、布局强 Schema 绑定、位序与符号要自己守。
三条最该记住:
- 溢出默认静默 。不写
OVERFLOW,u8255+1 变 0、i8127+1 变 -128,SET越界同样回绕,无符号负增量还会下溢成天文数字。计数器一律SAT;封顶致误判或扣减不可截断时用FAIL;OVERFLOW只向后生效。 - 原子但非事务,
FAIL也不是"什么都没发生" 。单条命令只保证不被并发打断,不回滚;跨命令读-改-写要搬进 Lua 或WATCH;全部写被FAIL拒绝时 key 仍可能已预扩展且不写 AOF、不同步副本。 - 用可维护性换内存压缩 。收益来自消除 per-key 元数据(每合并一个 key 省约 77 B),代价是布局强 Schema 绑定、位序 MSB-first、符号一次定型。字段固定、个数已知、值域小整数三条同时满足才值得用。
用一个 key 装几十个小整数,省的是 key 元数据,付的是"布局即契约"。