GraphQL实战经验和性能问题的解决方案
在现在的公司使用GraphQL有一段时间了。
现公司从创立之后的很长一段时间内是纯PHP的技术栈,前端、后端都在PHP代码中糅合在一起。新功能越加越多,页面越来越复杂之后,那些混在在PHP代码中的HTML代码越来越不可维护,于是终于有公司里的程序员看不下去,开始了技术革命,将PHP代码抽象成一个个微服务提供API,前端则采用Node+React,解放了前端工程师的生产力,使得新界面的开发越来越顺利,前端程序员也越发不用关心后端的实现了。
故事说到这里听起来皆大欢喜,然而时间长了,新的问题出现了——我们的微服务需要的调整越来越多。PM永远想要尝试新的点子,我们的新需求仍然是更加“花里胡哨”的页面,原先一个页面调用的微服务,在新的需求下需要新的数据,于是和原来比,要做的工作反而多了:在纯PHP的框架下,PHP后端代码和HTML前端代码都在同一个文件中,新需求也可能需要改一个(套)文件;然而在新的架构下,我们即需要调整微服务(PHP文件),又需要去调整前端代码(JS文件),还需要更改两者之间的协议(Apache Thrift),并且还需要严格的遵守Release的顺序和向前兼容的问题。
GraphQL就是在这样的背景下被引入到我们的技术栈之中,关于GraphQL的介绍网上有很多博文,在这里就不展开描述,个人觉得对于我们的产品开发中最有利的两点:
1. 降低了后端API的调整频度。所谓的新“需求”,有很多时候其实就是将数据转移,比如将本来在A页面展示的数据挪到B页面,或者将A和B页面合并成一个页面,抑或是A页面拆成B和C两个页面。在GraphQL引入之前,这样的展示层面的增删改都必将导致后端API的变化,但在GraphQL引入之后,前端程序员只需在Node端调整查询语句,就可以自己定制出自己需要的API。
2. 增加了前端的灵活性和可调试性。前端可以根据需求,理论上可以将整个数据库的数据在一个页面上实现任意的组合,并且由于有graphiql等强大的工具,可以边实现新的页面,边调整自己的查询语言,在出现问题时也可以通过直接执行查询语句来看是否后端返回的数据有问题。
比如有一款社交网络的应用,我们后端有一个getUserByUserId的API,可以查询一个用户的信息(ID,用户名,朋友们的ID),如果我们要做一个页面来显示一个用户的三度好友树,如果不使用GraphQL的解决方案,需要创建一个新的API,在API中先通过getUserByUserId去查询一个用户的信息和所有好友ID,再通过getUserByUserId去获得每个朋友的信息和好友ID,如此循环最后返回。
而如果使用GraphQL的解决方案,我们只需要定义用户和API的Schema:
type User {
userId: Int
userName: String
friends: [User]
}
extend type Query {
getUserByUserId(userId: Int): User //根据用户Id查询单个用户
getUsersByUserIds(userIds: [Int]): [User] //根据多个用户Id查询多个用户
}
而对应的Resolver逻辑为
export default {
Query: {
getUserByUserId: async (root, args, context) => await context.service.getUserByUserId(args.userId).then(response => response.user),
getUsersByUserIds: async (root, args, context) => await context.service.getUsersByUserIds(args.userIds).then(response => response.users),
} User: {
userId: (root) => root.userId,
userName: (root) => root.userName,
friends: (root, args, context) => await context.service.getUsersByUserIds(root.friendIds).then(response => response.users),
}
}
而getUserByUserId的返回格式为以下的格式,getUserByUserIds的话则是以下格式的列表形式
context.service.getUserByUserId(10001) {
"userId": 10001,
"userName": "Sample User Name",
"friendIds": [ 10002, 10003, 10004 ]
}
这里我们提供了两个API,一个是单数形式getUserByUserId,一个是复数形式getUsersByUsersId,实际实现中单数形式的API可以坍缩成复数形式API只有一个参数的调用,所以可以继续简化其实现。为什么不只创建单数形式的API呢?这在之后的实战问题中会描述。
这样,我们如果要实现上面所描述的三度好友页面,只需要定义两个API——getUserByUserId和getUsersByUserIds和下面的一条GraphQL查询语句
query getUserByUserId ($userId: Int) {
user: getUserByUserId(userId: $userId) {
userId
userName
friends { //朋友
userId
userName
friends { //朋友的朋友
userId
userName
}
}
}
}
这个查询会返回给我们这样的结果
{
"userId": ,
"userName": "Sample User Name",
"friends": [
{
"userId": ,
"userName": "Sample User Name 2",
"friends": [
{
"userId": ,
"userName": "Sample User Name 3"
},
{
"userId": ,
"userName": "Sample User Name 4"
},
....
]
},
{
"userId": ,
"userName": "Sample User Name 5",
"friends": [
{
"userId": ,
"userName": "Sample User Name 6"
},
{
"userId": ,
"userName": "Sample User Name 7"
},
....
]
},
....
]
}
这样我们只用了一个API(单复数共用一个实现的话)就组合出了这样的一个复合API,如果将来想要实现四度好友,五度好友,则可以在以上面的查询基础上继续嵌套,仍旧不需要增加后台的API代码。
这个示例只是最简单的示例,理论上如果你的服务的所有实体数据之间都有联结关系,那么只需要你实现每个实体数据根据ID的自查询API和实体之间的联结查询API,那么用GraphQL就可以将所有的实体连接成一张图(Graph),你可以通过GraphQL查询语句来构建这张图中的任何子图。
-------------------------- 我是和谐的分割线 --------------------------
正如每颗硬币都有正反面,在实际使用GraphQL的时候我们也遇到了很多问题,特别是性能上的问题。拿以上这个三度好友的GraphQL查询来举例,它有哪些问题呢?
1. 过度查询(Overfetching)
在一个页面中我们可能只会用一个实体的某几个属性,那么我们在后端的查询最好只需要选取需要的字段。而我们在实现GraphQL和后端服务的桥接时,不论GraphQL的查询语句请求了几个字段,后端服务永远会查询实体的所有字段并返回,而GraphQL的引擎则会根据查询语句只提取需要的字段作为返回结果。但是在这个过程中,不必要的字段占用了数据库的传输以及前后端网络传输的带宽。
比如上面的例子,如果我们的页面只要求获得用户ID并不要求返回用户名,那么我们的query可以改成以下的模式
query getUserByUserId ($userId: Int) {
user: getUserByUserId(userId: $userId) {
userId
friends {
userId
friends {
userId
}
}
}
}
表面上来看我们确实没有去查询userName,但实际上由于我们的API会返回所有的userId, userName, friendIds,所以这个查询和前面那个例子的查询开销上是一样的。
解决方案:针对整个问题,我们在GraphQL的Resolver层面做了一些改造,在查询被执行的时候从GraphQL引擎获得当前的查询语句请求的字段,并将字段作为隐藏参数传递给后端服务,后端服务根据传进来的字段进行数据库查询的优化。解决方案的伪代码如下。
export default { User: {
userId: (root) => root.userId,
userName: (root) => root.userName,
friends: (root, args, context, info) => await context.service.getUsersByUserIds(root.friendIds, info.fields).then(response => response.users), // 传入GraphQL查询中的field
}
}
而服务器端的API返回值也随着调用传参也变化
context.service.getUserByUserId(10001,["userId"]) {
"userId": 10001
}
2. 重复查询(Repeated Query)
一个较为复杂的页面中可能一个实体在页面的不同位置都有展现,比如上面那个查询,用户的一度好友们的二度好友们,很有可能互相之间也是好友,那么我们的两层嵌套查询中,有部分的查询实际上是可以避免的。
解决方案:这一点暂时没有很完美的解决方案,我们目前可以做到的是在上层Query中已经查询到的数据,如果下层Query也要查询,那么通过缓存的方式,使的下层的Query不去访问API,但是如果本身是不同的Query,暂时没有办法做跨请求的缓存。缓存实现的伪代码如下。
export default { User: {
userId: (root) => root.userId,
userName: (root) => root.userName,
friends: (root, args, context, info) => {
let cachedUsers = root.friendIds.map(id => context.cache.users[id]).filter(x => !!x); //找出所有缓存的用户
let idsToFetch = root.friendIds.filter(id => !context.cache.users[id]); //取得未缓存的用户ID
return context.service.getUsersByUserIds(idsToFetch, info.fields).then(response => { //查询未缓存的用户信息
for(let user in response.users) {
context.cache.users[user.id] = user;//将结果存储到缓存中
}
return response.users.concat(cachedUsers)://合并缓存结果和返回结果
});
}
}
}
3. N+1查询 (N+1 Query)
N+1查询是GraphQL使用中最可能也是最经常遇到的性能问题,当出现查询嵌套并且在内部嵌套的数据是列表类型时最容易出现这样的性能问题。还是以上面的查询为例,如果系统中每个用户平均有10个好友,那么以上的三度好友查询一共进行了多少次后端API的调用?答案是1 + 1 + 10 = 12次, 为什么是12次呢?
1. 第一次调用getUserByUserId, 获得了目标用户的ID和用户名信息以及平均10个朋友的ID
2. 第二次调用getUsersByUserIds,获得了10个目标的用户民信息已经他们10*10个朋友的ID
3. 对于2中获得的10批朋友ID,我们需要分别调用10次getUsersByUserIds,去获得者100个朋友的用户名信息
第二次调用获得了N批朋友ID,每批朋友信息的查询带来了N次的额外查询,所以我们将这种Pattern称为N+1查询。这里我们可以看出为什么我们一开始在定义API的时候一定要定义复数形式的API,这样一开始我们就考虑到了会有批(Batch)查询的的需求,否则的话如果只有单查询的接口,我们则需要1 + 10 + 10 * 10 = 111次API查询。但是12次查询也是非常大的消耗,并且收到前段和后端通信的并发限制,这最后的10次通信可能需要分批进行,那么最终会导致服务器端的返回速度收到了极大的限制。
解决方案:N+1 Query的问题没有一个非常好的解决方案,我们目前的做法是在GraphQL的Resolver逻辑中插入了自己的逻辑,当我们遇到这种多层嵌套查询的时候,在第N层去尝试等待其他的resolver,拿上面的例子,我们的第二次调用后,获得了10批朋友的ID,那么在第三层的结构进行resolve逻辑的时候,我们会收集所有需要调用getUsersByUserIds的参数,将其合并成一次调用,这次调用返回的Promise在全局共享,同时在运行时将每个resolver的逻辑替换成合并后调用的结果中找出自己需要的结果并返回。为了更好的帮助大家理解,可以参照下图。
GraphQL为前后端的API提供了一种便利的解决方案,但同时收到自身设计的限制,会有各种各样的问题需要针对具体的应用场景去优化,在使用之前不妨先问问自己:我到底需不需要GraphQL。
GraphQL实战经验和性能问题的解决方案的更多相关文章
- (转)国内外三个不同领域巨头分享的Redis实战经验及使用场景
随着应用对高性能需求的增加,NoSQL逐渐在各大名企的系统架构中生根发芽.这里我们将为大家分享社交巨头新浪微博.传媒巨头Viacom及图片分享领域佼佼者Pinterest带来的Redis实践,首先我们 ...
- HDFS配置参数及优化之实战经验(Linux hdfs)
HDFS优化之实战经验 Linux系统优化 一.禁止文件系统记录时间 Linux文件系统会记录文件创建.修改和访问操作的时间信息,这在读写操作频繁的应用中将带来不小的性能损失.在挂载文件系统时设置no ...
- Redis实战经验及使用场景
随着应用对高性能需求的增加,NoSQL逐渐在各大名企的系统架构中生根发芽.这里我们将为大家分享社交巨头新浪微博.传媒巨头Viacom及图片分享领域佼佼者Pinterest带来的Redis实践,首先我们 ...
- 【Oracle 集群】ORACLE DATABASE 11G RAC 知识图文详细教程之RAC 特殊问题和实战经验(五)
RAC 特殊问题和实战经验(五) 概述:写下本文档的初衷和动力,来源于上篇的<oracle基本操作手册>.oracle基本操作手册是作者研一假期对oracle基础知识学习的汇总.然后形成体 ...
- MySQL数据库的优化-运维架构师必会高薪技能,笔者近六年来一线城市工作实战经验
原文地址:http://liangweilinux.blog.51cto.com/8340258/1728131 首先在此感谢下我的老师年一线实战经验,我当然不能和我的老师平起平坐,得到老师三分之一的 ...
- MySQL索引实战经验总结
MySQL索引对数据检索的性能至关重要,盲目的增加索引不仅不能带来性能的提升,反而会消耗更多的额外资源,本篇总结了一些MySQL索引实战经验. 索引是用于快速查找记录的一种数据结构.索引就像是数据库中 ...
- 4月27号开学! 第6期《jmeter实战接口自动化+性能》课程,零基础也能学
2019年 第6期<jmeter实战接口自动化+性能>课程,4月27号开学! 主讲老师:飞天小子 上课方式:QQ群视频在线教学 本期上课时间:4月27号-6月9号,每周六.周日晚上20:0 ...
- Visual Studio 2015开发Qt项目实战经验分享(附项目示例源码)
Visual Studio 2015开发Qt项目实战经验分享(附项目示例源码) 转 https://blog.csdn.net/lhl1124281072/article/details/800 ...
- RabbitMQ实战经验分享
前言 最近在忙一个高考项目,看着系统顺利完成了这次高考,终于可以松口气了.看到那些即将参加高考的学生,也想起当年高三的自己. 下面分享下RabbitMQ实战经验,希望对大家有所帮助: 一.生产消息 关 ...
随机推荐
- 杂项-Log:log4net
ylbtech-杂项-Log:log4net log4net库是Apache log4j框架在Microsoft .NET平台的实现,是一个帮助程序员将日志信息输出到各种目标(控制台.文件.数据库等) ...
- DCloud-MUI:Hello mui
ylbtech-DCloud-MUI:Hello mui MUI-最接近原生App体验的前端框架 1. 返回顶部 1. MUI-最接近原生App体验的前端框架 极小 100k的js文件,60k的css ...
- cmake opencv,dlib 编译静态库 1
无论windows,linux 所有的库 ,dlib,opencv 通过cmake-gui 设置好静态库, 动态库,和其他各个选项 Tips: cmake 优先级用cmake-gui,因为命令太多,容 ...
- JSP介绍(4)--- JSP Cookie 处理
Cookie是存储在客户机的文本文件,它们保存了大量轨迹信息. JSP脚本通过request对象中的getCookies()方法来访问这些cookie,这个方法会返回一个Cookie对象的数组. 通常 ...
- HttpApplication 对象的创建过程及HttpModule过滤器的内部实现过程
最近通过Reflector学习了一下asp.net内部的原理,做做笔记,方便以后查阅. 先看下HttpApplication 对象的创建过程 从IHttpHandler applicationInst ...
- webStorage
1.HTML5中的本地存储概念是什么? 很多时候我们会存储用户本地信息到电脑上,例如:比方说用户有一个填充了一半的长表格,然后突然网络连接断开了,这样用户希望你能存储这些信息到本地,当网络恢复的时候, ...
- Java探索之旅(17)——多线程(1)
1.多线程 1.1线程 线程是程序运行的基本执行单元.指的是一段相对独立的代码,执行指定的计算或操作.多操作系统执行一个程序时会在系统中建立一个进程,而在这个进程中,必须至少建立一个线程(这个线程被 ...
- 【Java】Java程序员面试宝典(第三版)第5章----Java程序设计基本概念
1.static静态变量,在次级作用域也可以被修改. 2.k++ + k++.第一个自加实际上只有在与计算+k++时补增.详情P36的题目. 3.Java数据类型从低到高分为(byte short c ...
- JAVA相关资料
http://ifeve.com/java-multi-threading-concurrency-interview-questions-with-answers/ http://www.cnblo ...
- 【总结整理】JQuery基础学习---事件篇
jQuery鼠标事件之click与dbclick事件 用交互操作中,最简单直接的操作就是点击操作.jQuery提供了两个方法一个是click方法用于监听用户单击操作,另一个方法是dbclick方法用于 ...