AI 数据平台

两个质检员同时审核一条数据,会发生什么?

从两个审核请求出发,理解条件更新、乐观锁、事务与重复请求分别解决什么问题。

1. 两个人都看到了“待审核”

在标注平台里,任务 #100 的当前提交是 #502,状态是待审核。小林打开页面,小周也打开了同一条任务。

小林认为标签正确,点击通过;小周认为图片主体不明确,点击驳回。

这时,哪一个页面上的“待审核”才是真的?

两个页面都可能没有错。它们只是显示了之前读到的数据。问题发生在接下来的写入:两个请求不能各自按照旧页面,覆盖对方的结论。

这种场景让我觉得,并发并不一定要从很大的访问量讲起。只要两个用户有机会同时处理同一份数据,就已经需要考虑了。

2. 加了事务,为什么还可能覆盖

假设通过接口先查任务,再直接更新:

sql5 行
SELECT status FROM annotation_task WHERE id = 100;

UPDATE annotation_task
SET status = 'APPROVED'
WHERE id = 100;

驳回接口也做同样的事情,只是写入 ANNOTATING。

问题在于,第二条 SQL 只有编号条件。即使查询时是待审核,执行更新时已经通过,数据库也没有被要求拒绝这次覆盖。

把两条语句包进普通事务,不能自动把第一次查询变成独占操作。MySQL 的锁定读文档明确区分了普通读取与 SELECT ... FOR UPDATE 等锁定读。

事务让一组修改一起提交或回滚,却不会替业务补出“只有还没被别人审核,才能更新”这个条件。

3. 让数据库判断,现在还能不能改

先从最小的改动开始:把前提放进 WHERE。

sql5 行
UPDATE annotation_task
SET status = 'APPROVED'
WHERE id = #{taskId}
  AND status = 'REVIEWING'
  AND current_annotation_id = #{annotationId};

当前提交编号也要检查。旧页面看到 #501,新的待审核结果已经变成 #502 时,即使状态名称相同,也不能让旧页面审核新的结果。

在 InnoDB 中,更新会涉及加锁。MySQL 对 UPDATE 加锁的说明解释了更新语句的记录锁行为。这里通过主键定位一条任务,真正决定能否更新的条件,交给数据库在执行写入时判断。

先得到行锁的请求,如果成功把状态改成通过并提交,后续请求就不能再匹配待审核条件。业务代码看受影响行数:

text2 行
1 → 本次请求完成了状态转换
0 → 当前记录已经不满足条件,需要重新读取

这也解释了为什么不能不看返回值就展示“审核成功”。SQL 执行没有报错,和业务更新成功,是两件不同的事。

4. version 字段又解决什么问题

对一次待审核到通过的单向变化,前面的状态和提交编号已经能阻止重复转换。并不是所有表都必须再加一个 version。

如果还希望识别任务分配、配置等变化,可以给任务增加修改版本:

text4 行
id = 100
status = REVIEWING
current_annotation_id = 502
version = 7

页面读到 version = 7,提交时带回这个版本。更新时要求它仍然是 7,然后递增:

sql7 行
UPDATE annotation_task
SET status = 'APPROVED',
    version = version + 1
WHERE id = #{taskId}
  AND status = 'REVIEWING'
  AND current_annotation_id = #{annotationId}
  AND version = #{expectedVersion};

这就是一种乐观锁写法:先允许读取,写入时再检查有没有变化。使用这种约定后,所有会影响任务有效性的更新入口都需要正确递增版本,否则版本号就不能完整反映变化。

这里有两个容易混淆的版本。annotation_id 定位一份不可变的标注结果;任务的 version 表达这条可变记录更新到了哪一版。它们分别回答“审核什么”和“任务是否变化过”。

乐观锁也不是数据库完全不加锁。条件检查是业务冲突判断方式,InnoDB 执行更新仍需要相应的锁。

5. 状态和审核记录必须一起成功

通过操作还要留下审核人和结论。下面是省略 Mapper 定义的服务代码:

java22 行
@Transactional(rollbackFor = Exception.class)
public void approve(long taskId, long annotationId, long version) {
    long userId = currentUser.requireId();
    AnnotationTask task = taskMapper.requireById(taskId);
    permissions.requireProjectReviewer(userId, task.getProjectId());

    Annotation submitted = annotationMapper.requireById(annotationId);
    if (submitted.getTaskId() != taskId) {
        throw new BusinessException("提交不属于当前任务");
    }
    if (submitted.getSubmitterId() == userId) {
        throw new BusinessException("不能审核自己的提交");
    }

    int changed = taskMapper.approveCurrent(
        taskId, annotationId, version);
    if (changed != 1) {
        throw new ReviewConflictException("任务已变化,请刷新后重试");
    }

    reviewMapper.insertApproved(annotationId, userId);
}

这里沿用提交人与审核人不同的规则,即使账号兼有两个角色,也不能自行审核。查询的提交必须属于当前任务,之后仍由条件更新确认它是不是当前待审核结果。

这里假定这些 Mapper 使用同一事务数据源,方法通过 Spring 代理调用。有关代理与事务生效条件,可以查阅 Spring 文档。

在“一份提交最多一个最终审核结论”的约定下,还可以让 review.annotation_id 唯一,阻止遗漏了正确路径的代码插入重复结论。这个约束需要数据库表配合,不是方法名带有 insertApproved 就自然成立。

如果新增审核记录失败,应当让异常触发回滚,不能吞掉异常后仍返回成功。通知、文件操作等不属于同一数据库事务的事情,也不要夹进这个短事务里等待。

6. 把两个请求放到同一条时间线上

下面按 InnoDB 的事务规则来看两次竞争,不指定一定是谁先赢:

text6 行
A、B 都读到 version = 7

A:条件更新,持有行锁
B:尝试更新同一行,等待
A:写入审核记录,提交
B:继续执行,条件不再匹配,更新 0 行

前提是 A 最终提交。如果 A 写审核记录失败并回滚,任务仍然可能是待审核、版本仍为 7,B 就有机会更新成功。

另外,等待并不保证永远能等到结果。锁等待超时或死锁会产生数据库错误,需要回滚并按错误类型处理,不能把所有异常都说成“已经被别人审核”。

能够明确返回的是:条件更新为 0 时,这个请求没有完成想要的状态转换。至于是不是另一位审核员通过了,要重新查询已提交记录才能知道。

7. 网络重试和两个人抢着审核,不是同一个问题

还有一种情况:A 已经审核成功,响应在网络中丢失了。浏览器重试,服务器再次收到完全相同的请求。

条件更新可以阻止第二次写入,却不一定能告诉客户端“上一次其实是你成功的”。当前任务已通过,也可能是别人完成的。

这里的简化处理,是返回冲突并让页面刷新显示当前结果。如果以后要让重试直接拿到第一次的响应,可以增加请求幂等键,将它与操作者、任务、提交、动作及处理结果绑定;相同键却不同内容应当拒绝。

不能在冲突后偷偷读取最新版本,再替用户自动重试审核。那相当于让用户在没有看到新内容时,批准另一份结果。

8. 再看并发,先确定要保护什么

回到两个审核员的场景,需要保护的是同一份提交的最终结论。业务记录在 MySQL,所有写入方都要遵守相同的更新条件;只在一个 JVM 里加 synchronized,管不到另一个服务实例。

这种短操作可以先用条件更新表达冲突。如果以后需要先读取并锁定记录,再完成一组相关修改,就要重新考虑事务内 FOR UPDATE 是否更合适,同时控制持锁时间。

我接下来更想补齐的是冲突后的处理:页面怎样显示已生效的结论,响应丢失后怎样找到原请求的结果,以及怎样区分正常竞争与数据库故障。避免重复写入只是第一步,用户还需要知道自己的操作到底发生了什么。

相关阅读

← 返回文章