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 命令了。
思路:
- 找到Bitmap中第一个出现的1(
BITPOS)。 - 从这个位置开始往后遍历,统计有多少个连续的1。
- 遇到0了,就暂停,记录这个连续长度。
- 然后跳过0,找到下一个1,重复步骤2。
- 循环直到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适合存热数据。
方案:
- 每天产生的签到数据,写入Redis Bitmap。
- 每天凌晨,用PHP脚本把Redis里的数据导出(序列化成二进制文件或存入MySQL的大表)。
- 把Redis里的旧数据删掉(
DEL)。 - 这样Redis内存永远是今天的,或者最近一个月的,飞快!
3. PHP脚本的并发处理
签到是个高频操作。
如果是高并发,千万不要每个用户请求都去跑一个PHP脚本。
要用队列!用Worker!
伪代码思路:
- 前端发请求 -> 消息队列。
- PHP Worker从队列取数据。
- Worker执行
$redis->setBit(...)。 - 执行完毕,标记队列任务完成。
第五章:实战场景模拟——带点“味道”的代码
为了展示这玩意儿到底有多快,咱们来个完整的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库)。如果你的服务器环境没有安装,或者版本太老,BITCOUNT、BITPOS 这些高级指令可能不支持。升级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长度不同。通常做法是:
- 每天结束后,计算每个用户的签到数。
- 存入Redis的
ZSET(Sorted Set) 中。 ZADD sign:ranking:202310 $days user_id。- 每天晚上跑个脚本,把ZSET里的数据持久化到MySQL表
sign_ranking中。
位图用于存储原始状态,Redis的ZSET用于做聚合统计。两者结合,天下无敌。
结语:拒绝“阿里味”,拥抱“极客魂”
回顾一下,我们今天干了什么?
我们抛弃了繁琐的SQL表结构,抛弃了每次签到都要查库的开销,利用Redis的Bit位特性,把亿级数据压缩到了几兆内存里。
代码写得漂亮,不是为了炫耀,而是为了在老板看到服务器CPU跑满之前,淡定地喝口咖啡。
当你的同行还在数据库里跑 EXPLAIN,还在为分库分表愁秃了头的时候,你已经用一行 $redis->setBit(...) 解决了问题。
这就是PHP的魅力,也是位图的魅力。别再犹豫了,赶紧去把你的签到模块重构一下吧。祝你的数据库永远年轻,永远不挂!
(完)