价值
21世纪初期有个非常具有时代标志性的词叫做互联网,在这短短二十年的时间里,铺天盖地的产品如雨后春笋般破土而出。
工业时代到互联网时代的跨越,给我们普通人带来的最大改变是,井喷式的海量信息开始充斥到我们每个人的生活中。这些信息以各种各样的形式在我们的生活中呈现,而其最主要的聚集点便是我们的终端智能设备——智能手机,和家庭/办公设备——电脑,我们将这二者统称为客户端。
互联网时代有一个非常大的赛道,每个互联网产品都是赛道上的一名选手,衡量选手成绩的两个最核心的指标是「产品功能」和「用户体验」。「产品功能」是互联网产品的从0到1,决定了产品的底层架构;「用户体验」是互联网产品的从1到N,决定了产品的上层建设。早期的互联网产品更侧重的是「产品功能」,眼前只有馒头白米饭的时候,想吃就只能选这些;现阶段的互联网产品更侧重的是「用户体验」,同样免费的馒头白米饭和山珍佳肴都一样能填饱肚子,用户当然更愿意选择后者。
互联网产品中,客户端的用户体验,在当下行业竞争白热化的时代正在变得格外重要。在用户获取每条信息的背后,都是一行行的代码,如果互联网产品是一座高楼大厦,那这些代码便是构建这个庞然大物的基石。
服务端查询接口的代码质量,将直接决定产品的用户体验,对用户最直观的体验感受是,打开一个页面后的加载等待时长。回想一下当你打开手机App上的一个页面,等了三秒甚至更久都还加载不出来页面时的心情,这正是本文核心要剖析和解决的问题。
业务价值产品视角
一个好的查询接口会让用户在使用App过程中感受到丝般顺滑,无形之中给产品加分,提升用户的使用体验;反之一个糟糕的接口会让用户有种味如嚼蜡般的体感,这种类似的语言是不是似曾相似:“XX的App真难用,打开一个页面就要等半天”,“我手机连的是WIFI,为什么还是这么慢”......
时间视角
这是一个很有意思的命题,试想一下,我们App的DAU是100W,平均每个DAU产生的有效接口调用量是10,基于这个背景,如果我在查询接口的代码优化使得这个接口的耗时减少1s,便能使得我们的用户节省总计 100W * 10 * 1 ≈ 116天 的无效等待时间。而这仅仅是1天的节省成本,如果是1年,能使用户节省116年的时间,甚至超过了一个普通人的一生,使命感油然而生。
技术价值技术进阶
“还能不能更优”的习惯性自我审问,催生着我们一遍遍的去思考,思考解决一个场景化问题中更接近完美的方案。恰恰是思考,是促使人们不断进步的核心动力。
分享传播
科学无国界,编码一样没有,所以 Github 才会成为全世界最大的男性网站。分享和传播使得我们和世界建立更多的连接,从而有着更多的人生意义。
各维度去说明了优化的必要性之后,接下来我们从一个具体的编码案例引入。
一个例子功能诉求
很简洁的一个例子,背景就是根据内容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请求中没有设置超时时间)。
#p#分页标题#e#
这个结果看上去非常不可思议,但实际上这个现状在很多业务场景中很极有可能存在,只是没有这么明显而已。这里最关键的一点在于size的设置,普通的列表查询可能一次只会查找10条,即size=10,而此时,页面加载也就只有大概1.6s的耗时,我相信这个量级在我们的使用App的过程中应该是屡见不鲜的。屏幕前不耐烦等待的背后,可能正隐藏着类似上面的“整齐代码”。
接下来,我们将从编码角度,针对于这个1000条数据的查询场景,进行多个维度的代码优化。
优化路径
我们将按照优化的综合效果陆续展开,其中的每一步都会基于上一步的优化结果进行递进。为了便于理解,每一步优化我们都按照分析和改进进行展开,同时会在每项优化的最好采用类比的方式进行举例,以尽可能降低理解成本。
批量查询优化分析
分析上面的代码,首先找到最耗时的几个调用,分别是:
contentMapper.selectBatchIdsuserService.queryByIdcityService.queryByIdgoodsService.queryById
单独看每个方法的单次耗时,大概都在几十毫秒左右,这本身无足轻重。而一旦我们要查询的数据量变成了1000,当前代码写法的弊端就马上展现出来了。userService.queryById 这种查询是在每条数据中都单独进行一次,也就意味着1000次就要乘以1000倍,毫秒级别立刻变成秒级别。这里有个原则,服务接口的编码中绝对不能包含与外部入参有着线性关系的代码逻辑。
改进
将 queryById 接口替换为 queryList,在for循环外部进行批量查询,然后再在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次来回的时间异步调用优化分析
此时我们把焦点继续锁定在外部服务调用部分:
// 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型耗时任务的优化通常是,通过引入多线程并行调度来提高任务的整体执行效率。
改进
这里只贴出附属信息查询的逻辑
// 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 对于并发编程比较友好,并发场景中优先推荐使用该方式。类比#p#分页标题#e#
我要买奶茶,买炸鸡,买电影票
优化前,买奶茶排队10分钟,买炸鸡排队5分钟,买电影票排队7分钟,全部买完花了22分钟优化后,找3个人分别帮我排队,等他们全部买好通知我,全部买完花了10分钟,节省了12分钟熔断处理优化分析
来看这行代码:
// wait all query task end CompletableFuture.allOf(userId2UserFuture, cityId2CityFuture, goodsId2GoodsFuture).join();
在等待所有外部服务调度结束再往下走,这样写在绝大多数情况下没有问题,但有个致命的问题,把自己业务主体逻辑与外部依赖的服务强关联了。倘若在其中的一次请求中,外部服务出现抖动,导致该次调用耗时远远大幅度增加,那我们的服务就也会遇到同样的问题,因为我们强依赖外部了,是一根绳上的蚂蚱。
改进
// wait all query task with 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分钟还没买好我就不要了,只带走买好的东西,在异常情况下,这能节省大量的等待时间分拆并行优化分析
一句通常容易被忽视的代码:
// query from db List ContentEntity contents = contentMapper.selectBatchIds(contentIds);
如果此时的 contentIds 不再是1000,而是1W,甚至10W、20W,想想会怎样。DB中 in 语句的查询效率,在达到DB能承载的一定阈值(具体值视机器而定)的情况下,查询效率会出现断崖时下跌,理解起来也不奇怪,服务器的内存和CPU资源毕竟是有限的。
改进
// query from db List ContentEntity contents = Lists.partition(contentIds, 100).parallelStream() .flatMap(batchIds - contentMapper.selectBatchIds(batchIds).stream()) .collect(Collectors.toList());
优化对比:350ms = 280ms
Tip1:这里对size为1000的 contentIds 拆分效果并不明显,主要是因为我电脑的阈值转折点要远在1000之上
Tip2:除了对 in 的数据进行分拆之外,这里还做了异步并行的处理。
类比
我要搬100块砖
优化前,一次性搬100块,累的够呛,走一会儿歇一会儿,一共用时1小时
优化后
分拆,分5次搬,每次搬20块,搬得少自然搬得快,一共用时15分钟并行,找5个帮忙一起搬,每个人搬20块,这5个人有的快有的慢,等他们全部搬完,一共用时5分钟引进缓存优化分析
对于高并发和查询耗时比较严重的场景,还有一个终极优化方案——缓存。
改进
// 仅供参考 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沟通。
类比
不断有人问我,今天下雨了没
优化前,问我一次,我出去看一次,一次花费1分钟优化后,问我一次,我出去看一次,接下来5分钟内还有人问我,我告诉他最近一次看的结果;5分钟之后再有人问我,我再出去看,以此类推。这样在缓存命中时,我几乎不花时间就能告诉他结果,但可能不准总结归纳梳理
我们对上面所有的优化过程进行反复审视,最终抽象和提炼出以下支撑整个优化过程的三个原则。
降低调用边际成本
主线程调用IO型任务,通常都会伴随着CPU资源的浪费,此时异步处理是这类型问题的通解。
充分利用CPU资源
某种意义上来说,这也是上一条的延续,既然异步了就完全可以考虑并行,多一个线程处理任务对于整个任务的提效很显著。
空间换时间
一个让查询效率有质的飞跃的优化策略,该原则在算法的场景中更多被使用到。
关于优化
回到本文开始,做代码优化,就是在做用户体验,就是在做更好的互联网产品,就是做每个用户的 Time Savior。