当前位置:首页 > 文章列表 > 数据库 > Redis > Redis Bitmap怎样统计每日签到的连续天数怎样完成一个可复用的最小示例

Redis Bitmap怎样统计每日签到的连续天数怎样完成一个可复用的最小示例

来源:17golang原创 2026-11-10 00:50:03 0浏览 收藏

Redis Bitmap 适合把“某个自然日是否签到”压缩成一个位:签到当天把对应位设为 1,查询时从目标日向前读取,遇到第一个 0 就停止。这样连续签到天数由一段连续的 1 得到,重复签到天然幂等,也不需要为每个用户每天创建一条普通字符串记录。

官方地址:https://redis.io/docs/latest/develop/data-types/strings/bitmaps/

本文只解决一个具体问题:用 Redis Bitmap 统计每日签到连续天数,并给出一个可复用的 Go 最小实现。示例采用“每个用户一张位图、按业务时区计算自然日偏移”的方案,跨月不需要换键;如果业务只保留最近几个月的数据,再增加归档或按周期拆键即可。

先明确负载:Bitmap 保存状态,连续天数由业务定义

签到系统真正要保存的不是“签到次数”,而是某个用户在某个自然日是否完成签到。对一个用户来说,日期可以转换成从固定纪元开始的非负整数偏移量:

  • 位图的键只标识用户,例如 signin:{userID};
  • 位偏移代表业务时区下的自然日,而不是 UTC 的小时数;
  • 位值为 1 表示当天已签到,位值为 0 表示未签到;
  • 连续天数从目标日开始向前计数,首个缺席日就是边界。

这种模型的优点是空间稳定、写入幂等、跨月连续。它不直接保存签到时间、补签原因或奖励流水,因此这些信息要放在独立的业务表或 Hash 中,不能把 Bitmap 当作完整签到账本。

把业务时区的自然日映射成稳定的 Bitmap 位

时区是最容易被忽略的约束。不能直接用 time.Now().UTC() 的日期,也不能用两个本地零点相减后除以 24,因为夏令时切换时一天可能是 23 或 25 小时。正确做法是先在业务时区得到年月日,再把年月日按 UTC 零点重新构造,仅用于计算日历序号。

func calendarDay(t time.Time, loc *time.Location) time.Time {
    // 先转换到业务时区,确保“今天”符合产品定义。
    local := t.In(loc)
    return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
}

func dayOffset(day time.Time) int64 {
    // 只使用年月日构造 UTC 日期,避免夏令时改变一天的小时数。
    utcDay := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.UTC)
    epoch := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
    return int64(utcDay.Sub(epoch).Hours() / 24)
}

固定纪元可以根据业务数据范围选择。上面的纪元足以覆盖常见签到系统;如果允许查询纪元之前的日期,应在进入 Redis 前拒绝负偏移,否则 SETBIT 会收到无效位置。

Redis Bitmap 中用户标识、业务时区、自然日偏移、Bitmap 键和位状态的静态关系图
图1:Redis Bitmap 的日期映射说明图;它展示数据关系,不是运行截图或执行证据。

用 SETBIT 记录幂等签到

SETBIT key offset 1 是签到写入的核心。第一次签到把目标位从 0 改成 1,再次签到仍然写入 1,因此不会重复累计。若业务要求“只能在当天签到”,由应用层用业务时区判断日期;Bitmap 本身只负责记录位状态。

func SignIn(ctx context.Context, rdb *redis.Client, userID string, now time.Time, loc *time.Location) error {
    if userID == "" {
        // 空用户不能组成稳定键,直接返回参数错误。
        return errors.New("userID is empty")
    }

    day := calendarDay(now, loc)
    offset := dayOffset(day)
    if offset 

键名中的花括号对单机 Redis 没有特殊要求,但在 Redis Cluster 中可以让同一用户的相关键落在同一 hash slot;如果后续把签到状态和用户奖励拆成多个键,可以保留同一个 hash tag。

从目标日向前读取连续签到天数

查询从目标日开始循环:当前位为 1 就把天数加一,日期向前移动一天;遇到 0 立即结束。这里的“连续”是应用层按自然日定义的,不是 Redis 对位图自动提供的统计指标。

func Streak(ctx context.Context, rdb *redis.Client, userID string, target time.Time, loc *time.Location) (int, error) {
    if userID == "" {
        // 查询键必须和签到时使用同一套用户标识。
        return 0, errors.New("userID is empty")
    }

    day := calendarDay(target, loc)
    key := "signin:{" + userID + "}"
    streak := 0

    for dayOffset(day) >= 0 {
        offset := dayOffset(day)
        bit, err := rdb.GetBit(ctx, key, offset).Result()
        if err != nil {
            // Redis 网络错误不能被当成“未签到”,应交给上层重试或降级。
            return 0, err
        }
        if bit == 0 {
            // 第一个缺席日切断连续区间。
            break
        }
        streak++
        day = day.AddDate(0, 0, -1)
    }
    return streak, nil
}

如果今天还没有签到,结果是 0;如果今天和前两天都有签到、再前一天缺席,结果是 3。查询的 Redis 往返次数等于连续天数加一,正常签到场景通常很短;对可能连续数千天的账户,应设置上限或改成批量读取策略。

Redis Bitmap 连续签到查询中目标日、位偏移、GETBIT 返回、缺席位和连续天数的关系图
图2:连续签到天数查询关系示意图;它解释字段和结果的联系,不是 Redis 控制台截图。

可复用的 Go 最小实现

把日期转换、签到写入和连续天数查询组合起来即可形成一个小型服务组件。下面的示例使用 github.com/redis/go-redis/v9,初始化客户端时应根据部署环境补充认证、TLS 和连接池参数。

package main

import (
    "context"
    "errors"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

var epoch = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)

func calendarDay(t time.Time, loc *time.Location) time.Time {
    // 业务时区决定自然日,不能直接拿服务器时区代替。
    local := t.In(loc)
    return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
}

func offset(day time.Time) int64 {
    // 用日历日期构造 UTC 零点,规避夏令时带来的小时差。
    utcDay := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.UTC)
    return int64(utcDay.Sub(epoch).Hours() / 24)
}

func bitmapKey(userID string) string {
    // hash tag 便于 Redis Cluster 将同一用户的相关键放在同一槽位。
    return "signin:{" + userID + "}"
}

func signIn(ctx context.Context, rdb *redis.Client, userID string, now time.Time, loc *time.Location) error {
    if userID == "" {
        return errors.New("userID is empty")
    }
    day := calendarDay(now, loc)
    bit := offset(day)
    if bit = 0 {
        // GETBIT 的错误必须向上返回,不能把 Redis 故障误报成未签到。
        bit, err := rdb.GetBit(ctx, bitmapKey(userID), offset(day)).Result()
        if err != nil {
            return 0, err
        }
        if bit == 0 {
            break
        }
        total++
        day = day.AddDate(0, 0, -1)
    }
    return total, nil
}

func main() {
    ctx := context.Background()
    // 示例连接参数仅用于说明调用关系,生产环境应从配置或密钥管理系统读取。
    rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
    defer rdb.Close()

    loc, err := time.LoadLocation("Asia/Shanghai")
    if err != nil {
        panic(err)
    }
    now := time.Now()
    if err := signIn(ctx, rdb, "u-1001", now, loc); err != nil {
        panic(err)
    }
    days, err := streak(ctx, rdb, "u-1001", now, loc)
    if err != nil {
        panic(err)
    }
    fmt.Println("连续签到天数:", days)
}

示例里的 main 只展示调用顺序;真正的 HTTP 服务应复用 Redis 客户端,不要每个请求重新建立连接。对签到接口可以把写入和奖励发放拆成两个步骤:Bitmap 写成功只代表状态已记录,积分、勋章等副作用还需要幂等订单号或独立流水保护。

按约束选择 Bitmap、按日记录或混合方案

方案适合场景主要取舍
单用户 Bitmap只关心是否签到和连续天数空间小、写入快,但不保存签到详情
按日 Hash/表记录需要补签原因、来源、审核和时间字段完整,但记录量与索引成本更高
Bitmap + 业务流水既要快速统计,又要可追溯奖励需要处理两套数据的一致性和幂等

补签不能简单地把“今天的位”改成 1 就结束。应先校验补签资格,再写入目标日期的位,并用业务流水记录操作者、原因和奖励结果。若产品规定连续签到遇到周末不打断,也要在查询循环里明确跳过周末,而不是把所有缺席都当作断点。

另外要给连续查询设置业务上限,例如只统计最近 365 天,并考虑对长期连续用户缓存结果。Bitmap 的最大偏移、用户数量、过期策略、备份和集群容量都应在上线前纳入容量评估;不要因为位图单个位很小,就忽略用户键数量带来的元数据成本。

落地清单

  1. 确定业务时区,并用年月日计算偏移,避免服务器时区和夏令时造成错位。
  2. 让签到写入固定为 SETBIT key offset 1,重复请求不重复发奖。
  3. 让连续查询从目标日向前读取,区分“未签到”和 Redis 访问错误。
  4. 把补签、奖励、审核等可追溯信息放在 Bitmap 之外。
  5. 为长连续区间、历史归档、过期、备份和 Redis Cluster 预留方案。

常见问题

为什么不用 INCR 直接统计签到天数?

INCR 只能表示累计次数,无法判断中间哪一天缺席。Bitmap 保存每个自然日的状态,才能从目标日向前识别连续区间。

重复调用 SETBIT 会不会让连续天数增加两次?

不会。同一个键和偏移量始终只有一个位,重复写入 1 不会产生额外计数;但奖励发放仍需单独做幂等。

跨月或跨年需要换一张 Bitmap 吗?

不需要。本文用固定纪元计算日偏移,月份变化只改变偏移值。如果为了生命周期管理按月或按年拆键,就必须在查询边界切换键并处理跨周期连续性。

Redis 故障时能不能把 GETBIT 错误当成未签到?

不建议。这样会把基础设施故障伪装成业务缺席,导致错误奖励和错误统计;应返回错误、重试或走明确的降级策略。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
MySQL 优化器直方图数据分布变化后怎样更新列统计信息怎样在批处理代码中减少额外分配MySQL 优化器直方图数据分布变化后怎样更新列统计信息怎样在批处理代码中减少额外分配
上一篇
MySQL 优化器直方图数据分布变化后怎样更新列统计信息怎样在批处理代码中减少额外分配
Redis Bitmap怎样统计每日签到的连续天数为什么结果为空或不符合预期
下一篇
Redis Bitmap怎样统计每日签到的连续天数为什么结果为空或不符合预期
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    965次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    965次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    1077次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    1055次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    887次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码