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实战经验,希望对大家有所帮助: 一.生产消息 关 ...
随机推荐
- RS-485半双工延时问题
学习485总线时,遇到延时问题,困扰很久.通过学习知道了485半双工收发时必须延时,以保证系统的稳定性.可靠性.好多资料都介绍了485 防静电.抗干扰电路.惟独没提 每一帧收发停止位(或第9位)的延时 ...
- 给JZ2440开发板重新分区
转自:http://mp.weixin.qq.com/s?__biz=MzAxNTAyOTczMw==&mid=2649328035&idx=1&sn=7d3935cc05d3 ...
- word2010以上版本中快捷录入数学公式的方法(二)
以前推荐的方法,随着方正飞翔网站上关闭了数学公式输入法的支持也不能不用了,现在再推荐一个可以在word2010以上版中快捷输入数学公式的方法,安装AxMath,一切问题都OK!我是直接购买的正版,25 ...
- net start sql server (instance)
如何启动 SQL Server 实例(net 命令) 其他版本 可以使用 Microsoft Windows net 命令启动 Microsoft SQL Server 服务. 启动 SQL Se ...
- java 中Int和Integer区别以及相关示例
Java是一个近乎纯洁的面向对象编程语言,但是为了编程的方便还是引入不是对象的基本数据类型,但是为了能够将这些基本数据类型当成对象操作,Java为每一个基本数据类型都引入了对应的包装类型(wrappe ...
- Centos6.5_64位系统下安装Oracle 11g
一.硬件要求 1.内存与Sweap:内存2G(以上),Sweap 2G(以上) 内存: 1-2G 2-16G 16G以上 Sweap: 1.5倍内存 1倍内存 16G 检查:# grep MemTot ...
- 使用 Chrome Timeline 来优化页面性能
使用 Chrome Timeline 来优化页面性能 有时候,我们就是会不由自主地写出一些低效的代码,严重影响页面运行的效率.或者我们接手的项目中,前人写出来的代码千奇百怪,比如为了一个 Canvas ...
- jquery选择器中的find和空格,children和>的区别、及父节点兄弟节点,还有判断是否存在的写法
一.find和空格,children和>及其它的区别 空格:$('parent childchild')表示获取parent下的所有的childchild节点(所有的子孙). 等效成 = ...
- Object—C 块在函数中作为参数时的分析
暂时对这个有了一些粗浅的理解,记下来一边后面学习时学习,改正. 先举个例子: A类: .h文件: @interface A : NSObject - (void)Paly1:(void (^)(do ...
- 3、jquery_动态创建元素
动态创建元素:$('<b>javier</b>') $('#Button1').append($('<b>javier</b>')) 等价于 $($( ...