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": 10001, 
   "userName": "Sample User Name",
   "friends": [
       { 
             "userId": 10002, 
             "userName": "Sample User Name 2",
             "friends": [
                   {
                       "userId": 10003, 
                       "userName": "Sample User Name 3"
                   }, 
                   {
                        "userId": 10004, 
                        "userName": "Sample User Name 4" 
                   },
                   ....
              ]
         },
         {
              "userId": 10005, 
              "userName": "Sample User Name 5",
              "friends": [
                   { 
                     "userId": 10006, 
                     "userName": "Sample User Name 6" 
                  }, 
                  { 
                     "userId": 10007, 
                     "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.jpg

GraphQL为前后端的API提供了一种便利的解决方案,但同时收到自身设计的限制,会有各种各样的问题需要针对具体的应用场景去优化,在使用之前不妨先问问自己:我到底需不需要GraphQL。

推荐阅读更多精彩内容