ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Node.js分库分表实战:基于Sequelize自建动态分表路由层

Node.js分库分表实战:基于Sequelize自建动态分表路由层 深夜的监控告警把值班群炸醒订单库单表已经逼近5000万行一个带关联统计的查询跑了3.7秒连接数被打满DBA在工单上只留了一句话——“建议分库分表”。针对高并发、数据量大的场景分库分表确实是绕不开的一步。但真正动手之前你会发现Java生态里有ShardingSphere这样成熟的中间件而Node.js生态里主流ORM框架对动态分表的支持基本是“约等于没有”Sequelize没内置分表路由TypeORM的实体元数据启动时就写死了Prisma干脆连动态表名都不让你改。指望框架开箱即用地解决分表目前还不太现实。这篇文章把我最近两个月调研和落地的全过程分享出来先带你摸清各ORM的底牌再给一套基于Sequelize自建动态分表路由层的完整思路最后盘点那些拆表之后才踩得到的坑。适合正被单表数据量困扰、或者在评估Node.js技术栈分表方案的团队参考。1. 哪些信号说明你真该分表了先别急着拆1.1 单表数据量过大的三种典型表现不是所有项目都需要分表但当你出现下面三类情况时大概率躲不过去。第一类是查询性能劣化。MySQL的InnoDB底层是B树单表数据量超过千万级之后索引层数从3层涨到4层每次查询的磁盘IO次数明显增加。你会看到监控里慢查询越来越多明明索引都建了explain也正常就是响应时间不稳定。这时候不是加个索引能救回来的因为索引本身的维护成本也在上升。第二类是写入热点和锁竞争。单表写入TPS到达一定量级后InnoDB的行锁、间隙锁、自增锁都会变成瓶颈。尤其是批量更新场景锁等待一多整个业务的RT曲线就会出现明显的锯齿状抖动。第三类是运维层面的问题。5亿行的表做一次DDL变更即使使用gh-ost或者pt-online-schema-change耗时也是小时级别备份和恢复时间更是灾难。这些成本平时不显眼等出故障要紧急回滚数据的时候你会非常想穿越回去把表拆掉。1.2 分库分表和动态分表的边界别搞混分库分表通常连在一起说但它们是两层不同的优化分库把数据分散到多个数据库实例解决的是连接数、IO压力、CPU资源竞争的问题。分表把同一个业务表的数据按规则分散到多张物理表通常是同一个实例或同一个库解决的是单表数据量过大、索引退化、锁竞争的问题。而“动态分表”是分表落地的核心机制运行时根据某个分片键的值动态计算出这条数据应该读写哪一张物理表。比如用户ID为10001的订单路由到orders_1用户ID为10002的订单路由到orders_2。这个过程由路由层完成对上层业务逻辑透明或半透明。静态分表是写死访问某张表动态分表则把“表名”变成函数输出。真正的难点恰恰在这个路由层怎么分、怎么查、怎么扩容、怎么保证一致性。1.3 分表前必须定下的两件事分片键和路由策略先定分片键再谈其他。分片键是分表方案的灵魂选错了后面全是泪。核心原则很简单分片键必须是你业务里最高频、几乎每次查询都带的那个条件。对大多数交易系统来说user_id就是最优分片键对IoT场景通常是设备ID或者时间对流水类数据经常用时间范围。反面教材我也见过有人拿order_no当分片键结果业务大量查询是按user_id来的每次都要把16张表全扫一遍然后合并比不分表还慢。所以这个决策必须拉上最熟悉业务的人一起定别让DBA和架构师拍脑袋。路由策略有三种主流选择哈希取模index shardKey % N。实现最简单数据分布最均匀但扩容时几乎全量迁移。范围分表按时间或ID区间拆分。适合时序数据天然支持冷热分离但容易产生热点表。一致性哈希每个物理表承接多个虚拟节点扩容只迁移部分数据。实现复杂度最高适合容量不可控的长期方案。2. Node.js主流ORM框架对动态分表的支持度摸底Node.js生态里没有ShardingSphere这样的统一中间件所以大多数团队的分表能力取决于ORM框架本身。我把常见的几个框架逐一扒了一遍。2.1 Sequelize没有内置路由但有灵活的define机制Sequelize是老牌ORM它没有内置分表路由但它的模型定义方式是运行时执行sequelize.define()这就给动态表名留了一扇门你可以为每张物理表动态创建一个Model。const { Sequelize, DataTypes } require(sequelize); const sequelize new Sequelize(mydb, user, pass, { host: localhost, dialect: mysql, }); const modelCache new Map(); function getOrderModel(userId) { const index getTableIndex(userId); // 路由函数 const tableName orders_${index}; if (modelCache.has(tableName)) { return modelCache.get(tableName); } const Order sequelize.define(Order_${index}, { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, user_id: { type: DataTypes.BIGINT, allowNull: false }, order_no: { type: DataTypes.STRING(64), allowNull: false, unique: true }, amount: { type: DataTypes.DECIMAL(10, 2), allowNull: false }, status: { type: DataTypes.INTEGER, defaultValue: 0 }, }, { tableName, freezeTableName: true, underscored: true, indexes: [ { fields: [user_id] }, { fields: [order_no] }, ], }); modelCache.set(tableName, Order); return Order; }为什么要在modelCache里进行缓存因为每次调用sequelize.define都会生成一个新的Model并且注册到Sequelize内部的模型管理器。如果每次查询都动态定义内存里会堆积大量重复的模型定义关联关系和钩子也被重复注册跑几个小时就开始出诡异问题。Sequelize的另一条退路是直接写原生SQL。跨分片聚合、多表UNION这类操作用sequelize.query()反而比走模型层更可控。我在后面第三部分会给出具体代码。2.2 TypeORM装饰器把表名写死了运行时改起来很别扭TypeORM的问题在于它的设计哲学用类装饰器在编译期绑定实体和表名。你以为到了运行时改一个tableName就行实际上metadata在应用初始化时已经固化了。TypeORM社区给出过EntitySchema方案不依赖装饰器而是用工厂函数动态生成实体定义。但这样做的代价是要在启动前把所有分表实体全部注册进DataSource比如提前生成Order_0到Order_15共16个EntitySchema。如果后续要加表必须重启应用重新注册做不到真正的“动态”。import { EntitySchema } from typeorm; export const createOrderSchema (index: number) new EntitySchema({ name: Order_${index}, tableName: orders_${index}, columns: { id: { type: Number, primary: true, generated: true }, userId: { type: Number, name: user_id }, orderNo: { type: String, name: order_no }, amount: { type: decimal, precision: 10, scale: 2 }, }, });还有个第三方包typeorm-sharding做过尝试但星级和更新频率都不高在生产环境使用有风险。我的判断是TypeORM适合做分库但不适合做高频动态分表。如果你的存量项目已经是NestJS TypeORM且必须分表建议走“启动时批量注册所有分表实体”这条路不要在运行时跟metadata较劲。2.3 Prisma类型安全做得最好但也最不灵活Prisma通过schema.prisma生成类型安全的Client这是它最舒服的地方。代价是每个物理表都要在schema里以模型的形式预先声明动态表名官方不支持。有一个绕过方案既然所有分表的结构相同就在schema里定义N份相同的模型并映射到分表。比如model Order_0 { id BigInt id default(autoincrement()) userId BigInt map(user_id) amount Decimal db.Decimal(10, 2) status Int map(orders_0) } model Order_1 { id BigInt id default(autoincrement()) userId BigInt map(user_id) amount Decimal db.Decimal(10, 2) status Int map(orders_1) }然后在业务代码里通过动态属性访问prisma实例拿到对应的模型const index userId % 16; const tableModel (prisma as any)[orders_${index}]; const orders await tableModel.findMany({ where: { userId }, orderBy: { id: desc }, take: 20, });这个技巧能用但体验谈不上好。如果你的分表数量是16张schema文件里就得躺着16份结构相同的模型如果表要继续扩到64张schema文件直接爆炸。而且任何模型结构调整都要同步修改所有分表模型很考验细心程度。Prisma更实际的用法是非分表部分交给Prisma分表后的跨表聚合、UNION等复杂查询用$queryRaw直接写原生SQL。2.4 Knex没有模型层反而成了动态分表的最佳底子严格说Knex不是ORM而是查询构建器。但正因为少了模型层的固化动态表名反而是最简单的把表名当参数传进去就行。const TABLES Array.from({ length: 16 }, (_, i) orders_${i}); function tableFor(userId) { const index Math.abs(userId) % TABLES.length; return TABLES[index]; } async function findByUserId(userId) { return knex(tableFor(userId)) .select(*) .where(user_id, userId) .orderBy(id, desc) .limit(20); }这里有个非常重要的小细节表名必须从白名单数组里取绝对不能直接拼用户输入。knex(orders_ userId)这种写法一旦userId带着特殊符号就会产生SQL注入风险。因为你没有模型的字段约束Knex的方案对团队自控能力要求更高数据校验、查询约束、关联加载全都要自己封装一层。但如果你本来就打算自建数据访问层Knex是比再套一个ORM更轻的选择。2.5 其他框架和中间件Bookshelf基于Knex支持按索引动态extend出新的Model类但和Sequelize的define机制类似也要注意模型类堆积问题。MikroORM在动态实体定义方面做得更灵活但社区案例少踩坑只能靠自己。还有一个思路是引入数据库中间件你在业务代码里仍然写普通的ORM查询由中间件完成真实路由。这类方案在Node.js生态里成熟的并不多而且带入了额外组件和运维复杂度除非团队有专门的基础设施能力否则我不建议轻易上车。ORM动态表名支持实现成本适合场景Sequelize一般define可动态生成中多数业务系统需要模型层方便TypeORM差启动时写死metadata高存量NestJS项目分库为主Prisma很差必须预声明模型高非分表部分强类型分表走原生SQLKnex好表名即参数低团队自建数据层灵活优先3. 基于Sequelize实现动态分表路由层的完整思路如果你和我一样最终选了Sequelize那这套自建路由层的完整思路可以直接参考。3.1 路由规则先想清楚扩容这天怎么过哈希取模的实现很简单Math.abs(userId) % 16就够了。但请你想一个问题从16张表扩容到32张表的那一天数据怎么迁如果是纯取模原来的userId17落在orders_1扩容后落到orders_17这意味着几乎每条数据都要搬家。业界常用做法是“翻倍扩容”把分片数保持为2的幂次扩容时翻倍搬迁范围可控或者更进阶一些先把数据路由到64个虚拟桶再把桶映射到16张物理表加表时只改桶到物理表的映射关系数据可以先不迁等GC慢慢搬。后者逻辑复杂但在容量计划不确定时很香。我的取舍是第一阶段用简单的取模先跑起来同时把路由函数收敛到独立模块。后续真要扩容只改一个函数不影响Repository层。3.2 Repository封装不要让业务代码感知分表路由函数有了模型工厂有了接下来要把它们封装成Repository。这层的核心价值是业务代码不感知分表细节只提供一个类似OrderRepository的对象。class OrderRepository { async createOrder({ userId, orderNo, amount }) { const Order getOrderModel(userId); return Order.create({ user_id: userId, order_no: orderNo, amount, }); } async getById(userId, orderId) { const Order getOrderModel(userId); return Order.findOne({ where: { id: orderId, user_id: userId }, }); } async listByUser(userId, { limit 20, offset 0 }) { const Order getOrderModel(userId); return Order.findAll({ where: { user_id: userId }, order: [[id, DESC]], limit, offset, }); } }看到getById这个方法的签名了吗除了orderId之外还必须传userId。这不是我多此一举而是分表示范动作查询条件里不带分片键路由层根本算不出该查哪张表。如果确实存在按订单号全局查询的需求就必须额外维护一张“订单号到用户ID”的映射表或者全表扫描合并。这说明分表方案天然限制了查询维度产品需求和系统设计要在早期对齐。3.3 事务和批量查询路由后的两个分支单分片事务很好处理先拿到该分片的Model再把事务对象传进去const t await sequelize.transaction(); try { const Order getOrderModel(userId); await Order.create({ ... }, { transaction: t }); await Order.update({ status: 1 }, { where: { id: orderId }, transaction: t }); await t.commit(); } catch (e) { await t.rollback(); throw e; }但跨分片事务不是ORM层能解决的问题。Sequelize的transaction绑定的是单个连接你不可能在同一连接上操作两个不同的物理表。如果业务强依赖跨分片一致性就要引入本地消息表、TCC、Saga等分布式事务方案。这是另一个深坑后面第4部分详细说。跨分片批量查询的思路是并行查各表然后内存合并。比如统计订单总数SELECT COUNT(*) AS cnt FROM orders_0 UNION ALL SELECT COUNT(*) AS cnt FROM orders_1 -- ... 一直到 orders_15用Sequelize原生查询可以这样写const conditions []; for (let i 0; i SHARD_COUNT; i) { conditions.push(SELECT COUNT(*) AS cnt FROM orders_${i}); } const [rows] await sequelize.query(conditions.join( UNION ALL )); const total rows.reduce((sum, row) sum Number(row.cnt || 0), 0);注意并发控制如果分片数量达到几十个Promise.all并发执行时数据库连接池会被瞬间打满建议用p-limit之类的库限制并发数。4. 拆完表之后踩过的坑从ID到数据迁移路由层做完了不代表万事大吉。真正让人头疼的问题都在表拆完之后才浮出水面。4.1 主键不能再自增了全局唯一ID怎么造这是第一个要连夜修的问题。分表之后每个分片的自增主键都从1开始orders_0里有个id1orders_1里也有个id1跨表合并数据时直接打架。业界公认的做法是使用分布式ID常见两个梯队雪花算法64位整数由时间戳机器标识序列号组成趋势递增性能好。很多公司自研的ID生成服务或uuid替代品都基于这个思路。号段模式数据库里维护一个ID区间应用启动时预取一段号用完之后再取下一段。逻辑简单但依赖额外的数据库表。我推荐雪花ID还有一个非主流理由它有时间戳位可以反解出创建时间对之后做冷热数据归档很有用。注意order_no这种业务单号也要设计成全局唯一字段并在每个分片表上加唯一索引。虽然应用层的ID生成器已经保证不重复但加一层数据库约束等于多一份安全网。4.2 跨表JOIN做不了冗余字段才是解药单表内的JOIN一切正常跨表JOIN在分表后就是灾难。你以为的orders_xx JOIN products在分表之后要面对16张订单表和1张商品表数据库优化器根本不知道你要怎么关联。我的经验是一切按分片键维度构建宽表。典型的订单列表页需要显示商品名称、分类、价格那就直接在订单表里冗余商品快照字段下单时把快照写进去。宁可多占一点存储也不要让查询复杂化。如果确实有关联需求就把关联表按相同的分片键分表。比如order_items必须和orders使用同一个哈希规则这样同一个用户的订单和订单明细永远落在同一张物理表JOIN可以继续用。这个决策也要在分表前定下来。4.3 跨表聚合和全局分页别用OFFSET用游标跨表分页是另一个大坑。我见过有人真的写LIMIT 10000, 20然后循环查16张表结果可想而知每张表都要把前10000行读出来排序再合并再排序最后截取20条。分页越深查询越慢。替代方案是Keyset分页也叫游标分页。它不是按页数偏移而是记住上一页最后一条记录的ID下一页直接带上这个游标条件SELECT * FROM orders_3 WHERE user_id ? AND id :lastId ORDER BY id DESC LIMIT 20;这样每张表都只需要扫描极小的范围。代价是失去“跳转到第N页”的能力但电商C端场景里用户根本不会去翻第100页给个“下一页/加载更多”完全够用。4.4 事务边界跨分片一致性别指望ORM分表之后原本一个本地事务能保证的操作被拆成了两三个连接上的操作。比如“创建订单同时扣减库存”如果订单落在orders_3库存落在inventory_5任何ORM都做不到在一个本地事务里同时锁这两行。我的原则是尽量把强一致的业务链路约束在同一个分片内。也就是说你的分片键要选到业务主维度让一个用户的所有相关操作都落在同一张物理表上。这样事务依然是单库单事务最省心。万一业务真的必须跨分片那就不要硬蹭“事务”这个概念回到分布式事务的经典方案本地消息表保证最终一致性或者Saga编排。这块工程复杂度陡增团队没有心理准备的话很容易搞出一堆数据对不上的线上事故。4.5 数据迁移和后校验拆表容易收尾难存量数据怎么搬我的建议是不要停服一次性迁移。稳一点的做法是同步阶段双写。线上同时写入老表和分表新表老表负责读。回填阶段写脚本把老表历史数据按路由规则灌入分表做条数校验和唯一性校验。灰度阶段核心读流量切换到分表发现问题随时回切。清理阶段确认稳定后老表只保留只读备份。这里有个经常被忽略的事情分表后的数据校验不能只看条数要看业务维度的聚合。比如每个用户的订单数、订单总金额两边要对上。我自己当时写了一个校验脚本按user_id维度循环比对老表和分表汇总结果抓到好几处边界数据没迁移干净都是因为路由函数对负数和字符串的处理不一致导致的。5. 选型结论你的团队应该用哪套方案做了这么多对比和实验最后给一个基于团队情况的选型建议。如果团队用Sequelize且业务是典型的用户维度数据比如订单、流水、站内信那么自建路由层是性价比最高的路线代码也就100多行维护成本完全可控。推荐前面第3部分的实现思路。如果团队是NestJS TypeORM的存量项目不建议强行动态分表。要么在启动时批量注册所有分表实体接受“加表要重启”要么评估一下是否值得引入Knex做查询层并容忍部分业务查询不走TypeORM。如果团队极其看重TypeScript类型安全且采用了Prisma建议非分表部分继续用Prisma分表查询走$queryRaw。别和我一样一上来就声明16份一模一样的分表模型改一个字段要改16个地方实在痛苦。还有一种情况我建议别分表如果你的业务查询维度很多不仅要按用户查还要按商家查、按时间查、按状态查那么你需要的不是关系型数据库分表而是Elasticsearch或者ClickHouse这类天然面向多维查询的存储。强行为了一两个核心查询设计分表结果就是其他维度查询全部退化整个团队每天都在造临时聚合报表的轮子。如果你只是单表查询变慢但写入压力不大先试试MySQL原生分区表能不能扛住分区键满足条件的话迁移成本极低。只有数据量持续增长且写入有并发压力时才值得把分表这件事真正提上日程。说句实话Node.js生态里没有等同于Java世界ShardingSphere的分表中间件指望ORM框架原生支持动态分表目前还是不现实的事。与其花时间研究各种插件不如把路由规则、模型工厂、Repository封装控制在合理规模内逻辑全握在自己手里。分表是一台大手术动手前一定想清楚未来十年的容量预估、查询维度和数据运维手段否则最后拆出来的不是扩展性而是一整套更复杂的生产事故预案。
返回列表