Redis BitMap如何高效实现用户签到统计功能?

来源:Apache教程作者:马来西亚程序员头衔:程序员
导读:本期聚焦于马来西亚程序员创作的《Redis BitMap如何高效实现用户签到统计功能?》,敬请观看详情。签到功能几乎是所有App的标配模块,但用传统关系型数据库存储签到记录,随着用户量增长会面临表数据膨胀、查询缓慢等问题。Redis的BitMap数据结构用一串二进制位来记录每天的签到状态,一个用户一个月的签到数据只占用几十个字节,亿级用户的签到存储成本可以压缩到极低水平。本文将深入讲解BitMap的底层存储原理,包括SETBIT、GETBIT、BITCOUNT、BITPOS等核心命令的用法,演示如何用Spring Boot整合Redis完成连续签到天数、本月签到天数、首次签到日期等常见统计需求的实现,并分析大key偏移量越界、时区处理等实际开发中容易踩到的坑。

签到统计是典型的“写多读多、数据量随时间线性增长”场景。假设一个产品有一千万日活用户,每人每天产生一条签到记录,一年就是36.5亿条数据。如果用MySQL逐条存储,光索引就要占用大量磁盘空间,统计连续签到天数时还需要复杂的时间窗口查询。而Redis的BitMap把每天的签到状态压缩成一个二进制位,同一个用户一整年的签到数据加起来也不过46个字节左右,统计效率更是有数量级的提升。这篇文章从原理到实战,完整讲清楚如何用BitMap实现一套生产可用的签到系统。

Redis BitMap如何高效实现用户签到统计功能?

BitMap的底层存储原理

Redis中的BitMap并不是一种独立的数据类型,而是基于String类型实现的位操作扩展。当你执行SETBIT key offset value时,Redis会在底层维护一段连续的字节数组,offset表示二进制位的偏移位置,value只能是0或1。比如设置offset为7的位,Redis至少会分配1个字节来容纳前8个位。

这里有一个非常重要的特性:BitMap采用稀疏分配策略。如果你直接执行SETBIT sign:10086 365 1(给用户10086设置第365天的签到位),Redis会一次性分配约46字节的空间,中间未被设置的位自动补0。这意味着offset决定了内存占用上限,一个偏移量最大支持2的32次方减1,也就是42亿多个位,理论上一条String就能记录一个用户一百多万年的签到数据。

位与字节的对应关系遵循大端序规则:字节内的位从高位到低位编号。例如第一个字节的第0位是该字节的最高位(二进制10000000中的1)。理解这一点在做位运算分析时很关键,否则很容易把结果算反。

核心命令详解

与BitMap相关的命令主要有四个,各自承担不同的职责:

  • SETBIT key offset value:设置指定偏移量上的位,返回旧值。签到接口的核心写入命令。
  • GETBIT key offset获取指定偏移量上的值,用于判断某天是否签到。
  • BITCOUNT key [start end]:统计位图中值为1的数量,用于计算累计签到天数。注意start和end参数的单位默认是字节,如果需要按位统计要加BYTE参数显式声明或使用BIT选项。
  • BITPOS key bit [start]:查找第一个被设置为指定值(0或1)的位,可以用来快速定位首次签到日期或第一个未签到的天。

下面用redis-cli演示一个完整的签到流程,以2024年5月的第1天到第3天为例:

# 用户10086在5月1日签到,offset为当月第几天减1
127.0.0.1:6379> SETBIT sign:10086:202405 0 1
(integer) 0
# 5月2日签到
127.0.0.1:6379> SETBIT sign:10086:202405 1 1
(integer) 0
# 5月3日未签到
127.0.0.1:6379> GETBIT sign:10086:202405 2
(integer) 0
# 统计本月累计签到天数
127.0.0.1:6379> BITCOUNT sign:10086:202405
(integer) 2
# 查找本月第一个签到的日期
127.0.0.1:6379> BITPOS sign:10086:202405 1
(integer) 0

key的命名设计也有讲究。上面的例子使用了sign:用户ID:年月的格式,按月拆分数据。这样设计有两个好处:一是单个key最大只占31个位,永远不会成为大key;二是过期策略简单,可以给整个key设置TTL,超过一年的旧数据自动清理。

Spring Boot实战:签到与连续签到统计

理论讲完,接下来用Java实现一套完整的签到服务。技术栈为Spring Boot加Spring Data Redis,核心思路是把“当月第几天”映射为BitMap的offset。统计连续签到天数时,从今天开始往前逐位检查,遇到0就停止。

@Service
public class SignInService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 执行签到
     * @param userId 用户ID
     * @return 是否签到成功,false表示今天已经签过
     */
    public boolean doSignIn(long userId) {
        LocalDateTime now = LocalDateTime.now();
        String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
        String key = "sign:" + userId + ":" + keySuffix;
        // offset = 当月第几天 - 1,从0开始
        int offset = now.getDayOfMonth() - 1;
        Boolean result = redisTemplate.opsForValue().setBit(key, offset, true);
        // 设置过期时间,一年后自动清理(重复调用不会重置已有TTL)
        redisTemplate.expire(key, Duration.ofDays(365));
        return Boolean.TRUE.equals(result) ? false : true;
    }

    /**
     * 获取本月签到情况,用于日历回显
     */
    public List<Boolean> getSignInCalendar(long userId) {
        LocalDateTime now = LocalDateTime.now();
        String key = "sign:" + userId + ":" + now.format(DateTimeFormatter.ofPattern("yyyyMM"));
        int days = now.getMonth().maxLength();
        List<Boolean> calendar = new ArrayList<>(days);
        for (int i = 0; i < days; i++) {
            Boolean bit = redisTemplate.opsForValue().getBit(key, i);
            calendar.add(Boolean.TRUE.equals(bit));
        }
        return calendar;
    }

    /**
     * 统计从今天开始往前的连续签到天数
     */
    public long getContinuousDays(long userId) {
        LocalDateTime now = LocalDateTime.now();
        String key = "sign:" + userId + ":" + now.format(DateTimeFormatter.ofPattern("yyyyMM"));
        int todayOffset = now.getDayOfMonth() - 1;
        long count = 0;
        for (int i = todayOffset; i >= 0; i--) {
            Boolean bit = redisTemplate.opsForValue().getBit(key, i);
            if (Boolean.TRUE.equals(bit)) {
                count++;
            } else {
                break;
            }
        }
        return count;
    }
}

doSignIn方法里有个细节值得注意:setBit返回的是该位的旧值。如果旧值为true,说明今天已经签过到,直接返回失败,天然实现了签到幂等性,不需要额外的去重逻辑。

getContinuousDays的循环最多执行31次,即使全部命中也只是31次Redis往返。如果对性能有更高要求,可以把整个BitMap一次性读出来在内存中做位运算,一次网络往返搞定。连续签到跨月的场景(比如5月31日签完,6月1日继续签)需要额外查询上一个月的key,把两个BitMap的结果拼接起来再计算,代码逻辑稍微复杂一些但思路完全一致。

生产环境中的常见坑与优化建议

第一个坑是offset设计错误。有的团队图省事直接用“一年中的第几天”作为offset,一个key存一整年。这样虽然减少了key的数量,但统计“本月签到天数”就需要按位截取,而且当年的key会占用46个字节还不算大,可一旦把offset设计成时间戳除以86400这种全局天数,几年后offset会达到几千甚至几万,单个key的字节数虽然也不算夸张,但失去了按月清理的便利性。按月拆分是最均衡的方案。

第二个坑是时区问题。服务器时间、Redis服务器时间、用户实际所在时区三者可能不一致。一个乌鲁木齐的用户在北京时间23点50分签到,如果按UTC计算日期可能已经跨天。严谨的做法是在接口层接收用户时区参数,统一用用户本地时间计算“当月第几天”,避免签到日期错乱引发客诉。

第三个坑是重复设置TTL。上面的代码每次签到都会调用expire,这本身没有问题,但要清楚Redis的expire会刷新过期时间。如果你希望key严格在一年后的自然日过期,需要自行判断是否首次设置。另外,如果业务需要补签功能,BitMap同样支持,直接SETBIT历史日期对应的offset即可,但补签是否计入连续签到要由业务规则决定。

最后谈一下存储成本的直观对比:1000万用户、每人一个key、每个key 4个字节左右,总内存占用不到50MB。同样的数据放MySQL里,哪怕只存用户ID和日期两个整型字段,加上索引和行开销,轻松超过1GB。这也是BitMap在签到、活跃用户统计(DAU)、布隆过滤器等场景被广泛使用的根本原因——用极小的空间换取极高的统计效率。如果你的项目里还没有引入这套方案,不妨从签到模块开始实践。

Redis BitMap用户签到签到统计修改时间:2026-09-10 11:03:16

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0910/53977.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。