首页 / 文章 / 耗尽数据库连接池的 GraphQL 查询

耗尽数据库连接池的 GraphQL 查询

一份嵌套的 GraphQL 文档导致生产数据库崩溃。探讨了速率限制、HTTP 超时以及 DataLoader 为何会失效,以及最终控制住局势的四大措施。

1617 词

一次 GraphQL POST 请求就让 API 停服了近一小时——而且并非恶意行为

周末夜晚八点刚过,问题就开始显现。

Postgres 的 CPU 使用率一直处于满负荷状态,连接池中也再也没有空闲连接。所有客户端都出现了网关超时错误。周日晚上,该公共 API 完全无法使用,而那正是该产品使用量最高的时段。

当天的流量规模看似正常,甚至有些偏低,因此不太可能出现突然的流量激增。

自周中以来并未发布任何新版本,因此糟糕的部署也不太可能是原因。

经过大约十五分钟的排查才找到问题根源,乍看之下简直令人难以置信。

仅有一次HTTP请求。一次POST /graphql请求已经耗费了大约一分半钟来处理数据库操作,且仍处于活跃状态。这一次单独的请求所消耗的数据库时间就已经超过了此前几小时正常流量所用的时间。

接下来的内容将阐述该文档的结构、现有控制措施为何未能发现它,以及几天后确定的四个限制条件。文末还介绍了最糟糕的情况:恶意攻击者要利用同一个漏洞只需付出极少的努力。

查询内容

该文档在结构上虽有所简化,但依然保持准确,其结构类似如下:

query {
  organizations {
    members {
      user {
        organizations {
          members {
            user {
              organizations {
                members {
                  user { id, email }
                }
              }
            }
          }
        }
      }
    }
  }
}

数据之间存在七层嵌套的循环关系:组织包含成员,成员关联到个人,而个人又属于某个组织。

那些边是真实且双向的,因此以那种方式对它们进行建模是合理的。解析器运行正常,每次独立的SQL调用都能快速完成。

问题源于组合式的增长。

典型的账户属于大约三个组织,而典型的每个组织有约五十人。按照这种层级结构扩展的话,数量大致如下:

  • 第1层 → 约3个组织
  • 第2层 → 约150个成员关系
  • 第3层 → 约150名用户
  • 第4层 → 约450个组织
  • 第5层 → 约2.25万个成员关系
  • 第6层 → 约2.25万名用户
  • 第7层 → 约6.75万个组织

最底层有近七万个节点,每个节点都会带来更多的成员关系查询。在操作员终止任务之前,这种扩展仍在持续。

那只是一个简短的文本文件,没有认证漏洞,也没有可注入的字符串,扫描工具也找不到任何问题。其架构完全遵循它所生成的图结构。

是意外,而非恶意行为

起源对这一教训而言很重要。

该请求是通过某位员工的已认证会话发起的。一名移动端工程师在规划用户界面数据需求时,正在Apollo Studio中查看架构结构,尝试打开嵌套字段以了解其中的内容。

他们点击执行后,发现用户界面卡住了,便将原因归咎于网络问题,随后关闭了浏览器标签页。

关闭标签页并不会终止服务器端的处理流程。连接被断开后,程序仍会继续执行;数据库会继续处理成千上万个分支查询,却无人等待响应结果。

他们直到周一的事故讨论帖才得知这次故障。并没有发生任何恶意行为:他们只是使用了公司提供的工具,按照公司提供的架构结构进行操作。

为何现有的防护措施未能发挥作用

虽然有相应的控制机制,但没有一种能应对这种故障模式——而正是这种不匹配导致了问题。

每 IP 的请求上限。该限制针对的是每分钟的 HTTP 调用次数。一次调用即算一次。限流机制正确地放行了该请求。

边缘 HTTP 到期时间。平衡器在30秒后超时,调用方收到504错误。由于关闭套接字并不会终止其后面的操作,后端的 SQL 查询仍在继续运行。这实际上是对客户的欺骗,而服务器则持续消耗资源。

DataLoader。团队们常常将批量处理视为安全保障。

在单个时间单元内,DataLoader会合并重复的实体加载操作,并真正解决经典的 N+1 问题。在深层成员关系层面,数千次查询会被整合为几条 WHERE id IN (...) 语句。

将两万多条记录合并到一条语句中并不会让这些行变得可自由使用。往返次数会减少,但记录数量并未改变,更深层次的层级依然存在。批量处理只是提升效率的手段,并非上限。人们误将效率当成了限制。

登录与身份识别。 调用者已成功登录。身份识别回答的是“谁”,而非“成本有多高”。

字段级权限。 该用户被允许访问所有选中的字段。授权过程成功。问题出在合法的数据查询量过大,而非存在被禁止的数据。

在攻击下同样的漏洞会显得多么可怕

恢复之后,我们花了一下午时间来模拟针对同一漏洞的恶意利用方式。正是这种模拟才有了这篇文章。

别名功能允许同一文档使用不同参数重复某个字段:

mutation {
  a1: login(email: "target@company.com", password: "000001") { token }
  a2: login(email: "target@company.com", password: "000002") { token }
  a3: login(email: "target@company.com", password: "000003") { token }
  # ... two thousand more
}

仍然只有一次HTTP请求。速率限制也仅针对这一次请求。用于记录五次失败后触发登录锁定的计数器位于解析器内部,能够准确统计数千次尝试次数。

因此,那种密码暴力攻击会意外被阻止——因为该计数器恰好位于实际处理请求的位置。

更广泛的问题依然存在。任何性能较差的解析器都可能在单次请求中被调用数百次,而速率限制对此视而不见:搜索、提交任务、调用第三方服务等操作均不受限制。多年来基于REST架构设计的流量控制机制,如今却要应对一个行为并不遵循REST规范的API。

生产环境还保留了结构查询功能。任何人都可以获取完整的类型图谱——包括所有连接关系——从而无需猜测即可生成成本最高的文档。

但没人这么做。运气并非可控因素。

后续添加的四个限制

经过几天的开发工作,按影响程度排序,新增了以下限制措施。

1. 深度限制

最简单的方法就是:拒绝嵌套深度超过固定上限的文档。

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(7)]
});

通过对实际客户请求量的调查发现,正常情况下最深的嵌套层级为五层。后将上限提升至七层——既为后续发展留出空间,也能在解析器开始工作前过滤掉异常情况。

验证规则会在执行前检查已解析的文档,因此拒绝处理几乎不需要额外成本。

2. 查询成本分析

仅考虑深度是不够的。即使是一个层级较浅的文档,但如果要求返回一万个列表项,其处理量依然巨大。

成本评分系统会为各个字段设定权重,这些权重会与列表参数相乘,最终若总成本超过预算则拒绝处理。

const server = new ApolloServer({
  schema,
  plugins: [
    createComplexityPlugin({
      maximumComplexity: 1000,
      estimators: [
        fieldExtensionsEstimator(),
        simpleEstimator({ defaultComplexity: 1 })
      ]
    })
  ]
});

实现相关逻辑很简单,难点在于如何设定权重。标量类型的元素成本为1,列表则需支付first次其子元素的成本。那些需要调用第三方服务的解析器则会被赋予如50这样的手动设定权重。

通过两天的生产日志调优,数值才大致准确。大致准确就已经足够了。

3. 别名与节点数量限制

限制每次操作使用的别名数量以及总的AST节点数。

50个别名加上节点数量上限已能满足实际需求;没有哪个诚实的客户端会接近这些极限。如果别名数量达到上千,就会出现验证错误,而非所谓的“千倍解析风暴”。

封装好的防护工具能起到帮助作用。GraphQL Armor集成了深度控制、成本控制、别名管理、指令设置以及自检功能。新建项目团队应在重新设计各个组件之前先安装并调整该工具包。

4. 数据库端的语句超时设置

这是最后的防线——也是最快的解决方案:

ALTER ROLE api_user SET statement_timeout = '10s';

应用角色下的语句在十秒后就会失效。不是HTTP套接字,而是SQL本身。仅凭这一点,即便没有GraphQL相关知识,也能将数据库操作导致的停机时间从约94秒缩短到10秒。

同一周通过配置就关闭了生产环境中的自检功能——这本应是项目初期就应该做到的规范,但却被忽略了。

同一团队早期版本的教训

有三点需要牢记。

要控制成本,而不仅仅是请求数量。按分钟统计HTTP请求量是REST架构的惯用做法。GraphQL则可以通过一个POST请求隐藏任意复杂的处理逻辑。如果计量器只统计请求次数,那就不存在真正的成本衡量标准。

必须设置截止时间来强制终止任务。30秒的HTTP超时时间看似能起到保护作用,但实际上只是掩盖了后端仍在持续运行而给调用方带来的损害。应在任务执行的地方设置超时机制——对于Postgres,可在角色上设置statement_timeout。

DataLoader 不限制数据量大小。 批处理虽然会消除 N+1 问题,使每次请求的成本降低,但并不会缩小查询规模。效率与上限是两个独立的问题,两者都需要考虑。

生产环境 GraphQL 检查清单

尽快验证这些项,大多数只需几分钟即可完成。

  1. 生产环境中已关闭自描述功能吗? 否则整个架构都会公开。
  2. 设置了深度上限吗? 测量最深的合法查询长度,然后将上限设定在略高于该数值的水平。
  3. 设置了成本预算吗? 仅考虑深度无法应对包含大量数据的查询。
  4. 设置了别名上限吗? 这一设置经常被忽略,但能有效避免查询模式混乱。
  5. 数据库角色的statement_timeout 已配置吗? 它决定了单次查询允许的运行时间,涵盖这类查询的所有情况。
  • 限流是依据成本而非仅请求次数?只考虑请求次数的限制纯属虚设。
  • 那个团队最初只使用了六种限流方式中的一种,现在已全部启用;其中四种还是在同一个下午就部署完成的。

    规则

    请牢记以下区别:

    REST接口的设计本身就限定了功能范围,而GraphQL则允许客户端自行定义功能边界。除非服务器重新设置明确的限制,否则原有的限制就会被删除而非移除。

    以往的防御措施认为服务器能决定一次调用的成本,但GraphQL将这一决策权交给了文档的编写者——这既增强了灵活性,也是人们采用它的常见原因。责任必须由代码来承担,框架本身无法做到。

    几乎一小时的停机时间始于某位同事在工作室按下运行按钮的那一刻。这是比较温和的说法;实际上只需一个账号和稍加思考就能引发问题,只是因为没人尝试过,所以才没有发生。

    请先确认内省设置。这只需几秒钟,而且很多团队早已知道答案。