一、引言
在任何内容管理系统中,重复上传和意外重试都是常见场景。用户可能不小心点击两次上传按钮,网络中断后客户端重试,或者只是想更新同一小说的版本。如果没有良好的幂等设计,这些操作可能导致数据重复、状态混乱或其他不可预期的后果。
小说知识库涉及多个数据源:文件存储、MySQL业务数据、Qdrant向量数据,保持这三者的一致性尤为重要。StoryVerse通过精心设计的稳定ID机制、先删后插策略、唯一索引约束和事务管理,构建了一个健壮的更新流程,即使在重复操作的情况下,也能保证数据的一致性和正确性。
二、稳定ID生成策略
2.1 UUID5与确定性ID生成
系统的核心是使用UUID5生成可复现的稳定ID:
def _stable_point_uuid(self, *parts: str) -> uuid.UUID:
seed = ":".join(self._slugify(part) for part in parts)
return uuid.uuid5(uuid.NAMESPACE_URL, f"storyverse:{seed}")
def _world_point_id(self, novel_title: str) -> str:
return str(self._stable_point_uuid("world", novel_title))
def _character_point_id(self, novel_title: str, character_name: str) -> str:
return str(self._stable_point_uuid("character", novel_title, character_name))
def _plot_point_id(self, novel_title: str, chunk_index: int) -> str:
return str(self._stable_point_uuid("plot", novel_title, str(chunk_index)))
UUID5基于命名空间和种子生成,而不是随机数,这意味着相同的输入总是产生相同的UUID。对小说知识库来说,这至关重要:同一部小说的世界观总是生成相同的ID,同一个角色总是生成相同的ID,同一个剧情块也总是生成相同的ID。
这种设计的价值体现在两个方面。首先,在重新上传同一小说时,新生成的向量点会自动覆盖旧版本,不需要显式查找旧记录的ID。其次,外部系统可以根据同样的规则生成ID,实现与向量数据库的互操作,不需要每次都查询确认。
2.2 Slugify与种子规范化
为了确保ID生成的稳定性,需要对输入种子进行规范化处理:
def _slugify(self, value: str) -> str:
normalized = unicodedata.normalize("NFKC", value).strip().lower()
slug = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "-", normalized)
return slug.strip("-") or "unknown"
unicodedata.normalize("NFKC")处理了Unicode等价性问题,确保看起来相同但编码不同的字符(如全角和半角的'a')能生成相同的种子。转为小写确保大小写不敏感,正则替换将所有非字母数字汉字的字符替换为连字符,进一步规范化输入。
这种规范化保证了即使同一小说标题有微小的格式差异,也能生成相同的UUID。例如,"红楼梦"、"红楼梦"、"红楼梦"都会生成相同的种子,最终产生相同的向量点ID。
三、向量数据库的幂等更新
3.1 先删后插策略
在进行向量数据库更新时,系统采用先删后插的策略:
def upsert_novel_knowledge(self, request_model: NovelKnowledgeUpsertRequest) -> NovelKnowledgeUpsertResponse:
self.ensure_collection()
self._delete_by_novel_title(request_model.novel_title)
# ... 构建points ...
self.qdrant_client.upsert(
collection_name=collection_name,
points=points,
wait=True,
)
先删除旧数据再插入新数据,这种策略比逐个比较更新更简单且更可靠。因为小说上传通常是全量更新,我们希望用新上传的版本完全替换旧版本,而不是合并两者。
删除操作通过小说标题过滤实现:
def _delete_by_novel_title(self, novel_title: str) -> None:
try:
self.qdrant_client.delete(
collection_name=self._string_setting("qdrant_collection_name", "novel_knowledge"),
points_selector=qmodels.FilterSelector(
filter=qmodels.Filter(
must=[
qmodels.FieldCondition(
key="novel",
match=qmodels.MatchValue(value=novel_title),
)
]
)
),
wait=True,
)
except Exception as ex:
raise NovelKnowledgeError(f"Qdrant delete failed: {ex}") from ex
使用wait=True确保删除完成后再继续,避免竞态条件。这种先删后插的模式,配合稳定ID,提供了强有力的幂等性保证:多次执行相同的上传操作,结果都是一致的。
3.2 集合初始化与验证
在进行任何操作前,系统会确保集合存在并且配置正确:
def ensure_collection(self) -> None:
try:
if not self._bool_setting("qdrant_enabled", True):
raise NovelKnowledgeError("Qdrant is disabled")
collection_name = self._string_setting("qdrant_collection_name", "novel_knowledge")
vector_size = self._int_setting("qdrant_vector_size", 768)
existing_names = {item.name for item in self.qdrant_client.get_collections().collections}
if collection_name not in existing_names:
self.qdrant_client.create_collection(
collection_name=collection_name,
vectors_config=qmodels.VectorParams(
size=vector_size,
distance=self._distance(),
),
)
return
# ... 验证现有配置 ...
except Exception as ex:
raise NovelKnowledgeError(f"Qdrant collection check failed: {ex}") from ex
这种初始化代码虽然看似琐碎,但对系统健壮性很重要。它不仅在首次运行时创建集合,还会在后续运行中验证现有配置是否符合预期。如果向量维度或距离度量不匹配,会立即抛出错误,避免产生不可预期的混合配置问题。
这种设计考虑了环境迁移、配置错误等情况,在早期就发现问题,而不是在后续操作中出现难以调试的错误。
四、数据库层的一致性保障
4.1 唯一索引与Upsert
在MySQL层,系统使用唯一索引和upsert操作来实现幂等更新:
CONSTRAINT uq_character_profile_novel_name UNIQUE (novel_id, character_name)
这个唯一索引确保同一小说中的同一角色不会出现多条记录。配合MyBatis的upsert操作:
<insert id="upsert">
INSERT INTO character_profile (id, novel_id, character_name, summary_text, source_type)
VALUES (#{id}, #{novel_id}, #{character_name}, #{summary_text}, #{source_type})
ON DUPLICATE KEY UPDATE
summary_text = VALUES(summary_text),
source_type = VALUES(source_type),
updated_at = CURRENT_TIMESTAMP
</insert>
ON DUPLICATE KEY UPDATE是MySQL提供的强大功能,它实现了"存在则更新,不存在则插入"的语义。当遇到唯一索引冲突时,系统会更新现有记录而非插入新记录,这保证了即使同一角色被多次处理,最终也只有一条记录。
值得注意的是,在更新时,系统特意保留了原始的id和created_at,只更新summary_text、source_type和updated_at。这种设计选择是有道理的:ID保持稳定便于引用追踪,创建时间记录数据的首次产生时间,而更新时间则反映信息的新鲜程度。
4.2 事务边界与批量处理
角色画像的批量处理在单个事务中完成:
@Transactional
protected void persistCharacterProfiles(UUID novelId, List<NovelKnowledgeClient.CharacterProfileResult> profiles, String sourceType) {
for (NovelKnowledgeClient.CharacterProfileResult profile : profiles) {
// ... 创建记录并upsert ...
}
}
@Transactional注解确保了操作的原子性:要么所有角色画像都成功保存,要么全部失败回滚。这种设计避免了部分更新导致的数据不一致状态。
同时,使用事务还能提升性能。批量操作在事务中执行,减少了事务提交的开销,特别是在角色数量较多时,这种优化带来的性能提升会很明显。
五、文件系统与数据库的一致性
5.1 先文件后数据库的顺序
文件上传流程采用先保存文件后更新数据库的策略:
@Transactional
public NovelUploadResponse uploadNovel(NovelUploadRequest request) {
// ... 前期处理 ...
Path storedPath = storeNovelFile(relativeFileUri, fileBytes);
try {
if (novelMapper.findById(novelRecord.getId()) == null) {
novelMapper.insert(novelRecord);
} else {
novelMapper.update(novelRecord);
}
uploadRecordMapper.insert(uploadRecord);
} catch (RuntimeException ex) {
deleteQuietly(storedPath);
throw ex;
}
// ... 触发事件 ...
}
为什么选择这个顺序?因为数据库事务支持回滚,而文件系统操作相对来说更难回滚。如果先写数据库再写文件,当文件写入失败时,我们需要回滚数据库,这在复杂逻辑中容易出错。反过来,先写文件再写数据库,当数据库失败时,我们只需要删除已保存的文件即可,逻辑更简单。
5.2 补偿式事务处理
文件清理是这种策略的关键部分:
private void deleteQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// 忽略删除异常,避免二次错误
}
}
这里的异常处理很有讲究。如果删除失败,我们选择忽略异常而不是抛出。为什么?因为这时主要错误已经发生(数据库操作失败),我们正在进行清理工作,删除失败只是次要问题,不应该掩盖主要错误,也不应该导致用户看到多个错误信息。
但这种设计也留下了一个潜在问题:如果应用在文件保存后、数据库提交前崩溃,文件可能成为孤立文件。这类问题通常需要后台的垃圾收集机制来处理,定期扫描并删除没有对应数据库记录的孤立文件。
5.3 文件指纹与去重
虽然当前实现没有显式使用,但文件哈希计算为去重提供了基础:
byte[] fileBytes = readFileBytes(request);
String sourceHash = sha256Hex(fileBytes);
通过SHA-256哈希,系统可以识别内容相同的文件。在未来,可以实现这样的优化:如果用户上传与已有版本完全相同的小说,系统可以跳过处理,直接返回现有结果,节省处理时间和API调用成本。
六、状态追踪与可观测性
6.1 上传记录的全生命周期追踪
upload_record表记录了处理的每个阶段,是可观测性的核心:
CREATE TABLE upload_record (
id CHAR(36) PRIMARY KEY,
novel_id CHAR(36) NOT NULL,
status VARCHAR(32) NOT NULL,
stage VARCHAR(64) NOT NULL,
chunk_count INT NOT NULL DEFAULT 0,
character_count INT NOT NULL DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
status字段记录总体状态(pending/running/success/failed),stage字段记录当前处理阶段(received/parse_text/build_plot_chunks等)。这种设计让我们能够准确知道每次处理进行到哪一步,也让前端能够展示详细的处理进度。
在出错时,error_message字段会保存安全的错误信息,让用户了解失败原因,同时避免暴露敏感内部细节。
6.2 状态更新的原子性
每次状态更新都独立进行:
@Transactional
protected void updateUpload(UUID uploadRecordId, String status, String stage, Integer chunkCount, Integer characterCount, String worldPointId, String errorMessage) {
uploadRecordMapper.updateStatus(uploadRecordId, status, stage, chunkCount, characterCount, worldPointId, errorMessage);
}
每个关键步骤都会更新状态,这意味着即使后续步骤失败,我们也有记录显示前面完成了什么。这种设计对于调试问题非常有价值,当看到某个处理卡在特定阶段时,我们知道从哪里开始调查。
七、总结
StoryVerse的幂等设计和一致性保障是一个多层次的体系:UUID5提供稳定ID基础,先删后插保证向量库更新的幂等性,数据库唯一索引与upsert避免重复记录,先文件后数据库的顺序配合补偿式清理处理了事务边界问题,状态追踪提供了可观测性与故障诊断能力。
这些设计虽然不显眼,但它们共同构成了系统的可靠骨架,使用户可以安心地上传、重传、更新小说,而不必担心数据混乱或状态不一致。即使在网络不稳定、用户误操作的情况下,系统也能保持正确的状态。
幂等设计与一致性保障通常是系统成功的幕后英雄,它们让系统在面对异常时表现得优雅而可预期,这对任何生产级应用都是至关重要的品质。