下面提供一个基于 Java BitSet 的完整用户签到系统设计方案,涵盖需求分析、核心思路、关键代码与测试示例。
一、设计思路
1. 需求定义
- 用户每天可以签到一次,重复签到不会覆盖或重复计数。
- 支持查询任意用户在某一天是否已签到。
- 统计某个月份的签到总天数。
- 查询截至某一天的连续签到天数(按自然日连续,中间不能断开)。
2. 存储模型
使用 Map<Long, BitSet> 存储每个用户的签到记录:
- Key:用户ID(Long)
- Value :
BitSet,其中每个 bit 代表一天。- 位索引 = 从「基准日期」到签到日期的天数差。
- 基准日期固定为
2020-01-01,也可以调整为系统上线日期。 - 优点:支持任意未来日期,BitSet 会自动扩容,空间利用率高。
内存估算 :
假设系统运行 10 年(约 3652 天),每个用户的 BitSet 约 3652 bits ≈ 457 字节。
1000 万活跃用户 → 约 4.3 GB 内存,可接受(实际生产会使用 Redis + Bitmap,但本例展示内存实现)。
3. 核心操作时间复杂度
- 签到/查询:O(1)
- 月签到统计:O(当月天数)
- 连续签到统计:最坏 O(连续天数) (实际会提前终止)
4. 线程安全
使用 ConcurrentHashMap 存储,对每个用户的 BitSet 进行读/写时加锁(synchronized),避免并发修改异常。
二、完整 Java 代码
java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 基于 BitSet 的用户签到系统
* 功能:签到、检查、月签到统计、连续签到天数
*/
public class SignInService {
// 基准日期:系统上线日,所有签到天数偏移基于这一天
private static final LocalDate BASE_DATE = LocalDate.of(2020, 1, 1);
// 存储每个用户的签到位图
private final Map<Long, BitSet> userSignMap = new ConcurrentHashMap<>();
/**
* 用户签到
* @param userId 用户ID
* @param date 签到日期
* @return 是否签到成功(若当天已签到返回false)
*/
public boolean signIn(Long userId, LocalDate date) {
// 不能签未来的到
if (date.isAfter(LocalDate.now())) {
throw new IllegalArgumentException("不能签未来的日期");
}
int offset = getOffset(date);
BitSet bitSet = userSignMap.computeIfAbsent(userId, k -> new BitSet());
synchronized (bitSet) {
if (bitSet.get(offset)) {
return false; // 已签到
}
bitSet.set(offset);
return true;
}
}
/**
* 检查用户在某天是否已签到
*/
public boolean isSigned(Long userId, LocalDate date) {
BitSet bitSet = userSignMap.get(userId);
if (bitSet == null) {
return false;
}
int offset = getOffset(date);
synchronized (bitSet) {
return bitSet.get(offset);
}
}
/**
* 获取用户某个月的签到总天数
* @param userId 用户ID
* @param year 年份
* @param month 月份 (1-12)
*/
public int getMonthlySignCount(Long userId, int year, int month) {
BitSet bitSet = userSignMap.get(userId);
if (bitSet == null) {
return 0;
}
LocalDate firstDay = LocalDate.of(year, month, 1);
LocalDate lastDay = firstDay.withDayOfMonth(firstDay.lengthOfMonth());
int startOffset = getOffset(firstDay);
int endOffset = getOffset(lastDay);
int count = 0;
synchronized (bitSet) {
for (int offset = startOffset; offset <= endOffset; offset++) {
if (bitSet.get(offset)) {
count++;
}
}
}
return count;
}
/**
* 获取用户截至某一天的连续签到天数(包含当天)
* 连续定义:从当天向前追溯,直到第一个未签到的日期为止
* @param userId 用户ID
* @param date 截止日期
*/
public int getContinuousDays(Long userId, LocalDate date) {
BitSet bitSet = userSignMap.get(userId);
if (bitSet == null) {
return 0;
}
int continuous = 0;
LocalDate cur = date;
while (true) {
int offset = getOffset(cur);
synchronized (bitSet) {
if (!bitSet.get(offset)) {
break;
}
}
continuous++;
cur = cur.minusDays(1);
}
return continuous;
}
// 计算日期相对于基准日期的偏移量(天数)
private int getOffset(LocalDate date) {
return (int) ChronoUnit.DAYS.between(BASE_DATE, date);
}
// ---------- 测试 Demo ----------
public static void main(String[] args) {
SignInService service = new SignInService();
Long userId = 10086L;
// 签到示例
LocalDate today = LocalDate.now();
LocalDate yesterday = today.minusDays(1);
LocalDate twoDaysAgo = today.minusDays(2);
LocalDate threeDaysAgo = today.minusDays(3);
service.signIn(userId, threeDaysAgo); // 3天前签到
service.signIn(userId, yesterday); // 昨天签到
service.signIn(userId, today); // 今天签到
// 查询某天是否签到
System.out.println("今天是否签到? " + service.isSigned(userId, today));
System.out.println("两天前是否签到? " + service.isSigned(userId, twoDaysAgo));
// 月签到统计(当前月份)
int monthCount = service.getMonthlySignCount(userId, today.getYear(), today.getMonthValue());
System.out.println("本月累计签到天数: " + monthCount);
// 连续签到天数(截至今天)
int continuous = service.getContinuousDays(userId, today);
System.out.println("连续签到天数: " + continuous);
// 再签一天(明天不能签,演示会抛异常)
// service.signIn(userId, today.plusDays(1)); // 抛出 IllegalArgumentException
}
}
代码说明
| 方法 | 实现要点 |
|---|---|
signIn() |
计算偏移量,对 BitSet 加锁后设置位,返回是否新签到。 |
isSigned() |
获取 BitSet,加锁后检查指定位。 |
getMonthlySignCount() |
通过月份首尾日期计算偏移范围,遍历累加。 |
getContinuousDays() |
从指定日期向前循环检查,直到遇到未签到日停止。 |
getOffset() |
利用 ChronoUnit.DAYS.between 计算与基准日期的天数差。 |
三、扩展与优化建议
1. 替换 BitSet 为 RoaringBitmap(应对稀疏或超长周期)
- 当用户生命周期长达几十年(如 50 年 ≈ 18262 天),普通 BitSet 内存约 2.2KB/用户,仍可接受。
- 若出现天窗 (用户极少签到),可改用 RoaringBitmap 压缩存储,但对连续天数查询性能略有影响。
2. 持久化与生产部署
- 内存版仅适合单机演示或极小型应用。生产环境 推荐:
- Redis Bitmap (
SETBIT/GETBIT/BITCOUNT/BITPOS)天然支持位操作,且可持久化、集群。 - 数据库定期将 Redis 数据备份到 MySQL/HBase,用于离线分析。
- Redis Bitmap (
3. 连续天数查询优化
目前最坏情况遍历连续天数(通常 ≤ 365 天),性能无问题。如需极高并发,可对每个用户额外维护一个连续签到计数器 (例如在签到当天更新 user:sign:streak:userId),做到 O(1) 查询。
4. 跨月/跨年连续签到
上述 getContinuousDays 基于日期减法,自动处理跨月、跨年,无需额外逻辑。
5. 日活统计
利用 BitSet 的 cardinality() 方法可以快速统计某天全局签到人数,但需遍历所有用户的 BitSet,适合定时任务而非实时。
四、运行示例输出(假设当前日期 2026-05-22)
今天是否签到? true
两天前是否签到? false
本月累计签到天数: 3
连续签到天数: 2 // 注意:3天前签过,但前天未签,所以截至今天连续是 昨天+今天 = 2
(根据实际签到日期,输出可能略有不同)
以上代码完整实现了基于 BitSet 的用户签到系统,清晰展示了位图在海量布尔状态存储中的优势,可直接复制运行验证。