Redis BitMap实现用户签到功能

一、 BitMap 数据结构的核心原理与存储优势

在用户激励与活跃度运营体系中,签到功能承载着高频、轻量的状态记录诉求。传统关系型架构通常依赖一张独立的 tb_user_sign 表,以 (user_id, sign_date) 为主键记录每日签到状态。当平台用户量突破百万级、日均签到请求达千万级时,该方案将暴露出致命的存储与计算缺陷。

1.1 传统存储方案

假如有 1000 万用户,平均每人每年签到次数为 10 次,则这张表一年的数据量为 1 亿条。每签到一次需要使用(8+ 8+ 1+ 1+ 3+ 1)共 22 字节的内存,一个月则最多需要 600 多字节。这种存储方式在海量用户场景下将导致数据库表空间急剧膨胀,索引维护成本呈指数级上升。

1.2 BitMap 的位级压缩思想

BitMap 的核心在于将时间维度映射至物理内存的连续比特位。我们按月来统计用户签到信息,签到记录为 1,未签到则记录为 0。每月最多 31 天,仅需 31 个比特位即可完整刻画用户当月的签到轨迹,内存占用从 KB 级压缩至 Byte 级。

位图映射关系

将每一个 bit 位对应当月的每一天,形成了映射关系。用 0 和 1 标示业务状态,这种思路就称为位图(BitMap)。
#mermaid-svg-1Ns0yRgI9sXY5OUj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1Ns0yRgI9sXY5OUj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1Ns0yRgI9sXY5OUj .error-icon{fill:#552222;}#mermaid-svg-1Ns0yRgI9sXY5OUj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1Ns0yRgI9sXY5OUj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1Ns0yRgI9sXY5OUj .marker.cross{stroke:#333333;}#mermaid-svg-1Ns0yRgI9sXY5OUj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1Ns0yRgI9sXY5OUj p{margin:0;}#mermaid-svg-1Ns0yRgI9sXY5OUj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1Ns0yRgI9sXY5OUj .cluster-label text{fill:#333;}#mermaid-svg-1Ns0yRgI9sXY5OUj .cluster-label span{color:#333;}#mermaid-svg-1Ns0yRgI9sXY5OUj .cluster-label span p{background-color:transparent;}#mermaid-svg-1Ns0yRgI9sXY5OUj .label text,#mermaid-svg-1Ns0yRgI9sXY5OUj span{fill:#333;color:#333;}#mermaid-svg-1Ns0yRgI9sXY5OUj .node rect,#mermaid-svg-1Ns0yRgI9sXY5OUj .node circle,#mermaid-svg-1Ns0yRgI9sXY5OUj .node ellipse,#mermaid-svg-1Ns0yRgI9sXY5OUj .node polygon,#mermaid-svg-1Ns0yRgI9sXY5OUj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1Ns0yRgI9sXY5OUj .rough-node .label text,#mermaid-svg-1Ns0yRgI9sXY5OUj .node .label text,#mermaid-svg-1Ns0yRgI9sXY5OUj .image-shape .label,#mermaid-svg-1Ns0yRgI9sXY5OUj .icon-shape .label{text-anchor:middle;}#mermaid-svg-1Ns0yRgI9sXY5OUj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1Ns0yRgI9sXY5OUj .rough-node .label,#mermaid-svg-1Ns0yRgI9sXY5OUj .node .label,#mermaid-svg-1Ns0yRgI9sXY5OUj .image-shape .label,#mermaid-svg-1Ns0yRgI9sXY5OUj .icon-shape .label{text-align:center;}#mermaid-svg-1Ns0yRgI9sXY5OUj .node.clickable{cursor:pointer;}#mermaid-svg-1Ns0yRgI9sXY5OUj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1Ns0yRgI9sXY5OUj .arrowheadPath{fill:#333333;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1Ns0yRgI9sXY5OUj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1Ns0yRgI9sXY5OUj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1Ns0yRgI9sXY5OUj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1Ns0yRgI9sXY5OUj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1Ns0yRgI9sXY5OUj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1Ns0yRgI9sXY5OUj .cluster text{fill:#333;}#mermaid-svg-1Ns0yRgI9sXY5OUj .cluster span{color:#333;}#mermaid-svg-1Ns0yRgI9sXY5OUj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1Ns0yRgI9sXY5OUj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1Ns0yRgI9sXY5OUj rect.text{fill:none;stroke-width:0;}#mermaid-svg-1Ns0yRgI9sXY5OUj .icon-shape,#mermaid-svg-1Ns0yRgI9sXY5OUj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1Ns0yRgI9sXY5OUj .icon-shape p,#mermaid-svg-1Ns0yRgI9sXY5OUj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1Ns0yRgI9sXY5OUj .icon-shape .label rect,#mermaid-svg-1Ns0yRgI9sXY5OUj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1Ns0yRgI9sXY5OUj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1Ns0yRgI9sXY5OUj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1Ns0yRgI9sXY5OUj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 存储示例
1110111011011101111111110111
31bit = 4字节
用户签到BitMap
第1天
bit位0
第2天
bit位1
第3天
bit位2
第31天
bit位30

1.3 Redis BitMap 的操作命令

Redis 中是利用 String 类型数据结构实现 BitMap,因此最大上限是 512M,转换为 bit 则是 2^32 个 bit 位。Redis 提供的 BitMap 操作命令如下:

命令 功能描述 时间复杂度
SETBIT 向指定位置(offset)存入一个 0 或 1 O(1)
GETBIT 获取指定位置(offset)的 bit 值 O(1)
BITCOUNT 统计 BitMap 中值为 1 的 bit 位的数量 O(N)
BITFIELD 操作(查询、修改、自增)BitMap 中 bit 数组中的指定位置的值 O(1)
BITFIELD_RO 获取 BitMap 中 bit 数组,并以十进制形式返回 O(1)
BITOP 将多个 BitMap 的结果做位运算(与、或、异或) O(N)
BITPOS 查找 bit 数组中指定范围内第一个 0 或 1 出现的位置 O(N)

二、 签到功能实现

签到操作的本质是向指定偏移量写入状态值 1。Redis 的 SETBIT 命令具备天然的原子性与幂等性:重复执行同一偏移量的写入不会产生副作用,底层仅返回该位修改前的旧值。这一特性完美契合签到场景"防重放、防并发覆盖"的核心诉求。

2.1 键值对设计规范

业务维度 Redis Key 设计 Redis Value 数据类型 核心命令
用户月度签到轨迹 sign:{userId}:{yearMonth} 31位二进制位图(0未签/1已签) String (BitMap) SETBIT, GETBIT, BITFIELD

键值设计规范 :采用 业务域:主体标识:时间粒度 的三段式结构。以 yearMonth(如 202310)作为时间分片标识,既保障单月 BitMap 长度固定为 31 位,避免无限膨胀,又便于后续按月份归档冷数据或执行 TTL 过期策略。

2.2 完整签到时序推演

MySQL 数据库 Redis 服务端 FastAPI 路由层 客户端 MySQL 数据库 Redis 服务端 FastAPI 路由层 客户端 #mermaid-svg-9eEfjmuPfM5K6nrm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9eEfjmuPfM5K6nrm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9eEfjmuPfM5K6nrm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9eEfjmuPfM5K6nrm .error-icon{fill:#552222;}#mermaid-svg-9eEfjmuPfM5K6nrm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9eEfjmuPfM5K6nrm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9eEfjmuPfM5K6nrm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9eEfjmuPfM5K6nrm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9eEfjmuPfM5K6nrm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9eEfjmuPfM5K6nrm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9eEfjmuPfM5K6nrm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9eEfjmuPfM5K6nrm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9eEfjmuPfM5K6nrm .marker.cross{stroke:#333333;}#mermaid-svg-9eEfjmuPfM5K6nrm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9eEfjmuPfM5K6nrm p{margin:0;}#mermaid-svg-9eEfjmuPfM5K6nrm .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9eEfjmuPfM5K6nrm text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-9eEfjmuPfM5K6nrm .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9eEfjmuPfM5K6nrm .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-9eEfjmuPfM5K6nrm .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-9eEfjmuPfM5K6nrm .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-9eEfjmuPfM5K6nrm #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-9eEfjmuPfM5K6nrm .sequenceNumber{fill:white;}#mermaid-svg-9eEfjmuPfM5K6nrm #sequencenumber{fill:#333;}#mermaid-svg-9eEfjmuPfM5K6nrm #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-9eEfjmuPfM5K6nrm .messageText{fill:#333;stroke:none;}#mermaid-svg-9eEfjmuPfM5K6nrm .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9eEfjmuPfM5K6nrm .labelText,#mermaid-svg-9eEfjmuPfM5K6nrm .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-9eEfjmuPfM5K6nrm .loopText,#mermaid-svg-9eEfjmuPfM5K6nrm .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-9eEfjmuPfM5K6nrm .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9eEfjmuPfM5K6nrm .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-9eEfjmuPfM5K6nrm .noteText,#mermaid-svg-9eEfjmuPfM5K6nrm .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-9eEfjmuPfM5K6nrm .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9eEfjmuPfM5K6nrm .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9eEfjmuPfM5K6nrm .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9eEfjmuPfM5K6nrm .actorPopupMenu{position:absolute;}#mermaid-svg-9eEfjmuPfM5K6nrm .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-9eEfjmuPfM5K6nrm .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9eEfjmuPfM5K6nrm .actor-man circle,#mermaid-svg-9eEfjmuPfM5K6nrm line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-9eEfjmuPfM5K6nrm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt首次签到 (返回0)重复签到 (返回1) 1. POST /user/sign (携带登录态)12. 解析 userId,生成 key=sign:{userId}:{YYYYMM}23. 计算今日偏移量 offset = day_of_month - 134. SETBIT key offset 145. 返回 0 (今日首次签到) 或 1 (重复签到)56. UPDATE user SET points = points + 10 WHERE id = userId67. 事务提交成功78. 返回 {"success": true, "isNew": true}88. 返回 {"success": true, "isNew": false}9

  • 步骤 4SETBIT 直接在内存中翻转目标比特位,无需加载完整字符串,时间复杂度恒为 O(1)。
  • 步骤 5 :返回值天然标识签到状态。若为 0,说明该位原为未签到状态,本次为有效签到,可触发积分发放或任务链更新;若为 1,说明今日已签到,直接拦截后续业务逻辑,避免重复发奖。
  • 步骤 6:积分累加等重型业务逻辑仅在首次签到时触发,大幅降低数据库写入频率。

2.3 生产级代码实现

python 复制代码
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, Request, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import update
import redis.asyncio as aioredis

from common.database import get_db
from common.auth import get_current_user_id
from module_user.model import User

logger = logging.getLogger(__name__)
router = APIRouter()
redis_client = aioredis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True)

def get_sign_key(user_id: int, dt: datetime) -> str:
    """生成签到Key:sign:{userId}:{YYYYMM}"""
    return f"sign:{user_id}:{dt.year}{dt.month:02d}"

@router.post("/user/sign")
async def sign_in(request: Request, db: AsyncSession = Depends(get_db)):
    user_id = request.state.user_id
    now = datetime.now()
    sign_key = get_sign_key(user_id, now)
    offset = now.day - 1  # BitMap 偏移量从 0 开始
    
    # 1. 原子执行签到,获取修改前的旧值
    old_value = await redis_client.setbit(sign_key, offset, 1)
    
    if old_value == 1:
        # 重复签到,直接返回
        return {"success": True, "is_new": False, "message": "Already signed in today"}
        
    # 2. 首次签到,触发积分发放与连续天数统计(异步或同步均可)
    try:
        await db.execute(
            update(User).where(User.id == user_id).values(points=User.points + 10)
        )
        await db.commit()
    except Exception as e:
        logger.error(f"Points update failed for user {user_id}: {e}")
        # 生产环境建议引入补偿任务或消息队列保障最终一致
        
    return {"success": True, "is_new": True, "message": "Sign in successful"}

SETBIT 的原子性彻底消除了传统方案中"查询今日状态 -> 判断 -> 插入记录"的竞态窗口。即便万级并发请求在同一毫秒内抵达,Redis 单线程事件循环也会将其串行化执行,确保积分发放与状态翻转的绝对一致性。同时,BitMap 的内存压缩比使得百万用户月度签到数据的存储成本下降三个数量级。

三、 连续签到统计功能实现

连续签到天数的计算本质是从当前日期向前回溯,统计连续为 1 的比特位长度 。传统方案需逐日查询数据库或遍历日期数组,时间复杂度随回溯深度线性增长。Redis 的 BITFIELD 命令结合 Python 的位运算,可将该过程压缩至常数级内存操作。

3.1 算法原理与位操作时序

核心问题拆解

  1. 什么叫做连续签到天数?

    从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

  2. 如何得到本月到今天为止的所有签到数据?

    使用 BITFIELD key GET u{dayOfMonth} 0 命令,从偏移量 0 开始,连续读取 dayOfMonth 个无符号比特位,并将其打包为一个十进制整数返回。

  3. 如何从后向前遍历每个 bit 位?

    与 1 做与运算(value & 1),就能得到最后一个 bit 位。随后右移 1 位(value >>= 1),下一个 bit 位就成为了最后一个 bit 位。

#mermaid-svg-9cvzhgZbsPNfJzPM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9cvzhgZbsPNfJzPM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9cvzhgZbsPNfJzPM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9cvzhgZbsPNfJzPM .error-icon{fill:#552222;}#mermaid-svg-9cvzhgZbsPNfJzPM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9cvzhgZbsPNfJzPM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9cvzhgZbsPNfJzPM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9cvzhgZbsPNfJzPM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9cvzhgZbsPNfJzPM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9cvzhgZbsPNfJzPM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9cvzhgZbsPNfJzPM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9cvzhgZbsPNfJzPM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9cvzhgZbsPNfJzPM .marker.cross{stroke:#333333;}#mermaid-svg-9cvzhgZbsPNfJzPM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9cvzhgZbsPNfJzPM p{margin:0;}#mermaid-svg-9cvzhgZbsPNfJzPM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9cvzhgZbsPNfJzPM .cluster-label text{fill:#333;}#mermaid-svg-9cvzhgZbsPNfJzPM .cluster-label span{color:#333;}#mermaid-svg-9cvzhgZbsPNfJzPM .cluster-label span p{background-color:transparent;}#mermaid-svg-9cvzhgZbsPNfJzPM .label text,#mermaid-svg-9cvzhgZbsPNfJzPM span{fill:#333;color:#333;}#mermaid-svg-9cvzhgZbsPNfJzPM .node rect,#mermaid-svg-9cvzhgZbsPNfJzPM .node circle,#mermaid-svg-9cvzhgZbsPNfJzPM .node ellipse,#mermaid-svg-9cvzhgZbsPNfJzPM .node polygon,#mermaid-svg-9cvzhgZbsPNfJzPM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9cvzhgZbsPNfJzPM .rough-node .label text,#mermaid-svg-9cvzhgZbsPNfJzPM .node .label text,#mermaid-svg-9cvzhgZbsPNfJzPM .image-shape .label,#mermaid-svg-9cvzhgZbsPNfJzPM .icon-shape .label{text-anchor:middle;}#mermaid-svg-9cvzhgZbsPNfJzPM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9cvzhgZbsPNfJzPM .rough-node .label,#mermaid-svg-9cvzhgZbsPNfJzPM .node .label,#mermaid-svg-9cvzhgZbsPNfJzPM .image-shape .label,#mermaid-svg-9cvzhgZbsPNfJzPM .icon-shape .label{text-align:center;}#mermaid-svg-9cvzhgZbsPNfJzPM .node.clickable{cursor:pointer;}#mermaid-svg-9cvzhgZbsPNfJzPM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9cvzhgZbsPNfJzPM .arrowheadPath{fill:#333333;}#mermaid-svg-9cvzhgZbsPNfJzPM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9cvzhgZbsPNfJzPM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9cvzhgZbsPNfJzPM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9cvzhgZbsPNfJzPM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9cvzhgZbsPNfJzPM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9cvzhgZbsPNfJzPM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9cvzhgZbsPNfJzPM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9cvzhgZbsPNfJzPM .cluster text{fill:#333;}#mermaid-svg-9cvzhgZbsPNfJzPM .cluster span{color:#333;}#mermaid-svg-9cvzhgZbsPNfJzPM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9cvzhgZbsPNfJzPM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9cvzhgZbsPNfJzPM rect.text{fill:none;stroke-width:0;}#mermaid-svg-9cvzhgZbsPNfJzPM .icon-shape,#mermaid-svg-9cvzhgZbsPNfJzPM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9cvzhgZbsPNfJzPM .icon-shape p,#mermaid-svg-9cvzhgZbsPNfJzPM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9cvzhgZbsPNfJzPM .icon-shape .label rect,#mermaid-svg-9cvzhgZbsPNfJzPM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9cvzhgZbsPNfJzPM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9cvzhgZbsPNfJzPM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9cvzhgZbsPNfJzPM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

开始
获取本月签到BitMap
BITFIELD key GET u current_day 0
解析为十进制整数 value
value == 0 ?
连续天数 = 0
结束
循环: count++
value = value & 1
value = value >> 1

位运算示例

假设当前为 10 月 5 日,本月前 5 天的签到记录为 11101(第 5 天未签,第 1-4 天已签)。

  • BITFIELD sign:123:202310 GET u5 0 返回十进制值 29(二进制 11101
  • 第 1 轮:29 & 1 = 1(第 5 天已签),29 >> 1 = 14count = 1
  • 第 2 轮:14 & 1 = 0(第 4 天未签),循环终止
  • 结果:连续签到天数 = 1

3.2 生产级统计实现

python 复制代码
from datetime import datetime

@router.get("/user/sign/count")
async def get_consecutive_sign_count(request: Request, db: AsyncSession = Depends(get_db)):
    user_id = request.state.user_id
    now = datetime.now()
    sign_key = get_sign_key(user_id, now)
    current_day = now.day
    
    # 1. 读取从本月1号到今天的所有签到位,打包为整数
    result = await redis_client.bitfield(sign_key, "GET", f"u{current_day}", 0)
    if not result or result[0] == 0:
        return {"count": 0}
        
    # 2. 位运算回溯统计连续签到天数
    value = result[0]
    consecutive_days = 0
    
    while value > 0:
        if value & 1 == 0:
            # 遇到未签到日,中断统计
            break
        consecutive_days += 1
        value >>= 1  # 右移1位,检查前一天
        
    return {"count": consecutive_days, "month": f"{now.year}-{now.month:02d}"}

时间复杂度 :位运算循环次数最多等于当月天数(≤31),且每次操作均为 CPU 寄存器级指令,实际耗时可忽略不计。相较于数据库的 COUNT 聚合或应用层循环查询,性能提升呈指数级。

内存零拷贝BITFIELD 直接在 Redis 服务端完成比特位提取与整数转换,仅返回一个 64 位整型值。网络传输带宽消耗极低,彻底规避 N+1 查询问题。

边界处理 :若用户今日未签到,value & 1 首次判断即为 0,循环立即终止,返回 0,符合业务直觉。跨月场景由 Key 的时间分片天然隔离,无需额外逻辑干预。

四、后续的改进

BitMap 虽具备极致的内存效率,但随时间推移仍会累积大量历史月份数据。生产环境需建立明确的生命周期策略:

  • 热数据:当前月与上月 Key 常驻内存,保障高频签到与统计查询的毫秒级响应。
  • 冷数据归档 :通过定时任务扫描 sign:{userId}:{YYYYMM},将超过 3 个月的历史 BitMap 序列化后迁移至 MySQL 归档表或对象存储,随后执行 DEL 释放 Redis 内存。
  • TTL 兜底:为所有签到 Key 设置合理的过期时间(如 13 个月),防止极端异常场景下的内存泄漏。
相关推荐
大数据魔法师1 小时前
MongoDB(九) - MongoDB分片集安装与配置
数据库·mongodb
念何架构之路1 小时前
存储层技术MySQL
数据库·mysql
cfm_29141 小时前
Redis高并发多级缓存介绍 + JDHotkey热点探测了解
数据库·redis·缓存
yun呐1 小时前
mysql数据库误删恢复
数据库·mysql·adb
IvorySQL1 小时前
PostgreSQL 技术日报 (6月3日)|复制日志补丁更新,PG 黑客坊开启
数据库·人工智能·postgresql
j7~1 小时前
【MYSQL】图形化界面使用说明-- MYSQL(workbench)
数据库·mysql·mysql图形化界面·mysqlworkbench
m0_653031362 小时前
(文档)第124讲:异构数据库同步利器 — SynchDB使用全攻略
数据库
_Kafka_2 小时前
Oracle EBS 有期间控制的模块
数据库·oracle
repetitiononeoneday2 小时前
【面试题】Redis缓存穿透如何解决?
java·redis·缓存