锁有资源竞争问题就有一定有锁的存在,存储系统MySQL中,有锁机制保证数据并发访问。而编程语言层面Java中有JUC并发工具包来实现,那么锁解决的问题是什么?主要是在多线程环境下,对共享资源的互斥。从而保证数据一致性。
单机锁
单机锁,一般就直接使用syn或者lock。两种属于不同的机制,一种是jvm内置的机制,另一种是AQS原理机制来保证锁机制的。本质上都属于同一个进程内。但是当系统采用集群模式部署的时候,是跨机器、跨进程调用。这个时候就需要采用一个共享的存储系统来保存锁状态。
分布式锁
分布式锁一般实现方式有Redis、ZK、MySQL等本篇主要介绍Redis实现方式。
分布式锁需要具备以下几点
- 独占性:任何时刻都只能有一个线程持有。
- 高可用:如果是单机点Redis有单点宕机的可能,造成锁无法释放和获取。为保证高可用,一般需要集群模式
- 防止死锁:如果出现锁无法释放的情况下,那么需要有超时控制机制,进行兜底方案
- 不乱入:A锁进行释放不能释放成B锁,B锁释放不能释放成A锁
- 重入性:同一个节点的同一个线程获取后,再次读写共享资源不需要再次获取锁。
如何设置分布式锁 ?
因为setnx + expire 不是原子性操作,所以不推荐使用。
场景设计:秒杀设计。
V1:单机版没加锁 初始只是单个系统处理用户请求。因为没有加锁控制,会出现同一时间内,多个线程会处理同一个票的情况。出现了同一张票被卖出多次。业务上是不允许的。因此进行加锁,而在单体系统中,我们的程序都是在一个进程内执行的,也既这种锁就是JVM 进程内的。一般来说,我们可以采用syn和lock,具体如何权衡需要我们自己来分析。
V2:Nginx分布式微服务架构 在V1的基础上,加锁。但是一般系统为了更好的提供服务给用 户,需要多个模块集群部署。而对于用户来说,具体那一台服务器处理请求是完全透明的, 所以需要在前面加一台反向代理服务器Nginx,将用户请求通过某一种策略达到服务器上。但是通过Jmter压测后,还是发现同一个票会被卖多次。我们来分析一下,为什么呢,虽然,我们加锁了。但是这个锁只是进程内的锁,并不能保证跨进程之间共享数据的操作。所以,我们需要进一步采用分布式锁来解决这个问题。 setnx
V3:释放锁 虽然采用了分布式setnx进行处理,但是我们无法保证程序执行到最后一定会释 放锁,所以将释放锁的code放在finally块中。保证不管程序是否状态与否,一定会释放锁。 其他线程可以抢到锁。
V4:单点宕机 虽然V3可以保证程序一定可以释放锁,但是如果程序执行到一半,此时redis 宕机,那么锁就无法释放,而别的线程也无法获取锁。因此需要给一把锁加上一个过期时间。
V5:过期时间与设置key 对于设置key和过期时间,因为是两个不同的语句,无法保证原子性,因此有可能设置了key,但是过期时间没有设置上,所以需要保证原子操作。直接使用 redis提供的命令。
V6:只释放属于自己的锁 虽然我们保证了一定会释放锁,在极端情况下,比如A线程尝试获取锁,获取到RedLock,执行了30S后过期时间到了,但是线程A并没有执行完毕任务。线程 B去获取锁,获取到了。线程B往下执行。此时线程A走到释放锁的模块,直接释放锁,B在执行的过程中被A释放锁。就会出现业务数据的不一致。所以 删除锁的之后,应该先判断一 下,属于自己的锁才删除。
V7:判断锁和删除锁非原子性 可以使用Redis的事务进行保证,但是推荐使用Lua脚本。
V8:过期时间和业务处理时间 需要进行续期操作,如果没有执行完毕,延⻓过期时间。
V9:Redis的高可用 数据一致性 CAP强制规范下,Redis是一个高可用AP 而ZK是CP,通常, 我们搭建一个Redis的主从集群,而数据的一致性用ZK来保证。
V10:Redisson 虽然,我们使用redis相关命令可以实现分布式锁,但是还是推荐使用 redisson来实现,编程更加简易化。
/**
* @author i
* @create 2020/6/20 14:55
* @Description
* 加锁操作
* 1.使用setbx命令保证互斥性
* 2.需要设置锁的过期时间,避免死锁
* 3.setnx和设置过期时间需要保持原子性,避免在设置setnx成功之后在设置过期时间客户端崩溃导致死锁
* 4.加锁的value值为一个唯一标示,可以采用UUID作为一个唯一标示,加锁成功后需要把唯一标示返回给客户端来用客户端进行解锁操作
* 解锁操作
* 1.需要拿加锁成功的唯一标识进行解锁,从而保证加锁和解锁的是同一个客户端
* 2.解锁操作需要比较唯一标识是否相等,相等在执行删除操作,这两个操作可以采用lua脚本方式使用2个命令的原子性
*
*/
public interface DistributedLock {
/***
* 获取锁
* @return
*/
String acquire();
/***
* 释放锁
* @param identifier
* @return
*/
boolean release(String identifier);
}
@Slf4j
public class RedisDistributedLock implements DistributedLock {
private static final String LOCK_SUCCESS = "OK"; //上锁成功
private static final Long RELEASE_SUCCESS = 1L; //释放成功
private static final String SET_IF_NOT_EXIST = "NX"; //不存在
private static final String SET_WITH_EXPIRE_TIME = "PX"; //
/**
* redis 客户端
*/
private Jedis jedis;
/**
* 分布式锁的键值
*/
private String lockKey;
/**
* 锁的超时时间 10s
*/
int expireTime = 10 * 1000;
/**
* 锁等待,防止线程饥饿
*/
int acquireTimeout = 1 * 1000;
/**
* 获取指定键值的锁
*
* @param jedis jedis Redis客户端
* @param lockKey 锁的键值
*/
public RedisDistributedLock(Jedis jedis, String lockKey) {
this.jedis = jedis;
this.lockKey = lockKey;
}
/**
* 获取指定键值的锁,同时设置获取锁超时时间
*
* @param jedis jedis Redis客户端
* @param lockKey 锁的键值
* @param acquireTimeout 获取锁超时时间
*/
public RedisDistributedLock(Jedis jedis, String lockKey, int acquireTimeout) {
this.jedis = jedis;
this.lockKey = lockKey;
this.acquireTimeout = acquireTimeout;
}
/**
* 获取指定键值的锁,同时设置获取锁超时时间和锁过期时间
*
* @param jedis jedis Redis客户端
* @param lockKey 锁的键值
* @param acquireTimeout 获取锁超时时间
* @param expireTime 锁失效时间
*/
public RedisDistributedLock(Jedis jedis, String lockKey, int acquireTimeout, int expireTime) {
this.jedis = jedis;
this.lockKey = lockKey;
this.acquireTimeout = acquireTimeout;
this.expireTime = expireTime;
}
/***
* 获取锁
* 成功 返回唯一的token
* 失败 返回null
* @return
*/
@Override
public String acquire() {
try {
// 获取锁的超时时间,超过这个时间则放弃获取锁
long end = System.currentTimeMillis() + acquireTimeout;
// 随机生成一个value
String requireToken = UUID.randomUUID().toString();
//在固定时间内 不断重试获取锁 如果获取成功直接返回
//如果获取失败 并且超过获取锁的时间 直接返回null
//超过时间自动释放获取锁的流程
while (System.currentTimeMillis() < end) {
//lockKey:分布式锁的键值
//requireToken:生成一个随机token
//SET_IF_NOT_EXIST:如果不存在
//SET_WITH_EXPIRE_TIME 设定过期时间
//锁的超时时间
String result = jedis.set(lockKey, requireToken, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
//获取锁成功 返回给每个客户端一个不唯一的token标识
if (LOCK_SUCCESS.equals(result)) {
return requireToken;
}
//否则不断重试,在规定的超时时间内。
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} catch (Exception e) {
log.error("acquire lock due to error", e);
}
//没有获取锁 返回null
return null;
}
/**
* 释放锁
*
* @param identify
* @return
*/
@Override
public boolean release(String identify) {
//如果用户标志位null 返回false
if (identify == null) {
return false;
}
//lua脚本 从一定程度上可以保证释放锁的原子性操作
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = new Object();
try {
//执行脚本
result = jedis.eval(script, Collections.singletonList(lockKey),
Collections.singletonList(identify));
//释放锁成功 返回true
if (RELEASE_SUCCESS.equals(result)) {
log.info("release lock success, requestToken:{}", identify);
return true;
}
} catch (Exception e) {
log.error("release lock due to error", e);
} finally {
//释放连接资源
if (jedis != null) {
jedis.close();
}
}
//释放锁失败
log.info("release lock failed, requestToken:{}, result:{}", identify, result);
return false;
}
}
public class RedisDistributedLockTest {
static int n = 500;
public static void secskill() {
System.out.println(--n);
}
public static void main(String[] args) {
Runnable runnable = () -> {
DistributedLock lock = null;
String unLockIdentify = null;//释放锁标志
try {
Jedis conn = new Jedis("127.0.0.1",6379);
lock = new RedisDistributedLock(conn, "test1");
unLockIdentify = lock.acquire();
System.out.println(Thread.currentThread().getName() + "正在运行");
secskill();
} finally {
if (lock != null) {
lock.release(unLockIdentify);
}
}
};
for (int i = 0; i < 10; i++) {
Thread t = new Thread(runnable);
t.start();
}
}
}
Redis高可靠分布式锁
readLock是为了避免Redis实例因故障而导致无法工作的问题。
基本思想就是客户端发送的锁请求从多个Redis实例中依次请求,如果客户端可以获取半数以上的实例节点,那么就可以认为加锁成功。这样就可以避免因其中部分节点宕机导致获取不到锁,加锁失败。
ReadLock算法核心步骤
1.客户端获取当前时间
2.客户端按照顺序依次向N个Redis实例执行加锁操作。
3.一旦客户端完成了和所有Redis实例加锁操作,客户端计算整个过程总耗时。
当满足以下两个条件时,认为加锁成功
- 条件一:客户端获取了半数以上Redis实例的加锁成功结果。
- 条件二:客户端获取锁的时间没有超过锁的过期时间。
其中如果执行业务逻辑的时间没有来得及进行处理处理,那么也会加锁成功,直接释放锁。而这个耗时就是锁过期时间减去锁的加锁时间。
我们可以采用 Lua 脚本执行释放锁操作,通过 Redis 原子性地执行 Lua 脚本,来保证释放锁操作的原子性。
小结
本篇主要介绍了分布式锁,单机以及Redis实现方式。引入分布式虽然可以提高系统的吞吐量和稳定性,但是也引入了很多分布式问题,比如分布式共识、分布式锁、事务等,而这就是trade-off艺术。而分布式锁可以通过Redis、ZK、MySQL等实现。但是一般来说推荐使用Redis,主要是大多数互联网项目都使用Redis作为缓存,直接使用不需要再次引入ZK。但是具体场景也需要具体分析。