使用乐观锁方式修复红包超发的bug

乐观锁

  乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现,设计里面由于不阻塞其他线程,所以并不会引发线程频繁挂起和恢复,这样便能够提高并发能力,也称之为非阻塞锁。 乐观锁使用的是 CAS原理。

  在 CAS 原理中,对于多个线程共同的资源,先保存一个旧(Old Value),比如进入线程后,查询当前存量为 100 个红包,那么先把旧值保存为 100,然后经过一定的逻辑处理。

    当需要扣减红包的时候,先比较数据库当前的值和旧值是否一致,如果一致则进行扣减红包的操作,否则就认为它已经被其他线程修改过了,不再进行操作。
  

  CAS 原理并不排斥并发,也不独占资源,只是在线程开始阶段就读入线程共享数据,保存为旧值。当处理完逻辑,需要更新数据的时候,会进行一次 比较,即比较各个线程当前共享的数据是否和旧值保持一致。如果一致,就开始更新数据;如果不一致,则认为该前共享的数据是否和旧值保持一致。如果一致,就开始更新数据;如果不一致,则认为该重试,这样就是一个可重入锁,但是 CAS 原理会有一个问题,那就是 ABA 问题,我们先来看下ABA问题

  ABA 问题的发生 , 是因为业务逻辑存在回退的可能性 。 如果加入一个非业务逻辑的属性,比如在一个数据中加入版本号( version ),对于版本号有一个约定,就是只要修改 X变量的数据,强制版本号( version )只能递增,而不会回退,即使是其他业务数据回退,它也会递增,那么 ABA 问题就解决了。

  RedPacketDao.java

/**
     * @Description: 扣减抢红包数. 乐观锁的实现方式
     * 
     * @param id
     *            -- 红包id
     * @param version
     *            -- 版本标记
     * 
     * @return: 更新记录条数
     */
    public int decreaseRedPacketForVersion(@Param("id") Long id, @Param("version") Integer version);

  RedPacket.xml

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

  在扣减红包的时候 , 增加了对版本号的判断,其次每次扣减都会对版本号加一,这样保证每次更新在版本号上有记录 , 从而避免 ABA 问题

  对于查询也不使用 for update 语句 , 避免锁的发生 , 这样就没有线程阻塞的问题了。 然后就可 以在类 UserRedPacketServic接口中新增方法 grapRedPacketForVersion,然后在其实现类中完成对应的逻辑即可。
  UserRedPacketServic接口及实现类的改造

/**
     * 保存抢红包信息. 乐观锁的方式
     * 
     * @param redPacketId
     *            红包编号
     * @param userId
     *            抢红包用户编号
     * @return 影响记录数.
     */
    public int grapRedPacketForVersion(Long redPacketId, Long userId);

  

*
     * 乐观锁,无重入
     * */
    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
    public int grapRedPacketForVersion(Long redPacketId, Long userId) {
        // 获取红包信息
        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("redpacket- " + redPacketId);
            // 插入抢红包信息
            int result = userRedPacketDao.grapRedPacket(userRedPacket);
            return result;
        }
        // 失败返回
        return FAILED;
    }

  解决因version导致失败问题

  为提高成功率,可以考虑使用重入机制 。 也就是一旦因为版本原因没有抢到红包,则重新尝试抢红包,但是过多的重入会造成大量的 SQL 执行,所以目前流行的重入会加入两种限制

  一种是按时间戳的重入,也就是在一定时间戳内(比如说 100毫秒),不成功的会循环到成功为止,直至超过时间戳,不成功才会退出,返回失败。
  一种是按次数,比如限定 3 次,程序尝试超过 3 次抢红包后,就判定请求失效,这样有助于提高用户抢红包的成功率。

  因为乐观锁造成大量更新失败的问题,使用时间戳执行乐观锁重入,是一种提高成功率的方法,比如考虑在 100 毫秒内允许重入,把 UserRedPacketServicelmpl 中的方法grapRedPacketForVersion 修改下

  

/*
     * 乐观锁,按时间戳重入
     * 
     * @Description: 乐观锁,按时间戳重入
     * 
     * @param redPacketId
     * @param userId
     * @return
     * 
     * @return: int
     */
    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
    public int grapRedPacketForVersion(Long redPacketId, Long userId) {
        // 记录开始时间
        long start = System.currentTimeMillis();
        // 无限循环,等待成功或者时间满100毫秒退出
        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;
            } else {
                // 一旦没有库存,则马上返回
                return FAILED;
            }
        }
    }

  之前大量失败的场景消失了,也没有超发现象 , 3 万次尝试抢光了所有的红包 , 避免了总是失败的结果,但是有时候时间戳并不是那么稳定,也会随着系统的空闲或者繁忙导致重试次数不一。有时候我们也会考虑、限制重试次数,比如 3 次,如下所示:

/**
     * 
     * 
     * @Title: grapRedPacketForVersion
     * 
     * @Description: 乐观锁,按次数重入
     * 
     * @param redPacketId
     * @param userId
     * 
     * @return: int
     */
    @Override
    @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;
    }

  通过 for 循环限定重试 3 次, 3 次过后无论成败都会判定为失败而退出 , 这样就能避免过多的重试导致过多 SQL 被执行的问题,从而保证数据库的性能.

  3 万次请求,所有红包都被抢到了 , 也没有发生超发现象,这样就可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。

 

 

posted on 2019-06-26 21:29  溪水静幽  阅读(374)  评论(0)    收藏  举报