如何设计一个用户签到系统,支持连续签到统计?
引言:
在互联网业务中,用户活跃度 是衡量平台粘性的重要指标之一。许多产品通过"签到送积分/奖励"的机制来激励用户每日登录,提升日活。
你是否也曾在业务中遇到这样的需求:设计一个支持连续签到、断签重置、补签、统计连续天数的签到系统?
作为一名有 8年开发经验的 Java 工程师 ,我曾在多个项目中设计过类似系统,今天就和大家聊聊如何从 0 到 1 设计这样一个 既高性能又易扩展 的签到系统。
一、业务需求分析
我们先从产品角度解构这个需求:
1.1 核心功能点
- 用户每日可签到一次
- 支持计算连续签到天数
- 允许断签后重新开始统计
- 签到数据需可查询和展示(如:某月签到日历)
- 支持补签(如:VIP 用户可补签)
1.2 非功能性要求
- 高并发下保证用户签到数据不重复、不丢失
- 数据结构设计需支持快速查询与统计
- 系统应支持横向扩展(高并发、大用户量)
二、技术选型与设计思路
2.1 数据存储选型
我们可选用两种存储方式:
- MySQL:适合持久存储、查询统计
- Redis:适合快速读写、缓存热点数据
2.2 签到数据结构设计
✅ 推荐方案:Redis 位图(bitmap)+ MySQL
- Redis 用 bitmap 存储每日签到情况,快速读写
- MySQL 存储每月签到记录、奖励记录,做持久化
示例:bitmap 存储
-
key:
sign:{userId}:{yyyyMM}
-
value: 二进制位,
1
表示已签到 -
例如:
sign:1001:202506
- 第 1 位是 1 → 表示用户 1001 在 6 月 1 日签到
优势:
- 存储空间小(一个月只需几个字节)
- 查询/统计性能高 (
BITCOUNT
,GETBIT
) - 可通过 Lua 脚本实现原子操作
三、系统设计图(简述)
markdown
用户请求 → Controller → Service
↓ ↓
Redis ← 签到状态查询 / 写入
↓
定时任务 ← MySQL 异步持久化(每日或每周)
四、Java 实现代码示例
4.1 Redis 签到核心逻辑
ini
@Service
public class SignInService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String SIGN_KEY_PREFIX = "sign:";
/**
* 用户签到
*/
public boolean sign(Long userId) {
LocalDate now = LocalDate.now();
int dayOfMonth = now.getDayOfMonth() - 1; // bitmap 从 0 开始
String redisKey = buildSignKey(userId, now);
// 设置今天为已签到
Boolean signed = redisTemplate.opsForValue().getBit(redisKey, dayOfMonth);
if (Boolean.TRUE.equals(signed)) {
// 已签到
return false;
}
redisTemplate.opsForValue().setBit(redisKey, dayOfMonth, true);
return true;
}
/**
* 获取本月签到记录(如:用于日历展示)
*/
public List<Integer> getSignInDays(Long userId, YearMonth month) {
int daysInMonth = month.lengthOfMonth();
String redisKey = buildSignKey(userId, month.atDay(1));
List<Integer> result = new ArrayList<>();
for (int i = 0; i < daysInMonth; i++) {
Boolean signed = redisTemplate.opsForValue().getBit(redisKey, i);
if (Boolean.TRUE.equals(signed)) {
result.add(i + 1);
}
}
return result;
}
/**
* 计算连续签到天数
*/
public int getConsecutiveDays(Long userId) {
LocalDate today = LocalDate.now();
String redisKey = buildSignKey(userId, today);
int dayOfMonth = today.getDayOfMonth();
int count = 0;
for (int i = dayOfMonth - 1; i >= 0; i--) {
Boolean signed = redisTemplate.opsForValue().getBit(redisKey, i);
if (Boolean.TRUE.equals(signed)) {
count++;
} else {
break;
}
}
return count;
}
/**
* 构建 Redis Key:sign:用户ID:年月
*/
private String buildSignKey(Long userId, LocalDate date) {
return SIGN_KEY_PREFIX + userId + ":" + date.format(DateTimeFormatter.ofPattern("yyyyMM"));
}
}
4.2 Controller 示例
less
@RestController
@RequestMapping("/sign")
public class SignInController {
@Autowired
private SignInService signInService;
@PostMapping
public ResponseEntity<String> signIn(@RequestParam Long userId) {
boolean signed = signInService.sign(userId);
return signed ? ResponseEntity.ok("签到成功")
: ResponseEntity.ok("今日已签到");
}
@GetMapping("/calendar")
public ResponseEntity<List<Integer>> getSignCalendar(@RequestParam Long userId) {
YearMonth month = YearMonth.now();
return ResponseEntity.ok(signInService.getSignInDays(userId, month));
}
@GetMapping("/consecutive")
public ResponseEntity<Integer> getConsecutive(@RequestParam Long userId) {
return ResponseEntity.ok(signInService.getConsecutiveDays(userId));
}
}
五、可能的扩展点
- 补签功能:限制次数,补签后标记 bitmap
- 签到奖励:连续签到奖励、等级奖励(结合积分系统)
- 签到排行榜:每月签到次数排序(结合 Redis SortedSet)
- 数据落库:每日异步任务将 Redis 数据持久化到 MySQL
- 签到提醒:结合消息推送系统做未签到提醒
六、总结
- Redis bitmap 是构建签到系统的高效利器,节省空间又性能出众。
- 业务逻辑中应充分考虑幂等性、防刷机制(如接口限流、Token)
- 签到系统虽然简单,但能衍生出很多与用户互动的玩法(抽奖、积分、等级)
最后的话:
一个"简单"的签到系统,其实背后能练习到 高性能读写、数据结构优化、缓存设计、接口幂等、系统扩展性 等多个高级工程能力。
如果你也是一个追求代码优雅与系统设计的 Java 程序员,欢迎点赞收藏,也欢迎留言探讨更多业务系统设计!