一、概述
乐观锁和悲观锁是在数据库中引入的名词,悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其它线程修改,所以在数据处理前先对数据进行加锁,并在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制,数据库中实现是对数据记录操作前给记录加排它锁。如果获取锁失败,则说明数据正在被其它线程修改,当前线程则等待或者等待超时抛出异常。如果获取锁成功,则对记录进行操作,然后事务提交后释放排它锁。
乐观锁是相对悲观锁来说的,它认为数据一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测。具体说是根据 update 返回的行数让用户决定如何去做
二、悲观锁例子
下面看一个典型的使用悲观锁解决多线程下避免同时对一个记录进行修改造成数据混乱的例子:
public int updateEntry(long id){
//(1)使用悲观锁获取指定记录
EntryObject entry = query("select * from table1 where id = #{id} for update",id);
//(2)修改记录内容,根据计算修改entry记录的属性
String name = generatorName(entry);
entry.setName(name);
。。。。
//(3)update操作
int count = update("update table1 set name=#{name},age=#{age} where id =#{id}",entry);
return count;
}
如上代码,假设updateEntry,query,update方法都是使用了事务切面的方法,并且事务传播性设置为required。
当线程执行updateEntry方法时候如果上层调用方法里面没有开启事务,则当前会开启一个事务,然后执行代码(1),代码(1)调用了query方法根据指定id从数据库里面搜出来一个记录,并且加了for update(锁定该记录)。由于事务传播性为requried,所以执行query时候没有开启新的事务,而是加入到了updateEntry开启的事务中了,也就是等updateEntry方法执行完毕提交事务时候,query方法才会提交,也就是说对记录的锁定会持续到updateEntry执行结束。
代码(2)则对获取的记录进行修改,代码(3)则把修改的内容写回数据库,同理代码(3)的update方法也没有开启新的事务,而是加入了updateEntry的事务。也就是updateEntry,query,update方法公用同一个事务。
当多个线程同时调用updateEntry方法,并且传递的id是同一个的时候,只有一个线程执行代码(1)会成功,其他线程则会阻塞到代码(1),这是因为同时只有一个线程可以获取对应记录的锁,在获取锁的线程释放锁前(updateEntry执行完毕,提交事务前),其它线程必须等待,也就是同时只有一个线程可以对该记录进行修改。
要点 :悲观锁使用要求在select时候加 for update锁定记录,另外步骤(1)(2)(3)必须合并到同一个事务内,记录锁的释放时机是整个事物提交或者回滚后,另外这个行锁本质上还是一个分布式锁(分布式锁的其中一个实现就是使用数据库悲观锁来实现)。
三、乐观锁例子
上面的例子改为乐观锁时候代码如下:
使用乐观锁
public int updateEntry(long id){
//(1)
EntryObject entry = query("select * from table1 where id = #{id}",id);
//(2)修改记录内容,version字段不能被修改
String name = generatorName(entry);
entry.setName(name);
。。。。
//(3)update操作
int count = update("update table1 set name=#{name},age=#{age},version=${version}+1 where id =#{id} and version=#{version}",entry);
return count;
}
如上代码,当多个线程调用updateEntry方法并且传递的id是相同的时候,多个线程可以同时执行代码(1)获取id对应的记录并把记录放入线程本地栈里面,然后可以同时执行代码(2)对自己栈上的记录进行修改,多个线程修改后各自的entry里面的属性应该都不一样了。
然后多个线程可以同时执行代码(3),代码(3)中update语句的where条件里面加入了version=#{version}条件,并且set语句多了version=${version}+1 ,这句意思是如果数据库里面id =#{id} and version=#{version}的记录存在则更新version的值为原来值加1,这个有点CAS操作的意思。
假设多个线程同时执行的updateEntry并传递相同的id,那么执行代码(1)时候获取的Entry是同一个,获取的entry里面的version的值都是相同的(这里假设version=0),多个线程执行代码(3)时候,由于update语句更新本身是原子性的,假如线程A执行update成功了,那么这时候id对应的记录的version的值为原始version值变为了1。其他线程执行代码(3)更新的时候发现数据库里面已经没有了version=0的语句,所以会返回影响行号0。
业务上根据返回值为0就可以知道当前更新没有成功,那么接下来有两个做法,如果业务发现更新失败就失败了,那么下面可以什么都不做,把错误抛出去让上层调用方知道更新失败了,也就是上面这种处理方式。如果失败了需要重试,则updateEntry的代码可以修改为如下:
使用乐观锁
public boolean updateEntry(long id){
boolean result = false;
int retryNum = 5;
while(retryNum>=0){
//(1.1)
EntryObject entry = query("select * from table1 where id = #{id}",id);
//(2.1)修改记录内容,version字段不能被修改
String name = generatorName(entry);
entry.setName(name);
...
//(3.1)update操作
int count = update("update table1 set name=#{name},age=#{age},version=${version}+1 where id =#{id} and version=#{version}",entry);
if(count == 1){
result = true;
break;
}
retryNum--;
}
return result;
}
如上代码retryNum设置更新失败后的重试次数,如果代码(3.1)执行结果返回0则说明代码(1.1)获取的记录已经被修改了,则循环一下从新通过代码(1.1)获取最新的数据,然后在次执行点(3.1)尝试更新。这类似CAS的自旋操作,只是这里没有用死循环,而是指定了尝试次数。
要点:乐观锁并不会使用数据库提供的锁机制,一般在表添加 version 字段或者使用有序的业务状态(A->B->C)并且非(A->B->A)来做,乐观锁实现并不要求步骤(1)(2)(3)合并为同一个事务,乐观锁本质上是CAS。
四、参考
本文摘自 <<Java并发编程之美>>,具体移步阅读原文