文本已经改了,旧的音频生成结果还能回写吗?

在编辑器里提交文本 A 生成音频,任务还没结束,用户就把文本改成 B。几秒后 A 返回了音频。接口成功了,页面是否应该直接换上它?

如果直接按片段 ID 回写,文本可能已经是 B,音频却来自 A。只在前端忽略旧响应也不够,数据库中的当前结果仍可能被后台修改。

第十五篇讨论提交时固定任务输入,这篇继续看结果返回后的适用条件。Vimorph 的分段生成已有版本条件回写;下面结合源码和独立 Java 21 + Spring JDBC / H2 工程展开。示例模拟成功、失败和完成顺序,不调用真实 TTS 或云存储。

生成历史与当前片段分开

先把两件事分开:生成记录描述某次请求的执行结果,当前片段描述编辑器现在应该展示什么。

文本 A 提交后,生成记录应保留 A 的输入。用户改成 B,并不要求把 A 的记录篡改成 B,也不意味着供应商已经停止生成。A 可以正常完成,但是否采用它,需要另做判断。

当前 Vimorph 链路可以看到三个相关标识:

标识 当前源码中的用途 不能单独证明什么
version 编辑保存时的预期版本条件,保存成功后递增 某次音频结果仍适用
segmentVersion 提交生成时记录的分段输入版本,编辑入口会推进它 同版本的哪个请求应该胜出
generationId 标识一次生成,生效后写入当前片段的关联字段 只有它存在就能防止旧结果覆盖

createGenerations() 保存文本快照和 segmentVersion,Worker 使用生成记录中的文本调用合成接口。updateSegment() 修改片段并推进输入版本,保存时另用请求携带的 version 做条件更新。不要把这两个版本字段混用;本篇的回写守卫关注生成记录携带的输入版本是否仍适用。

版本推进也要覆盖会影响结果的其他编辑入口,例如音色、语言和合成情绪。当前分段编辑会推进版本,批量应用音色也有对应更新;角色默认值、项目默认值及执行时解析的 TTS 模型映射,则不能仅凭这两个入口认定已完整覆盖。相关风险留在内部核查,不把它们写成已经解决。

还要按参数真正在哪一步生效来分类:这条生成链路传给 TTS 的 speed 固定为 1,片段语速另用于播放和 FFmpeg 后处理;纯播放器音量或倍率也不必天然触发重新合成。反过来,传给供应商的音量、音高或情绪参数,仍可能改变合成字节。字段同名不代表作用相同。

编辑时推进输入版本

独立工程使用 segment 和 generation 两张表。为集中说明回写,只保留一个 input_version,对应本篇讨论的输入版本;省略生产里独立的编辑保存版本。start() 保存当前文本和版本,模拟一条 RUNNING 生成记录,不发起网络请求,也不启动真正的 TTS Worker。

下面是 GenerationWriteback.edit() 节选。jdbc 是构造器注入的 JdbcTemplate,requireText 检查文本非空及长度。expectedVersion 由调用方传入,返回 false 时调用方应处理冲突,不能继续把保存当成成功。

java 复制代码
public boolean edit(long segmentId, long expectedVersion, String text) {
    requireText(text, 1000);
    return jdbc.update("""
            UPDATE segment SET text_content=?,input_version=input_version+1,
                status='DIRTY',error_code=NULL
            WHERE id=? AND input_version=? AND deleted=FALSE
            """, text, segmentId, expectedVersion) == 1;
}

将条件和更新放在同一条语句里,是为了避免"先查版本相同,准备写入时版本又变了"。输入版本推进后,旧生成记录仍带着之前的版本;结果回来时,数据库可以据此判断是否匹配。

编辑后状态改为 DIRTY,但示例不会删除原来的音频引用。旧文件还在,不等于它与新文本一致;DIRTY 也不天然禁止播放,实际入口可能只检查 URL 是否存在。历史试听可以保留,当前片段试听则应清楚提示它来自旧输入。

导出同样需要核对输入与已关联生成版本。业务混音提交会拦截非 READY 片段,但不能因此推导所有入口都做了音频版本比对;按非空 URL 打包的 ZIP 路径有不同边界。本轮没有验证浏览器播放、真实混音或文件清理,不把"引用仍存在"描述成"可以安全使用或删除"。

把版本比较放进回写语句

Vimorph 的 generate() 先上传音频,再尝试将生成记录标为 READY,随后调用 applyGeneration()。Mapper 的回写条件包括片段 ID、segmentVersion 和未删除标记;它不会仅凭"合成接口成功"就无条件写入片段。

教学工程将两张表的写入放在同一本地事务,并显式返回结果,便于观察。下面是 succeed() 节选:transaction 是与 jdbc 使用同一 DataSource 的 TransactionTemplate;generation() 读取本例生成表,Completion 是类内定义的枚举。

java 复制代码
public Completion succeed(long generationId, String audioRef) {
    requireText(audioRef, 128);
    return Objects.requireNonNull(transaction.execute(status -> {
        Generation job = generation(generationId);
        int recorded = jdbc.update("""
                UPDATE generation SET status='READY',audio_ref=?,error_code=NULL
                WHERE id=? AND status='RUNNING'
                """, audioRef, generationId);
        if (recorded != 1) return Completion.ALREADY_COMPLETED;
        int applied = jdbc.update("""
                UPDATE segment SET audio_ref=?,generation_id=?,status='READY',error_code=NULL
                WHERE id=? AND input_version=? AND deleted=FALSE
                """, audioRef, generationId, job.segmentId(), job.inputVersion());
        return applied == 1 ? Completion.APPLIED : Completion.NOT_APPLIED;
    }));
}

第一次处理 RUNNING 记录时,先记下生成成功,再尝试回写当前片段:

  • APPLIED:本次结果已通过片段条件更新。
  • NOT_APPLIED:生成记录成功,但片段更新没有命中。可能是版本变化,也可能是逻辑删除,不直接断言具体原因。
  • ALREADY_COMPLETED:生成记录已经不是 RUNNING。本例不重新应用结果,也没有验证重放载荷是否一致。

因此,A 的历史可以是 READY,当前片段仍保持 B。READY 记录生成是否成功,Completion 记录这次回写是否命中,DIRTY 则描述当前输入可能需要重新生成,三个含义不能混成一个状态。尤其在 fail() 中,APPLIED 仅表示失败状态更新命中,不表示采用了成功音频。

APPLIED 也不是永久的采用标记:外层事务尚未提交时还可能回滚,之后还可能被别的任务替换。若产品要展示"当前已采用",需要结合当前关联、输入版本和片段状态计算,或另外记录采用事件,不能只看历史 READY。当前业务成功数量按生成 READY 统计,"生成成功数"不能直接当成"当前可用片段数"。

这些返回值和同一本地事务是教学设计。当前业务 Worker 没有处理 applyGeneration() 返回的 boolean,也不能把先后两次仓储调用直接看成同一事务;本篇不声称产品已向用户展示"生成成功但未采用"的完整状态。生成历史可以正常保留未回写结果,重新生成入口与成功统计则应分别围绕当前片段和生成任务定义。

业务使用的条件由 MyBatis-Plus Wrapper 构造。MySQL 文档区分实际变更行数与匹配行数。本轮本地 MySQL 补充实验中,一条无内容变化的 UPDATE 在 Connector/J useAffectedRows=false 时返回 1,设为 true 时返回 0。因此仍要核对驱动配置、实际 SQL 和回读结果,不能把所有 0 统一翻译成"输入已过期",更不能吞掉 SQL 异常后当成未命中。

失败回写也需要版本条件

只给成功结果加版本条件还不够。A 提交后编辑成 B,B 已经生成成功,A 随后超时;如果错误分支只按片段 ID 设置 FAILED,当前可用状态仍会被旧任务破坏。

业务 markGenerationFailed() 同样包含输入版本和未删除条件。教学工程的 fail() 方法节选如下,errorCode 是调用方传入的简短错误标识;这里没有依据 HTTP 状态码猜测供应商错误类别。

java 复制代码
public Completion fail(long generationId, String errorCode) {
    requireText(errorCode, 64);
    return Objects.requireNonNull(transaction.execute(status -> {
        Generation job = generation(generationId);
        int recorded = jdbc.update("""
                UPDATE generation SET status='FAILED',error_code=?
                WHERE id=? AND status='RUNNING'
                """, errorCode, generationId);
        if (recorded != 1) return Completion.ALREADY_COMPLETED;
        int applied = jdbc.update("""
                UPDATE segment SET status='FAILED',error_code=?
                WHERE id=? AND input_version=? AND deleted=FALSE
                """, errorCode, job.segmentId(), job.inputVersion());
        return applied == 1 ? Completion.APPLIED : Completion.NOT_APPLIED;
    }));
}

失败历史保留在对应生成记录,只有输入版本仍匹配,才尝试修改当前片段。逻辑删除也是独立条件:生成完成不能把已经删除的片段重新置为 READY 或 FAILED。

同一本地事务只能协调数据库写入。示例注入片段表 CHECK 约束,让片段更新失败,验证此前的生成历史更新一起回滚,之后可以显式重试。这个结果不证明生产 Worker 有相同事务,更不能回滚已经发生的 TTS 调用或对象上传。

同版本任务仍然可能竞争

假设用户没有编辑,先后为同一输入版本创建任务 G1、G2。G2 先成功,G1 后成功,两次回写的版本条件都能匹配;后回来的 G1 仍可能替换 G2。同版本 G1 的失败也可能把 G2 成功后的当前状态改成 FAILED。

示例故意允许同版本双任务,实际测试保留了这两个反例。当前业务的 applyGeneration() 也没有把 generationId 作为"当前指定任务"的比较条件;仅从这条更新不能推出"最后提交的请求胜出"。单次提交入口有忙碌检查,也不等于已经证明所有并发提交都被排除。

输入版本校验保护的是输入一致性,任务身份校验或可靠提交去重保护的才是同版本任务的优先级。generationId 在当前片段更新中是写入的关联值,并没有被拿来与"当前指定任务"比较;同一个 generationId 的重复完成守卫,也不能替代两个不同任务之间的协调。

如果业务要求最新请求胜出,可以在提交时维护当前任务标识,回写时同时比较输入版本与任务身份;如果不允许重复生成,则需要原子的提交去重和领取协议。当前按 requestId 复用任务及先查忙碌状态,都不能单独证明这种排他性。这些仍是可选设计,教学工程保留原有两个反例,不额外实现调度系统。

另一个边界在数据库之外:版本不匹配时,旧音频可能已经上传。拒绝回写不会撤销供应商调用,也不会自动删除文件。历史保留、引用保护和清理应单独制定;尤其不能在"未采用"时顺手删掉仍被历史或其他业务引用的对象。

验证旧结果的返回顺序

本次在 Java 21、Spring 6.1.15、H2 2.2.224 和 JUnit 下运行独立工程。版本与核查环境对应,不作为最新版本推荐。示例只处理手写文本和 fixture: 引用,没有生成真实音频。

打包程序的实际输出如下,属于文本实验记录:

text 复制代码
B completion: APPLIED
A completion: NOT_APPLIED
A history: READY
current: B / fixture:audio-B

原 20 项测试源码与历史记录一致,本轮先复跑通过;补充 5 项后,本轮 25 项 H2/JUnit 测试通过,失败 0、错误 0、跳过 0。原有不同版本隔离、逻辑删除、重复完成、同版本竞争反例及成功回写回滚全部保留;新增失败回写回滚、NOT_APPLIED 后重复完成、DIRTY 保留旧引用、APPLIED 非永久采用,以及外层事务回滚两表更新。

其中一个测试用 CountDownLatch 控制旧任务等待:确认它开始后修改为 B,让新任务完成,再放行旧任务;不用随机 sleep 制造顺序。条件更新、事务回滚和结果回读经过真实 H2;它不属于两个 MySQL 事务同时抢锁的专项验证。

另外启动全新临时 MySQL 8.0.11 / InnoDB 实例,用同一教学类和适配后的建表 SQL 完成 9 个补充场景:当前成功、旧版本未采用、同版本晚成功/晚失败、重复完成、逻辑删除、成功与失败回写的事务回滚,以及驱动更新行数。环境为 Connector/J 8.0.33、REPEATABLE READ;用触发器故障注入,没有把旧版 MySQL 的 CHECK 当作已生效约束。

源码 ZIP 解压后,同一套 25 项复跑通过,不另计测试数量。MySQL 的 9 个场景与 H2 部分重叠,单独报告,不相加宣称更多独立用例。它们验证的是教学 JDBC 类,没有执行生产 MyBatis 链路、真实 TTS、COS/OSS、浏览器交互、崩溃恢复或 Vimorph 端到端联调,也没有覆盖所有驱动和隔离级别。

结束语

耗时任务完成,只说明那次请求得到了结果。回写之前,还要确认输入版本与当前片段相符;错误分支和逻辑删除遵守同样的条件。再把"同版本选哪个任务"和"外部文件怎样保留"分别定义清楚,编辑器的当前状态才不会只由结果返回的先后顺序决定。

相关推荐
SelectDB4 小时前
存算分离冷查询提速 7.5 倍!Apache Doris 4.2 Peer Read 实现跨计算组缓存复用
大数据·数据库·数据分析
盛世宏博智慧档案5 小时前
一站式智慧档案解决方案,如何实现防火防盗漏水多维度安全监测实践
服务器·网络·数据库
SL_staff5 小时前
BI项目90%失败?JVS三阶闭环实践:从数据打通到业务嵌入的技术路径
java·开源·全栈
aiks_yuan5 小时前
一个端口能被两个程序同时监听吗:SO_REUSEADDR、SO_REUSEPORT Java 实测和 Spring Boot 端口被占照样启动的坑
java
雷✘5 小时前
C++ 类与对象的初始化机制、成员访问边界与拷贝优化
java·网络·c++
JOEH605 小时前
为什么你的消息队列总是丢消息?Java 后端 5 个经典坑
java·架构
数据库小学妹5 小时前
MySQL磁盘IO瓶颈:iostat与刷脏fsync
数据库·mysql·性能优化
程序员敲笔记5 小时前
Spring IoC 容器底层实现原理与 Bean 生命周期实战
java
三兔测序学社5 小时前
【零代码生信分析】RNA-seq中级分析11-差异基因进行222种不同类型数据库基因集之间的富集分析
数据库