【SSM抢红包简单项目】 ---- (三) 悲观锁和乐观锁消除超发问题

本文介绍了在SSM抢红包项目中如何利用悲观锁和乐观锁来解决超发问题。悲观锁通过`for update`避免并发更新,但可能导致性能下降;乐观锁采用CAS原理,通过版本号解决ABA问题,但在高并发下可能存在请求失败。此外,文中还探讨了乐观锁的重入机制,包括按时间戳和按次数的重入策略,以提高请求成功率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

1. 悲观锁
2. 乐观锁
3. 乐观锁重入机制的实现

超发问题分析
针对上篇文章中,用户抢到红包后,红包总量应-1,当多个用户同时抢红包,此时多个线程同时读得库存为n,相应的逻辑执行后,最后将均执update T_RED_PACKET set stock = stock - 1 where id = #{id} ,很明显这是错误的。

1.悲观锁(for update)

悲观锁是一种利用数据库内部机制提供的锁的方法,对更新的数据加锁, 在并发期间一旦有一个事务持有了数据库记录的锁,其他的线程将不再对数据进行更新了。

1.1 项目使用悲观锁操作

  • 线程1查询红包数时使用排他锁
    <!--查询红包具体信息-->
    <select id="getRedPacket" parameterType="long" resultType="cn.whc.pojo.RedPacket">
        select id, user_id as userId, amount, send_date as sendDate, total, unit_amount as unitAmount, stock,
        version, note from T_RED_PACKET where id = #{id} for update
    </select>
    
  • 然后进行后续操作(redPacketDao.decreaseRedPacket和userRedPacketDao.grapRedPacket)更新红包数量,最后提交事务
  • 线程2在查询红包数时,如果线程1还未释放排他锁,线程2将等待
  • 线程3同线程2,以此类推

1.2 共享锁(S锁) 和 排他锁(X锁)
共享锁和排他锁是悲观锁不同的实现,都属于悲观锁的范畴

数据库的增删改操作默认都会加排他锁,但查询不会加任何锁

共享锁: 指的是对于多个不同的事务,对同一资源共享同一个锁。对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源,但无法修改。
排他锁: 指对于多个不同的事务,对同一资源只能有一把锁。对某一资源加排他锁,自身可以进行增删改查,其他人无法进行任何操作。

1.3 悲观锁消除超发问题

在RedPacket.xml中查询红包具体信息后面添加for update语句,为查询增加行级锁,这样就不会出现超发现象引发数据不一致性问题了

<!--查询红包具体信息-->
    <select id="getRedPacketForUpdate" parameterType="long" resultType="cn.whc.pojo.RedPacket">
        select id, user_id as userId, amount, send_date as sendDate, total, unit_amount as unitAmount, stock,
        version, note from T_RED_PACKET where id = #{id} for update
    </select>

测试结果:
在这里插入图片描述
性能问题:
在这里插入图片描述
目前只对数据库加了一个锁,显示结果还不太明显,但是如果加得锁比较多的时候,数据库的性能会持续下降,原因如下:

  • 对于悲观锁来说,当一条线程抢占了资源后,其他的线程将得不到资源,这个时候,CPU就会将这些得不到资源的线程挂起,挂起的线程也会消耗CPU的资源,尤其是在高并发的请求中

2.乐观锁

乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现。
乐观锁使用的是CAS原理。

2.1 CAS原理
在CAS原理中,对于多个线程共同的资源,先保存一个旧值,比如进入线程后,查询当前存量为100个红包,那么先把旧值保存为100,然后经过一定的逻辑处理。当需要扣减红包的时候,先比较数据库当前的值和旧值是否一致,如果一致则进行扣减红包的操作,否则就认为它已经被其他线程修改过,不再进行操作

CAS可能会存在ABA问题,只要在数据中加入一个版本号的属性,就能解决ABA问题

2.2 乐观锁实现抢红包业务
规避CAS原理产生的ABA问题,需要先在红包表(T_RED_PACKET)加入一个新的列版本号(version),我们已经在一开始创建表的时候就建立了

<!--通过版本号扣减抢红包,每更新一次,版本增1,其次增加对版本号的判断-->
    <update id="decreaseRedPacketForVersion">
        update T_RED_PACKET
        set stock = stock - 1,
        version = version + 1
        where id = #{id}
        and version = #{version}
    </update>

在扣减红包的时候,增加了对版本号的判断,在每次扣减都会对版本号 + 1,保证每次更新在版本号上有记录,从而避免ABA问题。对于查询不使用for update语句,避免锁的发生,就没有线程阻塞问题了。

具体业务代码

  1. 在RedPacketDao接口加入对应的方法:

    /**
    	 * 通过对保存旧值版本号和数据库版本号匹配,扣减抢红包数
    	 * @param id 红包id
    	 * @param version 保存旧值的版本号
    	 * @return
    	 */
    	int decreaseRedPacketForVersion(@Param("id") Long id, @Param("version") Integer version);
    
  2. RedPacket.xml

    <!--通过版本号扣减抢红包,每更新一次,版本增1,其次增加对版本号的判断-->
        <update id="decreaseRedPacketForVersion">
            update T_RED_PACKET
            set stock = stock - 1,
            version = version + 1
            where id = #{id}
            and version = #{version}
        </update>
    
  3. UserRedPacketService接口加入方法:

    /**
    	 * 通过保本号保存抢红包信息
    	 * @param redPacketId 红包编号
    	 * @param userId 抢红包用户编号
    	 * @return 影响记录数
    	 */
    	int grapRedPacketForVersion(Long redPacketId, Long userId);
    
  4. UserRedPacketServiceImpl实现类

    @Service("userRedPacketService")
    public class UserRedPacketServiceImpl implements UserRedPacketService {
    
    	@Autowired
    	private UserRedPacketDao userRedPacketDao = null;
    
    	@Autowired
    	private RedPacketDao redPacketDao = null;
    
    	// 失败
    	private static final int FAILED = 0;
    
    	// 乐观锁
    	@Transactional(isolation = Isolation.READ_COMMITTED, propagation =  Propagation.REQUIRED)
    	public int grapRedPacketForVersion(Long redPacketId, Long userId) {
    		// 获取红包信息,注意version值
    		RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
    		// 当前小红包库存大于0
    		if (redPacket.getStock() > 0) {
    			// 再次传入线程保存的version旧值给SQL判断,是否有其它线程修改过数据
    			int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
    			// 如果没有数据更新,则说明其它线程已经修改过数据,则返回失败
    			if (update == 0) {
    				return FAILED;
    			}
    			// 生成抢红包信息
    			UserRedPacket userRedPacket = new UserRedPacket();
    			userRedPacket.setRedPacketId(redPacketId);
    			userRedPacket.setUserId(userId);
    			userRedPacket.setAmount(redPacket.getUnitAmount());
    			userRedPacket.setNote("抢红包" + redPacketId);
    			// 插入抢红包信息
    			int result = userRedPacketDao.grapRedPacket(userRedPacket);
    			return result;
    		} else {
    			// 失败返回
    			return FAILED;
    		}
    	}
    
    }
    

测试结果
在这里插入图片描述
从测试结果看出,经过3万次的抢夺,还存在大量红包因为版本并发的原因而没有被抢到,有时候容忍这个失败,取决于业务的需要。不过对于因为版本原因没有抢到红包,我们可以考虑用乐观锁重入机制。

3.乐观锁重入机制的实现

3.1 按时间戳的重入
在一定时间戳内,比如100毫秒,不成功会循环到成功为止,直到超过时间戳,不成功才会退出,返回失败。

业务代码

// 乐观锁重入机制
	@Transactional(isolation = Isolation.READ_COMMITTED, propagation =  Propagation.REQUIRED)
	public int grapRedPacketForVersion(Long redPacketId, Long userId) {
		// 记录开始时间
		long start = System.currentTimeMillis();
		while(true) {
			// 获取循环当前时间
			long end = System.currentTimeMillis();
			// 当前时间已超过100毫秒,返回失败
			if(end - start > 100) {
				return FAILED;
			}
			// 获取红包信息,注意version值
			RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
			// 当前小红包库存大于0
			if(redPacket.getStock() > 0) {
				// 再次传入线程保存的version旧值给SQL判断,是否有其它线程修改过数据
				int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
				// 如果没有数据更新,则说明其它线程已经修改过数据,则重新抢夺
				if(update == 0) {
					continue;
				}
				// 生成抢红包信息
				UserRedPacket userRedPacket = new UserRedPacket();
				userRedPacket.setRedPacketId(redPacketId);
				userRedPacket.setUserId(userId);
				userRedPacket.setAmount(redPacket.getUnitAmount());
				userRedPacket.setNote("抢红包" + redPacketId);
				// 插入抢红包信息
				int result = userRedPacketDao.grapRedPacket(userRedPacket);
				return result;
			}
			// 失败返回
			return FAILED;
		}
	}

测试结果
在这里插入图片描述
3.2 按次数
比如限定3次,程序尝试3次抢红包后,就判定请求失败

业务代码

// 通过重试次数提高乐观锁抢红包成功率
	@Transactional(isolation = Isolation.READ_COMMITTED, propagation =  Propagation.REQUIRED)
	public int grapRedPacketForVersion(Long redPacketId, Long userId) {
		for (int i = 0; i < 3; i++) {
			// 获取红包信息,注意version值
			RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
			// 当前小红包库存大于0
			if(redPacket.getStock() > 0) {
				// 再次传入线程保存的version旧值给SQL判断,是否有其它线程修改过数据
				int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
				// 如果没有数据更新,则说明其它线程已经修改过数据,则重新抢夺
				if(update == 0) {
					continue;
				}
				// 生成抢红包信息
				UserRedPacket userRedPacket = new UserRedPacket();
				userRedPacket.setRedPacketId(redPacketId);
				userRedPacket.setUserId(userId);
				userRedPacket.setAmount(redPacket.getUnitAmount());
				userRedPacket.setNote("抢红包" + redPacketId);
				// 插入抢红包信息
				int result = userRedPacketDao.grapRedPacket(userRedPacket);
				return result;
			} else {
				// 一旦没有库存,则马上返回
				return FAILED;
			}
		}
		// 失败返回
		return FAILED;
	}

测试结果
在这里插入图片描述
显然3万次请求,所有红包都被抢到了,也没有发生超发现象,这样就可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值