分布式锁
背景
在分布式系统和微服务盛行的年代,每个服务都是多服务器部署。在分布式的场景下,一些业务需要保证在单机环境单线程执行。所以就需要分布式锁来支持业务。传统的分布式已经无法满足业务要求了。
三种分布式锁
-
基于数据库锁(乐观锁,悲观锁)实现分布式锁
-
基于缓存(tair,reids)实现分布式锁
-
基于zookeeper实现分布式锁
分布式的特点
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
-
互斥性。在任意时刻,只有一个客户端能持有锁。
-
不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
-
具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
-
解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
基于数据库锁实现分布式锁
悲观锁
MySQL使用悲观锁查询业务的时候,不允许其他事务修改的id=3数据。并且在一段时间内只有一个事务能够修改成功。但是悲观锁的性能比较差
select * from t_goods where id=3 for update;
乐观锁
在修改数据的时候,使用版本号功能,利用cas锁特性,能够保证只有一个事务完成修改,完全符合分布式锁的特性。但是在没有修改成功时候,开发人员根据业务是否需要回滚数据。
set t_goods set status=1,version=version+1 where id=2 and version=2
基于Rediss实现分布式锁
Redis正确方式加锁解锁
package edu.whut.hehe.wallet;
import org.assertj.core.util.Lists;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisTemplate;
import redis.clients.jedis.*;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @Author 希界
* @Date 2021/07/29 10:57 AM
* @Uint 淘特用户增长
* @Description:
*
* 单机
* 推荐1:使用redisson方式上锁解锁
* 推荐2:使用lua脚本方式上锁解锁
*
* 多机
* 推荐1:使用Redisson的redLock方式
*
*/
public class RedisLock {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
private static final Long RELEASE_SUCCESS = 1L;
private final static String uuid= UUID.randomUUID().toString().replace("-","");
private Jedis jedis;
private RedisTemplate<String,String> redisTemplate;
private RedissonClient redissonClient;
private String getValue(){
long id = Thread.currentThread().getId();
return uuid+":"+id;
}
/**
*
* @Author 希界
* @Date 2021/07/29 11:03 AM
* @Uint 淘特用户增长
* @Description: jedis需要使用比较低的版本。比如2.9.0
* <dependency>
* <groupId>redis.clients</groupId>
* <artifactId>jedis</artifactId>
* <version>2.9.0</version>
* </dependency>
*
*
*/
public boolean lockByJedis(String key,String value,long expireTime){
String result = jedis.set(key, value, "NX", "EX", expireTime);
if (LOCK_SUCCESS.equals(result)){
return true;
}
return false;
}
//redis的PEXPIRE是以毫秒作为时间单位,EXPIRE是以秒作为时间单位
public boolean lockBySrcipt(String key,String value,long expireTime){
String script = "if redis.call('setnx', KEYS[1],ARGV[1]) == 1 then redis.call('pexpire', KEYS[1],ARGV[2]) return 1 else return 0 end";
Object result = jedis.eval(script, Lists.newArrayList(key), Lists.newArrayList(value,expireTime+""));
if (RELEASE_SUCCESS.equals(result)){
return true;
}
return false;
}
public boolean lockBySrcipt(String key,String value,long expireTime){
String script = "if redis.call('setnx', KEYS[1],ARGV[1]) == 1 then redis.call('expire', KEYS[1],ARGV[2]) return 1 else return 0 end";
Object result = jedis.eval(script, Lists.newArrayList(key), Lists.newArrayList(value,expireTime+""));
if (RELEASE_SUCCESS.equals(result)){
return true;
}
return false;
}
public boolean unlockByScript(Jedis jedis,String key, String value) {
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 (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
/**
*
* @Author 希界
* @Date 2021/07/29 11:24 AM
* @Uint 淘特用户增长
* @Description: redisson的上锁和解锁比较简单(推荐使用)
*
*/
public boolean lockByRedisson(String key,long waitTime,long expireTime) throws InterruptedException {
RLock lock = redissonClient.getLock(key);
boolean result= lock.tryLock(waitTime,expireTime, TimeUnit.MILLISECONDS);
return result;
}
public void unlockByRedssion(String key){
RLock lock = redissonClient.getLock(key);
lock.unlock();
}
}
Reids几种错误的加锁解锁
错误示例1
比较常见的错误示例就是使用jedis.setnx()
和jedis.expire()
组合实现加锁,代码如下:
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}
}
setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。乍一看好像和前面的set()方法结果一样,然而由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。
错误示例2
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
long expires = System.currentTimeMillis() + expireTime;
String expiresStr = String.valueOf(expires);
// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(lockKey, expiresStr) == 1) {
return true;
}
// 如果锁存在,获取锁的过期时间
String currentValueStr = jedis.get(lockKey);
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
String oldValueStr = jedis.getSet(lockKey, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
return true;
}
}
// 其他情况,一律返回加锁失败
return false;
}
问题:1 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()
方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。3. 锁不具备拥有者标识,即任何客户端都可以解锁。
解锁错误示例
最常见的解锁代码就是直接使用jedis.del()
方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
//可能直接删除其他人持有的锁
jedis.del(lockKey);
}
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
// 判断加锁与解锁是不是同一个客户端 ,需要保证原子性。也就是查询和删除必须在同时成功同时失败,也就是事务的原子性
if (requestId.equals(jedis.get(lockKey))) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁
jedis.del(lockKey);
}
}
基于zookeeper实现分布式锁
zookeeper使用的是临时节点的方式实现的,按照节点顺序进行排序实现,在此不做更多展开。
总结
上述的三种分布式锁,开发难度:mysql<redis<zookeeper ,性能:mysql<zookeeper<redis。 在做一般非高并发的业务可以考虑数据库锁的方式实现不是分布式功能,保证数据唯一性。如果是高并发的业务,建议采用缓存实现分布式锁。比如redis,zookeeper。
之前使用Redis分布式锁的时候,加锁解锁就出现了上面的错误示例,很多情况考虑不周。如果是单机的情况,建议的使用redis脚本或者redisson实现分布式锁。如果多机的情况,建议使用redisson的redlock。