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:
SpecialX
2026-07-15 01:28:20 +08:00
parent 47a062606f
commit a75527be80
26 changed files with 673 additions and 72 deletions

View 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;

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { EagerInvalidationService } from "./eager-invalidation.js";
@Module({
providers: [EagerInvalidationService],
exports: [EagerInvalidationService],
})
export class CacheModule {}

View 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 的所有 KeySCAN + 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),
]);
}
}

View 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, "");
}