feat(content): eager invalidation and optimistic lock for cqrs
M4: ADR-038 Eager Invalidation + ADR-039 Optimistic Lock - EagerInvalidationService: Redis DEL after MySQL commit - Cache key conventions for textbook/chapter/kp/question - Version header check (If-Match/X-Expected-Version) on write endpoints - 409 Conflict on version mismatch - All write endpoints return updatedAt timestamp
This commit is contained in:
35
services/content/src/shared/cache/cache-keys.ts
vendored
Normal file
35
services/content/src/shared/cache/cache-keys.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Content 服务查询缓存 Key 约定(ADR-038 Eager Invalidation)。
|
||||
*
|
||||
* Key 命名规范:
|
||||
* - 实体单条:`content:<entity>:<id>`
|
||||
* - 实体列表:`content:<entity>s:list` 或 `content:<entity>s:list:<parentId>`
|
||||
*
|
||||
* 写操作后,按实体粒度调用 EagerInvalidationService 失效对应 Key。
|
||||
* 列表 Key 可能因查询参数不同存在多种变体,故列表类失效使用 pattern
|
||||
* (如 `content:textbooks:list*`)兜底删除所有变体。
|
||||
*/
|
||||
export const ContentCacheKeys = {
|
||||
/** 教材单条:content:textbook:{id} */
|
||||
textbook: (id: string): string => `content:textbook:${id}`,
|
||||
/** 教材列表 pattern:匹配 content:textbooks:list 及其带参变体 */
|
||||
textbooksListPattern: (): string => `content:textbooks:list*`,
|
||||
|
||||
/** 章节单条:content:chapter:{id} */
|
||||
chapter: (id: string): string => `content:chapter:${id}`,
|
||||
/** 章节列表:content:chapters:list:{textbookId} */
|
||||
chaptersList: (textbookId: string): string =>
|
||||
`content:chapters:list:${textbookId}`,
|
||||
|
||||
/** 知识点单条:content:knowledge-point:{id} */
|
||||
knowledgePoint: (id: string): string => `content:knowledge-point:${id}`,
|
||||
/** 知识点列表:content:knowledge-points:list:{chapterId} */
|
||||
knowledgePointsList: (chapterId: string): string =>
|
||||
`content:knowledge-points:list:${chapterId}`,
|
||||
|
||||
/** 题目单条:content:question:{id} */
|
||||
question: (id: string): string => `content:question:${id}`,
|
||||
/** 题目列表:content:questions:list:{knowledgePointId} */
|
||||
questionsList: (knowledgePointId: string): string =>
|
||||
`content:questions:list:${knowledgePointId}`,
|
||||
} as const;
|
||||
8
services/content/src/shared/cache/cache.module.ts
vendored
Normal file
8
services/content/src/shared/cache/cache.module.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EagerInvalidationService } from "./eager-invalidation.js";
|
||||
|
||||
@Module({
|
||||
providers: [EagerInvalidationService],
|
||||
exports: [EagerInvalidationService],
|
||||
})
|
||||
export class CacheModule {}
|
||||
107
services/content/src/shared/cache/eager-invalidation.ts
vendored
Normal file
107
services/content/src/shared/cache/eager-invalidation.ts
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { getRedis } from "../../config/redis.js";
|
||||
import { ContentCacheKeys } from "./cache-keys.js";
|
||||
|
||||
/**
|
||||
* Eager Invalidation 服务(ADR-038)。
|
||||
*
|
||||
* 在 MySQL 事务提交后、响应返回前,同步向 Redis 发送 DEL 命令删除查询缓存,
|
||||
* 缩短 CQRS 读模型(ES / Neo4j / Redis 缓存)与主库的最终一致窗口。
|
||||
*
|
||||
* 失败策略(软失败):
|
||||
* - Redis 未配置(REDIS_URL 缺失)→ 静默跳过,由 Kafka projector 兜底
|
||||
* - Redis DEL / SCAN 抛错 → 记录 warn 日志,不阻断业务请求
|
||||
*
|
||||
* 该服务在写流程中被 await(同步语义),但任何异常都被吞掉(非阻塞)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class EagerInvalidationService {
|
||||
private readonly logger = new Logger(EagerInvalidationService.name);
|
||||
|
||||
/**
|
||||
* 同步删除指定缓存 Key。
|
||||
* Redis 不可用时静默跳过;删除出错仅记录 warn,不抛异常。
|
||||
*/
|
||||
async invalidateKeys(keys: string[]): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.del(...keys);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Eager invalidation (del) failed for keys [${keys.join(", ")}]: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描并删除匹配 pattern 的所有 Key(SCAN + DEL,避免 KEYS 阻塞)。
|
||||
* Redis 不可用时静默跳过;出错仅记录 warn,不抛异常。
|
||||
*/
|
||||
async invalidatePattern(pattern: string): Promise<void> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
let cursor = "0";
|
||||
do {
|
||||
const [nextCursor, batch] = await redis.scan(
|
||||
cursor,
|
||||
"MATCH",
|
||||
pattern,
|
||||
"COUNT",
|
||||
100,
|
||||
);
|
||||
cursor = nextCursor;
|
||||
if (batch.length > 0) {
|
||||
await redis.del(...batch);
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Eager invalidation (pattern) failed for ${pattern}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 实体级便捷失效方法 ==========
|
||||
|
||||
/** 失效教材相关缓存:单条 + 列表 pattern */
|
||||
async invalidateTextbook(id: string): Promise<void> {
|
||||
await Promise.all([
|
||||
this.invalidateKeys([ContentCacheKeys.textbook(id)]),
|
||||
this.invalidatePattern(ContentCacheKeys.textbooksListPattern()),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 失效章节相关缓存:单条 + 所属教材的章节列表 */
|
||||
async invalidateChapter(id: string, textbookId: string): Promise<void> {
|
||||
await this.invalidateKeys([
|
||||
ContentCacheKeys.chapter(id),
|
||||
ContentCacheKeys.chaptersList(textbookId),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 失效知识点相关缓存:单条 + 所属章节的知识点列表 */
|
||||
async invalidateKnowledgePoint(id: string, chapterId: string): Promise<void> {
|
||||
await this.invalidateKeys([
|
||||
ContentCacheKeys.knowledgePoint(id),
|
||||
ContentCacheKeys.knowledgePointsList(chapterId),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 失效题目相关缓存:单条 + 所属知识点的题目列表 */
|
||||
async invalidateQuestion(
|
||||
id: string,
|
||||
knowledgePointId: string,
|
||||
): Promise<void> {
|
||||
await this.invalidateKeys([
|
||||
ContentCacheKeys.question(id),
|
||||
ContentCacheKeys.questionsList(knowledgePointId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
services/content/src/shared/cache/version-header.ts
vendored
Normal file
18
services/content/src/shared/cache/version-header.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 乐观锁版本头解析(ADR-039)。
|
||||
*
|
||||
* 客户端可通过以下任一请求头携带期望版本(updated_at 的 ISO 字符串):
|
||||
* - `X-Expected-Version`(自定义头,推荐,语义明确)
|
||||
* - `If-Match`(标准 HTTP 头,可能以 ETag 形式带引号)
|
||||
*
|
||||
* 返回裸 ISO 字符串;未提供头时返回 undefined(跳过版本校验)。
|
||||
*/
|
||||
export function extractExpectedVersion(
|
||||
ifMatch?: string,
|
||||
xExpectedVersion?: string,
|
||||
): string | undefined {
|
||||
const raw = xExpectedVersion ?? ifMatch;
|
||||
if (!raw) return undefined;
|
||||
// If-Match 可能以 ETag 形式带引号,去除首尾引号后返回裸 ISO 字符串。
|
||||
return raw.replace(/^"|"$/g, "");
|
||||
}
|
||||
Reference in New Issue
Block a user