登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  Redis

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

来源:17golang原创

时间:2026-11-10 00:50:03 363浏览 收藏

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删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>