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

先把日期稳定地映射成位偏移
我会为每个用户按年度分键,例如 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”的统计口径。

为什么结果为空或总是少一天
我排查这类问题时,会按下面的顺序看,而不是先怀疑 Bitmap 本身:
- 键名是否一致。写入是
signin:10086:2026,读取却拼成了signin:10086:2025,GETBIT对不存在键会得到 0,看起来就像“没有签到”。 - offset 是否从 0 开始。年度第一天应为 0。读取逻辑若多加 1,当前日会读到下一天的位,结果通常表现为少一天或当天明明签到却为 0。
- 写入和读取是否使用同一个时区。凌晨附近最容易复现:写入端已经进入下一日,查询端还停留在前一日,两个请求使用的 offset 不同。
- 是否把当前日缺席当成历史连续。当前日位为 0 时必须返回 0,不能从前一天开始扫描。
- 是否跨年。按年度分键时,1 月 1 日向前的连续签到需要同时读取上一年度键;如果业务不允许跨年连续,明确把它截断为 0 或 1,不要让不同接口各自决定。
- 是否把总数当连续数。
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。按这个边界实现,空结果、少一天和跨年异常都会变成可以逐项解释的坐标问题。
-
117 收藏
-
426 收藏
-
171 收藏
-
113 收藏
-
195 收藏
-
363 收藏
-
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次学习