Spring事务管理(Oracle)下插入fileId重复问题求助
嘿,我完全懂你遇到的这个麻烦——并发场景下用max(fileId)+1生成新ID,结果两个事务撞车生成了重复值,这是典型的竞态条件坑,咱们来拆解原因并给出靠谱的解决方案:
问题根源
当两个事务同时执行插入时,它们都会先执行SELECT max(fileId) FROM Table1 WHERE name="file2" AND path="/folder1"。在默认的事务隔离级别(比如MySQL的REPEATABLE READ)下,两个事务都只能读到已提交的最新值(也就是201),各自加1后都插入202,自然就重复了。本质是因为读取和插入不是原子操作,中间有空隙被其他事务钻了空子。
靠谱的解决方案
按推荐优先级给你列几种办法:
1. 用数据库行锁+辅助表(最推荐)
创建一个专门维护name+path组合版本号的辅助表,利用数据库的行锁机制确保同一时间只有一个事务能更新版本号:
-- 先建辅助表,用name+path做主键 CREATE TABLE file_version ( name VARCHAR(255), path VARCHAR(255), current_max INT NOT NULL, PRIMARY KEY (name, path) ); -- 初始化数据(对应你的file2/folder1) INSERT INTO file_version (name, path, current_max) VALUES ("file2", "/folder1", 201);
然后在事务里先更新辅助表(这一步会加行锁),再拿更新后的值插入主表:
temp.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { // 1. 更新辅助表,自动加行锁,其他事务必须等当前事务提交 jdbcTemplate.update( "UPDATE file_version SET current_max = current_max + 1 WHERE name = ? AND path = ?", "file2", "/folder1" ); // 2. 获取更新后的最新版本号 Integer newFileId = jdbcTemplate.queryForObject( "SELECT current_max FROM file_version WHERE name = ? AND path = ?", Integer.class, "file2", "/folder1" ); // 3. 插入主表 jdbcTemplate.update( "INSERT INTO Table1 (fileId, name, path) VALUES (?, ?, ?)", newFileId, "file2", "/folder1" ); } });
这种方式把版本号的更新变成原子操作,彻底避免竞态,性能也不错。
2. 给查询加排他锁(简单直接)
在查询max(fileId)的时候用FOR UPDATE加排他锁,这样其他事务在执行同样的查询时会被阻塞,直到当前事务提交:
INSERT INTO Table1 (fileId, name, path) VALUES ( (SELECT max(fileId) + 1 FROM Table1 WHERE name="file2" AND path="/folder1" FOR UPDATE), "file2", "/folder1" );
⚠️ 注意:一定要给name和path建联合索引!否则FOR UPDATE会锁全表,严重影响性能。
3. 提升事务隔离级别(不推荐高并发场景)
把事务隔离级别设为SERIALIZABLE(最高级别),数据库会对查询范围加锁,强制事务串行执行:
TransactionTemplate temp = new TransactionTemplate((PlatformTransactionManager) new DataSourceTransactionManager(dsource)); // 设置隔离级别为SERIALIZABLE temp.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE); temp.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { // 原来的插入逻辑 } });
这种方式简单但性能极差,高并发场景下会导致大量事务等待,只适合低并发的小系统。
4. 乐观锁+重试(适合低并发)
如果你的系统并发不高,可以用乐观锁的思路:插入时检查当前的max值是否和之前查询的一致,不一致就重试:
temp.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { // 1. 查询当前max值 Integer currentMax = jdbcTemplate.queryForObject( "SELECT max(fileId) FROM Table1 WHERE name=? AND path=?", Integer.class, "file2", "/folder1" ); int newFileId = currentMax + 1; // 2. 插入时带条件,确保max值没被修改 int affectedRows = jdbcTemplate.update( "INSERT INTO Table1 (fileId, name, path) VALUES (?, ?, ?) " + "WHERE (SELECT max(fileId) FROM Table1 WHERE name=? AND path=?) = ?", newFileId, "file2", "/folder1", "file2", "/folder1", currentMax ); // 3. 如果没插入成功,说明被并发修改了,回滚并重试(可以加循环重试逻辑) if (affectedRows == 0) { status.setRollbackOnly(); // 这里可以加重试机制,比如最多重试3次 } } });
这种方式不需要加锁,但并发高的话会有很多重试失败的情况,体验不好。
总结
优先选辅助表+行锁或者SELECT ... FOR UPDATE的方案,既能保证正确性,又不会太影响性能。记得给name和path加联合索引,避免锁全表的坑!
内容的提问来源于stack exchange,提问作者user3458271

