Hasura GraphQL Engine 元数据乐观并发控制(Optimistic Concurrency Control)设计解析
2026/9/20 0:23:07 网站建设 项目流程
  • 后端
  • API网关
  • 数据库
  • GraphQL

【免费下载链接】graphql-engine

Blazing 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
点击查看免费下载

导读

本文以 rfcs/optimistic-concurrency-control.md 为骨架,深入剖析 Hasura GraphQL Engine 如何通过resource_version(资源版本号)机制解决多客户端并发修改元数据(Metadata)时的冲突问题:多个用户或自动化脚本同时操作同一个实例时,如何避免"操作基于过期元数据执行"导致的报错与副作用。读完本文,你将理解该 RFC 的动机、v1/metadataAPI 的请求/响应契约演进、export_metadatav2 的引入,以及该设计在hdb_catalog表与 schema 同步机制中的最终落地实现。

背景:控制台对"外部元数据变更"的失察

Hasura 的元数据(如表跟踪、关系、权限、事件触发器、远程 schema 等)统一存储在服务器的hdb_catalog中,任何客户端都可以通过/v1/metadataAPI 或控制台进行修改。RFC 指出现状存在一个核心痛点:

Console 无法感知发生在它之外(例如同一项目的另一位用户)的元数据变更。

当用户试图修改一个已经在服务端被其他人改动过的对象时,操作可能报错,也可能造成非预期的副作用。RFC 给出了两个并发场景(假设两个用户通过不同控制台页面操作同一台服务器):

场景 1:操作直接报错

  1. 用户 A 想取消跟踪(untrack)表t,但在点击按钮之前,用户 B 已经取消了表t的跟踪;
  2. 用户 A 的请求到达服务端后,得到类似table 't' not found的错误。

场景 2:副作用悄悄丢失他人成果

  1. 用户 A 想取消跟踪表t,但在点击按钮之前,用户 B 从表x到表t新增了一条关系(relationship);
  2. 用户 A 的 untrack 请求依然执行成功,但同时把用户 B 在表x上创建的关系一并丢弃了

场景 2 比场景 1 更隐蔽、更危险——它不是报错,而是静默地覆盖了他人的修改。为了让控制台感知外部变更,并在用户基于过期元数据操作时给出警告,RFC 引入了乐观并发控制(Optimistic Concurrency Control, OCC)。

方案核心:resourceVersion版本号机制

乐观并发控制是处理共享资源并发更新的经典策略。RFC 的核心思想可以概括为三步:

  1. 服务端维护内部版本号:为元数据资源维护一个内部resourceVersion,每次资源变更时递增;
  2. 客户端携带版本号:客户端在修改操作中携带它所见过的资源的resourceVersion
  3. 服务端校验版本号:仅当客户端发送的resourceVersion与服务端当前值一致时,操作才被放行,从而保证"资源自客户端上次查看以来未被修改"。

请求流程示例

RFC 以场景 1 为例给出了带版本号的完整请求流。设元数据当前版本为v,两个用户打开控制台时都拿到resourceVersion = v

  1. 用户 B 发送 untrack 表t的请求,携带resourceVersion = v
  2. 服务端校验当前版本确为v,放行操作,并将resourceVersion递增到w随响应返回;
  3. 用户 A 对此毫不知情,发出相同的 untrack 表t请求,仍携带resourceVersion = v
  4. 服务端发现当前版本已是w,比客户端携带的v更新,于是拒绝请求,并在响应中返回新的resourceVersion
  5. 用户 A 的控制台据此提示用户"元数据已在服务端被修改",并引导其重新拉取元数据。

这一机制的本质是"乐观"的:它不假设冲突一定会发生,而是在提交时一次性校验,冲突时拒绝并让客户端基于最新版本重试。

所需的 API 变更

1. 为所有元数据操作传递resourceVersion

当前所有元数据操作都以 POST 形式提交到v1/metadata,请求体如下:

{ "type": "operation_name", "version": "version_of_the_operation", "args": OperationArgs }

RFC 给出了两种携带resourceVersion的方案:

方案 A:扩展 JSON 请求体,增加顶层键

{ "type": "operation_name", "version": "version_of_the_operation", "args": OperationArgs, "resourceVersion": resourceVersion }

方案 B:作为 URL 查询参数

POST /v1/metadata?resourceVersion=x { "type": "operation_name", "version": "version_of_the_operation", "args": OperationArgs }

RFC 认为两种方案都合理,可以选取实现成本更低的一种。同时特别注明:部分操作应当忽略resourceVersion,例如export_metadata——它只导出、不修改元数据,不应因版本不匹配而被拒绝。

从当前仓库的实现看,最终采用的是方案 A(JSON 请求体顶层键):在 server/src-lib/Hasura/Server/API/Metadata.hs 中,RQLMetadata数据类型通过o .:? "resource_version"从请求体解析出可选的_rqlMetadataResourceVersion(类型为Maybe MetadataResourceVersion),随请求一并传入执行流程。这意味着resource_version在实现中是可选字段——不携带它的请求依然会被处理(向后兼容),只有携带时才会触发版本校验。

2.export_metadata响应变更与 v2 API

export_metadata的响应将改为携带版本号:

{ "resourceVersion": x, "metadata": Metadata }

由于响应结构发生了变化,需要新增一个v2 版本的export_metadataAPI,以免破坏既有客户端。当前仓库中该 API 已落地:在 server/src-lib/Hasura/RQL/DDL/Metadata.hs 的runExportMetadataV2中,响应被构造为包含resource_versionmetadata两个字段的对象;而在 server/src-lib/Hasura/Server/API/Metadata.hs 的runMetadataQueryV2M中可以看到,当前 v2 元数据 API 仅支持两个操作:RMV2ExportMetadataRMV2ReplaceMetadata

3. 修改类操作的响应携带新版本号

所有会修改元数据的操作,其响应都需要包含修改后的新resourceVersion,以便客户端更新本地记录。从实现看,这一信息体现在服务端更新元数据后返回的新版本上,详见下文"实现细节"中的写入流程。

仓库中的落地实现:从 RFC 到代码

该 RFC 的设想已在当前仓库中完整实现,以下从源码层面印证其关键环节。

存储层:hdb_metadata表中的版本列

元数据与版本号存储在同一张表hdb_catalog.hdb_metadata中。在 server/src-lib/Hasura/RQL/DDL/Schema/Catalog.hs 中:

  • fetchMetadataAndResourceVersionFromCatalog通过SELECT metadata, resource_version FROM hdb_catalog.hdb_metadata同时读取元数据与版本;
  • fetchMetadataResourceVersionFromCatalog单独读取版本号。

版本号类型定义在 server/src-lib/Hasura/RQL/Types/SchemaCache.hs:MetadataResourceVersion是包装了Int64的 newtype,初始版本为initialResourceVersion = MetadataResourceVersion 0MetadataWithResourceVersion则将元数据与其版本打包携带。

乐观并发校验:ON CONFLICT ... WHERE resource_version = $2

RFC 中"服务端仅当客户端版本与当前版本一致时才放行"的语义,在 setMetadataInCatalog 中实现得非常精巧——它借助 PostgreSQL 的INSERT ... ON CONFLICT原子语义完成"条件更新 + 版本递增 + 校验失败返回 409":

INSERT INTO hdb_catalog.hdb_metadata(id, metadata) VALUES (1, $1::json) ON CONFLICT (id) DO UPDATE SET metadata = $1::json, resource_version = hdb_catalog.hdb_metadata.resource_version + 1 WHERE hdb_catalog.hdb_metadata.resource_version = $2 RETURNING resource_version
  • WHERE条件命中(客户端携带的版本等于当前版本),则更新元数据并递增版本,返回新版本号;
  • 若条件不命中,则RETURNING返回空结果,代码随即抛出 409 冲突错误:"metadata resource version referenced (...) did not match current version"(对应 Catalog.hs)。

WHERE resource_version = $2正是 RFC 所描述的核心校验逻辑,且整个"校验 + 更新 + 递增"在单条 SQL 中原子完成,天然规避了并发窗口。

请求分发:哪些操作会触发版本校验

在 server/src-lib/Hasura/Server/API/Metadata.hs 的runMetadataQuery中,服务端先取出当前的MetadataWithResourceVersion,执行操作后调用updateMetadataAndNotifySchemaSync(写入路径见 server/src-lib/Hasura/App.hs):

newResourceVersion <- updateMetadataAndNotifySchemaSync appEnvInstanceId (fromMaybe currentResourceVersion _rqlMetadataResourceVersion) -- 客户端版本,缺省用当前版本 modMetadata cacheInvalidations

注意fromMaybe currentResourceVersion:当请求未携带resource_version时,直接以服务端当前版本参与校验(等价于不做 OCC 检查),保证了老客户端与脚本的兼容性——这与 RFC 中"部分操作可忽略 resourceVersion"的意图一致。

同时,同一文件中的queryModifiesMetadata函数(Metadata.hs)以穷举方式标注了每个元数据操作是否修改元数据:export_metadataget_inconsistent_metadataintrospect_remote_schema等只读操作返回False,而untrack_tablereplace_metadatareload_metadata等全部返回True。只有返回True的操作才会触发"写入目录 + 递增版本"的流程,这与 RFC 中"某些操作(如export_metadata)不修改元数据、应忽略版本号"的论断完全吻合。

版本递增的另一条路径:run_sql元数据级联

RFC 的最后一条变更要求指出:任何 source 上的run_sql若引发了元数据级联变更(例如表重命名),也必须递增resourceVersion。因为run_sql直接改变数据库 schema,进而会级联更新 Hasura 的元数据(如重命名表后关系、权限的级联调整),这类变更同样属于"元数据被外部修改"。仓库中为此提供了独立的bumpMetadataVersionInCatalog函数(Catalog.hs),其实现为:

UPDATE hdb_catalog.hdb_metadata SET resource_version = hdb_catalog.hdb_metadata.resource_version + 1

即当不需要修改元数据内容、仅需要"版本递增以通知其他实例/客户端"时,使用该函数完成 bump。

微妙之处(Subtleties):RFC 提出的边界问题

RFC 专门用一节讨论了版本号机制必须注意的边界情况,这些考量对实现质量至关重要:

1.reload_metadata也必须递增版本

reload_metadata本身不修改数据库中的元数据内容,但 RFC 论证了它仍然必须 bump 版本,理由有三:

  • 影响不一致对象集合reload_metadata可能使元数据进入不一致状态。用户在控制台 1 看到x个不一致对象、正准备点击drop_inconsistent_metadata,此时控制台 2 的另一个用户 reload 了元数据,不一致对象变为y个。由于元数据内容未变,若不 bump 版本,控制台 1 的删除请求会"顺利通过",但实际上它基于的是过时的不一致状态;
  • 改变操作语义reload_metadata会拉取远程 schema 的最新 schema。用户 1 正基于旧 schema 定义远程 schema 权限,用户 2 reload 后 schema 已变化,此时"add remote schema permissions"请求不应放行,因为该权限是基于更早的 schema 定义的;
  • 版本号作为多实例同步的候选机制:团队一直在考虑用resourceVersion替代当前基于 listen/notify 的 schema 同步机制。若reload_metadata不 bump 版本,这种方案将无法工作。

从当前仓库看,reload_metadataqueryModifiesMetadata中返回True(见 Metadata.hs),即它确实被纳入"修改元数据"的范畴并触发版本递增,与该节的设计结论一致。

2.run_sql的元数据级联必须递增版本

如前所述,run_sql引发的任何元数据级联(如表重命名)都必须 bumpresourceVersion,否则其他客户端仍会基于旧版本操作,重演本文开头场景 2 的"静默覆盖"问题。

3. 关于多实例 schema 同步的延伸

RFC 提到版本号机制与现有 schema 同步机制(基于 listen/notify)的关系。从仓库实现看,二者已融合:updateMetadataAndNotifySchemaSync(server/src-lib/Hasura/App.hs)在更新元数据后调用notifySchemaCacheSyncTxhdb_catalog.hdb_schema_notifications写入带resource_version的通知;而 server/src-lib/Hasura/Server/SchemaUpdate.hs 中的轮询同步逻辑通过fetchMetadataNotificationsFromCatalog(Catalog.hs,SELECT ... WHERE resource_version > $1 AND instance_id != $2按版本号增量拉取其他实例的变更通知,并用setMetadataResourceVersionInSchemaCache更新引擎内的scMetadataResourceVersion(定义于 server/src-lib/Hasura/RQL/Types/SchemaCache.hs)。由此可见,resourceVersion已成为跨实例 schema 同步的公共基准,与 RFC 中的前瞻性设想保持一致。

总结

optimistic-concurrency-controlRFC 为 Hasura GraphQL Engine 的元数据并发安全设计了一条清晰的技术路线:

设计要点RFC 提议仓库落地
版本载体服务端维护递增的resourceVersionhdb_catalog.hdb_metadata.resource_versionInt64,初始 0)
请求携带方式JSON 顶层键或 URL 参数JSON 顶层可选键resource_version(API/Metadata.hs)
并发校验版本不一致则拒绝ON CONFLICT ... WHERE resource_version = $2,失败抛 409(Catalog.hs)
导出带版本新增export_metadatav2runExportMetadataV2返回resource_version+metadata(RQL/DDL/Metadata.hs)
只读操作豁免不修改元数据的操作忽略版本queryModifiesMetadata穷举区分读写(API/Metadata.hs)
边界 bumpreload_metadatarun_sql级联需递增reload_metadata归入写操作;bumpMetadataVersionInCatalog独立递增(Catalog.hs)
多实例同步版本号可作为 schema 同步基准hdb_schema_notificationsresource_version > $1增量拉取(Catalog.hs)

对于控制台与 API 客户端开发者而言,这套机制给出了明确的集成范式:操作前通过export_metadata(v2)获取resource_version,所有修改类操作携带该值,收到 409 冲突后重新导出元数据并提示用户刷新——这正是 RFC 期望的"感知外部变更、避免静默覆盖"的完整闭环。相关设计与实现可进一步参考 rfcs/optimistic-concurrency-control.md、server/src-lib/Hasura/Server/API/Metadata.hs 与 server/src-lib/Hasura/RQL/DDL/Schema/Catalog.hs。

  • 后端
  • API网关
  • 数据库
  • GraphQL

【免费下载链接】graphql-engine

Blazing 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询