正确的实现redis分布式锁

mac2025-12-03  11

目录

概述

什么是分布式,什么是分布式锁,为什么使用分布式锁

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

分布式锁应用案例和效率分析

redis实现分布式原理

redis实现分布式锁方法:

第一种加锁(错误),使用setnx,和del(String key)。

第二种加锁(错误),使用setnx,和del和expire。

第三种加锁(错误),使用setnx,和del和getSet。

第四种加锁(错误),使用set,加Lua脚本

第五种加锁(正确),就是给第四种方法加守护线程。实现起来比较复杂,但已有人开发为工具就是Redisson:  Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。【Redis官方推荐】,

解锁一(错误)

解锁二,(错误)

解锁三,(正确)


概述

小编最近学习redis实现分布式,看了广大网友的博客,学习完后,发现有一半以上的博客redis分布式锁是有错误的。所以写此文章让大家学习正确的redis分布式锁。本文会讲用setnx,和set等五种加锁方法和三种解锁方法,并指出其错误点。

什么是分布式,什么是分布式锁,为什么使用分布式锁

1.什么是分布式?来一张图

当大量用户请求服务器时,如果一个服务器去相应的话,那么这台服务器的压力会特别大,有可能会崩溃,最好的方式是分发给此刻压力较小的服务器去处理。这个服务器可以是单独一台电脑,也可以同一台电脑中的其他进程。但是他们的读数据都是从数据库中读。

2.什么是分布式锁,为什么使用分布式锁,如下场景

假如双11有一个秒杀活动,秒杀100件短袖,同一秒有上万人秒杀,中转服务器将上万个请求散发给处理服务器们,他们处理时最重要的业务(比如删除修改)同一时刻只能一个服务器处理,好比如单机下给一个方法加synchronized,否则就会出现多卖的情况。这时候就要给最重要的业务或方法加分布式锁。保证不会出现多卖情况

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

在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行高可用的获取锁与释放锁高性能的获取锁与释放锁具备可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误)具备锁失效机制,防止死锁具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败

分布式锁应用案例和效率分析

场景:假如双11有一个秒杀活动,秒杀100件短袖,同一秒有上万人秒杀,

方法:由于是单机情况,所以我用10000线程(模拟进程)同一时刻去提交抢购短袖。

package com.util.test; public class TenProgress implements Runnable{ Seriver seriver; public TenProgress(Seriver seriver) { this.seriver = seriver; } public void start(){ new Thread(this,"秒杀线程").start(); } @Override public void run() { for(int i = 0; i < 10000; i++){ new Thread(new seckill("短袖"), "秒杀线程" + i).start(); } } class seckill implements Runnable{ String killName; public seckill(String Killname) { this.killName = Killname; } @Override public void run() { seriver.order1(killName); } } } public class Seriver { //商品总数 private static HashMap<String, Integer> product = new HashMap<>(); //订单表 private static HashMap<String, String> orders = new HashMap<>(); //库存表 private static HashMap<String, Integer> stock = new HashMap<>(); static { product.put("短袖", 100); stock.put("短袖", 100); } //开启抢购线程 public void startKill() { TenProgress progress = new TenProgress(this); progress.start(); } public void select_info(String product_id,String id) { System.out.println("限量抢购商品XXX共" + product.get(product_id) + ",现在成功下单" + orders.size() + ",剩余库存" + stock.get(product_id) + "件,当前" + "订单号:" + id); } //加reids分布式锁 public void order1(String product_id) { String value = System.currentTimeMillis() + 2000 + ""; if(redis.lock4(product_id, value)){ if (stock.get(product_id) == 0) { System.out.println(Thread.currentThread().getName() + "没有抢到"); } else { String str = UUID.randomUUID().toString(); orders.put(UUID.randomUUID().toString(), product_id); stock.put(product_id, stock.get(product_id) - 1); select_info(product_id,str); } redis.unlock3(product_id, value); } else{ System.out.println(Thread.currentThread().getName() + "没有抢到"); } } //加synchronized锁 public synchronized void order2(String product_id) { if (stock.get(product_id) <= 0) { System.out.println(Thread.currentThread().getName() + "没有抢到"); //已近买完了 } else { //还没有卖完 try { //模拟操作数据库 Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } String id= UUID.randomUUID().toString(); orders.put(id, product_id); stock.put(product_id, stock.get(product_id) - 1); select_info(product_id,id); } } //不加锁 public void order3(String product_id) { if (stock.get(product_id) <= 0) { System.out.println(Thread.currentThread().getName() + "没有抢到"); //已近买完了 } else { //还没有卖完 try { //模拟操作数据库 Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } String id= UUID.randomUUID().toString(); orders.put(id, product_id); stock.put(product_id, stock.get(product_id) - 1); select_info(product_id,id); } } }

不加锁的情况下,运行情况如下

 程序执行时间大概是3s左右,可以看到首先是多卖了,其次库存有100件变为3674件,明显出现混乱,

加synchronized测试如下:

程序完美运行,但是程序执行时间为13s,秒杀活动是秒杀,耗时13秒明显不合理,况且并发量不到1000,如果上万那么就会更慢

加redis分布式锁,测试如下:

程序完美运行,程序执行时间为2s左右,效率最高

redis实现分布式原理

1.相关配置,重点不在配置,这里为了让大家看清楚,我就简单的实现其配置。太多反而不利于阅读

public Redis() { //配置连接池 JedisPoolConfig config = new JedisPoolConfig(); //设置最大连接数 config.setMaxTotal(200); //设置最小空闲数 config.setMaxIdle(8); //设置最大等待时间 config.setMaxWaitMillis(1000 * 1000); config.setTestOnBorrow(true); jedispool = new JedisPool(config,"127.0.0.1",6379); }

2.必须要了解的方法,尤其是set方法

(1)jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:第一个第二个就不用说了,第三个是填写方式,一个有两个方式,1."nx" 2."xx"。填写"nx",意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作,xx意思SET IF  EXIST;第四个参数是时间单位格式,有两种1."ex"  "px",ex是秒,px是毫秒,最后一个是过期时间值,如果写1,和第四参数ex组合就是1s后失效。和px组合就是1毫秒后失效

(2)setnx(key, value):“set if not exits”,若该key-value不存在,则成功加入缓存并且返回1,否则返回0。

(3)get(key):获得key对应的value值,若不存在则返回null。

(4)getset(key, value):先获取key对应的value值,若不存在则返回nil,然后将旧的value更新为新的value。

(5)expire(key, seconds):设置key-value的有效期为seconds秒

(6)del(String key):删除对应的键

3.用redis实现分布式锁原因

(1)在分布式系统下,使用redis集群,所有进程可以操作一个redis缓存,并且redis读写效率远高于sql数据库

(2)redis是单线程工作模式,也就是同一时刻,一个方法只能有一个人执行,且运行速度快

(3)redis可以设置失效时间,以及续加时间。

redis实现分布式锁方法:

第一种加锁(错误),使用setnx,和del(String key)。

//第一种加锁, //方案:加锁直接返回true,没有设置失效时间,客户端执行完任务后自己释放 //错误说明:如果某个客户端在获取锁后程序崩溃,或断电则锁永远不会被释放 public boolean lock1(String key, String value) { Jedis jedis = jedispool.getResource(); //不等于0,说明设置成功,说明锁没有被占领,返回true,代表获取锁 if (jedis.setnx(key, value) != 0) { jedis.close(); return true; } jedis.close(); return false; }

错误说明:如果某个客户端在获取锁后程序崩溃,或断电则锁永远不会被释放

第二种加锁(错误),使用setnx,和del和expire。

//第二种加锁 //方案:加锁,设置失效时间,时间到了自动删除键。 //错误说明:还是会出现锁永远不会被释放的情况,如下 public boolean lock2(String key, String value) { Jedis jedis = jedispool.getResource(); //不等于0,说明设置成功,说明锁没有被占领,返回true,代表获取锁 if (jedis.setnx(key, value) != 0) { //如果客户端在此处崩溃或者断电,则永远不会释放锁 jedis.expire(key, 5); jedis.close(); return true; } jedis.close(); return false; }

错误说明:锁有可能永远不会被释放

第三种加锁(错误),使用setnx,和del和getSet。

//第三种加锁, //方案:加锁,不设置失效时间,采用value做失效时间,value放的值为"当前时间"+"有效时间", //若客户端崩溃,但该key(锁)已经被赋值,判断value的值是否小于当前时间,小于则代表失效,可以获取其锁。 //错误点:看完代码待会再说 public boolean lock3(String key,String value) { Jedis jedis = jedispool.getResource(); //假如有效时间为5秒 long expiretime = System.currentTimeMillis() + 5000; String expiretimeStr = String.valueOf(expiretime); //不等于0,说明设置成功,说明锁没有被占领,返回true,代表获取锁 if (jedis.setnx(key, expiretimeStr) != 0) { jedis.close(); return true; } //说明没有获取到锁,锁被其他客户端占领, //也有可能锁过了失效时间,所以要判断,当前value的值对应的时间是否小于当前时间,小于,则说明锁失效 String keyExpireTime = jedis.get(key); if(keyExpireTime != null && Long.parseLong(keyExpireTime) < System.currentTimeMillis()){ //进入这个方法你可能觉得设置value失效时间返回true就结束了,你会如下这样做 //jedis.set(key, value) //return true; //事实并不是如此,因为你忘了高并发情况下可能多个客户端同时进入到这个条件里,就会多个同时获取锁,正确如下 //首先设置锁的失效时间并获取其旧值(旧的失效时间),考虑到高并发只能用getSet这一条命令,不能分开写 String oldValueStr = jedis.getSet(key, expiretimeStr); //其次oldValueStr有可能为null,如果为null,equals会报错,所以用&&(短路运算),在高并发情况下虽然getSet可能被多个客户端执行, //但返回值oldValueStr只有一个和keyExpireTime相等,相等的那个获取锁,也就是有且只有一个能得到锁 if(oldValueStr != null && oldValueStr.equals(keyExpireTime)){ jedis.close(); return true; //你可能觉得想不到哪里有错,其实只是不完美,有瑕疵。如下 //1.jedis.getSet(key, expiretimeStr);这条语句可能被多次执行,虽然获取了锁,但是锁的失效时间可能被其他客户端覆盖 //2.锁不具有拥有者标识,解锁直接根据key进行解锁,即任何客户端都可以解锁,不安全。 //3.需要分布式下每个客户端的时间必须同步。 } } jedis.close(); return false; }

错误如下:1需要分布式下每个客户端的时间必须同步                 2.锁不具有拥有者标识,解锁直接根据key进行解锁,即任何客户端都可以解锁,不安全。                 3.jedis.getSet(key, expiretimeStr);这条语句可能被多次执行,虽然获取了锁,但是锁的失效时间可能被其他客户端覆盖

第四种加锁(错误),使用set,加Lua脚本

//第四种加锁, //方案:使用set(String key, String value, String nxxx, String expx, int time) //同时设置了值和失效时间。因为set方法是原子性的同一时刻只能有一个执行 //错误点:其lock4是目前网上主流用redis实现分布式锁,但是小编发现它还是有问题,先看代码 public boolean lock4(String key, String value) { Jedis jedis = jedispool.getResource(); String res = jedis.set(key, value, "nx", "ex", 5); if (res != null && res.equals("OK")) { jedis.close(); return true; } jedis.close(); return false; } //错误点:当客户端A拿到了锁,但是客户端A因为系统卡顿,或业务多原因处理该业务需要8秒时间, //假如锁的失效时间设置为5s,5秒后就会自动释放,此时客户端B就可以获取锁,也就是该业务同时两个人在做 //这明显会造成不安全行为。 //解决方法为:A客户端自己为自己,开启一个守护线程,当获取锁时代码未执行完,续加失效时间。 //那么客户端宕机了怎么办?啥都不做,它宕机了,他就不能为自己续加时间。时间到锁就过期了,完美解决这一问题。

错误点:当客户端A拿到了锁,但是客户端A因为系统卡顿,或业务多原因处理该业务需要8秒时间,假如锁的失效时间设置为5s,5秒后就会自动释放,此时客户端B就可以获取锁,也就是该业务同时两个人在做,这明显会造成不安全行为。

因为网上大多数博客都是这种方法所有小编图解下错误,帮助大家理解

这是一个极端场景,假如某线程成功得到了锁,并且设置的超时时间是 5 秒。

如果某些原因导致线程 A 执行的很慢很慢,过了 30 秒都没执行完,这时候锁过期自动释放,线程 B 得到了锁。

随后,线程 A 接着执行任务,也就是同一时间有 A,B 两个线程在访问代码块,如果此时线程B已经抢光短袖了,但线程A已经下单了,却没有货了。这明显是不合理的情况。

解决方法为:A客户端自己为自己,开启一个守护线程,当获取锁时代码未执行完,续加失效时间。那么客户端宕机了怎么办?啥都不做,它宕机了,他就不能为自己续加时间。时间到锁就过期了,完美解决这一问题。

 当线程 A 执行完任务,会显式关掉守护线程

第五种加锁(正确),就是给第四种方法加守护线程。实现起来比较复杂,但已有人开发为工具就是Redisson:  Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。【Redis官方推荐】,

加锁如下:

RLock mylock = redisson.getLock(key); 设置时间 mylock.lock(2, TimeUnit.MINUTES);

解锁如下:

RLock mylock = redisson.getLock(key); //释放锁(解锁) mylock.unlock();

解锁一(错误)

//第一种解锁,适用于第三种加锁 //方案:根据key直接删除, //错误点:不安全任何客户端都可以解锁, public void unlock1(String key) { Jedis jedis = jedispool.getResource(); jedispool.getResource().del(key); jedis.close(); }

解锁二,(错误)

//第二种解锁,适用于第一,第二,第四种加锁 //方案:根据key,和value,当jedis.get(key)和value一样的时候在删除, //错误点:注意还是存在线程安全, //1.因为判断和删除是两行代码,当进入到{}里时,正好锁(key)过期,其他线程拿到锁,而此时又把锁删除了。这是不安全的 //2.因为if进行判断时锁正好过期,而其他线程还没有拿锁,此时不存在key,jedis.get(key)的返回值为null,null不能进行equals public boolean unlock2(String key, String value) { Jedis jedis = jedispool.getResource(); if (value != null && jedis.get(key).equals(value)) { jedispool.getResource().del(key); jedis.close(); return true; } jedis.close(); return false; }

解锁三,(正确)

//第三种解锁,适用于第一,第二,第四种加锁 //方案:使用Lua脚本将判断,获取,删除,一次执行。 public boolean unlock3(String key, String value) { Jedis jedis = jedispool.getResource(); String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; Object result = jedis.eval(script,Collections.singletonList(key),Collections.singletonList(value)); if("OK".equals(result)){ jedis.close(); return true; } jedis.close(); return false; }

 

最新回复(0)