订单表、日志表这类流水数据往往会在几个月内增长到百万甚至千万行,此时索引优化带来的提升会越来越有限,冷热数据分离就成了一个绕不开的选项。核心思路是把频繁访问的近期数据留在热表,把较少访问的历史数据迁到冷表,查询时再根据时间条件选择数据源。在Node.js环境里落地这件事,需要处理的不只是SQL,还包括归档任务调度、批量迁移、事务控制、查询路由以及监控告警等一整套工程细节。下面我们以一个订单场景为例,给出具体实现。

一、冷热数据的判定与表结构设计
冷热划分不能拍脑袋定,需要结合业务访问模式。订单场景中最直观的判定字段是创建时间,比如超过90天且状态为已完成、已取消的订单,基本不会再被用户主动查询,后台运营的访问频率也极低,这些记录就可以归为冷数据。除了时间,最后访问时间、状态机终态也是常用依据。判定规则最好配置化,例如将阈值天数、包含的状态放在配置中心,方便根据数据量和性能表现动态调整。热表只保留最近90天的数据,单表行数就能控制在一个相对稳定的范围,索引维护成本和查询延迟都会下降。
表结构设计上,热表和冷表的字段可以保持一致,方便后续合并查询。以MySQL为例,热表orders_hot和冷表orders_archive都包含id、user_id、amount、status、created_at、updated_at等核心字段。冷表可以额外增加archived_at字段记录归档时间,便于排查问题。主键id建议使用自增或雪花ID,不要依赖业务单号作为主键,否则批量迁移时容易遇到主键冲突。热表上的索引要面向高频查询,比如(user_id, created_at)和(status, created_at);冷表上的索引可以更偏向统计分析,比如(created_at)和(user_id, created_at)。冷表数据量更大,如果使用MySQL 8.0可以考虑按时间范围分区,加速删除和备份。
同时要明确冷热边界的时间值在系统里如何统一。建议在归档任务和查询服务里都从一个公共配置读取cutoffDays,避免查询路由和任务扫描使用两个不同的阈值,导致数据在热表和冷表之间出现空洞或重叠。这个阈值可以理解为热表最短保留时间,冷表的归档时间应该比阈值更早。还可以增加一个归档状态表或日志表,记录每次归档任务的开始时间、结束时间、迁移行数、失败信息,方便运维排查。这一部分设计好后,下一步就可以编写具体的归档任务。
二、基于node-cron的自动归档任务实现
Node.js里做定时任务,node-cron是轻量好用的库,适合简单场景,如果是复杂任务调度也可以使用Bull或Agenda结合Redis。这里以node-cron和mysql2为例。归档任务的核心流程是:拿到一个数据库连接,开启事务;循环扫描热表中所有满足条件的记录,每次取一批,插入冷表,再删除热表中对应的行;全部处理完后提交事务。使用事务可以保证插入和删除要么同时成功,要么同时回滚,避免出现数据只在冷表或只在热表的不一致情况。需要注意的是,长时间持有事务会锁住大量行,影响在线业务,所以批次大小要控制,比如每批500到1000行。
下面给出一个可运行的归档函数。代码里用SELECT ... FOR UPDATE锁住本批要迁移的行,防止并发更新干扰;插入冷表时使用ON DUPLICATE KEY UPDATE保持幂等,重复执行同一个归档任务也不会报主键冲突。删除热表时使用主键IN列表,配合索引快速定位。每一批之间可以适当sleep几十毫秒,给数据库留出处理其他请求的时间。虽然sleep会拉长归档时长,但对线上影响更小。
const mysql = require('mysql2/promise');
const cron = require('node-cron');
const pool = mysql.createPool({
host: '127.0.0.1',
user: 'root',
password: 'your_password',
database: 'order_db',
waitForConnections: true,
connectionLimit: 10,
});
const CUTOFF_DAYS = 90;
const BATCH_SIZE = 500;
async function archiveColdOrders() {
const connection = await pool.getConnection();
const cutoffDate = new Date(Date.now() - CUTOFF_DAYS * 24 * 60 * 60 * 1000);
let totalArchived = 0;
try {
await connection.beginTransaction();
while (true) {
const [rows] = await connection.query(
'SELECT id, user_id, amount, status, created_at, updated_at FROM orders_hot WHERE created_at < ? AND status IN (?) ORDER BY id LIMIT ? FOR UPDATE',
[cutoffDate, ['completed', 'cancelled'], BATCH_SIZE]
);
if (rows.length === 0) break;
const values = rows.map(r => [r.id, r.user_id, r.amount, r.status, r.created_at, r.updated_at]);
await connection.query(
'INSERT INTO orders_archive (id, user_id, amount, status, created_at, updated_at) VALUES ? ON DUPLICATE KEY UPDATE amount = VALUES(amount)',
[values]
);
const ids = rows.map(r => r.id);
const [deleteResult] = await connection.query('DELETE FROM orders_hot WHERE id IN (?)', [ids]);
totalArchived += deleteResult.affectedRows;
// 每批之间短暂休眠,降低数据库瞬时压力
await new Promise(resolve => setTimeout(resolve, 50));
}
await connection.commit();
console.log(`归档完成,共迁移 ${totalArchived} 行`);
} catch (err) {
await connection.rollback();
console.error('归档任务失败,已回滚', err);
throw err;
} finally {
connection.release();
}
}
cron.schedule('0 2 * * *', archiveColdOrders);
上面的代码里,cutoffDate是根据当前时间减去90天计算出来的,实际项目中应该从配置读取。归档任务通过cron.schedule设置为每天凌晨2点执行。如果归档过程中出现异常,整个事务会回滚,并且在catch里可以接入日志或告警系统。另外,如果在归档还没跑完时服务重启,事务会自动回滚,不会产生半迁移状态。但要注意,如果归档任务执行时间过长,可能会跨过业务高峰期,所以最好加上最大执行时间限制和手动熔断开关。也可以把归档任务拆成多个小事务,每批一个事务,牺牲一部分原子性换取更小的锁范围,这个需要根据业务可接受的一致性程度来选择。
三、归档后的查询路由与一致性保障
冷热分离之后,查询逻辑就不能再只查一张表。最直接的做法是在查询入口处根据时间范围判断数据落在哪个表。比如用户查询近一个月的订单,只查热表;查询一年前的订单,只查冷表;查询跨过冷热边界的时间范围,则分别查询两个表后在内存中合并或使用UNION ALL。下面是一个简单的查询路由封装,它假设cutoffDays为90,热表和冷表结构一致。
async function queryOrders(startDate, endDate, limit = 100) {
const pool = getPool();
const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
let sql = '';
const params = [];
if (startDate >= cutoff) {
sql = 'SELECT * FROM orders_hot WHERE created_at BETWEEN ? AND ? ORDER BY created_at DESC LIMIT ?';
params.push(startDate, endDate, limit);
} else if (endDate < cutoff) {
sql = 'SELECT * FROM orders_archive WHERE created_at BETWEEN ? AND ? ORDER BY created_at DESC LIMIT ?';
params.push(startDate, endDate, limit);
} else {
sql = `(
SELECT * FROM orders_archive WHERE created_at BETWEEN ? AND ?
UNION ALL
SELECT * FROM orders_hot WHERE created_at BETWEEN ? AND ?
) AS combined_orders ORDER BY created_at DESC LIMIT ?`;
params.push(startDate, cutoff, cutoff, endDate, limit);
}
const [rows] = await pool.query(sql, params);
return rows;
}
上面代码只是说明路由思路,实际生产环境还需要处理排序、分页和总行数统计。如果跨表查询还要分页,用UNION ALL包裹后再LIMIT会比较好,否则需要分别查两次再合并,容易出现遗漏或重复。为了简化,可以先规定跨边界的查询统一走冷表加热表的UNION ALL,并在外层套一个子查询做排序和分页。比如SELECT * FROM (SELECT * FROM orders_archive WHERE ... UNION ALL SELECT * FROM orders_hot WHERE ...) AS t ORDER BY created_at DESC LIMIT 0, 20。
一致性保障除了归档中的事务,还要考虑归档前后新写入的数据。归档任务开始时,如果某条记录刚好在阈值边界附近,可能在任务扫描后又被更新,此时如果使用FOR UPDATE,会将相关行锁住,保证在本批迁移完成前不会被修改。查询路由里热表与冷表的边界要和归档任务的阈值保持一致,否则用户查询可能看不到刚被归档但冷表还没提交的数据。由于归档任务使用事务,在事务提交前冷表插入和热表删除对其他连接都不可见,提交后才会原子性地切换,因此查询服务在热表与冷表之间不会读到中间状态。唯一需要注意的是,查询路由使用的阈值可以略晚于归档阈值,例如归档删除90天前的数据,查询路由在80天前就开始查冷表,这样可以留出缓冲,避免刚好在边界瞬间出现不一致。
四、性能优化、监控与避坑建议
归档任务最大的性能瓶颈通常不在Node.js本身,而在数据库的扫描和写入方式。使用LIMIT加OFFSET分批虽然直观,但当热表数据量很大时,OFFSET越大查询越慢,因为数据库需要跳过大量行。更好的做法是用主键范围分页,记录上一次处理的最大id,下一批执行id大于lastId AND created_at小于cutoff的查询,这样可以利用主键索引快速定位。下面是一个改进后的分批循环片段。
async function archiveWithKeyPagination() {
const connection = await pool.getConnection();
const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
let lastId = 0;
let total = 0;
try {
await connection.beginTransaction();
while (true) {
const [rows] = await connection.query(
'SELECT id, user_id, amount, status, created_at, updated_at FROM orders_hot WHERE id > ? AND created_at < ? AND status = ? ORDER BY id LIMIT 500',
[lastId, cutoff, 'completed']
);
if (rows.length === 0) break;
const values = rows.map(r => [r.id, r.user_id, r.amount, r.status, r.created_at, r.updated_at]);
await connection.query(
'INSERT INTO orders_archive (id, user_id, amount, status, created_at, updated_at) VALUES ?',
[values]
);
const ids = rows.map(r => r.id);
const [del] = await connection.query('DELETE FROM orders_hot WHERE id IN (?)', [ids]);
total += del.affectedRows;
lastId = rows[rows.length - 1].id;
}
await connection.commit();
return total;
} catch (err) {
await connection.rollback();
throw err;
} finally {
connection.release();
}
}
冷表本身也需要优化。冷表数据只增不减,随着时间推移会变得非常大,建议在冷表上按月分区,或者定期把更早的数据归档到数据仓库、对象存储。冷表的索引不要照搬热表,可以根据离线分析的常用字段重新设计,减少索引数量,降低写入和存储成本。同时要监控归档任务的执行时长、迁移行数、失败次数,冷热表的行数变化趋势,以及查询路由的命中情况。Node.js进程可以暴露这些指标给Prometheus,或用简单的日志告警。
避坑方面,不要在生产高峰期执行一次性全量归档,宁可拆成多次小批量任务;不要在归档过程中执行DDL变更;迁移前先在测试环境验证备份和回滚方案;冷热表结构变更时要同步修改归档代码,避免字段不匹配。还要注意时区问题,Node.js的Date对象和数据库的DATETIME字段之间可能存在时区偏移,建议统一使用UTC存储,或者在连接配置里明确timezone选项。这些细节处理好了,冷热数据分离才能真正稳定运行。

