人人都会AI编程

13.1 关系型数据库

更新时间:2026-07-11

在大多数 Web 应用中,持久化数据的存储与查询是核心环节之一。Node.js 与关系型数据库交互的方式从最底层的原生驱动到高度抽象的 ORM 框架,开发者可以根据项目的规模和团队的偏好灵活选择。这一节我们将以 MySQL 为主线,梳理原生驱动、连接池、事务、ORM 选型以及性能优化等关键实践。

13.1.1 原生驱动:mysql2 的使用

虽然 MySQL 官方提供了 mysql 包,但目前更推荐使用 mysql2,它在性能、Promise 支持和错误处理方面都做了显著改进,并且与 mysql 的 API 保持高度兼容。

安装 mysql2:

npm install mysql2

基础查询

mysql2 同时支持回调方式和 Promise 方式。回调方式的典型用法如下:

const mysql = require('mysql2');

const connection = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: 'password',
  database: 'test'
});

connection.query('SELECT * FROM users WHERE id = ?', [1], (err, results) => {
  if (err) throw err;
  console.log(results);
});

更推荐使用 promise() 方法获得支持 async/await 的连接对象:

const mysql = require('mysql2/promise');

async function main() {
  const connection = await mysql.createConnection({
    host: 'localhost',
    user: 'root',
    password: 'password',
    database: 'test'
  });

  const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [1]);
  console.log(rows);
  await connection.end();
}
main();

execute 方法会自动对传入的参数进行转义,防止 SQL 注入。与 query 相比,execute 性能更好,因为它使用了 MySQL 的预处理语句(prepared statement)。如果一条 SQL 需要反复执行(例如在循环中),用 execute 能减少解析开销。

连接池

生产环境中极少使用单连接,因为每一个连接都会占用 MySQL 服务器的资源,频繁创建和销毁连接也会带来不必要的消耗。正确的方式是使用连接池,它维护一定数量的连接,复用这些连接处理请求。

const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: 'localhost',
  user: 'root',
  password: 'password',
  database: 'test',
  waitForConnections: true,
  connectionLimit: 10,      // 最大连接数
  maxIdle: 10,              // 最大空闲连接数
  idleTimeout: 60000,       // 空闲连接超时
  queueLimit: 0             // 排队等待数量,0 为不限制
});

async function getUsers() {
  const [rows] = await pool.execute('SELECT * FROM users LIMIT 10');
  return rows;
}

连接池的配置需要根据 MySQL 服务器的最大连接数(max_connections 变量)和业务的实际并发量来调整。connectionLimit 设置过高可能会撑爆 MySQL 服务器,过低则会导致请求排队等待甚至超时。监控连接的利用率和等待队列长度是运维阶段的重要工作。

13.1.2 事务处理

关系型数据库的核心优势之一就是事务的 ACID 特性。在 Node.js 中,事务通常需要手动控制连接的获取和提交/回滚。

使用原生驱动处理事务

要执行事务,必须从连接池中获取一个专用连接,因为事务要求所有操作都在同一个连接上完成。

const connection = await pool.getConnection();
try {
  await connection.beginTransaction();

  await connection.execute(
    'UPDATE accounts SET balance = balance - ? WHERE id = ?',
    [100, 1]
  );
  await connection.execute(
    'UPDATE accounts SET balance = balance + ? WHERE id = ?',
    [100, 2]
  );

  await connection.commit();
  console.log('转账成功');
} catch (err) {
  await connection.rollback();
  console.error('转账失败,已回滚', err);
} finally {
  connection.release(); // 释放回连接池
}

在所有 ORM 中,事务的实现最终都依赖于底层驱动的这种模式,只是封装了 API 使其更易用。

13.1.3 ORM 框架:选型与对比

对于业务逻辑较为复杂、数据模型经常变化的项目,直接写 SQL 不仅繁琐,而且难以维护。ORM(对象关系映射)将数据库表映射为编程语言中的对象,提供面向对象的数据操作方式,同时也保留了执行原生 SQL 的能力。目前 Node.js 生态中主流的 ORM 有三个:Sequelize、TypeORM 和 Prisma。

Sequelize:成熟稳重

Sequelize 是 Node.js 历史上最久远的 ORM 之一,支持 MySQL、PostgreSQL、SQLite 等,文档齐全,社区庞大。它采用 Active Record 模式,模型本身集成了查询方法。

定义模型:

const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize('test', 'root', 'password', {
  host: 'localhost',
  dialect: 'mysql'
});

const User = sequelize.define('User', {
  id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
  name: { type: DataTypes.STRING, allowNull: false },
  email: { type: DataTypes.STRING, unique: true }
});

await sequelize.sync(); // 自动创建表(生产环境慎用,应使用迁移)

增删改查:

const user = await User.create({ name: '张三', email: 'zhangsan@example.com' });
const users = await User.findAll({ where: { name: '张三' } });
await User.update({ email: 'new@example.com' }, { where: { id: 1 } });
await User.destroy({ where: { id: 1 } });

优点:

  • 迁移(Migration)和种子数据(Seeder)工具成熟。
  • 支持关联关系(一对一、一对多、多对多)声明式定义。
  • 庞大的社区和第三方插件。

缺点:

  • API 略显老旧,Promise 时代的设计导致某些用法不够优雅。
  • TypeScript 类型推导较弱,需要手动声明接口。
  • 性能不如更轻量的查询构建器(如 Knex),在复杂查询下生成的 SQL 可能不理想。

TypeORM:TypeScript 优先

TypeORM 受到 Hibernate、Doctrine 等 Java/PHP ORM 的启发,对 TypeScript 提供了一等支持,采用装饰器或声明式配置定义实体,并支持 ActiveRecord 和 DataMapper 两种模式。

定义实体:

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ unique: true })
  email: string;
}

使用:

const userRepo = dataSource.getRepository(User);

const user = new User();
user.name = '张三';
user.email = 'zhangsan@example.com';
await userRepo.save(user);

const users = await userRepo.find({ where: { name: '张三' } });

优点:

  • 对 TypeScript 类型推导友好,实体即类型。
  • 支持多种数据库,拥有丰富的装饰器和关系配置。
  • 迁移工具和 CLI 功能完善。

缺点:

  • 学习曲线较陡,文档虽全但组织略显松散。
  • 在某些边缘场景下可能生成意料之外的 SQL,需要仔细检查。
  • 社区活跃度近两年有下降趋势,更新节奏放缓。

Prisma:新一代 ORM

Prisma 并非传统的面向对象 ORM,它首先是一种 声明式数据建模工具,通过 Prisma Schema 定义数据模型,然后自动生成类型安全的客户端。这一模式近年来受到广泛欢迎。

定义 Schema (prisma/schema.prisma):

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model User {
  id    Int     @id @default(autoincrement())
  name  String
  email String  @unique
  posts Post[]
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  content  String?
  authorId Int
  author   User   @relation(fields: [authorId], references: [id])
}

生成客户端并使用:

npx prisma generate
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

const user = await prisma.user.create({
  data: { name: '张三', email: 'zhangsan@example.com' }
});

const users = await prisma.user.findMany({
  where: { name: '张三' },
  include: { posts: true }
});

优点:

  • 类型安全贯穿整个开发流程,自动生成的类型让开发体验极佳。
  • 数据模型即文档,直观可读。
  • 迁移工具 prisma migrate 简洁易用。
  • 查询 API 清晰,不容易写出低效查询。
  • 内置连接池和查询日志。

缺点:

  • 生成的客户端体积较大(Node.js 端),需要分发到多个服务时需要注意。
  • 对自定义 SQL 的支持偏弱,虽然可以通过 $queryRaw 执行原生 SQL,但类型安全会丢失。
  • 事务 API 较新,虽然已支持交互式事务,但与传统 ORM 的事务 API 仍有差异。

选型建议

  • 中小型项目或快速原型:Prisma 的开发效率最高,类型安全减少低级错误,尤其适合 TypeScript 全栈项目。
  • 已有大量 Sequelize 代码积累的老项目:不必贸然迁移,Sequelize 足够可靠。
  • 对关系数据库高级特性(复杂关联、多态关联、查询器)要求极高:TypeORM 或 Knex(SQL 构建器)+ 简单封装的组合可能更灵活。
  • 团队核心是 SQL 专家,讨厌 ORM:直接使用 mysql2 + SQL 模板或选用 Knex 作为 SQL 构建器。

无论选择哪种 ORM,都应当保留执行原生 SQL 的能力。在遇到 ORM 生成的 SQL 效率低下时,可以优化为手写 SQL 并通过 ORM 提供的原生查询方法执行,而不必完全绕过 ORM。

13.1.4 索引优化与慢查询排查

数据库性能的大多数问题都可以归结为两类:没建索引索引设计不佳

索引设计基本原则

  • WHERE、JOIN、ORDER BY 中出现频繁的字段应建立索引
  • 区分度高的字段优先:例如 email 优于 gender
  • 复合索引遵循最左前缀:如果建立了 (A, B, C) 索引,查询条件只包含 AA+B 可以走索引,但单独查 BB+C 则不行。
  • 避免在索引列上使用函数或运算WHERE LEFT(name, 3) = 'abc' 会导致索引失效。
  • 定期审视冗余索引:联合索引 (A, B) 已经包含了 A 索引的功能,单独建 A 索引可能不再需要。

在生产环境上线前,可以使用 MySQL 的 EXPLAIN 命令查看执行计划。在 Node.js 中也可以直接在 mysql2 中执行:

const [rows] = await pool.execute('EXPLAIN SELECT * FROM users WHERE email = ?', ['test@example.com']);
console.log(rows);

关键字段 type 应为 refeq_refconstrows 值应尽可能小,Extra 中不应出现 Using filesort(除非确实需要排序)或 Using temporary

慢查询日志与排查

开启 MySQL 的慢查询日志是定位性能瓶颈的起点。在 MySQL 配置中设置:

slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1   # 超过1秒的查询即记录

之后可以使用 mysqldumpslow 工具分析慢查询日志,找出执行频率高且耗时长的语句,然后对其进行优化。

应用层也可以借助 ORM 的日志功能记录 SQL 执行时间,例如 Prisma 的 log 配置:

const prisma = new PrismaClient({
  log: [{ emit: 'stdout', level: 'query' }],
});

结合 APM 工具(如 Datadog、Elastic APM)可以更好地追踪慢查询的上下文和调用来源。

13.1.5 分库分表基础

单表数据量达到数百万乃至数千万行时,即使索引优化到位,写入和查询都可能因 B+ 树层级加深、锁竞争激增而变慢。此时需要实施水平拆分,即将一个大表的数据分布到多个结构相同但范围不重叠的小表中。

分表策略

常见的分表策略有两种:

  1. 按范围分表(Range):例如按时间拆分 orders_2024_01orders_2024_02。实现简单,但可能造成热点表。
  2. 按哈希或取模分表(Hash/Mod):例如 orders_0orders_1orders_2,通过 user_id % 3 决定路由到哪张表。数据分布均匀,但扩容时需要重新哈希。

在应用层,可以通过封装一个路由中间件来隐藏分表逻辑:

function getTableName(base, shardKey, totalShards) {
  const index = hash(shardKey) % totalShards;
  return `${base}_${index}`;
}

ORM 本身一般不直接支持分表,需要开发者手动管理。Prisma 的优势在于可以使用 $queryRaw 执行动态表名的 SQL,Sequelize 和 TypeORM 也提供原生查询方式。分表会大幅增加查询复杂性,只有确认单表瓶颈确实无法通过优化解决时才应考虑。

分库

当数据库的整体写入压力或存储容量超出单机上限时,需要将不同模块的数据划分到不同数据库实例。在微服务架构中,每个服务拥有独立数据库是最常见的分库形态。如果单体应用需要分库,则需要引入类似 Apache ShardingSphere 等中间件,或者自己实现数据源路由。

读写分离

更轻量级的缓解方案是读写分离:主库负责写,从库负责读。Node.js 中可以配置两个连接池,然后根据操作类型选择使用哪个池。

const masterPool = mysql.createPool({ /* 主库配置 */ });
const slavePool = mysql.createPool({ /* 从库配置 */ });

async function executeQuery(sql, params, isWrite = false) {
  const pool = isWrite ? masterPool : slavePool;
  return pool.execute(sql, params);
}

许多 ORM 也内置了读写分离支持,例如 Sequelize 的 replication 配置:

const sequelize = new Sequelize({
  replication: {
    read: [{ host: 'slave1' }, { host: 'slave2' }],
    write: { host: 'master' }
  },
  dialect: 'mysql'
});

以上便是 Node.js 与关系型数据库交互的核心实践。从最底层的 mysql2 到高度抽象的 ORM,选择的关键在于项目复杂度、团队习惯以及对 SQL 的控制度需求。无论采用何种工具,深入理解数据库索引、事务隔离和分布式基础都是后端开发者必备的素养。下一节我们将转向 NoSQL 数据库,探讨 MongoDB 与 Mongoose 在 Node.js 生态中的运用。