Redis大key问题与scan命令

简介

前面不是写了一篇点赞功能的一种实现的文章吗

当时也提出了一些问题,今天就来解决其中的部分问题

开始

先讲一讲背景吧,以免没看过之前文章的迷惑

还是以点赞功能为话题,这里主要解决之前存在的大key问题

大key问题

由于Redis主线程为单线程模型,大key也会带来一些问题,如:

1、集群模式在slot分片均匀情况下,会出现数据和查询倾斜情况,部分有大keyRedis节点占用内存多,QPS高。

2、大key相关的删除或者自动过期时,会出现qps突降或者突升的情况,极端情况下,会造成主从复制异常,Redis服务阻塞无法响应请求。

策略

基于之前的设计,这里进行改进

因为点赞属于经常性操作,为了避免频繁操作数据库,这里的策略是:

redis
string key value
news:like:count:%s
新闻点赞数 string前缀:newsId
count
修改数
hash key field value
user:like:news:%s
用户点赞新闻 hash前缀:hashCode取模
%s:%s
userId:newsId
0(未点赞)/1(点赞)
set key value
user:like:news:set
用户点赞操作hashKey集合
user:like:news:%s
hashCode取模
news:like:count:set
新闻点赞数操作newsId集合
%s
newsId

流程图

点赞设计

通过定时任务(使用的JDK自带的ScheduledExecutorService)将redis数据持久化到mysql,后来发现问题,在使用ScheduledExecutorService时,应该是由于在非Spring组件中注入Spring组件导致的空指针异常,所以最后改为使用SpringBoot的定时任务,使用起来很简单,之前定时任务文章也提到了。

点赞会产生非常多数据,做持久化时为了不生成那么多数据,利用了valid字段

代码

下面是部分代码,可以参考一下

Redis工具类,主要定义一些常量和key拼装工具

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
public class RedisUtils {

/**
* 默认key过期时间(s)
*/
public static final Integer DEFAULT_TTL = 300;

/**
* 默认key过期时间(minute)
*/
public static final Integer DEFAULT_TTL_MINUTES = 30;

/**
* 默认key过期时间(day)
*/
public static final Integer DEFAULT_TTL_DAYS = 7;

/**
* 模 256
*/
public static final Integer KEY_MOLD = 1 << 8;

/**
* scan count
*/
public static final Integer SCAN_COUNT = 3000;

public static <P, T> String getKey(P keyPrefix, T id) {
StringBuilder builder = new StringBuilder().append(keyPrefix).append(id);
return builder.toString();
}

public static String getUserLikeNewsKey(Long userId) {
StringBuilder builder = new StringBuilder()
.append(RedisKeyConstants.USER_LIKE_NEWS)
.append(Math.abs(userId.hashCode() & KEY_MOLD - 1));
return builder.toString();
}

public static String getUserLikeNewsField(Long userId, Long newsId) {
StringBuilder builder = new StringBuilder()
.append(userId)
.append(RedisKeyConstants.SPLITTER)
.append(newsId);
return builder.toString();
}
}

下面主要是点赞动作

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
@Service("userLikeService")
public class UserLikeServiceImpl implements UserLikeService {

public static final Logger LOGGER = LoggerFactory.getLogger(UserLikeServiceImpl.class);

private static final String LIKE_STATE = "1";

private static final String UNLIKE_STATE = "0";

@Autowired
StringRedisTemplate stringRedisTemplate;

@Autowired
UserLikeNewsMapper userLikeNewsMapper;

@Override
public void like(Long userId, Long newsId) {

String userLikeNewsKey = RedisUtils.getUserLikeNewsKey(userId);

String userLikeNewsField = RedisUtils.getUserLikeNewsField(userId, newsId);

String newsLikeCountKey = RedisUtils.getKey(RedisKeyConstants.NEWS_LIKE_COUNT, newsId);

// 大key问题
String recordState = (String) stringRedisTemplate.opsForHash().get(userLikeNewsKey, userLikeNewsField);
if (!LIKE_STATE.equals(recordState)) {
// 未点赞,点赞
LOGGER.info("未点赞,点赞");
stringRedisTemplate.opsForHash().put(userLikeNewsKey, userLikeNewsField, LIKE_STATE);
// 操作key记录
stringRedisTemplate.opsForSet().add(RedisKeyConstants.USER_LIKE_NEWS_KEY_SET, userLikeNewsKey);
// 新闻点赞数+1
stringRedisTemplate.opsForValue().increment(newsLikeCountKey);
// 操作key记录
stringRedisTemplate.opsForSet().add(RedisKeyConstants.NEWS_LIKE_COUNT_KEY_SET, String.valueOf(newsId));
}

}

@Override
public void unlike(Long userId, Long newsId) {

String userLikeNewsKey = RedisUtils.getUserLikeNewsKey(userId);

String userLikeNewsField = RedisUtils.getUserLikeNewsField(userId, newsId);

String newsLikeCountKey = RedisUtils.getKey(RedisKeyConstants.NEWS_LIKE_COUNT, newsId);

String recordState = (String) stringRedisTemplate.opsForHash().get(userLikeNewsKey, userLikeNewsField);
if (!UNLIKE_STATE.equals(recordState)) {
// 已点赞,取消点赞
LOGGER.info("已点赞,取消点赞");
stringRedisTemplate.opsForHash().put(userLikeNewsKey, userLikeNewsField, UNLIKE_STATE);
// 操作key记录
stringRedisTemplate.opsForSet().add(RedisKeyConstants.USER_LIKE_NEWS_KEY_SET, userLikeNewsKey);
// 新闻点赞数-1
stringRedisTemplate.opsForValue().decrement(newsLikeCountKey);
// 操作key记录
stringRedisTemplate.opsForSet().add(RedisKeyConstants.NEWS_LIKE_COUNT_KEY_SET, String.valueOf(newsId));
}
}

/**
* 检查用户是否点赞新闻
* 暂未调用
*
* @param userId
* @param newsId
* @return
*/
@Override
public boolean liked(Long userId, Long newsId) {

String userLikeNewsKey = RedisUtils.getUserLikeNewsKey(userId);

String userLikeNewsField = RedisUtils.getUserLikeNewsField(userId, newsId);

String recordState = (String) stringRedisTemplate.opsForHash().get(userLikeNewsKey, userLikeNewsField);
if (Objects.nonNull(recordState)) {
return LIKE_STATE.equals(recordState);
} else {
UserLikeNews userLikeNews = userLikeNewsMapper.selectValidByUserIdAndNewsId(userId, newsId);
return Objects.nonNull(userLikeNews);
}
}
}

scan

最后关于持久化,可以先看下面文章

https://aijishu.com/a/1060000000007477

这篇讲的是利用scan代替keys

然后再看

https://cloud.tencent.com/developer/article/1650002

https://redis.io/commands/scan

这里讲的scan存在的问题

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
@Override
@Transactional(rollbackFor = Exception.class)
@Scheduled(initialDelay = 60 * 1000, fixedDelay = 5 * 60 * 1000)
public void persistUserLikeNews() {
// 定时任务持久化
Set<String> keys = stringRedisTemplate.opsForSet().members(RedisKeyConstants.USER_LIKE_NEWS_KEY_SET);

if (Objects.nonNull(keys)) {
for (String key : keys) {
// TODO cursor 问题
Cursor<Map.Entry<Object, Object>> cursor = stringRedisTemplate.opsForHash().scan(key
, ScanOptions.scanOptions().match("*").count(RedisUtils.SCAN_COUNT).build());
while (cursor.hasNext()) {
Map.Entry<Object, Object> entry = cursor.next();
String likeRecordField = (String) entry.getKey();

UserLikeNews userLikeRecord = getUserLikeNews(likeRecordField);

// ???检查用户和新闻还在不

UserLikeNews userLikeNews = userLikeNewsMapper.selectByUserIdAndNewsId(userLikeRecord.getUserId(), userLikeRecord.getNewsId());
boolean haveRecord = Objects.nonNull(userLikeNews);

String state = (String) entry.getValue();
// 点赞状态
if (LikeConstants.LIKE.equals(state)) {
// 有记录 valid true
if (haveRecord) {
userLikeNews.setValid(true);
userLikeNewsMapper.updateByPrimaryKeySelective(userLikeNews);
} else {
// 无记录 插入
userLikeNewsMapper.insertSelective(userLikeRecord);
}
} else if (LikeConstants.UNLIKE.equals(state)) {
// 取消点赞状态
if (haveRecord) {
// 有记录 valid false
userLikeNews.setValid(false);
userLikeNewsMapper.updateByPrimaryKeySelective(userLikeNews);
}
}
// 删除已持久化的field,问题若出现异常,mysql可以依据事务回滚,但redis不会
stringRedisTemplate.opsForHash().delete(key, likeRecordField);
}
try {
cursor.close();
} catch (IOException e) {
LOGGER.error("cursor关闭失败");
e.printStackTrace();
}
// 判断hashKey中是否还有元素未持久化
if (stringRedisTemplate.opsForHash().size(key) <= 0) {
// 从set中删除
stringRedisTemplate.opsForSet().remove(RedisKeyConstants.USER_LIKE_NEWS_KEY_SET, key);
}
}
}
}

定时任务代码就不贴了,有很多实现方式

总结

许多时候,我们真的需要实践才能得到真理

一开始畅想的很美好,以为实现很简单,等到手去做了,才发现总会遇到一些问题,不是那么顺畅,实践啊~实践啊