业务跑起来之后,数据库往往最先成为瓶颈。一张订单表从几十万涨到五千万,索引再怎么优化,一次深分页查询照样能把MySQL拖垮。这时候读写分离已经救不了场,分库分表才是正解。Java体系里有ShardingSphere这样成熟的框架,但Node.js生态里可选择的方案并不多,要么依赖Proxy层多一跳网络,要么干脆自己动手。本文就来实现一个嵌入应用层的Node.js分库分表中间件,从路由算法到SQL改写,把关键环节逐一拆解。

整体架构与核心模块设计
分库分表中间件的本质,是在应用与真实数据库之间加一层路由代理。它接收业务层发来的逻辑SQL,根据分片键计算出目标库和目标表,把逻辑表名改写成真实表名,再把改写后的SQL发往对应的物理库执行,最后将结果归并返回。
我们把中间件拆成四个核心模块:分片路由器负责根据分片键计算目标节点;SQL解析器负责提取SQL中的分片键值并改写表名;连接池管理器维护多个物理库的连接池;结果归并器处理跨分片查询的数据合并与排序。整体调用链路如下:
const sharding = createSharding({
// 逻辑库名
logicDb: 'order_db',
// 物理库连接配置,支持一主多从
datasources: [
{ host: '127.0.0.1', database: 'order_db_0' },
{ host: '127.0.0.1', database: 'order_db_1' },
{ host: '127.0.0.1', database: 'order_db_2' },
{ host: '127.0.0.1', database: 'order_db_3' }
],
// 分片规则
rules: {
t_order: {
dbShardColumn: 'user_id', // 分库键
tableShardColumn: 'user_id',// 分表键
dbCount: 4, // 库数量
tableCount: 4 // 每库表数量
}
}
});
// 业务层调用,和普通查询没有区别
const rows = await sharding.query(
'SELECT * FROM t_order WHERE user_id = ?',
[10001]
);这个设计的关键点在于:业务代码完全不感知分片细节,依然操作逻辑表t_order,中间件在内部完成所有路由和改写。这样既不引入额外的网络开销,又保留了随时调整分片规则的灵活性。
分片路由算法的实现
路由算法决定了数据如何分布,选错了后期迁移成本极高。目前主流方案有两种:哈希取模和范围分片。
哈希取模适合数据分布均匀、没有明显冷热特征的场景,比如按用户ID分片。它的优点是实现简单、负载均衡,缺点是一旦扩容就需要大规模数据迁移。范围分片则按时间或ID区间切分,比如按月分表,天然支持扩容,历史数据归档也方便,但容易产生热点问题——大家都写最新分片,老分片闲置。订单场景通常会结合两者:按用户ID哈希分库,按时间范围分表。
下面是路由器的核心实现:
class ShardingRouter {
constructor(rule) {
this.rule = rule;
}
// 计算目标库下标
routeDb(shardValue) {
const hash = this.hash(shardValue);
// 先定位表,再由表反推库,保证库表分布均匀
const tableIndex = hash % (this.rule.dbCount * this.rule.tableCount);
return Math.floor(tableIndex / this.rule.tableCount);
}
// 计算目标表下标
routeTable(shardValue) {
const hash = this.hash(shardValue);
const tableIndex = hash % (this.rule.dbCount * this.rule.tableCount);
return tableIndex % this.rule.tableCount;
}
// 字符串转正数哈希,避免JS位运算溢出问题
hash(value) {
const str = String(value);
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h * 31 + str.charCodeAt(i)) % 2147483647;
}
return h;
}
}
// 使用示例:user_id = 10001 路由到某个确定的库和表
const router = new ShardingRouter({ dbCount: 4, tableCount: 4 });
router.routeDb(10001); // 例如返回 2
router.routeTable(10001); // 例如返回 1,即 t_order_1这里有个细节值得注意:routeDb没有直接对dbCount取模,而是先算出全局表下标再反推库号。如果直接对库数量取模,同一个用户的数据会集中落在每库编号相同的表上,扩容时数据搬迁的规律性差,先定位表再定位库能让数据打散得更彻底。
另外哈希函数一定要用字符串累乘而不是简单的hashCode位运算,因为JavaScript的位运算只有32位,数值一大就溢出,可能导致不同ID算出相同路由,造成数据错乱。
SQL解析与改写机制
中间件拿到逻辑SQL后,需要做两件事:提取WHERE条件中的分片键值用于路由,然后把逻辑表名替换成真实表名。完整的SQL解析器可以借助node-sql-parser这类库生成AST,轻量场景下用正则也能搞定。
const { Parser } = require('node-sql-parser');
const parser = new Parser();
class SqlRewriter {
constructor(rules) {
this.rules = rules;
}
// 解析SQL,提取分片键值
extractShardValue(sql, params) {
const ast = parser.astify(sql);
const rule = this.rules[ast.table[0].table];
if (!rule) return null;
const column = rule.dbShardColumn;
let shardValue = null;
// 遍历WHERE条件查找分片键
const walk = (node) => {
if (!node) return;
if (node.type === 'binary_expr' && node.operator === '='
&& node.left.column === column) {
shardValue = node.right.value;
}
if (node.left) walk(node.left);
if (node.right) walk(node.right);
};
walk(ast.where);
return { rule, shardValue, ast };
}
// 改写逻辑表名为物理表名
rewrite(sql, tableIndex) {
return sql.replace(
/t_order\b/g,
`t_order_${tableIndex}`
);
}
}改写时有几类SQL需要特别对待。带分片键的等值查询,直接改写后发往单库单表,性能无损;不带分片键的查询,比如只按订单号查,就必须广播到所有分片再归并结果。这也是为什么分片键的选择如此重要——它决定了90%以上的查询能否精准路由。
对于INSERT语句,如果表名是逻辑表,还需要把分片键值代入路由算法算出真实表名。UPDATE和DELETE则必须校验WHERE条件里包含分片键,否则全分片广播更新风险极大,中间件应该直接拒绝执行,这是保护数据一致性的重要防线。
多数据源连接池与结果归并
分库之后,连接池管理变成一个现实问题。假设4个库各配10个连接,就是40个连接,库数量再翻几倍,连接数会迅速膨胀。方案是用mysql2的createPool为每个物理库维护独立连接池,并通过Promise并行下发广播查询:
const mysql = require('mysql2/promise');
class DataSourceManager {
constructor(datasources) {
// 为每个物理库创建独立连接池
this.pools = datasources.map(ds => mysql.createPool({
...ds,
connectionLimit: 10,
queueLimit: 0
}));
}
// 并行查询多个分片
async queryOnShards(dbIndexes, sql, params) {
const tasks = dbIndexes.map(dbIndex =>
this.pools[dbIndex].query(sql, params)
.then(([rows]) => rows)
.catch(err => { throw new Error(`分片${dbIndex}查询失败: ${err.message}`); })
);
return Promise.all(tasks);
}
}
// 归并器:跨分片查询结果合并、排序、分页
function mergeResults(results, { orderBy, limit, offset }) {
let all = results.flat();
if (orderBy && orderBy.column) {
all.sort((a, b) =>
orderBy.order === 'DESC'
? b[orderBy.column] - a[orderBy.column]
: a[orderBy.column] - b[orderBy.column]
);
}
return all.slice(offset || 0, (offset || 0) + (limit || all.length));
}归并环节是性能优化的重点。深分页时不要把所有分片的数据都拉回来,而是让每个分片先本地排序取前N条,归并器只在这批小数据集里做最终排序分页,网络传输量能降低几个数量级。聚合函数同理,COUNT可以让各分片返回计数后求和,AVG则需要各分片返回SUM和COUNT再计算,直接对各分片的平均值求平均是错误的。
遗留难题与工程化建议
自研中间件绕不开几个经典难题。分布式主键方面,自增ID在多库会重复,可以采用雪花算法生成全局唯一ID,或者引入号段模式。雪花算法在Node.js里要注意时钟回拨问题,简单处理是回拨时抛错重试,要求高的场景可以用等待策略。
跨分片事务是最棘手的部分。强一致事务需要引入两阶段提交或者TCC,复杂度很高。务实的建议是从业务层面规避:让同一用户的订单落在同一个库,绝大多数事务就退化成了单库事务,这也是分片键要优先按用户维度设计的原因。实在避不开的,用消息表加最终一致来兜底。
扩容迁移方面,建议一开始就把分片数规划得宽裕些,比如直接上32库64表,前期多个库可以部署在同一实例上,数据增长后再拆到独立机器,逻辑分片不变就不需要迁移数据。最后,中间件上线前务必配合压测验证路由正确性,用全量数据回放对比分片前后的查询结果,确保没有数据错乱再放量。