标题: 签到还在用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%,速度提升100倍
2️⃣ 连续签到:统计算法O(1)复杂度
3️⃣ 奖励递增:激励用户持续签到
4️⃣ 月度统计:BITCOUNT一键统计
5️⃣ 排行榜:ZSet实时排名
记住:签到功能天生适合用Bitmap! ✅
文档编写时间:2025年10月24日
作者:热爱用户增长的签到工程师
版本:v1.0
愿每个用户都坚持签到! ✨