关于 convex-best-practices
面向 Convex 应用开发者的最佳实践技能,聚焦索引读取、验证器和幂等写入等生产代码模式,并说明并发冲突与分页处理。
主要功能
- 01每个函数声明参数和返回验证器
- 02使用索引查询而非表过滤
- 03通过幂等变更降低重试风险
- 04介绍并发冲突与分页策略
- 05提供 ESLint 插件配置建议
适用场景
- 01审查 Convex 函数的数据读写方式
- 02减少高频更新造成的写入冲突
- 03为不断增长的数据列表实现分页
- 04配置 lint 规则捕捉常见错误
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ waynesutton/ builder-skills --skill convex-best-practices安装后在 Claude Code 中输入/convex-best-practices确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
agents/openai.yamlassets/small-logo.svgreferences/eslint-setup.md详细介绍
Convex 最佳实践
让 Convex 应用在生产环境中保持快速且正确的模式。最重要的规则:写入前尽可能少读,并通过索引读取。
最重要的规则
- 为每个函数添加验证器。使用
args和returns;没有返回值时使用returns: v.null()。 - 使用索引,而非过滤器。每次读取表都要通过
withIndex,并使用convex/schema.ts中定义的索引。 - 让 mutation 保持幂等。如果文档已经处于目标状态,就提前返回,确保重试安全。
- 先 patch,不必先读取。
ctx.db.patch(id, fields)在文档不存在时会抛出异常;通常无需获取旧值。 - 对彼此独立的写入使用
Promise.all。不要逐个等待。 - 只调度
internal.*。Cron 和ctx.scheduler不会携带客户端,因此公开目标会跳过身份验证。 - 使用精简的封装。身份验证和业务逻辑放在接收
ctx的普通辅助函数中。 - 客户端需要读取的错误都使用
ConvexError。生产环境会隐藏普通Error消息。
// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v, ConvexError } from "convex/values";
const taskValidator = v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
userId: v.id("users"),
title: v.string(),
status: v.union(v.literal("open"), v.literal("done")),
});
export const listOpen = query({
args: { userId: v.id("users") },
returns: v.array(taskValidator),
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_user_and_status", (q) =>
q.eq("userId", args.userId).eq("status", "open"),
)
.order("desc")
.take(100);
},
});
export const rename = mutation({
args: { taskId: v.id("tasks"), title: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
if (args.title.trim().length === 0) {
throw new ConvexError("Title cannot be empty");
}
await ctx.db.patch(args.taskId, { title: args.title.trim() });
return null;
},
});
该索引对应的 schema:
tasks: defineTable({
userId: v.id("users"),
title: v.string(),
status: v.union(v.literal("open"), v.literal("done")),
})
.index("by_user", ["userId"])
.index("by_user_and_status", ["userId", "status"]),
索引名称按字段顺序命名,查询字段也按相同顺序排列。
OCC 和写入冲突
Convex 在乐观并发控制下运行 mutations。mutation 会记录读取过的数据。如果另一个 mutation 先提交了对这些数据的更改,Convex 就会重试该 mutation。重试次数过多后,操作会失败,客户端会看到写入冲突错误。
冲突有以下三种来源:
- 两个 mutations 同时写入同一文档:计数器、“上次查看时间”字段、共享设置文档
- mutation 读取了较大范围的数据,例如对整张表调用
.collect(),因此该范围内的任何更改都会与之冲突 - 客户端调用同一个 mutation 的速度超过其提交速度:输入、拖动、轮询
保持幂等并优先 patch
export const complete = mutation({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get(args.taskId);
if (!task || task.status === "done") {
return null;
}
await ctx.db.patch(args.taskId, { status: "done" });
return null;
},
});
export const reorder = mutation({
args: { itemIds: v.array(v.id("items")) },
returns: v.null(),
handler: async (ctx, args) => {
await Promise.all(
args.itemIds.map((id, index) => ctx.db.patch(id, { order: index })),
);
return null;
},
});
complete 中的读取没问题:只读取一个文档,并提前返回。reorder 完全不读取数据。
用事件记录代替计数器
单个文档上的计数器字段是最常见的冲突来源。每个事件插入一行,并在查询中计数。
export const trackView = mutation({
args: { pageId: v.id("pages") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert("pageViews", { pageId: args.pageId });
return null;
},
});
export const viewCount = query({
args: { pageId: v.id("pages") },
returns: v.number(),
handler: async (ctx, args) => {
const views = await ctx.db
.query("pageViews")
.withIndex("by_page", (q) => q.eq("pageId", args.pageId))
.collect();
return views.length;
},
});
事件表变大后,请改用 @convex-dev/sharded-counter 或 @convex-dev/aggregate 组件来计数,而不是收集所有行。
去重时间窗口
对于心跳和在线状态,如果上次写入时间距今很近,就跳过本次写入。再配合客户端防抖:输入操作使用 300 到 500 ms,心跳使用几秒。
const DEDUP_MS = 10_000;
export const heartbeat = mutation({
args: { sessionId: v.string(), path: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query("sessions")
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
.unique();
if (!existing) {
await ctx.db.insert("sessions", { ...args, lastSeen: now });
return null;
}
if (existing.path === args.path && now - existing.lastSeen < DEDUP_MS) {
return null;
}
await ctx.db.patch(existing._id, { path: args.path, lastSeen: now });
return null;
},
});
将 lastSeen 等高频更新字段放在单独的表中,避免这些写入与对稳定文档的读取发生冲突。
使用分页代替 collect
对没有上限的表调用 .collect() 会一天比一天慢,最终还会触及读取限制。凡是用户可以不断增加内容的地方,都应使用分页。下面的 postValidator 是像上文 taskValidator 一样提取到外部定义的文档验证器。
import { paginationOptsValidator } from "convex/server";
export const feed = query({
args: { userId: v.id("users"), paginationOpts: paginationOptsValidator },
returns: v.object({
page: v.array(postValidator),
isDone: v.boolean(),
continueCursor: v.string(),
splitCursor: v.optional(v.union(v.string(), v.null())),
pageStatus: v.optional(
v.union(v.literal("SplitRecommended"), v.literal("SplitRequired"), v.null()),
),
}),
handler: async (ctx, args) => {
return await ctx.db
.query("posts")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.paginate(args.paginationOpts);
},
});
在客户端,convex/react 中的 usePaginatedQuery 会驱动 loadMore。
查询中不要使用 Date.now()
查询必须具有确定性,以便 Convex 能够缓存它们,并在数据更改时重新运行。将时间从客户端传入,或存储一个由定时 mutation 更新的状态字段。
export const dueBefore = query({
args: { userId: v.id("users"), now: v.number() },
returns: v.array(taskValidator),
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_user_and_due", (q) =>
q.eq("userId", args.userId).lt("dueAt", args.now),
)
.take(100);
},
});
Mutations 和 actions 可以调用 Date.now()。
ESLint 插件
@convex-dev/eslint-plugin 会在 lint 时捕获旧版函数语法、缺少参数验证器、查询中的 .filter(),以及整点触发的 cron。请在每个 Convex 项目中安装它。
npm i --save-dev @convex-dev/eslint-plugin
// eslint.config.js
import { defineConfig } from "eslint/config";
import convexPlugin from "@convex-dev/eslint-plugin";
export default defineConfig([...convexPlugin.configs.recommended]);
需要完整规则列表、TypeScript 感知配置、package 脚本或自定义 convex/ 目录时,请查看 references/eslint-setup.md。
常见错误
| 错误 | 为什么会出问题 | 应该怎么做 |
|---|---|---|
在表查询上使用 .filter() | 读取所有行,然后丢弃大部分 | 添加索引,使用 withIndex |
对无界表使用 .collect() | 每天都会变慢,触及读取限制,并扩大 OCC 影响范围 | 使用 .paginate() 或 .take(n) |
| 读取、计算,然后更新共享文档 | 两个客户端读取相同版本并同时写入 | 直接更新,或拆分为事件行 |
| 每个事件都递增计数器字段 | 每次递增都会与其他递增操作冲突 | 使用事件记录或分片计数器组件 |
| 修改操作没有提前返回 | 重试和重复点击会使变更应用两次 | 检查状态;如果已完成,则返回 null |
对不相关的写入依次执行 await | 速度慢,而且每次读取都会扩大冲突窗口 | 使用 Promise.all |
在查询中使用 Date.now() | 结果每毫秒都会变化,导致缓存和订阅失效 | 将 now 作为参数传入 |
调度 api.* | 公共函数运行时没有客户端身份验证 | 调度 internal.* |
用普通 Error 显示用户消息 | 在生产环境中会被隐藏为“Server Error” | 使用 ConvexError |
| 在处理函数中编写业务逻辑 | 难以测试,且会在多个函数中重复 | 编写接收 ctx 的普通辅助函数 |
检查清单
- 每个函数都有
args和returns - 每次表读取都使用
withIndex,绝不使用.filter() - 索引按字段顺序命名
- 如果文档已处于目标状态,修改操作会提前返回
- 除非需要旧值,否则修改操作无需先读取便可更新
- 不相关的写入通过
Promise.all并行执行 - 高频计数使用事件行或计数器组件
- 无界列表使用
.paginate() - 查询中不使用
Date.now() - 计划任务和 cron 的目标为
internal.* - 已安装
@convex-dev/eslint-plugin,且npm run lint通过