Implement robust financial synchronization using Redis. Use this skill when managing rate limits, creating distributed locks to prevent double-spends, handling idempotency caching, or caching non-sensitive product data using ioredis in Node.js.
Redis provides a fast in-memory layer for distributed locking, idempotency caching, and rate limiting. It complements PostgreSQL's ACID transactions with sub-millisecond key operations.
Current State & Risk Calibration:
- MyMoolah's wallet mutations use
sequelize.transaction()with PostgreSQL row-level locking. This prevents double-spends via database serialization. The system is safe today under single-instance deployment.- The idempotency middleware (
middleware/idempotency.js) hits PostgreSQL directly for every check — Redis would make this dramatically faster.- Redis locks become mandatory when running multiple Cloud Run instances (distributed lock prevents two instances from processing the same wallet mutation concurrently).
- Priority: Idempotency caching (high, quick win) > Product catalog caching (medium) > Wallet mutation locks (important before horizontal scaling).
wallets, transactions).middleware/idempotency.js).Before starting a Sequelize transaction that touches a wallet balance, acquire a Redis lock on that specific walletId.
Using ioredis and optionally redlock or a custom Lua script lock.
// utils/redisLock.js
const { redisClient } = require('../config/redis');
/**
* Acquires an exclusive lock on a resource.
* @param {string} resourceKey - e.g., 'lock:wallet:1234'
* @param {number} ttlMs - Lock expiration (e.g., 5000ms)
* @returns {string|null} - Lock token if acquired, null if failed
*/
async function acquireLock(resourceKey, ttlMs = 5000) {
const token = Math.random().toString(36).substring(2);
// SET key value NX (Not eXists) PX (milliseconds)
const result = await redisClient.set(resourceKey, token, 'NX', 'PX', ttlMs);
return result === 'OK' ? token : null;
}
/**
* Safely releases a lock ONLY if the token matches (Lua Script)
*/
async function releaseLock(resourceKey, token) {
const luaScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redisClient.eval(luaScript, 1, resourceKey, token);
}
module.exports = { acquireLock, releaseLock };
const { acquireLock, releaseLock } = require('../utils/redisLock');
async function sendMoney(req, res) {
const { senderWalletId, amount } = req.body;
const lockKey = `lock:wallet:${senderWalletId}`;
// 1. Attempt to acquire lock for 5 seconds
const lockToken = await acquireLock(lockKey, 5000);
if (!lockToken) {
// Another request is currently modifying this wallet
return res.status(409).json({
error: 'CONCURRENT_REQUEST',
message: 'Wallet is currently processing a transaction. Please try again in a few seconds.'
});
}
try {
// 2. Perform DB operations safely inside Sequelize transaction
await sequelize.transaction(async (t) => {
// Validate balance, insert journal entries, update wallet
});
return res.status(200).json({ success: true });
} finally {
// 3. Always release the lock, even if DB fails
await releaseLock(lockKey, lockToken);
}
}
While idempotency keys can be saved in PostgreSQL (IdempotencyKey model), Redis provides a dramatically faster first layer of defense against rapid double-clicks.
const { redisClient } = require('../config/redis');
const idempotencyMiddleware = async (req, res, next) => {
const key = req.headers['x-idempotency-key'];
if (!key) return res.status(400).json({ error: 'X-Idempotency-Key header required' });
const cacheKey = `idempotency:${key}`;
// Attempt to set a "processing" flag atomically
const isNew = await redisClient.set(cacheKey, 'processing', 'NX', 'EX', 86400); // 24hr TTL
if (!isNew) {
const status = await redisClient.get(cacheKey);
if (status === 'processing') {
return res.status(409).json({ error: 'Request is already processing' });
}
// If it's a JSON response, the previous request succeeded completely
try {
return res.status(200).json(JSON.parse(status));
} catch {
return res.status(500).json({ error: 'IDEMPOTENCY_CORRUPTION' });
}
}
// Inject a method to cache the FINAL response upon success
const originalJson = res.json;
res.json = function (body) {
if (res.statusCode >= 200 && res.statusCode < 300) {
// Overwrite 'processing' with the actual success JSON
redisClient.set(cacheKey, JSON.stringify(body), 'EX', 86400);
} else {
// If the transaction failed (e.g., 400 Insufficient Funds), delete the key
// so the user can fix the error and try again with the same key
redisClient.del(cacheKey);
}
originalJson.call(res, body);
};
next();
};
Providers like Flash and EasyPay have static catalogs (Voucher amounts, Electricity municipalities). Do not query PostgreSQL or the API provider on every user request.
async function getFlashProducts() {
const CACHE_KEY = 'products:flash';
// 1. Try Cache
const cached = await redisClient.get(CACHE_KEY);
if (cached) return JSON.parse(cached);
// 2. Fetch from DB/Provider
const products = await FlashProductModel.findAll();
// 3. Set Cache with TTL (e.g., 1 hour)
await redisClient.set(CACHE_KEY, JSON.stringify(products), 'EX', 3600);
return products;
}
EX, PX) been explicitly set on the inserted key?set NX operations?releaseLock)?