开场:被逼疯的签到系统
产品经理拿着PRD兴奋地说:"咱们做个签到功能,记录用户每天有没有签到,然后统计连续签到天数,搞个排行榜!"
你一算:
- 用户量: 1亿
- 记录天数: 365天
- 每条记录:
user_id(8字节) + date(8字节) + status(1字节) = 17字节 - 总内存:
1亿 × 365 × 17字节 ≈ 620GB💀
用MySQL存? 表都建不起来...
用Redis Hash? 内存直接炸了...
这时候你的高级同事过来瞟了一眼,淡定地说:"用Bitmap啊,512MB就够了。"
你: [表情包:黑人问号.jpg]
今天就告诉你**二值统计(Binary Statistics)**这个神器,如何用1个bit搞定原本需要17字节的事!🎯
一句人话版定义
二值统计 = 用0和1两个状态(bit)存储和统计海量的"是/否"数据,极致压缩空间。
- 传统方案: 用数据库/缓存存"已签到"/"未签到",内存炸裂
- 二值统计: 1个bit表示1种状态,1个字节能存8条记录
严谨定义: 二值统计通过位图(Bitmap/BitSet)数据结构,将布尔状态映射到bit位,实现O(1)时间复杂度的查询/修改,空间复杂度为O(n/8)字节。
生活类比:
传统方法相当于给每个人发一张A4纸写"已签到/未签到"(太浪费)。
二值统计相当于准备一张超大表格,每个格子只画"√"或空着(极致压缩)。
为什么需要二值统计?💡
适用场景
场景1: 用户行为统计
- ✅ 签到系统: 用户今天签到了吗?
- ✅ 活跃统计: 这个月用户哪几天登录了?
- ✅ 消息已读: 用户读过哪些消息?
场景2: 权限管理
- ✅ 功能开关: 用户是否开启了某功能?
- ✅ 灰度发布: 哪些用户在灰度名单?
场景3: 数据分析
- ✅ 去重统计: 哪些IP访问过?
- ✅ 集合运算: 今天和昨天都活跃的用户(交集)
对比: 为什么不用HashMap/数据库?
| 方案 | 1亿用户1年数据 | 查询速度 | 支持运算 | 适用场景 |
|---|---|---|---|---|
| MySQL | 620GB | 慢(需索引) | 复杂SQL | 需要复杂查询 |
| Redis Hash | 80GB | 快 | 需要代码 | 数据量中等 |
| Bitmap | 512MB ✅ | 极快O(1) | 原生支持 | 二值状态 |
压缩比: Bitmap比数据库方案省 1200倍 空间!🚀
核心原理: Bitmap数据结构详解🔍
🎯 位运算基础
1个字节(byte) = 8位(bit),每个bit只能是0或1:
字节: 10110100
位置: 76543210 (从右到左编号)
↑↑↑↑↑↑↑↑
││││││││
值: 10110100
核心操作:
// 设置第n位为1 (标记签到)
bitmap[n/8] |= (1 << (n%8))
// 设置第n位为0 (取消标记)
bitmap[n/8] &= ~(1 << (n%8))
// 查询第n位是否为1
boolean exists = (bitmap[n/8] & (1 << (n%8))) != 0
[图表1: Bitmap存储示意图]
用户ID: 0 1 2 3 4 5 6 7 | 8 9 10 ...
|----------------------------------|-----|
字节0: [0 1 0 1 1 0 1 0]| ...
未签 签到 未签 签到 签到 未签 签到 未签
内存占用: 1字节存储8个用户的状态
🧮 空间计算
统计1亿用户365天签到记录:
空间 = 用户数 × 天数 / 8字节
= 100,000,000 × 365 / 8
= 4,562,500,000字节
≈ 512MB ✅
相比MySQL方案的620GB,省了 99.9% 空间!
Java实现: 从零开始手写Bitmap🔥
方式1: 使用Java原生BitSet
import java.util.BitSet;
public class UserSignInSystem {
// key: userId, value: 365位的BitSet (每位代表一天)
private Map<Integer, BitSet> userSignRecords = new ConcurrentHashMap<>();
/**
* 用户签到
* @param userId 用户ID
* @param dayOfYear 一年中的第几天 (1-365)
*/
public void signIn(int userId, int dayOfYear) {
BitSet signRecord = userSignRecords.computeIfAbsent(
userId,
k -> new BitSet(365)
);
signRecord.set(dayOfYear - 1); // BitSet从0开始索引
}
/**
* 查询用户某天是否签到
*/
public boolean isSignedIn(int userId, int dayOfYear) {
BitSet signRecord = userSignRecords.get(userId);
return signRecord != null && signRecord.get(dayOfYear - 1);
}
/**
* 统计用户总签到天数
*/
public int getTotalSignDays(int userId) {
BitSet signRecord = userSignRecords.get(userId);
return signRecord == null ? 0 : signRecord.cardinality();
}
/**
* 统计连续签到天数
*/
public int getContinuousSignDays(int userId, int today) {
BitSet signRecord = userSignRecords.get(userId);
if (signRecord == null) return 0;
int continuous = 0;
for (int day = today - 1; day >= 0; day--) {
if (signRecord.get(day)) {
continuous++;
} else {
break; // 遇到未签到就中断
}
}
return continuous;
}
/**
* 测试示例
*/
public static void main(String[] args) {
UserSignInSystem system = new UserSignInSystem();
int userId = 12345;
// 模拟用户签到: 第1,2,3,5,6,7天签到
system.signIn(userId, 1);
system.signIn(userId, 2);
system.signIn(userId, 3);
system.signIn(userId, 5);
system.signIn(userId, 6);
system.signIn(userId, 7);
System.out.println("第3天签到了吗? " + system.isSignedIn(userId, 3)); // true
System.out.println("第4天签到了吗? " + system.isSignedIn(userId, 4)); // false
System.out.println("总签到天数: " + system.getTotalSignDays(userId)); // 6
System.out.println("从第7天往前连续签到: " + system.getContinuousSignDays(userId, 7)); // 3天(5,6,7)
}
}
方式2: 手写Bitmap (理解原理)
public class SimpleBitmap {
private byte[] bytes;
private int capacity; // 最大支持的bit数
public SimpleBitmap(int capacity) {
this.capacity = capacity;
this.bytes = new byte[(capacity + 7) / 8]; // 向上取整
}
/**
* 设置第n位为1
*/
public void set(int n) {
if (n >= capacity) throw new IndexOutOfBoundsException();
int byteIndex = n / 8;
int bitIndex = n % 8;
bytes[byteIndex] |= (1 << bitIndex);
}
/**
* 清除第n位 (设为0)
*/
public void clear(int n) {
if (n >= capacity) throw new IndexOutOfBoundsException();
int byteIndex = n / 8;
int bitIndex = n % 8;
bytes[byteIndex] &= ~(1 << bitIndex);
}
/**
* 查询第n位是否为1
*/
public boolean get(int n) {
if (n >= capacity) throw new IndexOutOfBoundsException();
int byteIndex = n / 8;
int bitIndex = n % 8;
return (bytes[byteIndex] & (1 << bitIndex)) != 0;
}
/**
* 统计有多少个1 (签到总天数)
*/
public int cardinality() {
int count = 0;
for (byte b : bytes) {
count += Integer.bitCount(b & 0xFF); // 统计字节中1的个数
}
return count;
}
/**
* 与操作 (求交集: 两天都签到的用户)
*/
public SimpleBitmap and(SimpleBitmap other) {
SimpleBitmap result = new SimpleBitmap(this.capacity);
for (int i = 0; i < bytes.length; i++) {
result.bytes[i] = (byte) (this.bytes[i] & other.bytes[i]);
}
return result;
}
/**
* 或操作 (求并集: 任意一天签到的用户)
*/
public SimpleBitmap or(SimpleBitmap other) {
SimpleBitmap result = new SimpleBitmap(this.capacity);
for (int i = 0; i < bytes.length; i++) {
result.bytes[i] = (byte) (this.bytes[i] | other.bytes[i]);
}
return result;
}
}
工业级方案: Redis Bitmap实战🚀
为什么选Redis?
- ✅ 天然支持: String类型就是Bitmap
- ✅ 内存优化: 自动压缩稀疏位图
- ✅ 丰富运算: AND/OR/XOR/NOT原生支持
核心命令
# 1. 设置位 (用户123在第10天签到)
SETBIT sign:123 10 1
# 2. 获取位 (查询用户123第10天是否签到)
GETBIT sign:123 10 # 返回1或0
# 3. 统计1的个数 (总签到天数)
BITCOUNT sign:123
# 4. 位运算 (统计1号和2号都签到的用户)
BITOP AND result sign:day1 sign:day2
BITCOUNT result
Java + Jedis完整实战
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.BitPosParams;
public class RedisSignInSystem {
private Jedis jedis;
public RedisSignInSystem(String host, int port) {
this.jedis = new Jedis(host, port);
}
/**
* 用户签到
*/
public void signIn(int userId, int dayOfYear) {
String key = "sign:user:" + userId;
jedis.setbit(key, dayOfYear - 1, true);
jedis.expire(key, 400 * 24 * 3600); // 保留400天
}
/**
* 查询是否签到
*/
public boolean isSignedIn(int userId, int dayOfYear) {
String key = "sign:user:" + userId;
return jedis.getbit(key, dayOfYear - 1);
}
/**
* 统计总签到天数
*/
public long getTotalSignDays(int userId) {
String key = "sign:user:" + userId;
return jedis.bitcount(key);
}
/**
* 统计本月签到天数
*/
public long getMonthSignDays(int userId, int monthStart, int monthEnd) {
String key = "sign:user:" + userId;
// BITCOUNT支持范围统计 (注意这里是字节范围)
return jedis.bitcount(key, monthStart / 8, monthEnd / 8);
}
/**
* 查询第一次签到是哪天
*/
public long getFirstSignDay(int userId) {
String key = "sign:user:" + userId;
return jedis.bitpos(key, true); // 返回第一个1的位置
}
/**
* 统计某天所有签到用户 (按天存储)
*/
public void signInByDay(int userId, String date) {
String key = "sign:day:" + date;
jedis.setbit(key, userId, true);
jedis.expire(key, 90 * 24 * 3600); // 保留90天
}
/**
* 统计某天签到人数
*/
public long getDaySignCount(String date) {
return jedis.bitcount("sign:day:" + date);
}
/**
* 统计连续两天都签到的用户数 (位运算AND)
*/
public long getContinuousSignUsers(String day1, String day2) {
String resultKey = "sign:temp:" + System.currentTimeMillis();
jedis.bitop(BitOP.AND, resultKey,
"sign:day:" + day1,
"sign:day:" + day2
);
long count = jedis.bitcount(resultKey);
jedis.del(resultKey); // 清理临时key
return count;
}
/**
* 完整示例
*/
public static void main(String[] args) {
RedisSignInSystem system = new RedisSignInSystem("localhost", 6379);
// 用户签到
int userId = 12345;
system.signIn(userId, 1);
system.signIn(userId, 2);
system.signIn(userId, 5);
// 查询统计
System.out.println("第1天签到了吗? " + system.isSignedIn(userId, 1));
System.out.println("总签到天数: " + system.getTotalSignDays(userId));
System.out.println("第一次签到: 第" + (system.getFirstSignDay(userId) + 1) + "天");
// 按天统计 (适合分析每天有多少人签到)
system.signInByDay(12345, "2025-11-10");
system.signInByDay(12346, "2025-11-10");
system.signInByDay(12347, "2025-11-10");
System.out.println("2025-11-10签到人数: " + system.getDaySignCount("2025-11-10"));
}
}
进阶: Roaring Bitmap优化稀疏数据💎
问题: 普通Bitmap的浪费
假设统计用户ID为 [1, 1000000, 2000000] 的签到:
普通Bitmap需要: 2000000 / 8 = 250KB
但实际只有3个用户!
解决: Roaring Bitmap (Redis 6.2+)
原理: 分块压缩,稀疏用数组,密集用bitmap
import org.roaringbitmap.RoaringBitmap;
public class RoaringBitmapExample {
public static void main(String[] args) {
RoaringBitmap bitmap = new RoaringBitmap();
// 添加稀疏数据
bitmap.add(1);
bitmap.add(1_000_000);
bitmap.add(2_000_000);
System.out.println("包含1? " + bitmap.contains(1));
System.out.println("基数: " + bitmap.getCardinality());
System.out.println("内存占用: " + bitmap.getSizeInBytes() + " 字节");
// 输出: 只有几百字节!
// 集合运算
RoaringBitmap other = RoaringBitmap.bitmapOf(1, 500_000, 1_000_000);
RoaringBitmap intersection = RoaringBitmap.and(bitmap, other);
System.out.println("交集: " + intersection.getCardinality()); // 2
}
}
Maven依赖:
<dependency>
<groupId>org.roaringbitmap</groupId>
<artifactId>RoaringBitmap</artifactId>
<version>0.9.45</version>
</dependency>
方案选型指南⚖️
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 用户ID连续 (1-1亿) | Redis Bitmap | 内存占用可预测 |
| 用户ID稀疏 (跳号严重) | Roaring Bitmap | 自动压缩 |
| 单机应用 | Java BitSet | 无需Redis |
| 需要持久化 | Redis + RDB | 自动落盘 |
| 实时分析 | Redis Bitmap | 位运算快 |
踩坑指南⚠️
坑1: Bitmap的索引陷阱
// ❌ 错误: 用字符串作为offset
jedis.setbit("key", "user123", true); // 报错!
// ✅ 正确: offset必须是整数
jedis.setbit("key", 123, true);
// 如果userId是字符串,需要映射成数字
Map<String, Integer> userIdMapping = ...;
int offset = userIdMapping.get("user_abc_123");
坑2: 内存暴涨的大坑
// ❌ 致命错误: 用超大offset
jedis.setbit("key", Integer.MAX_VALUE, true);
// Redis会分配 2GB内存! (Integer.MAX_VALUE / 8)
// ✅ 正确: 控制offset范围
// 如果userId最大1亿,offset就不要超过1亿
坑3: BITCOUNT的字节陷阱
# 错误理解: 统计第10-20位的1
BITCOUNT key 10 20 # ❌ 这是第10-20字节!
# 正确: 要统计bit,需要转换
# 第10-20位 = 第1-2字节
BITCOUNT key 1 2 # ✅
扩展阅读📚
相关技术
- BloomFilter: 判断元素是否存在(有误判)
- HyperLogLog: 基数统计(有误差)
- Count-Min Sketch: 频率统计
思考题🤔
- 如何用Bitmap实现"最近7天登录过的用户"查询?
- 如果要支持"取消签到",Bitmap如何处理?
- 如何高效统计"连续签到超过30天的用户"?
总结🎯
二值统计 = 用1个bit存储1个状态
|
├─ 核心: Bitmap/BitSet数据结构
├─ 优势: 空间压缩1200倍
├─ 场景: 签到/活跃/权限等二值状态
└─ 工具: Redis SETBIT/BITCOUNT/BITOP
记住3句话:
- 二值状态找Bitmap - 是/否场景必备
- 空间压缩到极致 - 512MB存40亿记录
- Redis开箱即用 - 5个命令走天下
下次产品再提"签到功能",直接说:"用Bitmap,512MB搞定1亿用户。"然后看着产品崇拜的眼神。[表情包: 装逼成功.gif]
建议文件名: 文章_二值统计_20251110.md
下一篇预告: 《排序统计: 如何O(n)时间找到10亿数中的中位数?》
继续进步,成为真正的性能优化大师!🚀✨