问题
我负责的项目中,有一个需求:要按照"部门(dept)、地区(area)、员工类型(empType)、自定义分组(group)"这几个维度,来设置"文章(article)"的可见范围。判断条件如下:
ini
flag = (dept & area & empType) || group
当 flag == true 时,员工在前台可以查看该文章。
为了实现这个权限判断的功能,最初的实现方式是:
- 使用用户的(dept、area、empType)信息查询出有权限的文章ID集合 S1
- 使用用户所属的分组(group)查询出有权限的文章ID集合 S2
- S1 和 S2 取并集得到文章ID 集合 S3,使用 S3 查询文章表并分页
这种查询方案,能够满足业务需求,但是随着数据量的增加,性能会越来越差。
问题分析
除了文章表 article 以外,还有文章可见范围表(article_view_scope),该表主要有三个字段:
diff
article_view_scope:
- article_id : 文章ID
- scope_type : 可见范围类型,有 department、empType、area
- scope_value : 可见范围的取值
查询文章ID
查询文章ID集合的SQL如下:
SQL
select a.article_id
from article a
// 员工类型
inner join article_view_scope empvs on empvs.article_id = a.article_id and empvs.scope_type = 'empType'
and empvs.scope_value in
<foreach collection="empTypes" item="id" open="(" separator="," close=")">
#{id}
</foreach>
// 地区
inner join article_view_scope areavs on areavs.article_id = a.article_id and areavs.scope_type = 'area'
and areavs.scope_value in
<foreach collection="cityCodes" item="id" open="(" separator="," close=")">
#{id}
</foreach>
// 员工部门
inner join article_view_scope deptvs on deptvs.article_id = a.article_id and deptvs.scope_type = 'department'
and scope_value in
<foreach collection="deptIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
where a.status = #{status} and a.deleted = 0;
该SQL是用 article 表对 article_view_scope 做了三次联表查询,伪代码如下:
ini
article_iter = iterator_over article
article_row = article_iter.next
while article_iter
# 依次遍历每个 article,使用 article_id 作为参数,查询对应的 article_view_scope
inner_iter1 = iterator over article_view_scope avs1 where avs1.article_id = article.id and avs1.scope_value in ('X1')
inner_iter1_row = inner_iter1.next
if inner_iter1_row
inner_iter2 = iterator over article_view_scope avs2 where avs2.article_id = article.id and avs2.scope_value in ('X2')
inner_iter2_row = inner_iter2.next
if inner_iter2_row
inner_iter3 = iterator over article_view_scope avs3 where avs3.article_id = article.id and avs3.scope_value in ('X3')
inner_iter3_row = inner_iter3.next
if inner_iter3_row
output[article_row.id]
inner_iter3_row = inner_iter3.next
end
end
end
end
在上面的SQL中,需要做一个四层的循环遍历:
- 最外层,遍历所有文章ID
- 内层,使用 artilce id 和 用户身份信息查询 article_view_scope 表中的数据,因为 article_id 已经加了索引,所以内层查询 article_view_scope 很快
对该语句的 EXPLAIN 如下:
与上面的分析一致,内层联表查询都使用了索引,查询性能还可以。
第二步根据用户分组查询文章ID,SQL语句相似,这里不再介绍。
查询文章列表
使用文章ID批量查询文章:
SQL
SELECT *
FROM article
WHERE deleted = 0 and `status` = 'online'
AND article_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
order by last_publish_at DESC LIMT X,10
当 文章 id 量较大的时候,批量查询不会使用索引,而是全表扫描。如下所示:
存在的问题
1、查询文章ID时,联表次数等于可见范围的参数个数,后续增加新的可见范围参数,需要再增加一次联表,随着文章数的增加和联表次数的增加,性能会越来越差。
2、大部分文章都是全部可见(占比 60%~70%),用文章ID批量查询,都没有使用到索引,是全表扫描,随着文章数的增加,查询性能也会越来越差。
为了解决存在的问题,有下面的两种优化方案。
优化方案
1、 使用 ES 或 mongodb 代替 mysql
- 把数据可见范围数据同步到ES,用ES来做复杂条件查询
- 使用 mongodb 保存文章及其可见范围,给可见范围增加索引,做复杂查询
2、 依然使用 mysql,记录每个可见范围关联的文章ID 和 排序条件,根据用户的信息分页查询关联的文章ID,再查询文章详情
方案1:
- 引入es 和 mongodb 一劳永逸解决问题,实现更简单
- 需要引入第三方组件,费用成本更高
方案2:
- 实现比方案1复杂
- 不需要引入第三方组件,费用成本更低
出于"用已有的方法、更低的成本"这一初衷,下面主要介绍方案2的设计与实现,并给出与之前方案的性能对比。
方案设计与实现
设计
因为 mysql 不支持像 ES 和 mongodb 那样的复杂查询,所以我们才把 文章 和 可见范围 分开存储,分别存储在两张表里(文章表 article、可见范围表 article_view_scope)。
查询文章ID
前面的查询逻辑是"找出哪些文章可以被用户查看",所以遍历 article 表,联表 article_view_scope,判断每个文章对用户是否可见。
实际上,我们要做的事情是:把用户信息作为关键字,查询满足条件的文章,类似于ES的倒排索引的"倒查"思路,(view_scope_type + view_scope_value) --> articleId。
因此,在查询文章ID的时候,SQL可以改写成如下方式:
SQL
select article_id FROM article_view_scope
WHERE
(scope_type = 'department' and scope_value in
<foreach collection="deptIds" item="deptId" open="(" separator="," close=")">
#{deptId}
</foreach>
)
OR (scope_type = 'area' and scope_value in
<foreach collection="cityCodes" item="cityCode" open="(" separator="," close=")">
#{cityCode}
</foreach>
)
OR (scope_type = 'empType' and scope_value in
<foreach collection="empTypes" item="empType" open="(" separator="," close=")">
#{empType}
</foreach>
)
<if test="groupIds != null and groupIds.size > 0">
OR (scope_type = 'group' and scope_value in
<foreach collection="groupIds" item="groupId" open="(" separator="," close=")">
#{groupId}
</foreach>
)
</if>
这里查询出的是用户只要满足一个条件就能查看的文章ID,然后在内存中对结果做 与操作 和 或操作,得到用户能查看的所有 文章ID集合。
给 scope_type 和 scope_value 增加索引后,EXPLAIN 如下:
之前的方案相比:
1、查询出的数据相同,都是用户能查看的所有文章ID集合
2、没有联表
查询文章列表
使用上面的方法查询出文章ID集合,如果直接用来查询文章详情,依然不会使用索引。所以,在上面查询出文章ID后,需要能够根据排序条件对文章ID进行排序和分页。
目前文章的排序SQL如下(top 是否置顶、top_sequence 置顶的顺序、last_publish_at 最后发布时间):
SQL
order by `top` DESC,top_sequence ASC, last_publish_at DESC
可以把这几个参数添加到 article_view_scope 表中,可见范围是文章的一个 值对象 字段,在保存文章时 会同步更新 可见范围 的值,两者可以实时保持一致。
在倒查文章ID集合后,在内存中对文章ID做排序和分页,取出对应的文章ID子集,使用ID子集查询文章详情。
文章ID集合的大小,就是每一页的数据量(20条),查询文章详情时可以使用到索引,EXPLAIN 如下:
之前的方案相比:
1、查询文章列表时,可以使用索引,性能不会随着文章数的增加而下降,保持稳定
测试
主要是测试两种方案查询文章的性能,为了避免外部服务(从用户中台获取用户信息等)的影响,提供两个测试接口,直接传入用户身份信息,比较前后两个方案在不同数量下的性能。
测试环境:本地,链接 dev 数据库
测试工具:Jmeter
测试参数:
1、分页:每页10条数据
2、用户可见范围,相同的用户身份数据
测试1:100条数据
之前的方案
优化后的方案
对比:
数据量100时,之前额方案和优化方案有效请求 7次
- 之前的方案,90%、95%、99% 指标分别为 120ms、214ms、214ms
- 优化的方案,90%、95%、99% 指标分别为 112ms、168ms、168ms
接口耗时减少40ms左右。
测试2:1000条数据
之前的方案
优化后方案
对比:
数据量1000时,之前的方案和优化方案有效请求数在 67 次
- 之前的方案,90%、95%、99% 指标在 362 ms、384 ms、661 ms,与 100条数据相比,耗时变成了3倍
- 优化的方案,90%、95%、99% 指标在 169 ms、190ms、475 ms,与100条数据相比,90%和95%指标增加了50ms 和 30ms
优化后的方案,性能更加稳定,没有像之前的方案那样,耗时翻3倍。
测试3:10000条数据
之前的方案
接口整体耗时
只查询 article_view_scope 耗时
优化后方案
接口整体耗时
只查询 article_view_scope 耗时
对比:
1、数据量10000时,之前方案和优化方案有效请求数在 616 次
- 之前的方案,90%、95%、99% 指标在 2481 ms、2567 ms、3113 ms,与 100条数据相比,耗时变成了7倍
- 优化的方案,90%、95%、99% 指标在 817 ms、848 ms、1494 ms,与1000条数据相比,耗时变成了3倍
2、与数据量1000时相比,优化后方案接口的耗时主要是查询 article_view_scope 的耗时增加,有时不会用到索引,而且查出来的数据量要大于之前的方案,传输也比较耗时
小结
经过上述分析和测试,有以下结论:
优化后的方案,与之前的方案相比,有以下优点:
- 相同数据量下,比之前的方案性能更好
- 随着数据量增加,性能更加稳定,不会像之前的方案那样性能迅速下降
缺点:
- 虽然接口整体性能提升,但是随着数据量增加,查询可见范围时性能会下降
建议直接上 ES,别省钱了。