UPDATE影响0行,接口却返回成功:把版本冲突接回业务

给编辑接口加了 version,并不代表冲突已经处理完。数据库更新影响0行,Service忽略返回值,Controller照常返回成功,页面还是会告诉用户"保存好了"。

下面用一个联系人编辑实验把这个问题拆开:先复现无条件更新的覆盖,再让两份版本0的修改先后提交。实验使用JDK21.0.11、H2 2.3.232、两个真实连接,按固定顺序执行,不是吞吐压测。

条件必须跟更新在同一条SQL里

表里放 id、name、version。两个用户都读到版本0,A先把name改成Alice,B随后提交自己表单里的Bob。

如果条件只有 where id=1,B会覆盖A。带上版本以后,关键语句变成:

sql 复制代码
update contact
set name = ?, version = version + 1
where id = 1 and version = ?;

A提交版本0,匹配成功,版本变成1。B仍提交0,已经匹配不到。这里把条件比较与修改放在一条更新里;不要先查版本、在Java里比较、再无条件更新,那会重新留下检查与写入之间的空隙。

executeUpdate() 返回影响行数,这是JDBC可以直接取到的结果,见Java Statement文档。本实验主键唯一,一次最多更新一行。

0行之后,决定权回到业务

下面是接入接口时的判断示意,完整实验中的 save 方法会原样返回行数:

java 复制代码
int changed = save(connection, requestedName, originalVersion);
if (changed == 0) {
    // 先按业务区分不存在、权限、状态和版本问题,不再返回保存成功。
    throw new IllegalStateException("本次修改未生效,请核对当前记录");
}

示意代码故意没有绑定一个HTTP状态码,因为0行不天然等于版本冲突:记录可能已删除,真实SQL还可能带租户、权限或状态条件。服务层要在既定权限范围内解释结果,避免为了区分原因泄露他人记录是否存在。本文固定记录存在,所以可以观察旧版本这一种原因。

也不要检测到冲突后,在后端重新查出version,再替用户重放旧表单。那会把"拒绝旧修改"的保护悄悄绕开。是否覆盖或合并,应建立在用户确认或明确业务规则上。

两次提交,实际留下了什么

本地运行的关键结果:

步骤 观察
无版本条件,先Alice后Bob 最终Bob,前一次被覆盖
版本0提交Alice 更新1行
版本0提交Bob 更新0行,仍保留Alice
模拟用户确认后,版本1提交Bob-confirmed 更新1行,版本变2

最后一步不是程序自动重试,代表用户已经看过最新值、确认新内容后的另一份提交。示例里没有实现前端,不能把这段命名当作已经验证用户交互。

实验还检查最终name和version,不只检查异常是否抛出。实际项目可以把页面"保留输入、展示最新内容、重新确认"放进验收,防止数据保住了、用户输入却被清空。

复制运行

建立 pom.xml 与 src/main/java/demo/VersionLab.java,内容见下方。命令在pom所在目录执行:

shell 复制代码
mvn -q compile exec:java -Dexec.mainClass=demo.VersionLab

程序自行建表,每次新进程重新开始,预期输出:

text 复制代码
unguarded=Bob; first=1; stale=0; confirmed=Bob-confirmed; checks=7

以下是完整文件。内存数据库只用来复现条件更新,不证明MySQL/PostgreSQL的并发隔离、锁等待或线上性能;换数据库后还要做对应集成测试。

pom.xml:

xml 复制代码
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion><groupId>demo</groupId><artifactId>weekly-evidence</artifactId><version>1.0</version>
  <properties><maven.compiler.release>21</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
  <dependencies><dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.3.232</version></dependency></dependencies>
  <build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version></plugin><plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version></plugin></plugins></build>
</project>

src/main/java/demo/VersionLab.java:

java 复制代码
package demo;
import java.sql.*;

public class VersionLab {
    static int checks;
    static void eq(Object expected, Object actual) {
        if (!expected.equals(actual)) throw new AssertionError(expected + " != " + actual);
        checks++;
    }
    static int save(Connection c, String name, int oldVersion) throws SQLException {
        try (var p = c.prepareStatement("update contact set name=?, version=version+1 where id=1 and version=?")) {
            p.setString(1, name); p.setInt(2, oldVersion);
            return p.executeUpdate(); // 必须把影响行数交给调用方判断。
        }
    }
    static String current(Connection c) throws SQLException {
        try (var s=c.createStatement(); var r=s.executeQuery("select name from contact where id=1")) {
            r.next(); return r.getString(1);
        }
    }
    public static void main(String[] args) throws Exception {
        // 两个真实连接,按固定顺序模拟两个人都持有版本0的表单。
        try (var a=DriverManager.getConnection("jdbc:h2:mem:version;DB_CLOSE_DELAY=-1");
             var b=DriverManager.getConnection("jdbc:h2:mem:version;DB_CLOSE_DELAY=-1")) {
            try (var s=a.createStatement()) {
                s.execute("create table contact(id int primary key,name varchar(60),version int not null)");
                s.execute("insert into contact values(1,'old',0)");
                s.executeUpdate("update contact set name='Alice' where id=1");
                s.executeUpdate("update contact set name='Bob' where id=1");
            }
            eq("Bob",current(a));
            try(var s=a.createStatement()){s.executeUpdate("update contact set name='old',version=0 where id=1");}
            int first=save(a,"Alice",0),stale=save(b,"Bob",0);
            eq(1,first);eq(0,stale);eq("Alice",current(a));
            // 用户阅读新内容并确认后,才用新版本发起新的修改。
            eq(1,save(b,"Bob-confirmed",1));eq("Bob-confirmed",current(a));
            try(var s=a.createStatement();var r=s.executeQuery("select version from contact where id=1")){r.next();eq(2,r.getInt(1));}
            System.out.println("unguarded=Bob; first="+first+"; stale="+stale+"; confirmed=Bob-confirmed; checks="+checks);
        }
    }
}

你们处理版本冲突时,是保留输入让用户重选,还是允许合并不同字段?如果采用合并,两个用户改同一字段时的规则是什么?

相关推荐
霸道流氓气质1 小时前
AI应用成本优化完全指南:Token压缩、语义缓存与模型路由的Java生产级实战
java·人工智能·缓存
mmm.c1 小时前
Redis 两级缓存 + Pub/Sub 广播:一次为抗高并发做的缓存改造
java
yexianglunbai1 小时前
中间件(Middleware)详解:概念、作用与实战
java·开发语言·中间件
叶总没有会1 小时前
OpenFeign
java·开发语言·springcloud·openfeign
小七在进步1 小时前
类和对象(四)
java·javascript·ajax
一帅1 小时前
Muzzle:给 Java Agent 戴上的"安全口罩"
后端
白帽攻防录1 小时前
SRC 挖洞:Apache Tomcat 加密拦截器绕过深度复盘,CVE-2026-34486 fail-open 一行代码怎么打穿集群通信
java·网络安全·tomcat·apache
Bazingga1 小时前
从0到1吃透Function Calling:Spring AI完整实战
后端
后端LV1 小时前
三级缓存的两个失效盲区,我用 binlog 和消费组广播补上了
java·架构