使用双 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 实现时,SETBITBITCOUNT等命令是原子操作,无需额外加锁即可避免并发冲突(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 的SETBITGETBITBITCOUNTBITOR等命令,封装签到、补签、统计等操作。


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 命令原子性SETBITBITCOUNT等命令均为原子操作,无需额外加锁,避免并发冲突。
  • 批量操作:若需批量查询多个用户的签到状态,可使用 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 的位运算命令,配合补签规则校验,即可实现稳定可靠的签到系统。

相关文章
|
运维
ETCD系列之一:简介
本文介绍etcd使用场景,工作原理。
78002 161
|
数据采集 编解码 数据可视化
三维基因组: Hi-C 差异分析(1)
三维基因组: Hi-C 差异分析(1)
三维基因组: Hi-C 差异分析(1)
|
7月前
|
JSON 安全 Java
支付宝支付实战全攻略
本文详细介绍了基于JDK17的企业级支付宝支付实现方案。首先阐述了支付宝支付的核心参与者、安全机制和支付场景区分,重点分析了RSA2签名验证流程。随后提供了完整的开发指南,包括开放平台配置、Maven环境搭建、数据库设计以及核心工具类封装。文章详细展示了基于MyBatis-Plus的持久层实现和Swagger3接口文档集成,并重点讲解了支付回调处理、退款功能等核心业务逻辑的实现。针对企业级应用场景,特别强调了幂等性设计、高可用方案和常见问题的解决方案,包括异步通知丢失兜底机制和安全加固措施。
675 2
|
11月前
|
存储 数据管理 数据库
数据字典是什么?和数据库、数据仓库有什么关系?
在数据处理中,你是否常困惑于字段含义、指标计算或数据来源?数据字典正是解答这些问题的关键工具,它清晰定义数据的名称、类型、来源、计算方式等,服务于开发者、分析师和数据管理者。本文详解数据字典的定义、组成及其与数据库、数据仓库的关系,助你夯实数据基础。
数据字典是什么?和数据库、数据仓库有什么关系?
|
存储 NoSQL Java
Java中使用redis的bitMap实现签到功能
这个实现示例提供了一种灵活、高效的方式,展示了如何使用Redis来解决现实中的问题。
893 2
|
存储 算法 安全
HashMap的实现原理,看这篇就够了
关注【mikechen的互联网架构】,10年+BAT架构经验分享。深入解析HashMap,涵盖数据结构、核心成员、哈希函数、冲突处理及性能优化等9大要点。欢迎交流探讨。
HashMap的实现原理,看这篇就够了
|
人工智能 边缘计算 云计算
2024.11|云计算行业的商业模式创新方法及实践
截至2024年,全球云计算行业迈入全新阶段,从IaaS到大规模AI模型平台,技术与商业模式不断创新。本文分析全球最新技术进展,探讨云计算商业模式创新策略与实践,解析云服务厂商如何通过技术革新实现价值最大化,推动企业数字化与智能化转型。重点讨论AI与云计算的深度融合、边缘计算与去中心化发展、平台化与生态系统建设,以及数据安全与绿色云计算等关键议题。
1273 30
|
机器学习/深度学习 数据挖掘
|
开发者 异构计算 AI芯片
Colab搞了个大会员,每月50刀训练不掉线,10刀会员:我卑微了?
你以为你充了会员就无敌了?其实上面还有大会员、超级会员、至尊会员……
4373 0
Colab搞了个大会员,每月50刀训练不掉线,10刀会员:我卑微了?
|
XML 存储 数据格式
RAG效果优化:高质量文档解析详解
本文关于如何将非结构化数据(如PDF和Word文档)转换为结构化数据,以便于RAG(Retrieval-Augmented Generation)系统使用。
1688 11