ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Hasura GraphQL 实时订阅架构剖析:如何支撑百万级并发 Live Queries

Hasura GraphQL 实时订阅架构剖析:如何支撑百万级并发 Live Queries 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 RAM500060%2xCPU, 4GB RAM1000073%4xCPU, 8GB RAM2000090%横向扩展结果当实时查询数量达到 100 万时Postgres 负载不超过 28%连接数峰值约 850。配置说明原文记录的测试环境未经任何微调AWS RDS Postgres、Fargate、ELB 均为默认配置RDS Postgres16xCPU、64GB RAM、Postgres 11Hasura 运行在 Fargate每实例 4xCPU、8GB RAM默认配置GraphQL 与订阅从 query 到 subscriptionGraphQL 让应用开发者轻松地从 API 中精确获取所需数据。以原文的外卖应用为例Postgres 中存在用户、订单、配送员等表应用界面显示当前用户的订单状态时一个 GraphQL 查询会获取订单最新状态和配送员定位。在底层查询被当作字符串发送给服务器经解析、授权后从数据库中获取数据返回的 JSON 数据结构与请求时相同。实时查询live queries的核心思想是订阅特定查询的最新结果——一旦底层数据改变服务器推送最新结果到客户端。这天然契合 GraphQL因为 GraphQL 客户端原生支持 subscription能自动处理繁琐的 websocket 连接对客户端而言把query换成subscription即可将普通查询升级为实时查询——前提是 GraphQL 服务器能实现它。实现 GraphQL 实时查询的难点实现实时查询是痛苦的当数据库查询包含授权规则时若要在变更事件发生时增量计算查询结果对 web 服务层而言实践难度极高对 Postgres 这类数据库来说这等同于“保持物化视图随底层表变更而更新”的难题。因此 Hasura 当前采取另一种方案为特定查询及其授权规则重新获取全部数据refetch。为什么“逐节点再获取”不可行一个典型 GraphQL 查询中授权 数据获取逻辑必须为每个“节点”运行一次。哪怕一个稍大的查询集合都可能轻易拖垮数据库——这正是 ORM 使用不当时的 N1 查询问题。Data loader 类模式可以缓解但底层仍会多次查询 Postgres从“响应中的条目数”降为“GraphQL 查询中的节点数”。对实时查询而言问题更严重每个客户端的查询都会转化为一次独立的再获取。即使查询“相同”由于授权规则产生不同的会话变量每个客户端仍需要独立的获取。Hasura 的方法声明式映射 单条 SQLHasura 的做法是从数据模型到 GraphQL schema 的声明式映射并用它创建单条 SQL 查询访问数据库——无论响应中条目有多少、GraphQL 查询节点有多少都避免对数据库的多次访问。这与“为每个节点运行 resolver”的典型实现形成鲜明对比。三大核心思路思路 #1把 GraphQL 查询“编译”成单条 SQL 查询Hasura 的一部分功能本质上是转译器transpiler利用“数据模型 → GraphQL schema”的映射元数据把 GraphQL 查询编译为 SQL 查询去数据库取数。编译链路为GraphQL 查询 → GraphQL AST → SQL AST → SQL这一步消除了 N1 查询问题且数据库能看到完整查询可以整体优化数据获取。但这还不够——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每个唯一多路复用查询的轮询线程等核心数据结构。何时再获取refetchHasura 尝试过多种从 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默认值均为100batch size与1refetch interval秒。以下运行参数解析于 server/src-lib/Hasura/Server/Init/Arg/Command/Serve.hs命令行参数环境变量默认值说明--live-queries-multiplexed-refetch-intervalHASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL1000毫秒即 1 秒可多路复用的实时查询在此间隔内最多推送一次结果--live-queries-multiplexed-batch-sizeHASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_BATCH_SIZE100多路复用的实时查询按指定大小分批执行--streaming-queries-multiplexed-refetch-intervalHASURA_GRAPHQL_STREAMING_QUERIES_MULTIPLEXED_REFETCH_INTERVAL1000毫秒流式订阅streaming subscription对应的再获取间隔--streaming-queries-multiplexed-batch-sizeHASURA_GRAPHQL_STREAMING_QUERIES_MULTIPLEXED_BATCH_SIZE100流式订阅的多路复用批大小多路复用轮询与响应去重单次轮询周期内server/src-lib/Hasura/GraphQL/Execute/Subscription/Poll/LiveQuery.hs 的pollLiveQuery完成对当前所有 cohort 做快照并按 batch size 用chunksOf分批并发执行每批多路复用 SQLrunDBSubscription对每个 cohort计算本次响应的ResponseHashBLAKE2b-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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进