场景描述:Java项目,用户签到得积分,积分有过期时间(一年),消费积分(从积分商城购买物品会花积分)的时候优先使用快过期的积分,使用Bitmap记录签到,连续签到可以获得更多积分,规则如下,第1天1分,第2天2分。。。直到第10天达到最大10分,后面连续签到都是保持10积分,如果有一天中断则重新从1分开始叠加。
要实现该需求,需结合Redis和MySQL,核心设计如下:
1. 签到功能(Redis Bitmap)
-
键设计:
sign:{userId}:{yearMonth}(例:sign:1001:202506) -
操作:
// 用户签到(返回连续签到天数) public int sign(int userId) { LocalDate today = LocalDate.now(); String key = "sign:" + userId + ":" + today.format(DateTimeFormatter.ofPattern("yyyyMM")); int dayOfMonth = today.getDayOfMonth() - 1; // Bitmap偏移量(0-indexed) // 1. 检查今日是否已签到 if (Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, dayOfMonth))) { throw new BusinessException("今日已签到"); } // 2. 标记今日签到 redisTemplate.opsForValue().setBit(key, dayOfMonth, true); // 3. 计算连续签到天数(含今日) int continuousDays = calculateContinuousDays(userId, today); // 4. 发放积分(规则:第1天1分,第2天2分...第10天起10分) int points = Math.min(continuousDays, 10); addPoints(userId, points, today); // 积分入库(见下文) return continuousDays; } -
连续签到计算:
private int calculateContinuousDays(int userId, LocalDate today) { int days = 1; // 今日已签,从1开始 LocalDate date = today.minusDays(1); // 昨日开始检查 while (date != null) { String key = "sign:" + userId + ":" + date.format(DateTimeFormatter.ofPattern("yyyyMM")); int offset = date.getDayOfMonth() - 1; // 检查该日是否签到(跨月需切换key) if (offset >= 0 && Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, offset))) { days++; date = date.minusDays(1); } else { break; } } return days; }
2. 积分管理(MySQL + Redis)
积分记录表 user_points
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| user_id | BIGINT | 用户ID |
| points | INT | 积分数量 |
| expire_time | DATETIME | 过期时间(签到时间+1年) |
| type | TINYINT | 类型(1:获得 2:使用) |
| create_time | DATETIME | 创建时间 |
积分过期策略
- 存储:每次签到生成一条记录,
expire_time = 当前时间 + 1年。 - 消费规则:优先使用最早过期的积分(FIFO)。
3. 积分消费(优先快过期)
public void consumePoints(int userId, int pointsToUse) {
// 1. 查询可用积分(按过期时间升序)
List<UserPoints> pointsList = userPointsMapper.selectAvailablePoints(userId);
// 2. 校验总积分
int totalPoints = pointsList.stream().mapToInt(UserPoints::getPoints).sum();
if (totalPoints < pointsToUse) throw new BusinessException("积分不足");
// 3. 优先扣除快过期的积分
int remaining = pointsToUse;
for (UserPoints up : pointsList) {
if (remaining <= 0) break;
if (up.getPoints() > remaining) {
// 部分扣除:当前记录拆分
userPointsMapper.deductPoints(up.getId(), remaining);
userPointsMapper.insert(up.copyWithPoints(up.getPoints() - remaining)); // 剩余部分新记录
remaining = 0;
} else {
// 整条扣除
remaining -= up.getPoints();
userPointsMapper.deleteById(up.getId());
}
}
// 4. 记录消费流水(略)
}
SQL查询(按过期时间排序):
SELECT * FROM user_points
WHERE user_id = #{userId}
AND expire_time > NOW()
AND type = 1
ORDER BY expire_time ASC;
4. 关键细节
-
跨月签到计算:
- 在
calculateContinuousDays()中处理跨月逻辑,自动切换Redis Key。 - 使用
LocalDate.minusDays(1)回溯日期并检查Bitmap。
- 在
-
积分过期清理:
- 定时任务每日删除
expire_time < NOW()的记录。 - 消费积分时自动跳过已过期记录。
- 定时任务每日删除
-
并发控制:
- 签到:用Redis分布式锁(
LOCK:sign:{userId})防止重复提交。 - 消费:数据库乐观锁(版本号)或SELECT FOR UPDATE。
- 签到:用Redis分布式锁(
-
性能优化:
- Bitmap压缩:每月一个Key,仅占31位(4字节)。
- 积分查询缓存:用Redis缓存用户总积分(更新时删除缓存)。
架构图
+--------+ 签到 +------------+ 记录积分 +------------+
| 用户端 | ---------> | Redis服务 | -----------> | MySQL |
+--------+ (Bitmap) +------------+ (积分明细) +------------+
| ^
| | 查询连续签到
| |
v |
+------------+
| 应用服务器 |
+------------+
| ^
| | 消费请求
v |
+-----------+ 扣减积分 +------------+
| 积分商城 | -----------> | MySQL |
+-----------+ (优先过期) +------------+
此设计确保:
- 签到高效(Bitmap O(1)操作)
- 积分管理符合业务规则(优先过期)
- 连续签到计算准确(跨月兼容)