Redis 用户签到

本文最后更新于:2024年3月18日 凌晨

Redis 用户签到

MySQL

  • 如果使用关系型数据库来实现签到功能,核心表(user_sign)如下:
字段名 描述
id 数据表主键(AUTO_INCREMENT)
user_id 用户ID
sign_date 签到日期(如 2021-03-09)
amount 连续签到天数
  • 签到:插入一条记录,并更新连续签到天数。

  • 查询:根据签到日期查询。

  • 统计:根据 amount 统计。

  • 如果这样存数据的话,对于用户量比较大的应用,数据库可能就扛不住了,比如 1000W 用户,一天一条,那么一个月就是 3 亿条数据,这是非常庞大的,在这样的体量下,肯定会有性能瓶颈而需要优化,最关键的是这种数据它本身就不是重要数据,存储在关系型数据库费钱(存储成本)又费力(优化成本)

  • 插入一条签到记录以后,根据下面这条 SQL 可以查看特定数据库特定表的数据部分大小,索引部分大小和总占用磁盘大小。

1
2
3
4
5
6
7
8
9
10
11
SELECT
a.table_schema,
a.table_name,
concat( round( sum( DATA_LENGTH / 1024 / 1024 ) + sum( INDEX_LENGTH / 1024 / 1024 ), 2 ), 'MB' ) total_size,
concat( round( sum( DATA_LENGTH / 1024 / 1024 ), 2 ), 'MB' ) AS data_size,
concat( round( sum( INDEX_LENGTH / 1024 / 1024 ), 2 ), 'MB' ) AS index_size
FROM
information_schema.TABLES a
WHERE
a.table_schema = 'example'
AND a.table_name = 'user_sign';
  • 根据查询结果我们做一个简单的计算:
    • 1 个用户签到一天会产生 0.02MB 数据。
    • 每个月都按 30 天来计算的话,1 个用户连续签到一个月会产生 0.60MB 数据。
    • 1 个用户连续签到一年会产生 7.20MB 数据。
    • 1000W 用户连续签到一年会产生 7200W MB 数据(7200W MB ÷ 1024 ÷ 1024 ≈ 68.66TB)

Redis

  • 如果使用 Redis 来做这件事,上述问题都会迎刃而解:
    • 体量问题(支持单机,主从,集群分片,提供冗余及自动故障转移,可以动态添加或移除节点)
    • 成本问题(可以利用 BitMap 位图来存储签到数据,节省空间)
    • 性能问题(底层 C 编写,性能优秀)
    • 学习问题(部署简单,Java 程序员配合 Spring Data 轻松上手)

BitMap

  • BitMap 叫位图,它不是 Redis 的基本数据类型(String,Hash,List,Set,Stored Set),而是基于 String 数据类型的按位操作,高阶数据类型的一种,BitMap 支持的最大位数是 2^32 位,使用 512M 内存就可以存储多达 42.9 亿的字节信息(2^32 = 4,294,967,296)
  • 它是由一组 bit 位组成的,每个 bit 位对应 0 和 1 两个状态,虽然内部还是采用 String 类型存储,但 Redis 提供了一些指令用于直接操作位图,可以把它看作是一个 bit 数组,数组的下标就是偏移量,它的优点是内存开销小,效率高且操作简单,很适合用于签到这类场景。
  • 比如按月进行存储,一个月最多 31 天,那么我们将该月用户的签到缓存二进制就是 00000000000000000000000000000000,当用户某天签到时将 0 改成 1 即可,而且 Redis 提供对 BitMap 的很多操作比如存储,获取,统计等指令,使用起来非常方便,
命令 功能 参数
SETBIT 指定偏移量 bit 位置设置值 key offset value[0 =< offset < 2^32]
GETBIT 查询指定偏移位置的 bit 值 key offset
BITCOUNT 统计指定区间被设置为 1 的 bit 数 key [start end]
BITFIELD 操作多字节位域 key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP|SAT|FAIL]
BITPOS 查询指定区间第一个被设置为 1 或者 0 的 bit 位 key bit [start] [end]
  • 考虑到每月初需要重置连续签到次数,最简单的方式是按用户每月存一条签到数据(也可以每年存一条数据),Redis Key 的格式为 user:sign:userId:yyyyMM,Value 则采用长度为 4 个字节(32位)的位图(因为最大月份只有 31 天),位图的每一位代表一天的签到,1 表示已签到,0 表示未签到,从高位插入,也就是说左边位是开始日期
  • 例如 user:sign:5:202103 表示用户 id=5 的用户在 2021 年 3 月的签到记录,那么:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 202131号签到
127.0.0.1:6379> SETBIT user:sign:5:202103 0 1
(integer) 0

# 202132号签到
127.0.0.1:6379> SETBIT user:sign:5:202103 1 1
(integer) 0

# 202133号签到
127.0.0.1:6379> SETBIT user:sign:5:202103 2 1
(integer) 0

# 获取202133号签到情况
127.0.0.1:6379> GETBIT user:sign:5:202103 2
(integer) 1

# 获取202134号签到情况
127.0.0.1:6379> GETBIT user:sign:5:202103 3
(integer) 0

# 统计20213月签到次数
127.0.0.1:6379> BITCOUNT user:sign:5:202103
(integer) 3

# 获取20213月首次签到(返回索引)
127.0.0.1:6379> BITPOS user:sign:5:202103 1
(integer) 0

# 获取20213月前3天签到情况,返回7,二进制111,意味着前三天都签到了
127.0.0.1:6379> BITFIELD user:sign:5:202103 get u3 0
(integer) 7
  • 使用 BitMap 以后我们再做一个简单的计算:
    • 1 个用户连续签到一月会产生 31bit 大约 4byte 数据。
    • 1 个用户签到一年会产生 48byte 数据。
    • 1000W 用户签到一年会产生 48000W byte 数据(48000W byte ÷ 1024 ÷ 1024 ≈ 457.76MB)

准备工作

pom.xml

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置文件

application.yml 配置 Redis 服务器相关信息。

1
2
3
4
5
6
7
spring:
redis:
host: 192.168.10.101 # Redis 服务器地址。
port: 6379 # Redis 服务器端口。
password: 123456 # Redis 服务器密码。
timeout: 3000 # 连接超时时间。
database: 0 # 几号库。

Redis 配置类

  • RedisTemplate 序列化默认使用 JdkSerializationRedisSerializer 存储二进制字节码,为了方便使用,自定义序列化策略。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Configuration
@EnableCaching
public class RedisConfig {

@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
//Json的序列化配置。
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jackson2JsonRedisSerializer.setObjectMapper(om);
//String的序列化配置。
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
// key采用String的序列化方式。
template.setKeySerializer(stringRedisSerializer);
// hash的key也采用String的序列化方式。
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式采用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
// hash的value序列化方式采用jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}

@Bean
@ConditionalOnMissingBean(StringRedisTemplate.class)
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}

功能开发

  • 该功能主要用于存储用户的签到数据至 Redis,无需实体类,我们只需要知道用户 ID 即可,用于构建 Redis Key

签到(补签)

  • 用户签到,默认当天,可以通过传入日期补签(比如拉新活动赠送额外签到次数),返回用户连续签到次数和总签到次数(如果后续有积分规则,再返回用户此次签到后的积分情况),

SignService.java

业务逻辑层主要关注以下细节:

  • 根据日期获取当前是多少号,不传入日期默认当天(使用 BITSET 操作时注意:offset 从 0 开始计算,0 就代表 1 号)
  • Redis Key:user:sign:用户ID:月份 用户签到信息按月存储。
  • 判断用户是否签到(GETBIT user:sign:5:202103 0:获取用户2021年03月01日签到情况)
  • 用户签到(SETBIT user:sign:5:202103 0 1:用户2021年03月01日进行签到)返回用户连续签到次数,获取用户从当前日期开始到 1 号的所有签到状态,然后进行位移操作,返回连续签到天数(BITFIELD user:sign:5:202103 GET u31 0:获取用户2021年03月01日到31日的签到情况)
  • 返回用户总签到次数(BITCOUNT user:sign:5:202103 0 31)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
@Service
public class SignService {

@Resource
private RedisTemplate redisTemplate;

/**
* 用户签到,可以补签。
*
* @param userId 用户ID
* @param dateStr 查询的日期,默认当天 yyyy-MM-dd
* @return 连续签到次数和总签到次数。
*/
public Map<String, Object> doSign(Integer userId, String dateStr) {
Map<String, Object> result = new HashMap<>();
// 获取日期。
Date date = getDate(dateStr);
// 获取日期对应的天数,多少号。
int day = DateUtil.dayOfMonth(date) - 1; // 从 0 开始。
// 构建 Redis Key
String signKey = buildSignKey(userId, date);
// 查看指定日期是否已签到。
boolean isSigned = redisTemplate.opsForValue().getBit(signKey, day);
if (isSigned) {
result.put("message", "当前日期已完成签到,无需再签");
result.put("code", 400);
return result;
}
// 签到。
redisTemplate.opsForValue().setBit(signKey, day, true);
// 根据当前日期统计签到次数。
Date today = new Date();
// 统计连续签到次数。
int continuous = getContinuousSignCount(userId, today);
// 统计总签到次数。
long count = getSumSignCount(userId, today);
result.put("message", "签到成功");
result.put("code", 200);
result.put("continuous", continuous);
result.put("count", count);
return result;
}

/**
* 统计连续签到次数。
*
* @param userId 用户ID
* @param date 查询的日期。
* @return
*/
private int getContinuousSignCount(Integer userId, Date date) {
// 获取日期对应的天数,多少号,假设是 31
int dayOfMonth = DateUtil.dayOfMonth(date);
// 构建 Redis Key
String signKey = buildSignKey(userId, date);
// e.g. bitfield user:sign:5:202103 u31 0
BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
.valueAt(0);
// 获取用户从当前日期开始到 1 号的所有签到状态。
List<Long> list = redisTemplate.opsForValue().bitField(signKey, bitFieldSubCommands);
if (list == null || list.isEmpty()) {
return 0;
}
// 连续签到计数器。
int signCount = 0;
long v = list.get(0) == null ? 0 : list.get(0);
// 位移计算连续签到次数。
for (int i = dayOfMonth; i > 0; i--) {// i 表示位移操作次数。
// 右移再左移,如果等于自己说明最低位是 0,表示未签到。
if (v >> 1 << 1 == v) {
// 用户可能当前还未签到,所以要排除是否是当天的可能性。
// 低位 0 且非当天说明连续签到中断了。
if (i != dayOfMonth) break;
} else {
// 右移再左移,如果不等于自己说明最低位是 1,表示签到。
signCount++;
}
// 右移一位并重新赋值,相当于把最低位丢弃一位然后重新计算。
v >>= 1;
}
return signCount;
}

/**
* 统计总签到次数。
*
* @param userId 用户ID
* @param date 查询的日期。
* @return
*/
private Long getSumSignCount(Integer userId, Date date) {
// 构建 Redis Key
String signKey = buildSignKey(userId, date);
// e.g. BITCOUNT user:sign:5:202103
return (Long) redisTemplate.execute(
(RedisCallback<Long>) con -> con.bitCount(signKey.getBytes())
);
}

/**
* 获取日期。
*
* @param dateStr yyyy-MM-dd
* @return
*/
private Date getDate(String dateStr) {
return StrUtil.isBlank(dateStr) ?
new Date() : DateUtil.parseDate(dateStr);
}

/**
* 构建 Redis Key - user:sign:userId:yyyyMM
*
* @param userId 用户ID
* @param date 日期。
* @return
*/
private String buildSignKey(Integer userId, Date date) {
return String.format("user:sign:%d:%s", userId, DateUtil.format(date, "yyyyMM"));
}

}

SignController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@RequestMapping("sign")
public class SignController {

@Resource
private SignService signService;

/**
* 用户签到,可以补签。
*
* @param userId 用户ID
* @param dateStr 查询的日期,默认当天 yyyy-MM-dd
* @return 连续签到次数和总签到次数。
*/
@PostMapping
public Map<String, Object> doSign(Integer userId, String dateStr) {
return signService.doSign(userId, dateStr);
}

}

获取当天签到情况

  • 为了增强用户体验,初始化个人中心页面时我们获取一下当天的签到情况以及连续签到次数和总签到次数返回页面显示。

SignService.java

  • 提供 String dateStr 参数是为了让该方法适用于多种场景,比如查看指定日期的签到情况。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 获取用户当天签到情况。
*
* @param userId 用户ID
* @param dateStr 查询的日期,默认当天 yyyy-MM-dd
* @return 当天签到情况,连续签到次数和总签到次数。
*/
public Map<String, Object> getSignByDate(Integer userId, String dateStr) {
Map<String, Object> result = new HashMap<>();
// 获取日期。
Date date = getDate(dateStr);
// 获取日期对应的天数,多少号。
int day = DateUtil.dayOfMonth(date) - 1; // 从 0 开始。
// 构建 Redis Key
String signKey = buildSignKey(userId, date);
// 查看是否已签到。
boolean isSigned = redisTemplate.opsForValue().getBit(signKey, day);
// 根据当前日期统计签到次数。
Date today = new Date();
// 统计连续签到次数。
int continuous = getContinuousSignCount(userId, today);
// 统计总签到次数。
long count = getSumSignCount(userId, today);
result.put("today", isSigned);
result.put("continuous", continuous);
result.put("count", count);
return result;
}

SignController.java

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取用户当天签到情况。
*
* @param userId 用户ID
* @param dateStr 查询的日期,默认当天 yyyy-MM-dd
* @return 当天签到情况,连续签到次数和总签到次数。
*/
@GetMapping("today")
public Map<String, Object> getSignByDate(Integer userId, String dateStr) {
return signService.getSignByDate(userId, dateStr);
}

获取当月签到情况

  • 为了增强用户体验,我们还需在日历元素中显示当前用户的签到情况,将已签到的日期标记为✅,

SignService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* 获取用户当月签到情况。
*
* @param userId 用户ID
* @param dateStr 查询的日期,默认当月 yyyy-MM
* @return
*/
public Map<String, Object> getSignInfo(Integer userId, String dateStr) {
// 获取日期。
Date date = getDate(dateStr);
// 构建 Redis Key
String signKey = buildSignKey(userId, date);
// 构建一个自动排序的 Map
Map<String, Object> signInfo = new TreeMap<>();
// 获取某月的总天数(考虑闰年)
int dayOfMonth = DateUtil.lengthOfMonth(DateUtil.month(date) + 1,
DateUtil.isLeapYear(DateUtil.dayOfYear(date)));
// e.g. bitfield user:sign:5:202103 u31 0
BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
.valueAt(0);
// 获取用户从当前日期开始到 1 号的所有签到数据。
List<Long> list = redisTemplate.opsForValue().bitField(signKey, bitFieldSubCommands);
if (list == null || list.isEmpty()) {
return signInfo;
}
long v = list.get(0) == null ? 0 : list.get(0);
// 从低位到高位进行遍历,为 0 表示未签到,为 1 表示已签到。
for (int i = dayOfMonth; i > 0; i--) {
/*
Map 存储格式:
签到:yyyy-MM-01 "✅"
未签到:yyyy-MM-02 不做任何处理。
*/
// 获取日期。
LocalDateTime localDateTime = LocalDateTimeUtil.of(date).withDayOfMonth(i);
// 右移再左移,如果不等于自己说明最低位是 1,表示已签到。
boolean flag = v >> 1 << 1 != v;
// 如果已签到,添加标记。
if (flag) {
signInfo.put(DateUtil.format(localDateTime, "yyyy-MM-dd"), "✅");
}
// 右移一位并重新赋值,相当于把最低位丢弃一位然后重新计算。
v >>= 1;
}
return signInfo;
}

SignController.java

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取用户当月签到情况。
*
* @param userId 用户ID
* @param dateStr 查询的日期,默认当月 yyyy-MM
* @return
*/
@GetMapping
public Map<String, Object> getSignInfo(Integer userId, String dateStr) {
return signService.getSignInfo(userId, dateStr);
}

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!