- 数据库
- 后端
【免费下载链接】convex-backend
The open-source reactive database for app developers
导读:本文基于 convex-backend 仓库中的性能审计技能文档,系统讲解 Convex 乐观并发控制(Optimistic Concurrency Control,OCC)冲突的成因、诊断信号与修复路径。当部署日志或 Dashboard 健康页出现冲突告警、npx convex insights显示高冲突率时,你可以依照本文给出的五步修复顺序,从收窄读集、拆分热文档、跳过空写、迁移非关键工作到合并竞争写,逐层降低写争用,同时理解这些手段在数据库提交层(committer.rs)与写日志(write_log.rs)中的底层原理。
核心原理:读集与写日志的冲突检测
Convex 的数据库采用乐观并发控制:事务在提交前不做加锁,而是先在自身快照上执行读与写,提交时由数据库统一校验该事务的**读集(ReadSet)**是否与这段时间内其他已提交或待提交的写入发生重叠。若发生重叠,只有一个事务能够成功,其余事务自动重试。这意味着冲突本身并不可怕——它是 OCC 的正常组成部分;但高冲突率意味着大量被浪费的计算与重试,直接表现为延迟上升与吞吐下降。
从源码结构看,冲突检测的完整链路位于 committer.rs 的commit_has_conflict(committer.rs)中,它依次检查两处:
- 已持久化的写日志(write log):调用
log.is_stale(reads, validated_through, commit_ts),判断事务开始之后、提交时刻之前,写日志中是否有写入命中了本事务的读集; - 内存中待提交的写入(pending writes):调用
pending_writes.is_stale(reads),判断其他尚未落盘但已通过预校验的并发提交是否与本事务读集重叠。
读集本身被建模为ReadSet结构(reads.rs),由两部分组成:
- 索引读(indexed):以
TabletIndexName -> IndexReads的映射记录每个被读取索引的字段与区间集合(IntervalSet),例如一次withIndex查询所覆盖的键范围; - 全文搜索读(search):记录搜索查询命中的文档。
在 write_log.rs 的is_stale中,冲突判定进一步分为database_index_conflict(write_log.rs,将读集区间与待提交索引键求交集,命中即冲突)与text_index_conflict(write_log.rs,对全文搜索读做线性扫描)。冲突最终以ErrorCode::Conflict呈现,对应 HTTP 409(见 errors/src/lib.rs),客户端或运行时随后自动重试。
理解这一机制后不难推出一个关键结论:读集越宽,被写入命中的概率越高。这也是本文所有修复策略的共同出发点。
症状:如何识别 OCC 冲突
当系统出现以下信号时,应优先怀疑 OCC 冲突:
- 部署日志或 Dashboard 健康页中出现 OCC conflict 错误;
- 同一个 mutation 需要多次重试才能成功;
- 写密集页面出现用户可感知的延迟尖峰;
- 运行
npx convex insights --details显示较高的冲突率。
insights是 Convex CLI 内置的健康检查命令(实现见 npm-packages/convex/src/cli/insights.ts),可查看部署近 72 小时的健康洞察,--details可附带每条洞察的近期事件,--prod可切换到生产部署。它是定位冲突率变化的首选观测入口。
常见成因分析
热文档(Hot Documents)
多个 mutation 并发写入同一份文档。典型场景包括:全局计数器、共享设置行、父记录上的lastUpdated时间戳。由于所有写入都命中同一个文档 ID,读集与写集必然重叠,冲突率随写入并发度线性上升。
宽读集导致的伪冲突(Broad Read Sets Causing False Conflicts)
一次扫描大表范围的查询会建立宽泛的读集区间。只要区间内有任意写入发生,即使该查询真正关心的那条文档并未被修改,事务也会被判为冲突。伪冲突是"读集太大"的直接代价,与数据量无关,而与区间覆盖面有关。
触发器或级联写入的扇出(Fan-out from Triggers or Cascading Writes)
单个用户动作触发多个 mutation,它们共同触碰彼此相关的文档,互相竞争。需要特别注意的是:数据库触发器(例如来自convex-helpers的触发器)运行在与触发它的 mutation同一个事务内。若触发器做了重活、读了额外表或写了很多文档,会显著扩大事务的读写集,从而加宽冲突窗口。因此应保持触发器逻辑最小化,或把昂贵的派生工作挪到 scheduled function 中。
写后读链(Write-then-Read Chains)
一个 mutation 写入文档,随后某个响应式查询重读该文档,另一个 mutation 又写入同一文档。在高负载下,这样的链式操作会不断叠加,读写双方在同一热点上持续碰撞。
修复顺序:五步逐层降争用
以下策略应按顺序依次尝试:先消除"可避免的冲突"(收窄读集、跳过空写),再处理"结构性热点"(拆分文档、迁移工作、合并写),而不是一开始就引入锁或队列。
1. 缩小读集(Reduce Read Set Size)
收窄读集是成本最低、收益最直接的修复。目标是让查询只触碰与自身逻辑真正相关的文档,而不是全表扫描后再在内存中过滤:
// 差:全表扫描建立宽冲突面 const allTasks = await ctx.db.query("tasks").collect(); const mine = allTasks.filter((t) => t.ownerId === userId);// 好:索引查询只触碰相关文档 const mine = await ctx.db .query("tasks") .withIndex("by_owner", (q) => q.eq("ownerId", userId)) .collect();从ReadSet的实现看,withIndex查询会把索引名与精确的键区间写入读集(reads.rs),而collect()全表扫描记录的是整张表对应索引的全部区间。区间越窄,database_index_conflict中的区间求交越不容易命中(write_log.rs)。
2. 拆分热文档(Split Hot Documents)
当大量写入者必须更新"同一份逻辑数据"时,把它拆成多个文档,把单点争用摊薄到多个键上:
// 差:每次投票都递增同一份计数器文档 const counter = await ctx.db.get(pollCounterId); await ctx.db.patch(pollCounterId, { count: counter!.count + 1 });// 好:把计数器分片到多份文档,读取时再聚合 const shardIndex = Math.floor(Math.random() * SHARD_COUNT); const shardId = shardIds[shardIndex]; const shard = await ctx.db.get(shardId); await ctx.db.patch(shardId, { count: shard!.count + 1 });当需要总数时,在查询或定时任务中聚合所有分片。注意随机分片适合"加法/计数"这类可聚合语义;若业务需要精确单调性,应评估聚合误差是否可接受。
3. 跳过无变化的写入(Skip No-op Writes)
不改变数据的写入同样参与冲突检测与订阅失效。即使patch写入的字段值未变,事务依然会进入提交管线、占住写日志槽位并触发订阅者重跑。在写入前比较值,可显著削减无意义写入:
// 差:即使状态没变也执行 patch await ctx.db.patch(doc._id, { status: args.status });// 好:仅在值真正变化时才写 if (doc.status !== args.status) { await ctx.db.patch(doc._id, { status: args.status }); }4. 把非关键工作迁移到定时函数(Move Non-critical Work to Scheduled Functions)
若一次 mutation 同时承担"主业务"与"次要记账"(分析、通知、缓存预热),后者会拉长事务生命周期并扩大读写集。把记账类工作交给scheduler.runAfter,让主事务保持短小:
// 差:分析上报与用户操作在同一事务中 await ctx.db.patch(userId, { lastActiveAt: Date.now() }); await ctx.db.insert("analytics", { event: "action", userId, ts: Date.now() });// 好:调度记账任务,让主事务更小 await ctx.db.patch(userId, { lastActiveAt: Date.now() }); await ctx.scheduler.runAfter(0, internal.analytics.recordEvent, { event: "action", userId, });5. 合并竞争写入(Combine Competing Writes)
如果两个 mutation 必须原子地更新同一份文档,可以评估是否将它们合并为客户端的一次 mutation 调用,从而减少往返次数与冲突窗口。需要强调的是:在尝试以上步骤之前,不要引入人工锁或队列——它们会带来复杂度与可用性风险,而绝大多数冲突在消除读集与热点问题后即可缓解。
相关:订阅失效范围(Invalidation Scope)
拆分热文档带来的收益不止于 OCC 冲突。若一份文档被频繁写入、同时被大量查询读取,那么每次写入都会触发这些查询重跑,即使查询关心的字段并未变化。把高频更新字段与低频读取字段拆分到不同文档,可以同时压缩 OCC 冲突窗口与订阅失效范围。相关模式参见subscription-cost.md第 4 节"Isolate frequently-updated fields"。
验证与回归
完成改造后,按以下清单确认效果:
- insights 或 Dashboard 中的 OCC 冲突率已下降;
- mutation 延迟更低且更稳定;
- 拆分或调度改造未引入数据正确性回归;
- 针对同一热文档的多个写入方已被一致地修复(避免只修一半导致热点转移)。
建议在压测或线上灰度中同时观察冲突率与订阅失效两个指标,确保优化没有以牺牲正确性或新鲜度为代价。
- 数据库
- 后端
【免费下载链接】convex-backend
The open-source reactive database for app developers
相关推荐
Convex OCC 冲突消解指南:从乐观并发控制原理到热文档写争用的系统化修复
Convex OCC 冲突消解指南:从乐观并发控制原理到热文档写争用的系统化修复 本指南基于 convex backend 仓库中的性能审计技能文档 occ c
数据库后端PowerCLI-Example-Scripts入门指南:如何快速开始使用社区脚本
PowerCLI Example Scripts入门指南:如何快速开始使用社区脚本 PowerCLI Example Scripts是VMware官方提供的Po
Convex OCC 冲突治理实战:从乐观并发控制原理到写热点性能优化
Convex OCC 冲突治理实战:从乐观并发控制原理到写热点性能优化 当 Convex 部署日志、Health 面板或 npx convex insights
数据库后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考