在高并发场景下,使用双 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; 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 的位运算命令,配合补签规则校验,即可实现稳定可靠的签到系统。