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 会收到无效位置。

用 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 往返次数等于连续天数加一,正常签到场景通常很短;对可能连续数千天的账户,应设置上限或改成批量读取策略。

可复用的 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 的最大偏移、用户数量、过期策略、备份和集群容量都应在上线前纳入容量评估;不要因为位图单个位很小,就忽略用户键数量带来的元数据成本。
落地清单
- 确定业务时区,并用年月日计算偏移,避免服务器时区和夏令时造成错位。
- 让签到写入固定为
SETBIT key offset 1,重复请求不重复发奖。 - 让连续查询从目标日向前读取,区分“未签到”和 Redis 访问错误。
- 把补签、奖励、审核等可追溯信息放在 Bitmap 之外。
- 为长连续区间、历史归档、过期、备份和 Redis Cluster 预留方案。
常见问题
为什么不用 INCR 直接统计签到天数?
INCR 只能表示累计次数,无法判断中间哪一天缺席。Bitmap 保存每个自然日的状态,才能从目标日向前识别连续区间。
重复调用 SETBIT 会不会让连续天数增加两次?
不会。同一个键和偏移量始终只有一个位,重复写入 1 不会产生额外计数;但奖励发放仍需单独做幂等。
跨月或跨年需要换一张 Bitmap 吗?
不需要。本文用固定纪元计算日偏移,月份变化只改变偏移值。如果为了生命周期管理按月或按年拆键,就必须在查询边界切换键并处理跨周期连续性。
Redis 故障时能不能把 GETBIT 错误当成未签到?
不建议。这样会把基础设施故障伪装成业务缺席,导致错误奖励和错误统计;应返回错误、重试或走明确的降级策略。
-
494 收藏
-
240 收藏
-
275 收藏
-
416 收藏
-
370 收藏
-
496 收藏
-
246 收藏
-
322 收藏
-
170 收藏
-
312 收藏
-
295 收藏
-
245 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习