用户签到功能实现方案:让签到如此简单高效!✅

83 阅读8分钟

标题: 签到还在用MySQL?Redis Bitmap来救场!
副标题: 从连续签到到月度统计,打造高性能签到系统


🎬 开篇:一次签到功能的性能瓶颈

某APP签到功能上线:

第1天:100万用户签到
MySQL:每次INSERT一条记录
数据库:压力山大 💀
响应时间:3秒

第7天:500万用户
MySQL:3500万条签到记录
查询连续签到天数:需要扫描7天数据
响应时间:10秒 💥
数据库:崩溃!

改用Redis Bitmap后:
存储空间:500万用户 × 365天 = 228MB
查询速度:50ms ⚡
连续签到统计:O(1) 🚀

效果:
- 存储空间:缩小99%
- 查询速度:提升200倍
- 数据库压力:降低100倍

老板:这才对嘛! 😊

教训:签到功能天生适合用Bitmap!

🤔 为什么用Redis Bitmap?

传统方案(MySQL):

-- 每次签到插入一条记录
INSERT INTO sign_log (user_id, sign_date) VALUES (123, '2025-01-15');

-- 查询连续签到需要复杂SQL
SELECT COUNT(*) FROM sign_log 
WHERE user_id = 123 
AND sign_date >= '2025-01-01';

问题:

  • 存储空间大(每条记录约20字节)
  • 查询慢(需要扫描多条记录)
  • 写入压力大(高并发插入)

Bitmap方案:

  • 1个用户1年签到 = 365 bit = 46字节
  • 查询O(1)时间复杂度
  • 支持高并发

📚 知识地图

用户签到系统
├── 🎯 核心功能
│   ├── 每日签到
│   ├── 连续签到统计
│   ├── 补签功能
│   ├── 签到奖励
│   └── 签到排行榜
├── 💾 存储方案
│   ├── Redis Bitmap(推荐)⭐⭐⭐⭐⭐
│   ├── MySQL ⭐⭐
│   └── Redis String ⭐⭐⭐
├── ⚡ 高级功能
│   ├── 连续签到奖励翻倍
│   ├── 月度签到统计
│   ├── 签到提醒
│   └── 分享赚积分
└── 📊 性能优化
    ├── 批量查询
    ├── 缓存预热
    ├── 异步统计
    └── 定时任务

⚡ 方案1:Redis Bitmap(推荐)

1. 核心实现

/**
 * 签到服务(Redis Bitmap实现)
 */
@Service
@Slf4j
public class SignInService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private SignInRewardService rewardService;
    
    /**
     * ⚡ 用户签到
     */
    public SignInResult signIn(Long userId) {
        LocalDate today = LocalDate.now();
        
        // 1. 检查今天是否已签到
        if (hasSignedToday(userId, today)) {
            throw new BusinessException("今天已经签到过了");
        }
        
        // 2. ⚡ 签到(设置Bitmap位)
        String key = buildSignKey(userId, today);
        int offset = today.getDayOfMonth() - 1;  // 当月第几天(0-30)
        
        redisTemplate.opsForValue().setBit(key, offset, true);
        
        // 3. 统计连续签到天数
        int continuousDays = getContinuousSignDays(userId);
        
        // 4. 发放签到奖励
        SignInReward reward = rewardService.grantReward(userId, continuousDays);
        
        // 5. 返回结果
        SignInResult result = new SignInResult();
        result.setSuccess(true);
        result.setSignDate(today);
        result.setContinuousDays(continuousDays);
        result.setReward(reward);
        
        log.info("用户签到成功:userId={}, date={}, continuousDays={}", 
            userId, today, continuousDays);
        
        return result;
    }
    
    /**
     * ⚡ 检查今天是否已签到
     */
    public boolean hasSignedToday(Long userId, LocalDate date) {
        String key = buildSignKey(userId, date);
        int offset = date.getDayOfMonth() - 1;
        
        Boolean result = redisTemplate.opsForValue().getBit(key, offset);
        
        return result != null && result;
    }
    
    /**
     * ⚡ 获取连续签到天数
     */
    public int getContinuousSignDays(Long userId) {
        LocalDate today = LocalDate.now();
        int continuousDays = 0;
        
        // 从今天往前查,直到遇到未签到的日期
        LocalDate checkDate = today;
        
        while (true) {
            if (hasSignedToday(userId, checkDate)) {
                continuousDays++;
                checkDate = checkDate.minusDays(1);
                
                // 最多统计365天
                if (continuousDays >= 365) {
                    break;
                }
            } else {
                break;
            }
        }
        
        return continuousDays;
    }
    
    /**
     * ⚡ 获取本月签到天数
     */
    public int getMonthSignCount(Long userId, YearMonth yearMonth) {
        LocalDate firstDay = yearMonth.atDay(1);
        String key = buildSignKey(userId, firstDay);
        
        // ⚡ BITCOUNT统计1的个数
        Long count = redisTemplate.execute((RedisCallback<Long>) connection -> {
            return connection.bitCount(key.getBytes());
        });
        
        return count != null ? count.intValue() : 0;
    }
    
    /**
     * ⚡ 获取本月签到详情(哪几天签到了)
     */
    public List<Integer> getMonthSignDetails(Long userId, YearMonth yearMonth) {
        List<Integer> signDays = new ArrayList<>();
        
        LocalDate firstDay = yearMonth.atDay(1);
        int daysInMonth = yearMonth.lengthOfMonth();
        
        String key = buildSignKey(userId, firstDay);
        
        // 查询每一天的签到状态
        for (int day = 1; day <= daysInMonth; day++) {
            Boolean signed = redisTemplate.opsForValue().getBit(key, day - 1);
            
            if (signed != null && signed) {
                signDays.add(day);
            }
        }
        
        return signDays;
    }
    
    /**
     * ⚡ 补签
     */
    public void makeUpSign(Long userId, LocalDate date) {
        // 1. 检查是否可以补签
        if (date.isAfter(LocalDate.now())) {
            throw new BusinessException("不能补签未来的日期");
        }
        
        if (date.isBefore(LocalDate.now().minusDays(7))) {
            throw new BusinessException("只能补签最近7天的记录");
        }
        
        // 2. 检查是否已签到
        if (hasSignedToday(userId, date)) {
            throw new BusinessException("该日期已签到,无需补签");
        }
        
        // 3. 扣除补签卡或积分
        // rewardService.consumeMakeUpCard(userId);
        
        // 4. ⚡ 补签
        String key = buildSignKey(userId, date);
        int offset = date.getDayOfMonth() - 1;
        
        redisTemplate.opsForValue().setBit(key, offset, true);
        
        log.info("用户补签成功:userId={}, date={}", userId, date);
    }
    
    /**
     * ⚡ 获取签到日历(整月)
     */
    public SignInCalendarVO getSignCalendar(Long userId, YearMonth yearMonth) {
        SignInCalendarVO calendar = new SignInCalendarVO();
        calendar.setYear(yearMonth.getYear());
        calendar.setMonth(yearMonth.getMonthValue());
        
        // 获取本月签到详情
        List<Integer> signDays = getMonthSignDetails(userId, yearMonth);
        calendar.setSignDays(signDays);
        
        // 统计本月签到天数
        calendar.setSignCount(signDays.size());
        
        // 连续签到天数
        calendar.setContinuousDays(getContinuousSignDays(userId));
        
        return calendar;
    }
    
    /**
     * 构建签到Key
     * 格式:sign:userId:202501
     */
    private String buildSignKey(Long userId, LocalDate date) {
        return String.format("sign:%d:%d%02d", 
            userId, date.getYear(), date.getMonthValue());
    }
}

/**
 * 签到结果VO
 */
@Data
public class SignInResult {
    
    /**
     * 是否成功
     */
    private Boolean success;
    
    /**
     * 签到日期
     */
    private LocalDate signDate;
    
    /**
     * 连续签到天数
     */
    private Integer continuousDays;
    
    /**
     * 签到奖励
     */
    private SignInReward reward;
}

/**
 * 签到日历VO
 */
@Data
public class SignInCalendarVO {
    
    /**
     * 年份
     */
    private Integer year;
    
    /**
     * 月份
     */
    private Integer month;
    
    /**
     * 签到的日期列表(1-31)
     */
    private List<Integer> signDays;
    
    /**
     * 本月签到天数
     */
    private Integer signCount;
    
    /**
     * 连续签到天数
     */
    private Integer continuousDays;
}

2. 签到奖励系统

/**
 * 签到奖励服务
 */
@Service
@Slf4j
public class SignInRewardService {
    
    @Autowired
    private UserPointsService pointsService;
    
    /**
     * ⚡ 发放签到奖励(连续签到奖励递增)
     */
    public SignInReward grantReward(Long userId, int continuousDays) {
        SignInReward reward = new SignInReward();
        
        // 基础积分
        int basePoints = 10;
        
        // ⚡ 连续签到奖励递增
        int bonusPoints = calculateBonus(continuousDays);
        
        int totalPoints = basePoints + bonusPoints;
        
        // 发放积分
        pointsService.addPoints(userId, totalPoints, "签到奖励");
        
        reward.setPoints(totalPoints);
        reward.setBasePoints(basePoints);
        reward.setBonusPoints(bonusPoints);
        reward.setMessage(buildRewardMessage(continuousDays, totalPoints));
        
        log.info("发放签到奖励:userId={}, continuousDays={}, points={}", 
            userId, continuousDays, totalPoints);
        
        return reward;
    }
    
    /**
     * 计算连续签到奖励
     */
    private int calculateBonus(int continuousDays) {
        if (continuousDays >= 30) {
            return 100;  // 连续30天:额外100积分
        } else if (continuousDays >= 15) {
            return 50;   // 连续15天:额外50积分
        } else if (continuousDays >= 7) {
            return 20;   // 连续7天:额外20积分
        } else if (continuousDays >= 3) {
            return 5;    // 连续3天:额外5积分
        } else {
            return 0;    // 无额外奖励
        }
    }
    
    /**
     * 构建奖励消息
     */
    private String buildRewardMessage(int continuousDays, int points) {
        if (continuousDays >= 30) {
            return String.format("恭喜!连续签到%d天,获得%d积分!", continuousDays, points);
        } else if (continuousDays >= 7) {
            return String.format("太棒了!连续签到%d天,获得%d积分!", continuousDays, points);
        } else {
            return String.format("签到成功!获得%d积分", points);
        }
    }
}

/**
 * 签到奖励VO
 */
@Data
public class SignInReward {
    
    /**
     * 总积分
     */
    private Integer points;
    
    /**
     * 基础积分
     */
    private Integer basePoints;
    
    /**
     * 奖励积分
     */
    private Integer bonusPoints;
    
    /**
     * 奖励消息
     */
    private String message;
}

3. 签到统计

/**
 * 签到统计服务
 */
@Service
@Slf4j
public class SignInStatisticsService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * ⚡ 统计今日签到人数
     */
    public long getTodaySignCount() {
        LocalDate today = LocalDate.now();
        String key = "sign:stat:" + today.toString();
        
        // 使用HyperLogLog统计
        Long count = redisTemplate.opsForHyperLogLog().size(key);
        
        return count != null ? count : 0;
    }
    
    /**
     * 记录今日签到用户
     */
    public void recordTodaySign(Long userId) {
        LocalDate today = LocalDate.now();
        String key = "sign:stat:" + today.toString();
        
        // 添加到HyperLogLog
        redisTemplate.opsForHyperLogLog().add(key, String.valueOf(userId));
        
        // 设置过期时间(保留7天)
        redisTemplate.expire(key, 7, TimeUnit.DAYS);
    }
    
    /**
     * ⚡ 签到排行榜(连续签到天数)
     */
    public List<SignInRankVO> getSignRank(int topN) {
        String key = "sign:rank:continuous";
        
        // 使用ZSet存储排行榜
        Set<ZSetOperations.TypedTuple<String>> rankSet = 
            redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, topN - 1);
        
        if (rankSet == null || rankSet.isEmpty()) {
            return Collections.emptyList();
        }
        
        return rankSet.stream()
            .map(tuple -> {
                SignInRankVO rank = new SignInRankVO();
                rank.setUserId(Long.parseLong(tuple.getValue()));
                rank.setContinuousDays(tuple.getScore().intValue());
                return rank;
            })
            .collect(Collectors.toList());
    }
    
    /**
     * 更新用户排行榜
     */
    public void updateUserRank(Long userId, int continuousDays) {
        String key = "sign:rank:continuous";
        
        redisTemplate.opsForZSet().add(key, String.valueOf(userId), continuousDays);
        
        // 只保留前1000名
        Long count = redisTemplate.opsForZSet().zCard(key);
        if (count != null && count > 1000) {
            redisTemplate.opsForZSet().removeRange(key, 0, count - 1001);
        }
    }
}

/**
 * 签到排行VO
 */
@Data
public class SignInRankVO {
    
    private Long userId;
    private String userName;
    private String avatar;
    private Integer continuousDays;
    private Integer rank;
}

📊 性能对比

Redis Bitmap vs MySQL

/**
 * 性能对比测试
 */
@SpringBootTest
public class SignInPerformanceTest {
    
    @Test
    public void testStorageSize() {
        // MySQL方案:
        // 1000万用户 × 365天 × 20字节/条 = 73GB
        
        // Bitmap方案:
        // 1000万用户 × 365bit ÷ 8 ÷ 1024 ÷ 1024 = 432MB
        
        // 节省空间:99.4%
    }
    
    @Test
    public void testQuerySpeed() {
        // MySQL方案:查询连续签到
        // SELECT COUNT(*) FROM sign_log 
        // WHERE user_id = ? AND sign_date >= ?
        // 耗时:~100ms
        
        // Bitmap方案:
        // 连续GETBIT查询
        // 耗时:~1ms
        
        // 性能提升:100倍
    }
}

⚡ 高级功能

1. 签到提醒

/**
 * 签到提醒任务
 */
@Component
@Slf4j
public class SignInReminderTask {
    
    @Autowired
    private SignInService signInService;
    
    @Autowired
    private MessageSendService messageSendService;
    
    /**
     * ⚡ 每天晚上8点提醒未签到用户
     */
    @Scheduled(cron = "0 0 20 * * ?")
    public void remindUnsignedUsers() {
        log.info("开始发送签到提醒");
        
        // 查询今天未签到的活跃用户
        List<Long> unsignedUsers = queryUnsignedActiveUsers();
        
        // 批量发送提醒消息
        for (Long userId : unsignedUsers) {
            messageSendService.sendMessage(userId, "SIGN_REMINDER", null);
        }
        
        log.info("签到提醒发送完成:count={}", unsignedUsers.size());
    }
    
    private List<Long> queryUnsignedActiveUsers() {
        // TODO: 查询活跃用户列表,过滤已签到用户
        return new ArrayList<>();
    }
}

2. 月度签到报告

/**
 * 月度签到报告
 */
@Service
public class MonthlySignReportService {
    
    @Autowired
    private SignInService signInService;
    
    /**
     * ⚡ 生成月度签到报告
     */
    public MonthlySignReportVO generateMonthlyReport(Long userId, YearMonth yearMonth) {
        MonthlySignReportVO report = new MonthlySignReportVO();
        
        // 本月签到天数
        int signCount = signInService.getMonthSignCount(userId, yearMonth);
        report.setSignCount(signCount);
        
        // 本月应签到天数
        int totalDays = yearMonth.lengthOfMonth();
        report.setTotalDays(totalDays);
        
        // 签到率
        double signRate = (double) signCount / totalDays * 100;
        report.setSignRate(Math.round(signRate * 100) / 100.0);
        
        // 签到详情
        List<Integer> signDays = signInService.getMonthSignDetails(userId, yearMonth);
        report.setSignDays(signDays);
        
        // 连续签到最长天数
        report.setMaxContinuousDays(calculateMaxContinuous(signDays));
        
        // 获得总积分
        report.setTotalPoints(signCount * 10);  // 简化计算
        
        return report;
    }
    
    /**
     * 计算最长连续签到天数
     */
    private int calculateMaxContinuous(List<Integer> signDays) {
        if (signDays.isEmpty()) {
            return 0;
        }
        
        Collections.sort(signDays);
        
        int maxContinuous = 1;
        int currentContinuous = 1;
        
        for (int i = 1; i < signDays.size(); i++) {
            if (signDays.get(i) == signDays.get(i - 1) + 1) {
                currentContinuous++;
                maxContinuous = Math.max(maxContinuous, currentContinuous);
            } else {
                currentContinuous = 1;
            }
        }
        
        return maxContinuous;
    }
}

/**
 * 月度签到报告VO
 */
@Data
public class MonthlySignReportVO {
    
    /**
     * 签到天数
     */
    private Integer signCount;
    
    /**
     * 总天数
     */
    private Integer totalDays;
    
    /**
     * 签到率
     */
    private Double signRate;
    
    /**
     * 签到详情
     */
    private List<Integer> signDays;
    
    /**
     * 最长连续签到
     */
    private Integer maxContinuousDays;
    
    /**
     * 获得总积分
     */
    private Integer totalPoints;
}

✅ 最佳实践

用户签到系统最佳实践:

1️⃣ 存储方案:
   □ 首选Redis Bitmap(空间小、速度快)
   □ MySQL作为备份(数据持久化)
   □ 按月分Key(便于管理)
   
2️⃣ 功能设计:
   □ 连续签到奖励递增
   □ 补签功能(限制天数)
   □ 签到提醒(定时推送)
   □ 签到日历(可视化)
   
3️⃣ 性能优化:
   □ Bitmap批量查询
   □ HyperLogLog统计人数
   □ ZSet排行榜
   □ 缓存预热
   
4️⃣ 用户体验:
   □ 签到动画
   □ 奖励提示
   □ 排行榜展示
   □ 月度报告
   
5️⃣ 防刷机制:
   □ 设备指纹
   □ IP限制
   □ 异常检测
   □ 人机验证

🎉 总结

用户签到系统核心:

1️⃣ Redis Bitmap:空间节省99%,速度提升1002️⃣ 连续签到:统计算法O(1)复杂度
3️⃣ 奖励递增:激励用户持续签到
4️⃣ 月度统计:BITCOUNT一键统计
5️⃣ 排行榜:ZSet实时排名

记住:签到功能天生适合用Bitmap!


文档编写时间:2025年10月24日
作者:热爱用户增长的签到工程师
版本:v1.0
愿每个用户都坚持签到!