PHP如何利用位图Bitmap实现亿级用户签到统计系统

PHP亿级用户签到系统:当你的数据库想“自杀”时,位图就是你的救命稻草

各位程序员老铁,大家好。

今天咱们不聊那些虚头巴脑的架构图,也不整那些让你听了就想辞职的P7晋升流程。咱们来聊点硬核的——亿级用户签到系统

假设你现在的公司,搞了个类似支付宝集五福或者微信步数这种东西。老板拍着你的肩膀说:“小王啊,我们要做日活1亿的签到功能,谁签到了给个金币,谁连续签到7天给个钻。数据要存下来,统计要实时,快,给我上!”

如果你脑子里第一个蹦出来的SQL是:
INSERT INTO sign_logs (user_id, date, status) VALUES (?, ?, 1);
那你趁早拿着简历去隔壁投简历吧,别耽误大家时间。兄弟,在亿级数据面前,这种建表方式,你的数据库连哭都找不到调,直接内存溢出给你看。

今天,我就带大家用PHP配合Redis的Bitmap,优雅地解决这个问题。我们要用最简单的代码,怼倒最复杂的业务。


第一章:为什么要用位图?别把大象装冰箱分三步,要分七步!

首先,咱们得统一思想。什么叫亿级?一亿个用户,每人一天签到,就是1亿条数据。

1亿条数据,如果用MySQL的VARCHAR存“已签到”,那就是1亿个字符。一个字符1字节,那就是100MB。
如果用TINYINT存状态,那就是1亿字节,1GB。
听着不多?但这只是一天的数据。
一年就是365GB。
等你积累了三年,后台的数据库文件可能比你的命都长。每次查询“今天谁签到了”,数据库得全表扫描,CPU直接干冒烟。

这时候,位图(Bitmap) 出马了。

位图是个什么鬼?它就是计算机里最小的数据单位——Bit(比特位)
0表示没签到,1表示签到。

一亿个Bit是多少?1亿 / 8 = 1250万Byte ≈ 12MB
你只需要12MB的内存,就能存下一亿人一整年的签到数据!

这就是位图的魔法:极致的压缩,极致的查询效率。


第二章:基础操作——PHP与Redis的“甜蜜约会”

在PHP里玩位图,得靠Redis。Redis天生就是玩二进制数据的祖宗。它提供了几个核心指令,咱们一个一个拆解。

1. 存储签到状态:SETBIT

假设用户ID是10086,今天是2023年10月27日。
我们要把这个用户的今天标记为1。

在Bitmap里,位置怎么算?按天偏移量。
我们需要一个统一的键名,比如 user:10086:sign

偏移量计算公式:
offset = (年 - 2020) * 365 + (月 - 1) * 30 + 日 - 1

<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$userId = 10086;
$year = 2023;
$month = 10;
$day = 27;

// 计算偏移量 (假设基准年2020)
// 2020年1月1日是第0位
$offset = ($year - 2020) * 365 + ($month - 1) * 30 + ($day - 1);

$redisKey = "sign:bitmap:{$userId}";

// 标记为签到
$redis->setBit($redisKey, $offset, 1); 

echo "用户{$userId} 今天签到成功n";
?>

看到没?一行代码,搞定。哪怕是一亿个用户,每秒存1万次,Redis的内存操作也就是微秒级的,你的PHP脚本几乎感觉不到延迟。

2. 查询签到状态:GETBIT

如果用户点“签到”按钮时,我们需要先查查他今天是不是已经签过了。千万别让他重复领奖。

<?php
// 还是那个用户
$signStatus = $redis->getBit($redisKey, $offset);

if ($signStatus == 1) {
    echo "哎呀,兄弟,你已经签过到了,别贪心!";
} else {
    echo "恭喜你,签到成功!";
}
?>

3. 统计总签到天数:BITCOUNT

这是业务上最常用的。老板要问:“用户10086,你这一年一共签到了多少天?”

PHP的Redis扩展直接给了BITCOUNT命令,底层经过高度优化(SIMD指令集),速度极快。

<?php
$days = $redis->bitCount($redisKey);
echo "用户{$userId} 本账号累计签到 {$days} 天!";
?>

第三章:进阶挑战——连续签到(最长连胜)

有了基础功能,还不够。签到系统里最诱人的就是“连续签到7天送大奖”。这就需要我们用到位图的组合运算了。

假设我们要统计用户10086在最近7天(比如10月21日到27日)的签到情况。
我们需要先取出这7天的数据,看看是不是全是1。

第一步:取出区间

Redis有个命令叫 BITOP,可以做位运算。但为了统计7天,我们通常的做法是:
把最近7天的数据先取出来,做个OR运算(或者AND,看需求),合成一个大Bitmap,然后算出有多少个1。

或者,更简单粗暴的PHP逻辑:

<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$userId = 10086;
$redisKey = "sign:bitmap:{$userId}";

// 假设我们要查最近7天
$daysToCheck = 7;
$totalOnes = 0;

for ($i = 0; $i < $daysToCheck; $i++) {
    // 计算当前这天的offset
    $currentOffset = ($year - 2020) * 365 + ($month - 1) * 30 + ($day - 1) - $i;

    if ($redis->getBit($redisKey, $currentOffset) == 1) {
        $totalOnes++;
    }
}

if ($totalOnes == $daysToCheck) {
    echo "神级操作!连续7天打卡!";
} else {
    echo "还需要努力,只连续了 {$totalOnes} 天。";
}
?>

第二步:寻找最长连续天数(硬核算法)

有时候我们不仅要统计当前连续了几天,还要知道他历史记录里最长连续是多少。这就需要用到位图的 BITPOS 命令了。

思路:

  1. 找到Bitmap中第一个出现的1(BITPOS)。
  2. 从这个位置开始往后遍历,统计有多少个连续的1。
  3. 遇到0了,就暂停,记录这个连续长度。
  4. 然后跳过0,找到下一个1,重复步骤2。
  5. 循环直到Bitmap结束。
<?php
function getLongestStreak($redisKey, $redis) {
    // 1. 找到Bitmap中第一个1的位置
    // start默认为0,end默认为-1(整个Bitmap)
    $firstOnePos = $redis->bitPos($redisKey, 1); 

    if ($firstOnePos === false) {
        return 0; // 没有签到过
    }

    $maxStreak = 0;
    $currentStreak = 0;

    // 2. 从第一个1开始往后遍历
    // 注意:Redis BITPOS返回的是从0开始的位置,我们需要转换成日期偏移量
    // 这里简化逻辑,假设我们从开头遍历

    // 遍历整个Bitmap
    // 这种全量遍历在亿级数据下可能慢,实际生产中建议只遍历最近一年的数据
    $len = $redis->bitSize($redisKey); // 获取Bitmap长度

    for ($i = 0; $i < $len; $i++) {
        if ($redis->getBit($redisKey, $i) == 1) {
            $currentStreak++;
            $maxStreak = max($maxStreak, $currentStreak);
        } else {
            $currentStreak = 0;
        }
    }

    return $maxStreak;
}

// 使用
$streak = getLongestStreak($redisKey, $redis);
echo "用户历史最长连胜记录:{$streak}天";
?>

温馨提示:上面的全量遍历算法在亿级数据下是会有性能瓶颈的。实际工程中,我们会把数据按月切分,或者使用Redis 6.2+的 BITCOUNT 指定范围优化,甚至使用Lua脚本把遍历逻辑下推到Redis服务端执行。


第四章:亿级数据的架构思考——别让Redis“积食”

虽然位图很香,但如果你真的有1亿个用户,千万把Redis当硬盘使。

1. 键的设计是关键

1亿个键,Redis服务器内存会炸。
我们绝对不能给每个用户建一个Bitmap。
我们通常采用“按日/按月切分”的策略。

策略A:按月分片
键名:sign:202310:user:{userId}
这样一个月只占用12MB,一年也就144MB。
你可以为每个用户开一个独立的键。

策略B:全局倒排(统计用)
如果你不仅要查“某个人”,还要查“某一天谁签到了”,那我们需要一个超级大的Bitmap,比如 sign:202310
第0位是用户1,第1位是用户2……
这种情况下,你需要1亿个Bit = 12MB。这个可以接受。
但要算这个Bitmap里有多少个1(即有多少人签到了),直接 BITCOUNT sign:202310 就行。

2. 冷热数据分离(归档)

用户去年的签到数据,谁还看?没人看。
数据放Redis里是占地方的。Redis适合存热数据。
方案:

  1. 每天产生的签到数据,写入Redis Bitmap。
  2. 每天凌晨,用PHP脚本把Redis里的数据导出(序列化成二进制文件或存入MySQL的大表)。
  3. 把Redis里的旧数据删掉(DEL)。
  4. 这样Redis内存永远是今天的,或者最近一个月的,飞快!

3. PHP脚本的并发处理

签到是个高频操作。
如果是高并发,千万不要每个用户请求都去跑一个PHP脚本。
要用队列!用Worker!

伪代码思路:

  1. 前端发请求 -> 消息队列。
  2. PHP Worker从队列取数据。
  3. Worker执行 $redis->setBit(...)
  4. 执行完毕,标记队列任务完成。

第五章:实战场景模拟——带点“味道”的代码

为了展示这玩意儿到底有多快,咱们来个完整的Demo。假设我们要做一个“月度签到”服务。

场景:一个月内用户签到情况统计

<?php
/**
 * 签到服务类
 * 采用PDO连接MySQL,Redis连接内存
 */
class SignService {
    private $redis;
    private $db;

    public function __construct() {
        $this->redis = new Redis();
        $this->redis->connect('127.0.0.1', 6379);

        $this->db = new PDO('mysql:host=localhost;dbname=sign_db', 'root', 'password');
    }

    /**
     * 用户签到入口
     */
    public function doSign($userId, $year, $month, $day) {
        // 1. 计算偏移量 (基准年2020)
        $offset = ($year - 2020) * 365 + ($month - 1) * 30 + ($day - 1);

        $redisKey = "sign:{$year}{$month}:{$userId}";

        // 2. 检查是否已签 (原子性操作建议用Lua脚本,这里为了演示简单)
        $isSigned = $this->redis->getBit($redisKey, $offset);
        if ($isSigned == 1) {
            return ['code' => 400, 'msg' => '今日已签到,勿重复操作'];
        }

        // 3. 标记签到
        $this->redis->setBit($redisKey, $offset, 1);

        // 4. 记录日志到MySQL(可选,用于留存审计,但不要存冗余状态,存流水即可)
        $sql = "INSERT INTO sign_log (user_id, y, m, d) VALUES (?, ?, ?, ?)";
        $this->db->prepare($sql)->execute([$userId, $year, $month, $day]);

        // 5. 检查连续签到
        $streak = $this->calculateStreak($userId, $year, $month, $day);

        // 6. 奖励逻辑 (根据连胜发金币)
        if ($streak >= 7) {
            $this->grantReward($userId);
        }

        return ['code' => 200, 'msg' => '签到成功!当前连续签到 ' . $streak . ' 天'];
    }

    /**
     * 计算连续签到天数 (优化版:只计算当月)
     */
    private function calculateStreak($userId, $year, $month, $day) {
        $redisKey = "sign:{$year}{$month}:{$userId}";

        // 获取当月总天数
        $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);

        $streak = 0;
        $maxStreak = 0;

        // 倒序遍历
        for ($i = $day - 1; $i >= 0; $i--) {
            if ($this->redis->getBit($redisKey, $i) == 1) {
                $streak++;
                $maxStreak = max($maxStreak, $streak);
            } else {
                // 只要断签,就算断
                break;
            }
        }

        return $maxStreak;
    }

    private function grantReward($userId) {
        echo "给用户 {$userId} 发送金币通知... n";
        // 这里可以写发邮件、推送的逻辑
    }
}

// === 调用演示 ===
$service = new SignService();

// 模拟10086用户签到
$result = $service->doSign(10086, 2023, 10, 27);
print_r($result);

// 模拟查询历史签到总天数
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$totalDays = $redis->bitCount("sign:202310:10086");
echo "用户10086在2023年10月签到了 {$totalDays} 天n";
?>

第六章:那些坑——别让你的代码在深夜报错

再好的技术,用不好也是灾难。

1. 时区大坑

PHP的时间函数和MySQL的时间函数时区设置经常不一致。
Redis存的数据是基于时间戳或者天偏移量的。如果你的PHP脚本跑了两个时区(比如服务器在纽约,你在北京调用),或者你写错了一天,计算出来的 offset 就会乱套。
建议: 全局强制设置一个时区,比如 date_default_timezone_set('Asia/Shanghai');。所有的日期计算逻辑,统一在PHP端完成,直接传参数给Redis。

2. Bitmap的大小限制

Redis的Bitmap其实就是一个大的String。虽然它能存536,870,912个位(64MB),但如果你存一亿个用户的当天签到,那就不行。
这也就是为什么我强调按月分片的原因。每个Key的大小必须控制在合理范围内。

3. PHP扩展依赖

PHP操作Redis,必须安装 php-redis 扩展(非Predis库)。如果你的服务器环境没有安装,或者版本太老,BITCOUNTBITPOS 这些高级指令可能不支持。升级PHP环境是必须的。

4. 数据丢失风险

Redis是内存数据库,断电数据会丢。除非你做了持久化(RDB/AOF),否则用户签到记录随时可能消失。
补救措施: 你必须保证Redis的数据能够实时导出到MySQL。MySQL存的是“流水”,Redis存的是“索引”。这也是为什么要存 sign_log 的原因。


第七章:终极奥义——BITOP 与 排名

除了统计单个用户,我们还得统计“榜单”。
比如“本月签到排行榜”,谁签到的天数最多?

这时候,我们需要用到一个大杀器:BITOP

假设我们有100万个用户的Bitmap键:
sign:202310:10001
sign:202310:10002

sign:202310:1000000

我们要统计“谁签到了3天以上”?
我们能不能把所有用户当成一个巨大的Bitmap的第0位到第100万位?
可以!
但前提是用户ID必须是连续的数字,从0开始。

如果用户ID是10086,第0位就是10086。
我们需要一个映射表。

更实际的做法是:
统计“活跃用户数”:BITCOUNT sign:202310(直接告诉你有几百万人签到了)。

统计“哪些用户连续签到最多”?
这比较难,因为不同用户Bitmap长度不同。通常做法是:

  1. 每天结束后,计算每个用户的签到数。
  2. 存入Redis的 ZSET (Sorted Set) 中。
  3. ZADD sign:ranking:202310 $days user_id
  4. 每天晚上跑个脚本,把ZSET里的数据持久化到MySQL表 sign_ranking 中。

位图用于存储原始状态,Redis的ZSET用于做聚合统计。两者结合,天下无敌。


结语:拒绝“阿里味”,拥抱“极客魂”

回顾一下,我们今天干了什么?
我们抛弃了繁琐的SQL表结构,抛弃了每次签到都要查库的开销,利用Redis的Bit位特性,把亿级数据压缩到了几兆内存里。

代码写得漂亮,不是为了炫耀,而是为了在老板看到服务器CPU跑满之前,淡定地喝口咖啡。

当你的同行还在数据库里跑 EXPLAIN,还在为分库分表愁秃了头的时候,你已经用一行 $redis->setBit(...) 解决了问题。

这就是PHP的魅力,也是位图的魅力。别再犹豫了,赶紧去把你的签到模块重构一下吧。祝你的数据库永远年轻,永远不挂!

(完)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注