上一篇中使用ssm+mysql实现,存在并发超发问题,这里我们使用悲观锁的方式来解决这个逻辑错误,并验证数据一致性和性能状况。
超发问题分析
针对这个案例,用户抢到红包后,红包总量应-1,当多个用户同时抢红包,此时多个线程同时读得库存为n,相应的逻辑执行后,最后将均执update T_RED_PACKET set stock = stock - 1 where id = #{id} ,很明显这是错误的。
使用数据库锁的解决方案
使用悲观锁(排它锁 for update)
- 线程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}
这样,保证了修改的数据是和它查询出来的数据是一致的,而其他线程并未进行修改。当然,如果更新失败,表示在更新操作之前有其他线程已经更新了该红包数,那么就可以尝试重入机制来保证更新成功。
总结
- 悲观锁使用了排他锁,当程序独占锁时,其他程序就连查询都是不允许的,导致吞吐较低。如果在查询较多的情况下,可使用乐观锁。
- 乐观锁更新有可能会失败,甚至是更新几次都失败,这是有风险的。所以如果写入较频繁,对吞吐要求不高,可使用悲观锁。
悲观锁(抽象的描述,不真实存在这个锁)
悲观锁是在操作数据时,认为此操作会出现数据冲突,所以在进行每次操作时都要通过获取锁才能进行对相同数据的操作,所以悲观锁需要耗费较多的时间。另悲观锁是由数据库自己实现了的,使用的时候,直接调用数据库的相关语句即可。
由悲观锁涉及到的另外两个锁概念就出来了,它们就是共享锁与排它锁。共享锁和排它锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。
数据库的增删改操作默认都会加排他锁,而查询不会加任何锁。
共享锁(S锁)
共享锁指的就是对于多个不同的事务,对同一个资源共享同一个锁.
对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源(也可以再继续加共享锁,即 共享锁可多个共存),但无法修改。要想修改就必须等所有共享锁都释放完之后.
语法:
select * from table lock in share mode ;
排他锁(X锁)
排它锁与共享锁相对应,就是指对于多个不同的事务,对同一个资源只能有一把锁。对某一资源加排他锁,自身可以进行增删改查,其他人无法进行任何操作。
与共享锁类型,在需要执行的语句后面加上for update就可以了
select * from table for update
修改代码
为了不影响上个版本,我们在mapper包下新增方法和和映射。 因为悲观锁是数据库提供的功能,所以仅仅在Dao层修改Sql,Service层无需新增新的接口,只需要切换下调用的Dao层的方法即可。
TRedPacketMapper.java
//悲观锁获取红包信息
TRedPacket getRedPacketForUpdate(Integer id);
TRedPacketMapper.xml
直接在原来的映射sql片段后加for update就行
事务传播机制参考:
https://www.cnblogs.com/baizhanshi/p/10425467.html
对于共享锁大家可能很好理解,就是多个事务只能读数据不能改数据,对于排他锁大家的理解可能就有些差别,我当初就犯了一个错误,以为排他锁锁住一行数据后,其他事务就不能读取和修改该行数据,其实不是这样的。排他锁指的是一个事务在一行数据加上排他锁后,其他事务不能再在其上加其他的锁。mysql InnoDB引擎默认的修改数据语句,update,delete,insert都会自动给涉及到的数据加上排他锁,select语句默认不会加任何锁类型,如果加排他锁可以使用select ...for update语句,加共享锁可以使用select ... lock in share mode语句。所以加过排他锁的数据行在其他事务种是不能修改数据的,也不能通过for update和lock in share mode锁的方式查询数据,但可以直接通过select ...from...查询数据,因为普通查询没有任何锁机制。
悲观锁是一种利用数据库内部机制提供的锁的方法,也就是对更新的数据加锁,这样在并发期间一旦有一个事务持有了数据库记录的锁,其他的线程将不能再对数据进行更新.
当使用select ... for update ...where ...时,mysql进行row lock还是table lock只取决于是否能使用索引(例如主键,unique字段),能则为行锁,否则为表锁;未查到数据则无锁。而 使用'<>','like'等操作时,索引会失效,自然进行的是table lock
在 SQL 中加入的 for update 语句,意味着将持有对数据库记录的行更新锁(因为这里使用主键查询,所以只会对行加锁。如果使用的是非主键查询,要考虑是否对全表加锁的问题,加锁后可能引发其他查询的阻塞〉,那就意味着在高并发的场景下 , 当一条事务持有了这个更新锁才能往下操作,其他的线程如果要更新这条记录,都需要等待,这样就不会出现超发现象引发的数据一致性问题了.
Service层调用新的Dao方法
UserRedPacketServiceImpl.java
TRedPacket tRedPacket = tRedPacketMapper.getRedPacketForUpdate(redPacketId);
此时超发问题解决,但是用时反而减小了,奇怪。。。
mysql中update语句带索引为行锁,不带索引为表锁
update条件更新方法解决超发问题
条件更新update语句执行时会先锁住数据,然后再进行判断和更新,其他进程只能读,但是我们所有的访问请求都是update,将查询改变为条件判断,所以可以避免超发问题。
TRedPacketMapper.java
//update条件语句更新
int updateByPanduan(Integer id);
TRedPacketMapper.xml
update t_red_packet set stock = if(stock>0,stock-1,stock) where id =
#{id,jdbcType=INTEGER}
但是这样我们无法获知某次请求是否成功,因为正常mysql中update之后如果数据没有改变就会返回受影响的条数:0,但是mybatis中jdbc返回的是匹配的操作数,所以要修改一下。
db.properties
jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/red_packet?useAffectedRows=true
jdbc.username=root
jdbc.password=suntong
http://www.cppcns.com/ruanjian/java/202830.html
此时记录条数正常,也没有超发,时间还可以接受。