前言:
为啥我们在项目需要用到分布式的锁, synchronized 不可以吗?当我们的开发的环境在多节点部署的时候,由于不在用一个jvm虚拟机上,我们的synchronized锁是不能实现跨虚拟机来加锁的,我们加锁的目的就是为了解决多个线程之间的并发问题,锁分为乐观锁,还有悲观锁,下面我们以悲观锁为例,详细介绍常用的三种实现分布式锁的方式。
1.基于数据库实现分布式锁:
创建表的SQL语句:
CREATE TABLE `distributed_lock` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
`unique_mutex` varchar(255) NOT NULL COMMENT '业务防重id',
`holder_id` varchar(255) NOT NULL COMMENT '锁持有者id',
`create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `mutex_index` (`unique_mutex`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
SET FOREIGN_KEY_CHECKS = 1;
id : 自增主键,这里为啥在分布式的环境下要用自增主键,这张表的每一条信息代表每个业务的唯一性,这张表不需要多节点部署,只需要在主库中部署即可(这也暴露了数据库实现分布式锁的缺陷性,万一数据库单节点宕机,会存在服务不可用)
unique_mutex: 业务防重id,一个需要加锁的业务,我们可以自定义一个业务防重id,在执行该业务时,先查询这张表有无该业务防重id,没有的话去添加一条数据加锁然后执行业务代码,具体unique_mutex的值可根据业务进行自定义:
insert into distributed_lock(unique_mutex, holder_id) values (‘unique_mutex’, ‘holder_id’);
然后在执行完业务的时候记得删除锁:
delete from methodLock where unique_mutex=‘unique_mutex’ and holder_id=‘holder_id’;
holder_id :代表竞争到锁的持有者id,可根据用户名,用户手机号组成全局唯一字段等进行自定义。
分析我们的以上的锁是否有局限性?
1.可重入性:
从我们的设计来看,不能满足重入性,我们在查询这张表有无该业务的防重id,没有的话去添加一条数据加锁然后执行业务代码,我们这里需要加再加一条判断条件,就算已经有锁了,我们判断该锁的持有者是否是
当前请求者,如果是可以继续执行当前业务代码,这样可重入性就解决了。
2.是否会产生死锁:
试想一下如果一条进程加锁成功,突然这条进程挂了,或者在代码执行到一半的时候别人直接用kill -9 将你的java进程杀死。那么这条业务永远被挂上了锁而无法解锁。解决的方法: 我们可以给我们的锁设置一个过期时间,在每次这条锁挡住我们之前,我们先判断这个锁是否已经过期了,如果已经过期了,那么不好意思,这条锁没有用了,我们直接将这个锁(对应的是数据库表中的一条记录该业务的信息)删除,然后再加锁。
3. 高可用问题:
目前我们的数据库锁设计方案只能在单节点部署,不存在高可用问题。
2. 基于redis实现分布式锁
redis工具类: 用于加减锁
注意到这里解锁其实是分为2个步骤,涉及到解锁操作的一个原子性操作问题。这也是为什么我们解锁的时候用Lua脚本来实现,因为Lua脚本可以保证操作的原子性。那么这里为什么需要保证这两个步骤的操作是原子操作呢?
设想:假设当前锁的持有者是竞争者1,竞争者1来解锁,成功执行第1步,判断自己就是锁持有者,这是还未执行第2步。这是锁过期了,然后竞争者2对这个key进行了加锁。加锁完成后,竞争者1又来执行第2步,此时错误产生了:竞争者1解锁了不属于自己持有的锁。可能会有人问为什么竞争者1执行完第1步之后突然停止了呢?这个问题其实很好回答,例如竞争者1所在的JVM发生了GC停顿,导致竞争者1的线程停顿。这样的情况发生的概率很低,但是请记住即使只有万分之一的概率,在线上环境中完全可能发生。因此必须保证这两个步骤的操作是原子操作。
/**
* redis工具类
*/
public class RedisTool {
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;
/**
* 加锁
* @param jedis Redis客户端
* @param lockKey 锁的key
* @param requestId 竞争者id
* @param expireTime 锁超时时间,超时之后锁自动释放
* @return
*/
public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
return LOCK_SUCCESS.equals(result);
}
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 锁持有者id
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
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(lockKey), Collections.singletonList(requestId));
return RELEASE_SUCCESS.equals(result);
}
}
jedis获取工具类:
/**
* jedis获取工具类
*/
@Component
public class RedisPoolUtils {
private static JedisPool jedisPool = null;
static {
Properties properties = new Properties();
InputStream configStream = RedisPoolUtils.class.getResourceAsStream("/application.properties");
try {
properties.load(configStream);
String ADDRESS = properties.getProperty("spring.redis.host");
String PORT = properties.getProperty("spring.redis.port");
String PASSWORD = properties.getProperty("spring.redis.password");
//可用连接实例的最大数目,默认值为8;
//如果赋值为-1,则表示不限制;如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)。
String MAX_ACTIVE = properties.getProperty("spring.redis.pool.max-active");
//控制一个pool最多有多少个状态为idle(空闲的)的jedis实例,默认值也是8
String MAX_IDLE = properties.getProperty("spring.redis.pool.max-idle");
//等待可用连接的最大时间,单位毫秒,默认值为-1,表示永不超时。如果超过等待时间,则直接抛出JedisConnectionException;
String MAX_WAIT = properties.getProperty("spring.redis.pool.max-wait");
JedisPoolConfig config = new JedisPoolConfig();
// config.setMaxActive(MAX_ACTIVE);
config.setMaxIdle(Integer.parseInt(MAX_IDLE));
// config.setMaxWait(MAX_WAIT);
//在borrow一个jedis实例时,是否提前进行validate操作;如果为true,则得到的jedis实例均是可用的;
boolean TEST_ON_BORROW = true;
config.setTestOnBorrow(TEST_ON_BORROW);
int TIMEOUT = 0;
jedisPool = new JedisPool(config, ADDRESS, Integer.parseInt(PORT), TIMEOUT);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 获取Jedis实例
*
* @return
*/
public synchronized Jedis getJedis() {
try {
return jedisPool.getResource();
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
测试类:
@RestController
@RequestMapping("/redis")
public class RedisController {
@Autowired
private RedisPoolUtils redisPoolUtils;
@RequestMapping("/testLock")
public String testLock() {
Jedis jedis = Objects.requireNonNull(redisPoolUtils.getJedis());
boolean distributedLock = RedisTool.getDistributedLock(jedis, "DX", "张三_178061612345", 2000);
if (distributedLock) {
System.out.println("开始执行业务逻辑....." + "\n\n");
try {
Thread.sleep(1200);
} catch (InterruptedException e) {
e.printStackTrace();
}
RedisTool.releaseDistributedLock(jedis, "DX", "张三_178061612345");
}
return "OK";
}
@RequestMapping("/testConcurrent")
public String testConcurrent(){
for (int i = 0; i < 1000; i++) {
new Thread(this::testLock).start();
}
return "OK";
}
}
分析我们的以上的锁是否有局限性?
1.是否可重入:以上实现的锁是不可重入的,如果需要实现可重入,在SET_IF_NOT_EXIST之后,再判断key对应的value是否为当前竞争者id,如果是返回加锁成功,否则失败。
2.锁释放时机:加锁时我们设置了key的超时,当超时后,如果还未解锁,则自动删除key达到解锁的目的。如果一个竞争者获取锁之后挂了,我们的锁服务最多也就在超时时间的这段时间之内不可用。
3.Redis单点问题:如果需要保证锁服务的高可用,可以对Redis做高可用方案:Redis集群+主从切换。目前都有比较成熟的解决方案
3.基于Zookeeper实现分布式锁
通过创建临时节点来进行排序,通过注册watcher事件来监听比他位置靠前的前一个节点的变化,如果前面的节点不存在了,就成功获得锁,任务执行完毕删除该临时节点。就像一个排队的队列,来一个任务在后面排队,只是监视前面那一个人是否存在,不存在了就轮到自己了,完成任务就将自己删除了。
public class ZookeeperLockTool {
public static ThreadLocal<CuratorZookeeperClient> zkClient = null;
public static String LOCK_NAME = "/lock";
public static ThreadLocal<String> CURRENT_NODE = new ThreadLocal<>();
static {
// zookeeper连接地址,端口号
zkClient.set(new CuratorZookeeperClient("localhost:7070",2000,2000,null,new RetryOneTime(1)));
}
public static boolean tryLock() {
String nodeName = LOCK_NAME + "/zk_";
try {
CURRENT_NODE.set(zkClient.get().getZooKeeper().create(nodeName, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL));
List<String> children = zkClient.get().getZooKeeper().getChildren(nodeName, false);
Collections.sort(children);
// 判断是否获得锁
String minNode = children.get(0);
if ((LOCK_NAME +"/"+minNode).equals(CURRENT_NODE.get())){
// 获取到锁
return true;
}
// 没有获取到锁,监听前一个节点是否存在
// 获取前一个节点的index
int i = children.indexOf(CURRENT_NODE.get().substring(CURRENT_NODE.get().lastIndexOf("/") + 1));
// 获取前一个节点
String preNodeName = children.get(i);
// 增加countDownLatch 同步线程
CountDownLatch countDownLatch = new CountDownLatch(1);
zkClient.get().getZooKeeper().exists(LOCK_NAME + "/" + preNodeName, watchedEvent -> {
// 监听前一个节点,前一个临时节点消失就是代表当前线程为最小的那个节点,可以获取到锁
if (Watcher.Event.EventType.NodeDeleted.equals(watchedEvent.getType())){
countDownLatch.countDown();
}
});
countDownLatch.await();
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
public static void unlock() {
try {
zkClient.get().getZooKeeper().delete(CURRENT_NODE.get(),-1);
CURRENT_NODE.remove();
} catch (Exception e) {
e.printStackTrace();
}finally{
zkClient.get().close();
}
}
}