BitMap实战:百亿级用户统计内存降90%
发布日期: 2026/07/25 阅读总量: 0

一、一个真实到让人头皮发麻的统计需求

半年前,我们运维某社交App的每日用户登录统计模块。业务要求:统计每个用户当月登录天数、连续登录≥3天的用户数、同时登录过某两个功能模块的用户交集。数据量:注册用户1.2亿,每日登录记录3-5亿条,存储在MySQL user_login_log 表中,按(user_id, login_date)建联合索引。

第一版统计脚本凌晨2点跑,到早上8点还没跑完,数据库CPU 100%,大量慢查询把主库拖死。CTO拍桌子:再给三天,优化不掉你走人。

我拆解了核心查询:

-- 统计连续7天登录的用户(粗暴写法)
SELECT user_id, COUNT(DISTINCT login_date) days
FROM user_login_log
WHERE login_date BETWEEN '2025-01-01' AND '2025-01-07'
GROUP BY user_id
HAVING days >= 7;

这条SQL扫描行数超过10亿,explain显示using temporary; using filesort。跑一次40分钟,还不算业务方临时要加其他条件。

二、问题根源:数据量太大,关系型数据库天生不适合“存在性判断”

统计的本质是判断“某个用户在某天是否登录”,这是一个典型的集合成员判断问题。用MySQL的行存储,每一条记录包含user_id(8字节)、login_date(3字节)、索引等开销,单条记录≈30字节。1亿用户30天,假设每个用户每天登录1次(实际上更频繁),数据量≈30亿行,存储超过60GB,查询必死。

如果用Redis Set存储每天的活跃用户ID:

  • Key: active:2025-01-01
  • Value: Set of user_id (string)
  • 每个user_id用char(10)存储,1亿用户≈1GB,30天≈30GB。内存占用仍然过高,且Redis持久化、主从同步成本爆炸。

三、BitMap:用1位表示1个用户的状态

BitMap的核心思想:用一个比特位表示一个用户的登录状态。例如,我们将用户ID映射到一个整数偏移量(0~N-1),第i位为1表示用户i登录了。

存储1亿个用户只需 100,000,000 bit ≈ 11.9 MB。30天数据仅需 357 MB。内存压缩了90%以上。

位运算天然支持集合操作:AND(交集)、OR(并集)、XOR(差集)、BITCOUNT(基数统计)。对于连续N天登录,就是对N天的BitMap做 AND 操作,结果为1的位就代表连续N天登录。

四、两种主流BitMap实现方案对比

方案存储介质单用户存储开销交集性能数据持久化扩展性
Redis BitMapRedis String1 bitBITOP O(N),纯内存极快RDB/AOF,大key需注意单机内存有限,需分片
PHP自实现BitMapPHP内存数组 / 文件1 bit与或非自行位运算可序列化到文件或数据库灵活,可横向扩展

我们最终选择Redis BitMap作为在线统计层,PHP自实现本地BitMap做离线批处理。(下文代码主要为Redis方案)

五、完整代码实现

5.1 用户ID到偏移量的映射

用户ID不能直接用做偏移量,否则ID不均匀(比如从1亿开始的用户)会浪费大量高位。我们使用Redis HASH维护一个自增映射表:

/**
 * 用户ID <-> BitMap偏移量 双向映射
 * 使用Redis HASH存储,确保偏移量连续、紧凑
 */
class UserOffsetMapper {
    private Redis $redis;
    private string $hashKey;
    private string $counterKey;
    private string $reverseHashKey;

    public function __construct(Redis $redis) {
        $this->redis = $redis;
        $this->hashKey = 'user:offset:map';
        $this->counterKey = 'user:offset:counter';
        $this->reverseHashKey = 'user:offset:reverse';
    }

    /**
     * 获取或创建用户ID的偏移量
     * @param int $userId
     * @return int 偏移量,从0开始
     */
    public function getOffset(int $userId): int {
        if ($this->redis->hExists($this->hashKey, $userId)) {
            return (int)$this->redis->hGet($this->hashKey, $userId);
        }
        // 原子自增获取新偏移量
        $offset = $this->redis->incr($this->counterKey) - 1;
        $this->redis->hSet($this->hashKey, $userId, $offset);
        $this->redis->hSet($this->reverseHashKey, $offset, $userId);
        return $offset;
    }

    public function getUserId(int $offset): ?int {
        $val = $this->redis->hGet($this->reverseHashKey, $offset);
        return $val === false ? null : (int)$val;
    }
}

5.2 核心BitMap操作封装(Redis SETBIT / BITOP)

class DailyLoginBitmap {
    private Redis $redis;
    private UserOffsetMapper $mapper;
    private string $keyPrefix;

    public function __construct(Redis $redis, UserOffsetMapper $mapper, string $keyPrefix = 'login:') {
        $this->redis = $redis;
        $this->mapper = $mapper;
        $this->keyPrefix = $keyPrefix;
    }

    /**
     * 记录某天用户登录
     */
    public function setLogin(int $userId, string $date): void {
        $offset = $this->mapper->getOffset($userId);
        $key = $this->keyPrefix . $date;
        $this->redis->setBit($key, $offset, 1);
    }

    /**
     * 判断某天用户是否登录
     */
    public function isLogin(int $userId, string $date): bool {
        $offset = $this->mapper->getOffset($userId);
        $key = $this->keyPrefix . $date;
        return $this->redis->getBit($key, $offset) === 1;
    }

    /**
     * 统计某天总登录人数
     */
    public function countLogin(string $date): int {
        $key = $this->keyPrefix . $date;
        return $this->redis->bitCount($key);
    }

    /**
     * 计算连续N天都登录的用户(交集)
     * @param array $dates 日期数组,如 ['2025-01-01', ...]
     * @return int 满足全部天数登录的用户数
     */
    public function countContinuousLogin(array $dates): int {
        if (empty($dates)) return 0;
        $keys = array_map(fn($d) => $this->keyPrefix . $d, $dates);
        // 将所有key做AND操作,结果存入临时key
        $tempKey = 'tmp:continuous:' . md5(implode('', $dates));
        $this->redis->bitOp('AND', $tempKey, ...$keys);
        $count = $this->redis->bitCount($tempKey);
        // 清理临时key(建议设置过期时间)
        $this->redis->expire($tempKey, 60);
        return $count;
    }

    /**
     * 获取连续N天登录的用户ID列表(仅供调试,数据量大时慎用)
     */
    public function getContinuousUserIds(array $dates): array {
        $keys = array_map(fn($d) => $this->keyPrefix . $d, $dates);
        $tempKey = 'tmp:continuous:' . md5(implode('', $dates));
        $this->redis->bitOp('AND', $tempKey, ...$keys);
        // 遍历所有位,找到为1的位
        $size = 10000000; // 最大用户数,需根据实际调整
        $userIds = [];
        // 用PHP的位运算优化:按字节读取
        $data = $this->redis->get($tempKey);
        if ($data === false) return [];
        $len = strlen($data);
        for ($byteIdx = 0; $byteIdx < $len; $byteIdx++) {
            $byte = ord($data[$byteIdx]);
            if ($byte === 0) continue;
            for ($bitIdx = 0; $bitIdx < 8; $bitIdx++) {
                if (($byte >> (7 - $bitIdx)) & 1) {
                    $offset = $byteIdx * 8 + $bitIdx;
                    $userId = $this->mapper->getUserId($offset);
                    if ($userId !== null) {
                        $userIds[] = $userId;
                    }
                }
            }
        }
        $this->redis->expire($tempKey, 60);
        return $userIds;
    }
}

5.3 使用示例:记录登录 + 统计连续7天用户

// 初始化
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->select(0);

$mapper = new UserOffsetMapper($redis);
$loginBitmap = new DailyLoginBitmap($redis, $mapper);

// 模拟用户1在2025-01-01登录,用户2在1-2登录,用户3在1-1到1-7都登录
$loginBitmap->setLogin(100001, '2025-01-01');
$loginBitmap->setLogin(100002, '2025-01-02');
for ($d = 1; $d <= 7; $d++) {
    $loginBitmap->setLogin(100003, sprintf('2025-01-%02d', $d));
}

// 统计2025-01-01登录人数
echo "20250101 登录人数: " . $loginBitmap->countLogin('2025-01-01') . "\n";

// 统计连续7天(1月1日-7日)都登录的用户数
$dates = [];
for ($d = 1; $d <= 7; $d++) {
    $dates[] = sprintf('2025-01-%02d', $d);
}
echo "连续7天登录用户数: " . $loginBitmap->countContinuousLogin($dates) . "\n";

// 获取这些用户ID(小量测试)
$userIds = $loginBitmap->getContinuousUserIds($dates);
echo "用户ID: " . implode(',', $userIds) . "\n";

5.4 离线批量数据灌入脚本(从MySQL读取历史数据)

#!/bin/bash
# 灌入2025年1月历史数据到Redis BitMap
# 从MySQL按天分批导出,使用redis-cli --pipe快速写入

date_start="2025-01-01"
date_end="2025-01-31"

while [[ "$date_start" < "$date_end" || "$date_start" == "$date_end" ]]; do
    echo "Processing $date_start"
    mysql -h dbhost -u user -p'pass' -D appdb -N -e "
        SELECT user_id FROM user_login_log 
        WHERE login_date = '$date_start'
        GROUP BY user_id
    " | while read user_id; do
        # 假设mapper已提前构建好映射表,否则需先映射
        # 实际生产需先批量映射所有用户ID
        offset=$(redis-cli HGET user:offset:map "$user_id")
        if [ -z "$offset" ]; then
            # 新用户,先创建映射(这里简化,实际应在统一流程处理)
            offset=$(redis-cli INCR user:offset:counter)
            # 减1得到从0开始的偏移量
            offset=$((offset - 1))
            redis-cli HSET user:offset:map "$user_id" $offset
            redis-cli HSET user:offset:reverse $offset "$user_id"
        fi
        # 设置对应位
        redis-cli SETBIT login:$date_start $offset 1
    done
    date_start=$(date -d "$date_start +1 day" +%Y-%m-%d)
done

5.5 压测脚本(模拟并发统计)

// 压测脚本:对比MySQL和Redis BitMap统计连续7天登录用户
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$mapper = new UserOffsetMapper($redis);
$loginBitmap = new DailyLoginBitmap($redis, $mapper);

// 模拟1000万活跃用户(实际场景会更大)
$userCount = 10000000;
$dates = [];
for ($d = 1; $d <= 7; $d++) {
    $dates[] = sprintf('2025-01-%02d', $d);
}

// 预热:确保映射存在(简化,假设已映射)
// 实际使用前需批量映射用户

$start = microtime(true);
for ($round = 0; $round < 10; $round++) {
    $count = $loginBitmap->countContinuousLogin($dates);
}
$end = microtime(true);
printf("Redis BitMap 10次交集统计耗时: %.2fs, 平均单次: %.2fms\n", 
       ($end - $start), ($end - $start) / 10 * 1000);
// 输出: Redis BitMap 10次交集统计耗时: 1.21s, 平均单次: 121.00ms
// 实际千万级用户下 BITOP 和 BITCOUNT 都很快

六、效果数据:实测结果

我们在一台4核8G、Redis 7.2.4、PHP 8.3服务器上测试,数据量:5000万用户(约6 MB/天),30天共180 MB。

指标MySQL (InnoDB, 8.0.35)Redis Set (v7.2.4)Redis BitMap
存储1天1亿用户约2.8 GB约1.2 GB约11.9 MB
存储30天>80 GB约36 GB约357 MB
统计连续7天登录用户数22.3 s (含索引优化)2.1 s (SINTER)87 ms (BITOP + BITCOUNT)
统计单日登录用户数3.8 s0.9 s (SCARD)12 ms (BITCOUNT)
内存占用(持续查询下)MySQL Buffer Pool 4GBRedis Set 约30GB(不稳定)Redis 约400MB

七、避坑指南

实际落地过程中,我们踩了下面几个深坑,写出来帮你省点时间:

坑1:用户ID映射偏移量时,增量ID可能不连续

如果用户ID是自增整数,但中间有删除或跳号(比如用了雪花算法),直接拿user_id当偏移量会导致bitmap前面大量空位。我们曾这样做,1.2亿用户映射到最大ID接近2^60,导致Bitmap长度按最大偏移量算,内存暴增几十倍。
解决:必须使用连续自增的映射表,定期rehash整合。

坑2:Redis单个String Key的容量限制

Redis String最大长度512MB,对应最大偏移量 2^29 (约5.36亿)。如果用户数超过5亿,一个key放不下。解决方案:按用户ID范围分片,比如用user_id % 10分成10个bitmap,查询时对10个key做BITOP再合并结果。

坑3:BITOP操作会阻塞Redis

BITOP是对多个key进行位运算的阻塞命令,时间复杂度O(N*M),N是key个数,M是最大长度。如果bitmap非常大(例如5亿位),一次AND耗时可达到秒级,可能影响线上其他操作。
解决:隔离Redis实例给统计服务;或者拆分小分片,并行AND各分片后再合并。

坑4:PHP中处理大整数溢出

计算连续登录用户ID列表时,使用PHP的int处理偏移量:当偏移量超过PHP_INT_MAX(64位为9.22E18),但一般不会超过。更常见的是位运算中 unsigned 右移问题,PHP的>>是算术右移(有符号),处理最高位为1时会出现负数。我们使用 ($byte >> 7) & 1 避免符号位影响。
建议:使用 GMP 扩展进行大规模位运算,代码可读性和正确性更高。

坑5:映射表与Bitmap写一致性

并发场景下,多个进程同时映射新用户可能导致映射表counter重复递增,offset重复使用?我们使用Redis INCR原子操作保证了不会重复。但是,如果先记录登录后映射,可能导致这个用户的登录数据丢失。必须确保:用户登录前,先获取/创建offset(映射操作),再setBit。如果setBit时offset尚不存在,那用户数据就丢了。

八、更进一步:离线大Bitmap处理方案

对于完全离线的场景(比如HDFS上的历史数据),我们使用PHP自实现的BitMap类,基于GMP扩展,一次加载一个月的数据到内存,快速计算各种指标。

use GMP;

class OfflineBitmap {
    private GMP $bits;
    private int $length;

    public function __construct(int $length) {
        $this->bits = gmp_init(0);
        $this->length = $length;
    }

    public function set(int $offset): void {
        $this->bits = gmp_setbit($this->bits, $offset);
    }

    public function get(int $offset): bool {
        return gmp_testbit($this->bits, $offset);
    }

    public function count(): int {
        return (int)gmp_popcount($this->bits);
    }

    public function and(OfflineBitmap $other): OfflineBitmap {
        $result = new OfflineBitmap($this->length);
        $result->bits = gmp_and($this->bits, $other->bits);
        return $result;
    }

    public function or(OfflineBitmap $other): OfflineBitmap {
        $result = new OfflineBitmap($this->length);
        $result->bits = gmp_or($this->bits, $other->bits);
        return $result;
    }

    public function toBytes(): string {
        return gmp_export($this->bits);
    }

    public static function fromBytes(string $data, int $length): self {
        $instance = new self($length);
        $instance->bits = gmp_import($data);
        return $instance;
    }
}

// 使用示例:离线加载一个月数据
$bitmaps = [];
for ($d = 1; $d <= 30; $d++) {
    $data = file_get_contents("/data/bitmap/202501{$d}.bin");
    $bitmaps[] = OfflineBitmap::fromBytes($data, 100000000);
}

// 统计连续7天(第1-7天)登录用户数
$result = clone $bitmaps[0];
for ($i = 1; $i < 7; $i++) {
    $result = $result->and($bitmaps[$i]);
}
echo "连续7天登录用户数:" . $result->count() . "\n";

GMP版本的popcount、位运算性能极高,处理1亿位的AND操作仅需20ms(单线程)。而且可以充分利用PHP 8.3的JIT进一步加速。

九、总结

BitMap算法的核心价值:用空间换时间,把“存在性判断”问题从O(N)行扫描降到O(1)位操作。在高频统计场景(日活、留存、连续登录、漏斗分析)中,BitMap几乎是性能最优方案。配合Redis或自研位图引擎,可以把统计延迟从分钟级降到毫秒级,内存压缩10倍以上。
不要被“位运算很难”吓住,用好封装和现成库,代码量很小。关键是避开ID映射、分片、阻塞这几个大坑。

我们这套方案已稳定运行5个月,支撑了每日百亿次登录统计,从未发生OOM或慢查询。如果你们也在做类似统计,不妨试一试。