Hasura GraphQL 实时订阅架构剖析:如何支撑百万级并发 Live Queries
【免费下载链接】graphql-engineBlazing fast, instant realtime GraphQL APIs on all your data with fine grained access control, also trigger webhooks on database events.项目地址: https://gitcode.com/gh_mirrors/gr/graphql-engine
本篇技术指南以 Hasura graphql-engine 仓库中的《借助 GraphQL 承载 100 万活动订阅(实时查询)》一文为骨架,结合仓库源码(server/src-lib下的订阅执行、轮询与多路复用实现)展开纵深解析。你将理解 Hasura 将 GraphQL 订阅编译为单条 SQL、把授权声明式地嵌入查询,并在单个 SQL 查询中批量复用多个客户端实时查询的三大核心思路,同时掌握实时订阅相关的运行时配置参数与调优方向。
说明:本文部分架构图与基准测试结果图来自原文文档引用的在线图床(原文档中为外部链接),故不在此处重复引用图片;但其中涉及的核心数据表格与配置信息已完整保留。
结论先行:一套“事件推送”的压测设定
原文给出了一套极具代表性的压测设定与结果,先交代清楚测试边界,后续再解释背后的架构支撑。
设置:每个客户端(Web 或移动应用)用认证令牌登录,并订阅一个实时查询结果;数据存放于 Postgres 数据库。每秒更新 Postgres 中的 100 万行数据,确保每个客户端都能收到一条新结果;Hasura 是(含授权的)GraphQL API 提供者。
测试目标:Hasura 能并发处理多少个客户端的实时订阅?是否可以纵向或横向伸缩?
单实例测试结果:
| 单例配置 | 活动实时查询数量 | CPU 平均负载 |
|---|---|---|
| 1xCPU, 2GB RAM | 5000 | 60% |
| 2xCPU, 4GB RAM | 10000 | 73% |
| 4xCPU, 8GB RAM | 20000 | 90% |
横向扩展结果:当实时查询数量达到 100 万时,Postgres 负载不超过 28%,连接数峰值约 850。
配置说明(原文记录的测试环境,未经任何微调):
- AWS RDS Postgres、Fargate、ELB 均为默认配置
- RDS Postgres:16xCPU、64GB RAM、Postgres 11
- Hasura 运行在 Fargate(每实例 4xCPU、8GB RAM),默认配置
GraphQL 与订阅:从 query 到 subscription
GraphQL 让应用开发者轻松地从 API 中精确获取所需数据。以原文的外卖应用为例:Postgres 中存在用户、订单、配送员等表;应用界面显示当前用户的订单状态时,一个 GraphQL 查询会获取订单最新状态和配送员定位。
在底层,查询被当作字符串发送给服务器,经解析、授权后从数据库中获取数据,返回的 JSON 数据结构与请求时相同。
实时查询(live queries)的核心思想是:订阅特定查询的最新结果——一旦底层数据改变,服务器推送最新结果到客户端。这天然契合 GraphQL,因为 GraphQL 客户端原生支持 subscription,能自动处理繁琐的 websocket 连接;对客户端而言,把query换成subscription即可将普通查询升级为实时查询——前提是 GraphQL 服务器能实现它。
实现 GraphQL 实时查询的难点
实现实时查询是痛苦的:当数据库查询包含授权规则时,若要在变更事件发生时增量计算查询结果,对 web 服务层而言实践难度极高;对 Postgres 这类数据库来说,这等同于“保持物化视图随底层表变更而更新”的难题。因此 Hasura 当前采取另一种方案:为特定查询(及其授权规则)重新获取全部数据(refetch)。
为什么“逐节点再获取”不可行
一个典型 GraphQL 查询中,授权 + 数据获取逻辑必须为每个“节点”运行一次。哪怕一个稍大的查询集合都可能轻易拖垮数据库——这正是 ORM 使用不当时的 N+1 查询问题。Data loader 类模式可以缓解,但底层仍会多次查询 Postgres(从“响应中的条目数”降为“GraphQL 查询中的节点数”)。
对实时查询而言问题更严重:每个客户端的查询都会转化为一次独立的再获取。即使查询“相同”,由于授权规则产生不同的会话变量,每个客户端仍需要独立的获取。
Hasura 的方法:声明式映射 + 单条 SQL
Hasura 的做法是:从数据模型到 GraphQL schema 的声明式映射,并用它创建单条 SQL 查询访问数据库——无论响应中条目有多少、GraphQL 查询节点有多少,都避免对数据库的多次访问。这与“为每个节点运行 resolver”的典型实现形成鲜明对比。
三大核心思路
思路 #1:把 GraphQL 查询“编译”成单条 SQL 查询
Hasura 的一部分功能本质上是转译器(transpiler):利用“数据模型 → GraphQL schema”的映射元数据,把 GraphQL 查询编译为 SQL 查询去数据库取数。编译链路为:
GraphQL 查询 → GraphQL AST → SQL AST → SQL
这一步消除了 N+1 查询问题,且数据库能看到完整查询,可以整体优化数据获取。但这还不够——resolver 通过只获取权限范围内的数据来强制授权,因此必须把授权规则嵌入生成的 SQL 中。
思路 #2:让授权变得声明式
访问数据时的授权本质上是一种约束,它取决于:
- 所获取的数据(行)的值;
- 动态提供的、应用用户级的“会话变量”。
例如最简单的行内含user_id表示数据所有权;或存在关联表document_viewers表示用户可查看哪些文档;其他场景中,会话变量本身包含与行相关的所有权信息(如账户管理员可访问的账户列表[1,2,3...]不存在于当前数据库,而是来自其他数据系统提供的会话变量)。
为此 Hasura 在 API 层实现了类似Postgres RLS的授权层,提供声明式框架配置访问控制。如果熟悉 RLS,类比是:SQL 查询中的“当前会话变量”换成了来自 cookie、JWT 或 HTTP 头的 HTTP 会话变量。
值得一提的是,原文提到 Hasura 工程在 Postgres RLS 特性进入 Postgres 之前就在应用层实现了该特性,甚至遇到过与 Postgres RLS 在 insert returning 子句上修复的相同 bug。
为什么在应用层做授权而非借助 RLS?因为在 API 层持有所有应用用户级会话变量,可以据此在单条 SQL 中嵌入授权规则(表、视图、甚至返回 SETOF 的函数均可),编译链路变为:
GraphQL 查询 → GraphQL AST → 含授权规则的内部 AST → SQL AST → SQL
在仓库中,这一“GraphQL 到 SQL”的编译逻辑位于 server/src-lib/Hasura/GraphQL/Execute/Subscription/Plan.hs,而查询规划结果最终由 server/src-lib/Hasura/Backends/Postgres/Execute/Subscription.hs 中的mkMultiplexedQuery生成实际 SQL。
思路 #3:在单条 SQL 查询中批量复用多个实时查询
仅有思路 #1、#2 时,10 万个已连接客户端仍可能造成成比例的 10 万条 Postgres 查询(假如 10 万次更新、每次对应一个客户端)。
但既然 API 层持有所有应用用户级会话变量,可以创建单条 SQL 查询一次性为许多客户端再获取数据:
- 假设多个客户端在订阅“最新订单状态 + 配送员位置”;
- 在查询中创建一个“关系”(relation),把不同客户端的查询变量(订单 id)与会话变量(用户 id)作为不同行放入其中;
- 用 join 将实际数据查询与该关系关联,在单次响应中为多个客户端取到最新数据;
- 响应中的每一行即对应用户的最终结果。
这样即使各客户端的参数与会话变量完全动态、仅在查询时可知,也能同时为多个用户取到最新结果。
源码佐证:在 server/src-lib/Hasura/Backends/Postgres/Execute/Subscription.hs#L344-L407 中,mkMultiplexedQuery生成的 SQL 形如:
SELECT _subs.result_id, _fld_resp.root AS result FROM unnest($1::uuid[], $2::json[]) AS _subs (result_id, result_vars) LEFT OUTER JOIN LATERAL ( SELECT ... ) AS _fld_resp ON true即通过unnest将一批result_id与result_vars(每个订阅的查询/会话变量)展开为行,再用LEFT OUTER JOIN LATERAL与每个订阅的查询结果关联——正是文档所述“在查询中创建包含所有变量的关系再 join”的落地实现。对应地,server/src-lib/Hasura/GraphQL/Execute/Subscription/Poll/Common.hs 定义了Cohort(同查询同变量的订阅者分组,对应 SQL 中_subs表的单行)、Poller(每个唯一多路复用查询的轮询线程)等核心数据结构。
何时再获取(refetch)?
Hasura 尝试过多种从 Postgres 底层捕获更新事件来触发再获取的方案:
- Listen/Notify:需要为所有表加触发器;消费端(web 服务器)重启或网络中断时,被消费事件可能丢失。
- WAL(预写式日志):流可靠,但 replication slot 成本高、横向扩展困难,托管数据库供应商通常不提供;繁重写负载会污染 WAL,需在应用层节流。
因此当前回退为基于时间间隔的轮询再获取——不是事件驱动,而是按时间间隔重新执行查询。两大原因:
- 把数据库事件映射到特定客户端的动态查询,仅在权限与条件简单时可行(如
order_id = 1 and user_id = cookie.session_id);对复杂条件(如'status' ILIKE 'failed_%')则不可行,声明式权限有时还跨表。Hasura 在该方向(含基础增量更新)投入了大量调研。 - 任何应用除非写吞吐量很小,最终都要对事件做节流/防抖(throttling/debouncing)。
该方法的代价是:写负载很小时存在延迟(不是立即再获取,而是等几毫秒后的间隔)。可通过适当调整再获取间隔与批量大小缓解。后续优化方向是用事件依赖(event dependency)减少每个间隔内被再获取的实时查询数量。
源码级配置佐证
轮询线程的休眠逻辑在 server/src-lib/Hasura/GraphQL/Execute/Subscription/State.hs#L219-L248:每个新订阅对应的 Poller 以forever循环执行pollLiveQuery,随后sleep (unRefetchInterval refetchInterval)。RefetchInterval与BatchSize类型定义于 server/src-lib/Hasura/GraphQL/Execute/Subscription/Options.hs,默认值均为100(batch size)与1(refetch interval,秒)。
以下运行参数解析于 server/src-lib/Hasura/Server/Init/Arg/Command/Serve.hs:
| 命令行参数 | 环境变量 | 默认值 | 说明 |
|---|---|---|---|
--live-queries-multiplexed-refetch-interval | HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL | 1000(毫秒,即 1 秒) | 可多路复用的实时查询在此间隔内最多推送一次结果 |
--live-queries-multiplexed-batch-size | HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_BATCH_SIZE | 100 | 多路复用的实时查询按指定大小分批执行 |
--streaming-queries-multiplexed-refetch-interval | HASURA_GRAPHQL_STREAMING_QUERIES_MULTIPLEXED_REFETCH_INTERVAL | 1000(毫秒) | 流式订阅(streaming subscription)对应的再获取间隔 |
--streaming-queries-multiplexed-batch-size | HASURA_GRAPHQL_STREAMING_QUERIES_MULTIPLEXED_BATCH_SIZE | 100 | 流式订阅的多路复用批大小 |
多路复用轮询与响应去重
单次轮询周期内,server/src-lib/Hasura/GraphQL/Execute/Subscription/Poll/LiveQuery.hs 的pollLiveQuery完成:
- 对当前所有 cohort 做快照,并按 batch size 用
chunksOf分批; - 并发执行每批多路复用 SQL(
runDBSubscription); - 对每个 cohort,计算本次响应的ResponseHash(BLAKE2b-256),与上一轮哈希比较——结果未变化则不推送,变化了才推送(见
pushResultToCohort)。哈希类型定义在 server/src-lib/Hasura/GraphQL/Execute/Subscription/Poll/Common.hs#L183-L194,用加密哈希确保碰撞概率几乎为 0,避免在内存中保留完整结果。
结论:正是“间隔轮询 + 批量多路复用 + 结果哈希去重”的组合,使得 Hasura 能以极少的 Postgres 查询服务海量订阅。
测试:WebSocket 实时查询的规模化验证
测试基于 WebSocket 的实时查询性能扩展性与可靠性极具挑战,原文记录其测试套件与基础设施自动化工具构建耗时数周。设置如下:
- 一个 Node.js 脚本运行大量 GraphQL 实时查询客户端,在内存中记录事件,随后入库(原文引用 github.com/hasura/subscription-benchmark 作为配套工具)。
- 一个在数据库上制造写负载的脚本,使所有运行实时查询的客户端之间发生变更(每秒更新 100 万行)。
- 测试套件运行完毕后,验证脚本在数据库中提取日志/事件,验证无错误且所有事件均被接收。
- 测试仅在以下条件下视为有效:
- 收到的有效载荷错误数为 0;
- 从事件创建到客户端接收的平均延迟小于 1000 毫秒。
仓库中与订阅状态、指标相关的实现(如serverMetrics、Prometheus 的submActiveLiveQueries等)可在 server/src-lib/Hasura/GraphQL/Execute/Subscription/State.hs 中看到,可用于观测活动订阅数。
本方法的优势
Hasura 让实时查询变得触手可及:查询的概念很容易扩展到实时查询,使用 GraphQL 的开发者无需任何额外工作。这四点是最重要的优势:
- 功能特性丰富的实时查询:全面支持 Postgres 的 operators / aggregations / views / functions 等;
- 性能可估:查询被编译为单条 SQL,行为可预期;
- 性能可纵向与横向伸缩扩展:压测数据表明单实例 2 万订阅,横向扩展可达 100 万订阅;
- 可运行于所有云、数据库供应商平台:不依赖托管数据库特有的 WAL/复制槽等能力。
未来展望
进一步降低 Postgres 负载的两个方向:
- 映射事件到实时查询:在适用场景用事件依赖替代全量间隔轮询,减少每个间隔内被再获取的查询数量;
- 结果集的增量计算:仅在结果变化的部分做增量更新,而非整体重取。
这两个方向也正是“事件驱动再获取”路线上的延续,与原文“我们将在接下来的几个月里继续关注改进”的规划一致;同时原文也说明内部已有基于事件的其他方法驱动,可针对特定用例协作适配。
【免费下载链接】graphql-engineBlazing fast, instant realtime GraphQL APIs on all your data with fine grained access control, also trigger webhooks on database events.项目地址: https://gitcode.com/gh_mirrors/gr/graphql-engine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考