Redis Bitmaps 用户签到系统设计方案

一、需求分析与方案演进
1.1 需求背景
我们需要实现一个用户签到系统,核心需求:
- 用户可以每日签到
- 查询用户某年/某月签到情况
- 统计用户累计签到天数
- 千万级用户规模,需要高效节省内存
1.2 方案选型对比
| 方案 | 空间占用(千万用户) | 查询用户月签到 | 统计日总签到 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 按用户按行存储(每条签到一条记录) | 千万用户 × 365条 ≈ 数十亿行,几百GB | 一次查询 | 聚合查询慢 | 功能灵活 | 空间爆炸,查询慢 |
| 按用户按年存储(一个用户一年一个bitmap) | 每个用户 46字节 × 千万 ≈ 44MB | 一次获取全年,按月截取 | 需要遍历所有用户 | 用户查询快,空间小 | 统计日总签到慢,长期还是占用空间 |
| 按用户按月存储(一个用户一个月一个bitmap) | 每个月 4字节 × 千万 ≈ 38MB/月 | 一次查询 | 依然很慢 | 空间比按年更省,空月不占用 | 还是统计日总签到慢 |
| 🔥 按天存储,全站共享一个bitmap,userId作为偏移 | 一天 1.2MB × 365 ≈ 438MB/年 | 30次查询 | 一次 BITCOUNT 出结果 |
空间极省,统计日签到极快 | 用户月查询需要30次调用 |
1.3 最终方案确定
我们最终选择 按天存储 + MySQL持久化 + Redis只缓存当月 的混合方案:
- 当前月份:全量放在 Redis 中,读写都走 Redis,速度快
- 历史月份:全量保存在 MySQL 中,需要查询时直接从 MySQL 读取组装,不存入 Redis
- 定时任务:每天结束将当天 bitmap 持久化到 MySQL
- 启动自动预热:程序启动只预热当前月份到 Redis
1.4 当前方案优点
-
空间极致节省
- 千万用户一天只需要 ≈ 1.2 MB
- 一年 365 天也就 ≈ 440 MB
- Redis 只缓存 当前一个月 ,所以 Redis 实际占用只有 ≈ 1.2 MB,几乎不占空间
- 历史数据全部存在 MySQL,占用空间也比传统方案小很多
-
读写分离,冷热分离
- 当月数据是热数据,全放 Redis 读写飞快
- 历史数据是冷数据,放 MySQL 不占用 Redis 内存
- 用户浏览历史记录才会查,不占用 Redis 宝贵内存
-
两种统计都高效
- 查询用户某月签到 :当月需要 30 次
getBit(其实也很快),历史月从 MySQL 一次性读取组装 - 统计今日全站签到人数 :直接
BITCOUNT一步出结果,O(1) 复杂度
- 查询用户某月签到 :当月需要 30 次
二、整体架构设计
2.1 存储设计
Redis Key 设计:
checkin:date:yyyy-MM-dd
例如今天:checkin:date:2026-06-13
Bit 偏移计算:
- 偏移量直接使用 userId
- userId 就是第几位,天然对应,不需要额外计算
- 支持千万用户,最大 userId = 10,000,000 ≈ 1.2 MB/天
MySQL 表结构:
sql
CREATE TABLE `t_user_checkin` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`data_str` varchar(15) NOT NULL COMMENT '日期字符串 yyyy-MM-dd',
`info` longtext COMMENT 'bitmap二进制字符串 010101...',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_data_str` (`data_str`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.2 核心逻辑流程
签到流程
用户点击日期 → 检查是否当前月份
↓
非当前月 → 禁止操作
↓
已签到 → 点击取消签到 → setBit key userId false
↓
未签到 → 执行签到 → setBit key userId true
查询用户月签到流程
前端请求 userId + year + month
↓
判断是否当前月份
↓
┌───────────┴───────────┐
↓ ↓
当前月份 历史月份
↓ ↓
循环天数,每天一次 从MySQL查询该月所有日期
getBit(key, userId) 每个日期取出对应用户id bit
↓
返回布尔数组
持久化流程
每天结束(定时任务调用)
↓
从 Redis GET 取出整个bitmap字节数组
↓
逐位转换为二进制字符串 "010101..."
↓
保存到 MySQL t_user_checkin
启动预热流程
Spring Boot 启动完成
↓
获取当前年月
↓
从MySQL查询当前月所有日期
↓
二进制字符串转换为字节数组
↓
SET 一次性写入 Redis
↓
预热完成
三、核心实现细节
3.1 签到/取消签到实现
java
// 签到
public boolean doCheckin(Long userId, String date) {
// 只有当月才能签到
if (!isCurrentMonth(...)) throw ...;
// 查询是否已签到
Boolean isChecked = redisTemplate.opsForValue().getBit(getRedisKey(date), userId.intValue());
if (Boolean.TRUE.equals(isChecked)) return false;
// 设置bit为1
redisTemplate.opsForValue().setBit(getRedisKey(date), userId.intValue(), true);
return true;
}
// 取消签到
public boolean cancelCheckin(Long userId, String date) {
Boolean isChecked = redisTemplate.opsForValue().getBit(getRedisKey(date), userId.intValue());
if (!Boolean.TRUE.equals(isChecked)) return false;
// 设置bit为0
redisTemplate.opsForValue().setBit(getRedisKey(date), userId.intValue(), false);
return true;
}
- 一次 Redis 命令完成操作,非常高效
- 只有当前月允许操作,历史月禁止修改
3.2 持久化:Redis → MySQL
java
public void saveToMysql(String dateStr) {
String key = getRedisKey(dateStr);
// 1. 一次性取出整个bitmap字节数组
byte[] bitmapBytes = redisTemplate.execute(
connection -> connection.get(key.getBytes())
);
if (bitmapBytes == null) { delete; return; }
// 2. 字节数组 → 二进制字符串
StringBuilder binary = new StringBuilder();
for (byte b : bitmapBytes) {
for (int i = 7; i >= 0; i--) { // Redis大端字节序,高位在前
int bit = (b >> i) & 1;
binary.append(bit);
}
}
// 3. 保存到MySQL
userCheckinMapper.upsert(new UserCheckin(dateStr, binary.toString()));
}
要点:
- 字节序和 Redis 保持一致,大端(高位在前)
- 一次性读取整个bitmap,比多次调用快很多
- MySQL 使用
ON DUPLICATE KEY UPDATE实现 upsert
3.3 预热:MySQL → Redis
java
private void warmupOneDay(UserCheckin item) {
String binaryStr = item.getInfo();
// 1. 计算字节数 = (长度 + 7) / 8
int bytesLength = (binaryStr.length() + 7) / 8;
byte[] bytes = new byte[bytesLength];
// 2. 二进制字符串 → 字节数组(位运算设置)
for (int i = 0; i < binaryStr.length(); i++) {
if (binaryStr.charAt(i) == '1') {
int byteIndex = i / 8;
int bitIndex = 7 - (i % 8); // 保持和Redis一致的字节序
bytes[byteIndex] |= (1 << bitIndex);
}
}
// 3. 一次性 SET 写入Redis
redisTemplate.execute(connection -> {
connection.set(key.getBytes(), bytes);
return bytes;
});
}
优化点:
- ❌ 原来方案:逐位
setBit,需要 N 次 Redis 调用 - ✅ 优化后:本地计算好整个字节数组,一次 SET 写入,速度提升几百倍
- 字节序和 Redis 完全一致,直接可用
3.4 查询:当月 vs 历史月
当月查询(走 Redis):
java
if (isCurrentMonth(year, month)) {
// 当前月:每天一个key,逐天getBit获取当前用户是否签到
for (int day = 1; day <= daysInMonth; day++) {
String dateStr = ...;
Boolean checked = redisTemplate.opsForValue().getBit(getRedisKey(dateStr), userId.intValue());
result[day - 1] = Boolean.TRUE.equals(checked);
}
}
历史月查询(走 MySQL,不回写 Redis):
java
} else {
// 非当前月:从MySQL读取,一次性组装,不存入Redis节省内存
List<UserCheckin> list = persistenceService.loadByMonth(yearMonth);
for (UserCheckin item : list) {
int day = ...;
boolean[] bits = binaryStringToBits(item.getInfo());
if (userId < bits.length) {
result[day - 1] = bits[userId.intValue()];
}
}
}
设计思路:
- 冷热分离:热数据(当月)在 Redis,冷数据(历史)在 MySQL
- 不回写:历史月查询完不存入 Redis,Redis 永远只存当前月,内存占用极小
- 如果用户不查历史,Redis 永远只有几十MB不到,非常干净
四、内存占用分析
4.1 按天共享 bitmap 空间计算
| 用户量 | 位数 | 字节数 | 每天占用 |
|---|---|---|---|
| 10万 | 100,000 bit | 12,500 B | 12.2 KB |
| 100万 | 1,000,000 bit | 125,000 B | 122 KB |
| 1000万 | 10,000,000 bit | 1,250,000 B | ≈ 1.2 MB/天 |
| 1亿 | 100,000,000 bit | 12,500,000 B | ≈ 12 MB/天 |
4.2 全年总占用
| 用户量 | 一年占用 |
|---|---|
| 100万 | ≈ 44 MB |
| 1000万 | ≈ 440 MB ≈ 0.43 GB |
| 1亿 | ≈ 4.3 GB |
4.3 Redis 实际占用(我们的方案)
因为只缓存当前月份,所以 Redis 实际占用:
Redis 占用 = 当前月份天数 × 1.2 MB/天 ≈ 30 × 1.2 MB = 36 MB
👉 千万用户情况下,Redis 只占用 36 MB,非常惊人!
五、前端设计
5.1 交互流程
- 左侧/上方用户列表,支持分页搜索
- 点击用户选中,自动加载当前月份签到日历
- 日历网格展示:
- 绿色 = 已签到
- 灰色 = 不可点击(非当月/未来日期)
- 边框加粗 = 今天
- 点击交互:
- 未签到 → 签到
- 已签到 → 取消签到
- 非当月 → 提示无法操作
5.2 技术栈
- Vue 2.x
- Element UI
- Axios
- Vue CLI 脚手架
六、优缺点总结
优点 ✅
- 空间利用率极高:千万用户一年才 400多 MB,Redis 只缓存当月只用 36 MB
- 统计高效 :日总签到直接
BITCOUNT,O(1) 出结果 - 冷热分离:热数据在 Redis,冷数据在 MySQL,合理利用资源
- 支持取消签到 :
setBit直接设 0,非常方便 - 持久化可靠:Redis 重启后,当前月份自动从 MySQL 预热恢复
缺点 ⚠️
- 用户月查询需要多次网络调用 :当月查询需要 30 次
getBit,但实际上 30 次调用也很快,影响不大 - userId 不能跳跃太大:如果最大 userId 是 1亿,但实际只有 1千万用户,会浪费一些空间(但依然比传统方案小很多)
适用场景
- ✅ 用户ID自增,用户量从几万到一亿
- ✅ 需要统计全站日签到人数
- ✅ 对内存占用比较敏感
- ✅ 大部分用户只看当月,历史月很少访问
七、项目结构
后端(Spring Boot)
├── config/
│ ├── CorsConfig.java # 跨域配置
│ └── RedisConfig.java # RedisTemplate 配置
├── controller/
│ ├── CheckinController.java # 签到接口
│ └── UserController.java # 用户列表接口
├── entity/
│ ├── User.java # 用户实体
│ └── UserCheckin.java # 签到持久化实体
├── mapper/
│ ├── UserMapper.java # 用户 DAO
│ └── UserCheckinMapper.java # 签到持久化 DAO
├── service/
│ ├── UserCheckinService.java # 签到服务接口
│ ├── CheckinPersistenceService.java # 持久化服务接口
│ └── impl/
│ ├── UserCheckinServiceImpl.java
│ └── CheckinPersistenceServiceImpl.java
└── RedisBitDemoApplication.java # 启动类,自动预热
前端(Vue CLI)
├── src/
│ ├── api.js # API 封装
│ ├── App.vue # 主页面
│ └── main.js # 入口,引入Element UI
├── vue.config.js # 开发代理配置
└── package.json
八、启动方式
后端启动:
bash
mvn spring-boot:run
默认端口 8080
前端启动:
bash
cd vscode/redis-checkin2
npm install
npm run serve
默认端口 8081,代理自动转发到后端 8080
数据库准备 :
需要提前创建两张表:
sql
-- 用户表
CREATE TABLE `t_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID,主键',
`username` varchar(50) NOT NULL COMMENT '用户名,唯一',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`city` varchar(50) DEFAULT NULL COMMENT '常在的城市',
`gender` tinyint(4) DEFAULT NULL COMMENT '性别:0-未知,1-男,2-女',
`age` int(11) DEFAULT NULL COMMENT '年龄',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_phone` (`phone`),
KEY `idx_email` (`email`),
KEY `idx_city` (`city`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';
-- 签到持久化表
CREATE TABLE `t_user_checkin` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键无实际意义',
`data_str` varchar(15) NOT NULL COMMENT '日期字符串',
`info` longtext COMMENT '当前签到信息',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_data_str` (`data_str`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;