|

实战篇 - 用户签到与UV统计模块

📋 模块概览

本模块实现两个功能:用户签到UV统计,共5个小节:

小节 内容 核心技术
1 BitMap 功能演示 Redis BitMap 命令
2 实现签到功能 SetBit + ValueOperations
3 统计连续签到 BitField + 位运算
4 HyperLogLog 用法 Redis HLL 命令
5 测试百万数据统计 HLL 性能与精度验证

一、签到功能分析与 BitMap

1.1 为什么不用数据库?

数据库方案:每行记录一次签到

tb_sign 表结构:id, user_id, year, month, date, is_backup

问题分析: - 1000万用户,每人每年签到≥10次 → 每年至少1亿条数据 - 每行占用约 22字节(id 8B + userId 8B + year 1B + month 1B + date 3B + is_backup 1B) - 1000万用户一个月 ≈ 660MB,一年数据量恐怖

1.2 BitMap 思路(签到卡思想)

核心思想:用 1bit 表示签到/未签到状态,将一个月的签到情况压缩为一个二进制串

签到状态:签到=1,未签到=0

示例(3月份签到记录):
日期:  1  2  3  4  5  6  7  8
状态:  1  1  1  0  0  1  1  1
二进制串: 11100111

空间对比: - 数据库方案:一个用户一个月 ≈ 几百字节 - BitMap方案:一个用户一个月 = 31bit ≈ 4字节 - 节省约 100倍 以上!

💡 BitMap本质:将每个 bit 位与每月的某一天形成映射,用 0/1 表示业务状态 - bit = 二进制位 - map = 映射

1.3 Redis BitMap 底层实现

  • 底层基于 String 类型 实现(String 本质是字节 = 8 bit)
  • String 最大存储 512MB = 2^32 bit,签到一个月只需 31bit,绰绰有余

1.4 BitMap 常用命令

命令 功能 说明
SETBIT key offset value 设置某一位的值 offset=偏移量(0开始),value=0/1
GETBIT key offset 获取某一位的值 返回 0 或 1
BITCOUNT key [start end] 统计值为1的数量 用于统计总签到次数
BITFIELD key GET type offset 批量读取多个bit 支持一次读取多位,返回十进制
BITFIELD_RO key GET type offset 只读版BITFIELD 只有查询功能
BITOP operation destkey key1 key2 多个BitMap位运算 AND/OR/NOT/XOR
BITPOS key bit [start end] 查找第一个0/1的位置 用于定位首次未签到日期

命令演示

# 1. 签到(第1、2、3、6、7、8天签到,第4、5天未签)
SETBIT sign:5:202203 0 1    # 第1天签到
SETBIT sign:5:202203 1 1    # 第2天签到
SETBIT sign:5:202203 2 1    # 第3天签到
SETBIT sign:5:202203 5 1    # 第6天签到
SETBIT sign:5:202203 6 1    # 第7天签到
SETBIT sign:5:202203 7 1    # 第8天签到
# 第4、5天不操作,默认就是0

# 2. 查询第3天是否签到
GETBIT sign:5:202203 2       # 返回 1(已签到)

# 3. 统计总签到次数
BITCOUNT sign:5:202203       # 返回 6

# 4. BITFIELD批量读取(从第0位开始读2个bit)
BITFIELD sign:5:202203 GET u2 0   # 返回 3(二进制11 = 十进制3)
BITFIELD sign:5:202203 GET u4 0   # 返回 14(二进制1110 = 十进制14)

# 5. 查找第一个0出现的位置
BITPOS sign:5:202203 0      # 返回 3(第4天,offset=3)

⚠️ Redis桌面端版本注意:必须使用 2022.22020年之前 的版本,2021版本不支持 Binary 显示


二、实现签到功能

2.1 设计思路

Key 结构sign:{userId}:{yearMonth}

示例:sign:5:202203
含义:5号用户 2022年3月 的签到记录

签到逻辑:根据当天日期计算 offset,dayOfMonth - 1 即为偏移量

2.2 接口定义

// POST /user/sign
// 无参数(自动获取当前登录用户和当前日期)
// 无返回值

2.3 核心代码

// UserController.java
@PostMapping("sign")
public Result sign() {
    return userService.sign();
}

// UserServiceImpl.java
@Override
public Result sign() {
    // 1. 获取当前登录用户
    Long userId = UserHolder.getUser().getId();

    // 2. 获取当前日期
    LocalDateTime now = LocalDateTime.now();

    // 3. 拼接key  sign:userId:yearMonth
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = USER_SIGN_KEY + userId + keySuffix;

    // 4. 获取今天是本月第几天(dayOfMonth从1开始,offset从0开始)
    int dayOfMonth = now.getDayOfMonth();

    // 5. 写入Redis SETBIT
    stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);

    return Result.ok();
}

2.4 Key 常量

// RedisConstants.java
public static final String USER_SIGN_KEY = "sign:";

💡 要点: - BitMap 操作封装在 ValueOperations 中,没有单独的 OpsForBitmap - setBit 的第三个参数用 booleantrue 代表签到(等同于 1) - 签到是无参接口,用户和日期都从上下文自动获取


三、统计连续签到天数

3.1 连续签到定义

最后一次签到开始,向前统计,直到遇到第一次未签到为止,计算总签到次数。

示例:本月签到记录(截止今天14号)
日期: 1  2  3  4  5  6  7  8  9 10 11 12 13 14
状态: 1  1  1  0  0  1  1  1  1  0  1  1  1  1

从第14天往前:14✓ 13✓ 12✓ 11✓ → 遇到第10天的0
连续签到天数 = 4天

3.2 实现思路(三步)

步骤1:获取本月截止今天的所有签到数据
       → BITFIELD key GET u{dayOfMonth} 0
       → 返回十进制数字(如 14339)

步骤2:逐个 bit 位遍历(从最后一位/今天开始向前)
       → 用「与运算」获取最低位:number & 1
       → 用「右移」抛弃最低位:number >>>= 1

步骤3:判断每个 bit 位
       → 为0:未签到,break 结束
       → 为1:已签到,counter++

3.3 位运算原理详解

与运算取最低位

  签到记录:  1  1  1  0  1  1  1  1
& 数字1:    0  0  0  0  0  0  0  1
───────────────────────────────────
  结果:     0  0  0  0  0  0  0  1  → 最低位=1(签到)

原理:与运算中,两个都是1结果才为1
     参与运算的一个数已经是1
     → 另一个数是几,结果就是几
     → 恰好得到最低位的值

右移遍历

原始:     1  1  1  0  1  1  1  1
右移1位:  0  1  1  1  0  1  1  1  → 倒数第二位变最低位
右移2位:  0  0  1  1  1  0  1  1  → 倒数第三位变最低位
...依此类推

3.4 接口定义

// GET /user/sign/count
// 无参数(自动获取当前用户和当前日期)
// 返回连续签到天数

3.5 核心代码

// UserController.java
@GetMapping("sign/count")
public Result signCount() {
    return userService.signCount();
}

// UserServiceImpl.java
@Override
public Result signCount() {
    // 1. 获取当前登录用户
    Long userId = UserHolder.getUser().getId();

    // 2. 获取当前日期
    LocalDateTime now = LocalDateTime.now();

    // 3. 拼接key
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = USER_SIGN_KEY + userId + keySuffix;

    // 4. 获取今天是本月第几天
    int dayOfMonth = now.getDayOfMonth();

    // 5. 获取本月截止今天的所有签到记录
    // BITFIELD key GET u{dayOfMonth} 0
    List<Long> result = stringRedisTemplate.opsForValue()
            .bitField(key, BitFieldSubCommands.create()
                    .get(BitFieldSubCommands.BitFieldType
                            .unsigned(dayOfMonth))
                    .valueAt(0));

    // 6. 判断结果
    if (result == null || result.isEmpty()) {
        return Result.ok(0);
    }
    Long num = result.get(0);
    if (num == null || num == 0) {
        return Result.ok(0);
    }

    // 7. 循环遍历,统计连续签到天数
    int count = 0;
    while (true) {
        // 7.1 让数字与1做与运算,得到最低位
        // 7.2 判断最低位是否为0
        if ((num & 1) == 0) {
            // 未签到,结束
            break;
        } else {
            // 已签到,计数器+1
            count++;
        }
        // 7.3 右移一位,抛弃已判断的最低位
        num >>>= 1;  // ⚠️ 必须做!否则死循环
    }

    return Result.ok(count);
}

3.6 测试验证

# 补签测试(手动用命令补签前几天)
SETBIT sign:5:202203 10 1   # 补签第11天
SETBIT sign:5:202203 11 1   # 补签第12天

# 验证连续签到:11号到14号连续4天 → 返回4

# 中断测试(将14号设为0)
SETBIT sign:5:202203 13 0   # 今天未签到
# → 返回 0(因为从今天开始往前,第一天就是0)

⚠️ 关键注意事项: - BITFIELD 的 GET 命令中,unsigned(dayOfMonth)读取的bit数(不是offset) - offset 是从第几位开始读,永远是 0(第一天开始) - 右移必须用 无符号右移 >>>=,否则高位补1可能导致死循环 - 循环内 必须做右移,否则永远判断同一位,造成死循环


四、UV统计 - HyperLogLog

4.1 UV 与 PV 的概念

概念 全称 含义 特点
UV Unique Visitor 独立访客量 一天内同一用户只记录一次
PV Page View 页面访问量/点击量 每访问一次记录一次

分析价值: - PV/UV 比值可反映用户粘度 - 比值高 → 用户深度浏览;比值低 → 用户浅层访问就离开

4.2 UV 统计的难点

直觉方案:每来一个用户,判断是否已统计,未统计则计数器+1

问题: - 需要保存每个已访问用户的信息 - 千万级用户 → 内存占用巨大,Redis 不适合

4.3 HyperLogLog(HLL)解决方案

什么是 HyperLogLog? - 一种概率统计算法,基于 Loglog 算法派生 - 不是精确计数,而是通过统计数据推算出总数 - 内存占用永远不超过 16KB(无论统计百万还是亿级数据) - 误差率约 0.81%

Redis 中的 HLL: - 底层也是 String 类型实现 - 自动去重:相同元素只记录一次 - 三个命令极其简单

4.4 HLL 命令

命令 功能 说明
PFADD key element [element ...] 添加元素 可添加多个
PFCOUNT key [key ...] 统计数量 返回估算值(非精确)
PFMERGE destkey sourcekey [sourcekey ...] 合并多个HLL 将多个key合并后统一统计

命令演示

# 1. 添加元素
PFADD hl1 1 2 3 4 5

# 2. 统计数量
PFCOUNT hl1    # 返回 5

# 3. 添加重复元素
PFADD hl1 1 2 3 4 5
PFCOUNT hl1    # 仍然是 5(自动去重!)

# 4. 合并多个HLL
PFADD uv:20220301 user1 user2 user3
PFADD uv:20220302 user2 user3 user4
PFMERGE uv:202203 uv:20220301 uv:20220302
PFCOUNT uv:202203  # 返回 4(user1~4,去重后)

💡 HLL 适合做 UV 统计的原因: - 天然去重,重复添加不影响结果 - 内存占用极低(<16KB),数据量再大也不怕 - 0.81% 的误差对 UV 统计完全可以接受(1万用户差80人)


五、百万数据测试验证

5.1 测试方案

利用 For 循环插入 100万条 数据到 HLL,验证: 1. 统计精度是否可接受 2. 内存占用是否真的 ≤ 16KB

5.2 测试代码

@Test
void testHyperLogLog() {
    String[] values = new String[1000];
    int j = 0;

    for (int i = 0; i < 1000000; i++) {
        j = i % 1000;  // 控制数组下标在 0~999
        values[j] = "user" + i;

        // 每1000条批量插入一次
        if (j == 999) {
            stringRedisTemplate.opsForHyperLogLog().add("hl", values);
        }
    }

    // 统计结果
    Long count = stringRedisTemplate.opsForHyperLogLog().size("hl");
    System.out.println("统计结果: " + count);
}

5.3 测试结果

指标 数据
插入数据量 1,000,000 条
统计结果 997,593
误差 2,407 条
误差率 约 0.24%(远小于理论值 0.81%)
内存占用 约 14KB(<16KB ✅)
# 查看内存
INFO MEMORY    # 测试前后对比,差值约 14KB

重复添加测试:再次插入同样的100万条 → 结果不变,验证了自动去重功能 ✅


📝 总结与要点

签到功能核心

要点 说明
数据结构 BitMap(底层 String 类型)
Key设计 sign:{userId}:{yearMonth},每月一个key
空间效率 一个用户一个月仅需 4 字节
核心命令 SETBIT(签到)、BITFIELD(批量读取)、BITCOUNT(统计)
连续签到算法 与运算取最低位 + 右移遍历 + 遇0即止

UV统计核心

要点 说明
数据结构 HyperLogLog(概率算法)
内存上限 永远 ≤ 16KB
精度 误差约 0.81%,UV统计可接受
核心命令 PFADD(添加)、PFCOUNT(统计)、PFMERGE(合并)
适用场景 大规模独立访客统计,容忍少量误差

常见踩坑

  1. BitField 的 type 参数unsigned(dayOfMonth) 表示读取多少个 bit,不是 offset
  2. offset 从 0 开始:第1天 offset=0,dayOfMonth - 1
  3. 右移必须做:循环内不做右移会导致死循环
  4. 使用无符号右移>>>= 而非 >>=,避免高位补1
  5. BitMap 操作用 ValueOperations:没有独立的 OpsForBitmap
  6. Redis桌面客户端版本:2022.2 或 2020年前版本才能正常显示 Binary

整理自 Redis 实战课程第15~19节转录文本

评论交流

文章目录