前段时间,一位兄弟去腾讯面试,回来跟我说他挂在了一道「签到题」上。
面试官问:
10 亿用户签到记一年,给你 1G 内存,怎么判断某个用户是否「连续签到 30 天」?
他开口就是 Bitmap,觉得自己答得不错。结果面试官继续追问,三句之后他就接不住了。
他缺的其实不是 Bitmap 怎么用,而是对「这道题到底在考什么」的理解。
一、先算一笔账:为什么不能硬存
10 亿用户,每人 365 天,如果把每次签到都当作 MySQL 里的一行:
10 亿 × 365 = 3650 亿 行
按 20 字节 / 行估算:3650 亿 × 20 ≈ 6.6 TB
6.6 TB,别说 1G,普通单机都扛不住。
但签到这个数据有个天然特性:它只有「签了」和「没签」两种状态。一个 bit 就能表达,不需要整行。
365 天 = 365 bit = 46 字节 / 人 / 年
10 亿用户全量:46 字节 × 10 亿 ≈ 43 GB
43 GB 依然要分片,但它已经从「做不了」变成了一道普通的容量题。
二、第一个坑:BITCOUNT 算不出「连续」
很多人一听到 Bitmap,马上想:
BITCOUNT 一下,不就知道 30 天里签了多少天吗?
但 BITCOUNT 只能告诉你「总数」,不能告诉你「连续」。
假设 30 天里有 30 个 1,但它们分别是月初 15 天、月末 15 天,中间断了——BITCOUNT 会开心地返回 30,但用户根本没连续签到。
所以这道题的真正考点不是「会不会用 Bitmap」,而是:拿到 46 字节之后,你怎么在本地判断连续性。
三、第二个坑:key 按哪个维度切
同样是 43 GB,两种存法,查询能力天差地别。
维度 | 按「天」存 | 按「用户」存 |
|---|---|---|
key 示例 | sign:20260802 | sign:u10086:2026 |
offset 含义 | uid | day_of_year |
单个 key 大小 | 10 亿 bit ≈ 119 MB | 365 bit ≈ 46 字节 |
擅长的查询 | 今天多少人签到? | 小强连签 30 天了吗? |
不擅长的查询 | 某人连续 30 天签到 → 30 次网络往返 | 今天总签到人数 → 遍历 10 亿 key |
面试官问「怎么算连续 30 天」,其实是在问:你的 key 按哪个维度切。
答案是按用户存。因为「连续签到」天然是单人维度的查询,一次 GET 就把一整年的 46 字节取回来。
四、正确做法:一次 GET,本地扫描
拿到 46 字节后,最简单的方法就是本地扫一遍。365 次循环,CPU 纳秒级完成。
# Java 示例:最长连续签到天数
public int maxConsecutiveDays(byte[] bits) {
int max = 0, cur = 0;
for (int i = 0; i < 365; i++) {
int b = (bits[i / 8] >> (i % 8)) & 1;
if (b == 1) {
cur++;
max = Math.max(max, cur);
} else {
cur = 0;
}
}
return max;
}
如果只想判断「是否存在连续 30 天」,可以用位运算技巧,五次移位就能出结果:
x &= x >> 1;
x &= x >> 2;
x &= x >> 4;
x &= x >> 8;
x &= x >> 14;
// 五步之后还非零,就说明存在连续 30 个 1
这五步的位移量不是各自独立生效,而是累加的——每一步都在「上一步已确认的长度」基础上再往外扩。把累计值摊开看:
步骤 | 本步位移 | 累计位移 | 能确认的连续 |
|---|---|---|---|
1 | 1 | 1 | ≥ 2 |
2 | 2 | 3 | ≥ 4 |
3 | 4 | 7 | ≥ 8 |
4 | 8 | 15 | ≥ 16 |
5 | 14 | 29 | ≥ 30 |
原理是每次把相邻的 1 压缩成一个「连续段标记」,五步之后如果还有非零位,就一定存在长度 ≥30 的连续 1。
五、那按天存的 Bitmap 是不是就没用了?
不是。两种维度服务不同的查询:
- 按用户存
查「某人连续签到多久」「某人哪天签了」——单人维度。
- 按天存
查「今天全站签到人数」「连续 N 天全勤的用户有哪些」——全站维度。
大厂的真实答案通常是两份都存。按用户的版本放在缓存层,支撑实时查询;按天的版本用于运营统计、离线分析。
有人问:用 BITOP AND 把 30 个按天的 bitmap 做与运算,不也能找出连续签到的人吗?技术上可以,但每个 key 119 MB,30 个 key 就是 3.5 GB,Redis 单线程会阻塞几百毫秒甚至秒级——线上不敢跑。
六、Redis 里怎么落盘
写入时只需要一行:
SETBIT sign:{uid}:{year} {day_of_year} 1
读取时一次 GET:
GET sign:{uid}:{year}
然后交给本地代码去判断连续性。Redis 只负责存取,不做滑动窗口计算。
跨年问题也好处理:如果当前日期在 1 月初,「最近 30 天」会跨到去年,读两个 key 拼起来即可。
七、常见翻车答案
答案 | 问题 |
|---|---|
MySQL 一行一条签到记录 | 6.6 TB,查询和存储都扛不住 |
Redis List / Set 存日期 | 每人 365 条记录,内存约 1.4 TB |
BITCOUNT 判断连续 | 只能算总数,不能判断连续性 |
BITOP AND 在线执行 | 3.5 GB 数据在单线程 Redis 上阻塞 |
只按天存 Bitmap | 单人连续签到要 30 次网络往返 |
八、面试官大概率会追问的变体
1)如果要看「累计签到次数」,能不能继续用 Bitmap?
可以,但 Bitmap 只能表达 0/1,累计次数需要 HyperLogLog 或一个额外的计数器。
2)如果签到数据很稀疏,99% 的人不活跃,Bitmap 会不会浪费?
会。稀疏场景下 Roaring Bitmap 比普通 Bitmap 省得多,这是工程上的进阶选项。
3)如果要发「连续签到 30 天」的奖励,怎么保证幂等?
不能靠判断连续就发奖,必须用另一个 key 记录「该用户是否已领取」,否则重试会多发。
4)如果内存再砍一半,只剩 256MB,怎么办?
用哈希分桶 + 外部排序:把 QQ 号或 uid 哈希到不同文件,分桶去重/分桶统计。
九、面试标准答案模板(直接背诵)
上面八节是原理,这一节是你能直接背进面试的话术。面试官问到「连续签到」,照着这五步走:
第一步 · 选型
签到只有「签了 / 没签」两种状态,用 Bitmap。每人每年 365 bit = 46 字节,全量 43 GB,比 MySQL 硬存的 6.6 TB 少了约 159 倍。这是存储层面的结论。
第二步 · 定 key 维度
这道题问的是「某个用户连续 30 天」,所以按用户存:sign:{uid}:{year},offset 用 day_of_year。一次 GET 取回 46 字节,本地就能算单人连续。按天存适合全站统计,不适合这道题。
第三步 · 主动避坑
BITCOUNT 只能算「总天数」,算不出「连续」。所以我不靠 BITCOUNT 判断连续,避免被追问时接不住。
第四步 · 给具体算法
GET 回来 46 字节后在本地扫一遍(365 次循环,纳秒级)。如果只判断「是否存在连续 30 天」,用五次移位x &= x>>1/2/4/8/14,结果还非零就存在连续段。Redis 只负责存取,不在线上做滑动窗口计算。
第五步 · 补工程闭环
线上通常两份都存:按用户的做实时查询,按天的做运营统计;跨年就读两个 key 拼起来。奖励发放用独立 key 保证幂等,避免重试多发。
背这一套的价值:它同时覆盖了「存储」「key 设计」「避坑」「算法」「工程闭环」五个层次,面试官不管往哪个方向追,你都有下一句接住。
Bitmap 不是数据结构的高招,而是「把业务语义压进 bit」的抽象能力。真正决定答案质量的,不是你调用了哪个 Redis 命令,而是你能否一眼看出这道题考的是 key 的维度选择。