目录
第一种加锁(错误),使用setnx,和del(String key)。
第二种加锁(错误),使用setnx,和del和expire。
第三种加锁(错误),使用setnx,和del和getSet。
概述
小编最近学习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;
}