Spring Boot + Redis BitMap:高性能签到系统实战

Spring Boot + Redis BitMap:高性能签到系统实战

下面我将介绍如何使用 Spring BootRedis BitMap 数据结构实现用户签到与统计功能,同时结合高并发、跨月统计、补签和排行榜等优化方案。

Spring Boot + Redis BitMap:高性能签到系统实战


1. 项目概述

我们将实现以下功能:

  • 用户每日签到
  • 检查某日签到状态
  • 统计本月签到次数
  • 获取本月连续签到天数(支持跨月)
  • 查看某月签到情况
  • 支持补签功能
  • 支持签到排行榜与奖励扩展

2. 技术栈

  • Spring Boot 2.x
  • Redis + Redisson 客户端
  • Maven

3. 实现步骤

3.1 添加依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.16.8</version>
    </dependency>
</dependencies>

3.2 配置 Redis 连接

spring:
  redis:
    host: localhost
    port: 6379
    database: 0

3.3 核心工具类实现

import org.redisson.api.RBitSet;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@Component
public class SignService {

    @Autowired
    private RedissonClient redissonClient;

    private static final DateTimeFormatter KEY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");

    /**
     * 用户签到
     */
    public boolean sign(Long userId, LocalDate date) {
        String key = buildKey(userId, date);
        int offset = date.getDayOfMonth() - 1;
        RBitSet bitSet = redissonClient.getBitSet(key);
        if (bitSet.get(offset)) {
            return false; // 已签到
        }
        bitSet.set(offset);
        return true;
    }

    /**
     * 补签功能
     */
    public boolean retroactiveSign(Long userId, LocalDate date) {
        return sign(userId, date); // 与签到逻辑一致
    }

    /**
     * 检查某日是否签到
     */
    public boolean checkSign(Long userId, LocalDate date) {
        String key = buildKey(userId, date);
        int offset = date.getDayOfMonth() - 1;
        RBitSet bitSet = redissonClient.getBitSet(key);
        return bitSet.get(offset);
    }

    /**
     * 获取当月签到次数
     */
    public long getSignCount(Long userId, LocalDate date) {
        String key = buildKey(userId, date);
        RBitSet bitSet = redissonClient.getBitSet(key);
        return bitSet.cardinality();
    }

    /**
     * 获取连续签到天数(支持跨月)
     */
    public int getContinuousSignCount(Long userId, LocalDate date) {
        int count = 0;
        LocalDate current = date;

        while (true) {
            String key = buildKey(userId, current);
            RBitSet bitSet = redissonClient.getBitSet(key);

            int day = current.getDayOfMonth() - 1;
            boolean signed = bitSet.get(day);

            if (!signed) break;
            count++;

            current = current.minusDays(1);
            if (current.getDayOfMonth() == current.lengthOfMonth()) {
                // 跨月继续统计
                continue;
            }
        }
        return count;
    }

    /**
     * 获取当月签到详情
     */
    public List<Boolean> getSignInfo(Long userId, LocalDate date) {
        String key = buildKey(userId, date);
        YearMonth yearMonth = YearMonth.from(date);
        int daysInMonth = yearMonth.lengthOfMonth();

        RBitSet bitSet = redissonClient.getBitSet(key);

        return IntStream.range(0, daysInMonth)
                .mapToObj(bitSet::get)
                .collect(Collectors.toList());
    }

    /**
     * 构建 Redis 键
     */
    private String buildKey(Long userId, LocalDate date) {
        return String.format("sign:%d:%s", userId, date.format(KEY_FORMATTER));
    }
}

3.4 控制器实现

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;
import java.util.List;

@RestController
@RequestMapping("/sign")
public class SignController {

    @Autowired
    private SignService signService;

    @PostMapping("/{userId}")
    public String sign(@PathVariable Long userId,
                       @RequestParam(required = false) 
                       @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {
        if (date == null) date = LocalDate.now();
        boolean result = signService.sign(userId, date);
        return result ? "签到成功" : "签到失败或已签到";
    }

    @PostMapping("/{userId}/retroactive")
    public String retroactiveSign(@PathVariable Long userId,
                                  @RequestParam 
                                  @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {
        boolean result = signService.retroactiveSign(userId, date);
        return result ? "补签成功" : "补签失败";
    }

    @GetMapping("/{userId}/check")
    public boolean checkSign(@PathVariable Long userId,
                             @RequestParam 
                             @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {
        return signService.checkSign(userId, date);
    }

    @GetMapping("/{userId}/count")
    public long getSignCount(@PathVariable Long userId,
                             @RequestParam 
                             @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {
        return signService.getSignCount(userId, date);
    }

    @GetMapping("/{userId}/continuous")
    public int getContinuousSignCount(@PathVariable Long userId,
                                      @RequestParam(required = false) 
                                      @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {
        if (date == null) date = LocalDate.now();
        return signService.getContinuousSignCount(userId, date);
    }

    @GetMapping("/{userId}/info")
    public List<Boolean> getSignInfo(@PathVariable Long userId,
                                     @RequestParam 
                                     @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {
        return signService.getSignInfo(userId, date);
    }
}

4. 功能测试

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDate;
import java.util.List;

@SpringBootTest
public class SignServiceTest {

    @Autowired
    private SignService signService;

    @Test
    public void testSignFeatures() {
        Long userId = 1001L;
        LocalDate today = LocalDate.now();

        System.out.println("签到结果: " + signService.sign(userId, today));
        System.out.println("检查签到: " + signService.checkSign(userId, today));
        System.out.println("本月签到次数: " + signService.getSignCount(userId, today));
        System.out.println("连续签到天数: " + signService.getContinuousSignCount(userId, today));
        List<Boolean> info = signService.getSignInfo(userId, today);
        System.out.println("本月签到详情: " + info);
    }
}

5. 原理解析

5.1 BitMap 原理

  • Redis BitMap 是基于 String 类型 的位操作,每个位可存储 0 或 1。
  • 每个用户每月使用一个 BitMap 键:
    • 键名格式:sign:{userId}:{yyyyMM}
    • 位偏移:0 表明当月 1 日,30 表明当月 31 日
  • 优势:
  • 极省空间:31 位≈4 字节存储一个月数据
  • 高效操作:签到、查询、统计均为 O(1)
  • 灵活性强:支持连续签到计算、补签、位统计

5.2 注意事项

  • 位偏移从 0 开始,对应日期需要减 1
  • Redis BitMap 最大偏移量为 2^32-1,日期存储足够
  • 跨月连续签到需统计前一个月最后一天的状态

6. 扩展功能

  1. 补签功能:允许用户补签之前漏签日期
  2. 签到日历:返回图形化签到状态给前端展示
  3. 签到奖励:根据连续签到天数发放奖励
  4. 签到排行榜
  • 使用 Redis Sorted Set 存储每月签到次数:
  • ZINCRBY sign:ranking:{yyyyMM} 1 {userId}
  • 可实时获取本月签到排行
  1. 高并发优化
  • Redis 单线程原子操作已足够
  • 复杂业务可配合 Redisson 分布式锁
  1. 数据维护
  • 设置键过期,如每年清理老数据
  • 定期统计 BitMap 内存占用,防止暴增

7. API 调用示例

功能

请求方式

示例

用户签到

POST

/sign/1001?date=2023-05-15

补签

POST

/sign/1001/retroactive?date=2023-05-10

检查签到

GET

/sign/1001/check?date=2023-05-15

获取当月签到次数

GET

/sign/1001/count?date=2023-05-15

获取连续签到天数

GET

/sign/1001/continuous?date=2023-05-15

获取本月签到详情

GET

/sign/1001/info?date=2023-05-15


8. 总结

通过 Spring Boot + Redis BitMap 的结合,我们实现了一个高效、节省空间、可扩展的签到系统:

  • 高性能:位操作 O(1)
  • 低成本:每用户每月仅需 4 字节存储
  • 易扩展:支持补签、排行榜、奖励、日历

优化提议:

  • 添加缓存预热、分布式锁、备份恢复
  • 监控 BitMap 大小和内存占用
  • 可结合 Sorted Set 或其他 Redis 数据结构扩展排行榜和奖励功能

该方案适合 大规模用户签到场景,性能与存储效率兼顾,方便在实际生产环境中落地。


© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
一粒苹果酒_Hazel的头像 - 鹿快
评论 抢沙发

请登录后发表评论

    暂无评论内容