bboyjing's blog

Redis学习笔记十二【使用Redis构建应用程序组件--自动补全】

前面学习了使用Redis来构建应用程序的基本方法以及所需的工具,本章将会学习更多更有用的工具和技术,并使用它们去构建更具规模的Redis应用。现在模拟一个场景,假设有一家叫Fake Game的游戏公司,每天都有好几百万玩家同时在线游戏。代码示例依然位于redis-sample项目的support模块中。

自动补全

在Web领域,自动补全功能大家已经很熟悉了,下面我们用Redis来实现两种自动补全功能,自动补全联系人和通讯录自动补全。

自动补全最近联系人

本节将实现一个用于记录最近联系人的自动补全程序,使用列表记录用户最近联系过的100位玩家。构建最近联系人自动补全列表通常需要对Redis执行如下操作:

  • 添加或者更新一个联系人
    • 如果指定的联系人已经存在,那么从列表中移除他
    • 将指定的联系人添加到最近联系人列表的最前面
    • 对列表进行修剪,只保留列表最前面100位联系人
  • 移除指定联系人
  • 获取自动补全列表并查找匹配的用户
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
//添加或修改联系人
public void addUpdateContact(String user, String contact) {
String acList = "recent:" + user;
stringRedisTemplate.execute(new SessionCallback<List<Object>>() {
public List<Object> execute(RedisOperations operations) throws DataAccessException {
operations.multi();
//移除列表中所有值为contact的元素。
operations.opsForList().remove(acList, 0, contact);
//将联系人添加到列表最前面
operations.opsForList().leftPush(acList, contact);
//保留别表最前面100个元素
operations.opsForList().trim(acList, 0, 99);
return operations.exec();
}
});
}
//删除联系人
public void removeContact(String user, String contact){
stringRedisTemplate.opsForList().remove("recent:" + user, 0, contact);
}
//获取匹配的联系人列表
public List<String> fetchAutocompleteList(String user, String prefix) {
List<String> candidates = stringRedisTemplate.opsForList().range("recent:" + user, 0, -1);
List<String> matches = candidates.stream().
filter(candidate -> candidate.toLowerCase().startsWith(prefix))
.collect(Collectors.toList());
return matches;
}

通讯录自动补全

在前面的自动补全例子中,Redis主要用于记录联系人列表,而非实际地执行自动补全操作。对于比较短的列表来说,这种做法还算可行,但对于非常长的列表来说,仅仅为了找几个元素而获取成千上完个元素,是一种非常浪费的做法。所以必须直接在Redis内部完成查找匹配元素的工作。下面以邮件补全功能为例,假设允许玩家向同一公会的其他玩家发邮件。这里利用有序集合,并且将分值都设为0来存储公会人员邮件列表,当有序集合的分值相同时,有序集合将按照字符串二进制顺序进行排序。为了方便起见,有序集合中的名字只能包含小写英文字母。
生成匹配范围前缀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 在ASCII码中,排在z后面的第一个字符是{,排在a前面的第一个字符时`
* 查找带有abc前缀的单词实际上就是查找介于abb之后和abd之前的字符串
*/
private static final String VALID_CHARACTERS = "`abcdefghijklmnopqrstuvwxyz{";
/**
* 根据给定前缀生成查找范围
* @param prefix
* @return
*/
public String[] findPrefixRange(String prefix) {
int posn = VALID_CHARACTERS.indexOf(prefix.charAt(prefix.length() - 1));
char suffix = VALID_CHARACTERS.charAt(posn > 0 ? posn - 1 : 0);
String start = prefix.substring(0, prefix.length() - 1) + suffix + '{';
String end = prefix + '{';
return new String[]{start, end};
}

加入、退出公会:

1
2
3
4
5
6
7
public void joinGuild( String guild, String user) {
stringRedisTemplate.opsForZSet().add("members:" + guild, user, 0);
}
public void leaveGuild(String guild, String user) {
stringRedisTemplate.opsForZSet().remove("members:" + guild, user);
}

匹配给定前缀列表:

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
public Set<String> autocompleteOnPrefix(String guild, String prefix) {
String[] range = findPrefixRange(prefix);
//防止同时向一个公会成员发邮件,添加uuid作为每次查询的唯一标示
String identifier = UUID.randomUUID().toString();
String start = range[0] + identifier;
String end = range[1] + identifier;
String zsetName = "members:" + guild;
/**
* 插入生成的prefix,包裹需要匹配的元素,此时Redis中的数据结构如下:
* 1) "helen"
* 2) "jack"
* 3) "jd{9ec78310-972f-444e-87bb-17412c8bc0fb"
* 4) "jean"
* 5) "jeannie"
* 6) "jeff"
* 7) "jezz"
* 8) "je{9ec78310-972f-444e-87bb-17412c8bc0fb"
* 9) "zoo"
*/
stringRedisTemplate.opsForZSet().add(zsetName, start, 0);
stringRedisTemplate.opsForZSet().add(zsetName, end, 0);
Set<String> items = null;
while (true){
SessionCallback<List<Object>> sessionCallback = new SessionCallback<List<Object>>() {
public List<Object> execute(RedisOperations operations) throws DataAccessException {
//watch确保有序集合在范围查找过程中发生变化时,进行重试
operations.watch(zsetName);
//获取插入元素的开始位置
int sindex = operations.opsForZSet().rank(zsetName, start).intValue();
//获取插入元素的结束位置
int eindex = operations.opsForZSet().rank(zsetName, end).intValue();
operations.multi();
//删除插入的元素
operations.opsForZSet().remove(zsetName, start);
operations.opsForZSet().remove(zsetName, end);
//获取插入元素之间的匹配值
operations.opsForZSet().range(zsetName, sindex, eindex - 2);
return operations.exec();
}
};
List<Object> results = stringRedisTemplate.execute(sessionCallback);
if (results != null){
items = (Set<String>)results.get(results.size() - 1)
}
}
//若匹配的集合中包含其请求插入的元素,则删除
for (Iterator<String> iterator = items.iterator(); iterator.hasNext(); ){
if (iterator.next().indexOf('{') != -1){
iterator.remove();
}
}
return items;
}

以上例子是通过向有序结合添加元素来创建查找范围,并在取得范围内的元素之后移除之前添加的元素来实现的,这是一种非常有用的技巧,可以用到任何已排序索引上面。不知道是不是因为本人的语言理解能力不行,在看代码之前,一大片文字看下来不知所云,很多时候这一章节就被烦躁地跳过了。此时就要静下心来,一行行debug代码,观察下Redis数据结构的变化,就豁然开朗。上面的代码片段用到了watch命令,随着负载的增加,程序进行重试的次数可能会越来越多,导致资源被白白浪费,下面一节我们将学习下如何通过锁来减少对watch命令的使用。