Bitmap、Bitfield 与 HyperLogLog
系统梳理 Redis Bitmap、Bitfield 与 HyperLogLog 的使用场景、内存模型、位运算、近似基数统计、Cluster 多 Key 限制和 Go 使用边界。
第 7 章:Bitmap、Bitfield 与 HyperLogLog
1. 本章定位
本章讨论三类“用少量内存表达大量状态”的 Redis 能力:
- Bitmap:用一个 bit 表示一个二值状态,适合签到、活跃、在线、功能开关等场景。
- Bitfield:把 Redis String 看作一组定长整数,适合紧凑存储小计数器、状态码和配额。
- HyperLogLog:不保存原始成员,只估算去重后的元素数量,适合 UV、活跃设备数等近似基数统计。
三者解决的问题不同:
| 能力 | 核心问题 | 是否精确 | 能否返回原始成员 |
|---|---|---|---|
| Bitmap | 某个编号对应的状态是否为 0/1 | 精确 | 可通过扫描位置间接恢复,但成本高 |
| Bitfield | 某个编号对应的小整数是多少 | 精确 | 可以按字段读取 |
| HyperLogLog | 一共有多少个不同元素 | 近似 | 不能 |
本文以 Redis 8.x 为基线,同时标注 Redis 7.x、Redis 8.2 等版本差异。
2. 学习目标
完成本章后,应能够:
- 解释 Bitmap 为什么本质上仍是 Redis String。
- 根据最大 offset 估算 Bitmap 的内存占用,并识别稀疏 ID 风险。
- 熟练使用
SETBIT、GETBIT、BITCOUNT、BITOP、BITPOS。 - 使用 Bitfield 紧凑存储多个整数,并正确选择
WRAP、SAT、FAIL。 - 解释 HyperLogLog 的寄存器、前导零和近似基数估算原理。
- 正确理解
PFADD、PFCOUNT、PFMERGE的语义与复杂度。 - 在 Go 中使用
go-redis/v9实现签到统计、UV 估算与集合合并。 - 在精确统计与近似统计之间做出可解释的工程选型。
2.1 本章边界与跳转
本章是 Bitmap、Bitfield 与 HyperLogLog 的主章节,重点是用较少内存表达大量状态。它们与 Set 的精确集合能力对比见[第 6 章](/blog/tech/Redis/06.Set、Sorted Set 与 GEO/);Bitmap 作为 String 的内部关系和对象编码细节见[第 4 章](/blog/tech/Redis/04.String 的使用场景与底层实现/)与[第 11 章](/blog/tech/Redis/11.Redis 核心对象与底层数据结构/);大范围 BITCOUNT、热 Key 和端到端延迟排查见[第 12 章](/blog/tech/Redis/12.Redis 高性能原理/)与第 20 章。
3. 核心概念
3.1 Bitmap 的本质
Redis 没有单独的 Bitmap 对象类型。Bitmap 是对 String value 中每一个 bit 的解释方式。
例如,一个字节有 8 个 bit:
字节序号: 0
二进制: 0 1 0 0 0 0 1 0
bit offset 0 1 2 3 4 5 6 7
Redis 将 offset 0 定义为第一个字节的最高位,offset 7 是第一个字节的最低位,offset 8 是第二个字节的最高位。
SETBIT 的 offset 必须小于 2^32,因此单个 Bitmap 最多约为 512 MiB。向一个很大的 offset 写入时,Redis 会扩展 String,并将中间区域补零。逻辑复杂度虽然是 O(1),但远端 offset 首次扩容可能产生显著的内存分配和延迟。(Redis)
Bitmap 的近似内存公式为:
占用字节数 ≈ floor(maxOffset / 8) + 1
例如,以用户 ID 直接作为 offset:
最大用户 ID = 1,000,000,000
Bitmap value ≈ 125,000,001 字节 ≈ 119.2 MiB
即使系统里只有 100 个用户,只要其中一个用户 ID 接近 10 亿,这个 Bitmap 仍然需要约 119 MiB。Bitmap 的内存取决于最大 offset,而不是 bit 为 1 的数量。
3.2 Bitmap 的两种建模方向
用户维度:一个用户一个时间窗口
key = signin:{user:42}:2026-06
offset = 当月第几天 - 1
value = 是否签到
这种模型适合回答:
- 用户本月签到了多少天?
- 用户今天是否签到?
- 用户第一次签到是哪一天?
一个月最多 31 bit,单个用户每月只需几个字节,不存在用户 ID 稀疏问题。
群体维度:一个日期一个用户集合
key = active:{2026w25}:2026-06-21
offset = 用户的紧凑编号
value = 当天是否活跃
这种模型适合回答:
- 当天活跃用户数是多少?
- 连续两天都活跃的用户有多少?
- 两天至少活跃一次的用户有多少?
此时可以使用 BITOP AND、BITOP OR 做交集和并集,但必须保证用户编号足够稠密。
3.3 Bitmap 不具备的能力
Bitmap 有三个重要限制:
- 不能给单个 bit 设置 TTL。TTL 只能作用于整个 key。
- 不适合稀疏、超大的编号空间。
- 不擅长枚举成员。虽然可以反复使用
BITPOS或客户端扫描,但这会退化成线性遍历。
因此,在线状态如果需要“每个用户独立过期”,通常还需要 ZSet、独立 key 或心跳时间戳配合,不能只依赖一个 Bitmap。
3.4 Bitfield:把 bit 数组解释成整数数组
Bitmap 中每个逻辑元素只有 1 bit,而 Bitfield 可以使用多个 bit 表示一个整数。
例如,使用 4 bit 表示等级:
0000 = 0
0001 = 1
...
1111 = 15
一个字节可以保存两个 4 bit 整数:
| 用户 0 等级,4 bit | 用户 1 等级,4 bit |
Bitfield 支持:
GET:读取字段。SET:设置字段并返回旧值。INCRBY:增加或减少字段值。OVERFLOW:控制溢出行为。
整数编码包括:
i1到i64:有符号整数。u1到u63:无符号整数。
无符号整数不支持 u64,原因是 Redis 协议不能用普通整数回复完整表示 64 位无符号数。offset 前加 # 表示“字段下标”,Redis 会自动乘以字段宽度。例如,u8 #3 等价于 bit offset 24。(Redis)
3.5 Bitfield 的溢出策略
假设使用 u4,可表示的范围是 0~15。
| 策略 | 15 再加 1 的结果 | 适用场景 |
|---|---|---|
WRAP | 0 | 环形序号、明确需要模运算 |
SAT | 15 | 等级、进度、封顶计数 |
FAIL | 不修改,返回空值 | 配额、余额、安全边界 |
默认策略是 WRAP。这是生产环境最危险的默认值之一:如果字段表示用户等级、库存或配额,到达上限后可能突然回到 0。
一个 OVERFLOW 子命令只影响其后的 SET、INCRBY,直到出现下一个 OVERFLOW。(Redis)
3.6 HyperLogLog:只回答“有多少个”
基数是集合中不同元素的数量:
{A, B, A, C, C} 的基数为 3
精确统计通常需要保存全部唯一元素,例如:
SADD daily:uv user-1
SCARD daily:uv
当一天有一亿个用户时,Set 的内存会随用户数增长。HyperLogLog 不保存全部用户,只保存一组统计寄存器,内存基本保持不变。
Redis 的 HyperLogLog:
- 标准误差约为 0.81%。
- 稠密表示约占 12 KiB。
- 使用 16384 个寄存器,每个寄存器 6 bit。
- 小基数时采用稀疏表示,数据增长后自动转换成稠密表示。(Redis)
注意,0.81% 是标准误差,不是“每次结果一定在真实值上下 0.81% 以内”的硬性保证。
3.7 HyperLogLog 的直觉原理
处理一个元素时:
- 对元素做稳定哈希。
- 使用部分 hash bit 选择一个寄存器。
- 使用剩余 bit 中连续前导零的数量生成观测值。
- 只有新观测值大于寄存器旧值时,才更新该寄存器。
- 查询时综合全部寄存器,估算总体基数。
直觉是:随机二进制序列中,连续出现很多个 0 的概率很低。如果观察到很长的前导零序列,就说明大概率已经见过很多不同元素。
两个 HyperLogLog 合并时,只需对相同位置的寄存器取最大值。因此它天然适合:
- 日 UV 合并为月 UV。
- 多机房统计合并。
- 多业务分片统计合并。
但因为只保存寄存器最大观测值,所以它:
- 不能列出原始用户。
- 不能判断某个用户是否出现过。
- 不能精确删除单个用户。
- 不能直接计算精确交集。
4. 命令与 Go 使用方法
4.1 Bitmap 命令
记录用户签到
2026 年 6 月 21 日对应当月 offset 20:
SETBIT signin:{user:42}:2026-06 20 1
返回值是修改前的 bit:
0:此前未设置
1:此前已经设置
查询当天是否签到:
GETBIT signin:{user:42}:2026-06 20
统计本月签到天数:
BITCOUNT signin:{user:42}:2026-06
查找第一次签到的位置:
BITPOS signin:{user:42}:2026-06 1
SETBIT、GETBIT 是常数复杂度;BITCOUNT、BITPOS 需要扫描相应范围,属于线性复杂度。(Redis)
限制统计范围
默认情况下,BITCOUNT 的 start、end 按字节计算:
BITCOUNT bitmap-key 0 9
Redis 7.0+ 可以显式按 bit 计算:
BITCOUNT bitmap-key 10 20 BIT
BITPOS 在 Redis 7.0+ 同样支持 BYTE 或 BIT 范围单位。
需要特别注意:当不限定范围并查找 0 时,如果已有字符串全是 1,Redis 会把字符串末尾之外理解为补零区域,可能返回字符串之后的第一个 bit;若指定了有限范围且没有找到,则返回 -1。
活跃用户交集与并集
SETBIT active:{week:2026w25}:2026-06-20 10042 1
SETBIT active:{week:2026w25}:2026-06-21 10042 1
# 两天都活跃
BITOP AND active:{week:2026w25}:both \
active:{week:2026w25}:2026-06-20 \
active:{week:2026w25}:2026-06-21
BITCOUNT active:{week:2026w25}:both
# 两天至少活跃一天
BITOP OR active:{week:2026w25}:either \
active:{week:2026w25}:2026-06-20 \
active:{week:2026w25}:2026-06-21
经典 BITOP 支持 AND、OR、XOR、NOT。Redis 8.2+ 又增加了 DIFF、DIFF1、ANDOR、ONE。所有 BITOP 都会生成或覆盖目标 key,并需要扫描输入字符串,因此大 Bitmap 上可能成为慢命令。(Redis)
4.2 Bitfield 命令
将一个 String 解释为 u12 数组,每个值范围为 0~4095:
BITFIELD quota:{tenant:7} \
SET u12 #0 1200 \
GET u12 #0
执行加法并在上限处饱和:
BITFIELD quota:{tenant:7} \
OVERFLOW SAT \
INCRBY u12 #0 3500 \
GET u12 #0
如果原值为 1200,加 3500 超过 4095,结果保持为 4095。
超过范围时拒绝修改:
BITFIELD quota:{tenant:7} \
OVERFLOW FAIL \
INCRBY u12 #0 1
只读场景可以使用 BITFIELD_RO:
BITFIELD_RO quota:{tenant:7} GET u12 #0
一条 BITFIELD 可以包含多个子操作,按照参数顺序执行并返回结果数组。超出已有 String 范围的写操作仍会触发补零扩容,因此同样要防止远端 offset。(Redis)
4.3 HyperLogLog 命令
写入每日 UV
PFADD uv:{campaign:42}:2026-06-20 user-1 user-2 user-1
PFCOUNT uv:{campaign:42}:2026-06-20
重复传入 user-1 不会让基数重复增加。
但 PFADD 返回 1 的准确含义是:
至少一个内部寄存器发生了改变。
它不代表“本次一定加入了一个之前从未出现过的用户”,更不能作为用户首次访问的判断依据。(Redis)
计算多个 HLL 的并集
不保存结果:
PFCOUNT \
uv:{campaign:42}:2026-06-20 \
uv:{campaign:42}:2026-06-21
保存合并结果:
PFMERGE uv:{campaign:42}:2026-06 \
uv:{campaign:42}:2026-06-20 \
uv:{campaign:42}:2026-06-21
PFCOUNT uv:{campaign:42}:2026-06
单 key PFCOUNT 通常很快,并会使用缓存的基数;多 key PFCOUNT 要临时合并多个 HLL,常数成本明显更高。PFMERGE 的复杂度与源 HLL 数量成正比,也被标记为慢命令。(Redis)
还有一个容易忽略的语义:如果目标 key 已经存在,PFMERGE 会把目标自身也纳入本次合并。因此需要“重新计算”时,应先删除旧目标,否则旧数据无法从结果中缩减。(Redis)
4.4 Go 客户端基础配置
使用:
github.com/redis/go-redis/v9
package redisstats
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
const (
normalCommandTimeout = 500 * time.Millisecond
mergeCommandTimeout = 2 * time.Second
)
func NewClient(addr, password string) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DialTimeout: 2 * time.Second,
ReadTimeout: 2 * time.Second,
WriteTimeout: 2 * time.Second,
PoolTimeout: 2 * time.Second,
})
}
redis.Client 内部管理连接池,可以由多个 goroutine 复用;不要为每个请求创建客户端。客户端本身可以并发使用,但单个 Pipeline 或 Tx 对象不应被多个 goroutine 共享。(GitHub)
4.5 Go 实现用户签到
// MarkMonthlySignIn 将业务时区中的“当月第几天”映射到 bit offset。
// changed 表示本次命令观察到旧 bit 为 0。
func MarkMonthlySignIn(
ctx context.Context,
rdb *redis.Client,
userID int64,
at time.Time,
businessLocation *time.Location,
) (changed bool, err error) {
if businessLocation == nil {
return false, errors.New("business location is nil")
}
localTime := at.In(businessLocation)
key := fmt.Sprintf(
"signin:{user:%d}:%s",
userID,
localTime.Format("2006-01"),
)
offset := int64(localTime.Day() - 1)
cmdCtx, cancel := context.WithTimeout(ctx, normalCommandTimeout)
defer cancel()
oldValue, err := rdb.SetBit(cmdCtx, key, offset, 1).Result()
if err != nil {
return false, fmt.Errorf("set sign-in bit: %w", err)
}
return oldValue == 0, nil
}
func MonthlySignInDays(
ctx context.Context,
rdb *redis.Client,
userID int64,
month time.Time,
businessLocation *time.Location,
) (int64, error) {
if businessLocation == nil {
return 0, errors.New("business location is nil")
}
localMonth := month.In(businessLocation)
key := fmt.Sprintf(
"signin:{user:%d}:%s",
userID,
localMonth.Format("2006-01"),
)
cmdCtx, cancel := context.WithTimeout(ctx, normalCommandTimeout)
defer cancel()
count, err := rdb.BitCount(cmdCtx, key, nil).Result()
if err != nil {
return 0, fmt.Errorf("count monthly sign-in bits: %w", err)
}
return count, nil
}
生产中不能仅依据 changed == true 发放不可重复奖励。若客户端超时发生在服务端已经执行之后,重试会看到旧 bit 已经是 1,最终状态正确,但调用方可能无法确定第一次命令是否执行成功。涉及发奖、记账时,需要独立的幂等键或数据库唯一约束。
4.6 Go 实现每日活跃用户估算
func dailyUVKey(campaignID string, at time.Time) string {
return fmt.Sprintf(
"uv:{campaign:%s}:%s",
campaignID,
at.Format("2006-01-02"),
)
}
func AddDailyVisitor(
ctx context.Context,
rdb *redis.Client,
campaignID string,
at time.Time,
visitorID string,
) error {
if visitorID == "" {
return errors.New("visitor ID is empty")
}
key := dailyUVKey(campaignID, at)
cmdCtx, cancel := context.WithTimeout(ctx, normalCommandTimeout)
defer cancel()
// 不使用返回值判断 visitorID 是否为新用户。
if _, err := rdb.PFAdd(cmdCtx, key, visitorID).Result(); err != nil {
return fmt.Errorf("add visitor to hyperloglog: %w", err)
}
return nil
}
func EstimateDailyVisitors(
ctx context.Context,
rdb *redis.Client,
campaignID string,
at time.Time,
) (int64, error) {
key := dailyUVKey(campaignID, at)
cmdCtx, cancel := context.WithTimeout(ctx, normalCommandTimeout)
defer cancel()
count, err := rdb.PFCount(cmdCtx, key).Result()
if err != nil {
return 0, fmt.Errorf("count daily visitors: %w", err)
}
return count, nil
}
相同逻辑用户必须始终转换成相同的 visitorID。例如,一部分请求使用数字用户 ID,另一部分使用邮箱,或者邮箱大小写没有统一,都会被 HLL 当成不同元素。
4.7 Go 实现 HLL 重新合并
// RebuildMergedUV 删除旧目标后重新合并,避免旧目标被 PFMERGE
// 自动作为一个源集合继续纳入结果。
func RebuildMergedUV(
ctx context.Context,
rdb *redis.Client,
destKey string,
sourceKeys []string,
ttl time.Duration,
) (int64, error) {
if destKey == "" {
return 0, errors.New("destination key is empty")
}
if len(sourceKeys) == 0 {
return 0, errors.New("source keys are empty")
}
cmdCtx, cancel := context.WithTimeout(ctx, mergeCommandTimeout)
defer cancel()
var countCmd *redis.IntCmd
_, err := rdb.TxPipelined(cmdCtx, func(pipe redis.Pipeliner) error {
pipe.Del(cmdCtx, destKey)
pipe.PFMerge(cmdCtx, destKey, sourceKeys...)
countCmd = pipe.PFCount(cmdCtx, destKey)
if ttl > 0 {
pipe.Expire(cmdCtx, destKey, ttl)
}
return nil
})
if err != nil {
return 0, fmt.Errorf("rebuild merged hyperloglog: %w", err)
}
count, err := countCmd.Result()
if err != nil {
return 0, fmt.Errorf("read merged cardinality: %w", err)
}
return count, nil
}
在 Redis Cluster 中,destKey 和所有 sourceKeys 必须位于同一 hash slot。例如:
uv:{campaign:42}:2026-06-20
uv:{campaign:42}:2026-06-21
uv:{campaign:42}:month:2026-06
相同的 {campaign:42} 会使这些 key 路由到同一 slot。BITOP、PFMERGE 等多 key 操作在 Cluster 模式下属于单 slot 操作。(Redis)
4.8 错误处理说明
本章多数命令在 key 不存在时不会返回 redis.Nil:
GETBIT返回 0。BITCOUNT返回 0。PFCOUNT返回 0。BITFIELD GET将缺失区域视为全 0。
redis.Nil 仍是 go-redis 中需要识别的正常业务结果,但更多出现在 GET、HGET 等命令。网络错误、超时、Redis 响应错误必须与 key 不存在区分处理。(Redis)
5. 典型业务场景
| 场景 | 推荐方案 | 适用条件 | 不适用条件 | 一致性要求 | 主要风险 | 替代方案 |
|---|---|---|---|---|---|---|
| 用户月签到 | 用户维度 Bitmap | 每月最多 31 个状态 | 需要保存每次签到时间 | 精确 | 时区、日期 offset 错误 | Set、数据库签到表 |
| 日活用户集合 | 日期维度 Bitmap | 用户编号连续、上限明确 | UUID、雪花 ID 直接作为 offset | 精确 | 稀疏 ID、超大 Bitmap | Set、外部压缩位图 |
| 连续多日活跃 | Bitmap + BITOP AND | 输入 Bitmap 大小可控 | 高频实时执行超大交集 | 精确 | BITOP 阻塞、临时结果占内存 | 离线计算、OLAP |
| 人群并集 | Bitmap + BITOP OR | 相同编号体系 | 多个集合编号体系不同 | 精确 | Cluster 跨 slot | Set、离线人群平台 |
| 在线状态 | Bitmap | 状态由外部任务统一清理 | 每个用户需要独立 TTL | 精确但易过期不准 | 断线未清理、陈旧 bit | ZSet 心跳、独立 TTL key |
| 功能开关 | Bitmap | 开关只有开/关两态 | 需要版本、原因、审计信息 | 精确 | 难以表达元数据 | Hash、配置中心 |
| 用户等级或小配额 | Bitfield | 数值范围固定且较小 | 数值范围频繁变化 | 精确 | 溢出、字段布局升级 | Hash、String Counter |
| 日/月 UV | HyperLogLog | 可接受统计误差 | 计费、额度、审计 | 近似 | 误差、无法追溯成员 | Set、数据库去重、OLAP |
| 多分片 UV 合并 | PFMERGE | key 可放在同 slot 或离线汇总 | 需要精确交集 | 近似 | 目标 key 残留、合并延迟 | 流处理、数仓 |
6. 底层实现
6.1 Bitmap 与 String
Bitmap value 仍由 Redis String 保存。设置 offset 时,Redis定位:
byteIndex = offset / 8
bitIndex = offset % 8
然后对对应字节执行掩码操作。
当 byteIndex 超出当前 String 长度时,Redis 必须:
- 申请更大的连续内存。
- 复制或扩展原有数据。
- 将中间新区域补零。
- 修改目标 bit。
因此:
SETBIT 的算法定位是 O(1)
≠
任何 offset 下的真实耗时都完全相同
对短字符串设置极远 bit 时,主要成本来自内存扩容,而不是 bit 运算本身。(Redis)
6.2 Bitmap 聚合命令
| 命令 | 复杂度 | 主要成本 |
|---|---|---|
GETBIT | O(1) | 定位一个 bit |
SETBIT | O(1) | 定位 bit;远端 offset 可能扩容 |
BITFIELD | 每个子操作 O(1) | 字段提取、写入;远端字段可能扩容 |
BITCOUNT | O(N) | 扫描指定字节范围 |
BITPOS | O(N) | 线性查找目标 bit |
BITOP | O(N) | 扫描输入,并写出目标 Bitmap |
BITOP 处理不同长度的输入时,会把较短输入缺失的部分视为 0。目标 key 的大小通常由最长输入决定,因此一次运算可能同时产生较高 CPU、内存带宽和目标 key 写入成本。(Redis)
6.3 Bitfield 的位布局
Bitfield 并不创建独立对象,而是在同一个 String 上提取连续 bit。
例如:
BITFIELD data SET u4 #0 9 SET u4 #1 3
得到:
1001 0011
字段可以跨越字节边界。Redis 的 offset 0 是第一个字节的最高位;字段按字节对齐时,可近似理解为大端布局。(Redis)
设计字段布局时必须固定:
- 字段宽度。
- 有符号或无符号。
- 字段下标含义。
- 版本号。
- 溢出策略。
一旦把 u8 改成 u12,所有后续字段的 offset 都会变化,旧数据不能自动兼容。生产系统应在 key 名中包含布局版本,例如:
user-state:v1:{segment:8}
user-state:v2:{segment:8}
6.4 HyperLogLog 的寄存器表示
Redis 当前实现使用:
P = 14
寄存器数量 = 2^14 = 16384
每个寄存器 = 6 bit
稠密寄存器区 = 16384 × 6 / 8 = 12288 字节
此外还有包含编码和缓存基数等信息的头部。小基数时,Redis 使用游程编码的稀疏表示;当稀疏表示不再划算时,转换为稠密表示。(Redis)
6.5 为什么 HLL 可以合并
设两个 HLL 同一位置的寄存器分别是:
A[i] = 5
B[i] = 7
两组数据并集在该寄存器上观察到的最大前导零长度就是:
max(A[i], B[i]) = 7
因此:
Union[i] = max(A[i], B[i])
这使 HLL 具备可交换、可结合的合并性质,适合分布式聚合。
6.6 为什么 HLL 无法删除单个元素
假设某个元素使寄存器从 4 变成 9。删除该元素时,HLL 不知道:
- 是否还有其他元素也产生了 9。
- 次大观测值是多少。
- 应该回退到 8、7,还是 4。
因为原始元素和次大值都没有保存,所以无法精确撤销。需要删除单个成员时,只能:
- 删除整个时间窗口 key。
- 从原始事件重建。
- 改用精确集合结构。
7. 高性能、高并发、高可用分析
7.1 高性能
控制 Bitmap 的最大长度
不要直接使用以下值作为 offset:
- 雪花 ID。
- 手机号。
- UUID 转整数。
- 数据库长期累积且大量删除后的主键。
更可靠的方式是建立紧凑索引:
业务用户 ID -> 连续 bitmap index
但紧凑 index 不能在旧统计窗口仍存在时随意复用,否则新用户会继承旧用户的历史 bit。
将大 Bitmap 分段
例如每 1000 万用户一个分段:
segment = compactID / 10_000_000
offset = compactID % 10_000_000
key:
active:{2026-06-21}:segment:0
active:{2026-06-21}:segment:1
这样可以:
- 限制单 key 大小。
- 将写入分散到多个分片。
- 并行执行分段统计。
代价是查询需要汇总多个分段,跨 segment 的 BITOP 也要逐段执行。
避免请求链路实时处理超大 BITOP
推荐模式:
原始日 Bitmap
↓ 定时或流式任务
预计算交集/并集 Bitmap
↓
在线请求只做 BITCOUNT 或读取缓存结果
Pipeline 只能减少网络往返,不能降低 BITCOUNT、BITOP 本身的服务器计算量。
分层合并 HLL
不要在每次请求中执行:
PFCOUNT day1 day2 day3 ... day365
更合理的是:
小时 HLL -> 日 HLL -> 月 HLL -> 年 HLL
多 key PFCOUNT 每次都需要临时合并,而单 key PFCOUNT 可以利用缓存。(Redis)
7.2 高并发
单条 SETBIT、PFADD、BITFIELD 命令在 Redis 内部执行时不会与另一条命令交叉执行,因此不会出现客户端读出旧值、在本地修改后再覆盖的典型丢失更新问题。
但以下组合仍然不是天然原子的:
SETBIT
EXPIRE
BITOP
BITCOUNT
DEL 临时结果
先 GETBIT
业务判断
再 SETBIT
需要整体原子性时,应使用:
- 一条复合命令,例如
BITFIELD的多个子操作。 MULTI/EXEC。- Lua 脚本。
- Redis Function。
Redis 事务中的命令会按顺序连续执行,不会被其他客户端命令插入。(Redis)
热 key 问题
即使 SETBIT 很快,所有用户都写入同一个日活 key 时,该 key 仍会形成:
- 单 slot 热点。
- 单主节点写热点。
- 复制流量热点。
- AOF 写入热点。
可以按租户、地域、用户区间或业务分区拆分,然后异步汇总。
重试语义
| 操作 | 最终状态是否天然幂等 | 返回值是否可能因重试变化 |
|---|---|---|
SETBIT key offset 1 | 是 | 是,第一次可能返回 0,重试返回 1 |
PFADD key same-element | 是 | 是,第一次可能返回 1,重试返回 0 |
PFMERGE,源不变 | 通常是 | 目标旧值可能影响重新计算 |
BITFIELD INCRBY | 否 | 会重复增加 |
BITOP,源不变 | 是 | 结果长度返回值稳定 |
因此不能把“客户端收到超时”理解成“服务端一定没有执行”。对 BITFIELD INCRBY 等非幂等操作,应增加请求 ID 去重、Lua 幂等检查或上层事件唯一约束。
7.3 高可用
Bitmap、Bitfield、HyperLogLog 都会参与正常的 RDB、AOF 和主从复制,但它们不会自动获得强一致性。
Redis Cluster 和 Sentinel 通常采用异步复制,故障转移窗口内已经向客户端确认的写入仍有丢失可能。WAIT 可以要求前序写入被一定数量的副本确认,从而缩小风险窗口,但不会把 Redis 变成强一致的 CP 系统。(Redis)
因此:
- 展示型 UV、推荐指标:通常可以接受。
- 签到页面状态:可根据业务容忍度使用 Redis,并由数据库事件补偿。
- 金额、计费、法务审计:不能只依赖 Bitmap 或 HLL。
- 发奖资格:应有数据库唯一记录或可重放事件作为最终依据。
8. 常见错误与生产事故
8.1 事故一:稀疏用户 ID 导致内存突增
现象
一次用户活跃写入后,Redis 内存突然增长数百 MiB,实例延迟升高,甚至触发 OOM。
根因
开发者直接将数据库用户 ID 作为 Bitmap offset,其中一个 ID 接近 40 亿。Redis 为这个 offset 扩展了几乎完整的 512 MiB String。
排查
- 检查最近的
SETBIT参数。 - 使用
STRLEN或MEMORY USAGE查看 key 大小。 - 检查用户 ID 最大值与分布,而不是只统计用户数量。
- 检查慢日志和内存增长时间点。
修复
- 删除异常大 key。
- 改用紧凑 index。
- 按区间分段。
- 稀疏集合改用 Set 或外部压缩位图。
预防
在写入前限制 offset 上界,并监控 Bitmap key 的实际字节长度。远端 offset 首次写入会扩容和补零。(Redis)
8.2 事故二:在线请求执行大范围 BITCOUNT
现象
活动页面偶发 1~2 秒延迟,Redis 同一时间其他请求也变慢。
根因
每次页面请求都对数百 MiB 的 Bitmap 执行全量 BITCOUNT,并同步执行多个 BITOP。
排查
- 查看
SLOWLOG。 - 对照命令延迟与 Bitmap 大小。
- 检查请求链路是否实时生成临时交集 key。
- 检查目标 key 是否被重复覆盖。
修复
- 改为定时预聚合。
- 按分段并行计算。
- 缓存计数结果。
- 只扫描必要的 bit 或字节范围。
预防
不能只依据“命令数量少”判断成本;BITCOUNT、BITOP 都会随处理的数据长度增长。(Redis)
8.3 事故三:签到日期出现跨天错位
现象
东京用户在凌晨签到后,记录出现在前一天;月底时还有用户写入错误月份。
根因
应用使用 UTC 计算 key 和 offset,而产品规则使用东京时间;或者一处使用 day,另一处使用 day-1。
排查
- 检查业务时区。
- 检查 key 月份与 offset 是否由同一个本地时间对象生成。
- 回放 UTC 日期边界附近的请求。
- 检查夏令时地区的测试用例。
修复
先统一转换为业务时区,再同时计算月份 key 和 day-1 offset。
预防
建立如下边界测试:
- 每月第一天。
- 每月最后一天。
- UTC 与业务时区跨日时刻。
- 闰年 2 月 29 日。
- 客户端时间与服务端时间不一致。
8.4 事故四:Bitfield 计数达到上限后归零
现象
用户等级从最高等级突然变成 0,限额计数出现负数或小值。
根因
使用 u4 或 i8 存储计数,但没有显式指定 OVERFLOW,默认 WRAP 使数值环绕。
排查
- 确认字段编码。
- 计算该编码的最小值、最大值。
- 检查所有写入路径是否显式设置溢出策略。
- 使用
BITFIELD ... GET读取原始字段。
修复
- 等级、进度改用
SAT。 - 配额、余额改用
FAIL。 - 扩大字段宽度并迁移旧数据。
预防
代码评审中禁止业务计数依赖默认 WRAP,字段布局必须文档化。(Redis)
8.5 事故五:使用 HyperLogLog 作为精确计费依据
现象
广告平台按照 HLL 的 UV 结果向客户计费,客户用明细日志复核后发现数量不一致。
根因
团队把“标准误差约 0.81%”错误理解成“结果精确”或“误差永远不会超过 0.81%”。
排查
- 确认计费数据是否来自
PFCOUNT。 - 与数据库或日志明细做精确去重对比。
- 检查用户标识是否规范化。
- 检查是否混入匿名 ID、登录 ID 等多种身份。
修复
- 计费改用可审计明细和精确去重。
- HLL 只用于看板、容量规划和趋势。
- 对历史账单重新计算。
预防
所有 HLL 指标名称显式标记 estimated,接口文档说明统计误差与不可追溯性。(Redis)
8.6 事故六:重新合并 HLL 后统计无法下降
现象
删除了错误日期的数据并重新执行 PFMERGE,月 UV 仍然维持在原来的高值。
根因
目标月 HLL 已经存在,而 PFMERGE 会把目标自身也当成一个源。旧寄存器继续参与合并,因此结果只能保留或增大,不能按新源数据缩减。
排查
- 查看合并命令的目标 key。
- 检查目标是否在合并前删除。
- 检查任务是增量合并还是全量重建。
- 对比使用新目标 key 的结果。
修复
全量重建时,在事务中先 DEL dest,再执行 PFMERGE。
预防
区分两种任务:
增量累计:保留目标
全量重建:先删除目标
该行为是 PFMERGE 的明确命令语义。(Redis)
8.7 事故七:Redis Cluster 出现 CROSSSLOT
现象
单机环境运行正常,迁移 Redis Cluster 后,BITOP、PFMERGE 报错:
CROSSSLOT Keys in request don't hash to the same slot
根因
输入 key 与目标 key 没有相同 hash tag,被分配到不同 slot。
排查
- 使用
CLUSTER KEYSLOT检查各 key。 - 检查大括号中的 hash tag。
- 确认目标 key 也使用相同 tag。
- 检查客户端是否在应用层拆分过 key。
修复
例如统一为:
active:{tenant:7}:2026-06-20
active:{tenant:7}:2026-06-21
active:{tenant:7}:both
预防
在 key 设计阶段列出全部多 key 命令。但也不能让所有业务 key 共用一个 hash tag,否则会将全部数据集中到同一 slot,形成热点。(Redis)
9. 方案选型与权衡
9.1 Bitmap、Set、HyperLogLog 对比
| 维度 | Bitmap | Set | HyperLogLog |
|---|---|---|---|
| 统计结果 | 精确 | 精确 | 近似 |
| 判断成员是否存在 | 支持 | 支持 | 不支持 |
| 枚举成员 | 困难 | 支持 | 不支持 |
| 删除单个成员 | 清零对应 bit | 支持 | 不支持 |
| 内存模型 | 与最大 offset 有关 | 与成员数量及成员长度有关 | 基本固定 |
| 稀疏 ID | 很差 | 较好 | 不受 ID 数值大小影响 |
| 交集、并集 | BITOP | SINTER、SUNION | 只适合并集基数 |
| 典型用途 | 状态、人群、签到 | 精确去重、成员集合 | UV、独立设备数 |
| 审计能力 | 中等 | 高 | 低 |
| Cluster 多 key | 需要同 slot | 需要同 slot | 多 key 操作需要同 slot |
9.2 Bitmap 与 Bitfield 对比
| 需求 | Bitmap | Bitfield |
|---|---|---|
| 每个对象只有开/关状态 | 最合适 | 可以但没有必要 |
| 每个对象有 0~15 等级 | 需要 4 个 Bitmap 或复杂编码 | 使用 u4 |
| 原子增加小计数器 | 不支持 | INCRBY |
| 需要溢出控制 | 不适用 | WRAP、SAT、FAIL |
| 字段布局升级 | 相对简单 | 需要谨慎迁移 |
| 按状态做大规模位运算 | 支持 | 通常不适合 |
9.3 精确统计与近似统计决策
选择精确统计:
- 结果参与计费、结算、额度或风控。
- 需要返回成员列表。
- 需要删除、修正单个元素。
- 需要解释某个用户为何被统计。
- 数据规模可接受,或已有 OLAP 系统。
选择 HyperLogLog:
- 只关心数量级和趋势。
- 基数非常大。
- 可接受统计误差。
- 不需要成员列表。
- 需要低成本跨窗口、跨分片合并。
常见组合方案是:
实时看板:HyperLogLog
最终报表:数仓精确去重
异常排查:保留原始事件日志
10. 高频面试题
10.1 问题:Redis Bitmap 是一种独立数据类型吗?
推荐回答
不是。Bitmap 本质上是 Redis String 的 bit 视图。SETBIT、GETBIT 等命令按照 bit offset 操作 String 中的字节。它的优势是一个二值状态只占一个 bit,但内存由最大 offset 决定。
进一步回答时,应指出远端 offset 会使 String 扩容并补零,单个 Bitmap 的 offset 上限小于 2^32。
面试官追问
- offset 为 10 亿时大约占多少内存?
- offset 0 对应一个字节中的哪一位?
常见错误回答
“Bitmap 是 Redis 专门实现的压缩集合,内存只和 1 的数量有关。”
评分点
- 初级:知道一个 bit 表示一个状态。
- 中级:知道底层是 String,能估算内存。
- 高级:能说明扩容延迟、稀疏 ID 和分段策略。
10.2 问题:为什么 Bitmap 不适合直接保存雪花 ID?
推荐回答
雪花 ID 数值很大且稀疏。Bitmap 要为最大 offset 之前的全部 bit 保留地址空间,即使只有一个 bit 为 1,也可能需要极大的连续 String;而且 Redis Bitmap offset 还有 <2^32 的限制。
应先映射成连续 index,或者改用 Set、数据库、压缩位图系统。
面试官追问
- 连续 index 可以重复利用吗?
- 如何将 Bitmap 分段?
常见错误回答
“Bitmap 会自动压缩连续的 0,因此雪花 ID 没有问题。”
评分点
- 初级:知道雪花 ID 很大。
- 中级:说明内存取决于最大 offset。
- 高级:说明 index 生命周期、分段和一致性问题。
10.3 问题:SETBIT 是 O(1),为什么仍可能造成延迟抖动?
推荐回答
O(1) 描述的是 bit 定位与修改次数,不代表所有输入下成本相同。如果对一个很短或不存在的 String 设置很远的 offset,Redis 必须分配并补零中间区域,真实成本可能由数百 MiB 内存扩容主导。
面试官追问
- 如何提前发现这种风险?
- 预先设置最大 bit 是否一定安全?
常见错误回答
“O(1) 命令不可能是慢命令。”
评分点
- 初级:知道 O(1)。
- 中级:指出远端 offset 扩容。
- 高级:能联系内存分配、主线程阻塞和容量治理。
10.4 问题:BITCOUNT 和 BITOP 有什么性能风险?
推荐回答
两者都需要扫描 Bitmap 内容,成本随处理字节数增长。BITOP 还会创建或覆盖目标 String,消耗额外内存带宽。对几百 MiB 的 Bitmap 实时执行可能阻塞同一 Redis 节点上的其他请求。
应限制范围、分段、离线预计算或缓存结果。
面试官追问
- Pipeline 能解决这个问题吗?
BITOP的目标 key 应如何管理?
常见错误回答
“Redis 是内存数据库,所以扫描几百 MiB 也没有影响。”
评分点
- 初级:知道它们比
GETBIT慢。 - 中级:知道是
O(N)。 - 高级:能提出分段、预聚合、临时 key 生命周期方案。
10.5 问题:Bitfield 相比 Hash 有什么优势和缺点?
推荐回答
Bitfield 用固定 bit 宽度紧凑存储大量小整数,单个值可能只占 4、8 或 12 bit,并支持原子 GET、SET、INCRBY。
缺点是可读性差、字段布局固定、扩展和迁移困难、没有单字段 TTL,还必须显式处理数值溢出。Hash 更容易理解和演进,但每个 field、value 有额外内存开销。
面试官追问
u8 #3的 offset 是多少?- 如何升级
u8到u12?
常见错误回答
“Bitfield 就是更省内存的 Hash,可以无成本替换。”
评分点
- 初级:知道可以保存小整数。
- 中级:知道字段宽度和
#offset。 - 高级:能讨论布局版本、迁移和可维护性。
10.6 问题:WRAP、SAT、FAIL 应如何选择?
推荐回答
WRAP:溢出后环绕,适合明确需要模运算的序号。SAT:封顶或保底,适合等级、进度。FAIL:拒绝越界,适合配额、余额和安全约束。
默认是 WRAP,业务计数不应无意识依赖默认值。
面试官追问
i8的 127 加 1 在WRAP下是多少?FAIL时字段是否会被修改?
常见错误回答
“超过范围时 Redis 都会报错。”
评分点
- 初级:能说出三种模式。
- 中级:能举出正确业务场景。
- 高级:能说明默认值风险和响应处理。
10.7 问题:如何使用 Bitmap 设计签到系统?
推荐回答
按用户、月份建 key:
signin:{userID}:yyyy-MM
以 day-1 为 offset,签到使用 SETBIT,查询使用 GETBIT,月签到天数使用 BITCOUNT,首次签到日可使用 BITPOS 1。
必须统一业务时区,并为历史月份 key 设置生命周期。该模型只保存“某天是否签到”,如果需要签到时间、渠道、补签原因,还要额外保存明细。
面试官追问
- 如何判断连续签到?
SETBIT返回旧值能否直接触发发奖?
常见错误回答
“把用户 ID 作为 offset,一天建一个 key。”
评分点
- 初级:能写出 key 和 offset。
- 中级:考虑月份、TTL、时区。
- 高级:讨论连续签到算法、幂等发奖和明细补偿。
10.8 问题:Redis HyperLogLog 的误差和内存是多少?
推荐回答
Redis HyperLogLog 的标准误差约 0.81%,稠密表示约 12 KiB,内存不会随着基数线性增长。当前实现使用 16384 个 6 bit 寄存器,小基数时还可以使用更小的稀疏表示。
0.81% 是统计标准误差,不是绝对误差上限。
面试官追问
- 为什么前导零能估算基数?
- 为什么寄存器可以合并?
常见错误回答
“误差最大就是 0.81%,而且每次结果都会偏小。”
评分点
- 初级:知道是近似去重。
- 中级:知道误差和固定内存。
- 高级:能解释寄存器、前导零与统计估算。
10.9 问题:PFADD 返回 1 是否说明新增了唯一用户?
推荐回答
不能。返回 1 只表示至少一个 HLL 内部寄存器发生了改变,或估算状态发生变化。HLL 不保存成员集合,因此不能用返回值判断某个用户是否首次出现。
需要精确首次判断时,应使用 SADD 的返回值、数据库唯一约束或独立幂等记录。
面试官追问
- 同一个元素重试是否安全?
- 为什么新元素可能返回 0?
常见错误回答
“与 SADD 一样,返回 1 就是新成员。”
评分点
- 初级:知道
PFADD用于 HLL。 - 中级:说清寄存器变化语义。
- 高级:能说明重试返回值和首次事件设计。
10.10 问题:什么时候选 Set,什么时候选 HyperLogLog?
推荐回答
需要精确数量、成员判断、枚举、删除或审计时选择 Set。只需要超大规模去重数量、能够接受误差、不需要原始成员时选择 HyperLogLog。
例如实时 UV 看板适合 HLL,广告计费和用户权益判断应使用精确数据。
面试官追问
- 能否同时使用二者?
- HLL 能否计算交集?
常见错误回答
“数据超过一万就必须使用 HLL。”
评分点
- 初级:区分精确与近似。
- 中级:结合业务正确选型。
- 高级:提出实时近似加离线精确的组合架构。
10.11 问题:PFCOUNT key1 key2 与 PFMERGE dest key1 key2 有什么区别?
推荐回答
多 key PFCOUNT 临时合并并返回并集基数,不保存合并后的 HLL;PFMERGE 会把结果写入目标 key,适合后续反复查询或分层聚合。
多 key PFCOUNT 不能使用单 key 基数缓存,频繁执行成本较高。还要注意 PFMERGE 会把已有目标 key 自身纳入合并。
面试官追问
- 如何全量重建目标 HLL?
- Cluster 中有什么限制?
常见错误回答
“二者只有是否返回数字的区别,目标旧值没有影响。”
评分点
- 初级:知道一个查询、一个存储。
- 中级:知道性能差异。
- 高级:指出目标残留、同 slot 和分层聚合。
10.12 问题:Go 中使用这些命令时如何处理并发、超时和重试?
推荐回答
共享一个 redis.Client,由其连接池服务多个 goroutine;每次调用继承上游 Context,并设置合理 deadline。命令错误要区分超时、网络错误、Redis 响应错误和 redis.Nil。
SETBIT 1、重复 PFADD 的最终状态近似幂等,但返回值可能因重试变化;BITFIELD INCRBY 非幂等,不能在结果未知时盲目重试。Pipeline 只降低网络往返,不提供原子性;需要原子执行时使用事务或脚本。
面试官追问
- 客户端超时是否代表 Redis 未执行?
- Pipeline 可以跨 goroutine 共享吗?
- Cluster 下如何设计 key?
常见错误回答
“所有 Redis 命令都可以自动重试三次,因为 Redis 是幂等的。”
评分点
- 初级:会传 Context 并检查 error。
- 中级:理解客户端复用、超时和幂等性。
- 高级:能处理结果未知、事务、Cluster slot 与业务去重。
11. 一分钟面试回答
Bitmap 本质是 Redis String 的 bit 视图,一个 bit 表示一个二值状态,适合签到、活跃用户和功能开关。它的内存由最大 offset 决定,因此必须避免直接使用稀疏的大 ID。SETBIT、GETBIT 是常数复杂度,而 BITCOUNT、BITPOS、BITOP 会扫描数据,大 Bitmap 上可能阻塞。
Bitfield 则把 String 中连续的 bit 解释成 u8、i16 等小整数,支持原子 GET、SET、INCRBY,需要特别注意默认 WRAP 溢出。
HyperLogLog 用约 12 KiB 内存估算超大集合基数,标准误差约 0.81%,适合 UV,但不能返回成员、判断成员或精确删除。精确统计用 Set、数据库或数仓;看板和趋势可以用 HLL。Cluster 中执行 BITOP、PFMERGE 时,所有 key 必须位于同一 hash slot。
12. 本章总结
- Bitmap 是 String 的 bit 操作方式,不是独立数据类型。
- Bitmap 内存与最大 offset 相关,不与 bit 为 1 的数量相关。
- 用户签到适合“一个用户一个月一个 Bitmap”。
- 群体交并集适合“一个日期一个 Bitmap”,但用户编号必须紧凑。
BITCOUNT、BITPOS、BITOP都可能因数据规模变大而产生长延迟。- Bitfield 适合紧凑的小整数数组,但字段布局和溢出策略必须显式设计。
- HyperLogLog 以不可逆、不可枚举为代价,换取近似固定的内存。
PFADD返回 1 不代表新增了一个精确唯一成员。PFMERGE会包含已有目标值,全量重建时应先删除目标。- Redis Cluster 中的多 key 位运算和 HLL 合并要求 key 位于同一 slot。
- Redis 客户端超时不代表命令一定没有执行,非幂等写入不能盲目重试。
- 展示型统计可以近似,计费、权益和审计必须有精确数据源。
13. 自测清单
- offset 为 8000 万的 Bitmap 至少需要多少字节?
- 为什么只有 10 个元素的 Bitmap 也可能占用数百 MiB?
- 用户月签到为什么通常不使用用户 ID 作为 offset?
BITCOUNT key 10 20默认按 bit 还是按字节?BITFIELD中u12 #5对应的起始 bit offset 是多少?u4的值为 15 时,在三种溢出策略下加 1 分别会怎样?- 为什么 HyperLogLog 可以计算并集,却不能删除单个成员?
PFADD返回 0 是否能证明传入元素以前出现过?- 为什么全量重建 HLL 时需要先删除旧目标 key?
- Go 客户端收到
context.DeadlineExceeded后,为什么不能直接重试BITFIELD INCRBY?
14. 官方资料
- Redis
SETBIT命令、offset 上限与远端扩容说明。(Redis) - Redis
BITCOUNT、BITOP命令与复杂度说明。(Redis) - Redis
BITFIELD编码、offset 与溢出策略。(Redis) - Redis HyperLogLog 数据结构说明。(Redis)
- Redis
PFADD、PFCOUNT、PFMERGE命令语义。(Redis) - Redis 8.x HyperLogLog 源码中的寄存器与编码常量。(GitHub)
- go-redis Bitmap 与 HyperLogLog 方法定义。(GitHub)
- go-redis 错误处理说明。(Redis)
- Redis Cluster 多 key 操作与 hash tag 规则。(Redis)