返回文章列表

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. 学习目标

完成本章后,应能够:

  1. 解释 Bitmap 为什么本质上仍是 Redis String。
  2. 根据最大 offset 估算 Bitmap 的内存占用,并识别稀疏 ID 风险。
  3. 熟练使用 SETBITGETBITBITCOUNTBITOPBITPOS
  4. 使用 Bitfield 紧凑存储多个整数,并正确选择 WRAPSATFAIL
  5. 解释 HyperLogLog 的寄存器、前导零和近似基数估算原理。
  6. 正确理解 PFADDPFCOUNTPFMERGE 的语义与复杂度。
  7. 在 Go 中使用 go-redis/v9 实现签到统计、UV 估算与集合合并。
  8. 在精确统计与近似统计之间做出可解释的工程选型。

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 ANDBITOP OR 做交集和并集,但必须保证用户编号足够稠密。

3.3 Bitmap 不具备的能力

Bitmap 有三个重要限制:

  1. 不能给单个 bit 设置 TTL。TTL 只能作用于整个 key。
  2. 不适合稀疏、超大的编号空间
  3. 不擅长枚举成员。虽然可以反复使用 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:控制溢出行为。

整数编码包括:

  • i1i64:有符号整数。
  • u1u63:无符号整数。

无符号整数不支持 u64,原因是 Redis 协议不能用普通整数回复完整表示 64 位无符号数。offset 前加 # 表示“字段下标”,Redis 会自动乘以字段宽度。例如,u8 #3 等价于 bit offset 24。(Redis)

3.5 Bitfield 的溢出策略

假设使用 u4,可表示的范围是 0~15。

策略15 再加 1 的结果适用场景
WRAP0环形序号、明确需要模运算
SAT15等级、进度、封顶计数
FAIL不修改,返回空值配额、余额、安全边界

默认策略是 WRAP。这是生产环境最危险的默认值之一:如果字段表示用户等级、库存或配额,到达上限后可能突然回到 0。

一个 OVERFLOW 子命令只影响其后的 SETINCRBY,直到出现下一个 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 的直觉原理

处理一个元素时:

  1. 对元素做稳定哈希。
  2. 使用部分 hash bit 选择一个寄存器。
  3. 使用剩余 bit 中连续前导零的数量生成观测值。
  4. 只有新观测值大于寄存器旧值时,才更新该寄存器。
  5. 查询时综合全部寄存器,估算总体基数。

直觉是:随机二进制序列中,连续出现很多个 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

SETBITGETBIT 是常数复杂度;BITCOUNTBITPOS 需要扫描相应范围,属于线性复杂度。(Redis)

限制统计范围

默认情况下,BITCOUNTstartend 按字节计算:

BITCOUNT bitmap-key 0 9

Redis 7.0+ 可以显式按 bit 计算:

BITCOUNT bitmap-key 10 20 BIT

BITPOS 在 Redis 7.0+ 同样支持 BYTEBIT 范围单位。

需要特别注意:当不限定范围并查找 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 支持 ANDORXORNOT。Redis 8.2+ 又增加了 DIFFDIFF1ANDORONE。所有 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 复用;不要为每个请求创建客户端。客户端本身可以并发使用,但单个 PipelineTx 对象不应被多个 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。BITOPPFMERGE 等多 key 操作在 Cluster 模式下属于单 slot 操作。(Redis)

4.8 错误处理说明

本章多数命令在 key 不存在时不会返回 redis.Nil

  • GETBIT 返回 0。
  • BITCOUNT 返回 0。
  • PFCOUNT 返回 0。
  • BITFIELD GET 将缺失区域视为全 0。

redis.Nil 仍是 go-redis 中需要识别的正常业务结果,但更多出现在 GETHGET 等命令。网络错误、超时、Redis 响应错误必须与 key 不存在区分处理。(Redis)


5. 典型业务场景

场景推荐方案适用条件不适用条件一致性要求主要风险替代方案
用户月签到用户维度 Bitmap每月最多 31 个状态需要保存每次签到时间精确时区、日期 offset 错误Set、数据库签到表
日活用户集合日期维度 Bitmap用户编号连续、上限明确UUID、雪花 ID 直接作为 offset精确稀疏 ID、超大 BitmapSet、外部压缩位图
连续多日活跃Bitmap + BITOP AND输入 Bitmap 大小可控高频实时执行超大交集精确BITOP 阻塞、临时结果占内存离线计算、OLAP
人群并集Bitmap + BITOP OR相同编号体系多个集合编号体系不同精确Cluster 跨 slotSet、离线人群平台
在线状态Bitmap状态由外部任务统一清理每个用户需要独立 TTL精确但易过期不准断线未清理、陈旧 bitZSet 心跳、独立 TTL key
功能开关Bitmap开关只有开/关两态需要版本、原因、审计信息精确难以表达元数据Hash、配置中心
用户等级或小配额Bitfield数值范围固定且较小数值范围频繁变化精确溢出、字段布局升级Hash、String Counter
日/月 UVHyperLogLog可接受统计误差计费、额度、审计近似误差、无法追溯成员Set、数据库去重、OLAP
多分片 UV 合并PFMERGEkey 可放在同 slot 或离线汇总需要精确交集近似目标 key 残留、合并延迟流处理、数仓

6. 底层实现

6.1 Bitmap 与 String

Bitmap value 仍由 Redis String 保存。设置 offset 时,Redis定位:

byteIndex = offset / 8
bitIndex  = offset % 8

然后对对应字节执行掩码操作。

byteIndex 超出当前 String 长度时,Redis 必须:

  1. 申请更大的连续内存。
  2. 复制或扩展原有数据。
  3. 将中间新区域补零。
  4. 修改目标 bit。

因此:

SETBIT 的算法定位是 O(1)

任何 offset 下的真实耗时都完全相同

对短字符串设置极远 bit 时,主要成本来自内存扩容,而不是 bit 运算本身。(Redis)

6.2 Bitmap 聚合命令

命令复杂度主要成本
GETBITO(1)定位一个 bit
SETBITO(1)定位 bit;远端 offset 可能扩容
BITFIELD每个子操作 O(1)字段提取、写入;远端字段可能扩容
BITCOUNTO(N)扫描指定字节范围
BITPOSO(N)线性查找目标 bit
BITOPO(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 只能减少网络往返,不能降低 BITCOUNTBITOP 本身的服务器计算量。

分层合并 HLL

不要在每次请求中执行:

PFCOUNT day1 day2 day3 ... day365

更合理的是:

小时 HLL -> 日 HLL -> 月 HLL -> 年 HLL

多 key PFCOUNT 每次都需要临时合并,而单 key PFCOUNT 可以利用缓存。(Redis)


7.2 高并发

单条 SETBITPFADDBITFIELD 命令在 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 参数。
  • 使用 STRLENMEMORY 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 或字节范围。

预防

不能只依据“命令数量少”判断成本;BITCOUNTBITOP 都会随处理的数据长度增长。(Redis)


8.3 事故三:签到日期出现跨天错位

现象

东京用户在凌晨签到后,记录出现在前一天;月底时还有用户写入错误月份。

根因

应用使用 UTC 计算 key 和 offset,而产品规则使用东京时间;或者一处使用 day,另一处使用 day-1

排查

  • 检查业务时区。
  • 检查 key 月份与 offset 是否由同一个本地时间对象生成。
  • 回放 UTC 日期边界附近的请求。
  • 检查夏令时地区的测试用例。

修复

先统一转换为业务时区,再同时计算月份 key 和 day-1 offset。

预防

建立如下边界测试:

  • 每月第一天。
  • 每月最后一天。
  • UTC 与业务时区跨日时刻。
  • 闰年 2 月 29 日。
  • 客户端时间与服务端时间不一致。

8.4 事故四:Bitfield 计数达到上限后归零

现象

用户等级从最高等级突然变成 0,限额计数出现负数或小值。

根因

使用 u4i8 存储计数,但没有显式指定 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 后,BITOPPFMERGE 报错:

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 对比

维度BitmapSetHyperLogLog
统计结果精确精确近似
判断成员是否存在支持支持不支持
枚举成员困难支持不支持
删除单个成员清零对应 bit支持不支持
内存模型与最大 offset 有关与成员数量及成员长度有关基本固定
稀疏 ID很差较好不受 ID 数值大小影响
交集、并集BITOPSINTERSUNION只适合并集基数
典型用途状态、人群、签到精确去重、成员集合UV、独立设备数
审计能力中等
Cluster 多 key需要同 slot需要同 slot多 key 操作需要同 slot

9.2 Bitmap 与 Bitfield 对比

需求BitmapBitfield
每个对象只有开/关状态最合适可以但没有必要
每个对象有 0~15 等级需要 4 个 Bitmap 或复杂编码使用 u4
原子增加小计数器不支持INCRBY
需要溢出控制不适用WRAPSATFAIL
字段布局升级相对简单需要谨慎迁移
按状态做大规模位运算支持通常不适合

9.3 精确统计与近似统计决策

选择精确统计:

  • 结果参与计费、结算、额度或风控。
  • 需要返回成员列表。
  • 需要删除、修正单个元素。
  • 需要解释某个用户为何被统计。
  • 数据规模可接受,或已有 OLAP 系统。

选择 HyperLogLog:

  • 只关心数量级和趋势。
  • 基数非常大。
  • 可接受统计误差。
  • 不需要成员列表。
  • 需要低成本跨窗口、跨分片合并。

常见组合方案是:

实时看板:HyperLogLog
最终报表:数仓精确去重
异常排查:保留原始事件日志

10. 高频面试题

10.1 问题:Redis Bitmap 是一种独立数据类型吗?

推荐回答

不是。Bitmap 本质上是 Redis String 的 bit 视图。SETBITGETBIT 等命令按照 bit offset 操作 String 中的字节。它的优势是一个二值状态只占一个 bit,但内存由最大 offset 决定。

进一步回答时,应指出远端 offset 会使 String 扩容并补零,单个 Bitmap 的 offset 上限小于 2^32

面试官追问

  1. offset 为 10 亿时大约占多少内存?
  2. 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、数据库、压缩位图系统。

面试官追问

  1. 连续 index 可以重复利用吗?
  2. 如何将 Bitmap 分段?

常见错误回答

“Bitmap 会自动压缩连续的 0,因此雪花 ID 没有问题。”

评分点

  • 初级:知道雪花 ID 很大。
  • 中级:说明内存取决于最大 offset。
  • 高级:说明 index 生命周期、分段和一致性问题。

10.3 问题:SETBIT 是 O(1),为什么仍可能造成延迟抖动?

推荐回答

O(1) 描述的是 bit 定位与修改次数,不代表所有输入下成本相同。如果对一个很短或不存在的 String 设置很远的 offset,Redis 必须分配并补零中间区域,真实成本可能由数百 MiB 内存扩容主导。

面试官追问

  1. 如何提前发现这种风险?
  2. 预先设置最大 bit 是否一定安全?

常见错误回答

“O(1) 命令不可能是慢命令。”

评分点

  • 初级:知道 O(1)。
  • 中级:指出远端 offset 扩容。
  • 高级:能联系内存分配、主线程阻塞和容量治理。

10.4 问题:BITCOUNTBITOP 有什么性能风险?

推荐回答

两者都需要扫描 Bitmap 内容,成本随处理字节数增长。BITOP 还会创建或覆盖目标 String,消耗额外内存带宽。对几百 MiB 的 Bitmap 实时执行可能阻塞同一 Redis 节点上的其他请求。

应限制范围、分段、离线预计算或缓存结果。

面试官追问

  1. Pipeline 能解决这个问题吗?
  2. BITOP 的目标 key 应如何管理?

常见错误回答

“Redis 是内存数据库,所以扫描几百 MiB 也没有影响。”

评分点

  • 初级:知道它们比 GETBIT 慢。
  • 中级:知道是 O(N)
  • 高级:能提出分段、预聚合、临时 key 生命周期方案。

10.5 问题:Bitfield 相比 Hash 有什么优势和缺点?

推荐回答

Bitfield 用固定 bit 宽度紧凑存储大量小整数,单个值可能只占 4、8 或 12 bit,并支持原子 GETSETINCRBY

缺点是可读性差、字段布局固定、扩展和迁移困难、没有单字段 TTL,还必须显式处理数值溢出。Hash 更容易理解和演进,但每个 field、value 有额外内存开销。

面试官追问

  1. u8 #3 的 offset 是多少?
  2. 如何升级 u8u12

常见错误回答

“Bitfield 就是更省内存的 Hash,可以无成本替换。”

评分点

  • 初级:知道可以保存小整数。
  • 中级:知道字段宽度和 # offset。
  • 高级:能讨论布局版本、迁移和可维护性。

10.6 问题:WRAPSATFAIL 应如何选择?

推荐回答

  • WRAP:溢出后环绕,适合明确需要模运算的序号。
  • SAT:封顶或保底,适合等级、进度。
  • FAIL:拒绝越界,适合配额、余额和安全约束。

默认是 WRAP,业务计数不应无意识依赖默认值。

面试官追问

  1. i8 的 127 加 1 在 WRAP 下是多少?
  2. FAIL 时字段是否会被修改?

常见错误回答

“超过范围时 Redis 都会报错。”

评分点

  • 初级:能说出三种模式。
  • 中级:能举出正确业务场景。
  • 高级:能说明默认值风险和响应处理。

10.7 问题:如何使用 Bitmap 设计签到系统?

推荐回答

按用户、月份建 key:

signin:{userID}:yyyy-MM

day-1 为 offset,签到使用 SETBIT,查询使用 GETBIT,月签到天数使用 BITCOUNT,首次签到日可使用 BITPOS 1

必须统一业务时区,并为历史月份 key 设置生命周期。该模型只保存“某天是否签到”,如果需要签到时间、渠道、补签原因,还要额外保存明细。

面试官追问

  1. 如何判断连续签到?
  2. SETBIT 返回旧值能否直接触发发奖?

常见错误回答

“把用户 ID 作为 offset,一天建一个 key。”

评分点

  • 初级:能写出 key 和 offset。
  • 中级:考虑月份、TTL、时区。
  • 高级:讨论连续签到算法、幂等发奖和明细补偿。

10.8 问题:Redis HyperLogLog 的误差和内存是多少?

推荐回答

Redis HyperLogLog 的标准误差约 0.81%,稠密表示约 12 KiB,内存不会随着基数线性增长。当前实现使用 16384 个 6 bit 寄存器,小基数时还可以使用更小的稀疏表示。

0.81% 是统计标准误差,不是绝对误差上限。

面试官追问

  1. 为什么前导零能估算基数?
  2. 为什么寄存器可以合并?

常见错误回答

“误差最大就是 0.81%,而且每次结果都会偏小。”

评分点

  • 初级:知道是近似去重。
  • 中级:知道误差和固定内存。
  • 高级:能解释寄存器、前导零与统计估算。

10.9 问题:PFADD 返回 1 是否说明新增了唯一用户?

推荐回答

不能。返回 1 只表示至少一个 HLL 内部寄存器发生了改变,或估算状态发生变化。HLL 不保存成员集合,因此不能用返回值判断某个用户是否首次出现。

需要精确首次判断时,应使用 SADD 的返回值、数据库唯一约束或独立幂等记录。

面试官追问

  1. 同一个元素重试是否安全?
  2. 为什么新元素可能返回 0?

常见错误回答

“与 SADD 一样,返回 1 就是新成员。”

评分点

  • 初级:知道 PFADD 用于 HLL。
  • 中级:说清寄存器变化语义。
  • 高级:能说明重试返回值和首次事件设计。

10.10 问题:什么时候选 Set,什么时候选 HyperLogLog?

推荐回答

需要精确数量、成员判断、枚举、删除或审计时选择 Set。只需要超大规模去重数量、能够接受误差、不需要原始成员时选择 HyperLogLog。

例如实时 UV 看板适合 HLL,广告计费和用户权益判断应使用精确数据。

面试官追问

  1. 能否同时使用二者?
  2. HLL 能否计算交集?

常见错误回答

“数据超过一万就必须使用 HLL。”

评分点

  • 初级:区分精确与近似。
  • 中级:结合业务正确选型。
  • 高级:提出实时近似加离线精确的组合架构。

10.11 问题:PFCOUNT key1 key2PFMERGE dest key1 key2 有什么区别?

推荐回答

多 key PFCOUNT 临时合并并返回并集基数,不保存合并后的 HLL;PFMERGE 会把结果写入目标 key,适合后续反复查询或分层聚合。

多 key PFCOUNT 不能使用单 key 基数缓存,频繁执行成本较高。还要注意 PFMERGE 会把已有目标 key 自身纳入合并。

面试官追问

  1. 如何全量重建目标 HLL?
  2. Cluster 中有什么限制?

常见错误回答

“二者只有是否返回数字的区别,目标旧值没有影响。”

评分点

  • 初级:知道一个查询、一个存储。
  • 中级:知道性能差异。
  • 高级:指出目标残留、同 slot 和分层聚合。

10.12 问题:Go 中使用这些命令时如何处理并发、超时和重试?

推荐回答

共享一个 redis.Client,由其连接池服务多个 goroutine;每次调用继承上游 Context,并设置合理 deadline。命令错误要区分超时、网络错误、Redis 响应错误和 redis.Nil

SETBIT 1、重复 PFADD 的最终状态近似幂等,但返回值可能因重试变化;BITFIELD INCRBY 非幂等,不能在结果未知时盲目重试。Pipeline 只降低网络往返,不提供原子性;需要原子执行时使用事务或脚本。

面试官追问

  1. 客户端超时是否代表 Redis 未执行?
  2. Pipeline 可以跨 goroutine 共享吗?
  3. Cluster 下如何设计 key?

常见错误回答

“所有 Redis 命令都可以自动重试三次,因为 Redis 是幂等的。”

评分点

  • 初级:会传 Context 并检查 error。
  • 中级:理解客户端复用、超时和幂等性。
  • 高级:能处理结果未知、事务、Cluster slot 与业务去重。

11. 一分钟面试回答

Bitmap 本质是 Redis String 的 bit 视图,一个 bit 表示一个二值状态,适合签到、活跃用户和功能开关。它的内存由最大 offset 决定,因此必须避免直接使用稀疏的大 ID。SETBITGETBIT 是常数复杂度,而 BITCOUNTBITPOSBITOP 会扫描数据,大 Bitmap 上可能阻塞。

Bitfield 则把 String 中连续的 bit 解释成 u8i16 等小整数,支持原子 GETSETINCRBY,需要特别注意默认 WRAP 溢出。

HyperLogLog 用约 12 KiB 内存估算超大集合基数,标准误差约 0.81%,适合 UV,但不能返回成员、判断成员或精确删除。精确统计用 Set、数据库或数仓;看板和趋势可以用 HLL。Cluster 中执行 BITOPPFMERGE 时,所有 key 必须位于同一 hash slot。


12. 本章总结

  1. Bitmap 是 String 的 bit 操作方式,不是独立数据类型。
  2. Bitmap 内存与最大 offset 相关,不与 bit 为 1 的数量相关。
  3. 用户签到适合“一个用户一个月一个 Bitmap”。
  4. 群体交并集适合“一个日期一个 Bitmap”,但用户编号必须紧凑。
  5. BITCOUNTBITPOSBITOP 都可能因数据规模变大而产生长延迟。
  6. Bitfield 适合紧凑的小整数数组,但字段布局和溢出策略必须显式设计。
  7. HyperLogLog 以不可逆、不可枚举为代价,换取近似固定的内存。
  8. PFADD 返回 1 不代表新增了一个精确唯一成员。
  9. PFMERGE 会包含已有目标值,全量重建时应先删除目标。
  10. Redis Cluster 中的多 key 位运算和 HLL 合并要求 key 位于同一 slot。
  11. Redis 客户端超时不代表命令一定没有执行,非幂等写入不能盲目重试。
  12. 展示型统计可以近似,计费、权益和审计必须有精确数据源。

13. 自测清单

  1. offset 为 8000 万的 Bitmap 至少需要多少字节?
  2. 为什么只有 10 个元素的 Bitmap 也可能占用数百 MiB?
  3. 用户月签到为什么通常不使用用户 ID 作为 offset?
  4. BITCOUNT key 10 20 默认按 bit 还是按字节?
  5. BITFIELDu12 #5 对应的起始 bit offset 是多少?
  6. u4 的值为 15 时,在三种溢出策略下加 1 分别会怎样?
  7. 为什么 HyperLogLog 可以计算并集,却不能删除单个成员?
  8. PFADD 返回 0 是否能证明传入元素以前出现过?
  9. 为什么全量重建 HLL 时需要先删除旧目标 key?
  10. Go 客户端收到 context.DeadlineExceeded 后,为什么不能直接重试 BITFIELD INCRBY

14. 官方资料

  • Redis SETBIT 命令、offset 上限与远端扩容说明。(Redis)
  • Redis BITCOUNTBITOP 命令与复杂度说明。(Redis)
  • Redis BITFIELD 编码、offset 与溢出策略。(Redis)
  • Redis HyperLogLog 数据结构说明。(Redis)
  • Redis PFADDPFCOUNTPFMERGE 命令语义。(Redis)
  • Redis 8.x HyperLogLog 源码中的寄存器与编码常量。(GitHub)
  • go-redis Bitmap 与 HyperLogLog 方法定义。(GitHub)
  • go-redis 错误处理说明。(Redis)
  • Redis Cluster 多 key 操作与 hash tag 规则。(Redis)