搭建了一个简单springboot的ssm项目,通过idea提供的多线程debug模式模拟并发更新丢失数据问题。业务是根据name查询出来total,然后再根据name更新total+1
controller代码
@RestController
public class UserController {
@Autowired
private UserService service;
@GetMapping("user/{id}")
public String getUser(@PathVariable("id") int id){
Map<String, Object> user = service.getUserById(id);
System.out.println(user);
return user.get("name").toString();
}
@GetMapping("test/concurrentUpdateLose")
public String concurrentUpdateLose() {
service.concurrentUpdateLose("zhangsan");
return "ok";
}
}
service代码
public interface UserService {
Map getUserById(int id);
int concurrentUpdateLose(String name );
}
service实现类代码
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserDao dao;
public Map getUserById(int id){
return dao.getUserById("lisi");
}
/**
* 原始查询并更新
*/
@Transactional
@Override
public int concurrentUpdateLose(String name ) {
Map userById = dao.getUserById(name);
int updateResult = getUpdateResult(userById);
return updateResult;
}
private int getUpdateResult(Map userById) {
int oldTotal = (Integer)(userById.get("total"));
Date time = (Date)(userById.get("time"));
int newTotal = oldTotal + 1;
String name = (String) userById.get("name");
/*try {
Thread.sleep(15000);
} catch (InterruptedException e) {
e.printStackTrace();
}*/
return dao.updateUserTotalByid(name, newTotal,time);
}
}
dao层代码
@Mapper
public interface UserDao {
@Select("select id, name,age,total,time from user where name = #{name}")
Map getUserById(String name);
@Update("update user set total = #{newTotal} where name = #{name}")
int updateUserTotalByid(String name,int newTotal, Date time);
}
springboot启动类代码
@SpringBootApplication
@MapperScan(basePackages="xxx.xxx")
public class SsmDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SsmDemoApplication.class, args);
}
}
数据库测试表
debug走一行查询语句,先不走更新语句,此时第二次调用接口,同样走一行查询语句,不走更新语句。为的是让两次调用的查询结果一致。
第一次调用
第二次调用
可以看到两次调用的线程不一样,查询结果都是total=1
之后分别走完update语句,因为都是更新的total=2,所以虽然调用了两次total+1,但是结果是更新完结果是total=2,而不是预期结果total=3.
select 语句加上lock in share mode
结果:会造成死锁(等待锁时间超时,会自动释放锁)。
原因:意向共享锁可以允许别的事务读,可继续加共享意向锁,但不允许别的事务修改。
不采用此方案
select语句加上for update
结果:可以解决并发更新丢失问题。
原因:意向排它锁,不允许别的事务加S锁或X锁,直到加锁的事务执行完事务释放锁,相当于每个事务串行执行,自然不存在并发更新丢失问题。
不采用,效率太低。
对要并发更新的表,添加版本号(可以采用mysql自动更新的时间戳,timestamp)。查询时查到该行数据的版本号(时间戳),更新时where条件带上“ and timestamp=xxx”,同时获取到更新结果成功的条数,(mybatis或jdbc带有此功能)。如果一个事务更新成功,则该update语句执行成功的结果是1,同时其他事务更新就会失败,执行成功条数的结果是0,因为一旦有事务更新该条记录成功,那么该条记录的版本号(时间戳)就会改变,因为更新语句中带有条件“ and timestamp=xxx”,所以更新会失败。
为了保证所有的事务最终都能打到更新的目的,需要对更新失败的事务做处理。如果更新结果为0,则循环执行事务,即重新查询该条记录,用最新的版本号作为更新条件。
这里涉及到mysql的隔离级别,在读提交(RC)级别下,可以读到别的事务已提交的结果,所以可以在一个事务中循环执行“先查询,再根据查询结果更新”。
RC级别,通过@Transactional(isolation = Isolation.READ_COMMITTED)设置会话事务的隔离级别。
而在可重复读(RR)级别下,不能读到别的事务已提交的结果,所以需要循环开启新的事务来执行流程。这里暂未测试
下面已RC级别演示解决方案。修改后的代码如下
service代码
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserDao dao;
public Map getUserById(int id){
return dao.getUserById("lisi");
}
/**
* 乐观锁解决方案,数据库隔离级别设置为读提交,这样在一个事务里可以读到其他事务已提交的变更
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
@Override
public int concurrentUpdateLose(String name ) {
int updateResult=0;
int cyclicCount = 0;
do {
Map userById = dao.getUserById(name);
updateResult = getUpdateResult(userById);
cyclicCount++;
}while (updateResult == 0 && cyclicCount<10);
return updateResult;
}
private int getUpdateResult(Map userById) {
int oldTotal = (Integer)(userById.get("total"));
Date time = (Date)(userById.get("time"));
int newTotal = oldTotal + 1;
String name = (String) userById.get("name");
/*try {
Thread.sleep(15000);
} catch (InterruptedException e) {
e.printStackTrace();
}*/
return dao.updateUserTotalByid(name, newTotal,time);
}
}
dao层代码
@Mapper
public interface UserDao {
// @Select("select id, name,age,total,time from user where name = #{name} for update")
@Select("select id, name,age,total,time from user where name = #{name}")
Map getUserById(String name);
@Update("update user set total = #{newTotal} where name = #{name} and time = #{time}")
int updateUserTotalByid(String name,int newTotal, Date time);
}
现在将代码改为更新时添加了一个查询条件time,time值是查询结果的time值。time是数据库表自动更新的时间戳,所以每次更新都会更新time,相当于版本号。
修改后,更新结果为total=3,演示流程步骤同上。
建议采用乐观锁的方案