为什么需要分布式锁?
在多线程并发的情况下,可以使用java的synchronized以及Reentrantlock类来保证一个代码块在同一时间只能由一个线程访问。这种方式可以保证在同一个JVM进程内的多个线程同步执行。如果在分布式的集群环境中,就需要使用分布式锁来保证不同节点的线程同步执行。

分布式锁的实现方式

  • 分布式系统的理论基石-CAP原理:一致性(Consistent)、可用性(Availability)、分区容错性(Partition tolerance)

1、Redis分布式锁

  • 加锁:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。
  • 解锁:DEL key,通过删除键值对释放锁,释放后其他线程可以通过SETNX来获取锁。
  • 超时:EXPIRE key timeout,设置key的超时时间,保证锁在没有显式释放的情况下,锁也可以自动释放,避免资源永远被锁住
    加锁伪代码如下:
if(setnx(key,1) ==1){
    expire(key,30)
    try{
        //业务代码
    }finally{
        del(key)
    }
}

但以上伪代码还存在问题:
(1)如下图所示,如果在setnx获得锁后,发生了断电、服务器挂掉,expire就会得不到执行,也会造成死锁。
图1-1
问题的根源在于setnx和expire是两条指令而不是原子指令。这里的问题不能用Redis事务来解决,因为expire和setnx之间存在依赖关系,如果setnx没有强盗锁,expire同样不能执行。在Redis2.8以后,加入了set指令的扩展参数,使setnx和expire可以一起执行。

(2)del导致误删
如下图所示,线程A获取了锁,并设置了过期时间为30秒,30秒后锁自动释放,但A业务还未完成,此时线程B获取了锁;然后A执行完成,执行del命令删除锁,但此时释放的是B的锁。
在这里插入图片描述
为了避免这种情况,可以将set指令的value参数设置为一个随机数或设置为当前线程的ID,释放锁时先匹配随机数是否一致,然后再删除key。伪代码如下:

//加锁
String threadId=Thread.currentThread().getId()
set(key,threadId,30,NX)

//解锁
if(threadId.equals(redisClient.get(key))){
    del(key)
}

但是这样引发了一个新的问题,判断锁和释放锁不是一个原子操作,这就需要使用Lua脚本实现。

(3)超时解锁导致并发
还是在图2中,有一段时间内,线程A和线程B是并发的。因此,可以将过期时间设置的足够长,保证业务在锁释放之前能够执行完,或者为获取锁的线程增加守护线程,为将要过期但未释放的锁增加执行时间。

(4)不可重入
当线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。Redis分布式锁可以对锁进行重入计数,使用Java中的ThreadLocal进行本地计数,代码如下:

private static ThreadLocal<Map<String, Integer>> LOCKERS = ThreadLocal.withInitial(HashMap::new);
// 加锁
public boolean lock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.containsKey(key)) {
    lockers.put(key, lockers.get(key) + 1);
    return true;
  } else {
    if (SET key uuid NX EX 30) {
      lockers.put(key, 1);
      return true;
    }
  }
  return false;
}
// 解锁
public void unlock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.getOrDefault(key, 0) <= 1) {
    lockers.remove(key);
    DEL key
  } else {
    lockers.put(key, lockers.get(key) - 1);
  }
}

集群下的分布式锁
在集群中,Redis一般采用主从方式进行部署。如图3中,加入第一个客户端在主节点中申请了一把锁,但这把锁还没来得及同步到从节点,主节点就挂掉了,然后从节点变成了主节点,这个新的主节点内还没有这把锁,所以当另一个客户端请求加锁时,会立即批准。这样就导致系统中同样一把锁被两个客户端同时持有,产生了不安全性。

在这里插入图片描述
可以使用RedLock算法解决,但会带来性能下降。

参考:

  • 《Redis深度历险:核心原理与实践应用》
  • “分布式锁的实现之 redis 篇”:https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/
  • “漫画:什么是分布式锁”: https://mp.weixin.qq.com/s/8fdBKAyHZrfHmSajXT_dnA

2、MySQL实现
(1)基于表结构的排他锁
最简单的方式就是创建一张锁表,然后通过操作表中的数据实现,加锁时就在表中增加记录,解锁时就删除,表结构设计如下:

CREATE TABLE `database_lock` (
	`id` BIGINT NOT NULL AUTO_INCREMENT,
	`resource` int NOT NULL COMMENT '锁定的资源',
	`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
	PRIMARY KEY (`id`),
	UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

获取锁时:

INSERT INTO database_lock(resource,description) VALUES(1,'lock');

表中的resource字段做了唯一性约束,如果多个请求同时提交到数据库,只有一个请求可以成功,也就是只有一个请求能获取锁。
释放锁时:

DELETE FROM database_lock WHERE resource=1;

锁没有设置失效时间,可能会导致资源被永久占用;
这种锁是非阻塞的,插入数据失败后会直接报错,想要获得锁就需要再此操作;
非可重入。

(2)乐观锁
表结构中增加version字段标识数据版本,更新时版本号加1,更新过程中会对版本号进行比较,版本号一致,则成功执行,反之,更新失败。表结构如下:

CREATE TABLE `database_lock` (
	`id` BIGINT NOT NULL AUTO_INCREMENT,
	`resource` int NOT NULL COMMENT '锁定的资源',
	`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
    `version` int NOT NULL COMMENT '版本信息',
	PRIMARY KEY (`id`),
	UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

乐观锁在检测冲突时并不依赖数据库本身的锁,所以不会影响请求的性能,但需要增加额外的字段,此外,并发量高时,version在频繁变化,会导致大量请求失败,影响系统可用性 。
数据库的锁作用于同一行数据记录上,如果大量的请求同时请求某一条记录的行锁,会对数据库造成很大的压力。
适合并发量不高,写操作不频繁的场景。

参考:

  • 基于MySQL实现的分布式锁:https://blog.csdn.net/u013474436/article/details/104924782/
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐