首页 › tutorials › Claude Code数据库全栈:PostgreSQL七步实战

Claude Code数据库全栈:PostgreSQL七步实战

学习目标:掌握用Claude Code完成PostgreSQL项目从Schema设计、Migration管理、MCP直连、查询优化到种子数据的完整工作流。适合有基本SQL基础、想用AI提升数据库开发效率的后端开发者。

前置条件:Node.js 18+、PostgreSQL 15+(本地或远程)、Claude Code CLI已安装认证。


第一步:项目初始化与CLAUDE.md配置

数据库项目比普通前端项目更依赖上下文——表结构关系、命名规范、Migration策略一旦偏离,修复成本极高。CLAUDE.md是Claude Code理解你数据库世界的入口。

1.1 创建项目骨架

mkdir pg-claude-demo && cd pg-claude-demo
git init
npm init -y
npm install drizzle-orm pg dotenv
npm install -D drizzle-kit tsx typescript @types/pg

1.2 编写数据库专属CLAUDE.md

# Database Project

## 技术栈
- PostgreSQL 16 + Drizzle ORM
- drizzle-kit 管理migration
- TypeScript strict mode

## 数据库规范
- 表名:snake_case复数(users, order_items)
- 列名:snake_case(created_at, user_id)
- 主键:id bigint GENERATED ALWAYS AS IDENTITY
- 外键:{关联表单数}_id(user_id, order_id)
- 时间列:created_at/updated_at TIMESTAMPTZ DEFAULT now()
- 软删除:deleted_at TIMESTAMPTZ NULL
- 金额:numeric(12,2) 不用float
- 枚举:PostgreSQL ENUM类型,不用varchar

## Migration规范
- 每个migration只做一件事
- 先加列后删列,分两步migration
- 新列必须有DEFAULT或允许NULL
- 禁止在migration中SELECT/INSERT大数据
- 文件名:0001_create_users.sql 顺序编号
- 回滚脚本与正向脚本同级目录

## 查询规范
- 禁止SELECT *,明确列出列
- JOIN超过3张表必须写注释
- 分页必须用OFFSET+LIMIT或cursor
- 大批量操作用COPY代替INSERT

这份配置告诉Claude Code你项目的"宪法"——命名风格、主键策略、Migration规则,后续所有生成都会遵循。

1.3 预期结果

pg-claude-demo/
├── CLAUDE.md          # 项目规范
├── package.json
├── tsconfig.json
├── drizzle.config.ts  # 下一步创建
├── src/
│   ├── db/
│   │   ├── schema.ts  # Drizzle schema定义
│   │   ├── index.ts   # 连接池
│   │   └── migrations/
│   └── app.ts
└── .env               # DATABASE_URL

1.4 常见问题

问题 解决
Claude生成camelCase列名 在CLAUDE.md加All column names MUST be snake_case
Migration缺少回滚脚本 明确要求Every migration must have a paired rollback .sql
生成float存金额 规范已写金额:numeric(12,2),如仍出错再强调

第二步:Schema设计与Drizzle建模

不要手写SQL DDL——用Drizzle的TypeScript schema让Claude Code生成,类型安全且可逆向同步。

2.1 配置Drizzle Kit

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./src/db/migrations",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

2.2 用Claude Code生成Schema

向Claude Code发出精确的Prompt:

基于CLAUDE.md的规范,为电商系统设计Drizzle ORM schema,包含以下表:
1. users - 用户(email唯一、密码哈希、角色enum)
2. products - 商品(名称、价格numeric(12,2)、库存、状态enum)
3. orders - 订单(用户外键、总额、状态enum、地址JSON)
4. order_items - 订单项(订单外键、商品外键、数量、单价)

要求:
- 使用pgTable,每个表都要created_at/updated_at
- 外键关系用relations()定义
- 用户和商品都支持软删除
- 导出所有类型(inferSelectModel/inferInsertModel)

Claude Code会生成完整的schema文件:

// src/db/schema.ts
import {
  pgTable, bigint, varchar, text, numeric, integer,
  timestamp, pgEnum, jsonb,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";

// 枚举定义
export const userRoleEnum = pgEnum("user_role", ["customer", "admin", "vendor"]);
export const productStatusEnum = pgEnum("product_status", ["draft", "active", "archived"]);
export const orderStatusEnum = pgEnum("order_status", ["pending", "paid", "shipped", "delivered", "cancelled"]);

// 用户表
export const users = pgTable("users", {
  id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
  email: varchar({ length: 255 }).notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  name: varchar({ length: 100 }),
  role: userRoleEnum().default("customer").notNull(),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
  deletedAt: timestamp("deleted_at", { withTimezone: true }),
});

// 商品表
export const products = pgTable("products", {
  id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
  name: varchar({ length: 255 }).notNull(),
  description: text(),
  price: numeric({ precision: 12, scale: 2 }).notNull(),
  stock: integer().default(0).notNull(),
  status: productStatusEnum().default("draft").notNull(),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
  deletedAt: timestamp("deleted_at", { withTimezone: true }),
});

// 订单表
export const orders = pgTable("orders", {
  id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
  userId: bigint("user_id", { mode: "number" }).notNull().references(() => users.id),
  totalAmount: numeric("total_amount", { precision: 12, scale: 2 }).notNull(),
  status: orderStatusEnum().default("pending").notNull(),
  shippingAddress: jsonb("shipping_address").$type<{
    line1: string; city: string; zip: string; country: string;
  }>(),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});

// 订单项表
export const orderItems = pgTable("order_items", {
  id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
  orderId: bigint("order_id", { mode: "number" }).notNull().references(() => orders.id),
  productId: bigint("product_id", { mode: "number" }).notNull().references(() => products.id),
  quantity: integer().notNull(),
  unitPrice: numeric("unit_price", { precision: 12, scale: 2 }).notNull(),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});

// 关系定义
export const usersRelations = relations(users, ({ many }) => ({
  orders: many(orders),
}));

export const productsRelations = relations(products, ({ many }) => ({
  orderItems: many(orderItems),
}));

export const ordersRelations = relations(orders, ({ one, many }) => ({
  user: one(users, { fields: [orders.userId], references: [users.id] }),
  items: many(orderItems),
}));

export const orderItemsRelations = relations(orderItems, ({ one }) => ({
  order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
  product: one(products, { fields: [orderItems.productId], references: [products.id] }),
}));

// 类型导出
export type User = typeof users.$inferSelectModel;
export type NewUser = typeof users.$inferInsertModel;
export type Product = typeof products.$inferSelectModel;
export type Order = typeof orders.$inferSelectModel;
export type OrderItem = typeof orderItems.$inferSelectModel;

2.3 连接池初始化

// src/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
import * as schema from "./schema";

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30000,
});

export const db = drizzle(pool, { schema });
export { pool };

第三步:Migration生成与执行策略

Migration是数据库项目中最危险的操作。Claude Code能生成migration,但你需要一套验证流程。

3.1 Drizzle Kit自动生成

# 从schema生成migration SQL
npx drizzle-kit generate

# 输出结构:
# src/db/migrations/
# ├── 0000_marvelous_weapon_omega.sql   # 正向
# └── meta/
#     ├── _journal.json
#     └── 0000_snapshot.json

3.2 编写幂等Migration的最佳实践

当Claude Code生成migration时,要求它遵循幂等模式:

-- src/db/migrations/0001_add_product_category.sql

-- 幂等:IF NOT EXISTS
DO $$ BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_type WHERE typname = 'product_category'
  ) THEN
    CREATE TYPE product_category AS ENUM (
      'electronics', 'clothing', 'food', 'books', 'other'
    );
  END IF;
END $$;

-- 加列:带DEFAULT,对现有行安全
ALTER TABLE products
  ADD COLUMN IF NOT EXISTS category product_category
  DEFAULT 'other';

-- 加索引:CONCURRENTLY不锁表
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_category
  ON products (category);

-- 加注释
COMMENT ON COLUMN products.category IS 'Product classification for filtering';

3.3 回滚脚本配对

每个正向migration必须配对回滚:

-- src/db/migrations/0001_add_product_category_rollback.sql

DROP INDEX CONCURRENTLY IF EXISTS idx_products_category;
ALTER TABLE products DROP COLUMN IF EXISTS category;

-- 注意:不删除ENUM类型,因为可能有其他引用
-- 如果确认无引用:
-- DROP TYPE IF EXISTS product_category;

3.4 用Claude Code审查Migration

关键操作:不要盲信AI生成的migration。用Claude Code做预检:

审查 src/db/migrations/0001_add_product_category.sql:

1. 这个migration在12M行的products表上执行是否安全?
2. 是否会造成锁表?预估执行时间?
3. 回滚脚本是否完整?
4. 是否遗漏了索引?
5. 对外键约束有没有影响?

Claude Code会分析SQL并给出具体建议,例如:

ALTER TABLE products ADD COLUMN 在PostgreSQL中对大表是安全的(不重写表),但 CREATE INDEX 应该用 CONCURRENTLY 避免锁写。回滚脚本需要先删索引再删列。

3.5 执行Migration

# 开发环境:直接推送schema到数据库
npx drizzle-kit push

# 生产环境:执行已审查的migration文件
npx drizzle-kit migrate

# 验证:检查数据库是否与schema一致
npx drizzle-kit check

第四步:MCP直连PostgreSQL

让Claude Code直接读取数据库状态——表结构、索引、数据分布——而不需要你手动复制粘贴。

4.1 方案一:DBHub MCP Server

DBHub是目前最成熟的数据库MCP,支持PostgreSQL、MySQL、SQLite、SQL Server。

// .claude/settings.json 或 ~/.claude/settings.json
{
  "mcpServers": {
    "dbhub": {
      "command": "npx",
      "args": ["-y", "@anthropic/dbhub", "--url", "postgresql://user:pass@localhost:5432/mydb"]
    }
  }
}

DBHub提供的核心能力:

工具 用途
list-tables 列出所有表和行数
describe-table 表结构、索引、约束
read-rows 安全读取数据(只读)
execute-query 执行SQL(需授权)
list-constraints 外键和唯一约束

4.2 方案二:PostgreSQL MCP Server(读写)

{
  "mcpServers": {
    "postgres": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-postgres",
               "postgresql://user:pass@localhost:5432/mydb"]
    }
  }
}

4.3 MCP连接后的实际用法

连接MCP后,Claude Code可以直接:

# 查看当前数据库状态
列出所有表和它们的行数

# 检查索引
products表上有哪些索引?有没有未使用的索引?

# 分析查询性能
对以下查询做EXPLAIN ANALYZE分析:
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.created_at > '2025-01-01'
GROUP BY u.id
ORDER BY order_count DESC
LIMIT 20;

# 对比schema差异
对比 src/db/schema.ts 和数据库实际结构,告诉我哪些地方不一致

这种"数据库即上下文"的模式,让Claude Code不再依赖你的描述,而是直接感知数据库的真实状态。

4.4 安全注意事项

MCP数据库连接安全铁律:
1. 永远不要在生产数据库上配置读写MCP
2. 开发环境用只读连接(read-only user)
3. .env中的DATABASE_URL不要提交到git
4. MCP的SQL执行需要用户确认(Claude Code默认行为)
5. 定期轮换数据库密码

第五步:查询优化实战

这是Claude Code在数据库领域ROI最高的场景——粘贴慢查询,获得优化方案。

5.1 慢查询诊断流程

flowchart TD
    A[发现慢查询] --> B[获取EXPLAIN ANALYZE]
    B --> C{粘贴给Claude Code}
    C --> D[分析扫描类型]
    D --> E{Seq Scan?}
    E -->|是| F[检查缺失索引]
    E -->|否| G{Index Scan但仍慢?}
    G -->|是| H[检查索引选择性]
    G -->|否| I{Nested Loop?}
    I -->|是| J[检查JOIN条件索引]
    I -->|否| K[检查Sort/GroupBy]
    F --> L[Claude生成优化查询+索引]
    H --> L
    J --> L
    K --> L
    L --> M[验证优化效果]
    M --> N{查询时间降低?}
    N -->|是| O[创建migration添加索引]
    N -->|否| P[调整查询逻辑]

5.2 N+1查询检测与修复

给Claude Code一段ORM代码,它能发现N+1问题:

// 问题代码:N+1查询
// Claude Code会标记这段代码有问题
async function getUserOrders(userId: number) {
  const orders = await db.select().from(ordersTable)
    .where(eq(ordersTable.userId, userId));

  // 每个order都查一次product —— N+1!
  for (const order of orders) {
    const items = await db.select().from(orderItemsTable)
      .where(eq(orderItemsTable.orderId, order.id));
    order.items = items;
  }
  return orders;
}

Claude Code建议的修复:

// 修复:一次查询获取所有数据
async function getUserOrdersOptimized(userId: number) {
  const result = await db
    .select({
      order: ordersTable,
      item: orderItemsTable,
      product: productsTable,
    })
    .from(ordersTable)
    .innerJoin(orderItemsTable, eq(ordersTable.id, orderItemsTable.orderId))
    .innerJoin(productsTable, eq(orderItemsTable.productId, productsTable.id))
    .where(eq(ordersTable.userId, userId));

  // 在内存中组装
  const orderMap = new Map<number, Order & { items: OrderItem[] }>();
  for (const row of result) {
    if (!orderMap.has(row.order.id)) {
      orderMap.set(row.order.id, { ...row.order, items: [] });
    }
    orderMap.get(row.order.id)!.items.push(row.item);
  }
  return Array.from(orderMap.values());
}

5.3 EXPLAIN ANALYZE解读

Claude Code最擅长解读PostgreSQL执行计划:

-- 粘贴慢查询的EXPLAIN ANALYZE输出给Claude Code:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT p.name, sum(oi.quantity * oi.unit_price) as revenue
FROM products p
JOIN order_items oi ON p.id = oi.product_id
JOIN orders o ON oi.order_id = o.id
WHERE o.created_at BETWEEN '2025-01-01' AND '2025-12-31'
  AND o.status IN ('paid', 'delivered')
GROUP BY p.id
ORDER BY revenue DESC
LIMIT 10;

Claude Code可能给出这样的分析:

诊断结果:
1. orders表在created_at上做了Seq Scan → 需要复合索引 (status, created_at)
2. order_items的JOIN用Nested Loop → product_id上缺少索引
3. 排序在内存中完成但用了external merge → work_mem可能不够

建议索引:
CREATE INDEX CONCURRENTLY idx_orders_status_created
  ON orders (status, created_at)
  WHERE status IN ('paid', 'delivered');  -- 部分索引,更小更快

CREATE INDEX CONCURRENTLY idx_order_items_product
  ON order_items (product_id)
  INCLUDE (quantity, unit_price);  -- 覆盖索引,避免回表

5.4 索引设计五原则

让Claude Code遵循这五条规则设计索引:

## 索引设计规则(加入CLAUDE.md)

1. **等值条件列在前,范围条件列在后**
   WHERE status = 'active' AND created_at > '2025-01-01'
   → INDEX (status, created_at) ✓
   → INDEX (created_at, status) ✗

2. **高选择性列在前**
   WHERE country = 'CN' AND status = 'active'
   country选择性低(大量CN),status选择性高
   → INDEX (status, country) ✓

3. **能用部分索引就不建全表索引**
   WHERE status = 'active'
   → WHERE status = 'active' 的部分索引更小

4. **覆盖索引避免回表**
   SELECT id, name FROM users WHERE email = ?
   → INDEX (email) INCLUDE (name)

5. **监控索引使用率**
   每月检查pg_stat_user_indexes的idx_scan
   idx_scan = 0 的索引考虑删除

第六步:种子数据与测试数据工厂

真实的测试数据比空表调试效率高10倍。Claude Code能生成符合约束的测试数据。

6.1 数据生成策略

// src/db/seed.ts
import { db } from "./index";
import { users, products, orders, orderItems } from "./schema";
import { faker } from "@faker-js/faker/locale/zh_CN";

async function seed() {
  console.log("🌱 开始播种...");

  // 1. 用户
  const userList = Array.from({ length: 100 }, () => ({
    email: faker.internet.email(),
    passwordHash: "$2b$10$dummy.hash.for.seed.data",
    name: faker.person.fullName(),
    role: faker.helpers.arrayElement(["customer", "customer", "customer", "vendor"]),
  }));
  const insertedUsers = await db.insert(users).values(userList).returning();

  // 2. 商品
  const productList = Array.from({ length: 500 }, () => ({
    name: faker.commerce.productName(),
    description: faker.commerce.productDescription(),
    price: faker.commerce.price({ min: 1, max: 9999, dec: 2 }),
    stock: faker.number.int({ min: 0, max: 1000 }),
    status: faker.helpers.arrayElement(["draft", "active", "active", "active", "archived"]),
  }));
  const insertedProducts = await db.insert(products).values(productList).returning();

  // 3. 订单 + 订单项(每个用户1-5个订单,每个订单1-5个商品)
  for (const user of insertedUsers) {
    const orderCount = faker.number.int({ min: 1, max: 5 });
    for (let i = 0; i < orderCount; i++) {
      const itemCount = faker.number.int({ min: 1, max: 5 });
      const selectedProducts = faker.helpers.arrayElements(insertedProducts, itemCount);

      const orderResult = await db.insert(orders).values({
        userId: user.id,
        totalAmount: "0", // 先插入,后面更新
        status: faker.helpers.arrayElement(["pending", "paid", "shipped", "delivered"]),
        shippingAddress: {
          line1: faker.location.streetAddress(),
          city: faker.location.city(),
          zip: faker.location.zipCode(),
          country: "CN",
        },
      }).returning();

      let total = 0;
      for (const product of selectedProducts) {
        const qty = faker.number.int({ min: 1, max: 3 });
        await db.insert(orderItems).values({
          orderId: orderResult[0].id,
          productId: product.id,
          quantity: qty,
          unitPrice: product.price,
        });
        total += parseFloat(product.price) * qty;
      }

      // 更新订单总额
      await db.update(orders)
        .set({ totalAmount: total.toFixed(2) })
        .where(eq(orders.id, orderResult[0].id));
    }
  }

  console.log("✅ 播种完成!");
}

seed().catch(console.error);

6.2 让Claude Code生成种子数据的技巧

为电商数据库生成种子数据:

要求:
1. 50个用户,email不重复,密码统一用测试哈希
2. 200个商品,价格在10-5000之间
3. 每个用户2-8个订单,订单状态分布:
   - pending 10%, paid 30%, shipped 25%, delivered 30%, cancelled 5%
4. 订单项的unit_price必须等于下单时商品的价格
5. 订单的total_amount必须等于所有订单项的(quantity * unit_price)之和
6. 所有外键关系必须正确

输出为 drizzle-orm 的 insert 语句

关键点:第5、6条约束是Claude Code容易犯错的地方——明确指出外键关系和金额一致性,能大幅提升生成质量。

6.3 执行种子

# 运行种子脚本
npx tsx src/db/seed.ts

# 验证数据
npx tsx -e "
const { db } = require('./src/db/index');
const { users, products, orders } = require('./src/db/schema');
import { sql } from 'drizzle-orm';

async function check() {
  const counts = await Promise.all([
    db.select({ count: sql\`count(*)\` }).from(users),
    db.select({ count: sql\`count(*)\` }).from(products),
    db.select({ count: sql\`count(*)\` }).from(orders),
  ]);
  console.log('Users:', counts[0][0].count);
  console.log('Products:', counts[1][0].count);
  console.log('Orders:', counts[2][0].count);
}
check();
"

第七步:完整实战——商品搜索与排序优化

将前六步整合为一个真实场景:为商品列表实现高性能搜索+排序+分页。

7.1 需求描述

实现商品搜索接口:
- 支持按名称模糊搜索(LIKE '%keyword%')
- 支持按分类、价格区间、状态筛选
- 支持按价格/销量/创建时间排序
- 分页:cursor-based,每页20条
- 目标:< 50ms响应时间

使用Drizzle ORM,不要原始SQL。
配合必要的索引和EXPLAIN验证。

7.2 Claude Code生成的实现

// src/repositories/product.search.ts
import { db } from "../db";
import { products, orderItems, sql, and, or, ilike, gte, lte, desc, asc, eq } from "drizzle-orm";
import type { SQL } from "drizzle-orm";

interface ProductSearchParams {
  keyword?: string;
  category?: string;
  minPrice?: string;
  maxPrice?: string;
  status?: string;
  sortBy?: "price" | "sales" | "created_at";
  sortOrder?: "asc" | "desc";
  cursor?: string; // base64 encoded JSON: { field: value }
  limit?: number;
}

interface ProductSearchResult {
  items: Product[];
  nextCursor: string | null;
  total: number;
}

export async function searchProducts(
  params: ProductSearchParams
): Promise<ProductSearchResult> {
  const {
    keyword, category, minPrice, maxPrice, status,
    sortBy = "created_at", sortOrder = "desc",
    cursor, limit = 20,
  } = params;

  // 构建WHERE条件
  const conditions: SQL[] = [];

  // 软删除过滤
  conditions.push(sql`${products.deletedAt} IS NULL`);

  if (keyword) {
    conditions.push(ilike(products.name, `%${keyword}%`));
  }
  if (category) {
    conditions.push(eq(products.category, category as any));
  }
  if (minPrice) {
    conditions.push(gte(products.price, minPrice));
  }
  if (maxPrice) {
    conditions.push(lte(products.price, maxPrice));
  }
  if (status) {
    conditions.push(eq(products.status, status as any));
  }

  // Cursor分页
  if (cursor) {
    const cursorData = JSON.parse(Buffer.from(cursor, "base64").toString());
    if (sortOrder === "desc") {
      conditions.push(sql`${products[sortBy]} < ${cursorData.value}`);
    } else {
      conditions.push(sql`${products[sortBy]} > ${cursorData.value}`);
    }
  }

  const whereClause = and(...conditions);

  // 按销量排序需要子查询
  if (sortBy === "sales") {
    const salesSubquery = db
      .select({
        productId: orderItems.productId,
        totalSold: sql<number>`coalesce(sum(${orderItems.quantity}), 0)`,
      })
      .from(orderItems)
      .groupBy(orderItems.productId)
      .as("sales");

    const result = await db
      .select({
        id: products.id,
        name: products.name,
        price: products.price,
        stock: products.stock,
        status: products.status,
        createdAt: products.createdAt,
        totalSold: salesSubquery.totalSold,
      })
      .from(products)
      .leftJoin(salesSubquery, eq(products.id, salesSubquery.productId))
      .where(whereClause)
      .orderBy(
        sortOrder === "desc"
          ? desc(salesSubquery.totalSold)
          : asc(salesSubquery.totalSold)
      )
      .limit(limit + 1);

    const hasNext = result.length > limit;
    const items = hasNext ? result.slice(0, -1) : result;
    const lastItem = items[items.length - 1];

    return {
      items,
      nextCursor: hasNext
        ? Buffer.from(JSON.stringify({
            field: "sales",
            value: lastItem.totalSold,
          })).toString("base64")
        : null,
      total: -1, // cursor分页不返回总数,单独count
    };
  }

  // 其他排序:直接查products表
  const result = await db
    .select()
    .from(products)
    .where(whereClause)
    .orderBy(
      sortOrder === "desc"
        ? desc(products[sortBy])
        : asc(products[sortBy])
    )
    .limit(limit + 1);

  const hasNext = result.length > limit;
  const items = hasNext ? result.slice(0, -1) : result;
  const lastItem = items[items.length - 1];

  return {
    items,
    nextCursor: hasNext
      ? Buffer.from(JSON.stringify({
          field: sortBy,
          value: lastItem[sortBy],
        })).toString("base64")
      : null,
    total: -1,
  };
}

7.3 配套索引Migration

Claude Code根据查询模式生成的索引:

-- src/db/migrations/0002_product_search_indexes.sql

-- 名称模糊搜索:pg_trgm三元组索引
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_name_trgm
  ON products USING gin (name gin_trgm_ops);

-- 分类+状态组合筛选
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_category_status
  ON products (category, status)
  WHERE deleted_at IS NULL;

-- 价格范围查询
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_price
  ON products (price)
  WHERE deleted_at IS NULL;

-- 创建时间排序(分页cursor)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_created_desc
  ON products (created_at DESC)
  WHERE deleted_at IS NULL;

-- 订单项按商品聚合(销量排序)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_items_product_qty
  ON order_items (product_id)
  INCLUDE (quantity);

7.4 性能验证

# 执行migration
npx drizzle-kit migrate

# 用Claude Code验证查询计划

将以下内容发给Claude Code(配合MCP直连数据库):

在已连接的PostgreSQL上执行以下验证:

1. EXPLAIN ANALYZE 搜索 "手机" 按销量排序前20条
2. EXPLAIN ANALYZE 价格100-500区间按创建时间排序
3. 检查所有新建索引是否被使用
4. 如果有Seq Scan,分析原因并给出修复建议

7.5 完整工作流总览

flowchart LR
    A[CLAUDE.md规范] --> B[Schema定义]
    B --> C[Drizzle生成Migration]
    C --> D[MCP直连验证]
    D --> E[种子数据填充]
    E --> F[查询开发]
    F --> G[EXPLAIN优化]
    G --> H[索引Migration]
    H --> D

    style A fill:#e1f5fe
    style D fill:#fff3e0
    style G fill:#e8f5e9

高级技巧:五个让效率翻倍的Prompt模式

模式一:Schema Diff审查

对比 git HEAD~1 和 HEAD 的 schema.ts 变更:
1. 哪些表被修改了?
2. 是否有破坏性变更(删列、改类型)?
3. 需要几个migration?给出SQL。

模式二:逆向工程

我用MCP连接了一个已有的PostgreSQL数据库。
请:
1. 列出所有表的结构
2. 生成对应的Drizzle schema.ts
3. 生成relations()关系定义
4. 导出所有TypeScript类型

这对接手遗留项目极其有用。

模式三:性能巡检

对当前数据库做全面性能巡检:
1. 列出所有缺失索引的查询模式(从pg_stat_statements)
2. 找出未使用的索引(idx_scan = 0)
3. 检查膨胀率超标的表(dead_tuples ratio > 10%)
4. 建议需要VACUUM的表
5. 检查外键是否都有对应索引

模式四:Migration安全检查

我准备在生产库执行以下migration:

[paste SQL here]

请检查:
1. 是否会锁表?锁类型和预估时间
2. 在1000万行表上的执行时间估算
3. 是否有数据丢失风险
4. 回滚方案是否可行
5. 是否需要SET statement_timeout

模式五:数据一致性校验

编写SQL校验以下数据一致性规则:
1. 订单总额 = SUM(订单项.数量 * 单价)
2. 用户不能有重复email
3. 订单项的商品必须存在且未删除
4. 库存不能为负数
5. 外键无孤立记录

输出为可重复执行的检查脚本,每条规则输出通过/失败+违规数量。

常见问题排查表

症状 可能原因 Claude Code诊断命令
Schema生成了camelCase CLAUDE.md未加载 确认CLAUDE.md在项目根目录
Migration锁表 缺少CONCURRENTLY 审查migration SQL
查询用了Seq Scan 索引缺失或选择性低 EXPLAIN ANALYZE分析
N+1查询 循环中做查询 审查ORM代码模式
种子数据外键冲突 插入顺序错误 检查insert执行顺序
MCP连不上 URL格式或权限 检查连接字符串和pg_hba.conf
查询返回重复行 JOIN关系不对 检查关联条件和GROUP BY
Cursor分页跳过记录 排序字段有重复值 改用(id, sort_field)复合cursor

延伸阅读

参考来源

  1. How to Use Claude Code for Database Design and Migration — Atticus Li的数据库设计Migration全流程指南
  2. Database and Code Migrations | Developer Toolkit — 零停机Migration设计与执行工作流
  3. Query Optimization with Claude Code: Fix N+1, Slow Queries — N+1检测与索引优化实战
  4. Using Claude Code for Database Work | Airbyte — 数据库查询、Schema、Migration、优化全场景
  5. How to Use Claude Code with Your Database: DBHub — DBHub MCP配置与SQL查询生成

训练营适用性:本教程适合"全栈开发训练营"(Day 3-4数据库模块),建议教学时长4小时(2小时讲解+2小时实操)。学员需具备基本SQL和TypeScript基础。可作为"AI辅助后端开发"专题课的核心实验。

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入