高并发–【抢红包案例分析和代码实现以及各种方案的优缺点】之一中使用ssm+mysql实现,存在并发超发问题,这里我们使用悲观锁的方式来解决这个逻辑错误,并验证数据一致性和性能状况。
针对这个案例,用户抢到红包后,红包总量应-1,当多个用户同时抢红包,此时多个线程同时读得库存为n,相应的逻辑执行后,最后将均执update T_RED_PACKET set stock = stock - 1 where id = #{id}
,很明显这是错误的。
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)
,更新红包数量,最后提交事务。update T_RED_PACKET set stock = stock - 1, version = version + 1 where id = #{id} and version = #{version}
这样,保证了修改的数据是和它查询出来的数据是一致的,而其他线程并未进行修改。当然,如果更新失败,表示在更新操作之前有其他线程已经更新了该红包数,那么就可以尝试重入机制来保证更新成功。
悲观锁是在操作数据时,认为此操作会出现数据冲突,所以在进行每次操作时都要通过获取锁才能进行对相同数据的操作,所以悲观锁需要耗费较多的时间。另悲观锁是由数据库自己实现了的,使用的时候,直接调用数据库的相关语句即可。
由悲观锁涉及到的另外两个锁概念就出来了,它们就是共享锁与排它锁。共享锁和排它锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。
数据库的增删改操作默认都会加排他锁,而查询不会加任何锁。
共享锁指的就是对于多个不同的事务,对同一个资源共享同一个锁.
对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源(也可以再继续加共享锁,即 共享锁可多个共存),但无法修改。要想修改就必须等所有共享锁都释放完之后.
语法:
select * from table lock in share mode ;
排它锁与共享锁相对应,就是指对于多个不同的事务,对同一个资源只能有一把锁。对某一资源加排他锁,自身可以进行增删改查,其他人无法进行任何操作。
与共享锁类型,在需要执行的语句后面加上for update就可以了
语法:
select * from table for update
为了不影响上个版本,我们新加个接口方法和Mapper映射。 因为悲观锁是数据库提供的功能,所以仅仅在Dao层修改Sql,Service层无需新增新的接口,只需要切换下调用的Dao层的方法即可。
/**
* 获取红包信息. 悲观锁的实现方式
*
* @param id
* --红包id
* @return 红包具体信息
*/
public RedPacket getRedPacketForUpdate(Long id);
<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>
悲观锁是一种利用数据库内部机制提供的锁的方法,也就是对更新的数据加锁,这样在并发期间一旦有一个事务持有了数据库记录的锁,其他的线程将不能再对数据进行更新.
在 SQL 中加入的 for update 语句,意味着将持有对数据库记录的行更新锁(因为这里使用主键查询,所以只会对行加锁。如果使用的是非主键查询,要考虑是否对全表加锁的问题,加锁后可能引发其他查询的阻塞〉,那就意味着在高并发的场景下 , 当一条事务持有了这个更新锁才能往下操作,其他的线程如果要更新这条记录,都需要等待,这样就不会出现超发现象引发的数据一致性问题了.
将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 进行大量的上下文切换,目前比较普遍的是乐观锁机制。
https://github.com/yangshangwei/ssm_redpacket