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

Redis Bitmap怎样统计每日签到的连续天数为什么结果为空或不符合预期

来源:17golang原创

时间:2026-11-10 00:50:19 494浏览 收藏

我第一次把 Redis Bitmap 用到每日签到时,遇到过一个很容易误判的结果:BITCOUNT 返回的总签到天数没问题,但“连续签到天数”要么一直是 0,要么比页面显示少一天。原因通常不是 Redis 丢了位,而是把“统计 1 的总数”和“检查相邻日期”当成了一件事。

这篇文章只解决一个具体任务:用 Redis Bitmap 表示用户每天是否签到,并计算截至某个业务日期的连续签到天数。官方资料把 Bitmap 定义为建立在 String 上的位操作集合;SETBIT 和 GETBIT 负责单个位,BITCOUNT 负责统计 1 的数量。

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

先给出我最终采用的口径:当前业务日没有签到,连续天数就是 0;当前业务日已签到,就从当前日期对应的 offset 向前检查,遇到第一个 0 停止。BITCOUNT 只作为总签到天数或排查数据的辅助指标。

先分清总签到天数和连续签到天数

假设一位用户在 1 日、2 日、4 日和 5 日签到。bitmap 中可能是 11011。这时:

  • BITCOUNT 得到 4,表示总共签到过 4 天;
  • 如果查询日期是 5 日,从末尾向前看是 1、1,然后遇到 0,所以连续签到是 2 天;
  • 如果查询日期是 3 日,当天是 0,连续签到应立即返回 0,即使前两天都签到过。

因此,直接把 BITCOUNT 的返回值展示成“连续签到”,一定会在漏签后产生错误。这个区分也是排查空结果的第一步:先问清楚接口返回的字段到底是总天数,还是以某个业务日期为终点的连续天数。

用户年度键、业务日期和 dayOffset 映射到 Redis Bitmap 位的静态结构说明图
图1:日期偏移结构说明图,展示签到日期如何对应 bitmap 位;这是静态说明图,不是运行截图。

先把日期稳定地映射成位偏移

我会为每个用户按年度分键,例如 signin:10086:2026。然后约定年度起点为 2026-01-01,把业务日期与年度起点相差的整天数作为 offset:1 月 1 日是 0,1 月 2 日是 1,依次类推。

这个约定有三个好处:键名不会把每一天拆成大量小键;同一用户的签到轨迹集中在一个位向量中;查询连续天数时可以直接按 offset 向左检查。关键是“业务日期”必须由同一个时区规则产生,不能写入时按服务器 UTC,读取时按用户本地时间。

# 用 redis-cli 演示:用户 10086 在年度 bitmap 的第 4 位签到
redis-cli SETBIT signin:10086:2026 4 1

# 读取同一个偏移,1 表示已签到,0 表示没有签到
redis-cli GETBIT signin:10086:2026 4

# 统计所有已置为 1 的日期数量,不代表连续签到天数
redis-cli BITCOUNT signin:10086:2026

这里的 4 不是“第 4 天”的自然语言编号,而是从 0 开始的日期差。若代码把 1 月 1 日当作 1,写入和读取都使用同一套错误偏移,表面上可能看不出来;一旦接口用另一套日期函数读取,就会出现少一天、空结果或把相邻年度混在一起。

写入签到时,先固定业务日期

签到接口不要直接拿 Redis 服务器时间或应用机器的本地时间当业务日期。更稳妥的做法是先确定业务时区,再把当前时间转换成该时区的“日期”,最后计算年度起点差值。下面的 Go 片段只展示日期坐标和写入参数,Redis 客户端调用可以替换成项目使用的实现。

package main

import (
	"context"
	"fmt"
	"time"
)

// dayOffset 统一使用业务时区,把日期转换成从年度起点开始的 0-based 偏移。
func dayOffset(now time.Time, loc *time.Location) (string, int, error) {
	localNow := now.In(loc)
	date := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
	start := time.Date(date.Year(), time.January, 1, 0, 0, 0, 0, loc)
	return fmt.Sprintf("signin:%d:%04d", 10086, date.Year()), int(date.Sub(start).Hours() / 24), nil
}

func main() {
	// 固定业务时区;生产代码应从配置注入,而不是依赖机器默认时区。
	loc, err := time.LoadLocation("Asia/Shanghai")
	if err != nil {
		panic(err)
	}
	key, offset, err := dayOffset(time.Now(), loc)
	if err != nil {
		panic(err)
	}
	_ = context.Background() // 真实项目中把 ctx 传给 Redis 客户端。
	fmt.Println(key, offset)
}

示例里的关键点不是客户端库,而是键和 offset 的生成规则。真实写入时,把 key 与 offset 交给 SETBIT key offset 1 即可。重复签到写同一个位不会增加“天数”,这正是 Bitmap 适合表示每日布尔状态的地方。

连续天数要从当前日向前扫描

确定查询日期后,先读取该日期对应的位。如果是 0,返回 0;如果是 1,再读取 bitmap 的内容或按字节分段获取,从当前 offset 向前检查,直到遇到第一个 0。扫描经过的连续 1 的数量,就是当前连续签到天数。

伪代码可以写成下面这样。它故意把“当前日未签到”的分支放在最前面,这能避免把前几天的历史签到误算成当前连续签到。

// streakFromBits 根据从年度起点开始的位序列计算截至 endOffset 的连续天数。
func streakFromBits(bits []byte, endOffset int) int {
	if endOffset = 0 && getBit(bits, offset); offset-- {
		// 只向前数相邻的 1,遇到第一个 0 就停止。
		streak++
	}
	return streak
}

// getBit 按 Redis 的高位到低位约定读取一个字节中的目标位。
func getBit(bits []byte, offset int) bool {
	byteIndex := offset / 8
	bitIndex := 7 - offset%8
	return byteIndex >= 0 && byteIndex 

在应用层取得 bitmap 后,可以使用这样的扫描逻辑。若 bitmap 很长,不要每次都把一个超大年度键完整传输;可以按业务保留周期拆成月键,或只取包含当前日期的字节区间,再向前逐段读取。拆键是容量和网络成本的决策,不会改变“当前位为 1 后向前找第一个 0”的统计口径。

Redis Bitmap 从当前 offset 向前扫描并区分连续天数与 BITCOUNT 总天数的静态结构图
图2:连续性查询结构说明图,区分向前扫描得到的连续天数与 BITCOUNT 得到的总天数;不是运行截图。

为什么结果为空或总是少一天

我排查这类问题时,会按下面的顺序看,而不是先怀疑 Bitmap 本身:

  1. 键名是否一致。写入是 signin:10086:2026,读取却拼成了 signin:10086:2025,GETBIT 对不存在键会得到 0,看起来就像“没有签到”。
  2. offset 是否从 0 开始。年度第一天应为 0。读取逻辑若多加 1,当前日会读到下一天的位,结果通常表现为少一天或当天明明签到却为 0。
  3. 写入和读取是否使用同一个时区。凌晨附近最容易复现:写入端已经进入下一日,查询端还停留在前一日,两个请求使用的 offset 不同。
  4. 是否把当前日缺席当成历史连续。当前日位为 0 时必须返回 0,不能从前一天开始扫描。
  5. 是否跨年。按年度分键时,1 月 1 日向前的连续签到需要同时读取上一年度键;如果业务不允许跨年连续,明确把它截断为 0 或 1,不要让不同接口各自决定。
  6. 是否把总数当连续数。BITCOUNT 返回 4 并不说明最近连续 4 天签到,它只说明整个 bitmap 里有 4 个 1。

排查时可以把键名、业务日期、年度起点、offset 和 GETBIT 的结果放在同一条结构化日志里。不要只记录“连续天数为 0”,否则无法判断是当前日确实缺席,还是坐标计算错了。

按负载选择年度键、月键和汇总字段

这个方案的取舍不只在命令是否正确,还在于查询负载:

方案适合场景需要留意
按用户按年一个 bitmap保留周期较长,查询集中在当前用户读取范围可能变大,跨年逻辑要明确
按用户按月一个 bitmap只看近期开签到,或希望限制单键长度跨月连续需要拼接两个键
bitmap 加连续天数缓存首页高频展示,允许写入时维护摘要补签、撤销签到和重算都要同步更新摘要

对我来说,最稳妥的落地方式是:Bitmap 作为每日事实,连续天数作为可重算结果;高频首页可以缓存连续天数,但后台修复任务仍然以 bitmap 为准重算。这样既保留了稀疏布尔数据的空间优势,也避免把一个容易变化的统计值当成唯一事实。

最后用一组最小检查确认口径

# 先确认当前业务日期落在哪个年度键和 offset;这里的数字只作示例
redis-cli GETBIT signin:10086:2026 4

# 再看整个年度有多少个签到日,核对事实数据是否存在
redis-cli BITCOUNT signin:10086:2026

# 如果当前位是 1,再由应用从 offset=4 向前检查 3、2、1、0
# 命中第一个 0 后停止;扫描数量才是连续签到天数

如果这三步能在同一个业务日期、同一个键名和同一个 offset 规则下解释清楚,空结果通常就能定位。反过来,只看到一个 BITCOUNT 数字,却没有保存日期坐标,就很难解释“为什么总数正确但连续数不对”。

相关问题

BITCOUNT 能直接算连续签到天数吗?

不能。它只统计 bitmap 中值为 1 的位数。连续签到需要从指定结束日期对应的位开始,检查相邻日期是否连续为 1。

当前日期没有签到,但前几天连续签到,应该返回多少?

按“截至当前业务日”的口径应返回 0。如果产品要展示“最近一次连续签到”,那是另一个查询口径,需要先找到最近的 1,再从那里向前扫描。

为什么同一用户偶尔会少一天?

最常见的是 offset 基准不一致或时区不同。把年度起点、业务日期、键名和 offset 同时记录下来,通常比反复调整 Redis 命令更快找到问题。

总结

Redis Bitmap 适合表达“某个用户在某一天是否签到”,但连续签到不是简单的计数题。正确做法是固定业务时区和年度起点,把日期映射为从 0 开始的 offset;写入用 SETBIT,单日判断用 GETBIT,总签到数才用 BITCOUNT;连续天数则从当前 offset 向前扫描,直到遇到第一个 0。按这个边界实现,空结果、少一天和跨年异常都会变成可以逐项解释的坐标问题。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>