使用双 Bitmap实现签到和补签

简介: 在高并发场景下,采用双 Bitmap 实现签到与补签方案,具有内存占用低、操作效率高、并发安全等优势。通过 Redis 存储 Bitmap 数据,结合位运算实现签到记录与统计,可高效支撑大规模用户签到需求,适用于社交 APP、会员系统等场景。

在高并发场景下,使用双 Bitmap实现签到和补签是一种高效且可行的方案。其核心优势在于内存占用极低、操作效率高(基于位运算),且结合 Redis 等高性能存储可轻松支撑高并发读写。下面从方案可行性分析和代码实现两方面详细说明。

一、方案可行性分析(高并发场景)

1. 核心设计思路

  • Bitmap 原理:用一个二进制位(bit)表示某一天的签到状态(1 = 已签,0 = 未签)。例如,一年 365 天仅需 365 位(约 46 字节)即可存储一个用户的全年签到记录。
  • 双 Bitmap 分工:
  • 主 Bitmap(main_bitmap):记录正常签到(用户当天主动签到)。
  • 补签 Bitmap(supplement_bitmap):记录补签(用户事后补签过期日期)。
  • 统计时通过位或运算(BITOR) 合并两个 Bitmap,得到最终的签到结果(只要主 Bitmap 或补签 Bitmap 中某一位为 1,即视为已签)。

2. 高并发场景下的优势

  • 内存占用极低:
    单个用户全年签到记录仅需约 46 字节,1000 万用户全年数据仅需约 440MB(1000 万 × 46 字节),远低于数据库存储(每条签到记录需至少几十字节),适合 Redis 等内存数据库存储,降低 IO 压力。
  • 操作效率极高:
    签到 / 补签本质是位运算(SETBIT),统计是位计数(BITCOUNT),均为 O (1) 或 O (n) 复杂度(n 为天数,最多 365),操作耗时微秒级,支持每秒数十万次并发。
  • 并发安全易保证:
    基于 Redis 实现时,SETBIT、BITCOUNT等命令是原子操作,无需额外加锁即可避免并发冲突(Redis 单线程模型保证命令串行执行)。
  • 灵活支持补签规则:
    补签 Bitmap 与主 Bitmap 分离,可独立控制补签范围(如仅允许补签 30 天内的日期)、补签次数(如每月最多 3 次),不影响主签到逻辑。

3. 潜在挑战与解决方案

  • 跨年份存储:
    若用户签到记录跨多年,需按年拆分 Bitmap(如main_bitmap:user:10086:2024),避免单 Bitmap 偏移量过大(超过 2^32 位时 Redis 会自动扩容,但效率略降)。
  • 补签与主签冲突:
    同一日期可能既在主 Bitmap 又在补签 Bitmap(如用户当天签到后又补签),通过BITOR合并时会自动去重(1|1=1),不影响统计结果。
  • 数据持久化:
    依赖 Redis 的 RDB/AOF 持久化机制,确保 Bitmap 数据不会因重启丢失(可配置 AOF 每秒刷盘,平衡性能与可靠性)。

二、代码层面实现(基于 Redis + Java)

1. 核心工具类设计

依赖 Redis 的SETBIT、GETBIT、BITCOUNT、BITOR等命令,封装签到、补签、统计等操作。


import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
@Component
public class SignService {
    private final RedisTemplate<String, Object> redisTemplate;
    // Redis键前缀(主签到+补签)
    private static final String MAIN_SIGN_PREFIX = "sign:main:";
    private static final String SUPPLEMENT_SIGN_PREFIX = "sign:supplement:";
    // 补签规则:最多补签30天内的日期,每月最多3次
    private static final int SUPPLEMENT_DAYS_LIMIT = 30;
    private static final int MONTHLY_SUPPLEMENT_LIMIT = 3;
    public SignService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    /**
     * 正常签到(主Bitmap)
     * @param userId 用户ID
     * @param date 签到日期(默认当天)
     * @return 是否签到成功(已签过返回false)
     */
    public boolean sign(Long userId, LocalDate date) {
        LocalDate signDate = date == null ? LocalDate.now() : date;
        int offset = getDayOfYear(signDate); // 计算当年第几天(偏移量)
        String key = getMainSignKey(userId, signDate);
        // 先判断是否已签到(避免重复签到)
        Boolean isSigned = redisTemplate.opsForValue().getBit(key, offset);
        if (Boolean.TRUE.equals(isSigned)) {
            return false; // 已签到
        }
        // 执行签到(设置位为1)
        redisTemplate.opsForValue().setBit(key, offset, true);
        return true;
    }
    /**
     * 补签(补签Bitmap)
     * @param userId 用户ID
     * @param date 补签日期
     * @return 是否补签成功(超出范围/次数返回false)
     */
    public boolean supplementSign(Long userId, LocalDate date) {
        LocalDate today = LocalDate.now();
        // 校验补签日期范围(只能补签今天之前且30天内的日期)
        long daysBetween = ChronoUnit.DAYS.between(date, today);
        if (daysBetween < 1 || daysBetween > SUPPLEMENT_DAYS_LIMIT) {
            return false;
        }
        // 校验当月补签次数(每月最多3次)
        String countKey = "sign:supplement:count:" + userId + ":" + getYearMonth(today);
        Long count = redisTemplate.opsForValue().increment(countKey, 1);
        if (count == null || count > MONTHLY_SUPPLEMENT_LIMIT) {
            redisTemplate.opsForValue().decrement(countKey); // 回滚计数
            return false;
        }
        // 执行补签(设置补签Bitmap的位为1)
        int offset = getDayOfYear(date);
        String key = getSupplementSignKey(userId, date);
        redisTemplate.opsForValue().setBit(key, offset, true);
        return true;
    }
    /**
     * 统计用户某段时间内的总签到天数(合并主签+补签)
     * @param userId 用户ID
     * @param start 开始日期
     * @param end 结束日期
     * @return 总签到天数
     */
    public long countSignDays(Long userId, LocalDate start, LocalDate end) {
        // 若跨年份,需分段统计(此处简化为同一年份)
        if (start.getYear() != end.getYear()) {
            throw new IllegalArgumentException("暂不支持跨年份统计");
        }
        int startOffset = getDayOfYear(start);
        int endOffset = getDayOfYear(end);
        int bitCount = endOffset - startOffset + 1;
        String mainKey = getMainSignKey(userId, start);
        String supplementKey = getSupplementSignKey(userId, start);
        String tempKey = "sign:temp:" + userId + ":" + System.currentTimeMillis();
        try {
            // 合并主签和补签Bitmap(BITOR)
            redisTemplate.getConnectionFactory().getConnection().bitOp(
                BitOperation.OR,
                tempKey.getBytes(),
                mainKey.getBytes(),
                supplementKey.getBytes()
            );
            // 统计合并后范围内的1的个数
            return redisTemplate.opsForValue().bitCount(tempKey, startOffset, endOffset);
        } finally {
            // 删除临时Key
            redisTemplate.delete(tempKey);
        }
    }
    // ---------------- 工具方法 ----------------
    /**
     * 计算日期是当年的第几天(作为Bitmap的偏移量,从0开始)
     */
    private int getDayOfYear(LocalDate date) {
        return date.getDayOfYear() - 1; // 1月1日为0,1月2日为1,...,12月31日为364
    }
    /**
     * 获取主签到Redis Key(包含年份)
     */
    private String getMainSignKey(Long userId, LocalDate date) {
        return MAIN_SIGN_PREFIX + userId + ":" + date.getYear();
    }
    /**
     * 获取补签Redis Key(包含年份)
     */
    private String getSupplementSignKey(Long userId, LocalDate date) {
        return SUPPLEMENT_SIGN_PREFIX + userId + ":" + date.getYear();
    }
    /**
     * 获取年月字符串(如2024-05)
     */
    private String getYearMonth(LocalDate date) {
        return date.getYear() + "-" + String.format("%02d", date.getMonthValue());
    }
}

2. 高并发优化点

  • Redis 命令原子性:SETBIT、BITCOUNT等命令均为原子操作,无需额外加锁,避免并发冲突。
  • 批量操作:若需批量查询多个用户的签到状态,可使用 Redis Pipeline 批量执行GETBIT,减少网络往返次数。
  • 缓存日期偏移量:getDayOfYear方法的计算结果可缓存(如本地 ConcurrentHashMap),避免重复计算。
  • 补签次数限制:通过 Redis 的INCR原子计数,确保补签次数统计准确(即使高并发下也不会超限制)。

3. 使用示例

// 正常签到(用户10086签到2024-05-20)
LocalDate signDate = LocalDate.of(2024, 5, 20);
boolean signSuccess = signService.sign(10086L, signDate);
// 补签(用户10086补签2024-05-18)
LocalDate supplementDate = LocalDate.of(2024, 5, 18);
boolean supplementSuccess = signService.supplementSign(10086L, supplementDate);
// 统计2024-05-01至2024-05-31的签到天数
LocalDate start = LocalDate.of(2024, 5, 1);
LocalDate end = LocalDate.of(2024, 5, 31);
long totalDays = signService.countSignDays(10086L, start, end);


三、总结

双 Bitmap 方案在高并发场景下可行性极高,尤其适合用户规模大、签到频率高的场景(如社交 APP、会员系统)。其核心优势是内存高效、操作快速、并发安全,结合 Redis 可轻松支撑百万级 TPS。代码层面通过封装 Redis 的位运算命令,配合补签规则校验,即可实现稳定可靠的签到系统。

相关文章
|
Java Linux 定位技术
企业实战(21)基于Linux下安装部署GeoServer地图服务
企业实战(21)基于Linux下安装部署GeoServer地图服务
1137 0
|
消息中间件 数据采集 缓存
性能调优篇:困扰我半年之久的RocketMQ timeout exception 终于被破解了
性能调优篇:困扰我半年之久的RocketMQ timeout exception 终于被破解了
性能调优篇:困扰我半年之久的RocketMQ timeout exception 终于被破解了
|
人工智能 自然语言处理 数据可视化
两大 智能体框架 Dify vs Langchain 的全面分析,该怎么选?资深架构师 做一个彻底的解密
两大 智能体框架 Dify vs Langchain 的全面分析,该怎么选?资深架构师 做一个彻底的解密
两大 智能体框架 Dify vs Langchain 的全面分析,该怎么选?资深架构师 做一个彻底的解密
|
缓存 负载均衡 Java
2025春招 SpringCloud 面试题汇总
大家好,我是V哥。SpringCloud是面试中的重点,涵盖基础概念、组件细节、高级特性及性能优化等内容。为帮助大家更好地准备2025年的Spring Cloud面试,我整理了一系列常见面试题及答案,涉及服务注册与发现(Eureka)、配置管理(Spring Cloud Config)、负载均衡(Ribbon)、断路器(Hystrix)、微服务网关(Spring Cloud Gateway)等关键知识点。此外,还包括分布式事务管理、链路追踪(Sleuth+Zipkin)、安全性(OAuth2)以及性能优化和实践经验。希望这些内容能助你一臂之力,顺利通过面试。欢迎关注威哥爱编程,全栈之路就你行。
5123 24
|
存储 算法 安全
HashMap的实现原理,看这篇就够了
关注【mikechen的互联网架构】,10年+BAT架构经验分享。深入解析HashMap,涵盖数据结构、核心成员、哈希函数、冲突处理及性能优化等9大要点。欢迎交流探讨。
HashMap的实现原理,看这篇就够了
|
XML 存储 数据格式
RAG效果优化:高质量文档解析详解
本文关于如何将非结构化数据(如PDF和Word文档)转换为结构化数据,以便于RAG(Retrieval-Augmented Generation)系统使用。
1897 11
|
缓存 NoSQL Redis
开发指南064-Caffeine里模式搜索
平台兼容Caffeine和Redis两种缓存模式,大型应用使用Redis,小型应用使用Caffeine
|
缓存 前端开发 JavaScript
前端优化之路:打包文件拆包、增加哈希值
前面对项目打包做了优化处理,但是还不够完美,有两点可继续优化。
|
JSON 网络协议 数据格式
网络协议基础:HTTP请求与响应详解
【7月更文挑战第11天】HTTP协议作为Web通信的核心,其请求与响应机制是理解网络通信的关键。本文详细介绍了HTTP请求与响应的格式、过程以及常用的请求方法,帮助读者更好地理解HTTP协议的工作原理和应用场景。在实际应用中,HTTP协议的可定制性和灵活性使其能够适应多种
1067 2
|
机器学习/深度学习 语音技术
深度学习之音频伪造检测
基于深度学习的音频伪造检测是一个旨在利用深度学习技术识别和检测伪造音频内容的研究领域。
964 0