实践检验乐观锁与悲观锁

前言

在实际生产环境中,往往会遇到热门的产品,导致短时间内大量用户涌入。比如某款新手机上市,会在某个时间点开抢,这时就需要面对这个高并发现象。

我们就通过简单的模拟实验,复现这个场景并解决。

模拟过程(这里针对单一商品,一次只能购买一个)

环境配置并测试

直接使用Mybatis作为我们的持久层框架。

1、搭建Mybatis环境(参考:Mybatis快速开始)。建立如下两张表和相应持久层对象。

商品明细表(tb_product)

购买记录表(tb_purchase_record)

2、到层接口和对应的mapper文件(就查库存、减库存、增加购买记录三个相应的方法)

public interface ProductDao {

	//根据id查询库存
	int getProductRepertoryById(Integer productId);
	
	//更改库存
	int updateProductRepertoryById(Integer productId);
}

 

<mapper namespace="com.zepal.mybatis.dao.ProductDao">

	<select id="getProductRepertoryById" parameterType="int" resultType="int">
		select product_repertory from tb_product where product_id = #{productId};
	</select>
	
	<update id="updateProductRepertoryById" parameterType="int">
		update tb_product set product_repertory = product_repertory - 1
		where product_id = #{productId};
	</update>
</mapper>
public interface PurchaseRecordDao {

	//增加购买记录
	int savePurchaseRecord(PurchaseRecord purchaseRecord);
}
<mapper namespace="com.zepal.mybatis.dao.PurchaseRecordDao">

	<insert id="savePurchaseRecord" useGeneratedKeys="true" parameterType="com.zepal.mybatis.domain.PurchaseRecord">
		insert into tb_purchase_record (record_person, record_time, product_id)
		values
		(#{recordPerson}, #{recordTime}, #{product.productId});
	</insert>
</mapper>

 

3、模拟场景(50个线程抢购30件商品,还可以自行使用其它的方式实现模拟场景哦)

单例下获取SqlSessionFactory

public class MybatisFactory {
	
	private static SqlSessionFactory sqlSessionFactory = null;
	
	public static SqlSessionFactory getSqlSessionFactory() {
		if(sqlSessionFactory == null) {
			synchronized(SqlSessionFactory.class) {
				if(sqlSessionFactory == null) {
					InputStream is = MybatisFactory.class.getResourceAsStream("/mybatis/mybatis-config.xml");
					sqlSessionFactory = new SqlSessionFactoryBuilder().build(is);
				}
			}
		}
		return sqlSessionFactory;
	}
}

执行对象

public class Execute implements Runnable {

	@Override
	public void run() {
		SqlSessionFactory sqlSessionFactory = MybatisFactory.getSqlSessionFactory();
		SqlSession sqlSession = sqlSessionFactory.openSession(TransactionIsolationLevel.READ_COMMITTED);
		ProductDao productDao = sqlSession.getMapper(ProductDao.class);
		PurchaseRecordDao purchaseRecordDao = sqlSession.getMapper(PurchaseRecordDao.class);
		int productRepertory = productDao.getProductRepertoryById(1);
		if(productRepertory > 0) {//如果库存大于0
			productDao.updateProductRepertoryById(1);//减库存
			PurchaseRecord purchaseRecord = new PurchaseRecord();
			purchaseRecord.setRecordPerson(Thread.currentThread().getName());
			purchaseRecord.setRecordTime(new Date());
			purchaseRecord.setProduct(new Product(1, null, null));
			purchaseRecordDao.savePurchaseRecord(purchaseRecord);//增加购买记录
		}
		sqlSession.commit();
		sqlSession.close();
	}
}

测试代码(电脑配置高,可以适当加点数量)

public class Test{

	public static void main(String[] args) {
	  ExecutorService es = Executors.newCachedThreadPool(); 
	  for(int i=0;i<50;i++) {
		  es.execute(new Execute()); 
	  } 
	  es.shutdown();
	}
}

4、执行结果

执行

SELECT COUNT(*) FROM tb_purchase_record;

执行后可以看见,刚开始商品有30件,而且是商品库存大于0才能卖商品,购买记录也是39条。

而且是限制了读取到的库存大于0后才能执行购买操作减库存

还请注意一个地方

我获取的事务,是读已提交事务。就是说,当前一个线程将库存修改完,且提交事务后,下一个事务才会读取,不会出现脏读现象,但是,大量的用户同时涌入并不会排着队,一个一个访问,等A执行完毕,B在执行.......都是同时在访问。那么问题就出在:

在某一时间点,多个线程同时读取到商品库存大于0,那么这些线程也就都会随之执行减库存操作。也就导致了出现超卖现象。

接下来就解决这个问题。

悲观锁

按照前面的分析,既然大量的线程不是排着队在访问,我们就让他们排着队访问。当某一个线程,最先访问到目标商品的记录之后,我们就将其锁定,等待其操作完成之后释放锁,再由下一个线程进入。就像去一间房屋里夺宝一样,管你门外千百人,只要先进入房间的人,将门锁上,外面的人根本进不来。程序中锁的概念也是这么由来,就是在某一节点,给定一个标识,当存在这个标识之后,只有与之对应的线程才能在当前节点活动,其它线程发现了这个标识就不能活动,只有释放这个标识之后,其它线程才能从新获取标识再活动。

使用悲观锁,是在数据库层面上的,只需要将上述示例改动一个地方,如下:

<select id="getProductRepertoryById" parameterType="int" resultType="int">
		select product_repertory from tb_product 
		where product_id = #{productId}
		<!-- 注意这里 -->
		for update;
</select>

在查询库存的时候,加上 for update语句。这样,在当前事务的执行过程中,就会锁定该行数据,其它事务就不能再对该行数据进行读写,直到持锁事务执行完成之后,才会释放锁,让其它事务进行读写,所以悲观锁又称为独占锁和排它锁

接下来,恢复原数据之后,我们重新执行测试用例(注意先,记录下购买记录中的最大最小时间差,在后面比较下性能)。

执行完之后,会发现

超卖现象没有了,和购买记录中的统计信息也吻合。但是,再不加锁的情况下,50个线程购买30件商品几乎是在同一时间完成的,枷锁之后,最后一条记录和第一条记录,前后查了2s(当然取决于你电脑的性能,如果没有时间差,可以将线程数量和商品库存调大一点作加锁和不加锁的测试),我反复两次都是2S,说明不是巧合,从执行过程分析,也明确这不是巧合,因为有一个加锁和解锁的过程。这里是50对30,那提高数万倍之后,这个效率就可怕了,所以,为了解决效率,又出现了悲观锁。

题外话:

因为悲观锁锁住的是单行数据,所以属于行级锁,在数据库中,行级锁有两种表现形式。

一种称为共享锁:SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE;

一种称为排他锁:SELECT * FROM table_name WHERE ... FOR UPDATE(示例的实现方式);

注意:对于UPDATE、DELETE和INSERT(DML)语句,InnoDB会自动给涉及数据集加排他锁(称为隐式加锁),不要尝试在DML语句后面手动加锁,对于普通SELECT语句,InnoDB不会加任何锁,才可以在需要的地方手动加锁(称为显式加锁)。

共享锁主要用在需要数据依存关系时来确认某行记录是否存在,并确保没有事务对这个记录进行update或者delete。如果当前事务也需要对该记录进行更新操作,则很可能造成死锁,所以,对于锁定记录后需要进行更新操作的事务,应该使用排他锁。

乐观锁

前面分析,悲观锁的效率损失在加锁和解锁过程造成线程阻塞引起的,那么乐观锁就是一种不使用数据库锁和不阻塞线程并发的方案。因此乐观锁也称为非独占锁或无阻塞锁

针对上述问题,乐观锁的实现方式就是,对需要被并发访问的数据加一个标识,姑且称为version(int 类型),因为该行数据没有加锁,所以所有线程都有可能在同一时间节点访问到该数据,对于获取到该行数据的线程,根据version保存起来(假定最初的version值为1,然后只要有线程对该行数据作了修改,version就依次递增,2,3,4....保证version不会重复)。那么每个获取到该行数据的线程就保存了version为1的整行数据(这里成为旧值)。当某个线程需要对该行数据进行update的时候,就拿出旧值中的version和该行数据的当前时间节点的version值进行比较,如果version没有变,则证明该行数据没有被其它线程修改过,就可以修改。假如线程A获取的版本为1,线程B获取的版本也是1,但是线程B执行更快,对该行数据作了修改,那么version就应该变为新version,为2.那么A线程要对该行数据作修改时,会拿就version(1)与新version(2)作比较,值不一样,那么线程A就放弃此次操作。

实现过程:

1.更改tb_product表的结构,增加一个version字段,并更改相应的持久化对象,删除原有的悲观锁(for update)

2、在减库存的操作的地方,增加乐观锁的SQL表现形式

<!-- 增加乐观锁 -->
<update id="updateProductRepertoryById" parameterType="int">
	update tb_product set product_repertory = product_repertory - 1,
	<!-- 每次对数据作了更改,则改变version值 -->
	version = version+1
	<!-- 这里将获取该行数据时的version(旧值)与当前时间节点的version作为比较条件 -->
	where product_id = #{productId} and version = #{old_version};
</update>

3、相应的减库存操作的dao层接口也作改变,

//更改库存
int updateProductRepertoryById(Integer productId, Integer old_version);

4、查询库存的操作,更改为将库存和version一起查询出来,相应的dao层接口也作更改

   <resultMap type="com.zepal.mybatis.domain.Product" id="productMap">
		<result column="product_repertory" property="productRepertory"/>
		<result column="version" property="version"/>
	</resultMap>
	<select id="getProductById" parameterType="int" resultMap="productMap">
		select product_repertory, version from tb_product 
		where product_id = #{productId};
	</select>
//根据id查询库存和version
int getProductById(Integer productId);

4、测试用例和执行结果(还是针对同一件商品,按一次购买一个测试,可以自行更改数量)

public class Execute implements Runnable {

	@Override
	public void run() {
		SqlSessionFactory sqlSessionFactory = MybatisFactory.getSqlSessionFactory();
		//注意这里要读已提交,避免B线程对version作更改为2的时候,还未commit,却被A读取到version为1。
		SqlSession sqlSession = sqlSessionFactory.openSession(TransactionIsolationLevel.READ_COMMITTED);
		ProductDao productDao = sqlSession.getMapper(ProductDao.class);
		PurchaseRecordDao purchaseRecordDao = sqlSession.getMapper(PurchaseRecordDao.class);
		Product product = productDao.getProductById(1);//获取库存和version,作为旧值
		if(product.getProductRepertory() > 0) {//如果库存大于0
			//如果库存大于0则证明可以购买,将要购买的商品id和旧值version发送到数据库作比较
			int result = productDao.updateProductRepertoryById(1, product.getVersion());
			if(result != 1) {
				//说明数据库的version已经被更改,即是在并发情况下被其它线程作了修改,因为旧version和数据库version已经匹配不上
				//当前线程就放弃此次操作
				return;
			}
			//如果购买成功,即result==1,就插入相应的购买记录
			PurchaseRecord purchaseRecord = new PurchaseRecord();
			purchaseRecord.setRecordPerson(Thread.currentThread().getName());
			purchaseRecord.setRecordTime(new Date());
			purchaseRecord.setProduct(new Product(1, null, null,null));
			purchaseRecordDao.savePurchaseRecord(purchaseRecord);//增加购买记录
		}
		sqlSession.commit();
		sqlSession.close();
	}
}

通过上面的测试结果分析,超卖现象没有了,但是由于很多线程的旧值version和数据库的version不匹配,导致很多事务失败了,而且失败率还很高。(实际生产环境中,也许由于网络、业务更加复杂的情况下,线程执行效率没这么高,成功率会稍微高点,但也是不允许的)。

为了处理这个问题,乐观锁还可以引入重入机制,也就是一旦更新失败,就重新执行一遍,这里就是重做查库存和减库存的操作。所以有时候也可以称乐观锁为可重入锁

但是,这个重入机制,不是不限制的重复,比如某个事务需要执行3条SQL完成,但是重复5次的话,相当于要执行15条SQL,那么在高并发场景下必会对数据库造成更大的压力,一般会考虑限制时间或重入次数,以压制这个问题。

乐观锁--用时间戳限制可重入

这里就限定100ms,在这100ms内无限重复,如果100s还不能成功执行事务,则放弃。

这里就对执行逻辑作更改。

public class Execute implements Runnable {

	@Override
	public void run() {
		SqlSessionFactory sqlSessionFactory = MybatisFactory.getSqlSessionFactory();
		//注意这里要读已提交,避免B线程对version作更改为2的时候,还未commit,却被A读取到version为1。
		SqlSession sqlSession = sqlSessionFactory.openSession(TransactionIsolationLevel.READ_COMMITTED);
		ProductDao productDao = sqlSession.getMapper(ProductDao.class);
		PurchaseRecordDao purchaseRecordDao = sqlSession.getMapper(PurchaseRecordDao.class);
		long start_time = System.currentTimeMillis();//记录一个开始时间
		while(true) {
			long end_time = System.currentTimeMillis();//每次重试前验证时间戳
			if((end_time - start_time) > 100) {
				break;//超时就放弃
			}
			Product product = productDao.getProductById(1);//获取库存和version,作为旧值
			if(product.getProductRepertory() > 0) {//如果库存大于0
				//如果库存大于0则证明可以购买,将要购买的商品id和旧值version发送到数据库作比较
				int result = productDao.updateProductRepertoryById(1, product.getVersion());
				if(result != 1) {
					//说明数据库的version已经被更改,即是在并发情况下被其它线程作了修改,因为旧version和数据库version已经匹配不上
					//当前线程就放弃此次操作
					continue;//失败就继续执行
				}
				//如果购买成功,即result==1,就插入相应的购买记录
				PurchaseRecord purchaseRecord = new PurchaseRecord();
				purchaseRecord.setRecordPerson(Thread.currentThread().getName());
				purchaseRecord.setRecordTime(new Date());
				purchaseRecord.setProduct(new Product(1, null, null,null));
				purchaseRecordDao.savePurchaseRecord(purchaseRecord);//增加购买记录
			}
		}
		sqlSession.commit();
		sqlSession.close();
	}
}

利用无限循环做重试,每个重试前验证是否超时。

执行结果

这里成功率是提高了,如果将时间戳再调大点(肯定不能一味的大,这里因为业务不复杂,所以线程执行快,失败率较高),成功率肯定更高。但是时间戳的弊端就是,随着系统自身的忙碌,而大大减少重入次数(假定循环体中逻辑很复杂,那么执行一次很慢),所以具体生产中只能择优选择。

乐观锁--用次数限制可重入

public class Execute implements Runnable {

	@Override
	public void run() {
		SqlSessionFactory sqlSessionFactory = MybatisFactory.getSqlSessionFactory();
		//注意这里要读已提交,避免B线程对version作更改为2的时候,还未commit,却被A读取到version为1。
		SqlSession sqlSession = sqlSessionFactory.openSession(TransactionIsolationLevel.READ_COMMITTED);
		ProductDao productDao = sqlSession.getMapper(ProductDao.class);
		PurchaseRecordDao purchaseRecordDao = sqlSession.getMapper(PurchaseRecordDao.class);
		for(int i=0;i<3;i++) {//限定重入次数
			Product product = productDao.getProductById(1);//获取库存和version,作为旧值
			if(product.getProductRepertory() > 0) {//如果库存大于0
				//如果库存大于0则证明可以购买,将要购买的商品id和旧值version发送到数据库作比较
				int result = productDao.updateProductRepertoryById(1, product.getVersion());
				if(result != 1) {
					//说明数据库的version已经被更改,即是在并发情况下被其它线程作了修改,因为旧version和数据库version已经匹配不上
					//当前线程就放弃此次操作
					continue;//失败就继续执行
				}
				//如果购买成功,即result==1,就插入相应的购买记录
				PurchaseRecord purchaseRecord = new PurchaseRecord();
				purchaseRecord.setRecordPerson(Thread.currentThread().getName());
				purchaseRecord.setRecordTime(new Date());
				purchaseRecord.setProduct(new Product(1, null, null,null));
				purchaseRecordDao.savePurchaseRecord(purchaseRecord);//增加购买记录
			}
			break;//如果库存不足或重试成功,跳出循环
		}
		sqlSession.commit();
		sqlSession.close();
	}
}

利用循环限定每个事务重试3此。

这里全部成功了(但是不能保证100%成功),而且也没超卖现象。观察购买记录中的时间,发现执行完成几乎在同一秒钟,说明哪怕加了重入机制的乐观锁,执行效率依然比悲观锁高不少。


在实际生产环境中,需要根据自身需求去决定用哪种方式,比如说,允许失败的情况下,让客户端手动重试,可以缓解大量的压力。在使用乐观锁的时候,就要选择可重入乐观锁。

当然,针对这一问题,还有更优的解决方式,就是利用中间件作为载体。简单点,就可以利用缓存,比如redis,分为两步

1、先利用redis快速响应客户端请求,并记录下用户的操作;

2、将记录下的用户操作及时(因为redis存储不稳定,所以要及时操作,比如可以利用定时任务不停的更新,更可靠的就是做好备份和容灾)的将用户的操作记录更新到数据库(这里就可以允许慢一点了);

当然,针对这一机制,还有专业的MQ中间件可以使用。

此篇完结

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值