Redis (三)|Redis分布式锁

Scroll Down

什么是分布式锁

分布式锁,也是一种锁机制,只不过是专门应对“分布式”的环境而出现的,它并不是一种全新的中间件或者组件,而只是一种机制,一种实现方式,甚至可以说是一种解决方案。
它指的是在分布式部署的环境下,通过锁机制让多个客户端或者多个服务进程互斥地对共享资源进行访问,从而避免出现并发安全、数据不一致等问题。

一、经典问题

正常流程:
前端用户要访问获取数据时,后端首先会在缓存Redis中查询。

1、如果能查询到数据,则直接将数据返回给用户,流程结束;
2、如果在缓存中没有查询到数据,则前往数据库中查询,此时如果能查询到数据,则将数据返回给用户,同时将数据塞入缓存中,流程结束
3、如果在数据库中没有查询到数据时,则返回Null,同时流程结束

1.1、缓存穿透

问题

根据以上数据,去数据查询,然后没查询出数据,最终返回NULL。

概念

如果前端频繁请求,数据库永远返回null,由于null不会存入缓存,导致每次都请求到数据库。假如是恶意攻击,这样就会对数据库极大压力,导致数据库崩溃。
这个过程就是缓存穿透的原因,顾名思义“永远越过缓存直接访问数据库”
  • 解决方案

查询到null数据直接将其插入缓存中,并且设置一个过期时间

1.2、缓存雪崩

  • 概念

某个时间点,缓存中的key集体时间过期,导致大量请求聚集在数据库,从而导致数据库崩溃

  • 原因

大量key在某个时间段集体失效

  • 解决方案

将这些key随机设置失效时间或者不同时效时间,错开时间段

1.3、缓存穿击

  • 概念
    缓存中某个key频繁访问(俗称热点key),在不停的高并发请求下,这时候没毛病,但是某一个瞬间,时间过期失效。从而导致高并发请求穿破缓存,导致数据库某个时刻压力暴增。(类似一张薄膜被凿出一个洞)

  • 原因
    热点key过期失效了,在实际工作中,这个key当做热点频繁访问

  • 解决方案
    可以设置晚上或者啥时候主动更新这个key就行,找空闲时候更新热点数据,设置key过期时间永不过期

二、为什么使用分布式锁

JVM中提供的synchronized和Lock锁都是JVM级别的,当运行一个Java程序时,会启动一个JVM进程来运行我们的应用程序。synchronized和Lock在JVM级别有效,也就是说,synchronized和Lock在同一Java进程内有效。

  • synchronized和Lock在JVM级别能够保证高并发程序的互斥

  • 当我们将应用程序部署成分布式架构,或者将应用程序在不同的JVM进程中运行时,synchronized和Lock就不能保证分布式架构和多JVM进程下应用程序的互斥性了

三、 概念

3.1、JVM锁原理

  • 在Java对象的对象头上,有一个锁的标记,
  • 比如,第一个线程执行程序时,检查Java对象头中的锁标记,发现Java对象头中的锁标记为未加锁状态,于是为Java对象进行了加锁操作,将对象头中的锁标记设置为锁定状态。
  • 第二个线程执行同样的程序时,也会检查Java对象头中的锁标记,此时会发现Java对象头中的锁标记的状态为锁定状态。于是,第二个线程会进入相应的阻塞队列中进行等待

3.2、什么是CAP

CAP 定律,又被叫作布鲁尔定律,在一个分布式系统中,
Consistency(一致性): 指的是:一个节点进行数据更新操作后,结果必须立即对其他所有节点可见,也就是线性一致性
Availability(可用性):用户对集群的任何读写操作都是成功的
Partition tolerance(分区容错性):集群因为某种原因产生分区后,各分区仍然能够独立对外提供服务。
三者不可兼得。
在分布式领域中,是必须要保证分区容错性的,也就是必须要保证“P”,所以,我们只能保证CP或者AP

3.3、BASE理论

BASE 理论是 eBay 架构师结合 CAP 定理与实际分布式应用设计总结出的新的理论,核心思想是,在满足分区容忍与适当的可用性的条件下,根据自身的业务特点,采用适当的方式来使系统达到最终一致性。
其中,“BASE”是“Basically Available(基本可用)”、“Soft State(软状态)”和“Eventually Consistent(最终一致性)”三个短语的缩写。
CAP 是一个定理,而 BASE 是一个理论。BASE 理论本质上是 CAP 定理满足 P 的条件下,在 C 和 A 之间找到一个符合特定系统实际情况的平衡。

四、场景

经典场面那就是12306购票时候

4.1、问题

买票需要优先选择车次,看是否有票无,有票才可以提交订单,保证同一时间只能售卖一次

  • 判断有无票
  • 下单

4.2、强一致性与完全可用性

  • 对于“检查是否有余票”这个操作来说,这个余票的数字(get num)不一定要求是最新的,比如实际只有一张票了,但是返回还有 2 张也没有关系,这个数据可以不是强一致性的。
  • 而对于“下单购买”这个操作来说,余票的数字就必须是最新的(get num & num–),此时数据要满足强一致性。
在春运抢票的时候,因为系统容量问题,无法承载所有请求,这个时候如果不进行特殊处理,那服务器一定会被打垮,这和是否 CAP 没有关系,CAP 并不能解决流量洪峰问题。所以只能对流量进行限流,保护应用服务器。而限流会造成用户的当前请求不可用,但并不会导致整个 APP 不可用,这就是一个基本可用的状态。
所以,强一致性和完全可用对于某些业务场景来说,并不是必需品。CAP 定理固然是一个事实,但是解决实际问题的时候,还是应该考虑实际情况。而 BASE 理论就是根据实际的业务诉求与 CAP 定理相结合而总结出来的。

五、常见实现方式

5.1、数据库

  • 悲观锁:操作和查询带一个version版本,通过version充当条件来控制每条数据的更新操作
update table set key=value,version=version+1 whre id=#{id} and version=#{version}
  • 乐观锁:一般InnoDB引擎下,加上forUpdate关键字表示当前线程被锁(行级锁、表锁)。只有提交事务后才会释放锁,别的线程才可以获取当前这条数据
select 列 from table for update

5.2、Redis原子性操作:通过redis提供的setnx与expire来实现。

  • setnx表示只有key在redis中不存在才可以设置成功。通常当前key需要设计成与共享资源有关,间接充当锁。
  • expire表示用来释放锁

5.3、Zookeeper互斥排他锁

这种机制主要是通过ZooKeeper在指定的标识字符串(通常这个标识字符串需要设计为跟共享资源有联系,即可以间接地当作“锁”)下维护一个临时有序的节点列表Node List,并保证同一时刻并发线程访问共享资源时只能有一个最小序号的节点(即代表获取到锁的线程),该节点对应的线程即可执行访问共享资源的操作。

5.4、开源框架Redission

这里就不阐述了,自己查下资料

六、Redis和Zookeeper

6.1、基于Redis的AP架构

在redis的AP架构中,向redis节点1写入数据,会立即返回结果,之后redis会以异步方式同步数据

6.2、基于Zookeeper的CP架构

在zookeeper中,向节点一写入数据,会等待同步结果,当数据大多数Zookeeper节点同步成功后,才会返回结果

七、注意事项

● 使用SETNX命令获取“锁”时,如果操作结果返回0(表示Key及对应的“锁”已经存在,即已经被其他线程所获取了),则获取“锁”失败,反之则获取成功。
● 为了防止并发线程在获取“锁”之后,程序出现异常情况,
从而导致其他线程在调用SETNX命令时总是返回0而进入死锁状态时,
需要为Key设置一个“合理”的过期时间。
● 当成功获取到“锁”并执行完成相应的操作之后,需要释放该“锁”。
可以通过执行 DEL命令将“锁”删除,
而在删除的时候还需要保证所删除的“锁”是当时线程所获取的,
从而避免出现误删除的情况!

八、实践代码

8.1、DB锁

  • 不加锁
SELECT
        <include refid="Base_Column_List"/>
        FROM user_account
        WHERE user_id=#{userId}

UPDATE user_account
        SET amount = amount - #{money}
        WHERE id = #{id}
  • 乐观锁
SELECT
        <include refid="Base_Column_List"/>
        FROM user_account
        WHERE user_id=#{userId}
        
 update user_account
        set amount = amount - #{money},
            version=version + 1
        where id = #{id}
          and version = #{version}
          and amount > 0
          and (amount - #{money}) >= 0
  • 悲观锁
 SELECT
        <include refid="Base_Column_List"/>
        FROM user_account
        WHERE user_id=#{userId} FOR UPDATE
        
   UPDATE user_account
        SET amount = amount - #{money}
        WHERE  id = #{id}
          and amount > 0
          and (amount - #{money}) >= 0        

8.2、redis

  • 发红包
public String handOut(RedPacketDto dto) throws Exception {
        if (dto.getTotal()>0 && dto.getAmount()>0){
            //生成随机金额
            List<Integer> list= RedPacketUtil.divideRedPackage(dto.getAmount(),dto.getTotal());

            //生成红包全局唯一标识,并将随机金额、个数入缓存
            String timestamp=String.valueOf(System.nanoTime());
            String redId = new StringBuffer(keyPrefix).append(dto.getUserId()).append(":").append(timestamp).toString();
            redisTemplate.opsForList().leftPushAll(redId,list);

            String redTotalKey = redId+":total";
            redisTemplate.opsForValue().set(redTotalKey,dto.getTotal());

            //异步记录红包发出的记录-包括个数与随机金额
            redService.recordRedPacket(dto,redId,list);

            return redId;
        }else{
            throw new Exception("系统异常-分发红包-参数不合法!");
        }
    }

逻辑:
生成随机金额,然后将随机金额以及个数存入缓存

  • 加分布式锁的情况 抢红包-分“点”与“抢”处理逻辑
public BigDecimal rob(Integer userId,String redId) throws Exception {
        ValueOperations valueOperations=redisTemplate.opsForValue();

        //用户是否抢过该红包
        Object obj=valueOperations.get(redId+userId+":rob");
        if (obj!=null){
            return new BigDecimal(obj.toString());
        }

        //"点红包"
        Boolean res=click(redId);
        if (res){
            //上锁:一个红包每个人只能抢一次随机金额;一个人每次只能抢到红包的一次随机金额  即要永远保证 1对1 的关系
            final String lockKey=redId+userId+"-lock";
            Boolean lock=valueOperations.setIfAbsent(lockKey,redId);
            redisTemplate.expire(lockKey,24L,TimeUnit.HOURS);
            try {
                if (lock) {

                    //"抢红包"-且红包有钱
                    Object value=redisTemplate.opsForList().rightPop(redId);
                    if (value!=null){
                        //红包个数减一
                        String redTotalKey = redId+":total";

                        Integer currTotal=valueOperations.get(redTotalKey)!=null? (Integer) valueOperations.get(redTotalKey) : 0;
                        valueOperations.set(redTotalKey,currTotal-1);


                        //将红包金额返回给用户的同时,将抢红包记录入数据库与缓存
                        BigDecimal result = new BigDecimal(value.toString()).divide(new BigDecimal(100));
                        redService.recordRobRedPacket(userId,redId,new BigDecimal(value.toString()));

                        valueOperations.set(redId+userId+":rob",result,24L,TimeUnit.HOURS);

                        log.info("当前用户抢到红包了:userId={} key={} 金额={} ",userId,redId,result);
                        return result;
                    }

                }
            }catch (Exception e){
                throw new Exception("系统异常-抢红包-加分布式锁失败!");
            }
        }
        return null;
    }
    

    /**
     * 点红包-返回true,则代表红包还有,个数>0
     * @throws Exception
     */
    private Boolean click(String redId) throws Exception{
        ValueOperations valueOperations=redisTemplate.opsForValue();

        String redTotalKey = redId+":total";
        Object total=valueOperations.get(redTotalKey);
        if (total!=null && Integer.valueOf(total.toString())>0){
            return true;
        }
        return false;
    }    

逻辑:
1、用户是否抢过该红包
2、读取redis判断是否还存在红包
3、上锁 (setIfAbsent)

final String lockKey=redId+userId+"-lock";
Boolean lock=valueOperations.setIfAbsent(lockKey,redId);
redisTemplate.expire(lockKey,24L,TimeUnit.HOURS);

4、随机读取redis取一个红包 (redis rightPop实现消息队列)
5、红包个数减少一个