去年双十一,我们线上一个签到统计接口突然超时。10亿用户,每天签到状态要实时统计连续签到天数、月签到次数。MySQL单表10亿行,count(*)跑了30秒还没返回。DBA直接报警,老板在群里@我。这就是我今天要讲的故事。
业务场景:用户每天签到,需要统计:
SELECT COUNT(*) FROM sign_log WHERE user_id = 123456 AND sign_date BETWEEN '2024-01-01' AND '2024-01-31';这个查询在10亿行数据上,即使有索引,也要扫描几百万行。实测耗时:30秒+。接口直接超时。我们评估了三种方案:
| 方案 | 存储空间 | 查询耗时 | 写入耗时 | 复杂度 |
|---|---|---|---|---|
| MySQL单表 | 1TB+ | 30s | 10ms | 低 |
| Redis Set | 每个用户一个Set,10亿用户约500GB | 1ms | 1ms | 中 |
| BitMap | 每个用户一个BitMap,10亿用户约1.2GB | 0.1ms | 0.1ms | 高 |
BitMap本质是一个二进制位数组。每一位代表一个状态:0表示未签到,1表示已签到。比如用户ID 123456,2024年第1天签到,第2天未签到,第3天签到,BitMap就是:101000...(共365位)。在Redis中,BitMap通过String类型实现,支持SETBIT、GETBIT、BITCOUNT、BITOP等命令。SETBIT key offset value:设置第offset位为value(0或1)。GETBIT key offset:获取第offset位的值。BITCOUNT key [start end]:统计指定范围内1的个数。BITOP operation destkey key [key ...]:对多个BitMap进行与、或、非、异或操作。
环境:PHP 8.3,Laravel 11,Redis 7.2,predis/predis 2.2。安装依赖:
composer require predis/predisRedis连接配置(config/database.php):'redis' => [
'client' => 'predis',
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => 0,
],
],签到服务类:namespace App\Services;
use Illuminate\Support\Facades\Redis;
use Carbon\Carbon;
class SignService
{
private $redis;
private $year;
public function __construct()
{
$this->redis = Redis::connection();
$this->year = Carbon::now()->year;
}
/**
* 用户签到
* @param int $userId
* @return bool
*/
public function sign(int $userId): bool
{
$key = $this->getSignKey($userId);
$offset = $this->getDayOfYear();
// 设置第offset位为1
$result = $this->redis->setbit($key, $offset, 1);
// 设置过期时间,防止内存泄漏
$this->redis->expire($key, 366 * 24 * 3600);
return $result == 0; // 返回true表示首次签到
}
/**
* 检查用户今天是否签到
* @param int $userId
* @return bool
*/
public function checkSign(int $userId): bool
{
$key = $this->getSignKey($userId);
$offset = $this->getDayOfYear();
return $this->redis->getbit($key, $offset) == 1;
}
/**
* 获取用户本月签到次数
* @param int $userId
* @param int|null $month 月份,1-12
* @return int
*/
public function getMonthSignCount(int $userId, ?int $month = null): int
{
$key = $this->getSignKey($userId);
$month = $month ?? Carbon::now()->month;
$startDay = Carbon::create($this->year, $month, 1)->dayOfYear;
$endDay = Carbon::create($this->year, $month, 1)->endOfMonth()->dayOfYear;
// BITCOUNT支持按字节范围统计,需要将dayOfYear转换为字节偏移
$startByte = intdiv($startDay, 8);
$endByte = intdiv($endDay, 8);
// 注意:BITCOUNT的start和end是字节索引,不是位索引
$count = $this->redis->bitcount($key, $startByte, $endByte);
// 处理边界:startDay和endDay可能不在字节边界上
// 需要减去startByte之前的多余位,加上endByte之后的多余位
// 简化处理:直接遍历位(性能可接受,因为最多31天)
$count = 0;
for ($day = $startDay; $day <= $endDay; $day++) {
if ($this->redis->getbit($key, $day)) {
$count++;
}
}
return $count;
}
/**
* 获取用户连续签到天数
* @param int $userId
* @return int
*/
public function getContinuousSignDays(int $userId): int
{
$key = $this->getSignKey($userId);
$today = $this->getDayOfYear();
$count = 0;
for ($day = $today; $day >= 1; $day--) {
if ($this->redis->getbit($key, $day)) {
$count++;
} else {
break;
}
}
return $count;
}
/**
* 获取全站今日签到人数
* @param array $userIds
* @return int
*/
public function getTodaySignCount(array $userIds): int
{
$count = 0;
$offset = $this->getDayOfYear();
foreach ($userIds as $userId) {
$key = $this->getSignKey($userId);
if ($this->redis->getbit($key, $offset)) {
$count++;
}
}
return $count;
}
/**
* 批量获取用户签到状态(用于排行榜等)
* @param array $userIds
* @return array
*/
public function batchCheckSign(array $userIds): array
{
$result = [];
$offset = $this->getDayOfYear();
$pipe = $this->redis->pipeline();
foreach ($userIds as $userId) {
$key = $this->getSignKey($userId);
$pipe->getbit($key, $offset);
}
$responses = $pipe->execute();
foreach ($userIds as $index => $userId) {
$result[$userId] = $responses[$index] == 1;
}
return $result;
}
private function getSignKey(int $userId): string
{
return "user:sign:{$this->year}:{$userId}";
}
private function getDayOfYear(): int
{
return Carbon::now()->dayOfYear;
}
}控制器调用示例:namespace App\Http\Controllers\Api;
use App\Services\SignService;
use Illuminate\Http\Request;
class SignController extends Controller
{
private $signService;
public function __construct(SignService $signService)
{
$this->signService = $signService;
}
public function sign(Request $request)
{
$userId = $request->user()->id;
$isFirst = $this->signService->sign($userId);
return response()->json([
'code' => 0,
'message' => $isFirst ? '签到成功' : '今日已签到',
]);
}
public function monthSignCount(Request $request)
{
$userId = $request->user()->id;
$month = $request->input('month');
$count = $this->signService->getMonthSignCount($userId, $month);
return response()->json([
'code' => 0,
'data' => ['count' => $count],
]);
}
public function continuousDays(Request $request)
{
$userId = $request->user()->id;
$days = $this->signService->getContinuousSignDays($userId);
return response()->json([
'code' => 0,
'data' => ['days' => $days],
]);
}
}压测脚本(使用ab工具):# 模拟1000个用户并发签到
ab -n 10000 -c 100 -H "Authorization: Bearer test_token" http://api.example.com/api/sign压测环境:
| 操作 | MySQL方案 | Redis Set方案 | BitMap方案 |
|---|---|---|---|
| 单用户签到 | 10ms(INSERT) | 1ms(SADD) | 0.1ms(SETBIT) |
| 单用户月签到统计 | 30s(COUNT) | 1ms(SCARD) | 0.1ms(BITCOUNT) |
| 单用户连续签到天数 | 无法直接统计 | 5ms(遍历) | 0.5ms(遍历) |
| 全站今日签到人数(100万用户) | 无法实时 | 100ms(批量SCARD) | 10ms(批量GETBIT) |
| 内存占用(10亿用户) | 1TB+ | 500GB | 1.2GB |
坑1:位偏移从0开始,不是1
Redis的SETBIT和GETBIT的offset从0开始。dayOfYear从1开始(1月1日是第1天)。如果直接用dayOfYear作为offset,第1天对应offset=1,第0位浪费了。正确做法:offset = dayOfYear - 1。或者直接用dayOfYear,但第0位永远为0。我一开始没注意,导致1月1日签到数据错位。
坑2:BITCOUNT的start和end是字节索引,不是位索引
BITCOUNT key start end,start和end是字节偏移。比如要统计第0-7位,start=0, end=0。要统计第0-15位,start=0, end=1。如果直接传位索引,结果完全错误。我写月统计时,传了dayOfYear作为start,结果统计出来是0。排查了半天才发现。
坑3:跨年统计问题
BitMap按年存储,key包含年份。跨年统计需要合并两个BitMap。比如统计用户2023年12月到2024年1月的连续签到天数。我的做法:分别查询两个年份的BitMap,然后拼接。但注意:12月31日是第365天,1月1日是第1天,连续签到需要判断12月31日是否签到。代码:
public function getContinuousSignDaysCrossYear(int $userId, int $year): int
{
$currentYear = Carbon::now()->year;
if ($year == $currentYear) {
return $this->getContinuousSignDays($userId);
}
// 获取去年最后一天的签到状态
$lastYearKey = "user:sign:{$year}:{$userId}";
$lastDayOfYear = Carbon::create($year, 12, 31)->dayOfYear;
$lastDaySign = $this->redis->getbit($lastYearKey, $lastDayOfYear - 1);
if (!$lastDaySign) {
return 0;
}
// 统计去年连续签到天数
$count = 0;
for ($day = $lastDayOfYear; $day >= 1; $day--) {
if ($this->redis->getbit($lastYearKey, $day - 1)) {
$count++;
} else {
break;
}
}
// 加上今年连续签到天数
$currentYearKey = "user:sign:{$currentYear}:{$userId}";
$today = Carbon::now()->dayOfYear;
for ($day = 1; $day <= $today; $day++) {
if ($this->redis->getbit($currentYearKey, $day - 1)) {
$count++;
} else {
break;
}
}
return $count;
}坑4:并发写入导致数据覆盖
SETBIT是原子操作,但多个客户端同时写入同一个BitMap的不同位,不会互相影响。但如果业务逻辑需要先读后写(比如判断是否已签到再写入),需要加锁。我用Redis分布式锁:
public function signWithLock(int $userId): bool
{
$lockKey = "lock:sign:{$userId}";
$lock = $this->redis->setnx($lockKey, 1);
if (!$lock) {
return false; // 获取锁失败,说明正在处理
}
$this->redis->expire($lockKey, 5); // 5秒自动释放
try {
$key = $this->getSignKey($userId);
$offset = $this->getDayOfYear();
$exists = $this->redis->getbit($key, $offset);
if ($exists) {
return false; // 已签到
}
$this->redis->setbit($key, $offset, 1);
$this->redis->expire($key, 366 * 24 * 3600);
return true;
} finally {
$this->redis->del($lockKey);
}
}坑5:内存泄漏
BitMap按年存储,但如果不设置过期时间,用户数据会永久存在。10亿用户,每年1.2GB,10年就是12GB。虽然不大,但没必要。我设置过期时间为366天,确保数据在次年自动删除。注意:如果用户跨年签到,需要保留去年数据用于连续签到统计。我的做法:在每年1月1日,将去年数据备份到另一个key(如user:sign:2023:123456),然后设置过期时间为30天,给跨年统计留缓冲。
BitMap适合海量用户的状态统计场景,核心优势是内存占用极低、位运算极快。但要注意位偏移、字节索引、跨年处理、并发锁、过期策略。如果你也在做签到、活跃度、在线状态等统计,直接上BitMap,别用MySQL硬扛。
专注技术分享与实战