棋牌游戏技术实战(十三):缓存对局历史时控制容量,命中以后仍然检查成员权限
给历史接口加缓存,看起来像是把数据库查询结果放进一个Map。第一次读完以后保存,下一次直接返回,代码很短,也容易在本地看到响应变快。真正接进棋牌游戏服务以后,会遇到三个问题:对局仍在继续,旧结果不能当作最新历史;成员资格可能被撤销,缓存命中不能绕过权限;历史内容不断增加,Map不能无限积累。还有一个更隐蔽的问题,调用者修改了返回对象,可能连带修改下一位用户拿到的缓存。
本篇把这些边界一起做成可运行的模块。缓存同时限制条目数、序列化内容字节数和正在加载的键数量,支持短有效期与最近使用顺序。同一键同时出现多个未命中请求时,共用一次加载,但每个调用者得到独立解析的对象。业务接入每次先验证房间成员并取得当前epoch与seq,再决定使用哪个缓存键;缓存保存的是某个明确版本的历史,不是所有用户共享的"demo最新状态"。
需要先说明这是一份教学实现,不是证明历史查询一定加速的基准。本例为了严格复用既有权限逻辑,先调用store.sync取得完整快照,这本身有查询和序列化成本。它能减少同一版本历史的重复读取,却仍然会读取当前房间状态。真实优化应在不改变授权语义的前提下增加轻量的成员与版本查询,并实际测量数据库、网络和对象解析成本,不能因为代码里出现cache就写成性能提高数倍。
继续进入示例工程/02-07-room-platform。Node.js版本为24,数据库和测试依赖沿用锁文件,新增缓存核心只使用内置performance。接入函数依赖前面已有RoomStore的sync和history方法,不新增数据库表。fixture仍会创建独立的PGlite测试实例,真实PostgreSQL的查询计划与多实例缓存一致性另外验收。
powershell
node --version
npm ci
node --check src/bounded-cache.mjs
node --check scripts/cache-demo.mjs
先约定可缓存的数据。这里面向JSON可以表示的普通历史对象,事件、棋盘、序号和字符串都符合要求;undefined、循环引用、函数、BigInt或带特殊原型的对象不在契约内。JSON序列化会改变Date等对象的类型,所以不能把一个任意业务对象塞进来以后期待取出仍是原来的实例。缓存与HTTPJSON接口共用数据契约,才能让复制和大小统计有清楚的含义。
条目里的内容保存成JSON字符串,而不是保存原始对象引用。读出时重新parse,多个调用方不会共享同一块可变数组。例如客户端渲染层对棋盘做了本地标记,若它直接拿到缓存拥有的board数组,后续读取就会看到被污染的内容。Object.freeze只冻结第一层对象也不够,嵌套数组仍能修改;本文用序列化副本处理整个可表示的数据树。
缓存键包含用户、房间、epoch和seq。用户参与键可以避免当前实现把权限上下文混在一起,房间区分不同对局,epoch区分房间重建或新的实例代际,seq区分事件推进。只用roomId会把昨天demo的结果和今天重新创建的demo混起来,只用seq也会把不同代际的第十步混起来。键不是安全凭据,仍需在业务入口验证会话和房间成员资格。
保存下面完整模块为src/bounded-cache.mjs。默认最多一百二十八项,序列化载荷与键合计最多两兆字节,存活一秒,最多十六个不同键正在加载。所有限制都可在创建时注入,测试用很小的数观察边界。这里的payloadBytes不是JavaScript堆实际占用,因为字符串对象、Map条目、Promise和解析副本都有额外内存,不能拿两兆字节上限承诺整个进程只增加两兆内存。
javascript
import { performance } from 'node:perf_hooks';
export class BoundedCache {
constructor({ maxEntries = 128, maxBytes = 2 * 1024 * 1024, ttlMs = 1000,
maxPending = 16, now = () => performance.now() } = {}) {
if (![maxEntries, maxBytes, ttlMs, maxPending].every(x => Number.isSafeInteger(x) && x > 0)) throw new Error('BAD_CACHE_LIMIT');
Object.assign(this, { maxEntries, maxBytes, ttlMs, maxPending, now });
this.entries = new Map(); this.pending = new Map(); this.bytes = 0;
}
remove(key) {
const item = this.entries.get(key);
if (item) { this.bytes -= item.bytes; this.entries.delete(key); }
}
get(key) {
const item = this.entries.get(key);
if (!item) return undefined;
if (this.now() >= item.expires) { this.remove(key); return undefined; }
this.entries.delete(key); this.entries.set(key, item);
// A caller never receives the mutable object owned by the cache.
return JSON.parse(item.json);
}
put(key, value) {
const json = JSON.stringify(value);
if (typeof json !== 'string') throw new Error('NOT_JSON');
const bytes = Buffer.byteLength(key) + Buffer.byteLength(json);
this.remove(key);
if (bytes > this.maxBytes) return false;
for (const [id, item] of this.entries) if (this.now() >= item.expires) this.remove(id);
while (this.entries.size >= this.maxEntries || this.bytes + bytes > this.maxBytes) this.remove(this.entries.keys().next().value);
this.entries.set(key, { json, bytes, expires: this.now() + this.ttlMs }); this.bytes += bytes;
return true;
}
async load(key, loader) {
if (typeof key !== 'string' || key.length > 200) throw new Error('BAD_CACHE_KEY');
const cached = this.get(key); if (cached !== undefined) return cached;
let pending = this.pending.get(key);
if (!pending) {
if (this.pending.size >= this.maxPending) throw Object.assign(new Error('CACHE_BUSY'), { code: 'CACHE_BUSY', status: 503 });
pending = Promise.resolve().then(loader).then(value => {
// Serialize before sharing to reject cycles and separate ownership.
const json = JSON.stringify(value); if (typeof json !== 'string') throw new Error('NOT_JSON');
this.put(key, JSON.parse(json)); return json;
});
this.pending.set(key, pending);
pending.then(() => this.pending.delete(key), () => this.pending.delete(key));
}
return JSON.parse(await pending);
}
stats() { return { entries: this.entries.size, payloadBytes: this.bytes, pending: this.pending.size }; }
}
export function cachedHistory(store, cache) {
return async (room, user) => {
// Every read rechecks membership and obtains the current epoch and sequence.
const view = await store.sync(room, user, null, -1);
const key = `${user}:${room}:${view.epoch}:${view.seq}`;
return cache.load(key, async () => {
const history = await store.history(room, user);
if (history.epoch !== view.epoch || history.seq !== view.seq) {
throw Object.assign(new Error('HISTORY_ADVANCED'), { code: 'HISTORY_ADVANCED', status: 409 });
}
return history;
});
};
}
get先检查是否存在和是否过期。过期时真正删除条目并减少字节统计,而不是返回undefined却留着对象。命中以后把键删除再放回Map末尾,利用插入顺序表达最近使用顺序。最早位置表示最久没有被读取的缓存,不表示最早创建的对局;读取一条旧历史可以把它移动到最近使用位置,这正是LRU与简单先进先出淘汰的区别。
有效期从put保存时开始,命中只改变LRU位置,不延长expires。否则一个已经落后的结果只要被频繁读取,就可能永远不失效。本篇键有明确版本,因此旧内容本身不会变成新版本,但有限生存时间仍有助于控制不再访问的数据。若业务需要访问续期,应把语义单独写明,并核对是否会扩大数据保留时间,不能把两种模式混用。
put先生成JSON并计算键和内容的UTF-8字节数,再删除同键旧内容。单条超过字节上限时不缓存,调用者仍能得到本次加载结果。大对象不应为腾位置清空所有旧缓存再告诉调用者放不下,所以代码在淘汰其他条目之前检查单条大小。删除同键旧值是避免保留与新结果不一致的内容;此时该键下一次需要重新加载,属于明确的策略取舍。
缓存创建和淘汰的字节计算始终使用Buffer.byteLength,中文不是一个字符一个字节。每次remove根据条目创建时保存的bytes减去,避免对象后来变化导致统计漂移。由于实际只保存字符串,缓存自己拥有的数据不会在外部被修改。测试会把容量压到五十字节,确认过期清理后总载荷回到零,不能只测entries数量而遗漏字节账本。
写入新键前清理过期项,然后在条目数或字节限制需要时从Map首项淘汰。这个扫描和淘汰都有条目上限,适合小缓存;大规模使用时线性检查也会消耗事件循环。条目数不是越大越好,缓存内容解析也可能造成短时CPU和内存压力。本文不加入自动扩大容量的逻辑,实际参数应从访问分布与延迟测量决定。
同一未命中键的并发合并叫作single flight。第一次调用创建加载Promise并登记在pending,后续同键等待同一个Promise,避免在对局结束后几十个页面一起打开时同时查询完全相同的历史。加载函数放在微任务里开始,登记发生在await之前,所以同一个执行上下文里的后续调用可以看见它。Promise完成或失败后删除pending,不会把一次数据库异常永久缓存成失败。
共享的是加载工作,不是最终对象。pending返回序列化字符串,每个等待者在await之后自己解析,才能保证a修改events不会影响b。缓存命中也重新解析。这样会增加CPU和对象分配,尤其历史较大时需要测量;为了省一次parse直接返回同一个对象,可能重新引入污染风险。若改用不可变数据结构或冻结深层对象,需要让调用端也遵守契约,并重新做实际成本比较。
maxPending限制不同键的正在加载数量,同键加入已有工作不额外占名额。若十六个房间正在读取,第十七个新键返回CACHE_BUSY;一个已在加载的同键仍可等待原Promise。否则并发合并模块自己可能成为无界任务队列。这个限制只覆盖当前缓存对象,不能控制所有未缓存查询,也不会中止一个永远不结束的loader,所以数据库查询仍须有时间预算。
加载失败不会put内容,pending也会清掉,下一次调用可以重新加载。错误不会被自动改成空历史,因为空数组可能被客户端当成"对局确实没有事件"。临时数据库失败和合法零事件必须区分。错误默认传给业务入口,由接口统一返回受控状态;日志记录错误类别时仍应遵守前一篇白名单,不能把SQL和连接字符串完整输出。
接入函数cachedHistory每次先执行store.sync,这一步会按既有逻辑验证房间成员并取得当前代际与序号。之后才查缓存,所以一个已经被撤销成员资格的用户,即使刚才读过同一历史,也不能继续拿缓存。授权的判断时刻仍是这次数据库查询里的检查;如果撤销恰好发生在检查之后,本次已通过授权的读取可能继续返回。这和普通接口的授权时间边界一致,不应许诺撤销后所有在途响应立刻消失。
第一次加载历史时还会核对history的epoch和seq是否与前面读到的view相同。如果对局在两次读取之间前进,返回HISTORY_ADVANCED,不把新历史保存进旧版本键。它不是整体跨请求数据库快照,只有每个既有store调用内部事务的保证。读者可以选择让客户端重试一次,或增加同一个数据库事务内读取授权与历史的专用方法,但不能删掉检查以后仍说缓存键严格对应版本。
写接口绝不以缓存为真实依据。落子仍使用数据库事务、请求指纹和expectedSeq校验,不能先看缓存里的turn就决定允许提交。客户端展示可以容忍短暂旧版本,写入合法性必须依赖当前事务状态。本文缓存历史读取,没有缓存会话有效性、成员权限或命令结果,避免把安全撤销和写入原子性混进普通短时数据缓存。
保存下面完整演示文件为scripts/cache-demo.mjs。它不连接数据库,故意同时读取同一个键两次,并让第一个结果修改事件序号。预期loader只执行一次,第二个返回对象仍保留原序号。这个演示用于说明共享加载与独立对象的区别,不是模拟真实多线程数据库并发,也没有给出性能收益数字。
javascript
import { BoundedCache } from '../src/bounded-cache.mjs';
const cache = new BoundedCache(); let reads = 0;
const load = async () => { reads++; return { events: [{ seq: 1 }] }; };
const [a, b] = await Promise.all([cache.load('alice:demo:epoch:1', load), cache.load('alice:demo:epoch:1', load)]);
a.events[0].seq = 99;
console.log(JSON.stringify({ reads, b, cache: cache.stats() }));
powershell
node scripts/cache-demo.mjs
输出中的reads应为一,b里的事件序号仍为一,cache.entries应为一、pending应为零。修改a不会改变b,更不会改变缓存之后的返回。输出顺序和字节数可能随着键与内容变化,应该核对语义,不把某个固定总字节当作运行成功的唯一标准。若reads变成二,检查是否每次创建了新的缓存实例;共享实例应在进程启动时创建,不是放在每个请求函数里临时构造。
真实服务的接入片段如下,依赖已经初始化的store,以及请求处理器中通过会话认证得到的user和路由roomId。readHistory在启动时创建一次,不能对每个请求创建新的BoundedCache。此片段只展示业务调用位置,HTTP错误映射仍需沿用原处理器,不能把CACHE_BUSY或HISTORY_ADVANCED吞掉以后返回200。
javascript
// 接入片段:启动时创建,供历史读取路由共用。
const historyCache = new BoundedCache({
maxEntries: 128,
maxBytes: 2 * 1024 * 1024,
ttlMs: 1000,
maxPending: 16
});
const readHistory = cachedHistory(store, historyCache);
// 接入片段:已有请求处理器内,user已经经过auth.user验证。
const history = await readHistory(roomId, user);
send(res, 200, history);
先在本地测试确认行为,再决定是否接入原HTTP入口。本次配套工程保留原有入口不变,新增模块和演示不会让前几篇运行方式悄悄改变。读者接入之后需要重新执行原历史接口权限测试、事件重演测试和缓存测试,证明原本输出没有被丢字段。不要把缓存里的数据裁剪作为权限检查的替代,因为授权与数据视图是两层不同约束。
保存下面完整测试文件test/bounded-cache.test.mjs。前面三项测试在内存中精确控制时间和加载顺序,分别检查淘汰与统计、同键合并及对象隔离、正在加载容量。最后一项使用PGlite与真实RoomStore,写入一步后读到新seq,再撤销alice成员资格,确认原缓存没有让读取继续通过,数据库的已提交序号也保持原值。
javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { BoundedCache, cachedHistory } from '../src/bounded-cache.mjs';
import { fixture, input } from './fixture.mjs';
test('LRU, TTL and oversized values preserve accounting', async () => {
let time = 0; const cache = new BoundedCache({ maxEntries: 2, maxBytes: 50, ttlMs: 10, now: () => time });
cache.put('a', { x: 1 }); cache.put('b', { x: 2 }); cache.get('a'); cache.put('c', { x: 3 });
assert.equal(cache.get('b'), undefined); assert.equal(cache.stats().entries, 2);
assert.equal(cache.put('huge', 'x'.repeat(60)), false); assert.equal(cache.stats().entries, 2);
time = 10; assert.equal(cache.get('a'), undefined); assert.equal(cache.get('c'), undefined);
assert.equal(cache.stats().payloadBytes, 0);
});
test('single flight shares work but never shares mutable results, failures recover', async () => {
const cache = new BoundedCache(); let calls = 0;
const load = async () => { calls++; return { board: [0] }; };
const [a, b] = await Promise.all([cache.load('same', load), cache.load('same', load)]);
assert.equal(calls, 1); a.board[0] = 9; assert.equal(b.board[0], 0);
assert.equal((await cache.load('same', load)).board[0], 0);
await assert.rejects(cache.load('fail', async () => { throw new Error('DB'); }));
assert.deepEqual(await cache.load('fail', load), { board: [0] }); assert.equal(cache.stats().pending, 0);
});
test('inflight capacity bounds new loads while duplicate key can still join', async () => {
const cache = new BoundedCache({ maxPending: 1 }); let release;
const pending = cache.load('one', () => new Promise(r => { release = r; }));
await Promise.resolve();
await assert.rejects(cache.load('two', async () => 2), { code: 'CACHE_BUSY' });
const joined = cache.load('one', async () => 999); release(1);
assert.equal(await pending, 1); assert.equal(await joined, 1);
});
test('real database key changes after move and cached result cannot bypass revoked member', async () => {
const f = await fixture();
try {
const read = cachedHistory(f.store, new BoundedCache());
const initial = await read('demo', 'alice'); assert.equal(initial.seq, 0);
const view = await f.store.sync('demo', 'alice', null, -1);
await f.store.command('demo', 'alice', input(view));
assert.equal((await read('demo', 'alice')).seq, 1);
await f.db.query("UPDATE lab_members SET active=false WHERE room_id='demo' AND user_id='alice'");
await assert.rejects(read('demo', 'alice'), { code: 'NOT_A_MEMBER' });
assert.equal((await f.db.query("SELECT seq FROM lab_rooms WHERE id='demo'")).rows[0].seq, 1);
} finally { await f.close(); }
});
powershell
node --test --test-isolation=none test/bounded-cache.test.mjs
本次四项测试实际执行并通过。数据库测试是隔离的PGlite实例,没有使用.env里的真实PostgreSQL;缓存模块没有经过长时间压力、网络多实例一致性或设备端验收。测试中没有真实HTTP,因为本篇关注业务读取与缓存边界,HTTP传输保持原入口的既有语义。不要把这里的数据库方法调用描述成端到端浏览器验证。
第一项通过读取a改变LRU顺序,再写c淘汰b,证明被淘汰的是最久未使用项,不是简单最早创建项。写入超大值返回false,不破坏已有两项。时间推进到过期边界后逐个读取,统计字节回到零。第二项同时等待同一键,检查loader只执行一次,同时修改a不影响b与下一次命中;失败键之后重新加载成功,证明pending没有残留永久失败状态。
第三项在第一个loader被人为挂起时,第二个不同键被拒绝,同键仍然可以加入。释放loader以后两位调用者拿到同一内容但不是同一个对象。第四项证明历史版本会跟随落子变化,成员撤销后无法命中旧数据继续读取。相比只测get返回了什么,这些验证覆盖了业务状态、权限和内存管理一起工作的边界。
排查缓存"没效果",先看是否键每次都变化。对局seq持续前进,本来就不能重用旧版本历史,命中率低并不一定是实现错误。若用户ID每次被错误写成随机trace,也会制造无意义的新键。若每次请求都创建一个cache实例,当然也不会命中。观察键时不要完整输出用户身份与epoch到公共日志,可以统计命中、未命中、加载失败和淘汰次数,避免调试手段制造新的信息泄露。
排查内存增长,除了entries还要看pending和调用方持有的返回对象。缓存移除条目,只是删除自身引用;渲染层、Promise回调和历史导出脚本可能仍保留解析后的内容。payloadBytes回到零不意味着垃圾回收立刻完成,也不意味着进程RSS会马上下降。验收应结合堆对象和时间曲线,不用一次任务管理器截图断言没有泄露。
私有游戏状态不宜简单跨用户共用缓存。即使某个历史现在看上去全是公开事件,将来新增私有抽取信息后,旧键策略可能让不同视角得到不应共享的数据。本文键包含user,并且沿用store.history的既有输出;若想把公开部分跨用户共享,应先将公开投影与私有投影明确定义,再分别缓存和测试。权限检查仍需在共享公开内容前执行,不能因为数据公开就允许已退出房间的用户查看内部记录。
缓存不适合保存失败事务的半成品。loader必须在拿到完整结果后才put,不能在逐条读取事件过程中往缓存对象里追加,再让其他请求同时读取这个对象。对局历史的完整性检查仍可以与前面的分页和导出校验衔接,缓存命中只是数据来源变化,不会免除序号连续、代际相同和规则版本一致的要求。特别是历史用于重演时,返回HTTP200不是验证完成的证据。
对已结束且不可修改的历史,可以设置更长TTL,但需要明确数据是否真的不可变。后台修复、成员隐私策略和规则版本迁移都可能改变读取视图。若允许修改旧事件,单用epoch与seq可能无法区分修改前后,应增加内容版本或禁止原地修改并建立新的代际。缓存键应表达业务真实变化,不是为了提升命中率刻意忽略变更。
多实例部署时每个缓存独立,进程重启后冷缓存也独立。大量实例同时重启可能一起查询数据库,同键合并只能在单实例内减少重复,不能保证全局一次加载。若引入共享缓存,还要处理网络失败、键空间、访问权限、过期与容量,不能直接把本地Map替换成远程get就宣称完成。先把单实例不变量做清楚,才能知道分布式方案需要保留哪些规则。
缓存上线前,先选一个能够保持不变的房间作为观察对象。登录后读取当前epoch和seq,在没有人继续落子的前提下连续调用cachedHistory。第一次应进入loader,第二次应从已保存的同键结果解析出一个新对象。这里的"新对象"值得实际检查:两个返回值内容可以相同,但引用不能相同;修改第一个对象的嵌套棋盘,第二个对象和下次读取的结果都应保持原样。否则缓存就成了多个请求共享的可变全局变量。
观察时不要只记一次接口耗时。可以在受控测试里给loader增加计数,分别记录总调用次数和真正读取历史的次数。先等第一轮完成,再做第二轮,能够验证命中;同时发出两次同键请求并把loader暂停,则能够验证合并。在生产里不建议把完整缓存键直接输出,因为键含有用户和房间信息,可以只记录固定路由的命中、未命中和加载失败数量。统计是帮助解释行为,不能改变原来的权限路径。
一个合理的失败实验,是让第一次loader抛出明确异常,第二次返回正常历史。第一次的所有等待者应得到失败,第二次必须重新执行loader并成功。如果失败Promise一直留在pending里,这个键之后每次都会复用同一个失败结果,看上去像数据库一直故障,实际上数据库根本没有再次被调用。本实现给成功和失败都安排了清理,但清理发生在Promise结束时;永不结束的loader仍会长期占位,因此查询预算不能省略。
pending的容量与entries的容量含义不同。entries控制已经完成、可复用的结果;pending控制同时执行中的不同加载任务。十个请求等待同一个键,只应占一个pending;十个不同键则可能占十个。若把等待人数当成任务数,热门房间会很快消耗额度;若完全不控制不同键,用户可以不断换房间编号制造大量数据库工作。两种资源分别预算,才能解释CACHE_BUSY是在保护哪一部分。
新键被拒绝时,服务端可以返回可重试的繁忙错误,但不能把失败伪装成空历史。空历史是合法业务数据,会被客户端当成房间没有事件,进而清空展示或导出错误文件。也不建议收到CACHE_BUSY后立即绕过缓存查询数据库,那会在最需要限制加载数量的时候放开第二条路径。若业务必须有直读降级,应给直读路径独立的并发预算,并验证拒绝和超时状态,不是简单写一个catch再调history。
失效时间从put成功时计算,因此加载耗时不吞掉条目存活时间。这个选择适合完整结果加载完成后再使用;如果内容带有严格业务有效期,例如某个临时授权视图,TTL还要被该视图的到期时间约束。本文的历史读取没有把授权有效期塞进缓存对象,因为每次仍经过store.sync检查成员关系。不要把缓存TTL误解为用户还能合法访问多久,两者是不同的时钟和不同的责任。
当前容量计算包含键和序列化正文的UTF-8字节。这比按字符串length估算更适合含汉字的历史,但它仍只是被保存载荷的近似预算。Map节点、字符串内部表示、Promise、解析后的数组和垃圾回收临时对象都没有被精确计算。设置两兆字节不意味着进程最多用两兆内存。工程验收应检查预算是否按预期触发,同时观察堆和进程内存,不能用其中一个指标替代另一个。
需要调整容量时,先考虑单个房间历史的大小分布。如果多数结果远小于上限,条目数量可能先触发;如果少量长对局占据大部分载荷,字节上限可能先触发。两种情况对应不同优化:前者可以评估是否适当增加条目数,后者可能更适合分页读取或只缓存摘要。本文遇到超大结果直接返回给当前调用者但不保存,避免一个对象把小对象全部挤走;返回本身仍需要独立的响应体预算。
LRU移动发生在有效命中时,它表达最近使用,而不是最早写入。可以构造三个键,在容量只能容纳两个时先写甲和乙,再读取甲,再写丙;最后应保留甲和丙。测试里用短小可辨认的对象,不靠打印大量JSON观察。若预期与实际不符,先看读取时是否已经过期,再看键是否真正一致。用户、epoch或seq任何一处不同,都是另一个键,不能为了让测试命中而删掉业务字段。
房间继续落子以后,当前版本变化,旧条目可以暂时留在容量范围内,但新请求不会再使用旧键。这样避免主动遍历所有相关键删除,代价是旧载荷要等到过期或淘汰才释放。对本地小缓存这个取舍清楚可控;对高频变化的大历史可能浪费较多空间。优化之前应观察新旧版本比例,而不是看到Map里存在旧键就认定数据一定被错误返回。
这里还有一次读取与加载之间的竞态。第一次sync确认版本时,房间可能还是第十步;history加载时可能已经第十一步。若把第十一步历史写到第十步键下,之后同键命中会得到与键不一致的内容。本实现比较两次结果的epoch与seq,发现变化就抛HISTORY_ADVANCED。调用方可在自己的读取预算内重新获取当前版本,但不能无限循环,因为活跃房间可能一直变化。每次重试都需要重新授权。
同键合并只对同一读取视角成立。即使两个用户都在同一个房间,键里也保留用户身份,不能让其中一个人的私有结果被另一个人复用。对于公开棋盘,跨用户共享可能在技术上可行,但需要先证明输出确实不含私有字段,并保证未来变更不会破坏这份契约。为了省一次查询直接去掉user,比增加一段缓存代码更容易引入长期隐患。
成员退出的验证应使用已经成功命中过的键。先以alice读取两次让缓存处于热状态,再撤销该成员,第三次读取必须在sync授权阶段被拒绝。如果只用从未缓存过的新用户测试403,就没有检验"缓存掩盖撤销"这个问题。还应确认拒绝之后没有新事件和新回执;只读失败不应偷偷创建成员、恢复座位或更新对局状态。测试同时检查状态事实,才算业务验证。
撤销与正在执行的请求存在时间边界。一个请求先获得授权,再发生撤销,它可能继续完成已经开始的读取。本文并未实现对所有在途读取的即时取消,也没有宣称撤销瞬间之前启动的响应必定被拦截。若业务要求更强的即时撤销语义,需要在发送前再核对授权版本,或使用可验证的成员版本参与结果校验;这会增加查询和竞态设计,必须另设测试,不能靠缩短TTL替代。
在接入HTTP时,应把cachedHistory放在身份已经解析之后。user要来自会话校验结果,room要经过路径格式验证,不能直接采用请求体自报的用户。缓存函数本身接受的是可信应用参数,它不是登录模块。如果希望模块自己承担身份校验,应改变接口并增加相应依赖;当前示例保持职责简单,文章给出的调用顺序不能省略。把认证和缓存分别测通,不等于把它们接反以后仍然安全。
缓存对象最好只由这份适配器生成。其他代码若直接调用put写入任意键,可以绕过版本和授权约定。模块导出的基础能力用于演示和单元测试,应用服务应该封装自己的键生成和加载逻辑,避免各路由各拼一套字符串。尤其不要允许客户端提交缓存键;客户端可以请求房间,却不能决定服务端把哪些用户和版本视为同一个结果。
排查缓存造成的异常,先临时关闭读取复用而保留原有授权和数据读取,再比较同一固定房间的结果。如果直读也错误,应先修复业务读取;如果只有缓存错误,再检查键、复制和失效边界。切换开关不应改变输出协议,否则无法知道差异来自缓存还是来自另一个格式。回退实验要限制范围,避免在高峰把所有请求同时转成数据库查询。
本篇没有提供Redis连接或分布式锁,也没有把本地命中率写成多实例收益。不同进程都有自己的Map,各自可能加载一次;数据库仍需承受实例数乘以冷启动需求。滚动发布期间新实例缓存为空,应该先用受控流量观察,必要时逐步接入。预热也必须经过正常授权,不能为了热缓存批量读取所有人的私有房间数据,再将这些对象长期留在内存。
若后续要缓存分页历史,键应带上游标和页大小,还要保留对局代际及读取视角。缓存一个页不能证明整个导出文件完整,跨页仍可能遇到版本推进。导出应按前面的归档协议冻结或校验读取边界,并核对文件尾记录。分页缓存只是减少某一页重复计算,不能替代连续序号和最终状态验证,也不能把某页短暂为空解释为整个历史结束。
最终提交前,可以在本机保存一次测试输出和配置值,记录Node版本、隔离数据库类型、entries上限、字节上限及TTL。这样以后修改算法时能复现旧边界。本次验证使用PGlite的隔离数据库和既有RoomStore流程,能检查权限与落子后的版本变化;真实PostgreSQL连接池、多进程部署和持续高负载尚未执行。把这些项目留在待验收清单,比写一个没有实验条件的"缓存提升明显"更有用。
版本键设计完成后,仍要与接口协议一起维护。新增私有字段、规则版本或历史修复能力时,应重新检查哪些变动需要生成新键,而不是只修改返回JSON。缓存失效规则也是读取契约的一部分,代码评审不能仅关注淘汰算法。
验收本篇时,正常读取得到当前版本历史,重复同键加载只执行一次,调用者不能污染别人结果,超大对象不挤爆缓存,失败加载能恢复,权限撤销不会被命中掩盖。真实性能收益应另做基准,不写进尚未执行的验证记录。缓存的价值是有条件地减少重复工作,条件包括版本明确、容量受控、身份可靠和输出可复制,缺少任何一项都可能把短小优化变成难以排查的业务错误。