列表查询是服务端开发中非常高频的诉求,接口的性能往往会跟用户体验强关联。本文通过一个具体的例子,来总结服务端写查询接口时的通用优化方案。
一个例子
功能诉求
给出一个具体的例子,背景是根据内容ID来查询内容信息(如下),目标是通过编码优化使得这个查询效率变快,减少上游(客户端App或外部服务)的等待时间。
public interface ContentService {
List<ContentVO> queryList(List<Long> contentIds);
}
模型
内容主体
public class ContentVO { private Long id; private String title; private String text; private Long userId; private String userNick; private String userAvatar; private Long cityId; private String cityName; private String cityDescription; private List<GoodsDTO> goodsList; }
其中,
Content
在DB里面只有userId
、cityId
和goodsIds
这些关联信息的外键,并没有冗余诸如userNick
、userAvatar
等这些附属字段,这些都需要我们通过调用外部服务进行额外的查询和组装。附属信息
分别对应
UserDTO
、CityDTO
和GoodsDTO
三个外部服务模型。public class UserDTO { private Long id; private String nick; private String avatar; }
public class CityDTO { private Long id; private String name; private String description; }
public class GoodsDTO { private Long id; private String name; private String image; }
都是一个ID主键对应两个具象字段,比较明了,无需赘述。
现有接口
内部DB查询主接口
public interface ContentMapper { List<ContentEntity> selectBatchIds(List<Long> contentIds); }
外部服务接口
public interface UserService { UserDTO queryById(Long id); }
另外的
CityService
和GoodsService
也保持一致,这里不列了。
编码现状
考虑到篇幅问题,我省略了类声明和 Bean
注入这些无关紧要的部分,当前的现状如下。
@Override
public List<ContentVO> queryList(List<Long> contentIds) {
// query from db
List<ContentEntity> contents = contentMapper.selectBatchIds(contentIds);
return contents.stream()
.map(content -> {
// entity => vo
ContentVO vo = new ContentVO()
.setId(content.getId())
.setTitle(content.getTitle())
.setText(content.getText());
// fill user info
Optional.ofNullable(content.getUserId())
.map(userService::queryById)
.ifPresent(user -> vo
.setUserId(user.getId())
.setUserNick(user.getNick())
.setUserAvatar(user.getAvatar())
);
// fill city info
Optional.ofNullable(content.getCityId())
.map(cityService::queryById)
.ifPresent(city -> vo
.setCityId(city.getId())
.setCityName(city.getName())
.setCityDescription(city.getDescription())
);
// fill goods info
Optional.ofNullable(content.getGoodsIds())
.map(StringUtil::str2LongList)
.filter(CollectionUtil::isNotEmpty)
.map(ids -> new ArrayList<>(goodsService.batchQuery(ids).values()))
.ifPresent(vo::setGoodsList);
return vo;
})
.collect(Collectors.toList());
}
测试用例
我们用size为1000的数据进行测试,当前的接口耗时为 163152ms
,接近三分多钟,也就意味着用户要在App页面中近3分钟才能显示内容(前提是客户端在Http请求中没有设置超时时间)。
这个结果看上去非常不可思议,但实际上这个现状在很多业务场景中很极有可能存在,只是没有这么明显而已。这里最关键的一点在于size的设置,普通的列表查询可能一次只会查找10条,即size=10,而此时,页面加载也就只有大概1.6s的耗时,这个量级在我们的使用App的过程中应该是屡见不鲜的。用户在屏幕前不耐烦等待的背后,可能正隐藏着类似上面的“整齐代码”。
接下来,我们将从编码角度,针对于这个1000条数据的查询场景,进行多个维度的代码优化。
优化路径
我们将按照优化的综合效果陆续展开,其中的每一步都会基于上一步的优化结果进行递进。为了便于理解,每一步优化我们都按照分析和改进进行展开,同时会在每项优化的最后都采用类比的方式进行举例,以尽可能降低理解成本。
批量查询优化
针对 UserService
、CityService
、GoodsService
每一项的耗时进行优化。
分析
分析上面的代码,首先找到最耗时的几个调用,分别是:
contentMapper.selectBatchIds
userService.queryById
cityService.queryById
goodsService.queryById
单独看每个方法的单次耗时,大概都在几十甚至几毫秒左右,这本身无足轻重。而一旦我们要查询的数据量变成了1000,当前代码写法的弊端就马上展现出来了。userService.queryById
这种查询是在每条数据中都单独进行一次,也就意味着1000次就要乘以1000倍,毫秒级别立刻变成秒级别。这里有个原则,服务接口的编码中绝对不能包含与外部入参有着线性关系的代码逻辑。
这种单查询+循环调用的通解是,将单查询替换为批量查询,这样带来的主要优化点在于,减少了接口的调用耗时。调用方实际耗时=网络传输耗时+服务执行耗时,如果单次查询被循环调用多次,就意味着每次都要伴随着额外的网络传输耗时(下称调用耗时)。
比如,外部服务是Http形式的网络请求,调用耗时为100ms,服务的执行耗时为30ms,如果只是一次调用则共计耗时130ms,用户感知并不很明显;但如果这里的调用逻辑是在for循环里面,那就意味着,调用耗时会成倍甚至成十倍、百倍的增加,这个接口的整体耗时会变得极度不可控,如下图
图中红色部分代表调用耗时,蓝色部分代表执行耗时。左边的50列用表示for循环50次,其中每列代表一次单独的 queryById
的外部服务调用,最右边的一列表示,将这50次单独的查询,替换为1次 batchQuery
的批量查询。结合公式来看,单次查询+遍历时的调用耗时是这样的:
$$ T_{UserService} = 1000 * T_{queryById} = 1000 * (T_{call} + T_{invoke}) = 1000 * T_{call} + 1000 * T_{invoke} $$
替换批量查询之后的耗时是这样的:
$$ T_{UserService} = 1 * T_{batchQuery} = T_{call} + T_{invoke} $$
批量查询之后,调用耗时和执行耗时相对于单次查询都会有所增加,这是由于调用时传递数据量变多的缘故;但接口的整体耗时时间,相对于之前降低的非常明显。
改进
将 queryById
接口替换为 batchQuery
,在for循环外部进行批量查询,然后再在循环内部进行数据填充。
@Override
public List<ContentVO> queryList(List<Long> contentIds) {
// query from db
List<ContentEntity> contents = contentMapper.selectBatchIds(contentIds);
// collect related ids from contents
Set<Long> userIds = new HashSet<>();
Set<Long> cityIds = new HashSet<>();
Set<Long> goodsIds = new HashSet<>();
contents.forEach(content -> {
Optional.ofNullable(content.getUserId()).ifPresent(userIds::add);
Optional.ofNullable(content.getCityId()).ifPresent(cityIds::add);
Optional.ofNullable(content.getGoodsIds())
.map(StringUtil::str2LongList)
.ifPresent(goodsIds::addAll);
});
// query user info
Map<Long, UserDTO> userId2User = userService.batchQuery(new ArrayList<>(userIds));
// query city info
Map<Long, CityDTO> cityId2City = cityService.batchQuery(new ArrayList<>(cityIds));
// query goods info
Map<Long, GoodsDTO> goodsId2Goods = goodsService.batchQuery(new ArrayList<>(goodsIds));
return contents.stream()
.map(content -> {
// entity => vo
ContentVO vo = new ContentVO()
.setId(content.getId())
.setTitle(content.getTitle())
.setText(content.getText())
.setUserId(content.getUserId())
.setCityId(content.getCityId());
// fill user info
Optional.ofNullable(content.getUserId())
.map(userId2User::get)
.ifPresent(user -> vo
.setUserNick(user.getNick())
.setUserAvatar(user.getAvatar())
);
// fill city info
Optional.ofNullable(content.getCityId())
.map(cityId2City::get)
.ifPresent(city -> vo
.setCityName(city.getName())
.setCityDescription(city.getDescription())
);
// fill goods info
Optional.ofNullable(content.getGoodsIds())
.map(StringUtil::str2LongList)
.filter(CollectionUtil::isNotEmpty)
.map(ids -> ids.stream()
.map(goodsId2Goods::get)
.collect(Collectors.toList())
)
.ifPresent(vo::setGoodsList);
return vo;
})
.collect(Collectors.toList());
}
优化对比:163152ms
=> 623ms
(本机测试数据,供参考,下同)
Tip1:代码改进过程中,通常需要注意对附属外键(如userId
)的判空保护,尽量防止传入null
进而引起外部服务的查询异常。Tip2:通常意义上讲,提供批量查询接口是作为服务提供者的基本共识。
类比
我人在北京,想来杭州买1000件衣服
- 优化前,我从北京到杭州来回1000次,每次买1件
- 优化后,我从北京到杭州来回1次,1次买1000件,节省了999次来回的时间
异步调用优化
针对 UserService
、CityService
、GoodsService
的总耗时进行优化。
分析
此时我们把焦点继续锁定在外部服务调用部分:
// query user info
Map<Long, UserDTO> userId2User = userService.batchQuery(new ArrayList<>(userIds));
// query city info
Map<Long, CityDTO> cityId2City = cityService.batchQuery(new ArrayList<>(cityIds));
// query goods info
Map<Long, GoodsDTO> goodsId2Goods = goodsService.batchQuery(new ArrayList<>(goodsIds));
虽然经过上一步的改进,我们取得了不错的优化成果,但在这里三行代码里,依然存在着巨大的优化空间。
调度外部服务本质是一种IO型任务,这类型任务的显著特点就是不会消耗CPU资源,但是会在当前线程进行阻塞等待。对于IO型耗时任务的优化通常是,通过引入多线程并行调度来提高任务的整体执行效率。
现在的写法,对于这三个外部服务的调用是串行执行的,调用结束的总耗时等于三个服务各自的查询耗时之和,即
$$ T_{total} = Sum(T_{UserService}, T_{CityService}, T_{GoodsService}) $$
我们可以将这里的串行调用改为并行调用,使得三个服务同时执行,这样调用结束的总耗时等于三个服务中查询耗时的最大值,即
$$ T_{total} = Max(T_{UserService}, T_{CityService}, T_{GoodsService}) $$
改进
这里只贴出附属信息查询的逻辑
// query user info
CompletableFuture<Map<Long, UserDTO>> userId2UserFuture = CompletableFuture.supplyAsync(
() -> userService.batchQuery(new ArrayList<>(userIds))
);
// query city info
CompletableFuture<Map<Long, CityDTO>> cityId2CityFuture = CompletableFuture.supplyAsync(
() -> cityService.batchQuery(new ArrayList<>(cityIds))
);
// query goods info
CompletableFuture<Map<Long, GoodsDTO>> goodsId2GoodsFuture = CompletableFuture.supplyAsync(
() -> goodsService.batchQuery(new ArrayList<>(goodsIds))
);
// wait all query task end
CompletableFuture.allOf(userId2UserFuture, cityId2CityFuture, goodsId2GoodsFuture).join();
// get every extra info
Map<Long, UserDTO> userId2User = userId2UserFuture.getNow(Collections.emptyMap());
Map<Long, CityDTO> cityId2City = cityId2CityFuture.getNow(Collections.emptyMap());
Map<Long, GoodsDTO> goodsId2Goods = goodsId2GoodsFuture.getNow(Collections.emptyMap());
优化对比:623ms
=> 455ms
Tip:
CompletableFuture
对于并发编程比较友好,并发场景中优先推荐使用该方式。
类比
我要买奶茶,买炸鸡,买电影票
- 优化前,买奶茶排队10分钟,买炸鸡排队5分钟,买电影票排队7分钟,全部买完花了22分钟
- 优化后,找3个人分别帮我排队,等他们全部买好通知我,全部买完花了10分钟,节省了12分钟
熔断处理优化
分析
来看这行代码:
// wait all query task end
CompletableFuture.allOf(userId2UserFuture, cityId2CityFuture, goodsId2GoodsFuture).join();
在等待所有外部服务调度结束再往下走,这样写在绝大多数情况下没有问题,但有个致命的问题,把自己业务主体逻辑与外部依赖的服务强关联了。倘若在其中的一次请求中,外部服务出现抖动,导致该次调用耗时远远大幅度增加,那我们的服务就也会遇到同样的问题,因为我们强依赖外部了,是一根绳上的蚂蚱。
这一步优化,本质上来讲,就是在上一步的基础上,再添加一个兜底方案。
优化前:
$$ T_{total} = Max(T_{UserService}, T_{CityService}, T_{GoodsService}) $$
优化后:
$$ T_{total} = Min(Max(T_{UserService}, T_{CityService}, T_{GoodsService}), T_{limit}) $$
有了T_limit
的限制之后,也就意味着我们对外部服务的调用耗时会有个上限,一旦达到这个上限,便不去继续等待结果了。如上图,我们最大上限定为500ms(实际业务场景中该值较大,一般在2~5s左右进行业务兜底),此时 GoodsService
耗时已经超过我们预设的最大值500ms时,因此我们便对该次 GoodsService
的调用进行熔断,以保证主业务链路能正常执行不受影响。
改进
// wait all query task within limit time
try {
CompletableFuture.allOf(userId2UserFuture, cityId2CityFuture, goodsId2GoodsFuture)
.get(2000, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
if (!userId2UserFuture.isDone()) {
log.warn("Fetch user info timeout, data size is {}", userIds.size());
}
if (!cityId2CityFuture.isDone()) {
log.warn("Fetch city info timeout, data size is {}", cityIds.size());
}
if (!goodsId2GoodsFuture.isDone()) {
log.warn("Fetch goods info timeout, data size is {}", goodsIds.size());
}
} catch (InterruptedException | ExecutionException e) {
log.error("Fetch extra info error", e);
}
优化对比:455ms
=> 350ms
Tip1:这里常常容易犯错,对每个CompletableFuture
使用get(2000, TimeUnit.MILLISECONDS)
进行等待,这样就会导致实际的最长等待时长为6s,而不是预期的2s。Tip2:异常问题的预案很重要,技术侧要做好监控告警,业务侧要做好产品交互。
类比
同样是上面排队的例子
- 优化前,制作奶茶的器械突然坏了,以至于维修好之后,已经从原本的10分钟等待变成了1小时
- 优化后,我就等15分钟时间,如果15分钟还没买好我就不要了,只带走买好的东西,在异常情况下,这能节省大量的等待时间
分拆并行优化
针对 ContentMapper
的耗时进行优化。
分析
一句通常容易被忽略的代码:
// query from db
List<ContentEntity> contents = contentMapper.selectBatchIds(contentIds);
这里使用了DB的批量查询,按照当前的代码写法会有一个安全隐患:当 contentIds
的量比较大时,这句SQL查询就会变得比较耗时。此时我们可以将这一大批ID拆分为很多份,然后分别去DB并行查询,这样就能充分发挥多核CPU的优势。具体可以参考下图:
红色表示优化前的单次查询,蓝色表示优化后的分批并行查询,由于蓝色部分是并行查询,在不考虑任务数与线程池中工作线程数不对等的边界情况下,我们可以用公式表示优化前后的耗时。
优化前:
$$ T_{total} = T_{1\backsim1000} = ∑_{x=0}^{9}T_{(100x + 1) \backsim (100(x + 1))} $$
优化后:
$$ T_{total} = Max(T_{1\backsim100}, T_{101\backsim200}, ..., T_{901\backsim1000}) $$
改进
// query from db
List<ContentEntity> contents = Lists.partition(contentIds, 100).parallelStream()
.flatMap(batchIds -> contentMapper.selectBatchIds(batchIds).stream())
.collect(Collectors.toList());
优化对比:350ms
=> 280ms
以下是我针对本机的mysql数据库跑出来的,拆分前后对比的实际耗时数据:
可以看出,在查询数据量较少的查询时,拆分前后的性能差异并不大,但随着查询数量逐渐增大,拆分带来的整体耗时差距也随之被逐步拉开。因此我们编码时,尤其是在不知道上游调用方传来的具体查询量时,通常需要添加拆分并行的代码保护机制,以规避上图中这种单次查询导致耗时很长的情况。
Tip1:这里对size为1000的contentIds
拆分效果并不明显,主要是因为我电脑的阈值转折点要远在1000之上Tip2:除了对
in
的数据进行分拆之外,这里还做了异步并行的处理。
类比
搬100块砖
- 优化前,一次性搬100块,累的够呛,走一会儿歇一会儿,一共用时20分钟
- 优化后,找5个人帮忙一起搬,每个人搬20块,这5个人有的快有的慢,等他们全部搬完,一共用时约5分钟
引进缓存优化
分析
对于高并发和查询耗时比较严重的场景,我们通常会选择终极优化方案——缓存。
和上面其他优化方式不同的是,缓存优化的性能提升效果非常显著,但同时也具有一些不可小觑的弊端——时效性。我们在引进缓存策略时,也需要添加很多尽可能保证数据时效性的处理逻辑。
数据查询效率一般符合如下规律
$$ T_{Http} > T_{Rpc(集群内调用)} > T_{DB} > T_{Disk} > T_{Memory} $$
缓存优化,通常是把 Http
、 Rpc
或 DB
级别的数据,暂存到 Disk
或 Memory
这类查询效率更高的地方,当接下来需要查询前者的数据时,我们使用后者暂存的数据作为查询结果,从而达到提升查询效率的目的。在引入缓存方案时,我们要尤其要关注两个方面,缓存命中率和缓存时效性。
改进
// 仅供参考
public static <T> List<T> getList(String domain, List<Long> ids, Function<List<Long>, List<T>> queryFunc) {
List<Long> distinctIds = ids.stream().distinct().collect(Collectors.toList());
Map<Long, Object> cacheMap = Cache.getCache(domain);
List<Long> absentIds = distinctIds.stream()
.filter(id -> !cacheMap.containsKey(id))
.collect(Collectors.toList());
if (CollectionUtil.isNotEmpty(absentIds)) {
List<T> absentList = queryFunc.apply(absentIds);
absentList.forEach(absent -> {
try {
Field idField = absent.getClass().getDeclaredField("id");
idField.setAccessible(true);
cacheMap.put((Long) idField.get(absent), absent);
} catch (Exception e) {
e.printStackTrace();
}
});
}
return distinctIds.stream()
.map(id -> (T) cacheMap.get(id))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
// 改进前
contentMapper.selectBatchIds(batchIds)
// 改进后
CacheUtil.getList("queryContentFromDB", batchIds, contentMapper::selectBatchIds)
// 改进前
userService.batchQuery(new ArrayList<>(userIds)
// 改进后
CacheUtil.getMap("queryUser", new ArrayList<>(userIds), userService::batchQuery)
上面的缓存实现部分是通过反射方式来实现单机的内存级别缓存的,在实际业务开发中,我们需要替换为当前应用中引用的缓存方案(分布式系统需要替换为基于分布式中间件的缓存方案)。
优化对比:280ms
=> 21ms
=> 1ms
(添加不同粒度的缓存,对应不同的效果)
Tip1:添加缓存时, 使用尽可能细粒度的缓存策略,可以有效提高缓存命中率。Tip2:使用该方案首先要考虑的就是时效性,时效性分为两种,系统内和系统外。对于系统内的缓存,我们可以感知失效时机,通常问题不大;而对于系统外的缓存,我们要结合具体场景去分析。
Tip3:该方案优化效果显著,但同时弊端也很明显,很多线上问题均由它直接或间接引起的,务必做好CodeReview,以及保证跟产品经理进行充分的badcase沟通。
类比
不断有人问我,今天下雨了没
- 优化前,问我一次,我出去看一次
- 优化后,问我一次,我出去看一次;接下来5分钟内还有人问我,我告诉他最近一次看的结果;5分钟之后再有人问我,我再出去看,以此类推。优点是在缓存命中时,我几乎不花时间就能告诉他结果,缺点是我的结果可能不准
总结
我们对上面所有的优化过程进行抽象和总结,最终归纳出支撑整个优化过程的几个原则
- 降低调用边际成本,主线程调用IO型任务,通常都会伴随着CPU资源的浪费,此时异步处理是这类型问题的通解
- 充分利用CPU资源,通过异步+并行的方式,来充分返回多核CPU的优势,从而提高整个任务的执行效率
- 空间换时间,一个让查询效率有质的飞跃的优化策略,但该方案可能会伴随着数据时效性问题,需权衡考虑