跳转至

Convex 后端平台完全指南

为什么要学 Convex

  1. 实时同步开箱即用:Convex 的核心特性是实时响应式查询。当数据库中的数据变化时,所有订阅该数据的客户端会自动收到更新,不需要你写 WebSocket 代码、配置发布/订阅系统。就像 Firebase 的实时数据库,但有完整的事务支持。

  2. TypeScript 全栈,类型安全贯穿:后端函数用 TypeScript 写,客户端调用时有完整的类型推导。你在后端改了一个字段,前端的 TypeScript 编译器会立即报错。这种端到端类型安全在 Firebase/Supabase 中是做不到的。

  3. 自动事务与一致性:Convex 的所有数据库操作都在自动管理的事务中执行。不需要手动 BEGIN/COMMIT,不需要担心竞态条件。Convex 使用乐观并发控制(OCC),冲突时自动重试。

  4. 零基础设施管理:不需要配置数据库、部署服务器、设置连接池、管理缓存。写函数 → 部署 → 自动扩缩容。Convex 是真正的 Backend-as-a-Service。

  5. 函数即 API,无需 REST/GraphQL:Convex 函数自动成为 API 端点。你定义查询函数和变更函数,Convex 框架处理序列化、验证、授权、重试等一切事务。


核心概念详解

Convex 是什么(白话解释)

想象你在用电子表格协作编辑:当同事改了一个单元格,你立刻看到变化。Convex 就是把这种体验带到了应用开发中。

你写的后端函数分三类: - 查询(Query):读取数据,客户端自动订阅,数据变了自动推送更新 - 变更(Mutation):修改数据,在事务中执行,保证一致性 - 操作(Action):调用外部 API、发邮件等副作用操作

前端调用这些函数就像调用本地函数一样,Convex 处理网络通信、缓存、重试等所有复杂性。

三种函数类型

类型用途特性限制
Query读取数据实时订阅、自动缓存、确定性不能有副作用、不能调外部API
Mutation写入数据事务性、原子性、可调度不能有副作用、不能调外部API
Action外部交互可调用外部API、可调度不能直接读写DB(需通过mutation)

数据模型

Convex 使用文档数据库(类似 MongoDB),但有关系查询能力:

// 每个文档自动有:
// _id: Id<"tableName">  -- 全局唯一 ID
// _creationTime: number  -- 创建时间戳

Convex vs Supabase vs Firebase 对比

特性ConvexSupabaseFirebase
数据库文档DB(自研)PostgreSQLFirestore (文档DB)
实时同步核心特性,自动Realtime插件核心特性
类型安全端到端TS类型需生成类型
事务自动OCC事务PostgreSQL事务有限支持
后端函数TypeScriptEdge Functions (Deno)Cloud Functions
SQL查询无(自有查询API)完整SQL
关系查询支持(索引连接)原生SQL JOIN不支持
认证集成Clerk/Auth0等内置Auth内置Auth
文件存储内置内置内置
调度任务内置Cron/Schedulepg_cronCloud 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>
  );
}

运行:

npx convex dev  # 启动 Convex 后端(另一个终端)
npm run dev     # 启动 Next.js 前端


进阶用法

场景一:实时聊天

// 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)

npm install @clerk/nextjs
// 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