本文使用的数据库是mysql
一、不考虑并发时的写法
假设现在有一张t_product表,我们先只考虑单实例部署时的情况
CREATE TABLE t_product(
id INT PRIMARY KEY,
NAME VARCHAR(50)
,nums INT
);
INSERT INTO t_product(id,NAME,nums) VALUES(1,'aa',1);
我们现在有一个加库存的接口会被并发调用,如果保证并发调用时存到数据库里的结果是对的。
先看下不考虑并发时接口一般怎么写。
service层
@Service
public class ProductService {
@Autowired
ProductDao productDao;
public void add(){
//查询旧的库存值
int old = productDao.getProductNumById(1);
//库存值加1更新到数据库
productDao.add(old+1,1);
}
}
<select id="getProductNumById" resultType="int">
SELECT nums FROM t_product WHERE id=#{id}
</select>
<update id="add">
UPDATE t_product set nums=#{nums} WHERE id=#{id}
</update>
这种写法中如果add方法被并发调用,线程t1先查询出old,然后线程t2已经把数据库更新了,这时t1接着执行就会造成数据不一致,可以用下面的代码模拟下这种场景
public class MyTest {
public static void main(String[] args) throws IOException, InterruptedException {
HttpClientBuilder builder = HttpClientBuilder.create();
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
CloseableHttpClient httpClient = builder.build();
HttpGet get = new HttpGet("http://localhost:8090/demo/product/add");
get.setHeader("Accept","*/*");
get.setHeader("Content-Type","application/json");
CloseableHttpResponse response = null;
try {
response = httpClient.execute(get);
} catch (IOException e) {
e.printStackTrace();
}
System.out.println(response);
}
});
t.start();
}
}
}
上边这段代码模拟了10个人同时调用add接口时的场景,理论上数据库中nums的值应该增加10,实际上因为并发更新的问题nums增加的值不是10
二、解决办法
2.1使用mysql的行锁
使用mysql提供的行锁,t1执行add时把表记录锁住让其他线程阻塞,
把getProductNumById的sql改成
SELECT nums FROM t_product WHERE id=#{id} for update;
前提时是add方法要使用事务,只有add方法有事务才能保证在事务提交前上边sql获取的行锁不会释放,如果add方法没事务那查询旧值的sql执行完后事务被自动提交获取的锁旧又被释放了,起不到阻塞别的线程的作用,所以测试时记得给add方法加上@Transactional
注解。
2.2 java代码中使用synchronize
这种方法的思路是把add方法变成同步方法,同时只有一个线程再执行,然后就可以保证查询旧值和更新新值这两个操作和起来是原子性的。可能会写出这样的代码,但这样的代码真的可以保证一致性吗?
@Transactional
public synchronized void add(){
int old = productDao.getProductNumById(1);//(1)
productDao.add(old+1,1);
}
要注意数据库的事务隔离级别要是读已提交,mysql默认的隔离级别是可重复读,假设线程t1和t2都已经开启事务,t1先进入add方法执行,当它更新完数据库、释放锁、提交事务后t2再开始执行,那么t2能查询到nums的最新值吗,可重复读级别下是不能的,所以我们要修改这个方法使用的事务的隔离级别,所以我们对代码做出改进,
//指定这个方法中使用的事务隔离级别是读已提交
@Transactional(isolation = Isolation.READ_COMMITTED)
public synchronized void add(){
int old = productDao.getProductNumById(1);
productDao.add(old+1,1);
}
那么现在的代码看着好像没问题了,再思考两个问题,事务是什么时候开启的?锁又是什么时候加上去的?
spring的事务控制是基于aop实现的,那么开启事务和加锁是这样的顺序:
(1)aop开启事务
(2)开始执行目标方法加锁
(3)目标方法执行完成解锁
(4)aop提交事务
所以在解锁的时候事务还没有提交,别的线程这个时候去执行add方法查询到的还是旧值,所以为了保证数据一致性我们要实现先加锁再开启事务,那只能把add方法中查询和更新的逻辑抽取成另一个事务方法,这样才可以实现加锁后开启事务。
有因为同一个service中方法互相调用不走aop所以需要抽取到另一个service中
@Service
public class ProductService {
@Autowired
AddProductService addProductService;
//因为这个方法现在不需要读取数据了所以可以不开启事务
public synchronized void add(){
addProductService.addNums();
}
}
@Service
public class AddProductService {
@Autowired
ProductDao productDao;
//指定这个方法中使用的事务隔离级别是读已提交
@Transactional(isolation = Isolation.READ_COMMITTED)
public void addNums(){
int old = productDao.getProductNumById(1);
productDao.add(old+1,1);
}
}
2.3 更新时使用旧值作为条件
UPDATE t_product set nums=#{nums} WHERE id=#{id} and nums=#{oldNums}
这种方式利用了cas的思想在更新的时候检查旧值对不对,如果不对update语句就不会执行成功,然后可以在代码里重新查询值
public class ProductService {
@Autowired
ProductDao productDao;
//指定这个方法中使用的事务隔离级别是读已提交
@Transactional(isolation = Isolation.READ_COMMITTED)
public void add(){
int old = productDao.getProductNumById(1);
int res = productDao.add(old + 1, 1, old);
while(res==0) {
//进循环说明update语句执行的时候nums已经被更新过了
old = productDao.getProductNumById(1);
res = productDao.add(old + 1, 1, old);
}
}
}
因为要在service中查询旧值,所以也要注意事务的隔离级别,但因为这种方式不涉及在代码中加锁所以不需要单独抽象servcie去开启新事物。
那么这种方式可行的原理是什么呢?
mysql中的update语句会给某一行数据加上行锁,事务t1先执行update会给数据行加行锁,
那事务t2要执行update时也会先给数据加行锁,这时因为数据行已经有行锁就会被阻塞住直到t1提交,这时t2才能执行update,因为nums的值已经被改了所以update不会更新数据影响行数是0,所以代码里就会进入while循环重新查询。