在构建期间处理 10,000+ 数据库查询:Node.js 连接池故事

发布: (2026年1月14日 GMT+8 01:56)
8 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的具体内容(文章正文、代码块除外),我将为您翻译成简体中文并保留原有的格式和技术术语。

概览

  • PostgreSQL 数据库?
  • Prisma ORM?
  • Node.js 静态站点生成?

FATAL: too many connections for role "role_xxxxx"

Prisma Accelerate 内置了连接池功能,可防止此类错误。

我的构建过程在 生成 70+ 页面,每个页面都需要多个数据库查询。计算非常简单且残酷:

70 pages × 4 queries per page × concurrent execution = connection‑pool explosion.

问题:Node.js 事件循环 vs. 数据库连接

大多数教程不会告诉你:Node.js 在并发方面 太强大 了。当你生成静态页面时,Node.js 并不会礼貌地等一个页面完成后才开始下一个——它会 同时触发所有页面的生成。每个页面需要:

  1. 从数据库查询产品
  2. 查询博客文章以获取相关内容
  3. 查询分类数据
  4. 生成动态站点地图条目

这可能导致 280+ 并发数据库连接,而连接池仅限制在 5‑10 个连接。

结果: 构建失败、超时错误,以及大量的挫败感。

为什么标准解决方案失败

尝试 1:增加连接限制

DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20"

结果: 仍然失败。问题不在于限制,而是 连接创建的速率

尝试 2:手动连接管理

await prisma.$connect();
const data = await prisma.product.findMany();
await prisma.$disconnect();

结果: 让情况更糟。我在快速创建和销毁连接,压垮了数据库服务器。

尝试 3:Prisma 的内置连接池

const prisma = new PrismaClient();

结果: 不够。Prisma 的单例模式非常适合运行时请求,但静态生成的并发特性绕过了它。

解决方案:在 Node.js 中进行查询排队

我需要在构建期间 序列化 数据库操作,同时在运行时保持并发。有效的模式如下:

// lib/db.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
  queryQueue: Promise<any> | undefined;
};

const createPrismaClient = () => {
  return new PrismaClient({
    log:
      process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
  });
};

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

/**
 * Serialize queries during the production build.
 * In dev/runtime the function runs immediately.
 */
const queueQuery = async (fn: () => Promise<any>): Promise<any> => {
  if (
    process.env.NEXT_PHASE === 'phase-production-build' ||
    process.env.NODE_ENV === 'production'
  ) {
    const previousQuery = globalForPrisma.queryQueue ?? Promise.resolve();
    const currentQuery = previousQuery
      .then(() => fn())
      .catch(() => fn()); // ensure the chain continues on error
    globalForPrisma.queryQueue = currentQuery;
    return currentQuery;
  }
  return fn();
};

export async function withPrisma(
  callback: (prisma: PrismaClient) => Promise<any>
): Promise<any> {
  return queueQuery(async () => {
    try {
      return await callback(prisma);
    } catch (error) {
      console.error('Database operation failed:', error);
      throw error;
    }
  });
}

关键洞察

使用 promise 链 作为队列。每个查询在 构建期间 等待前一个查询完成,但在 运行时 则立即执行。

工作原理

上下文执行流程
构建(生产)Query 1 → Query 2 → Query 3 → … (serial)
运行时(服务器)Query 1 ↘
Query 2 → Query 3 → … (concurrent)

globalForPrisma.queryQueue 充当检查点。每个新查询:

  1. 等待前一个 promise 解析。
  2. 执行其数据库操作。
  3. 成为下一个查询的新检查点。

实现跨代码库

站点地图生成

// app/sitemap.ts
export default async function sitemap() {
  const baseUrl = process.env.BASE_URL ?? 'https://example.com';
  const staticPages = [
    { url: `${baseUrl}/`, priority: 1 },
    // …other static routes
  ];

  // ---- Products -------------------------------------------------
  let productPages: { url: string; lastModified: Date; priority: number }[] =
    [];
  try {
    const products = await getAllAmazonProducts({ limit: 1000 });
    productPages = products.map((p) => ({
      url: `${baseUrl}/products/${p.slug}`,
      lastModified: p.updatedAt,
      priority: p.featured ? 0.85 : 0.75,
    }));
  } catch (error) {
    console.error('Error fetching products for sitemap:', error);
  }

  // ---- Blog posts -----------------------------------------------
  let blogPages: { url: string; lastModified: Date; priority: number }[] = [];
  try {
    const posts = await prisma.blogPost.findMany({
      where: { status: 'published' },
    });
    blogPages = posts.map((post) => ({
      url: `${baseUrl}/blog/${post.slug}`,
      lastModified: post.updatedAt,
      priority: 0.65,
    }));
  } catch (error) {
    console.error('Error fetching blog posts for sitemap:', error);
  }

  return [...staticPages, ...productPages, ...blogPages];
}

每个 try‑catch 块都能优雅地处理失败——如果数据库调用失败,该部分会被直接跳过,而不会导致整个构建崩溃。

生产结果

指标之前之后
构建成功率~30 %100 %
平均构建时间~2 min (不稳定)45 s (稳定)
连接超时错误经常
生成的页面数70 + (经常失败)70 + (全部成功)

您可以在 elyvora.us 查看此架构处理生产流量——70 + 个基于数据库的页面,全部在没有任何连接错误的情况下构建。

经验教训

  1. 环境特定行为很重要 – 不要假设构建时的行为与运行时行为相同。Node.js 在不同上下文中表现不同。
  2. 全局状态并非总是有害 – 使用 globalThis 在模块边界之间维护一个 promise 队列,解决了静态生成期间的“多实例”问题。
  3. 在构建期间序列化数据库工作可以帮你省事 – 简单的 promise 链队列可以防止连接池耗尽,同时不牺牲运行时并发。

祝构建愉快,愿你的连接池保持健康!

优雅降级胜于完美

我的站点地图具有回退逻辑。如果商品查询失败,它仍然会生成静态页面。部分成功 > 完全失败

在构建期间记录所有信息

console.log(`✅ Sitemap generated with ${allPages.length} URLs`);
console.log(`   - Product pages: ${productPages.length}`);
console.log(`   - Blog pages: ${blogPages.length}`);

这些日志为你节省了数小时的调试时间。你无法在构建过程中附加调试器,所以 stdout 是你最好的伙伴。

何时使用此模式

  • 静态站点生成,使用数据库支持的内容
  • 页面数量多(50 页以上)
  • 受限的数据库连接池(共享主机、免费层)
  • 每页多个查询(复杂的数据关系)

对于仅运行时的应用程序(纯 API 服务器),请坚持使用标准连接池。

结论

Node.js 的异步特性通常是一个超能力。但在带有数据库依赖的静态生成过程中,它会变成一个挑战。解决方案不是与 Node.js 的并发作斗争,而是 控制并发发生的时机。通过在构建期间将查询串行化,而在运行时保持并发,你可以兼顾两者的优势:构建可靠,服务器响应快速。

更新: 该模式已在 elyvora.us 的生产环境中运行超过 3 周,未出现任何连接问题。每小时重建 70 + 页,轻松无压力。

Back to Blog

相关文章

阅读更多 »

在 Prisma API 中没人谈论的问题

Prisma 让在 Node.js 中访问 SQL 数据库变得极其简洁——模式易于阅读,使用简单的 findMany 就能快速入门。在构建了一些真实的…