BitMap实战:统计场景从OOM到10ms
发布日期: 2026/07/18 阅读总量: 0

一、真实场景:签到系统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.3s30s+
Redis SetSADD user:sign:20240315 1001~80MB(每个用户ID存为字符串)1msSCARD 0.5msSINTERSTORE 10ms
Redis BitMapSETBIT user:sign:20240315 1001 1~125KB(100万位=125KB)0.1msBITCOUNT 0.3msBITOP 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延迟45ms5ms1ms

关键数据

  • 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