高并发场景解决--抢红包

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_33764491/article/details/81083644

前言

高并发场景越来越多的应用在互联网业务上。

本文将重点介绍悲观锁、乐观锁、Redis分布式锁在高并发环境下的如何使用以及优缺点分析。

本文相关的学习项目–抢红包,欢迎Star.

三种方式介绍

悲观锁

悲观锁,假定会发生并发冲突,在你开始改变此对象之前就将该对象给锁住,直到更改之后再释放锁。

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

悲观锁的实现方式: SQL + FOR UPDATE

    <!--悲观锁-->
    <select id="getRedPacketForUpdate" parameterType="int" resultType="com.demo.entity.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>

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

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

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

于是频繁挂起,等待持有锁线程释放资源,一旦释放资源后,就开始抢夺,恢复线程,周而复始直至所有红包资源抢完。试想在高并发的过程中,使用悲观锁就会造成大量的线程被挂起和恢复,这将十分消耗资源,这就是为什么使用悲观锁性能不佳的原因。有些时候,我们也会把悲观锁称为独占锁,毕竟只有一个线程可以独占这个资源,或者称为阻塞锁,因为它会造成其他线程的阻塞。无论如何它都会造成并发能力的下降,从而导致CPU频繁切换线程上下文,造成性能低下。为了克服这个问题,提高并发的能力,避免大量线程因为阻塞导致CPU进行大量的上下文切换,程序设计大师们提出了乐观锁机制,乐观锁已经在企业中被大量应用了。

乐观锁。

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

扫描二维码关注公众号,回复: 3153168 查看本文章

它的实现思路是,在更新时会判断其他线程在这之前有没有对数据进行修改,一般用版本号机制。

读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提 交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据 版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

    <!--乐观锁-->
    <update id="decreaseRedPacketByVersion">
        update t_red_packet
        set
          stock = stock - 1,
          version = version + 1
        where
          id = #{id}
        and version = #{version}
    </update>

但是,仅仅这样是不行的,在高并发的情景下,由于版本不一致的问题,存在大量红包争抢失败的问题。为了提高抢红包的成功率,我们加入重入机制。

重入机制
  • 按时间戳重入(比如100ms时间内)
    示例代码:
        // 记录开始的时间
        long start = System.currentTimeMillis();

        // 无限循环,当抢包时间超过100ms或者成功时退出
        while(true) {
            // 循环当前时间
            long end = System.currentTimeMillis();
            // 如果抢红包的时间已经超过了100ms,就直接返回失败
            if(end - start > 100) {
                return FAILED;
            }
            ....

        }
  • 按次数重入(比如3次机会之内)
    示例代码:
        // 允许用户重试抢三次红包
        for(int i = 0; i < 3; i++) {
            // 获取红包信息, 注意version信息
            RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);

            // 如果当前的红包大于0
            if(redPacket.getStock() > 0) {
                // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
                int update = redPacketDao.decreaseRedPacketByVersion(redPacketId, redPacket.getVersion());
                // 如果没有数据更新,说明已经有其他线程修改过数据,则继续抢红包
                if(update == 0) {
                    continue;
                }
            ....
            }
            ...
        }

这样就可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。

Redis

我们知道当数据量非常大时,频繁的存取数据库,对于数据库的压力是非常大的。这时我们可以采用缓存技术,利用Redis的轻量级、便捷、快速的机制解决高并发问题。

这里写图片描述

通过流程图,我们看到整个流程与数据库交互只有两次,用户抢红包操作的过程其实都是在Redis中完成的,这显然提高了效率。

但是如何解决数据不一致带来的超发问题呢?

分布式锁

通俗的讲,分布式锁就是说,缓存中存入一个值(key-value),谁拿到这个值谁就可以执行代码。

在并发环境下,我们通过锁住当前的库存,来确保数据的一致性。知道信息存入缓存、库存-1之后,我们再重新释放锁。

为了防止死锁的发生,可以设置锁的过期时间来解决。

  • 加锁
// 先判断缓存中是否存在值,没有返回true,并保存value,已经有值就不保存,返回false
        if(stringRedisTemplate.opsForValue().setIfAbsent(key, value)) {
            return true;
        }
        String curentValue = stringRedisTemplate.opsForValue().get(key);

        // 如果锁过期
        if(!StringUtils.isEmpty(curentValue) && Long.parseLong(curentValue) < System.currentTimeMillis()) {
            // getAndSet设置新值,并返回旧值
            // 获取上一个锁的时间
            String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, value);
            if(!StringUtils.isEmpty(curentValue) && oldValue.equals(curentValue)) {
                return true;
            }
        }

        return false;
  • 解锁
try {
            String currentValue = stringRedisTemplate.opsForValue().get(key);
            if(!StringUtils.isEmpty(currentValue) && currentValue.equals(value)) {
                stringRedisTemplate.opsForValue().getOperations().delete(key);
            }
        } catch (Exception e) {
            logger.error("RedisLock 解锁异常:" + e.getMessage());
        }

总结

悲观锁使用了数据库的锁机制,可以消除数据不一致性,对于开发者而言会十分简单,但是,使用悲观锁后,数据库的性能有所下降,因为大量的线程都会被阻塞,而且需要有大量的恢复过程,需要进一步改变算法以提高系统的井发能力。

使用乐观锁有助于提高并发性能,但是由于版本号冲突,乐观锁导致多次请求服务失败的概率大大提高,而我们通过重入(按时间戳或者按次数限定)来提高成功的概率,这样对于乐观锁而言实现的方式就相对复杂了,其性能也会随着版本号冲突的概率提升而提升,并不稳定。使用乐观锁的弊端在于, 导致大量的SQL被执行,对于数据库的性能要求较高,容易引起数据库性能的瓶颈,而且对于开发还要考虑重入机制,从而导致开发难度加大。

使用Redis去实现高并发,消除了数据不一致性,并且在整个过程中尽量少的涉及数据库。但是这样使用的风险在于Redis的不稳定性,因为其事务和存储都存在不稳定的因素,所以更多的时候,建议使用独立Redis服务器做高并发业务,一方面可以提高Redis的性能,另一方面即使在高并发的场合,Redis服务器岩机也不会影响现有的其他业务,同时也可以使用备机等设备提高系统的高可用,保证网站的安全稳定。

以上讨论了3 种方式实现高并发业务技术的利弊,妥善规避风险,同时保证系统的高可用和高效是值得每一位开发者思考的问题。

参考

猜你喜欢

转载自blog.csdn.net/qq_33764491/article/details/81083644