业务量上来之后,单表数据膨胀到几千万行,MySQL的B+树层级加深,一次简单查询都可能扫出几百毫秒的延迟,这时候分库分表就成了必须面对的课题。对于使用Prisma的Node.js项目来说,官方文档在这方面的指引相当克制,社区里能搜到的方案也多是浅尝辄止。本文整理一套在实际项目中验证过的做法,核心思路是:在应用层引入一个路由模块,根据分片键算出目标库和目标表,再动态获取对应的Prisma Client实例去执行查询。

为什么Prisma原生方案不够用,路由层要解决什么问题
Prisma的多数据库支持主要有两条路:一是一个schema文件里写多个datasource,用multiSchema预览特性切换;二是为每个库单独生成一个Prisma Client。前者主要面向同库多schema的场景(比如PostgreSQL的多schema),对真正物理隔离的分库支持并不好;后者能用,但库一多,代码里就会散落着大量prismaOrderDb1、prismaOrderDb2这样的实例,路由逻辑和业务代码搅在一起,后期几乎没法维护。
分库分表的本质诉求有三个:第一,把写压力和存储容量摊到多个物理库上;第二,让绝大多数带分片键的查询能精准命中一个分片,避免广播;第三,扩容时能相对平滑地迁移数据。要满足这三点,Prisma侧需要一个能力——根据路由结果拿到对应的Client实例。好在Prisma Client本身是按schema.prisma里的output路径生成的,同一个schema模板指向不同连接串,就能生成行为一致但连接不同库的客户端。我们把这个生成过程收敛到一个管理类里,业务代码就完全感知不到底层有几个库。
另一个容易被忽视的点是连接池。假设分了4个库、每个库的Prisma Client默认开10个连接,加上PgBouncer或者直连,数据库侧的总连接数会随分片数线性增长。所以在设计路由层时,必须把连接池参数做成可配置项,并且对Client实例做单例缓存,避免重复生成带来的内存和连接浪费。
路由模块设计与核心代码实现
先确定分片策略。以订单场景为例,最常见的分片键是user_id,策略取user_id % 分片数,哈希取模简单直接,数据分布也均匀。表层面再按order_id做取模,比如分成16张表。这样路由计算就是两步:先定库,再定表。
const { PrismaClient } = require('./shards/db0/client');
// 实际项目中建议每个分片的client生成到独立目录,如 prisma/shards/db0
const SHARD_COUNT = 4; // 分库数量
const TABLE_COUNT = 16; // 每个库的分表数量
// 分片配置:每个分片对应一个独立数据库连接串
const shardConfigs = [
{ name: 'db0', url: 'mysql://root:pass@127.0.0.1:3306/order_db0' },
{ name: 'db1', url: 'mysql://root:pass@127.0.0.1:3307/order_db1' },
{ name: 'db2', url: 'mysql://root:pass@127.0.0.1:3308/order_db2' },
{ name: 'db3', url: 'mysql://root:pass@127.0.0.1:3309/order_db3' },
];
// 计算目标分片
function calcShard(userId) {
return Number(BigInt(userId) % BigInt(SHARD_COUNT));
}
// 计算目标分表序号
function calcTable(orderId) {
return Number(BigInt(orderId) % BigInt(TABLE_COUNT));
}
// Client实例缓存池,保证每个分片只初始化一次
const clientPool = new Map();
function getShardClient(shardIndex) {
if (!clientPool.has(shardIndex)) {
const config = shardConfigs[shardIndex];
const { PrismaClient } = require(`./shards/${config.name}/client`);
const client = new PrismaClient({
datasources: { db: { url: config.url } },
// 连接池大小按分片数反向压缩,防止总连接数爆炸
connection_limit: 5,
pool_timeout: 10,
});
clientPool.set(shardIndex, client);
}
return clientPool.get(shardIndex);
}
// 对外的路由入口:根据分片键返回 client + 物理表名
function route(userId, orderId) {
const shardIndex = calcShard(userId);
const tableIndex = calcTable(orderId);
return {
client: getShardClient(shardIndex),
table: `order_${tableIndex.toString().padStart(2, '0')}`,
shardIndex,
tableIndex,
};
}
module.exports = { route, calcShard, calcTable, getShardClient };这里有几个细节值得展开。用BigInt做取模是为了防止ID超过JavaScript安全整数范围后出现精度丢失,一旦路由算错,数据就写进了错误的分片,排查起来非常痛苦。分表名做了补零处理,order_00到order_15,这样字典序和数值序一致,方便运维脚本遍历。Client缓存用Map即可,Node.js进程模型决定了同进程内不会并发初始化同一个key,不必加锁。
分表的DDL需要保持完全一致。可以写一个脚本从模板表批量复制结构,或者用CREATE TABLE ... LIKE,这比手写16份建表语句可靠得多。同时在每个分片库里保留一份sharding_meta表,记录当前分片数和表数,扩容时用于校验新旧路由规则是否一致。
业务层封装与跨分片查询的处理
有了路由函数,还差最后一层封装。Prisma的模型是按表名静态生成的,分表后的物理表名不在schema里,所以对分表的读写要借助$queryRaw或$executeRaw。为了保留类型安全,参数一律用模板字符串形式传入,Prisma会自动做参数化处理,不用担心注入问题。
const { route } = require('./router');
const { Prisma } = require('@prisma/client');
// 写入订单:必须带分片键
async function createOrder(order) {
const { client, table } = route(order.userId, order.orderId);
const result = await client.$executeRaw`
INSERT INTO ${Prisma.raw(table)}
(order_id, user_id, amount, status, created_at)
VALUES
(${order.orderId}, ${order.userId}, ${order.amount},
${order.status}, ${order.createdAt})
`;
return result;
}
// 按用户查订单列表:能精准路由到单个分片
async function listOrdersByUser(userId, page, size) {
const { client } = route(userId, 0n); // 表内查询走全分表时另说
// 按用户维度查询时通常扫描该用户下的所有分表
const tables = Array.from({ length: 16 }, (_, i) => `order_${i.toString().padStart(2, '0')}`);
const unionSql = Prisma.join(
tables.map(t =>
Prisma.sql`(SELECT order_id, amount, status, created_at
FROM ${Prisma.raw(t)}
WHERE user_id = ${userId})`
),
' UNION ALL '
);
const rows = await client.$queryRaw`
SELECT * FROM (${unionSql}) AS t
ORDER BY created_at DESC
LIMIT ${size} OFFSET ${(page - 1) * size}
`;
return rows;
}上面的例子暴露了分库分表最经典的矛盾:不带分片键的查询怎么办。按用户查订单时,user_id已经把范围锁定到一个库,库内16张表的UNION ALL还能接受;但如果运营后台要按时间范围查全量订单,就必须广播到所有分片,再在内存里归并排序。这种查询建议走独立的只读汇总库,通过binlog或CDC工具(比如Canal加Kafka)把分片数据同步到一张宽表里,避免广播查询拖垮线上库。
对于后台低频的全量统计,另一个务实的做法是用Promise.allSettled并发查询4个分片,每个分片内部只查聚合结果(count、sum),回来后再累加。分片数不多时延迟可控,实现成本远低于搭建同步链路。原则就一条:高频路径必须带分片键精准路由,低频路径可以容忍广播或走异构汇总。
事务、扩容与常见坑点
分库之后本地事务就失效了,这是架构层面必须提前想清楚的问题。同一个用户的数据由于路由规则固定在同一分片,单分片内的事务可以直接用$transaction,没有任何问题;跨分片的操作(比如资金划转类场景)就需要引入分布式事务方案。实践里用得最多的是基于消息表的最终一致性:在主分片的事务里同时写入业务数据和一条待发送消息记录,事务提交后异步投递到MQ,消费方在目标分片执行对应操作,失败则重试。TCC和Seata这类强一致方案在Node.js生态的支持都一般,除非资金强一致要求,否则不建议引入。
扩容是取模方案的软肋。从4库扩到8库时,取模结果会大面积变化,历史上常用的应对办法是一致性哈希或者翻倍扩容加数据迁移。推荐的做法是预留双写窗口:新规则上线后同时写新旧两个分片,读仍走旧规则,后台任务逐步迁移存量数据并校验,校验通过后切换读流量,最后停掉旧写入。整个流程的状态机要用上面提到的sharding_meta表驱动,每一步都可回滚。
最后列几个实际踩过的坑。其一,Prisma Client生成时每个分片指向不同output目录,CI流水线里要记得对每个分片都执行generate,漏掉一个本地能跑线上直接崩。其二,雪花ID做分片键时要确认workerId的分配,多进程部署下重复的机器号会导致路由撞车。其三,分表后的索引要跟着分片键走,order_id在分表内天然均匀,但如果高频查询条件不含分片键,再怎么加索引也是全分片广播。架构上先想清楚查询路径,再定分片键,这个顺序反了后面会很痛苦。
Prisma分库分表Node.js数据库路由Prisma多数据源修改时间:2026-09-16 09:48:54