在项目开发中,为了减轻数据库的 I/O 压力,加快请求的响应速度,缓存是常用到的技术。Redis和 Memcache是现在常用的两个用来做数据缓存的技术。数据缓存一些常见的做法是,让数据写入到数据库以后通过一些自动化的脚本自动同步到缓存,或者在向数据库写数据后再手动向缓存写一次数据。这些做法不免都有些繁琐,且代码也不好维护。我在写 Node.js 项目的时候,发现利用 Mongoose(一个 MongoDB 的 ODM)和 Sequelize(一个 MySQL 的 ORM)的一些功能特性能够优雅的做到让写入到 MongoDB/MySQL 的数据自动写入到 Redis,并且在做查询操作的时候能够自动地优先从缓存中查找数据,若缓存中找不到才进入 DB 中查找,并将 DB 中找到的数据写入缓存。
本文不讲解 Mongoose 和 Sequelize 的基本用法,这里只讲解如何做到上面所说的自动缓存。
本文要用到的一些库为 Mongoose、Sequelize、ioredis和 lodash。Node.js 版本为 7.7.1。
在 MongoDB 中实现自动缓存
// redis.js
const Redis = require('ioredis');
const Config = require('../config');
const redis = new Redis(Config.redis);
module.exports = redis;
上面文件的代码主要用于连接 redis。
// mongodb.js
const mongoose = require('mongoose');
mongoose.Promise = global.Promise;
const demoDB = mongoose.createConnection('mongodb://127.0.0.1/demo', {});
module.exports = demoDB;
上面是连接 mongodb 的代码。
// mongoBase.js
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const redis = require('./redis');
function baseFind(method, params, time) {
const self = this;
const collectionName = this.collection.name;
const dbName = this.db.name;
const redisKey = [dbName, collectionName, JSON.stringify(params)].join(':');
const expireTime = time || 3600;
return new Promise(function (resolve, reject) {
redis.get(redisKey, function (err, data) {
if (err) return reject(err);
if (data) return resolve(JSON.parse(data));
self[method](params).lean().exec(function (err, data) {
if (err) return reject(err);
if (Object.keys(data).length === 0) return resolve(data);
redis.setex(redisKey, expireTime, JSON.stringify(data));
resolve(data);
});
});
});
}
const Methods = {
findCache(params, time) {
return baseFind.call(this, 'find', params, time);
},
findOneCache(params, time) {
return baseFind.call(this, 'findOne', params, time);
},
findByIdCache(params, time) {
return baseFind.call(this, 'findById', params, time);
},
};
const BaseSchema = function () {
this.defaultOpts = {
};
};
BaseSchema.prototype.extend = function (schemaOpts) {
const schema = this.wrapMethods(new Schema(schemaOpts, {
toObject: { virtuals: true },
toJSON: { virtuals: true },
}));
return schema;
};
BaseSchema.prototype.wrapMethods = function (schema) {
schema.post('save', function (data) {
const dbName = data.db.name;
const collectionName = data.collection.name;
const redisKey = [dbName, collectionName, JSON.stringify(data._id)].join(':');
redis.setex(redisKey, 3600, JSON.stringify(this));
});
Object.keys(Methods).forEach(function (method) {
schema.statics[method] = Methods[method];
});
return schema;
};
module.exports = new BaseSchema();
上面的代码是在用 mongoose 建模的时候,所有的 schema 都会继承这个 BaseSchema。这个 BaseSchema 里面就为所有继承它的 schema 添加了一个模型在执行 save 方法后触发的文档中间件,这个中间件的作用就是在数据被写入 MongoDB 后再自动写入 redis。然后还为每个继承它的 schema 添加了三个静态方法,分别是 findByIdCache、findOneCache 和 findCache,它们分别是 findById、findOne 和 find 方法的扩展,只是不同之处在于用添加的三个方法进行查询时会根据传入的条件先从 redis 中查找数据,若查到就返回数据,若查不到就继续调用所对应的的原生方法进入 MongoDB 中查找,若在 MongoDB 中查到了,就把查到的数据写入 redis,供以后的查询使用。添加的这三个静态方法的调用方法和他们所对应的的原生方法一致,只是可以多传入一个时间,用来设置数据在缓存中的过期时间。
// userModel.js
const BaseSchema = require('./mongoBase');
const mongoDB = require('./mongodb.js');
const userSchema = BaseSchema.extend({
name: String,
age: Number,
addr: String,
});
module.exports = mongoDB.model('User', userSchema, 'user');
这是为 user 集合建的一个模型,它就通过 BaseSchema.extend 方法继承了上面说到的中间件和静态方法。
// index.js
const UserModel = require('./userModl');
const action = async function () {
const user = await UserModel.create({ name: 'node', age: 7, addr: 'nodejs.org' });
const data1 = await UserModel.findByIdCache(user._id.toString());
const data2 = await UserModel.findOneCache({ age: 7 });
const data3 = await UserModel.findCache({ name: 'node', age: 7 }, 7200);
return [ data1, data2, data3];
};
action().then(console.log).catch(console.error);
上面的的代码就是向 User 集合中写了一条数据后,然后依次调用了我们自己添加的三个用于查询的静态方法。把 redis 的 monitor 打开,发现代码已经按我们预想的那样执行了。
总结,上面的方案主要通过 Mongoose 的中间件和静态方法达到了我们想要的功能。但添加的 findOneCache 和 findCache 方法很难达到较高的数据一致性,若要追求很强的数据一致性就用它们所对应的的 findOne 和 find。findByIdCache 能保证很好的数据一致性,但也仅限于修改数据时是查询出来再 save,若是直接 update 也做不到数据一致性。
在 MySQL 中实现自动缓存
// mysql.js
const Sequelize = require('sequelize');
const _ = require('lodash');
const redis = require('./redis');
const setCache = function (data) {
if (_.isEmpty(data) || !data.id) return;
const dbName = data.$modelOptions.sequelize.config.database;
const tableName = data.$modelOptions.tableName;
const redisKey = [dbName, tableName, JSON.stringify(data.id)].join(':')
redis.setex(redisKey, 3600, JSON.stringify(data.toJSON()));
};
const sequelize = new Sequelize('demo', 'root', '', {
host: 'localhost',
port: 3306,
hooks: {
afterUpdate(data) {
setCache(data);
},
afterCreate(data) {
setCache(data);
},
},
});
sequelize
.authenticate()
.then(function () {
console.log('Connection has been established successfully.');
})
.catch(function (err) {
console.error('Unable to connect to the database:', err);
});
module.exports = sequelize;
上面的代码主要作用是连接 MySQL 并生成 sequelize 实例,在构建 sequelize 实例的时候添加了两个钩子方法 afterUpdate 和 afterCreate。afterUpdate 用于在模型实例更新后执行的函数,注意必须是模型实例更新才会触发此方法,如果是直接类似 Model.update 这种方式更新是不会触发这个钩子函数的,只能是一个已经存在的实例调用 save 方法的时候会触发这个钩子。afterCreate 是在模型实例创建后调用的钩子函数。这两个钩子的主要目的就是用来当一条数据被写入到 MySQL 后,再自动的写入到 redis,即实现自动缓存。
// mysqlBase.js
const _ = require('lodash');
const Sequelize = require('sequelize');
const redis = require('./redis');
function baseFind(method, params, time) {
const self = this;
const dbName = this.sequelize.config.database;
const tableName = this.name;
const redisKey = [dbName, tableName, JSON.stringify(params)].join(':');
return (async function () {
const cacheData = await redis.get(redisKey);
if (!_.isEmpty(cacheData)) return JSON.parse(cacheData);
const dbData = await self[method](params);
if (_.isEmpty(dbData)) return {};
redis.setex(redisKey, time || 3600, JSON.stringify(dbData));
return dbData;
})();
}
const Base = function (sequelize) {
this.sequelize = sequelize;
};
Base.prototype.define = function (model, attributes, options) {
const self = this;
return this.sequelize.define(model, _.assign({
id: {
type: Sequelize.UUID,
primaryKey: true,
defaultValue: Sequelize.UUIDV1,
},
}, attributes), _.defaultsDeep({
classMethods: {
findByIdCache(params, time) {
this.sequelize = self.sequelize;
return baseFind.call(this, 'findById', params, time);
},
findOneCache(params, time) {
this.sequelize = self.sequelize;
return baseFind.call(this, 'findOne', params, time);
},
findAllCache(params, time) {
this.sequelize = self.sequelize;
return baseFind.call(this, 'findAll', params, time);
},
},
}, options));
};
module.exports = Base;
上面的代码同之前的 mongoBase 的作用大致一样。在 sequelize 建模的时候,所有的 schema 都会继承这个 Base。这个 Base 里面就为所有继承它的 schema 添加了三个静态方法,分别是 findByIdCache、findOneCahe 和 findAllCache,它们的作用和之前 mongoBase 中的那三个方法作用一样,只是为了和 sequelize 中原生的 findAll 保持一致,findCache 在这里变成了 findAllCache。在 sequelize 中为 schema 添加类方法(classMethods),即相当于在 mongoose 中为 schema 添加静态方法(statics)。
// mysqlUser.js
const Sequelize = require('sequelize');
const base = require('./mysqlBase.js');
const sequelize = require('./mysql.js');
const Base = new base(sequelize);
module.exports = Base.define('user', {
name: Sequelize.STRING,
age: Sequelize.INTEGER,
addr: Sequelize.STRING,
}, {
tableName: 'user',
timestamps: true,
});
上面定义了一个 User schema,它就从 Base 中继承了findByIdCache、findOneCahe 和 findAllCache。
const UserModel = require('./mysqlUser');
const action = async function () {
await UserModel.sync({ force: true });
const user = await UserModel.create({ name: 'node', age: 7, addr: 'nodejs.org' });
await UserModel.findByIdCache(user.id);
await UserModel.findOneCache({ where: { age: 7 }});
await UserModel.findAllCache({ where: { name: 'node', age: 7 }}, 7200);
return 'finish';
};
action().then(console.log).catch(console.error);
总结,通过 sequelize 实现的自动缓存方案和之前 mongoose 实现的一样,也会存在数据一致性问题,findByIdCache 较好,findOneCache 和 findAllCache 较差,当然这里很多细节考虑得不够完善,可根据业务合理调整。