在大厂面试中,面试题:如何设计一个用户签到系统(BitMap)是考察数据结构选型与性能优化能力的经典题目。传统数据库存储签到记录会导致表数据量爆炸(如千万用户每日签到年增3.65亿条记录),而基于BitMap(位图)的设计能将存储空间压缩99%以上,同时支持高效的签到统计与连续签到计算。本文将从需求分析、BitMap原理、系统设计到Redis实战,全面拆解用户签到系统的设计要点,结合真实业务数据与Redis命令示例,帮你在面试中展现从底层优化到架构设计的全链路能力,正如鳄鱼java在《Redis实战指南》中强调的:"BitMap不是简单的存储优化,而是用位运算思维解决高并发统计问题的典范。"
需求分析:用户签到系统的核心挑战

设计用户签到系统需平衡存储成本、查询效率与业务扩展性,核心需求与技术挑战如下:
1. 业务需求与量化指标
- 核心功能:用户每日签到、当月签到状态查询、连续签到天数统计、签到排行榜
- 用户规模:支持千万级用户(如日活1000万)
- 数据量级:单用户年签到记录365条,千万用户年产生36.5亿条原始数据
- 性能要求:签到接口响应时间<10ms,统计接口<50ms,支持每秒10万+签到请求
传统关系型数据库方案的痛点:若使用MySQL存储签到记录(user_id, sign_date, is_sign),单表年数据量达36.5亿条,按每条记录30字节计算,年存储占用1095GB,查询时需全表扫描,性能极差。
2. BitMap方案的核心优势
BitMap通过位运算实现签到状态存储,核心优势如下: - 极致空间效率:1位(bit)存储1天签到状态,单用户每月仅需4字节(31天),年存储48字节,千万用户年存储仅480MB(传统方案的0.04%) - 高效统计能力:通过BITCOUNT命令秒级计算当月签到次数,BITOP命令实现连续签到天数统计 - Redis原生支持:Redis的String类型原生支持BitMap操作,无需额外组件,兼容性好
鳄鱼java技术团队实测显示:在1000万用户规模下,BitMap方案的存储成本仅为MySQL方案的1/2500,签到统计接口性能提升100倍以上。
BitMap核心原理:位运算与Redis实现
BitMap本质是二进制位数组,通过0/1标识状态,结合Redis的位操作命令实现高效签到功能。
1. 数据结构与存储设计
Redis中BitMap基于String类型实现(String最大支持512MB,可存储2^32-1位),设计如下:
- Key命名规范:sign:{user_id}:{year}:{month},如sign:10086:2024:05(用户10086在2024年5月的签到记录)
- 位偏移(Offset):当月第n天(1-31)对应位索引为n-1(如5月1日→offset=0,5月31日→offset=30)
- 值(Value):1表示签到,0表示未签到
示例:用户10086在5月1日、3日签到,BitMap存储为101000...(第0位和第2位为1,其余为0)。
2. Redis核心命令与操作示例
Redis提供丰富的BitMap命令,支撑签到系统核心功能:
| 命令 | 作用 | 示例 | 返回值 |
|---|---|---|---|
| SETBIT | 设置指定位的值(1/0) | SETBIT sign:10086:2024:05 0 1 | 0(原位置值) |
| GETBIT | 获取指定位的值 | GETBIT sign:10086:2024:05 0 | 1(已签到) |
| BITCOUNT | 统计1的个数(签到次数) | BITCOUNT sign:10086:2024:05 | 2(当月签到2天) |
| BITPOS | 查找第一个0/1的位置 | BITPOS sign:10086:2024:05 0 | 1(第一个未签到的日期是2日) |
系统设计:基于BitMap的签到系统架构
结合Redis BitMap与业务需求,设计高可用、高并发的签到系统架构。
1. 整体架构与核心服务
用户请求 → API网关(限流/鉴权) → 签到服务 → Redis集群(BitMap存储)
↓
统计分析服务 → MySQL/ClickHouse(历史数据归档)
核心组件职责: - 签到服务:处理签到请求(SETBIT)、查询签到状态(GETBIT),无状态设计支持水平扩容 - 统计分析服务:异步计算连续签到天数、月度签到率,结果缓存至Redis - Redis集群:主从+哨兵架构保证高可用,按用户ID哈希分片解决数据倾斜 - 归档存储:每月末将BitMap数据归档至MySQL/ClickHouse,支持历史数据查询
2. 核心功能实现方案
- 用户签到:
// 伪代码:用户签到 String key = String.format("sign:%s:%s:%s", userId, year, month); int day = LocalDate.now().getDayOfMonth(); int offset = day - 1; // 设置位值为1,返回原状态(0未签,1已签) Long original = redisTemplate.opsForValue().setBit(key, offset, true); if (original == 1) { return "今日已签到"; } else { // 触发连续签到统计 countContinuousSign(userId, year, month, day); return "签到成功"; } - 连续签到统计:
通过BITPOS命令查找当前日期前的第一个0位,计算连续天数:
// 伪代码:统计连续签到天数 String key = String.format("sign:%s:%s:%s", userId, year, month); int day = LocalDate.now().getDayOfMonth(); int offset = day - 1; // 从当前位置向前查找第一个0 Long firstZero = redisTemplate.execute(new DefaultRedisScript<>( "return redis.call('BITPOS', KEYS[1], 0, 0, ARGV[1])", Long.class), Collections.singletonList(key), String.valueOf(offset) ); int continuousDays = firstZero == -1 ? day : offset - firstZero.intValue();示例:当前day=5,offset=4,若前4位(0-3)
版权声明
本文仅代表作者观点,不代表百度立场。
本文系作者授权百度百家发表,未经许可,不得转载。





