Redis caching strategy
Mousumi Bakshi
Technical Manager | Software Solution Architect | AWS- GCP | Cloud Architect | Generative AI - LLM
It's crucial to make a decision on the caching strategy for a SaaS-based product. The main purpose of using Redis in my use case is for configuration caching.
The choice of where to keep platform configuration in a SaaS-based project using Node.js depends on your specific needs and constraints. Environment variables and configuration files are straightforward for simpler applications, while configuration management tools and centralized services are better suited for more complex and scalable applications.
Using a centralized configuration service with a database for storage and caching for faster access is a robust approach for managing configurations in a SaaS-based Node.js application. This method combines the benefits of centralized management with improved performance and scalability.
Implementation Overview
Detailed Steps
1. Set Up the Database
First, choose a database and set up your schema for storing configurations. For example, in MongoDB, you might have a collection called configs:
{
"_id": "appConfig",
"database": {
"host": "localhost",
"user": "root",
"password": "password"
},
"apiKeys": {
"service1": "your-api-key"
}
}
2. Set Up the Cache
Set up a caching system like Redis. Redis can store key-value pairs where the key could be the configuration ID and the value is the configuration data.
3. Implement the Configuration Service API
Create an API to fetch configurations. This API will first check the cache and then fall back to the database if the configuration is not found in the cache.
Here's an example using Express.js, MongoDB, and Redis:
const express = require('express');
const mongoose = require('mongoose');
const redis = require('redis');
const { promisify } = require('util');
// MongoDB connection
mongoose.connect('mongodb://localhost:27017/configDB', {
useNewUrlParser: true,
useUnifiedTopology: true
});
// Redis client
const redisClient = redis.createClient();
const getAsync = promisify(redisClient.get).bind(redisClient);
const setAsync = promisify(redisClient.set).bind(redisClient);
// Configuration model
const Config = mongoose.model('Config', new mongoose.Schema({
_id: String,
database: Object,
apiKeys: Object
}));
const app = express();
app.get('/config/:id', async (req, res) => {
const configId = req.params.id;
try {
// Try to get configuration from cache
let config = await getAsync(configId);
if (!config) {
// If not found in cache, fetch from database
config = await Config.findById(configId);
if (config) {
// Store the fetched configuration in cache
await setAsync(configId, JSON.stringify(config), 'EX', 3600); // Cache for 1 hour
} else {
return res.status(404).send('Configuration not found');
}
} else {
config = JSON.parse(config);
}
res.json(config);
} catch (error) {
res.status(500).send('Server error');
}
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
4. Use the Configuration Service in Your Application
In your Node.js application, make HTTP requests to the configuration service to fetch the required configurations:
const axios = require('axios');
async function getConfig(configId) {
try {
const response = await axios.get(`https://localhost:3000/config/${configId}`);
return response.data;
} catch (error) {
console.error('Error fetching configuration:', error);
throw error;
}
}
// Usage
(async () => {
const config = await getConfig('appConfig');
console.log(config);
})();
Benefits of This Approach
For a centralized configuration service in a SaaS-based Node.js application, the best caching strategy typically depends on the specific use case requirements such as read/write patterns, consistency needs, and cache invalidation policies. Here are a few caching strategies that you might consider, along with recommendations for the best approach in this context:
Caching Strategies
Description: In this strategy, the cache sits in front of the database. When a request is made for data, it first checks the cache. If the data is not present, the cache fetches it from the database, stores it, and then returns it.
Pros: Simplifies code as the cache is responsible for fetching missing data.
Cons: Cache may be less effective if there are frequent writes or updates.
Implementation Example:
async function getConfig(configId) {
let config = await getAsync(configId);
if (!config) {
config = await Config.findById(configId);
if (config) {
await setAsync(configId, JSON.stringify(config), 'EX', 3600); // Cache for 1 hour
} else {
throw new Error('Configuration not found');
}
} else {
config = JSON.parse(config);
}
return config;
}
2. Write-Through Cache
Description: In this strategy, data is written to both the cache and the database simultaneously. It ensures that the cache is always up-to-date.
Pros: Ensures strong consistency between cache and database.
Cons: Slower writes due to double writes to cache and database.
Implementation Example:
async function updateConfig(configId, newConfig) {
await Config.findByIdAndUpdate(configId, newConfig);
await setAsync(configId, JSON.stringify(newConfig), 'EX', 3600); // Cache for 1 hour
}
3. Cache-Aside (Lazy Loading).
Description: In this strategy, data is written to both the cache and the database simultaneously. It ensures that the cache is always up-to-date.
Pros: Ensures strong consistency between cache and database.
Cons: Slower writes due to double writes to cache and database.
Implementation Example:
async function getConfig(configId) {
let config = await getAsync(configId);
if (!config) {
config = await Config.findById(configId);
if (config) {
await setAsync(configId, JSON.stringify(config), 'EX', 3600); // Cache for 1 hour
} else {
throw new Error('Configuration not found');
}
} else {
config = JSON.parse(config);
}
return config;
}
async function updateConfig(configId, newConfig) {
await Config.findByIdAndUpdate(configId, newConfig);
await delAsync(configId); // Invalidate the cache
}
Write-Behind Cache (Write-Back)
Description: Updates are made to the cache first, and then asynchronously written to the database.
Pros: Faster writes as they are only done to the cache initially.
领英推荐
Cons: More complex to implement, potential data loss if the cache fails before data is written to the database.
Best Strategy for Your Use Case
For a configuration service, where reads are frequent and writes are relatively infrequent, the Cache-Aside (Lazy Loading) strategy is generally the best choice. This strategy provides a good balance between performance and consistency:
Final Implementation Example
Here's a more detailed example using Cache-Aside strategy with Redis and MongoDB:
Here's a more detailed example using Cache-Aside strategy with Redis and MongoDB:
const express = require('express');
const mongoose = require('mongoose');
const redis = require('redis');
const { promisify } = require('util');
// MongoDB connection
mongoose.connect('mongodb://localhost:27017/configDB', {
useNewUrlParser: true,
useUnifiedTopology: true
});
// Redis client
const redisClient = redis.createClient();
const getAsync = promisify(redisClient.get).bind(redisClient);
const setAsync = promisify(redisClient.set).bind(redisClient);
const delAsync = promisify(redisClient.del).bind(redisClient);
// Configuration model
const Config = mongoose.model('Config', new mongoose.Schema({
_id: String,
database: Object,
apiKeys: Object
}));
const app = express();
app.get('/config/:id', async (req, res) => {
const configId = req.params.id;
try {
// Check cache
let config = await getAsync(configId);
if (!config) {
// Cache miss, fetch from database
config = await Config.findById(configId);
if (config) {
// Store in cache
await setAsync(configId, JSON.stringify(config), 'EX', 3600); // Cache for 1 hour
} else {
return res.status(404).send('Configuration not found');
}
} else {
config = JSON.parse(config);
}
res.json(config);
} catch (error) {
res.status(500).send('Server error');
}
});
app.put('/config/:id', async (req, res) => {
const configId = req.params.id;
const newConfig = req.body;
try {
// Update database
const updatedConfig = await Config.findByIdAndUpdate(configId, newConfig, { new: true, upsert: true });
// Invalidate cache
await delAsync(configId);
res.json(updatedConfig);
} catch (error) {
res.status(500).send('Server error');
}
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
Conclusion
The Cache-Aside (Lazy Loading) strategy is well-suited for a configuration service in a SaaS-based Node.js application. It optimizes for read performance while maintaining consistency and is relatively straightforward to implement
Main difference between "Read-Through Cache" and "Cache-Aside (Lazy Loading)"
The main difference between "Read-Through Cache" and "Cache-Aside (Lazy Loading)" lies in how the application interacts with the cache and the database, especially in terms of who is responsible for fetching data from the database and managing the cache.
Read-Through Cache
Definition: In a Read-Through Cache, the application interacts with the cache directly, and the cache itself is responsible for loading data from the database when there is a cache miss.
Workflow:
Pros:
Cons:
Example:
async function getConfig(configId) {
let config = await getAsync(configId); // Assuming getAsync abstracts read-through behavior
return config;
}
Cache-Aside (Lazy Loading)
Definition: In a Cache-Aside strategy, the application is responsible for managing the cache. The application explicitly loads data into the cache and also fetches data from the database when needed.
Workflow:
Pros:
Cons:
Example:
async function getConfig(configId) {
let config = await getAsync(configId);
if (!config) {
config = await Config.findById(configId);
if (config) {
await setAsync(configId, JSON.stringify(config), 'EX', 3600); // Cache for 1 hour
} else {
throw new Error('Configuration not found');
}
} else {
config = JSON.parse(config);
}
return config;
}
async function updateConfig(configId, newConfig) {
await Config.findByIdAndUpdate(configId, newConfig);
await delAsync(configId); // Invalidate the cache
}
Key Differences
Responsibility:
Control:
Complexity:
Conclusion