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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions