实战篇 - 用户签到与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.2 或 2020年之前 的版本,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的第三个参数用boolean,true代表签到(等同于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(合并) |
| 适用场景 | 大规模独立访客统计,容忍少量误差 |
常见踩坑
- BitField 的 type 参数:
unsigned(dayOfMonth)表示读取多少个 bit,不是 offset - offset 从 0 开始:第1天 offset=0,
dayOfMonth - 1 - 右移必须做:循环内不做右移会导致死循环
- 使用无符号右移:
>>>=而非>>=,避免高位补1 - BitMap 操作用 ValueOperations:没有独立的 OpsForBitmap
- Redis桌面客户端版本:2022.2 或 2020年前版本才能正常显示 Binary
整理自 Redis 实战课程第15~19节转录文本