UNPKG

@playding/redis-simple-csc

Version:

A minimal ~80-line client-side cache implementation for Redis with RESP3 protocol support

266 lines (238 loc) 8.22 kB
#!/usr/bin/env node const { ClientSideCacheProvider } = require('@redis/client/dist/lib/client/cache'); /** * Generate a unique cache key from Redis command arguments * @param {Array<Buffer|string>} redisArgs - Redis command arguments * @returns {string} Cache key in format "len1_len2_arg1_arg2" * @example * generateCacheKey(['user:1']) // "6_user:1" * generateCacheKey(['user:1', 'user:2']) // "6_6_user:1_user:2" */ function generateCacheKey(redisArgs) { const tmp = new Array(redisArgs.length * 2); for (let i = 0; i < redisArgs.length; i++) { tmp[i] = redisArgs[i].length; tmp[i + redisArgs.length] = redisArgs[i]; } return tmp.join('_'); } /** * 极简客户端缓存 - 只有本地Map + GET/SET + INVALIDATE * 继承 ClientSideCacheProvider * * 核心数据结构: * - cache: Map<cacheKey, value> - 存储缓存值 * - keyToCacheKeys: Map<redisKey, Set<cacheKey>> - 反向索引,用于失效通知 * * 为什么需要 keyToCacheKeys? * 问题: 缓存用 cacheKey (命令+参数),失效通知用 Redis key (单个键名) * 例如: * GET('user:1') → cacheKey: "6_user:1" * MGET(['user:1', 'user:2']) → cacheKey: "6_6_user:1_user:2" * 当 Redis 发送失效通知 'user:1' 时,需要删除两个缓存条目! * * 解决方案: keyToCacheKeys 维护 Redis key → Set<cacheKey> 的映射 * 'user:1' → Set(['6_user:1', '6_6_user:1_user:2']) * 'user:2' → Set(['6_6_user:1_user:2']) * 失效时 O(1) 查找 + O(k) 删除,精准高效 * * @extends ClientSideCacheProvider * @fires SimpleClientSideCache#invalidate * @example * const cache = new SimpleClientSideCache({ enableStat: true }); * const client = redis.createClient({ * RESP: 3, * clientSideCache: cache * }); */ class SimpleClientSideCache extends ClientSideCacheProvider { /** * Create a simple client-side cache instance * @param {Object} [options={}] - Cache options * @param {boolean} [options.enableStat=false] - Enable statistics tracking * @param {Function} [options.CacheMapClass=Map] - Custom Map class for cache storage (must extend native Map) * @param {Function} [options.KeyMapClass=Map] - Custom Map class for key-to-cacheKeys mapping (must extend native Map) */ constructor(options = {}) { super(); const CacheMapClass = options.CacheMapClass || Map; const KeyMapClass = options.KeyMapClass || Map; // Validate that provided classes extend Map if (!(CacheMapClass.prototype instanceof Map || CacheMapClass === Map)) { throw new TypeError('CacheMapClass must extend native Map'); } if (!(KeyMapClass.prototype instanceof Map || KeyMapClass === Map)) { throw new TypeError('KeyMapClass must extend native Map'); } this.cache = new CacheMapClass(); this.keyToCacheKeys = new KeyMapClass(); this._initializeStatistics(options.enableStat); } /** * Initialize statistics tracking * @private * @param {boolean} enableStat - Whether to enable statistics */ _initializeStatistics(enableStat) { if (enableStat) { this._stats = { hitCount: 0, missCount: 0, loadSuccessCount: 0, loadFailureCount: 0, totalLoadTime: 0, evictionCount: 0 }; this._incHit = () => this._stats.hitCount++; this._incMiss = () => this._stats.missCount++; this._incLoadSuccess = () => this._stats.loadSuccessCount++; this._incLoadFailure = () => this._stats.loadFailureCount++; this._addLoadTime = (time) => this._stats.totalLoadTime += time; this._incEviction = (count = 1) => this._stats.evictionCount += count; } else { this._incHit = () => {}; this._incMiss = () => {}; this._incLoadSuccess = () => {}; this._incLoadFailure = () => {}; this._addLoadTime = () => {}; this._incEviction = () => {}; } } /** * Handle cache lookup and storage for Redis commands * @param {Object} client - Redis client instance * @param {Object} parser - Command parser with redisArgs and keys * @param {Function} fn - Function to execute Redis command * @param {Function} [transformReply] - Optional reply transformation function * @param {Object} [typeMapping] - Type mapping for reply transformation * @returns {Promise<*>} Cached or fresh command result */ async handleCache(client, parser, fn, transformReply, typeMapping) { const cacheKey = generateCacheKey(parser.redisArgs); if (this.cache.has(cacheKey)) { this._incHit(); return structuredClone(this.cache.get(cacheKey)); } this._incMiss(); const startTime = process.hrtime.bigint(); let reply; try { reply = await fn(); this._incLoadSuccess(); } catch (err) { this._incLoadFailure(); throw err; } finally { const endTime = process.hrtime.bigint(); const elapsed = Number(endTime - startTime) / 1e6; // Convert to ms this._addLoadTime(elapsed); } let value = transformReply ? transformReply(reply, parser.preserve, typeMapping) : reply; this.cache.set(cacheKey, value); // 建立反向索引: 每个 Redis key → 包含它的所有 cacheKey // 这样失效通知来时能快速找到所有相关缓存条目 for (const key of parser.keys) { const keyStr = key.toString(); if (!this.keyToCacheKeys.has(keyStr)) { this.keyToCacheKeys.set(keyStr, new Set()); } this.keyToCacheKeys.get(keyStr).add(cacheKey); } return structuredClone(value); } /** * Return the command to enable client tracking * @returns {string[]} Redis command array */ trackingOn() { return ['CLIENT', 'TRACKING', 'ON']; } /** * Handle cache invalidation notifications from Redis * @param {Buffer|null} key - Redis key to invalidate, or null for global flush * @fires SimpleClientSideCache#invalidate */ invalidate(key) { if (key === null) { // 全局失效 (FLUSHDB 等) const evictedCount = this.cache.size; this.cache.clear(); this.keyToCacheKeys.clear(); this._incEviction(evictedCount); this.emit('invalidate', key); return; } const keyStr = key.toString(); const cacheKeys = this.keyToCacheKeys.get(keyStr); if (cacheKeys) { // 删除所有包含此 Redis key 的缓存条目 // 例如: 'user:1' 失效会删除 GET('user:1') 和 MGET(['user:1','user:2']) 的缓存 const evictedCount = cacheKeys.size; for (const cacheKey of cacheKeys) { this.cache.delete(cacheKey); } this.keyToCacheKeys.delete(keyStr); this._incEviction(evictedCount); } this.emit('invalidate', key); } /** * Clear all cached entries */ clear() { this.cache.clear(); this.keyToCacheKeys.clear(); } /** * Get cache statistics * @returns {Object} Statistics object * @property {number} hitCount - Number of cache hits * @property {number} missCount - Number of cache misses * @property {number} loadSuccessCount - Number of successful loads from Redis * @property {number} loadFailureCount - Number of failed loads from Redis * @property {number} totalLoadTime - Total time spent loading from Redis (ms) * @property {number} evictionCount - Number of cache entries evicted */ stats() { if (this._stats) { return { ...this._stats }; } return { hitCount: 0, missCount: 0, loadSuccessCount: 0, loadFailureCount: 0, totalLoadTime: 0, evictionCount: 0 }; } /** * Handle Redis client errors by clearing cache */ onError() { this.clear(); } /** * Handle Redis client closure by clearing cache */ onClose() { this.clear(); } /** * Get the number of cached entries * @returns {number} Number of entries in cache */ size() { return this.cache.size; } } /** * Invalidate event * @event SimpleClientSideCache#invalidate * @type {Buffer|null} * @description Emitted when cache entries are invalidated. * The key is a Buffer for specific key invalidations, or null for global flush. */ module.exports = { SimpleClientSideCache };