给编辑接口加了 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);
}
}
}
你们处理版本冲突时,是保留输入让用户重选,还是允许合并不同字段?如果采用合并,两个用户改同一字段时的规则是什么?