第8章:Redis Bitmap 与 BitField 实战:签到、活跃用户与状态压缩

1. 项目背景

1.1 业务场景

运营团队发起了"连续签到领积分"活动:用户每天打开 App 即可签到,连续签到 7 天额外送 50 积分,连续签到 30 天送 200 积分。产品还要求在个人中心展示本月签到日历,每个日期格子里显示"已签"或"未签"的图标。同时数据团队需要统计以下指标:每日 App 活跃用户数(DAU)、每周微信小程序活跃用户数、App+Web+小程序三端同时活跃的用户数(用于评估跨端渗透率)。

第一版后端方案是建一张签到表 user_checkin(uid, checkin_date, channel, created_at),DAU 统计用 SELECT COUNT(DISTINCT uid) FROM user_checkin WHERE checkin_date = ?。这个方案功能上能跑通,但活动一热起来,签到表每天新增数百万行,连续签到需要跨天查询,DAU 统计需要大表去重,高峰期慢查询频发。更要命的是,数据团队想做"三端同时活跃的用户交集",在 MySQL 里需要三次去重查询 + 应用层交集,效率极低。

另一个隐蔽的需求是"状态压缩"。每个用户每天有多个布尔状态------是否签到、是否看过新手引导、是否领取当日券、是否完成首次下单。如果每个状态都存一个 String key(如 status:checkin:{uid}:{date}),一亿用户 × 4 个状态 × 365 天 = 1460 亿个 key------这个规模连 Redis Cluster 都扛不住。

1.2 Redis Bitmap 的核心价值

Redis Bitmap 不是一种新的数据类型,而是把 String 当作二进制位数组来使用。一个 String 可以存 512MB(2^32 位),每一位只占 1 bit,值为 0 或 1。这种模型的优势在"海量布尔值"场景下体现得淋漓尽致。

操作 数据库方案 Bitmap 方案 节省
DAU 统计(1000 万用户) SELECT COUNT(DISTINCT uid) 全表扫描 BITCOUNT active:20260718 O(N) 但 N 是字节数 从秒级降到毫秒级
签到日历(31 天) 31 行数据库记录 1 个 key 31 位 存储开销 1/100
三端交集活跃 三次查询+应用层计算 BITOP AND 一条命令 开发时间 1/10
连续签到判断 跨天 SQL 窗口函数 循环 GETBIT 逐位检查 逻辑简单

1.3 本章目标

掌握 Redis Bitmap 和 BitField 的四种实战模式:

  • 个人签到 :按用户建模,SETBIT + BITCOUNT + 连续签到判断。
  • 活跃用户统计 :按日期建模,BITOP AND/OR 计算 DAU/MAU 和渠道交集。
  • 状态位压缩:BitField 把小整数和布尔值打包到最小空间。
  • 布隆过滤器入门:了解 Bitmap 在去重预判中的作用。

同时理解三个关键边界:

  • Bitmap 的 offset 如果是稀疏的巨大 ID(如用户 ID = 900000001),会浪费大量内存。
  • BITOP 对大 Bitmap 是 CPU 密集型操作,不适合高峰期实时执行。
  • BitField 的位布局必须文档化,否则后续维护就是灾难。

2. 项目设计

2.1 第一幕:签到打卡是日历盖章

小胖:"签到不就是日历上盖章吗?哪天签了哪天就盖个'已签'的章。这和我家楼下的健身房一样------每天去了就拿印章在卡片上盖一下,月底凭印章数量抽奖。"

小白:"那问题来了------是把每个人的签到存成一个 key,还是把每天所有签到的人存成一个 key?如果前者,我查个人签到很方便,但要统计'今天有多少人签到'就要遍历所有用户。如果后者,统计很方便,但查个人签到日历就要查 31 个 key。"

大师 :"这就是 Bitmap 建模的第一个决策点------维度选择。维度取决于你的主要查询路径:"

yaml 复制代码
方案 A:按用户维度(查个人签到方便)
  Key:   signin:{uid}:202607
  offset: 0=7月1日, 1=7月2日, ..., 30=7月31日
  操作: SETBIT signin:1001:202607 0 1   → 7月1日签到
        BITCOUNT signin:1001:202607      → 本月签到天数
  查询: 个人签到日历 → 读 1 个 key,31 次 GETBIT
        今日签到总人数 → 需要遍历所有 UID(不合适!)

方案 B:按日期维度(查某天签到方便)
  Key:   signin:20260701
  offset: uid(需要连续内部编号)
  操作: SETBIT signin:20260701 1001 1  → 用户1001在7月1日签到
  BITCOUNT signin:20260701            → 7月1日签到总人数
  查询: 今日签到总人数 → O(1) BITCOUNT
        个人签到日历 → 需要查 31 个 key,各 1 次 GETBIT

大师:"通常的做法是------两个维度都建。查个人签到时按用户维度,查统计数据时按日期维度。虽然数据冗余了,但每个维度的查询都很快。而且签到数据量不大(31 位/用户/月),冗余成本完全可以接受。"

技术映射:Bitmap 建模的关键是"沿着主要查询路径建 key"。如果需要双向查询,就建双向索引------这和 Set 的关注/粉丝正反索引是同一个思想。

2.2 第二幕:用户 ID 稀疏怎么办?

小胖 :"假设我的用户 ID 是 900000001,如果我按日期维度建 Bitmap,SETBIT active:20260718 900000001 1------这意味着 Redis 需要把一个 String 扩展到第 900000001 位。9 亿位 ÷ 8 = 112MB。仅仅为了记录一个用户签到,就要分配 112MB 内存?"

小白 :"对。这是 Bitmap 最大的坑。SETBIT 会按最高 offset 扩展底层 String。如果你的用户 ID 很稀疏而且很大,不要直接用原始 ID 做 offset。"

大师给出了三种解决方案:

ini 复制代码
方案 1:内部连续编号
  建一张映射表: uid → 内部连续编号 (1, 2, 3, ...)
  Bitmap 用内部编号做 offset
  代价: 需要维护映射表,新增用户时要分配编号

方案 2:分片 Bitmap
  key = active:20260718:{uid % 1000}   ← 分成 1000 个 Bitmap
  offset = uid / 1000
  代价: 统计 DAU 时需要 BITCOUNT 1000 次

方案 3:使用 Set / HyperLogLog
  如果用户量极大且不需要精确交集(比如只看趋势),改用 HyperLogLog(第 9 章)
  代价: 0.81% 误差,不能做精确集合运算

大师:"如果你的用户 ID 是自增主键(1, 2, 3, 4...),直接用它当 offset 没问题。如果是雪花算法生成的 19 位 ID,就必须做映射或分片。"

技术映射:Bitmap 省内存的前提是 offset 相对密集。稀疏大 offset 反而更费内存------每个 '1' 的位置需要一个巨大的底层字符串来撑到那个 offset。

2.3 第三幕:BitField------把格子切得更细

小胖:"Bitmap 每位只能存 0 或 1。如果我想存更多信息呢?比如某用户今天的签到渠道(0=未签, 1=App, 2=Web, 3=小程序),这需要 2 位------Bitmap 做不了。"

大师:"BitField 就是为这个设计的。它把字符串按位切割,可以在不同的 offset 读写不同宽度的小整数。"

yaml 复制代码
BitField 状态压缩示例(每个用户每天占用 1 个字节 = 8 位):

  Bit 0:     是否签到 (1 位, 值 0-1)
  Bit 1-2:   签到渠道 (2 位, 值 0-3: 0=未签 1=App 2=Web 3=小程序)
  Bit 3-5:   会员等级 (3 位, 值 0-7)
  Bit 6:     是否领券 (1 位, 值 0-1)
  Bit 7:     预留

  1 字节 = 8 位,存了 4 种状态!
  如果存成 4 个 String key: 每个 key 约 60 字节 → 240 字节
  用 BitField: 1 字节 → 节省 99.6% 空间

小白:"这压缩率确实惊人。但可读性呢?如果后来要加一个字段,或者改某个字段的位宽,之前的数据怎么处理?"

大师:"这就是 BitField 的代价------灵活性和可维护性成反比。我建议:BitField 只用于'字段稳定、常年不变、对内存极度敏感'的场景(比如物联网设备状态、游戏玩家属性)。对于业务字段,宁可多用几个 Hash 或 String,也不要牺牲可维护性去省那几百 MB。"

技术映射:BitField 是极致压缩的工具,但位布局一旦上线就是合约------改布局意味着老数据全部失效。文档化、版本化、测试覆盖是必须的。

2.4 第四幕:BITOP 的代价

小胖 :"三端同时活跃的用户------BITOP AND active:all:20260718 active:app:20260718 active:web:20260718 active:mini:20260718,然后 BITCOUNT,完美!"

大师 :"命令没问题。但要注意------BITOP 的复杂度是 O(N),N 是 bit 的总数。如果每个活跃 Bitmap 覆盖了 1000 万个 offset(底层字符串约 1.25MB),BITOP AND 就要逐字节与操作 3 次 × 1.25MB。在 Redis 主线程中执行,虽然通常能在几毫秒内完成,但在高峰期要注意调度。"

小白 :"那什么时候用 BITOP,什么时候用 HyperLogLog 的 PFMERGE?"

大师:"简单判断:需要精确数字和精确交集的 → Bitmap。只需要估算数、不需要精确交集的 → HyperLogLog。统计'三端都活跃的具体用户列表' → Bitmap;统计'大概有多少三端活跃用户' → HyperLogLog。"


3. 项目实战

3.1 环境准备

bash 复制代码
docker exec -it redis-lab redis-cli -a redis123

3.2 实战一:月度签到日历(用户维度)

步骤 1:写入签到数据

bash 复制代码
# 7 月份签到: offset 0=7月1日, 1=7月2日, ..., 30=7月31日
# 用户 1001 在 7 月 1、2、3、5、6 日签到

127.0.0.1:6379> SETBIT signin:1001:202607 0 1
(integer) 0     # 返回 0 表示这一位之前是 0
127.0.0.1:6379> SETBIT signin:1001:202607 1 1
(integer) 0
127.0.0.1:6379> SETBIT signin:1001:202607 2 1
(integer) 0
127.0.0.1:6379> SETBIT signin:1001:202607 4 1   # 7月5日 (offset=4)
(integer) 0
127.0.0.1:6379> SETBIT signin:1001:202607 5 1   # 7月6日 (offset=5)
(integer) 0

步骤 2:查询签到信息

bash 复制代码
# 检查 7 月 3 日是否签到 (offset=2)
127.0.0.1:6379> GETBIT signin:1001:202607 2
(integer) 1     # 已签到

# 检查 7 月 4 日是否签到 (offset=3)
127.0.0.1:6379> GETBIT signin:1001:202607 3
(integer) 0     # 未签到

# 本月签到总天数
127.0.0.1:6379> BITCOUNT signin:1001:202607
(integer) 5

# 指定范围统计(前 7 天签到天数:offset 0~6)
127.0.0.1:6379> BITCOUNT signin:1001:202607 0 0
(integer) 5
# 参数含义: BITCOUNT key [start_byte] [end_byte],按字节索引不是按位!
# 要统计整个 key 直接不带范围参数

步骤 3:连续签到判断(关键业务逻辑)

bash 复制代码
# 需求:判断"是否连续签到 7 天"
# 策略:从今天向前逐位检查

# 假设今天是 7 月 7 日 (offset=6),先检查是否今天签到
127.0.0.1:6379> GETBIT signin:1001:202607 6
(integer) 0     # 今天未签到,连续中断

# 如果今天是 7 月 6 日 (offset=5) 且已签到:
127.0.0.1:6379> GETBIT signin:1001:202607 5
(integer) 1
# offset=4 也签到:
127.0.0.1:6379> GETBIT signin:1001:202607 4
(integer) 1
# offset=3 (7月4日):
127.0.0.1:6379> GETBIT signin:1001:202607 3
(integer) 0     # 中断!连续签到只有 2 天(5日和6日)

Java 连续签到实现:

java 复制代码
public class SignInService {
    private static final int[] MONTH_DAYS = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

    // 用户签到
    public void signIn(String uid, LocalDate date) {
        String key = buildMonthKey(uid, date);
        int offset = date.getDayOfMonth() - 1;  // 1号→offset 0
        redis.setBit(key, offset, true);
        // 设置 60 天过期(覆盖至少两个月的签到数据)
        redis.expire(key, Duration.ofDays(60));
    }

    // 查询某天签到状态
    public boolean isSignedIn(String uid, LocalDate date) {
        String key = buildMonthKey(uid, date);
        return Boolean.TRUE.equals(
            redis.getBit(key, date.getDayOfMonth() - 1));
    }

    // 获取本月签到天数
    public long getMonthSignInCount(String uid, int year, int month) {
        String key = String.format("signin:%s:%d%02d", uid, year, month);
        Long count = redis.bitCount(key);
        return count == null ? 0 : count;
    }

    // 计算连续签到天数(从今天往前数)
    public int getContinuousDays(String uid, LocalDate today) {
        int count = 0;
        LocalDate cursor = today;

        while (true) {
            if (!isSignedIn(uid, cursor)) {
                break;  // 中断就停止
            }
            count++;
            cursor = cursor.minusDays(1);
        }
        return count;
    }

    // 签到奖励判定
    public String getSignInReward(String uid, LocalDate today) {
        int continuousDays = getContinuousDays(uid, today);

        if (continuousDays >= 30) {
            return "🎉 连续签到 30 天!获得 200 积分 + 限定勋章";
        } else if (continuousDays >= 7) {
            return "连续签到 7 天!获得 50 积分";
        } else if (continuousDays >= 3) {
            return "连续签到 3 天!获得 10 积分";
        } else {
            return "今日已签到";
        }
    }

    private String buildMonthKey(String uid, LocalDate date) {
        return String.format("signin:%s:%d%02d", uid, date.getYear(), date.getMonthValue());
    }
}

跨月连续签到的处理思路:如果今天是 8 月 3 日,连续签到跨越了 7 月和 8 月。程序应该先查 8 月的 key 从 3 号往前数,到了 8 月 1 号后,继续查 7 月的 key 从 7 月 31 号往前数------直到中断。

3.3 实战二:活跃用户统计(日期维度)

步骤 1:记录每日活跃

bash 复制代码
# 假设有连续内部编号的用户 1~1000

# 7 月 18 日 App 活跃用户
127.0.0.1:6379> SETBIT active:app:20260718 1001 1
127.0.0.1:6379> SETBIT active:app:20260718 1002 1
127.0.0.1:6379> SETBIT active:app:20260718 1005 1

# 同日 Web 端活跃用户
127.0.0.1:6379> SETBIT active:web:20260718 1002 1
127.0.0.1:6379> SETBIT active:web:20260718 1003 1
127.0.0.1:6379> SETBIT active:web:20260718 1005 1

# 同日小程序活跃用户
127.0.0.1:6379> SETBIT active:mini:20260718 1001 1
127.0.0.1:6379> SETBIT active:mini:20260718 1004 1
127.0.0.1:6379> SETBIT active:mini:20260718 1005 1

步骤 2:统计各端 DAU

bash 复制代码
# 各端独立活跃数
127.0.0.1:6379> BITCOUNT active:app:20260718
(integer) 3
127.0.0.1:6379> BITCOUNT active:web:20260718
(integer) 3
127.0.0.1:6379> BITCOUNT active:mini:20260718
(integer) 3

步骤 3:计算交集------三端同时活跃用户

bash 复制代码
# BITOP AND: 三端交集
127.0.0.1:6379> BITOP AND active:all:20260718 active:app:20260718 active:web:20260718 active:mini:20260718
(integer) 126   # 返回底层字符串长度(字节数)

# 统计三端活跃用户数
127.0.0.1:6379> BITCOUNT active:all:20260718
(integer) 1     # 只有用户 1005 三端同时活跃

# 临时统计 key 设置过期
127.0.0.1:6379> EXPIRE active:all:20260718 86400
(integer) 1

步骤 4:计算并集------任一端活跃用户(总 DAU)

bash 复制代码
# BITOP OR: 三端并集
127.0.0.1:6379> BITOP OR active:total:20260718 active:app:20260718 active:web:20260718 active:mini:20260718
(integer) 126

127.0.0.1:6379> BITCOUNT active:total:20260718
(integer) 5     # 共有 5 个独立用户(1001, 1002, 1003, 1004, 1005)

127.0.0.1:6379> EXPIRE active:total:20260718 86400
(integer) 1

步骤 5:计算月活(MAU)

bash 复制代码
# 假设已有 7 月 16、17、18 三日活跃数据
# 可以先准备好:
127.0.0.1:6379> SETBIT active:app:20260716 1001 1
127.0.0.1:6379> SETBIT active:app:20260716 1006 1
127.0.0.1:6379> SETBIT active:app:20260717 1001 1
127.0.0.1:6379> SETBIT active:app:20260717 1007 1

# 月活 = 多日活跃的并集
127.0.0.1:6379> BITOP OR active:app:mau:202607 active:app:20260716 active:app:20260717 active:app:20260718
(integer) 126

127.0.0.1:6379> BITCOUNT active:app:mau:202607
(integer) 5

127.0.0.1:6379> EXPIRE active:app:mau:202607 86400
(integer) 1

步骤 6:Java 活跃统计服务

java 复制代码
public class ActiveUserStatsService {

    // 记录活跃用户
    public void recordActive(String channel, String date, long innerUid) {
        String key = String.format("active:%s:%s", channel, date);
        redis.setBit(key, innerUid, true);
        redis.expire(key, Duration.ofDays(90));  // 保留 90 天
    }

    // 单端 DAU
    public long getDau(String channel, String date) {
        String key = String.format("active:%s:%s", channel, date);
        Long count = redis.bitCount(key);
        return count == null ? 0 : count;
    }

    // 多端交集(比如 App+Web 双端活跃)
    public long getIntersectionActive(String date, String... channels) {
        String dstKey = "active:intersect:" + date + ":" + String.join("_", channels);
        String[] srcKeys = Arrays.stream(channels)
            .map(ch -> String.format("active:%s:%s", ch, date))
            .toArray(String[]::new);

        redis.bitOp(BitOperation.AND, dstKey, srcKeys);
        redis.expire(dstKey, Duration.ofHours(1));

        Long count = redis.bitCount(dstKey);
        return count == null ? 0 : count;
    }

    // 月活
    public long getMau(String channel, String yearMonth, List<String> dates) {
        String dstKey = String.format("active:%s:mau:%s", channel, yearMonth);
        String[] srcKeys = dates.stream()
            .map(d -> String.format("active:%s:%s", channel, d))
            .toArray(String[]::new);

        redis.bitOp(BitOperation.OR, dstKey, srcKeys);
        redis.expire(dstKey, Duration.ofDays(7));

        Long count = redis.bitCount(dstKey);
        return count == null ? 0 : count;
    }
}

3.4 实战三:稀疏 ID 的分片 Bitmap

当用户 ID 不连续且很大时,用分片 Bitmap:

bash 复制代码
# 用户 ID 900000001,分 1000 片
# 片号 = 900000001 % 1000 = 1
# offset = 900000001 / 1000 = 900000

127.0.0.1:6379> SETBIT active:app:20260718:1 900000 1
(integer) 0
127.0.0.1:6379> BITCOUNT active:app:20260718:1
(integer) 1

# 统计所有分片的总活跃数
# 需要遍历 1000 个分片各自 BITCOUNT 然后求和

Java 分片 Bitmap:

java 复制代码
public class ShardedBitmapService {
    private static final int SHARD_COUNT = 1000;

    public void setActive(String channel, String date, long originalUid) {
        int shardId = (int) (originalUid % SHARD_COUNT);
        long innerOffset = originalUid / SHARD_COUNT;
        String key = String.format("active:%s:%s:%d", channel, date, shardId);
        redis.setBit(key, innerOffset, true);
    }

    public long countActive(String channel, String date) {
        long total = 0;
        for (int i = 0; i < SHARD_COUNT; i++) {
            String key = String.format("active:%s:%s:%d", channel, date, i);
            Long count = redis.bitCount(key);
            if (count != null) total += count;
        }
        return total;
    }
}

3.5 实战四:BitField 状态压缩

bash 复制代码
# 为每个用户每天分配 8 位状态

# 写入: 用户 1001 在 7 月 18 日通过 App(channel=1)签到, 会员等级 5, 已领券
# u1 = unsigned 1-bit, u2 = unsigned 2-bit, u3 = unsigned 3-bit

127.0.0.1:6379> BITFIELD state:1001:20260718 SET u1 0 1   # 0位写1位: 已签到
1) (integer) 0
127.0.0.1:6379> BITFIELD state:1001:20260718 SET u2 1 1   # 1位写2位: 渠道=App(1)
1) (integer) 0
127.0.0.1:6379> BITFIELD state:1001:20260718 SET u3 3 5   # 3位写3位: 会员等级=5
1) (integer) 0
127.0.0.1:6379> BITFIELD state:1001:20260718 SET u1 6 1   # 6位写1位: 已领券
1) (integer) 0

# 读取: 一次性读出所有字段
127.0.0.1:6379> BITFIELD state:1001:20260718 GET u1 0 GET u2 1 GET u3 3 GET u1 6
1) (integer) 1     # 已签到
2) (integer) 1     # 渠道=App
3) (integer) 5     # 会员等级=5
4) (integer) 1     # 已领券

# 溢出策略:INCRBY 超出位宽时
127.0.0.1:6379> BITFIELD counter:test OVERFLOW SAT INCRBY u3 3 1  # 3+1=4, <7 OK
1) (integer) 4
127.0.0.1:6379> BITFIELD counter:test OVERFLOW SAT INCRBY u3 3 10 # 4+10=14, >7 → SAT 截断为 7
1) (integer) 7

# 溢出策略有三种:
# WRAP: 回绕(默认), 7+1=0, 0-1=7
# SAT:  饱和截断, 最大值/最小值
# FAIL: 超出范围时拒绝操作, 返回 nil

Java BitField 操作:

java 复制代码
public class UserDailyStateService {

    // 位布局定义(必须文档化!)
    // Bit 0:     签到标记 (u1)
    // Bit 1-2:   签到渠道 (u2)  0=未签 1=App 2=Web 3=小程序
    // Bit 3-5:   会员等级 (u3)  0-7
    // Bit 6:     领券标记 (u1)
    // Bit 7:     预留

    public void saveState(String uid, String date, int channel, int level, boolean coupon) {
        String key = "state:" + uid + ":" + date;

        redis.bitField(key,
            BitFieldCommands.BitFieldType.UINT_1, 0, 1,              // 签到=1
            BitFieldCommands.BitFieldType.UINT_2, 1, channel,        // 渠道
            BitFieldCommands.BitFieldType.UINT_3, 3, level,          // 等级
            BitFieldCommands.BitFieldType.UINT_1, 6, coupon ? 1 : 0  // 领券
        );

        redis.expire(key, Duration.ofDays(90));
    }

    public DailyState getState(String uid, String date) {
        String key = "state:" + uid + ":" + date;

        List<Long> values = redis.bitField(key,
            BitFieldCommands.BitFieldType.UINT_1.valueAt(0),
            BitFieldCommands.BitFieldType.UINT_2.valueAt(1),
            BitFieldCommands.BitFieldType.UINT_3.valueAt(3),
            BitFieldCommands.BitFieldType.UINT_1.valueAt(6)
        );

        if (values == null || values.isEmpty()) return null;

        return DailyState.builder()
            .signedIn(values.get(0) == 1)
            .channel((int) (long) values.get(1))
            .level((int) (long) values.get(2))
            .couponTaken(values.get(3) == 1)
            .build();
    }
}

3.6 实战五:BitMap 的 BITPOS 技巧

bash 复制代码
# BITPOS: 查找第一个 1 或 0 的位置

# 准备数据
127.0.0.1:6379> SETBIT test:bits 0 1
127.0.0.1:6379> SETBIT test:bits 3 1
127.0.0.1:6379> SETBIT test:bits 7 1

# 找到第一个 1(从 offset 0 开始)
127.0.0.1:6379> BITPOS test:bits 1
(integer) 0

# 找到第一个 0
127.0.0.1:6379> BITPOS test:bits 0
(integer) 1

# 找到第一个 1(从 offset 2 开始)
127.0.0.1:6379> BITPOS test:bits 1 2
(integer) 3

# 实用场景:找出"今天第一个签到的用户"(offset 最小且位为 1)
127.0.0.1:6379> BITPOS signin:20260718 1
# 返回最小 offset → 对应的就是第一个签到的内部用户编号

4. 项目总结

4.1 核心命令速查

命令 用途 复杂度 注意事项
SETBIT key offset val 设置位值 O(1) offset 太大导致 String 自动扩展
GETBIT key offset 读取位值 O(1) offset 超出返回 0
BITCOUNT key [start end] 统计 1 的个数 O(N) N 是字节数;start/end 是字节索引
BITOP AND/OR/XOR/NOT dst k1 k2... 位运算 O(N) 生成新 key,注意设置 TTL
BITPOS key bit [start end] 查找第一个 0/1 O(N) start/end 是字节索引
BITFIELD key SET type offset val 位域写 O(1) type 如 u1/u2/u3/i8
BITFIELD key GET type offset 位域读 O(1) 可一次读多个字段
BITFIELD key OVERFLOW SAT/WRAP/FAIL INCRBY type offset val 位域自增 O(1) 注意溢出策略

4.2 Bitmap 维度选择速查

查询需求 推荐维度 Key 示例 说明
个人签到日历 用户维度 signin:{uid}:202607 31 位查一个 key
每日签到总人数 日期维度 signin:20260718 一次 BITCOUNT
连续签到 用户维度 signin:{uid}:202607 逐位向前查
DAU/MAU 日期维度 active:app:20260718 BITCOUNT + BITOP OR
渠道交集 日期维度 active:{channel}:date BITOP AND

4.3 优点与缺点

维度 详细说明
优点1:极致节省空间 1 亿用户的 DAU 统计只需 12.5MB(= 1 亿位 / 8)
优点2:集合运算高效 BITOP AND/OR 在服务端完成,不需传输原始数据
优点3:命令简单 只有 5 个核心命令,10 分钟上手
优点4:存储+计算一体 不需要额外 OLAP 引擎就能做多维度统计分析
缺点1:稀疏 ID 陷阱 大 offset 导致 String 过度扩展,严重浪费内存
缺点2:中间结果需清理 BITOP 每次都生成新 key,必须设 TTL
缺点3:不可逆 SETBIT 后不能知道"哪些位置是 1"(除非全量扫 BITPOS)
缺点4:不能存复杂值 只有 0/1,表达力有限(BitField 部分缓解)

4.4 适用与不适用场景

适用场景:

  1. 签到打卡------用户维度,按天 offset,连续签到判断。
  2. DAU/MAU 统计------日期维度,按用户 ID offset,BITCOUNT + BITOP。
  3. 用户状态标记------是否新手、是否 VIP、是否认证,海量布尔值。
  4. 功能开关矩阵------多个功能的 A/B 测试分组。
  5. 布隆过滤------SETBIT + GETBIT 模拟存在判断(配合多个哈希函数)。
  6. 设备在线状态------IoT 设备 ID 做 offset,1=在线 0=离线。

不适用场景:

  1. 需要精确成员列表------BitMap 只能知道"有几个"和"某个在不在",无法枚举所有在集合中的成员。
  2. 用户 ID 极大且稀疏------如 UUID、雪花 ID 作为 offset,应改用 Set 或 HLL。
  3. 需要频次计数的场景------不能存"用户访问了几次",应改用 INCR。
  4. 动态字段频繁变更------BitField 的位布局一旦变动影响全量数据。

4.5 常见踩坑经验

案例 表象 根因 解法
内存爆炸 一个签到 key 占用 12MB 用雪花 ID 做 offset(最高位=9 亿),SETBIT 触发底层 String 扩展到 112MB 改用内部连续编号或分片 Bitmap
BITCOUNT 结果异常 BITCOUNT key 0 6 返回 0 但明显有签到数据 BITCOUNT 的 start/end 参数是字节索引不是位索引 BITCOUNT key 0 6 统计的是第 0-6 字节,不是 0-6 位。统计全部用无参 BITCOUNT
BITOP 临时 key 堆积 keys 数量异常增多,内存占用持续增长 每次 BITOP 生成新 key 但忘记设 TTL BITOP 后立即 EXPIRE;定时扫描 active:*:intersect* 清理
跨月签到断裂 用户 8 月 1 日签到,连续签到显示为 0 天 只查了 8 月的 key,没检查 7 月最后几天 跨月时查上月 key 的末尾 offset,连续补算
BitField 布局变更 历史数据解析出错误的值 新增字段改变了原有字段的 offset 和宽度 文档化位布局;不同版本用不同 key 前缀;数据迁移要谨慎

4.6 思考题

  1. 设计题:你需要设计一个"用户 30 天内是否活跃"的标记系统,支持 1 亿用户。每个用户只需要存"最近 30 天是否至少活跃过一次"(一个布尔值)。你会用哪个 Redis 数据结构?如果用 Bitmap,如何解决用户 ID 不连续的问题?估算一下内存占用。

  2. 性能题 :DAU 统计中 BITOP OR 三个渠道(App/Web/小程序)各 5000 万用户的 Bitmap 时需要合并三个约 6MB 的 String,操作耗时约 5ms。如果在高峰期间每分钟执行一次(计算上一分钟的 DAU),这个负载可以接受吗?如果不可以,有什么优化方案?

(答案提示将在下一章末尾给出。)

上一章思考题答案提示(第7章):

第1题 (小时榜设计):key 设计为 rank:gift:hourly:2026071814(精确到小时)。score 用礼物收入金额。每分钟刷新时:取当前小时榜单 Top 100 展示,同时用 ZREMRANGEBYSCORE key -inf (now-3600000) 清理 60 分钟前数据(score 是毫秒时间戳)。注意清理操作需要在低峰期或异步执行,避免影响榜单读取性能。

第2题 (延时队列空转优化):不要每次都用 ZRANGEBYSCORE -inf now。先用 ZRANGE key 0 0 WITHSCORES 取 score 最小的任务,如果最小 score > now,说明所有任务都未到期,sleep(最小score - now)秒后再扫。这样空转时只需一次 O(log N) 的 ZRANGE 而非 O(log N + M) 的 ZRANGEBYSCORE。

相关推荐
Maxkim2 小时前
私钥就是数字 4:扒一扒这场把一家 CA 直接送走的事故
javascript·架构
4SAPI2 小时前
电商全链路内容自动化生成大模型 API 生产选型指南:4SAPI、硅基流动与 OpenRouter 的架构权衡
运维·人工智能·架构·自动化·1024程序员节
吴建旭 智宅焕3 小时前
智能家居全国交付中前端工具与后端基础设施的系统集成架构:报价入口与交付兜底的闭环设计
前端·架构·智能家居
深圳市恒星物联科技有限公司3 小时前
排水管网监测分层分布式架构:感知-传输-平台-应用详解
分布式·架构
Dawson Zhu3 小时前
【CUDA编程入门⑧】性能分析工具链:Nsight、Profiler与Roofline模型
人工智能·语言模型·架构·aigc·agi
欣欣之王来了3 小时前
v-bind语法糖::属性名的简写方式
前端·学习·架构·项目·vue3教程
2601_954005623 小时前
# ricon SVG 编辑器:工业图元的“军火工厂“
前端·物联网·数学建模·架构·编辑器·制造
得物技术3 小时前
国产算力·昇腾 910C 大模型推理优化(下):源码修改与算子优化,提速30%|得物技术
架构·llm·github
淡海水4 小时前
07-06-源码-.NET Core GC的极致优化技术
unity·架构·c#·.netcore·gc·1024程序员节