Skip to content

文档删除与分块的并发竞态问题 #42

Description

@magestacks

文档删除与分块的并发竞态问题

当前状态:未修复

一、问题背景

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 持续重试至成功
文件删除失败 同上

六、落地清单

  1. DocumentStatus 枚举新增 DELETING
  2. KnowledgeDocumentMapper 新增 casStatus(docId, fromList, to)selectStatusById(docId)
  3. KnowledgeDocumentServiceImpl#delete 改造:去除原 if status == RUNNING 校验,改为 CAS
  4. 分块入口(KnowledgeChunkService 或调度任务侧)改造:入口 CAS + 写库前中间检查
  5. 新建 vector_cleanup_taskfile_cleanup_task 表 + 后台 worker
  6. 移除 deleteStoredFileQuietly 的静默吞异常逻辑

Metadata

Metadata

Assignees

No one assigned

    Labels

    good first issueGood for newcomersquestionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions