悲观锁,乐观锁,redis+lua分布式锁的数据库案例讲解

超发问题分析

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

锁的概念

悲观锁(包括 排它锁,分享锁 for update)

悲观锁是一个统称的概念
它的特点是先获取锁,再进行业务操作,即“悲观”的认为获取锁是非常有可能失败的,因此要先确保获取锁成功再进行业务操作。通常所说的“一锁二查三更新”即指的是使用悲观锁。作用就是线程中的同步,同java中的synchronized。

通常来讲在数据库上的悲观锁需要数据库本身提供支持,即通过常用的select … for update操作来实现悲观锁。当数据库执行select for update时会获取被select中的数据行的行锁,因此其他并发执行的select for update如果试图选中同一行则会发生排斥(需要等待行锁被释放),因此达到锁的效果。select for update获取的行锁会在当前事务结束时自动释放,因此必须在事务中使用。

这里需要注意的一点是不同的数据库对select for update的实现和支持都是有所区别的,例如oracle支持select for update no wait,表示如果拿不到锁立刻报错,而不是等待,mysql就没有no wait这个选项。另外mysql还有个问题是select for update语句执行中所有扫描过的行都会被锁上,这一点很容易造成问题。因此如果在mysql中用悲观锁务必要确定走了索引(索引查询),而不是全表扫描,全表扫描悲观锁会把整个表锁住,造成其他任何查询级别之上的操作都需要等待,性能影响特别大。

由悲观锁涉及到的另外两个锁概念就出来了,它们就是共享锁与排它锁。共享锁和排它锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。

数据库的增删改操作默认都会加排他锁,而查询不会加任何锁。下面的sql实现的锁主要针对mysql,其他版本是的数据库不一定支持

排他锁(X锁)

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

与共享锁类型,在需要执行的语句后面加上for update就可以了

语法:

select * from table for update

共享锁(S锁)

共享锁指的就是对于多个不同的事务,对同一个资源共享同一个锁.

对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源(也可以再继续加共享锁,即 共享锁可多个共存),但无法修改。要想修改就必须等所有共享锁都释放完之后.

语法:

select * from table lock in share mode ;

使用排他锁,这样流程如下:

线程1在查询红包数时使用排他锁 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
然后进行后续的操作(redPacketDao.decreaseRedPacket 和 userRedPacketDao.grapRedPacket),更新红包数量,最后提交事务。
线程2在查询红包数时,如果线程1还未释放排他锁,它将等待
线程3同线程2,依次类推

乐观锁(依靠表的设计和代码)

在红包表添加version版本字段或者timestamp时间戳字段,这里我们使用version
线程1查询后,执行更新变成了update T_RED_PACKET set stock = stock - 1, version = version + 1 where id = #{id} and version = #{version}
这样,保证了修改的数据是和它查询出来的数据是一致的,而其他线程并未进行修改。当然,如果更新失败,表示在更新操作之前有其他线程已经更新了该红包数,那么就可以尝试重入机制来保证更新成功。

** 总结**
1.悲观锁使用了排他锁,当程序独占锁时,其他程序就连查询都是不允许的,导致吞吐较低。如果在查询较多的情况下,可使用乐观锁。
2.乐观锁更新有可能会失败,甚至是更新几次都失败,这是有风险的。所以如果写入较频繁,对吞吐要求不高,可使用悲观锁。

代码改造
分析
为了不影响上个版本,我们新加个接口方法和Mapper映射。 因为悲观锁是数据库提供的功能,所以仅仅在Dao层修改Sql,Service层无需新增新的接口,只需要切换下调用的Dao层的方法即可。

RedPacketDao新增接口方法
/**
* 获取红包信息. 悲观锁的实现方式
*
* @param id
* --红包id
* @return 红包具体信息
*/
public RedPacket getRedPacketForUpdate(Long id);

RedPacket.xml配置映射文件

	<!-- 查询红包具体信息  悲观锁的实现方式for update -->
	<select id="getRedPacketForUpdate" parameterType="long"
		resultType="com.artisan.redpacket.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>

悲观锁是一种利用数据库内部机制提供的锁的方法,也就是对更新的数据加锁,这样在并发期间一旦有一个事务持有了数据库记录的锁,其他的线程将不能再对数据进行更新(Mysql中体现在行锁和表锁上).

在 SQL 中加入的 for update 语句,意味着将持有对数据库记录的行更新锁(因为这里使用主键查询,所以只会对行加锁。如果使用的是非主键查询,要考虑是否对全表加锁的问题,加锁后可能引发其他查询的阻塞〉,那就意味着在高并发的场景下 , 当一条事务持有了这个更新锁才能往下操作,其他的线程如果要更新这条记录,都需要等待,这样就不会出现超发现象引发的数据一致性问题了.

Service层调用新的Dao方法

还原数据,部署测试
将T_RED_PACKET和T_USER_RED_PACKET中的数据还原为初始数据后,启动应用,通过FireFox 访问 http://localhost:8080/ssm_redpacket/grap.jsp

统计报告
一致性数据统计:

SELECT
	a.id,
	a.amount,
	a.stock
FROM
	T_RED_PACKET a
WHERE
	a.id = 1
UNION ALL
	SELECT
		max(b.user_id),
		sum(b.amount),
		count(*)
	FROM
		T_USER_RED_PACKET b
	WHERE
		b.red_packet_id = 1;

这里已经解决了超发的问题,所以结果是正确的,最起码逻辑是正确的了。除了结果正确,我们还需要考虑性能问题,统计来看下

性能数据统计:

SELECT
	(
		UNIX_TIMESTAMP(max(a.grab_time)) - UNIX_TIMESTAMP(min(a.grab_time)) 
	)  AS lastTime
FROM
	T_USER_RED_PACKET a;

注意事项
不使用悲观锁时,2万个红包190秒【主机配置很低】抢完(但存在超发现象),现在是275秒。 目前只是对数据库加了一个锁,当加的锁比较多的时候,数据库的性能还会持续下降,所以要区分不同的业务场景,慎重使用。

悲观锁导致性能下降的原因探究

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

!

只能有一个事务占据资源,其他事务被挂起等待持有资源的事务提交并释放资源。当此时就进入了线程 2 , 线程 3……线程n,开始抢夺资源的步骤了,这里假设线程 3 抢到资源

!
一旦线程1 提交了事务,那么锁就会被释放,这个时候被挂起的线程就会开始竞争红包资源,那么竞争到的线程就会被 CPU 恢复到运行状态,继续运行。

于是频繁挂起,等待持有锁线程释放资源, 一旦释放资源后,就开始抢夺,恢复线程,直至所有红包资源抢完。

在高并发的过程中,使用悲观锁就会造成大量的线程被挂起和恢复,这将十分消耗资源,这就是为什么使用悲观锁性能不佳的原因。

有些时候,我们也会把悲观锁称为独占锁,毕竟只有一个线程可以独占这个资源,或者称为阻塞锁,因为它会造成其他线程的阻塞。无论如何它都会造成并发能力的下降,从而导致 CPU频繁切换线程上下文,造成性能低下。

为了克服这个问题,提高并发的能力,避免大量线程因为阻塞导致 CPU 进行大量的上下文切换,目前比较普遍的是乐观锁机制。

redis调用lua脚本

Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。使用脚本的好处如下:

1.减少网络开销:本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。
2.原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入(java等客户端则会执行多次命令完成一个业务,违反了原子性操作)。
3.复用:客户端发送的脚本会永久存储在Redis中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑。

这里着重看第二条,相比客户端调用redis命令,一个业务流程可能要设计到读取,修改两个操作命令,而这个过程中间有可能被其他线程执行了修改命令,那么这个操作就不具有原子性操作。而redis执行lua脚本是在同一个线程中独占方式执行,期间没有其他线程占用(redis本身是单线程的特点),因此具有天然的原子性,同事一次性执行性能开销很小,相比多条命令要好很多。

@Autowired
	private RedisTemplate redisTemplate;

	@Autowired
	private RedisRedPacketService redisRedPacketService;

	// Lua脚本
	String script = "local listKey = 'red_packet_list_'..KEYS[1] \n" 
			+ "local redPacket = 'red_packet_'..KEYS[1] \n"
			+ "local stock = tonumber(redis.call('hget', redPacket, 'stock')) \n" 
			+ "if stock <= 0 then return 0 end \n"
			+ "stock = stock -1 \n" 
			+ "redis.call('hset', redPacket, 'stock', tostring(stock)) \n"
			+ "redis.call('rpush', listKey, ARGV[1]) \n" 
			+ "if stock == 0 then return 2 end \n" 
			+ "return 1 \n";

	// 在缓存LUA脚本后,使用该变量保存Redis返回的32位的SHA1编码,使用它去执行缓存的LUA脚本[加入这句话]
	String sha1 = null;

	@Override
	public Long grapRedPacketByRedis(Long redPacketId, Long userId) {
		// 当前抢红包用户和日期信息
		String args = userId + "-" + System.currentTimeMillis();
		Long result = null;
		// 获取底层Redis操作对象
		Jedis jedis = (Jedis) redisTemplate.getConnectionFactory().getConnection().getNativeConnection();
		try {
			// 如果脚本没有加载过,那么进行加载,这样就会返回一个sha1编码
			if (sha1 == null) {
				sha1 = jedis.scriptLoad(script);
			}
			// 执行脚本,返回结果
			Object res = jedis.evalsha(sha1, 1, redPacketId + "", args);
			result = (Long) res;
			// 返回2时为最后一个红包,此时将抢红包信息通过异步保存到数据库中
			if (result == 2) {
				// 获取单个小红包金额
				String unitAmountStr = jedis.hget("red_packet_" + redPacketId, "unit_amount");
				// 触发保存数据库操作
				Double unitAmount = Double.parseDouble(unitAmountStr);
             	redisRedPacketService.saveUserRedPacketByRedis(redPacketId, unitAmount);
			}
		} finally {
			// 确保jedis顺利关闭
			if (jedis != null && jedis.isConnected()) {
				jedis.close();
			}
		}
		return result;
	}

这里使用了保存脚本返回 的 SHAl 字符串 ,所以只会发送一次脚本到 Redis 服务器,之后只传输 SHAl 字符串和参数到 Redis 就能执行脚本 了, 当脚本返回为 2 的时候, 表示此时所有的红包都已经被抢光了 ,那么就会触发 redisRedPacketService 的 saveUserRedPacketByRedis 方法。由于在 saveU serRedPacketByRedis 加入注解@Async , 所以 Spring 会创建一条新的线程去运行它 , 这样就不会影响最后抢一个红包用户 的响应时间了 。

lua脚本实现redis分布式锁

setnx 如果key不存在则添加值并返回1,如果已经存在key则返回0

加锁
使用业务setnx(key,业务流水号)当加锁成功返回1时设置过期时间,避免业务异常没有解锁时防止死锁
当同一业务再次申请锁时,如果随机值相同 则认为是重试,则直接设置过期时长;如果随机值不同则直接返回0,获取锁失败

解锁
业务完成直接del(key)完成

lock.lua

-- Set a lock
--  如果获取锁成功,则返回 1
local key     = KEYS[1]
local content = ARGV[1]
local ttl     = tonumber(ARGV[2])
local lockSet = redis.call('setnx', key, content)
if lockSet == 1 then
  redis.call('PEXPIRE', key, ttl)
else
  -- 如果value相同,则认为是同一个线程的请求,则认为重入锁
  local value = redis.call('get', key)
  if(value == content) then
    lockSet = 1;
    redis.call('PEXPIRE', key, ttl)
  end
end
return lockSet

unlock.lua

-- unlock key
local key     = KEYS[1]
local content = ARGV[1]
local value = redis.call('get', key)
if value == content then
  return redis.call('del', key)
else
    return 0
end


测试加锁和解锁

redis-cli  --eval lock.lua lo3 , 2 60000

redis-cli  --eval unlock.lua lo3 , 2

在java代码中我们可以使用AOP获取当前业务的key,业务主键实现加锁,如果一旦业务异常 那么在超时后自动解锁


作者:小小工匠
来源:CSDN
原文:https://blog.csdn.net/yangshangwei/article/details/82980659
版权声明:本文为博主原创文章,转载请附上博文链接!

猜你喜欢

转载自blog.csdn.net/zjcjava/article/details/84851850
今日推荐