一、什么是分布式锁?
为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,由于分布式系统多线程、多进程并且分布在不同机器上,需要一种跨机器的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
二、分布式锁应该具备哪些条件
在分析分布式锁的三种实现方式之前,先了解一下分布式锁应该具备哪些条件:
1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
2、高可用的获取锁与释放锁;
3、高性能的获取锁与释放锁;
4、具备可重入特性;
5、具备锁失效机制,防止死锁;
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
三、分布式锁的三种实现方式
目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。
基于数据库实现分布式锁; 基于缓存(Redis等)实现分布式锁; 基于Zookeeper实现分布式锁;
四、基于Redis的实现方式
1、选用Redis实现分布式锁原因:
(1)Redis有很高的性能;
(2)Redis命令对此支持较好,实现起来比较方便
2、使用命令介绍:
(1)SETNX
SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
(2)expire
expire key timeout:为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
(3)delete
delete key:删除key
在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。
3、实现思想:
(1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
(2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
(3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。
五、代码实现:
pom文件:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>redisLock</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>redisLock</name>
<description>redisLock</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.3.7.RELEASE</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- spring2.X集成redis所需common-pool2-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.6.0</version>
</dependency>
<!-- redisson 分布式锁-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.11.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.3.7.RELEASE</version>
<configuration>
<mainClass>com.example.redislock.RedisLockApplication</mainClass>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
yml:
server:
#port: 8206
spring:
redis:
host: 192.168.247.100
port: 6379
database: 0
timeout: 1800000
lettuce:
pool:
max-active: 20 #最大连接数
max-wait: -1 #最大阻塞等待时间(负数表示没限制)
max-idle: 5 #最大空闲
min-idle: 0 #最小空闲
操作reids代码:
package com.example.redislock.service.impl;
import com.example.redislock.service.TestService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class TestServiceImpl implements TestService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 最初版,单一部署 synchronized锁
* 在缓存中存储一个num,初始值为0
* 利用缓存中的StringRedisTemplate,获取到当前的num数据值
* 如果num不为空,则需要对当前值+1操作
* 如果num为空,返回即可
*/
// @Override
// public synchronized void testLock() {
// // 利用缓存中的StringRedisTemplate,获取到当前的num数据值
// String num = redisTemplate.opsForValue().get("num");
// if (StringUtils.isEmpty(num)) {
// return;
// }
// // 如果num不为空,则需要对当前值+1操作
// int numValue = Integer.parseInt(num);
// // 写回缓存
// redisTemplate.opsForValue().set("num", String.valueOf(++numValue));
// }
/**
* 使用redis分布式锁版
*/
@Override
public void testLock() {
// setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁。
String uid = UUID.randomUUID().toString();
// 1. 使用setnx命令 setnx lock ok,并设置过期时间 3m(设置过期时间,自动释放锁。)
Boolean flag = redisTemplate.opsForValue().setIfAbsent("lock",uid,3, TimeUnit.MINUTES);
// 2. 判断flag是否为true;
if(flag){
// flag = true 表示获取到锁,执行业务
String num = redisTemplate.opsForValue().get("num");
//如果num没有设置初始值,则不执行业务
if(StringUtils.isEmpty(num)){
return;
}
int newnum = Integer.parseInt(num);
redisTemplate.opsForValue().set("num", String.valueOf(++newnum));
// 释放lock锁
// 定义一个lua脚本,保证删除的原子性
// String secript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// // 准备执行lua 脚本
// DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// // 设置lua脚本
// redisScript.setScriptText(secript);
// // 设置DefaultRedisScript 这个对象的泛型
// redisScript.setResultType(Long.class);
// redis调用lua脚本
// redisTemplate.execute(redisScript, Arrays.asList("lock"), uid);
// 释放lock锁
if(uid.equals(redisTemplate.opsForValue().get("lock"))){
redisTemplate.delete("lock");
}
}else {
// 没有获取到锁
try {
Thread.sleep(100);
// 每隔1秒钟回调一次,再次尝试获取锁(自旋)
testLock();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
最后总结
为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
互斥性。在任意时刻,只有一个客户端能持有锁;
不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁;
解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了;
加锁和解锁必须具有原子性
感谢俩位老哥博客的指点:
(30条消息) 使用 Redis 实现分布式锁案例_SuZhan0711的博客-CSDN博客_redis分布式锁使用实例
什么是分布式锁?实现分布式锁的三种方式 - 刘清政 - 博客园 (cnblogs.com)
------------------------------------------------------------------------------------------------------------------
更新:
使用Redis实现分布式锁主要有两种经典方案:基于SETNX命令的传统实现和基于SET命令的更安全实现(推荐使用)。
应用上下文,创建一个简单的应用上下文类,用于初始化Jedis客户端
import redis.clients.jedis.Jedis;
public class AppContext {
public static Jedis jedis = new Jedis("localhost"); // 连接到本地Redis实例,默认端口6379
}
方案一:基于SETNX的传统实现(不推荐)
注意:此方法存在一些风险,比如锁的自动释放问题,因此推荐使用下面的SET命令方法。
import redis.clients.jedis.Jedis;
public class DistributedLockWithSetNX {
private Jedis jedis;
private static final String LOCK_KEY = "my_lock";
public DistributedLockWithSetNX(Jedis jedis) {
this.jedis = jedis;
}
/**
* 尝试获取锁
* @param requestId 请求标识,用于释放锁时验证
* @param timeout 锁超时时间,单位:秒
* @return 是否获取锁成功
*/
public boolean lock(String requestId, int timeout) {
long expires = System.currentTimeMillis() + timeout * 1000;
String expiresStr = String.valueOf(expires);
// 使用SETNX命令尝试设置锁,如果设置成功(即锁未被其他线程持有),则返回true
if (jedis.setnx(LOCK_KEY, expiresStr) == 1) {
// 设置成功,锁被当前线程持有
return true;
}
// 检查锁是否超时,如果是超时的锁则尝试获取
String currentValue = jedis.get(LOCK_KEY);
if (currentValue != null && Long.parseLong(currentValue) < System.currentTimeMillis()) {
// 获取锁的过期时间,并检查是否过期
String oldValue = jedis.getSet(LOCK_KEY, expiresStr);
// 防止并发情况下多个线程同时检查到锁过期并尝试获取
if (oldValue != null && oldValue.equals(currentValue)) {
// 成功获取过期的锁
return true;
}
}
// 锁未获取成功或已被其他线程持有
return false;
}
/**
* 释放锁
* @param requestId 请求标识,用于校验锁的归属
* @return 是否释放锁成功
*/
public boolean unlock(String requestId) {
String currentValue = jedis.get(LOCK_KEY);
if (currentValue != null && currentValue.equals(requestId)) {
// 删除锁,只有锁的持有者才能成功删除
jedis.del(LOCK_KEY);
return true;
}
return false;
}
}
使用基于SETNX的锁
public class CounterWithSetNX {
public void incrementCounter(int delta) {
DistributedLockWithSetNX lock = new DistributedLockWithSetNX(AppContext.jedis);
String requestId = UUID.randomUUID().toString(); // 生成唯一的请求ID
int timeout = 5; // 锁超时时间,单位:秒
try {
// 尝试获取锁
while (!lock.lock(requestId, timeout)) {
Thread.sleep(100); // 如果没获取到锁,稍后再试
}
// 执行更新操作,这里仅为示例,实际可能涉及数据库操作等
int currentCount = getCountFromSomewhere(); // 假设这是从某处获取当前计数的方法
int newCount = currentCount + delta;
saveNewCountSomewhere(newCount); // 假设这是保存新计数的方法
System.out.println("Counter incremented by " + delta + ", new count: " + newCount);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
// 释放锁
lock.unlock(requestId);
}
}
// 假设的辅助方法,实际应用中需替换为真实的逻辑
private int getCountFromSomewhere() {
return 0;
}
private void saveNewCountSomewhere(int newCount) {}
}
方案二:基于SET命令的安全实现(推荐)
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
public class DistributedLockWithSet {
private Jedis jedis;
private static final String LOCK_KEY = "my_lock";
private static final String LOCK_VALUE_PREFIX = "lock:";
public DistributedLockWithSet(Jedis jedis) {
this.jedis = jedis;
}
/**
* 尝试获取锁
* @param requestId 请求标识,用于构建锁的唯一值
* @param timeout 锁的超时时间,单位:秒
* @return 是否获取锁成功
*/
public boolean lock(String requestId, int timeout) {
String lockValue = LOCK_VALUE_PREFIX + requestId;
SetParams params = new SetParams().nx().px(timeout * 1000); // nx: set if not exists, px: milliseconds expire time
// 使用SET命令原子性地设置锁,同时设置过期时间,避免死锁问题
if (jedis.set(LOCK_KEY, lockValue, params)) {
// 锁设置成功
return true;
}
// 锁已经被其他线程获取
return false;
}
/**
* 释放锁
* @param requestId 请求标识,用于构建锁的唯一值,确保解锁安全
* @return 是否释放锁成功
*/
public boolean unlock(String requestId) {
String lockValue = LOCK_VALUE_PREFIX + requestId;
// 使用Lua脚本确保删除操作的原子性,避免因客户端延迟导致的误删他人锁
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, 1, LOCK_KEY, lockValue);
// 如果结果为1,表示锁成功删除
return result.equals(1L);
}
}
使用基于SET的锁
public class CounterWithSet {
public void incrementCounter(int delta) {
DistributedLockWithSet lock = new DistributedLockWithSet(AppContext.jedis);
String requestId = UUID.randomUUID().toString(); // 生成唯一的请求ID
int timeout = 5; // 锁超时时间,单位:秒
try {
// 尝试获取锁
while (!lock.lock(requestId, timeout)) {
Thread.sleep(100); // 如果没获取到锁,稍后再试
}
// 执行更新操作
int currentCount = getCountFromSomewhere();
int newCount = currentCount + delta;
saveNewCountSomewhere(newCount);
System.out.println("Counter incremented by " + delta + ", new count: " + newCount);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
// 释放锁
lock.unlock(requestId);
}
}
// 同上,仅为示例的辅助方法
private int getCountFromSomewhere() {
return 0;
}
private void saveNewCountSomewhere(int newCount) {}
}
注意事项:
方案一中,锁的自动续期和防止误删他人锁的逻辑较为复杂,易出错,因此在生产环境中推荐使用方案二。
方案二使用了SET命令的NX(Set if Not Exists)和PX(Expire Time in Milliseconds)选项,实现了锁的原子性设置与超时特性,更安全可靠。
解锁时推荐使用Lua脚本,确保删除操作的原子性和安全性,避免由于网络延迟等原因导致的误操作。
上述示例中,incrementCounter方法展示了如何在操作共享资源之前获取锁,操作完毕后释放锁,确保操作的原子性和一致性。
实际应用中,应当根据业务场景和Redis集群的具体配置,适当调整锁的超时时间和重试逻辑。
对于基于SET的锁,Lua脚本确保了解锁操作的原子性,提高了安全性。
请根据实际环境调整Jedis连接参数,如Redis服务器地址、端口等。