redis(二)——分布式锁之Redis实现

目录

    • `Redis`实现分布式锁
      • 分布式锁应该具备哪些条件
      • 分布式锁的基本原理
      • 分布式锁的三种实现方式
      • 分布式锁之Redis实现
        • 加锁
        • 解锁
        • 实现
      • 可重入分布式锁
    • 实战
      • 使用本地锁
      • 使用本地锁在分布式下的问题
      • 分布式锁

Redis实现分布式锁

为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLockSynchronized)进行互斥控制。
在单机环境中,Java中提供了很多并发处理相关的API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!

redis(二)——分布式锁之Redis实现_第1张图片
上图可以看到,变量A存在JVM1、JVM2、JVM3三个JVM内存中(这个变量A主要体现是在一个类中的一个成员变量,是一个有状态的对象,例如:UserController控制器中的一个整形类型的成员变量),如果不加任何控制的话,变量A同时都会在JVM分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的!即使不是同时发过来,三个请求分别操作三个不同JVM内存区域的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的!
如果我们业务中确实存在这个场景的话,我们就需要一种方法解决这个问题!

分布式锁应该具备哪些条件

1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
2、高可用的获取锁与释放锁;
3、高性能的获取锁与释放锁;
4、具备可重入特性;
5、具备锁失效机制,防止死锁;
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

分布式锁的基本原理

redis(二)——分布式锁之Redis实现_第2张图片
我们可以同时去一个地方“占坑”,如果占到,就执行逻辑。否则就必须等待,直到释放锁。 “占坑”可以去redis,可以去数据库,可以去任何大家都能访问的地方。 等待可以自旋的方式。

分布式锁的三种实现方式

目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如:分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。

  • 基于数据库实现分布式锁;
  • 基于缓存(Redis等)实现分布式锁;
  • 基于Zookeeper实现分布式锁;

分布式锁之Redis实现

加锁

加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间。

SET lock_key random_value NX PX 5000
  • random_value 是客户端生成的唯一的字符串。
  • NX 代表只在键不存在时,才对键进行设置操作。
  • PX 5000 设置键的过期时间为5000毫秒。

演示分布式锁:只有一个会话能成功占坑
redis(二)——分布式锁之Redis实现_第3张图片
在这里插入图片描述

redis(二)——分布式锁之Redis实现_第4张图片
注意:

  • 设置锁和过期时间必须是一个原子操作,不能分开,因为可能会出现锁设置好了,但是在准备设置过期时间的时候,微服务宕机了,就会出现死锁。

解锁

解锁的过程就是将Key键删除。但也不能乱删,不能说客户端1的请求将客户端2的锁给删除掉。这时候random_value的作用就体现出来。
为了保证解锁操作的原子性,我们用LUA脚本完成这一操作。先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。

if redis.call('get',KEYS[1]) == ARGV[1] then 
   return redis.call('del',KEYS[1]) 
else
   return 0 
end

注意

  • 如果由于业务时间很长,锁自己过期了,我们直接删除,有可能把别人正在持有的锁删除了。
    解决:占锁的时候,值指定为random_value,每个人匹配是自己的锁才删除
  • 获取锁的值,对比成功删除(获取锁,判断成功删除锁,是两步,不是一个原子操作)。如果获取到锁的值,锁刚好过期,对比发现值是一样的,但是锁实际已经过期了,此时锁是其他线程的锁。那么我们删除的是其他线程的锁
    解决: 删除锁必须保证原子性。使用redis+Lua脚本完成

redis分布式锁,核心两处,加锁保证原子性,解锁保证原子性

实现

依赖:

<!--jedis-->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.0.0</version>
</dependency>
<!--lombok-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.16.18</version>
</dependency>

配置类以及IDUtil工具类:

@Configuration
public class RedisConfig {
    @Bean
    public JedisPool jedisPool(){
        return new JedisPool(new JedisPoolConfig(),"127.0.0.1",6379);
    }
}
public class IDUtil {
    //唯一id生成
    public static String getId() {
        // 取当前时间的长整形值包含毫秒
        long millis = System.currentTimeMillis();
        // 加上三位随机数
        Random random = new Random();
        int end3 = random.nextInt(999);
        // 如果不足三位前面补0
        String str = millis + String.format("%03d", end3);
        return str;
    }
    public static void main(String[] args) {
        System.out.println(IDUtil.getId());
    }
}

上锁以及解锁的类:

@Slf4j
@Component
public class RedisLock {
    //锁键
    private String lock_key = "redis_lock";
    //锁过期时间
    protected long internalLockLeaseTime = 30000;
    //获取锁的超时时间
    private long timeout = 10000;
    //SET命令的参数
    SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime);
    @Autowired
    JedisPool jedisPool;
    //redis密码
    String password="zhongguo";
	
	//加锁
    public boolean lock(String id){
        Jedis jedis = jedisPool.getResource();
        jedis.auth(password);
        Long start = System.currentTimeMillis();
        try{
            for(;;){
                //第一条线程进来设值,第二条线程进来
                // key="redis_lock"已经存在了,则不进行设值操作,
                // 往下执行,等待锁过期,如果一直都等不到锁,
                // 循环等待的时间大于了timeout,则直接返回false
                //SET命令返回OK ,则证明获取锁成功
                //设置锁和超时时间同一原子操作
                String lock = jedis.set(lock_key, id, params);
                if("OK".equals(lock)){
                    log.info("线程{}获取锁成功",Thread.currentThread().getName() );
                    return true;
                }
                //否则循环等待,在timeout时间内仍未获取到锁,则获取失败
                long l = System.currentTimeMillis() - start;
                if (l>=timeout) {
                    log.info("线程{}获取锁超时",Thread.currentThread().getName() );
                    return false;
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }catch (Exception e){
            log.error("获取锁失败:",e);
            new RuntimeException(String.format("当前线程{}获取锁失败",Thread.currentThread().getName()));
        }
        finally {
            jedis.close();
        }
        return false;
    }
    //解锁
    public boolean unlock(String id){
        Jedis jedis = jedisPool.getResource();
        jedis.auth(password);
        String script =
                "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                        "   return redis.call('del',KEYS[1]) " +
                        "else" +
                        "   return 0 " +
                        "end";
        try {
            //Collections.singletonList
            //返回一个只包含指定对象的不可变集合,但是这个长度的集合只有1,可以减少内存空间。
            //删除锁必须保证原子性。使用redis+Lua脚本完成
            Object result = jedis.eval(script, Collections.singletonList(lock_key),
                    Collections.singletonList(id));
            if("1".equals(result.toString())){
                log.info("线程{}释放锁成功",Thread.currentThread().getName() );
                return true;
            }
            log.info("线程{}释放锁失败",Thread.currentThread().getName() );
            return false;
        }catch (Exception e){
            log.error("释放锁失败:",e);
            new RuntimeException(String.format("当前线程{}释放锁失败",Thread.currentThread().getName()));
        }
        finally {
            jedis.close();
        }
        return false;
    }
}

测试:

@Slf4j
@Controller
public class IndexController {
    @Autowired
    RedisLock redisLock;
    
    int count = 0;
    
    @RequestMapping("/index")
    @ResponseBody
    public String index() throws InterruptedException {

        int clientcount =10;
        //减法计数器,总数为clientcount
        CountDownLatch countDownLatch = new CountDownLatch(clientcount);
		//线程池
        ExecutorService executorService = Executors.newFixedThreadPool(clientcount);
        long start = System.currentTimeMillis();
        for (int i = 0;i<clientcount;i++){
            executorService.execute(() -> {
            
                //通过Snowflake算法获取唯一的ID字符串
                String id = IDUtil.getId();
                try {
                    redisLock.lock(id);
                    //执行业务逻辑
                    count++;
                }catch (Exception e){
                    log.error("获取锁失败:",e);
                    new RuntimeException(String.format("当前线程{}获取锁失败",Thread.currentThread().getName()));
                }
                finally {
                    //解锁就是删除key,id是唯一的作用就体现出来了
                    //客户端A的请求删不掉客户端B的锁,因为id唯一
                    redisLock.unlock(id);
                }
                //数量减一
                countDownLatch.countDown();
            });
        }
        //所有线程阻塞在此
        //直到所有线程的任务执行完,计数器归0,才向下执行
        countDownLatch.await();
        long end = System.currentTimeMillis();
        log.info("执行线程数:{},总耗时:{},count数为:{}",clientcount,end-start,count);
        return "Hello";
    }
}

问题:
1.不具备锁不具有可重入特性。
2.如果业务逻辑的执行时间比锁的过期时间长,怎么办?

可重入分布式锁

可参考redisson可重入分布式锁的原理:
https://www.cnblogs.com/cjsblog/p/9831423.html
https://www.bilibili.com/video/BV1Xa4y1s79P?from=search&seid=4569538984682713544

实战

需求:查询三级菜单分类,先查询缓存,缓存有,则直接返回,缓存没有,先查数据数据库,在把数据放进缓存,在返回数据,需解决各种分布式环境下的问题,包括缓存问题。
不使用jedis,这里是springboot整合的redis,使用的是spring-boot-starter-data-redis

使用本地锁

使用本地锁实现需求

    @Override
    public Map> getCatalogJson() {
        /**
         * 1.空结果缓存:解决缓存穿透。
         * 2.设置过期时间(加随机值): 解决缓存雪崩的问题
         * 3.加锁:解决缓存击穿问题
         *      本地锁只能锁住当前进程,我们需要分布式锁
         */

        //加入缓存逻辑,缓存中存的数据是json字符串
        //json跨语言,跨平台兼容
        ValueOperations stringStringValueOperations = stringRedisTemplate.opsForValue();
        String catalogJson = stringStringValueOperations.get("catalogJson");

        //缓存为空
        if (StringUtils.isEmpty(catalogJson)) {
            System.out.println("缓存没命中,查询数据库");
            //==========================================================================
            //缓存中没有,就查询数据库
            //加锁:解决缓存击穿问题
            Map>  catalogJsonFromDb= getCatalogJsonFromDb();
            //==========================================================================
            //查询菜单这个业务,不存在缓存穿透的问题,因为查询菜单不需要入参,所以不需要将空结果缓存
            return catalogJsonFromDb;
        }

        System.out.println("缓存命中,直接返回");
        //缓存不为空,把json数据转为对象
        Map> stringListMap = JSON.parseObject(catalogJson, new TypeReference>>() {
        });

        return stringListMap;
    }
    //从数据库查询并封装分类数据
    public Map> getCatalogJsonFromDb() {
         //本地锁: synchronized、JUC(Lock)。(分布式情况下,想要锁住所有,必须使用分布式锁)
        //==========================================================================
        synchronized (this) {
            //当前线程获取到锁后,应该在确认一下,之前是不是已经有线程获取到锁,查询db并把缓存已经设置上了
            ValueOperations stringStringValueOperations = stringRedisTemplate.opsForValue();
            String catalogJson = stringStringValueOperations.get("catalogJson");
            //已经有缓存了
            if (!StringUtils.isEmpty(catalogJson)) {
                //缓存不为空,把json数据转为对象
                Map> stringListMap = JSON.parseObject(catalogJson, new TypeReference>>() {
                });
                return stringListMap;
            }
            System.out.println("查询了数据库...");

            //直接出所有分类
            List selectList = baseMapper.selectList(null);

            //查出所有一级分类
            List level1Categorys = getParent_cid(selectList, 0L);

            //封装数据
            Map> parentCid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
                //每一个一级分类,查其二级分类
                List categoryEntities = getParent_cid(selectList, v.getCatId());
                //封装上面的结果
                List catelog2Vos = null;
                if (categoryEntities != null) {
                    catelog2Vos = categoryEntities.stream().map(l2 -> {
                        //二级分类
                        Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());
                        //找到当前二级分类的三级分类封装成vo
                        List categoryEntities1 = getParent_cid(selectList, l2.getCatId());
                        if (categoryEntities1 != null) {
                            List collect = categoryEntities1.stream().map(l3 -> {
                                //三级分类
                                Catelog2Vo.Category3Vo category3Vo =
                                        new Catelog2Vo.Category3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName());
                                return category3Vo;
                            }).collect(Collectors.toList());
                            //将三级分类封装到二级分类中
                            catelog2Vo.setCatalog3List(collect);
                        }
                        return catelog2Vo;
                    }).collect(Collectors.toList());

                }
                return catelog2Vos;
            }));

            //查询到数据,再放入缓存,先将对象转为json
            String string = JSON.toJSONString(parentCid);
            //==========================================================================
            //放入缓存,并设置随机的过期时间,解决缓存雪崩的问题
            Random random = new Random();
            int timeout = random.nextInt(9) + 1;
            stringStringValueOperations.set("catalogJson", string, timeout, TimeUnit.HOURS);

            return parentCid;
        }
    }

锁时序问题:本地锁需要将确认缓存是否存在,查询数据库,结果放入缓存,这三步锁起来。如果结果放入缓存这一步没有锁起来,就会出现锁时序问题,比如,并发访问时,一条线程进来获取到锁,并确认没缓存,查询了数据库,然后把锁释放了,但是在它,将结果放入缓存之前,另一个并发进来的线程抢到锁,此时,它发现缓存也是没有的,又查询了一次数据库,这就不符合我们的预期了,我们预期是并发访问,只有一条线程能查询数据库,其他全走缓存。因此,也要把结果放入缓存这一步,也要锁起来

redis(二)——分布式锁之Redis实现_第5张图片
使用Jmeter,压力测试该服务:只查询了一次数据库,其他全走,缓存,符合预期。

redis(二)——分布式锁之Redis实现_第6张图片

使用本地锁在分布式下的问题

复制三个当前服务
redis(二)——分布式锁之Redis实现_第7张图片
启动参数,把端口改一改
redis(二)——分布式锁之Redis实现_第8张图片
启动,就相当于,当前微服务启动了4个,由nginx将请求转发到网关,在由网关负载均衡到这四个服务。

Jmeter压测:
设置线程组
redis(二)——分布式锁之Redis实现_第9张图片
HTTP请求设置
redis(二)——分布式锁之Redis实现_第10张图片
发现同一个微服务的不同节点,每一个都去查了一次数据库:
redis(二)——分布式锁之Redis实现_第11张图片
redis(二)——分布式锁之Redis实现_第12张图片
这就说明了本地锁,只能锁住当前进程

分布式锁

使用分布式锁实现上面的需求。

   @Override
    public Map> getCatalogJson() {
        /**
         * 1.空结果缓存:解决缓存穿透。
         * 2.设置过期时间(加随机值): 解决缓存雪崩的问题
         * 3.加锁:解决缓存击穿问题
         *      本地锁只能锁住当前进程,我们需要分布式锁
         */

        //加入缓存逻辑,缓存中存的数据是json字符串
        //json跨语言,跨平台兼容
        ValueOperations stringStringValueOperations = stringRedisTemplate.opsForValue();
        String catalogJson = stringStringValueOperations.get("catalogJson");

        //缓存为空
        if (StringUtils.isEmpty(catalogJson)) {
            //缓存中没有,就查询数据库
            /加锁:解决缓存击穿问题
            //本地锁: synchronized、JUC(Lock),分布式情况下,想要锁住所有,必须使用分布式锁
            System.out.println("缓存没命中,查询数据库");
            //使用本地锁
			//Map>  catalogJsonFromDb= getCatalogJsonFromDb();
           //使用分布式锁
            Map>  catalogJsonFromDb= getCatalogJsonFromDbWithRedisLock();
            //***查询菜单这个业务,不存在缓存穿透的问题,因为查询菜单不需要入参,所以不需要将空结果缓存
            return catalogJsonFromDb;
        }

        System.out.println("缓存命中,直接返回");
        //缓存不为空,把json数据转为对象
        Map> stringListMap = JSON.parseObject(catalogJson, new TypeReference>>() {
        });

        return stringListMap;
    }
    //分布式锁查询数据库
  	public Map> getCatalogJsonFromDbWithRedisLock() {
        //占分布式锁。去redis占锁
        String uuid = UUID.randomUUID().toString();
        //setIfAbsent如果键不存在则新增,存在则不改变已经有的值。
        //设置值和超时时间,保证加锁是原子性的
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);

        //上锁成功
        if (lock){
            System.out.println("获取分布式锁成功...");
            Map> dataFromDb;
            try {
                //执行业务逻辑(查数据库的方法,不码出来了)
                dataFromDb= getDataFromDb();
            }finally {
                //lua脚本解锁
                String script="if redis.call('get',KEYS[1]) == ARGV[1] then \n" +
                        "   return redis.call('del',KEYS[1]) \n" +
                        "else\n" +
                        "   return 0 \n" +
                        "end";
                //删除锁,保证原子性
                Long lock1 = stringRedisTemplate.execute(new DefaultRedisScript(script, Long.class),
                        Arrays.asList("lock"), uuid);
            }
            return dataFromDb;
        }else{
            System.out.println("获取分布式锁失败...等待重试");
            //加锁失败...重试
            //休眠
            try {
                Thread.sleep(100);
            }catch (Exception e){
                e.printStackTrace();
            }
            //自旋锁
            return getCatalogJsonFromDbWithRedisLock();
        }
    }

分布式锁可以封装工具类,但是分布式锁有更专业的框架!

附录:
https://blog.csdn.net/wuzhiwei549/article/details/80692278
https://www.jianshu.com/p/47fd7f86c848

你可能感兴趣的:(redis,分布式系统)