文档删除与分块的并发竞态问题
当前状态:未修复
一、问题背景
KnowledgeDocumentService#delete(String docId) 在执行删除前,做了一次状态校验:
KnowledgeDocumentDO documentDO = documentMapper.selectById(docId);
Assert.notNull(documentDO, () -> new ClientException("文档不存在"));
if (DocumentStatus.RUNNING.getCode().equals(documentDO.getStatus())) {
throw new ClientException("文档正在分块中,无法删除");
}
knowledgeChunkService.deleteByDocId(docId);
scheduleService.deleteByDocId(docId);
chunkLogMapper.delete(...);
documentDO.setDeleted(1);
documentMapper.deleteById(documentDO);
vectorStoreService.deleteDocumentVectors(collectionName, docId);
deleteStoredFileQuietly(documentDO);
这是典型的 TOCTOU(Time-of-Check-Time-of-Use) 模式:先查状态,再依据查到的状态做决定。检查与使用之间存在时间窗口,无法保证原子性。
二、竞态时序
t0 delete: select 文档,status = IDLE
t1 delete: 校验通过,进入删除流程
t2 chunk: 定时任务 / 手动触发,CAS status → RUNNING,开始分块
t3 delete: 删除 chunk / schedule / log
t4 chunk: embedding 完成,写入新 chunk 记录、写入 PgVector
t5 delete: 逻辑删除 document,删向量、删文件
t6 chunk: 分块任务后续步骤继续执行(基于已被删除的 docId)
最终状态:
knowledge_document 已逻辑删除
knowledge_chunk 中存在 t4 写入的孤儿分块记录
- PgVector 中可能残留 t4 写入的向量
- 文件可能已被物理删除,但分块任务仍在引用
三、当前校验为什么不够
if status == RUNNING throw 这一行只能拦截"删除发起时分块已经在跑"的情况,无法拦截"删除发起后分块才开始"的情况。两个流程之间没有任何互斥机制:
- 删除流程不会阻止后续分块任务启动
- 分块流程不会感知当前文档正在被删除
- 两者跑在独立事务里,互不可见
校验通过 ≠ 删除期间不会有新分块。
四、修复方案
4.1 扩展状态机
新增 DELETING 状态,将分块与删除变为状态字段上的互斥操作:
IDLE / FAILED / COMPLETED ──┬──► RUNNING ──► COMPLETED / FAILED
└──► DELETING ──► [记录 deleted=1]
CAS 规则:
| 操作 |
允许的源状态 |
目标状态 |
| 分块入口 |
IDLE, FAILED |
RUNNING |
| 删除入口 |
IDLE, COMPLETED, FAILED |
DELETING |
| 分块完成 |
RUNNING |
COMPLETED |
| 分块失败 |
RUNNING |
FAILED |
4.2 删除流程改造
@Transactional(rollbackFor = Exception.class)
public void delete(String docId) {
KnowledgeDocumentDO doc = documentMapper.selectById(docId);
Assert.notNull(doc, () -> new ClientException("文档不存在"));
int n = documentMapper.casStatus(
docId,
List.of(IDLE.getCode(), COMPLETED.getCode(), FAILED.getCode()),
DELETING.getCode()
);
if (n == 0) {
throw new ClientException("文档正在分块或已被处理,无法删除");
}
knowledgeChunkService.deleteByDocId(docId);
scheduleService.deleteByDocId(docId);
chunkLogMapper.delete(Wrappers.lambdaQuery(KnowledgeDocumentChunkLogDO.class)
.eq(KnowledgeDocumentChunkLogDO::getDocId, docId));
doc.setDeleted(1);
doc.setUpdatedBy(UserContext.getUsername());
documentMapper.deleteById(doc);
vectorStoreService.deleteDocumentVectors(resolveCollectionName(doc.getKbId()), docId);
fileCleanupOutbox.enqueue(doc.getFilePath());
}
casStatus 对应的 SQL:
UPDATE knowledge_document
SET status = #{newStatus},
update_time = NOW()
WHERE id = #{docId}
AND deleted = 0
AND status IN
<foreach collection="fromStatuses" item="s" open="(" close=")" separator=",">
#{s}
</foreach>
返回值为 0 表示并发冲突,抛异常使事务整段回滚。
4.3 分块流程改造
public void chunk(String docId) {
int n = documentMapper.casStatus(
docId,
List.of(IDLE.getCode(), FAILED.getCode()),
RUNNING.getCode()
);
if (n == 0) {
log.info("文档状态非空闲,跳过分块: {}", docId);
return;
}
try {
List<Chunk> chunks = split(docId);
List<float[]> vectors = embeddingClient.embed(chunks);
Integer latest = documentMapper.selectStatusById(docId);
if (!RUNNING.getCode().equals(latest)) {
log.warn("分块过程中状态被改写为 {},放弃写入: {}", latest, docId);
return;
}
chunkMapper.batchInsert(chunks);
vectorStoreService.upsert(collection, chunks, vectors);
documentMapper.casStatus(docId, List.of(RUNNING.getCode()), COMPLETED.getCode());
} catch (Exception e) {
documentMapper.casStatus(docId, List.of(RUNNING.getCode()), FAILED.getCode());
throw e;
}
}
入口 CAS 保证分块只在空闲态启动。Embedding 是耗时外部调用,写库前再 reload 一次状态,覆盖"分块期间被强制删除"的极端场景。
4.4 PgVector 与文件删除
PgVector 是独立 Postgres 实例,与主库(MySQL)不共享事务。同步调用 vectorStoreService.deleteDocumentVectors 一旦失败,主事务已 commit,无法回滚,会留下脏向量。
推荐使用 outbox:
- 主事务内插一条
vector_cleanup_task(doc_id, kb_id, status=PENDING)
- 后台 worker 拉取并执行
DELETE FROM vectors WHERE doc_id = ?
- 失败重试,N 次后告警
DELETE FROM vectors WHERE doc_id = ? 在 Postgres 内是原子幂等的,重试安全。
文件删除同理。当前代码使用 deleteStoredFileQuietly 静默吞异常,失败即丢失,应改为 outbox 兜底。
五、改造后竞态分析
| 场景 |
结果 |
| 删除先到 |
分块的 CAS(IDLE → RUNNING) 失败,分块流程不启动 |
| 分块先到(短任务) |
删除的 CAS(... → DELETING) 失败,抛异常,前端提示稍后重试 |
| 分块进行中触发删除 |
分块中间检查点发现 status 已非 RUNNING,主动放弃写入 |
| MySQL 删除成功、PgVector 删除失败 |
outbox 持续重试至成功 |
| 文件删除失败 |
同上 |
六、落地清单
DocumentStatus 枚举新增 DELETING
KnowledgeDocumentMapper 新增 casStatus(docId, fromList, to)、selectStatusById(docId)
KnowledgeDocumentServiceImpl#delete 改造:去除原 if status == RUNNING 校验,改为 CAS
- 分块入口(
KnowledgeChunkService 或调度任务侧)改造:入口 CAS + 写库前中间检查
- 新建
vector_cleanup_task、file_cleanup_task 表 + 后台 worker
- 移除
deleteStoredFileQuietly 的静默吞异常逻辑
文档删除与分块的并发竞态问题
一、问题背景
KnowledgeDocumentService#delete(String docId)在执行删除前,做了一次状态校验:这是典型的 TOCTOU(Time-of-Check-Time-of-Use) 模式:先查状态,再依据查到的状态做决定。检查与使用之间存在时间窗口,无法保证原子性。
二、竞态时序
最终状态:
knowledge_document已逻辑删除knowledge_chunk中存在 t4 写入的孤儿分块记录三、当前校验为什么不够
if status == RUNNING throw这一行只能拦截"删除发起时分块已经在跑"的情况,无法拦截"删除发起后分块才开始"的情况。两个流程之间没有任何互斥机制:校验通过 ≠ 删除期间不会有新分块。
四、修复方案
4.1 扩展状态机
新增
DELETING状态,将分块与删除变为状态字段上的互斥操作:CAS 规则:
IDLE,FAILEDRUNNINGIDLE,COMPLETED,FAILEDDELETINGRUNNINGCOMPLETEDRUNNINGFAILED4.2 删除流程改造
casStatus对应的 SQL:返回值为 0 表示并发冲突,抛异常使事务整段回滚。
4.3 分块流程改造
入口 CAS 保证分块只在空闲态启动。Embedding 是耗时外部调用,写库前再 reload 一次状态,覆盖"分块期间被强制删除"的极端场景。
4.4 PgVector 与文件删除
PgVector 是独立 Postgres 实例,与主库(MySQL)不共享事务。同步调用
vectorStoreService.deleteDocumentVectors一旦失败,主事务已 commit,无法回滚,会留下脏向量。推荐使用 outbox:
vector_cleanup_task(doc_id, kb_id, status=PENDING)DELETE FROM vectors WHERE doc_id = ?DELETE FROM vectors WHERE doc_id = ?在 Postgres 内是原子幂等的,重试安全。文件删除同理。当前代码使用
deleteStoredFileQuietly静默吞异常,失败即丢失,应改为 outbox 兜底。五、改造后竞态分析
CAS(IDLE → RUNNING)失败,分块流程不启动CAS(... → DELETING)失败,抛异常,前端提示稍后重试六、落地清单
DocumentStatus枚举新增DELETINGKnowledgeDocumentMapper新增casStatus(docId, fromList, to)、selectStatusById(docId)KnowledgeDocumentServiceImpl#delete改造:去除原if status == RUNNING校验,改为 CASKnowledgeChunkService或调度任务侧)改造:入口 CAS + 写库前中间检查vector_cleanup_task、file_cleanup_task表 + 后台 workerdeleteStoredFileQuietly的静默吞异常逻辑