Convex 后端平台完全指南¶
为什么要学 Convex¶
实时同步开箱即用:Convex 的核心特性是实时响应式查询。当数据库中的数据变化时,所有订阅该数据的客户端会自动收到更新,不需要你写 WebSocket 代码、配置发布/订阅系统。就像 Firebase 的实时数据库,但有完整的事务支持。
TypeScript 全栈,类型安全贯穿:后端函数用 TypeScript 写,客户端调用时有完整的类型推导。你在后端改了一个字段,前端的 TypeScript 编译器会立即报错。这种端到端类型安全在 Firebase/Supabase 中是做不到的。
自动事务与一致性:Convex 的所有数据库操作都在自动管理的事务中执行。不需要手动 BEGIN/COMMIT,不需要担心竞态条件。Convex 使用乐观并发控制(OCC),冲突时自动重试。
零基础设施管理:不需要配置数据库、部署服务器、设置连接池、管理缓存。写函数 → 部署 → 自动扩缩容。Convex 是真正的 Backend-as-a-Service。
函数即 API,无需 REST/GraphQL:Convex 函数自动成为 API 端点。你定义查询函数和变更函数,Convex 框架处理序列化、验证、授权、重试等一切事务。
核心概念详解¶
Convex 是什么(白话解释)¶
想象你在用电子表格协作编辑:当同事改了一个单元格,你立刻看到变化。Convex 就是把这种体验带到了应用开发中。
你写的后端函数分三类: - 查询(Query):读取数据,客户端自动订阅,数据变了自动推送更新 - 变更(Mutation):修改数据,在事务中执行,保证一致性 - 操作(Action):调用外部 API、发邮件等副作用操作
前端调用这些函数就像调用本地函数一样,Convex 处理网络通信、缓存、重试等所有复杂性。
三种函数类型¶
| 类型 | 用途 | 特性 | 限制 |
|---|---|---|---|
| Query | 读取数据 | 实时订阅、自动缓存、确定性 | 不能有副作用、不能调外部API |
| Mutation | 写入数据 | 事务性、原子性、可调度 | 不能有副作用、不能调外部API |
| Action | 外部交互 | 可调用外部API、可调度 | 不能直接读写DB(需通过mutation) |
数据模型¶
Convex 使用文档数据库(类似 MongoDB),但有关系查询能力:
Convex vs Supabase vs Firebase 对比¶
| 特性 | Convex | Supabase | Firebase |
|---|---|---|---|
| 数据库 | 文档DB(自研) | PostgreSQL | Firestore (文档DB) |
| 实时同步 | 核心特性,自动 | Realtime插件 | 核心特性 |
| 类型安全 | 端到端TS类型 | 需生成类型 | 弱 |
| 事务 | 自动OCC事务 | PostgreSQL事务 | 有限支持 |
| 后端函数 | TypeScript | Edge Functions (Deno) | Cloud Functions |
| SQL查询 | 无(自有查询API) | 完整SQL | 无 |
| 关系查询 | 支持(索引连接) | 原生SQL JOIN | 不支持 |
| 认证 | 集成Clerk/Auth0等 | 内置Auth | 内置Auth |
| 文件存储 | 内置 | 内置 | 内置 |
| 调度任务 | 内置Cron/Schedule | pg_cron | Cloud Scheduler |
| 自托管 | 不支持 | 支持 | 不支持 |
| 价格 | 按用量 | 按用量+固定 | 按用量 |
| 开源 | 运行时开源 | 完全开源 | 不开源 |
| 离线支持 | 乐观更新 | 无 | 离线缓存 |
| 学习曲线 | 低 | 中(需会SQL) | 低 |
安装与配置¶
创建 Convex 项目(Next.js 示例)¶
# 新建 Next.js 项目
npx create-next-app@latest my-app
cd my-app
# 安装 Convex
npm install convex
# 初始化 Convex(会引导你登录和创建项目)
npx convex dev
项目结构¶
my-app/
├── convex/ # Convex 后端代码
│ ├── _generated/ # 自动生成的类型和 API
│ │ ├── api.d.ts
│ │ └── server.d.ts
│ ├── schema.ts # 数据库 Schema 定义
│ ├── tasks.ts # 后端函数
│ ├── users.ts # 后端函数
│ └── tsconfig.json
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── components/
├── convex.json # Convex 配置
└── package.json
定义 Schema¶
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string()),
role: v.union(v.literal("admin"), v.literal("user")),
})
.index("by_email", ["email"])
.index("by_role", ["role"]),
messages: defineTable({
body: v.string(),
authorId: v.id("users"),
channelId: v.id("channels"),
likes: v.number(),
})
.index("by_channel", ["channelId"])
.index("by_author", ["authorId"]),
channels: defineTable({
name: v.string(),
description: v.optional(v.string()),
isPrivate: v.boolean(),
}),
});
客户端配置(Next.js App Router)¶
// src/app/ConvexClientProvider.tsx
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: React.ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
// src/app/layout.tsx
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}
快速上手:5 分钟最小示例¶
convex/tasks.ts(后端函数):
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// 查询:实时获取所有任务
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("tasks").order("desc").collect();
},
});
// 变更:添加新任务
export const create = mutation({
args: { title: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("tasks", {
title: args.title,
completed: false,
});
},
});
// 变更:切换完成状态
export const toggle = mutation({
args: { id: v.id("tasks") },
handler: async (ctx, args) => {
const task = await ctx.db.get(args.id);
if (!task) throw new Error("Task not found");
await ctx.db.patch(args.id, { completed: !task.completed });
},
});
// 变更:删除任务
export const remove = mutation({
args: { id: v.id("tasks") },
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
},
});
src/app/page.tsx(前端页面):
"use client";
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { useState } from "react";
export default function Home() {
const tasks = useQuery(api.tasks.list);
const createTask = useMutation(api.tasks.create);
const toggleTask = useMutation(api.tasks.toggle);
const removeTask = useMutation(api.tasks.remove);
const [title, setTitle] = useState("");
return (
<main style={{ maxWidth: 600, margin: "0 auto", padding: "2rem" }}>
<h1>Convex Todo</h1>
<form onSubmit={async (e) => {
e.preventDefault();
if (!title.trim()) return;
await createTask({ title });
setTitle("");
}}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="新任务..."
/>
<button type="submit">添加</button>
</form>
{tasks === undefined ? (
<p>加载中...</p>
) : (
<ul>
{tasks.map((task) => (
<li key={task._id}>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask({ id: task._id })}
/>
<span style={{ textDecoration: task.completed ? "line-through" : "none" }}>
{task.title}
</span>
<button onClick={() => removeTask({ id: task._id })}>删除</button>
</li>
))}
</ul>
)}
</main>
);
}
运行:
进阶用法¶
场景一:实时聊天¶
// convex/messages.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: { channelId: v.id("channels") },
handler: async (ctx, args) => {
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("asc")
.take(100);
// 联合查询作者信息
return Promise.all(
messages.map(async (msg) => {
const author = await ctx.db.get(msg.authorId);
return { ...msg, authorName: author?.name ?? "未知用户" };
})
);
},
});
export const send = mutation({
args: {
body: v.string(),
authorId: v.id("users"),
channelId: v.id("channels"),
},
handler: async (ctx, args) => {
await ctx.db.insert("messages", {
body: args.body,
authorId: args.authorId,
channelId: args.channelId,
likes: 0,
});
},
});
// 前端:消息自动实时更新
function Chat({ channelId }) {
const messages = useQuery(api.messages.list, { channelId });
const send = useMutation(api.messages.send);
// messages 会在任何人发送新消息时自动更新
return (
<div>
{messages?.map(msg => (
<div key={msg._id}>
<strong>{msg.authorName}: </strong>{msg.body}
</div>
))}
</div>
);
}
场景二:文件存储¶
// convex/files.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
// 生成上传 URL
export const generateUploadUrl = mutation(async (ctx) => {
return await ctx.storage.generateUploadUrl();
});
// 保存文件引用
export const saveFile = mutation({
args: {
storageId: v.id("_storage"),
fileName: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.insert("files", {
storageId: args.storageId,
fileName: args.fileName,
});
},
});
// 获取文件 URL
export const getFileUrl = query({
args: { storageId: v.id("_storage") },
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});
// 前端上传组件
function FileUpload() {
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const saveFile = useMutation(api.files.saveFile);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
// 1. 获取上传 URL
const url = await generateUploadUrl();
// 2. 上传文件
const result = await fetch(url, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
const { storageId } = await result.json();
// 3. 保存文件引用
await saveFile({ storageId, fileName: file.name });
}
return <input type="file" onChange={handleUpload} />;
}
场景三:定时任务(Cron Jobs)¶
// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
// 每小时清理过期数据
crons.interval(
"cleanup expired sessions",
{ hours: 1 },
internal.maintenance.cleanupSessions
);
// 每天凌晨2点生成报告
crons.cron(
"daily report",
"0 2 * * *",
internal.reports.generateDaily
);
// 每5分钟检查外部API
crons.interval(
"check external status",
{ minutes: 5 },
internal.monitoring.checkStatus
);
export default crons;
场景四:认证集成(Clerk)¶
// convex/auth.config.ts
export default {
providers: [
{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
applicationID: "convex",
},
],
};
// convex/users.ts
import { query, mutation } from "./_generated/server";
export const getMe = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
// 查找或创建用户
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
return user;
},
});
场景五:乐观更新¶
const toggleTask = useMutation(api.tasks.toggle).withOptimisticUpdate(
(localStore, args) => {
const tasks = localStore.getQuery(api.tasks.list, {});
if (!tasks) return;
const task = tasks.find(t => t._id === args.id);
if (!task) return;
// 本地立即更新,服务端确认后同步
localStore.setQuery(api.tasks.list, {},
tasks.map(t =>
t._id === args.id
? { ...t, completed: !t.completed }
: t
)
);
}
);
场景六:HTTP Actions(暴露 REST API)¶
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/api/webhook",
method: "POST",
handler: httpAction(async (ctx, request) => {
const body = await request.json();
// 处理 webhook
await ctx.runMutation(internal.webhooks.process, { data: body });
return new Response("OK", { status: 200 });
}),
});
http.route({
path: "/api/public/tasks",
method: "GET",
handler: httpAction(async (ctx) => {
const tasks = await ctx.runQuery(api.tasks.list);
return new Response(JSON.stringify(tasks), {
headers: { "Content-Type": "application/json" },
});
}),
});
export default http;
常见问题与排错¶
问题一:查询结果是 undefined¶
原因:Convex 查询是异步的,首次渲染时数据还未加载。
const tasks = useQuery(api.tasks.list);
// tasks 初始值是 undefined,加载完成后自动更新
// 正确处理加载状态
if (tasks === undefined) return <Loading />;
return <TaskList tasks={tasks} />;
问题二:Query 函数中不能调用外部 API¶
原因:Query 必须是确定性的(相同输入产生相同输出),这样 Convex 才能正确缓存和重新计算。
// 错误:Query 中调用外部 API
export const getData = query({
handler: async (ctx) => {
const res = await fetch("https://api.example.com/data"); // 不允许
return res.json();
},
});
// 正确:用 Action 调用外部 API,结果存到数据库
export const fetchExternalData = action({
handler: async (ctx) => {
const res = await fetch("https://api.example.com/data");
const data = await res.json();
await ctx.runMutation(internal.data.store, { data });
},
});
问题三:Mutation 超时¶
Convex Mutation 有 500ms 时间限制。耗时操作应使用 Action。
// 错误:在 Mutation 中做耗时操作
export const heavyMutation = mutation({
handler: async (ctx) => {
// 处理大量数据...可能超时
},
});
// 正确:用 Action + 分批 Mutation
export const processInBatches = action({
handler: async (ctx) => {
const items = await ctx.runQuery(internal.data.getUnprocessed);
for (const batch of chunk(items, 100)) {
await ctx.runMutation(internal.data.processBatch, { batch });
}
},
});
问题四:如何做分页¶
export const listPaginated = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.order("desc")
.paginate(args.paginationOpts);
},
});
// 前端
import { usePaginatedQuery } from "convex/react";
function MessageList() {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listPaginated,
{},
{ initialNumItems: 20 }
);
return (
<>
{results.map(msg => <Message key={msg._id} msg={msg} />)}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(20)}>加载更多</button>
)}
</>
);
}
问题五:环境变量¶
# 设置环境变量
npx convex env set API_KEY "your-secret-key"
# 列出环境变量
npx convex env list
# 在代码中使用
const apiKey = process.env.API_KEY; // 仅在 Action 中可用
参考资源¶
- 官方文档:https://docs.convex.dev/
- GitHub:https://github.com/get-convex/convex-backend
- 官方示例:https://github.com/get-convex
- Stack(社区模板):https://stack.convex.dev/
- Convex Discord:https://convex.dev/community
- Convex + Next.js 指南:https://docs.convex.dev/quickstart/nextjs
- Convex + Clerk 认证:https://docs.convex.dev/auth/clerk
- 定价:https://convex.dev/pricing