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

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. 空间极致节省

    • 千万用户一天只需要 ≈ 1.2 MB
    • 一年 365 天也就 ≈ 440 MB
    • Redis 只缓存 当前一个月 ,所以 Redis 实际占用只有 ≈ 1.2 MB,几乎不占空间
    • 历史数据全部存在 MySQL,占用空间也比传统方案小很多
  2. 读写分离,冷热分离

    • 当月数据是热数据,全放 Redis 读写飞快
    • 历史数据是冷数据,放 MySQL 不占用 Redis 内存
    • 用户浏览历史记录才会查,不占用 Redis 宝贵内存
  3. 两种统计都高效

    • 查询用户某月签到 :当月需要 30 次 getBit(其实也很快),历史月从 MySQL 一次性读取组装
    • 统计今日全站签到人数 :直接 BITCOUNT 一步出结果,O(1) 复杂度

二、整体架构设计

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 交互流程

  1. 左侧/上方用户列表,支持分页搜索
  2. 点击用户选中,自动加载当前月份签到日历
  3. 日历网格展示:
    • 绿色 = 已签到
    • 灰色 = 不可点击(非当月/未来日期)
    • 边框加粗 = 今天
  4. 点击交互:
    • 未签到 → 签到
    • 已签到 → 取消签到
    • 非当月 → 提示无法操作

5.2 技术栈

  • Vue 2.x
  • Element UI
  • Axios
  • Vue CLI 脚手架

六、优缺点总结

优点 ✅

  1. 空间利用率极高:千万用户一年才 400多 MB,Redis 只缓存当月只用 36 MB
  2. 统计高效 :日总签到直接 BITCOUNT,O(1) 出结果
  3. 冷热分离:热数据在 Redis,冷数据在 MySQL,合理利用资源
  4. 支持取消签到setBit 直接设 0,非常方便
  5. 持久化可靠:Redis 重启后,当前月份自动从 MySQL 预热恢复

缺点 ⚠️

  1. 用户月查询需要多次网络调用 :当月查询需要 30 次 getBit,但实际上 30 次调用也很快,影响不大
  2. 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;
相关推荐
江华森2 小时前
FastAPI 极速开发指南 — 从零到生产级 API 实战
数据库·fastapi
小小工匠2 小时前
Redis - 如何使用 Redis 实现分布式锁
redis·性能优化·集群·并发
老纪3 小时前
Redis分布式锁进第九零篇
数据库·redis·分布式
haven-8523 小时前
MySQL事务ACID、隔离级别、MVCC、幻读解决
数据库·mysql
小高学习java3 小时前
事务的边界问题,如何判断数据回滚时机。
java·数据库·后端
迷枫7124 小时前
【无标题】
数据库
TDengine (老段)4 小时前
TDengine 扫描算子 — TableScan、TagScan 与下推优化
大数据·数据库·物联网·时序数据库·tdengine·涛思数据
放下华子我只抽RuiKe54 小时前
FastAPI 全栈后端(三):数据库与 ORM
前端·数据库·react.js·oracle·性能优化·前端框架·fastapi
x***r1515 小时前
linux安装 redis-8.6.0.tar.gz 详细步骤(源码编译、配置、启动)
redis