首页 > 其他分享 >因为一个“乐观锁”引发的一些思考以及一系列问题

因为一个“乐观锁”引发的一些思考以及一系列问题

时间:2022-08-13 19:14:06浏览次数:88  
标签:事务 goods 读取 引发 乐观 修改 version 思考 id

1、思考过程:

在讲“秒杀+分布式锁”解决商品超卖时候,我们并没有直接使用乐观锁的方式,而是采取了类似于乐观锁的一种解决方案,优化了SQL语句

超卖:

在多个用户同时发起对同一个商品的下单请求时,先查询商品库存,再修改商品库存,会出现资源竞争问题,导致库存的最终结果出现异常。比如,当商品A一共有库存15件,用户甲先下单10件,用户乙下单8件,这时候库存只能满足一个人下单成功,如果两个人同时提交,就出现了超卖的问题。

先附上代码以及表结构:

image-20220813120139433
//GoodsServiceImpl.java
@Transactional
public void doProcessSkill1(Integer userId, Integer goodsId) {
    //处理秒杀
    //1.减少库存
    int count = goodsMapper.updateTotalStocksAndUpdateTimeByGoodsId(goodsId);
    if (count > 0) {
        //库存更新成功(受影响行数大于0),插入订单
        addOrder(userId, goodsId);
    }
}
<update id="updateTotalStocksAndUpdateTimeByGoodsId">
    update seckill.goods
    set total_stocks=total_stocks - 1,
    update_time=NOW()
    where goods_id = # {goodsId,jdbcType=INTEGER}
    and total_stocks - 1 >= 0
</update>

在有人问到,没有使用乐观锁,老师的回答是相当于乐观锁。确实是相当于乐观锁,这种解决方式,SQL语句的优化写法看起来确实和乐观锁很相似,但我认为和乐观锁还是有区别的。

区别在于,乐观锁是当前线程在访问修改数据库字段的时候,会判断本次某个字段的值是否被其他线程修改了,也就是说,数据库的一个不可重复读的问题这里是个疑问点,下文会提到),而昨天课上采用的是判断本次查询的当前库存量-1是否<0,如果<0,那当然不符合这个过滤条件

事务执行过程中的并发问题(脏读、幻读、不可重复读)

  1. 脏读:事务A读取了事务B更新并且未提交的数据,然后B回滚操作,那么A读取到的数据是脏数据
  2. 不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果不一致。(一个事务对同一行数据重复读取两次,但是却得到了不同的结果)
  3. 幻读:事务A从一个表中读取了一个字段,然后B在该表中插入/删除了一些新的行。 之后, 如果 A 再次读取同一个表, 就会多/少几行,就好像发生了幻觉一样,这就叫幻读。

补充:不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表

  • 举个例子:本次采用乐观锁的方式如下所示

goods表增加一个字段version

public interface GoodsMapper {
    @Select("select * from goods where id=# {id}")
    Goods selectById(long id);
    //使用乐观锁(添加版本号条件和版本号增加)
    @Update("update goods set total_stocks=total_stocks- # {quantity}, version=version+1 where goods_id=#{goods_id} and version=# {version}")
    void descreaseStock(int goods_id, int quantity, long version);
}
@Service
public class GoodsServiceImpl implements GoodsService {
    ......
    //乐观锁方案
    @Transactional
    public boolean doProcessSkill1(int userId, int goodsId, int quantity) {
        //根据产品id判断库存是否够
        Goods goods=goodsMapper.selectById(goodsId);
        //如果库存不够,购买失败
        if(quantity>goods.getTotalStocks()) {
            return false;
        }
        //如果库存足够--减库存
        int result=goodsMapper.descreaseStock(goodsId,quantity,goods.getVersion());
        // 影响行数0,没修改成,代表版本号已经改变,已经并发,放弃本次修改
        System.out.println(result);
        if(result==0) {
            return false;
        }
        //增加购买记录
        addOrder(userId,product,quantity);
        return true;
    }
}
//先查询出商品的信息,包括库存以及版本等
Goods goods=goodsMapper.selectById(goodsId);  //时间节点A

//把查询出的版本号作为参数插入函数中
int result=goodsMapper.descreaseStock(goodsId,quantity,goods.getVersion());  //时间节点B(B=A+5)

//执行本条SQL
update goods set total_stocks=total_stocks- #{quantity}, version=version+1 where goods_id=#{goods_id} and version=#{version}

执行过程:上述代码是在一个事务中,假设,线程1在时间节点A查询数据库表中符合条件的元组属性version为1,若库存足够,接着便在时间节点B执行减库存操作,若中间(时间节点A~B)有其他线程先一步执行了减库存操作(减库存的同时修改了version,假定修改后version为2),那么线程1在执行减库存操作的时候,执行SQL:update goods set total_stocks=total_stocks- #{quantity}, version=version+1 where goods_id=#{goods_id} and version=#{version},根据SQL的update原理以及的执行顺序,线程1访问数据库表goods,拿到(读取)到表的原始数据(其中,我们想要修改的那个元组的属性version值为2,记住此字段值是已经被修改过了的),根据过滤条件where goods_id=#{goods_id} and version=#{version}#{version}是我们输入的值:goods.getVersion(),此时goods表中没有符合过滤条件的元组,自然也不会去修改我们需要修改的元组数据,也就是说数据库发现本次事务中version前后值不一致,是一个不可重复读的并发问题。(不可重复读:一个事务对同一行数据重复读取两次,但是却得到了不同的结果)

  • 乐观锁与悲观锁不一样,悲观锁是会通过数据库自身的锁机制来实现,从而保证数据操作的排它性,而乐观锁不采用数据库自身的锁机制,而是通过程序来实现的,所以悲观锁是真的有锁,数据、资源被锁死了,别人用不了,而乐观锁并不是真实存在的锁,而是在更新的时候判断此时的库存是否是之前查询出的库存,如果相同,表示没人修改,可以更新库存,否则表示别人抢过资源,不再执行库存更新。就好像是有一道校验门(过滤条件),校验通过,符合提交就能获得数据

SQL的update原理:先删除后添加

  • 或者是先查找再删除后添加(个人理解)

SQL中的Update语句,其实是执行了两步操作,Delete原来的一条记录,Insert新记录。因此在Update触发器中会出现两个临时表,即:Deleted和Inserted,分别存取删除的记录和更新的记录。

比如执行这个语句:update guestbook set password='123' where user_id=1000; 就是把用户号为1000的用户的密码改成123. 在SQL server执行的机制中,先是把user_id=1000的这条记录删除掉,然后再insert 一条语句。而不是DBMS先找到该条记录,就直接在上面修改某个字段的数据。

用SQL语句表示的话,如果你要执行 update guestbook set password='123' where user_id=1000; 其实是执行了下面的几条语句(一步一步的):

  • delete from guestbook where user_id=1000
  • insert into guestbook values(1000,'123') (假设这个表就这两个字段)

另外,在执行update语句的时候,即使更新的内容和原来记录一模一样,也是执行了上述操作,在有Update触发器的时候也会触发。为了数据的合理性,应该在设置触发器的时候做好判断,仅对于不同的记录做更新,相同的更新做处理。

注:此表述为update执行原理的浅层表述,深层表述请参考:

【Mysql】 update语句更新原理_Franco蜡笔小强的博客-CSDN博客_mysql update原理

而昨天的,where total_stock-1>0 and id=# {id},强调的是,本次查询中,数据库表某个元组的total_stock字段,如果total_stock-1<0,就不符合过滤条件,而不是强调了“乐观锁的思想:多个线程并发执行时,当前元组有没有被其他线程修改”,一个是自身不合符条件,一个是数据被改动而不符合条件。从上述两种解决超卖的方案代码中看见一般。

image-20220813173022081

image-20220813173130527

也就是说,乐观锁的实现在一个事务中,起码得两条SQL语句才能实现,前一条SQL查询出来得数据用于后一条SQL做更新操作时的参数。

综上所述,所以这就是为什么说,相当于乐观锁,而不就是乐观锁的原因所在。

标签:事务,goods,读取,引发,乐观,修改,version,思考,id
From: https://www.cnblogs.com/angelzheng/p/16583815.html

相关文章