- 后端
- 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.
导读
本文以 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:操作直接报错
- 用户 A 想取消跟踪(untrack)表
t,但在点击按钮之前,用户 B 已经取消了表t的跟踪; - 用户 A 的请求到达服务端后,得到类似
table 't' not found的错误。
场景 2:副作用悄悄丢失他人成果
- 用户 A 想取消跟踪表
t,但在点击按钮之前,用户 B 从表x到表t新增了一条关系(relationship); - 用户 A 的 untrack 请求依然执行成功,但同时把用户 B 在表
x上创建的关系一并丢弃了。
场景 2 比场景 1 更隐蔽、更危险——它不是报错,而是静默地覆盖了他人的修改。为了让控制台感知外部变更,并在用户基于过期元数据操作时给出警告,RFC 引入了乐观并发控制(Optimistic Concurrency Control, OCC)。
方案核心:resourceVersion版本号机制
乐观并发控制是处理共享资源并发更新的经典策略。RFC 的核心思想可以概括为三步:
- 服务端维护内部版本号:为元数据资源维护一个内部
resourceVersion,每次资源变更时递增; - 客户端携带版本号:客户端在修改操作中携带它所见过的资源的
resourceVersion; - 服务端校验版本号:仅当客户端发送的
resourceVersion与服务端当前值一致时,操作才被放行,从而保证"资源自客户端上次查看以来未被修改"。
请求流程示例
RFC 以场景 1 为例给出了带版本号的完整请求流。设元数据当前版本为v,两个用户打开控制台时都拿到resourceVersion = v:
- 用户 B 发送 untrack 表
t的请求,携带resourceVersion = v; - 服务端校验当前版本确为
v,放行操作,并将resourceVersion递增到w随响应返回; - 用户 A 对此毫不知情,发出相同的 untrack 表
t请求,仍携带resourceVersion = v; - 服务端发现当前版本已是
w,比客户端携带的v更新,于是拒绝请求,并在响应中返回新的resourceVersion; - 用户 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_version与metadata两个字段的对象;而在 server/src-lib/Hasura/Server/API/Metadata.hs 的runMetadataQueryV2M中可以看到,当前 v2 元数据 API 仅支持两个操作:RMV2ExportMetadata与RMV2ReplaceMetadata。
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 0;MetadataWithResourceVersion则将元数据与其版本打包携带。
乐观并发校验: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_metadata、get_inconsistent_metadata、introspect_remote_schema等只读操作返回False,而untrack_table、replace_metadata、reload_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_metadata在queryModifiesMetadata中返回True(见 Metadata.hs),即它确实被纳入"修改元数据"的范畴并触发版本递增,与该节的设计结论一致。
2.run_sql的元数据级联必须递增版本
如前所述,run_sql引发的任何元数据级联(如表重命名)都必须 bumpresourceVersion,否则其他客户端仍会基于旧版本操作,重演本文开头场景 2 的"静默覆盖"问题。
3. 关于多实例 schema 同步的延伸
RFC 提到版本号机制与现有 schema 同步机制(基于 listen/notify)的关系。从仓库实现看,二者已融合:updateMetadataAndNotifySchemaSync(server/src-lib/Hasura/App.hs)在更新元数据后调用notifySchemaCacheSyncTx向hdb_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 提议 | 仓库落地 |
|---|---|---|
| 版本载体 | 服务端维护递增的resourceVersion | hdb_catalog.hdb_metadata.resource_version(Int64,初始 0) |
| 请求携带方式 | JSON 顶层键或 URL 参数 | JSON 顶层可选键resource_version(API/Metadata.hs) |
| 并发校验 | 版本不一致则拒绝 | ON CONFLICT ... WHERE resource_version = $2,失败抛 409(Catalog.hs) |
| 导出带版本 | 新增export_metadatav2 | runExportMetadataV2返回resource_version+metadata(RQL/DDL/Metadata.hs) |
| 只读操作豁免 | 不修改元数据的操作忽略版本 | queryModifiesMetadata穷举区分读写(API/Metadata.hs) |
| 边界 bump | reload_metadata、run_sql级联需递增 | reload_metadata归入写操作;bumpMetadataVersionInCatalog独立递增(Catalog.hs) |
| 多实例同步 | 版本号可作为 schema 同步基准 | hdb_schema_notifications按resource_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.
相关推荐
Milvus Partial Update 乐观并发控制(Optimistic CAS)设计深度解析
Milvus Partial Update 乐观并发控制(Optimistic CAS)设计深度解析 本文以仓库设计文档 docs/design docs/de
数据库向量数据库分布式数据库后端Hasura GraphQL Engine v3 架构深度解析:从 Open DDS 元数据到 GraphQL 执行引擎
Hasura GraphQL Engine v3 架构深度解析:从 Open DDS 元数据到 GraphQL 执行引擎 本文以 v3/docs/archite
后端API网关数据库GraphQL数据库并发控制机制:Awesome Design Patterns 乐观与悲观锁
数据库并发控制机制:Awesome Design Patterns 乐观与悲观锁 为什么需要并发控制 你是否遇到过这些问题?电商秒杀时商品超卖?转账操作导致余额
文档技术博客
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考