一、真实场景:签到系统OOM了
2024年3月,我负责的某社区App签到功能上线。用户量200万,日活50万。签到数据用MySQL存储:
CREATE TABLE user_sign (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
sign_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_date (user_id, sign_date)
) ENGINE=InnoDB;
上线第三天,MySQL CPU飙到95%,慢查询日志里全是这种:
SELECT COUNT(*) FROM user_sign WHERE sign_date = '2024-03-15'; -- 耗时2.3s
SELECT COUNT(DISTINCT user_id) FROM user_sign WHERE sign_date BETWEEN '2024-03-01' AND '2024-03-15'; -- 耗时8.7s
更致命的是,运营要查「连续签到7天用户数」,一个SQL跑30秒,直接拖垮主库。DBA半夜打电话让我改方案。
二、问题分析:统计场景的三大痛点
- 存储膨胀:200万用户×365天=7.3亿行,单表1.2TB
- 查询慢:COUNT(DISTINCT)走全表扫描,IO打满
- 计算复杂:连续签到需要自关联或窗口函数,MySQL8.0以下不支持
我需要一个方案:内存占用低、查询O(1)、支持位运算。
三、方案对比:MySQL vs Redis Set vs BitMap
| 方案 | 存储结构 | 内存占用(100万用户) | 签到耗时 | 统计日活耗时 | 连续签到 |
|---|---|---|---|---|---|
| MySQL | 行记录 | ~200MB(索引+数据) | 15ms(写) | 2.3s | 30s+ |
| Redis Set | SADD user:sign:20240315 1001 | ~80MB(每个用户ID存为字符串) | 1ms | SCARD 0.5ms | SINTERSTORE 10ms |
| Redis BitMap | SETBIT user:sign:20240315 1001 1 | ~125KB(100万位=125KB) | 0.1ms | BITCOUNT 0.3ms | BITOP AND 2ms |
结论:BitMap内存是MySQL的1/1600,是Set的1/640。查询速度提升2-3个数量级。
四、BitMap原理:为什么这么省?
BitMap本质是一个bit数组。每个bit代表一个用户:0未签到,1已签到。100万用户只需要100万bit = 125KB。
Redis的BitMap基于String实现,最大512MB,可存储42.9亿个bit。操作命令:
SETBIT key offset value:设置第offset位为0或1,时间复杂度O(1)GETBIT key offset:获取第offset位的值,O(1)BITCOUNT key [start end]:统计bit位为1的数量,O(N)BITOP operation destkey key [key ...]:位运算(AND/OR/XOR/NOT),O(N)
关键点:offset就是用户ID。所以用户ID必须是连续整数,或者做一次映射。
五、完整代码实现(PHP8.3 + Redis7.2 + Laravel11)
5.1 用户ID映射
真实场景用户ID不连续(比如有删除、合并)。需要维护一个自增映射表:
CREATE TABLE user_id_mapping (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
bit_index INT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
// app/Services/BitMapService.php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class BitMapService
{
private string $redisPrefix = 'sign:';
private int $batchSize = 1000;
/**
* 获取用户bit索引
*/
public function getBitIndex(int $userId): int
{
$mapping = DB::table('user_id_mapping')
->where('user_id', $userId)
->first();
if ($mapping) {
return $mapping->bit_index;
}
// 分布式锁防止并发插入重复
$lockKey = "lock:bitmap:mapping:{$userId}";
$lock = Redis::set($lockKey, 1, 'EX', 5, 'NX');
if (!$lock) {
usleep(100000); // 100ms
return $this->getBitIndex($userId);
}
try {
$maxIndex = DB::table('user_id_mapping')->max('bit_index') ?? 0;
$newIndex = $maxIndex + 1;
DB::table('user_id_mapping')->insert([
'user_id' => $userId,
'bit_index' => $newIndex,
]);
Redis::del($lockKey);
return $newIndex;
} catch (\Exception $e) {
Redis::del($lockKey);
throw $e;
}
}
/**
* 用户签到
*/
public function sign(int $userId, string $date = null): bool
{
$date = $date ?? date('Ymd');
$bitIndex = $this->getBitIndex($userId);
$key = $this->redisPrefix . $date;
// SETBIT返回旧值,0表示未签到,1表示已签到
$oldValue = Redis::command('SETBIT', [$key, $bitIndex, 1]);
return $oldValue == 0;
}
/**
* 检查用户是否签到
*/
public function checkSign(int $userId, string $date = null): bool
{
$date = $date ?? date('Ymd');
$bitIndex = $this->getBitIndex($userId);
$key = $this->redisPrefix . $date;
return Redis::command('GETBIT', [$key, $bitIndex]) == 1;
}
/**
* 统计某天签到人数
*/
public function countSignByDate(string $date): int
{
$key = $this->redisPrefix . $date;
return Redis::command('BITCOUNT', [$key]);
}
/**
* 统计某段时间内签到人数(OR运算:只要有一天签到就算)
*/
public function countUniqueUsers(string $startDate, string $endDate): int
{
$keys = [];
$current = $startDate;
while ($current <= $endDate) {
$keys[] = $this->redisPrefix . $current;
$current = date('Ymd', strtotime($current . ' +1 day'));
}
if (empty($keys)) {
return 0;
}
$destKey = 'tmp:unique:' . uniqid();
Redis::command('BITOP', ['OR', $destKey, ...$keys]);
$count = Redis::command('BITCOUNT', [$destKey]);
Redis::command('DEL', [$destKey]);
return $count;
}
/**
* 统计连续签到N天的用户数
*/
public function countContinuousSignUsers(int $days, string $endDate = null): int
{
$endDate = $endDate ?? date('Ymd');
$keys = [];
for ($i = 0; $i < $days; $i++) {
$date = date('Ymd', strtotime($endDate . " -{$i} days"));
$keys[] = $this->redisPrefix . $date;
}
if (count($keys) < $days) {
return 0;
}
$destKey = 'tmp:continuous:' . uniqid();
Redis::command('BITOP', ['AND', $destKey, ...$keys]);
$count = Redis::command('BITCOUNT', [$destKey]);
Redis::command('DEL', [$destKey]);
return $count;
}
/**
* 批量签到(用于初始化历史数据)
*/
public function batchSign(array $userIds, string $date): int
{
$bitIndexes = [];
foreach ($userIds as $userId) {
$bitIndexes[] = $this->getBitIndex($userId);
}
$key = $this->redisPrefix . $date;
$pipe = Redis::pipeline();
foreach ($bitIndexes as $index) {
$pipe->setbit($key, $index, 1);
}
$pipe->exec();
return count($bitIndexes);
}
}
5.2 控制器调用
// app/Http/Controllers/SignController.php
namespace App\Http\Controllers;
use App\Services\BitMapService;
use Illuminate\Http\Request;
class SignController extends Controller
{
private BitMapService $bitMapService;
public function __construct(BitMapService $bitMapService)
{
$this->bitMapService = $bitMapService;
}
public function sign(Request $request)
{
$userId = $request->user()->id;
$result = $this->bitMapService->sign($userId);
return response()->json([
'success' => $result,
'message' => $result ? '签到成功' : '今日已签到',
]);
}
public function stats(Request $request)
{
$date = $request->input('date', date('Ymd'));
$count = $this->bitMapService->countSignByDate($date);
return response()->json([
'date' => $date,
'sign_count' => $count,
]);
}
public function continuousStats(Request $request)
{
$days = (int) $request->input('days', 7);
$count = $this->bitMapService->countContinuousSignUsers($days);
return response()->json([
'days' => $days,
'user_count' => $count,
]);
}
}
5.3 路由配置
// routes/api.php
use App\Http\Controllers\SignController;
Route::middleware('auth:sanctum')->group(function () {
Route::post('/sign', [SignController::class, 'sign']);
Route::get('/sign/stats', [SignController::class, 'stats']);
Route::get('/sign/continuous-stats', [SignController::class, 'continuousStats']);
});
5.4 压测脚本(使用wrk)
#!/bin/bash
# 压测签到接口
wrk -t4 -c100 -d30s --latency \
-H "Authorization: Bearer test_token" \
-s sign.lua \
http://localhost:8000/api/sign
# sign.lua内容:
# wrk.method = "POST"
# wrk.body = "{}"
# wrk.headers["Content-Type"] = "application/json"
六、效果数据:对比压测结果
测试环境:PHP8.3 + Laravel11 + Redis7.2.4,服务器4核8G,MySQL8.0.35。模拟100万用户,连续签到30天。
| 指标 | MySQL方案 | Redis Set方案 | Redis BitMap方案 |
|---|---|---|---|
| 存储空间 | 1.2TB(7.3亿行) | 2.4GB(3000万key) | 3.75MB(30个key) |
| 单用户签到耗时 | 15ms(INSERT) | 1ms(SADD) | 0.1ms(SETBIT) |
| 日活统计耗时 | 2.3s(COUNT) | 0.5ms(SCARD) | 0.3ms(BITCOUNT) |
| 连续7天统计耗时 | 30s+(窗口函数) | 10ms(SINTERSTORE) | 2ms(BITOP AND) |
| 30天去重用户统计 | 8.7s(COUNT DISTINCT) | 15ms(SUNIONSTORE) | 3ms(BITOP OR) |
| QPS(签到接口) | ~2000 | ~15000 | ~50000 |
| P99延迟 | 45ms | 5ms | 1ms |
关键数据:
- BitMap内存占用是MySQL的1/327680,是Set的1/655
- 连续7天统计从30秒降到2ms,提升15000倍
- 签到接口QPS从2000提升到50000,提升25倍
七、避坑指南(我踩过的坑)
坑1:位偏移从0开始
SETBIT的offset从0开始。如果用户ID从1开始,直接使用会导致第一位(索引0)浪费。正确做法:bit_index = user_id - 1,或者映射表从0开始。
// 错误
Redis::setbit('sign:20240315', $userId, 1); // 如果userId=1,占用第2位
// 正确
Redis::setbit('sign:20240315', $userId - 1, 1); // userId=1,占用第0位
坑2:大Key问题
如果用户量超过1亿,单个BitMap key会达到12.5MB。虽然Redis能处理,但BITOP操作会阻塞。解决方案:
- 分片:按用户ID范围拆分,比如 user:sign:20240315:0-1000000
- 使用Redis Cluster自动分片
- BITOP操作放到Slave节点执行
// 分片示例
public function getShardKey(string $date, int $bitIndex): string
{
$shardSize = 1000000; // 每片100万用户
$shardId = intdiv($bitIndex, $shardSize);
return "sign:{$date}:{$shardId}";
}
坑3:跨天统计的边界问题
BITOP OR/AND要求所有key的长度一致。如果某天key不存在,Redis会当作空字符串处理,导致结果错误。必须初始化空key:
// 初始化空BitMap
public function ensureKeyExists(string $date): void
{
$key = $this->redisPrefix . $date;
if (!Redis::exists($key)) {
Redis::setbit($key, 0, 0); // 设置第0位为0,创建key
}
}
坑4:用户ID映射表性能瓶颈
每次签到都查映射表,高并发下MySQL扛不住。解决方案:
- 用Redis Hash缓存映射关系:
HSET user_bit_map user_id bit_index - 批量预生成映射:用户注册时直接分配bit_index
- 使用Lua脚本原子操作
-- Lua脚本:签到+检查是否重复
local key = KEYS[1]
local bitIndex = tonumber(ARGV[1])
local oldValue = redis.call('SETBIT', key, bitIndex, 1)
if oldValue == 0 then
return 1 -- 签到成功
else
return 0 -- 已签到
end
坑5:BITCOUNT的start/end参数是字节偏移
很多人以为BITCOUNT key 0 10是统计前10个bit,其实是前10个字节(80个bit)。如果要统计前N个bit,需要自己计算字节范围。
// 统计前1000个用户的签到情况
$byteStart = 0;
$byteEnd = intdiv(1000, 8); // 125字节
$count = Redis::command('BITCOUNT', [$key, $byteStart, $byteEnd]);
八、扩展应用:UV统计
除了签到,BitMap还适合做UV去重统计。比如统计某篇文章的独立访客:
class UVService
{
private string $prefix = 'uv:';
public function recordView(int $articleId, int $userId): void
{
$key = $this->prefix . $articleId;
Redis::setbit($key, $userId, 1);
}
public function getUV(int $articleId): int
{
$key = $this->prefix . $articleId;
return Redis::bitcount($key);
}
// 统计多篇文章的总UV(去重)
public function getTotalUV(array $articleIds): int
{
$keys = array_map(fn($id) => $this->prefix . $id, $articleIds);
$destKey = 'tmp:uv:total:' . implode('_', $articleIds);
Redis::bitop('OR', $destKey, ...$keys);
$count = Redis::bitcount($destKey);
Redis::del($destKey);
return $count;
}
}
九、总结
BitMap不是银弹,但适合以下场景:
- 海量数据去重统计:UV、签到、打卡
- 位运算聚合:连续N天、交集/并集
- 内存敏感:移动端、嵌入式设备
不适合:数据稀疏(比如10亿用户只有1000人签到,浪费空间)、需要存储额外信息(签到时间、积分等)。
最后一句:能用bit解决的问题,别用byte。