逅弈 转载请注明原创出处,谢谢!
搜索需求
业务中有一些常用的搜索需求,其中一个是相关用户的搜索。即输入一个昵称,返回一批匹配的相关用户。
搜索的输入与输出
输入:根据current_uid,query_name搜索相关的用户
输出:返回用户的uid,nickname和headpic等信息
对返回结果的要求
需要进行分页,并且返回的结果需要按照一定的排序规则排序后返回,其中排序值越小排序越靠前,排序规则如下:
order number | order rule |
---|---|
1 | 完全匹配 |
2 | 匹配中自己 |
3 | 匹配中我的好友(和我相互关注的用户),按粉丝数由多到少排序 |
4 | 匹配中我关注的用户,按粉丝数由多到少排序 |
5 | 匹配中达人用户,按粉丝数由多到少排序 |
6 | 匹配中其他普通用户,按粉丝数由多到少排序 |
需求分析
该查询按照要返回的用户类型来说属于两个不同的查询,按照需要召回的结果集来分也属于两种不同的结果集:
- 和我相关的用户
- 和我不相关的用户
面临的问题
1.无法在一次查询中返回两种不同的结果,这里所说的一次查询是指查询mysql或者es的过程
2.由于需要对结果按照一定的规则进行排序,而存储引擎无法在一次查询中完成所有规则的排序
3.像我的好友
,我关注的用户
这种规则不是一个可以度量的值,比如粉丝数是一个可度量的值,可以按照这个粉丝数进行排序,但是不能按照我的好友
进行排序
4.分页只能对一个结果集进行操作,无法对不同的结果集进行全局的分页
5.线上用户量比较大,共有一千八百万用户,因此无法将各种不同规则的结果一次性查询到内存中进行排序后分页返回,因为这会很容易造成创建了大量的对象导致内存溢出
6.用户表,用户关注表,用户达人表分属于三张独立的表,并且分在不同的库中,因此不方便通过直接查询数据库,并且直接查数据库效率也比较低下,一个like查询都很慢
对于这种复杂的搜索需求,直接查数据库肯定是不行了,一方面是数据量大查询效率低,另一方面数据分散在不同的库中操作起来比较麻烦。而我们对于这种查询的需求通常都借助于一些搜索引擎,比如sphinx、solr、elasticsearch等等。而我们系统中应用了两种不同的搜索引擎:sphinx和elasticsearch,在不同的业务场景下使用不同的搜索引擎。对于这个用户搜索的业务场景,我们使用了elasticsearch。
但是即便使用es搜索,对于这样的排序规则,也还是需要通过拆分的方式来简化搜索的复杂性。
ES索引的确定
首先建立如下的ES索引:
{
"s_uid" : "用户id",
"s_nickname" : "昵称",
"s_headpic" : "头像",
"s_talent" : "是否是达人,1:是,0:否",
"s_followers_number" : "粉丝数",
"s_following" : [ // 关注的用户
{
"t_uid" : "用户id",
"t_nickname" : "昵称",
"t_headpic" : "头像",
"t_talent" : "是否是达人,1:是,0:否",
"t_followers_number" : "粉丝数",
"t_follow_back" : "是否互相关注,1:是,0:否"
}
]
}
其中s_following节点是每个用户所关注的用户,通过一个数组来保存,可以用该子文档来匹配我的好友
,我关注的用户
,只需要指定s_uid等于输入的uid就可以了,其他的直接匹配主文档。
将该查询分解为多个查询:
1.完全匹配(规则1)
query ==> s_nickname equals query_name
return ==> s_uid
2.匹配自己(规则2)
query ==> (s_uid=current_uid && s_nickname match query_name)
return ==> s_uid
3.匹配和我相关的用户(规则3、4)
query ==> (s_uid=current_uid && t_nickname match query_name)
order by ==> t_follow_back desc,t_followers_number desc
return ==> t_uid
4.匹配其他用户(规则5、6)
query ==> (s_uid!=current_uid && s_nickname match query_name)
order by ==> s_talent desc,s_followers_number desc
return ==> s_uid
虽然查询通过拆分进行了简化,但是对于客户端来说,他们是不知道该接口内部是怎么操作的,什么时候查询什么规则的数据返回对客户端来说是透明的。但是服务端又需要通过客户端来告知他当前需要查询什么规则的数据了,因此这是一个需要客户端和服务端协同的接口。
接口定义
接口的请求参数定义如下:
field | description | required | default |
---|---|---|---|
uid | 当前用户id | true | no default |
queryName | 查询的用户昵称 | true | no default |
rule | 当前查询的规则 | true | 1 |
page | 当前要查询的页数 | true | 1 |
pageSize | 每页的数据量 | true | 10 |
接口的返回值结构定义如下:
field | description |
---|---|
rule | 下次要查询的规则 |
page | 下次要查询的页数 |
data | 匹配的数据 |
其中rule和page由服务端计算好之后返回给客户端,客户端下次查询时带上。
简化后的查询共有4个rule,接口默认从rule1
开始开始scan,一直scan到rule4
,得到满足条件的数据后即返回,整个过程可以用伪代码表示如下:
cnt = 0;
offset = (page-1) * pageSize;
limit = pageSize;
data = array;
calculated = false;
// 查询规则rule下的数据
result = query(rule,offset,limit);
cnt += result.data.length;
data.put(result.data);
// 如果当前查询到的数据不足一个pageSize,则继续查询更多的数据
while(cnt<pageSize){
// 如果当前rule中还有数据,则查询下一页
if(result.hasMoreData()){
page++;
}else if(hasNextRule(rule)){
rule++;
// 查询下一个rule的数据,page从第一页开始
page = 1;
}else{
page = 0;
calculated = true;
break;
}
result = query(rule,offset,limit);
cnt += result.data.length;
data.put(result.data);
}
if(!calculated){
if(result.hasMoreData()){
page++;
}else if(hasNextRule(rule)){
rule++;
page = 1;
}else{
// 当返回page=0时,表示没有更多的数据了
page = 0;
}
}
return {
"rule" : rule,
"page" : page,
"data" : data
};
为了简单起见,查询数据时对要查询的size做了补齐操作。
假设pageSize为10时,当rule1和rule2都匹配了1条数据,按理说查询rule3时,只需要查询size=8
条数据,达到总的返回数为一个pageSize即可。但是如果这样做的话,下一次再查询rule3时,offset将从上一次查询的第8条数据之后开始,这样就增加了系统的复杂性,需要记住上一次查询的offset。所以为了简单起见,当查询的size不足一个pageSize时,也补齐一个为pageSize,这样offset就可以默认为(page-1) * pageSize
。
关于ES索引的更新
对于索引的更新一般有两种方式:
- 全量更新
- 增量更新
对于数据量比较小的业务数据,建议全量更新,因为操作简单,数据一致性也能得到保证。但是对于数据量非常大的数据,一般通过全量更新+增量更新的方式结合使用。
全量更新
我们目前是在每天的凌晨0:00:00进行全量索引的重建,重建的逻辑是:
1.删除因停机而产生的临时索引
2.创建一个临时索引
3.将数据写入临时索引中
4.索引替换:将老索引的索引名加到新索引上,并删除老索引
这种方式的好处是不会影响线上的搜索服务,是通过临时索引replace老的索引的方式。
目前对于一千八百万的用户数据,在10个线程下,重建一次全量的索引大概耗时700秒。
增量更新
增量更新是每10分钟执行一次,更新的策略是,每次更新完之后在redis中纪录一个lastUpdateTime,下次更新时,查询出update_time大于lastUpdateTime的纪录进行更新即可,如下面的查询语句:
select u.uid,u.nickname from user u where update_time>#{lastUpdateTime}
但是这种方式的缺点是,对于直接删除纪录而非逻辑删除的表,无法得到删除的纪录。但是对于用户表这样的业务数据来说,删除操作基本上发生的概率很低,只有注销时才会出现,而一般也很少有系统提供用户账户注销的功能。
如果非要考虑删除的情况下增量更新的话,可以通过查看该纪录的逻辑删除标志位来判断,该条纪录是否已经被删除了。
关于其他索引结构的思考
对于索引的创建可以有不同的实现方式,每个人都可以根据实际的实现方式创建不同的索引。
例如可以将用户和关注的用户都建在主文档中,如下面这种结构:
{
"s_uid" : "用户id",
"s_nickname" : "昵称",
"s_headpic" : "头像",
"s_talent" : "是否是达人,1:是,0:否",
"s_followers_number" : "粉丝数",
"t_uid" : "用户id",
"t_nickname" : "关注的用户的昵称",
"t_headpic" : "关注的用户的头像",
"t_talent" : "关注的用户是否是达人,1:是,0:否",
"t_followers_number" : "关注的用户的粉丝数",
"t_follow_back" : "是否互相关注,1:是,0:否"
}
这种方式会存在数据冗余,如A关注了B,A关注了C,B关注了C,则会出现以下的冗余数据:
s_uid t_uid
A B
A C
B C
除此之外,也可以通过将用户和用户关系分开建索引的方式,这种方式就跟数据库中表的存储保持了一致。
以上是我对于这个用户搜索需求的一个优化和思考的过程,如果您有更好的实现方式,欢迎不吝指教。
![](https://img.haomeiwen.com/i5417792/f0b1dcdbbfebcbad.jpg)
网友评论