512MB存储40亿用户签到?二值统计让你怀疑人生!🤯

24 阅读9分钟

开场:被逼疯的签到系统

产品经理拿着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年数据查询速度支持运算适用场景
MySQL620GB慢(需索引)复杂SQL需要复杂查询
Redis Hash80GB需要代码数据量中等
Bitmap512MB ✅极快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: 频率统计

思考题🤔

  1. 如何用Bitmap实现"最近7天登录过的用户"查询?
  2. 如果要支持"取消签到",Bitmap如何处理?
  3. 如何高效统计"连续签到超过30天的用户"?

总结🎯

二值统计 = 用1个bit存储1个状态
    |
    ├─ 核心: Bitmap/BitSet数据结构
    ├─ 优势: 空间压缩1200倍
    ├─ 场景: 签到/活跃/权限等二值状态
    └─ 工具: Redis SETBIT/BITCOUNT/BITOP

记住3句话:

  1. 二值状态找Bitmap - 是/否场景必备
  2. 空间压缩到极致 - 512MB存40亿记录
  3. Redis开箱即用 - 5个命令走天下

下次产品再提"签到功能",直接说:"用Bitmap,512MB搞定1亿用户。"然后看着产品崇拜的眼神。[表情包: 装逼成功.gif]


建议文件名: 文章_二值统计_20251110.md

下一篇预告: 《排序统计: 如何O(n)时间找到10亿数中的中位数?》

继续进步,成为真正的性能优化大师!🚀✨