在开发过程中当我们为了提高效率,我们往往会引用多线程并行的方式来提高访问修改并发,我们在面对并发问题时,多个线程一起修改时,如果是单应用部署,直接通过synchronized关键字、 ReetrantLock 类来控制一个JVM进程内多个线程对本地共享资源的访问。
但如果是在分布式、微服务等架构下,不同的服务/客户端通常运行在独立的JVM进程上,使用本地锁的方式就不能有效的解决这个问题啦,因此就引出了一个分布式锁的概念。
下面是我画的一张示意图:
多个服务的线程同时访问同一个共享资源时,在同一时间只会有一个线程获取到,其他线程会进行阻塞(也可以直接返回获取不到锁的结果)。
基本条件:
额外条件:
在使用分布式锁实现上:目前主要可以通过三个方式实现:
在这里具体介绍通过redis的方式如何实现分布式锁
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
继续使用上一篇使用redis实现stream配置即可
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// 这个地方不可使用 json 序列化,如果使用的是ObjectRecord传输对象时,可能会有问题,会出现一个 java.lang.IllegalArgumentException: Value must not be null! 错误
redisTemplate.setHashValueSerializer(RedisSerializer.string());
return redisTemplate;
}
用于获取到分布式锁和释放分布式锁
redisTemplate.opsForValue().setIfAbsent()
, 方法实现,该方法的底层其实就是调用了execute()
方法实现了setnx
命令(先进行判定键是否存在,不存在则设置key,成功后返回true
;存在则直接返回false
)。redisTemplate.delete()
方法删除掉该key即可释放成功。但在删除之前需要通过验证值的方式,验证是否是该线程自己的锁。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.concurrent.TimeUnit;
/**
* 使用redis data的RedisTemplate的原生方式实现加锁和释放锁
*/
@Component
public class DistributedLock {
private static final String LOCK_PREFIX = "lock:";
private static final long DEFAULT_EXPIRE_TIME = 10; // 默认锁过期时间为60秒
@Autowired
private RedisTemplate<String, String> redisTemplate;
public boolean tryLock(String lockKey,String value) {
return tryLock(lockKey,value, DEFAULT_EXPIRE_TIME);
}
/**
* 获取锁
* @param lockKey
* @param value
* @param expireTime 超时时间
* @return
*/
public boolean tryLock(String lockKey,String value, long expireTime) {
String key = LOCK_PREFIX + lockKey;
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, TimeUnit.SECONDS);
return success != null && success;
}
/**
* 释放锁
* @param lockKey
* @param value
*/
public void unlock(String lockKey,String value) {
String key = LOCK_PREFIX + lockKey;
String v = redisTemplate.opsForValue().get(key);
if (!StringUtils.isEmpty(v) && value.equals(v)){
redisTemplate.delete(key);
}
}
}
该处代码主要是模拟发生秒杀情况时,多个线程一起去争抢的场景,通过写了两个一样的接口用于模拟争抢,通过设置debug的断点为Thread模拟测试。
具体业务流程:
import com.zheng.redislock.setnx.service.ProductService;
import com.zheng.redislock.setnx.util.DistributedLock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.UUID;
/**
* 测试使用redis的setnx命令实现分布式锁功能
* 使用该方式,会出现两个问题:
* 1、可能会出现误删的情况(即客户端a在进行操作,服务器发生卡顿,达到key设置的过期时间,解开了
* 锁,客户端b开始进行操作;然后在b进行操作期间,a卡顿结束,继续删锁操作,会导致误删了b的锁)
* 所以需要设置唯一值(uuid等)用于验证;
* 2、该种方式实现因为设置锁和删除锁分开的,因此缺乏原子操作,可以采用lua脚步方式实现
*/
@RequestMapping("/index")
@RestController
@Slf4j
public class Index {
@Resource
private ProductService productService;
@Resource
private RedisTemplate<String,Object> redisTemplate;
private final Long time = 1000L;
@Resource
private DistributedLock distributedLock;
/**
*@Description//TODO用户秒杀接收数据端1
*@param:goodsId商品id
*@param:userId用户id
*@return:java.lang.String
**/
@GetMapping("doSeckill1")
public String doSeckill1(String goodsId ,Integer userId ){
Long currentTime = 0L;
String value= UUID.randomUUID() + "-" + Thread.currentThread().getId();
// 通过key到redis中去获取锁,如果成功则返回true并且设置过期时间,如果失败则返回false;
while (currentTime <= time){
if (distributedLock.tryLock(goodsId,value, 60)) {
// 获取到锁,执行业务退出
try {
// 执行具体业务 ****
productService.update(goodsId, userId);
} finally {
// 删除key
distributedLock.unlock(goodsId,value);
}
log.info("一号直接获取锁成功");
return "库存扣减成功";
}
// 未获取到继续自旋,并且暂停100毫秒
try {
Thread.sleep(100);
log.warn("一号自旋获取结果:失败,继续重试,时间{}",currentTime);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
currentTime+=100;
}
return "库存扣减失败";
}
/**
*@Description//TODO用户秒杀接收数据端2
*@param:goodsId商品id
*@param:userId用户id
*@return:java.lang.String
**/
@GetMapping("doSeckill2")
public String doSeckill2(String goodsId ,Integer userId ){
Long currentTime = 0L;
String value= UUID.randomUUID() + "-" + Thread.currentThread().getId();
// 通过key到redis中去获取锁,如果成功则返回true并且设置过期时间,如果失败则返回false;
while (currentTime <= time){
if (distributedLock.tryLock(goodsId,value, 60)) {
// 获取到锁,执行业务退出
try {
// 执行具体业务 ****
productService.update(goodsId, userId);
} finally {
// 删除key
distributedLock.unlock(goodsId,value);
}
log.info("二号直接获取锁成功");
return "库存扣减成功";
}
// 未获取到继续自旋,并且暂停100毫秒
try {
Thread.sleep(100);
log.warn("二号自旋获取结果:失败,继续重试,时间{}",currentTime);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
currentTime+=100;
}
return "库存扣减失败";
}
}
当多个线程同时争抢同一个redis锁时,如果这些线程都是通过使用 RedisTemplate 的 delete() 方法来释放锁的话,可能会出现下面情况:
线程a成功获取到锁,并且设置了一个过期时间;
这时线程a去调用了 RedisTemplate 的 delete() 方法来释放锁,这时因为持有锁的其实已经是线程b的锁了,所以就会把线程b的锁给释放掉,从而到导致线程b到锁失效。
因此,使用 RedisTemplate 的 delete() 方法来释放锁的方式可能存在删除其他线程获取的锁的风险。如果要避免这种情况发生,可以使用Redis到lua脚步,在执行释放锁时,把进行验证锁的value值和删除操作去保证一个原子操作。从而避免误删其他线程的锁
基本配置同上
在上面的demo中虽然获取锁使用的setnx保证了原则性,但在删除释放锁时,因为需要先验证value值,是分开两步操作的,就没法保证原子性了,所以需要引用lua脚步去保证。
在使用lua脚本的使用方式可以使用redisTemplate.execute()
方法,这个方法总共三个参数。
script
:用于封装脚本和执行完返回的参数;
keys
:对应着redis的键名的集合;
args
:数组,按照顺序对应着lua脚本中的每一个argv[]
值,可以多个
获取锁:
private static final String LOCK_SCRIPT =
"if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return true; " +
"else return false; " +
"end";
释放锁
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"redis.call('del', KEYS[1]); " +
"return true; " +
"else return false; " +
"end";
具体代码:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import java.util.Arrays;
/**
* 使用redis加lua脚步方式实现加锁和释放锁
*/
@Component
public class DistributedLuaLock {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String LOCK_PREFIX = "luaLock:";
// 锁的过期时间,单位毫秒
private static final long LOCK_EXPIRE_TIME = 60000;
// 获取锁的 Lua 脚本:
private static final String LOCK_SCRIPT =
"if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return true; " +
"else return false; " +
"end";
// 释放锁的 Lua 脚本:
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"redis.call('del', KEYS[1]); " +
"return true; " +
"else return false; " +
"end";
public boolean luaLock(String key,String value){
return luaLock(key,value, LOCK_EXPIRE_TIME);
}
/**
* 获取分布式锁
* @param key
* @param value
* @param lockTime 超时时间(毫秒)
* @return
*/
public boolean luaLock(String key,String value,Long lockTime){
String[] args = {value,String.valueOf(lockTime)};
RedisScript<Boolean> script = new DefaultRedisScript<>(LOCK_SCRIPT, Boolean.class);
Boolean result = redisTemplate.execute(script, Arrays.asList(LOCK_PREFIX+key), args);
return result != null && result;
}
/**
* 释放锁
* @param key
* @param value
* @return
*/
public boolean unlock(String key,String value){
String[] args = {value};
DefaultRedisScript<Boolean> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Boolean.class);
Boolean result = redisTemplate.execute(script, Arrays.asList(LOCK_PREFIX+key), args);
return result != null && result;
}
}
具体执行业务逻辑上和上面的demo逻辑都是一样的,只不过这里在释放锁的时候,使用lua脚本的方式把验证value值和删除键绑定为一个原子操作了
/**
*lua脚本方式实现接口
*/
@RequestMapping("/indexLua")
@RestController
@Slf4j
public class IndexLua {
@Resource
private ProductService productService;
private final Long time = 1000L;
@Resource
private DistributedLuaLock distributedLuaLock;
/**
* 1
**/
@GetMapping("doSeckill1")
public String doSeckill1(String goodsId ,Integer userId ){
Long currentTime = 0L;
String value= UUID.randomUUID() + "-" + Thread.currentThread().getId();
// 通过key到redis中去获取锁,如果成功则返回true并且设置过期时间,如果失败则返回false;
while (currentTime <= time){
if (distributedLuaLock.luaLock(goodsId,value, 60000l)) {
// 获取到锁,执行业务退出
try {
// 执行具体业务 ****
productService.update(goodsId, userId);
} finally {
// 删除key
distributedLuaLock.unlock(goodsId,value);
}
log.info("一号直接获取锁成功");
return "库存扣减成功";
}
// 未获取到继续自旋,并且暂停100毫秒
try {
Thread.sleep(100);
log.warn("一号自旋获取结果:失败,继续重试,时间{}",currentTime);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
currentTime+=100;
}
return "库存扣减失败";
}
/**
* 2
**/
@GetMapping("doSeckill2")
public String doSeckill2(String goodsId ,Integer userId ){
Long currentTime = 0L;
String value= UUID.randomUUID() + "-" + Thread.currentThread().getId();
// 通过key到redis中去获取锁,如果成功则返回true并且设置过期时间,如果失败则返回false;
while (currentTime <= time){
if (distributedLuaLock.luaLock(goodsId,value, 60000l)) {
// 获取到锁,执行业务退出
try {
// 执行具体业务 ****
productService.update(goodsId, userId);
} finally {
// 删除key
distributedLuaLock.unlock(goodsId,value);
}
log.info("二号直接获取锁成功");
return "库存扣减成功";
}
// 未获取到继续自旋,并且暂停100毫秒
try {
Thread.sleep(100);
log.warn("二号自旋获取结果:失败,继续重试,时间{}",currentTime);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
currentTime+=100;
}
return "库存扣减失败";
}
}
虽然使用lua脚本的方式实现可以有效的解决非原子性造成的删除错锁的问题,看似是已经很完美了;但该种方式在设置锁时,设计超时时间是固定的,如上我们设置的是60秒,但实际开发过程中,我们可能因为某些特定的业务场景以及系统问题可能会有不同,所以在设置锁的过期时间上需要根据实际业务需求来进行设置,如果过短可能会导致频繁获取地去获取锁,如果过长则可能会造成失效不及时的问题。
在解决这个问题上可以采用下面的Redisson
来实现,Redisson
使用来一种叫看门狗的机制实现对锁的自动续期,可以有效的帮我们解决锁的超时时间设置不当的问题。
Redisson是一个基于Redis的分布式Java对象和服务框架,它提供了一系列的分布式对象和服务,使得在Java应用程序中使用Redis变得更加方便和高效。
<dependency>
<groupId>org.redissongroupId>
<artifactId>redisson-spring-boot-starterartifactId>
<version>3.15.6version>
dependency>
Redisson在内部给我们提供了很多锁供我们选择。主要通过redissonClient接口去获取到不同的锁对象。
Redisson常用分布式锁对象:
RLock lock = redissonClient.getLock(key)
// 可重入锁(最常用的)RLock fairLock = redissonClient.getFairLock(key)
;//公平锁RLock spinLock = redissonClient.getSpinLock(key);
//自旋锁RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(key);
//读写锁RLock multiLock = redissonClient.getMultiLock(lock);
//多重锁
获取锁方式:
获取锁的方式上主要有两种方式获取,一种上阻塞的方式去获取;一种上通过非阻塞的方式获取。
lock.lock()
:阻塞的方式获取,使用该方式去获取,如果没有获取到锁时,会一直阻塞请求获取锁,直到获取到锁为主;如果不设置超时时间则默认使用看门狗功能自动续期(一般不介意设置超时时间)。默认30秒为最大时间10秒续期一次,续期锁调用RedissonBaseLock
类的renewExpirationAsync()
方法实现锁的异步续期lock.tryLock(expireTime,TimeUnit.SECONDS)
:非阻塞的方式获取锁,最多可以设置三个参数,分别指定重试时间,锁过期时间,和时间单位;一般建议设置一个重试时间,如果指定时间没获取则返回false,因为毕竟是非阻塞执行的嘛,如果不设置则会一直去请求获取锁。释放锁:则直接通过lock.unlock()进行释放即可
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* 使用redisson方式实现加锁和释放锁
*/
@Component
public class RedissonLock {
@Resource
private RedissonClient redissonClient;
private static final String LOCK_PREFIX = "sonLock:";
/**
* 阻塞方式获取锁
* @param key
* @param expireTime
* @return
*/
public RLock lock(String key,Integer expireTime){
// 可重入锁
RLock lock = redissonClient.getLock(LOCK_PREFIX + key);
// redissonClient.getFairLock(); //公平锁
// redissonClient.getSpinLock(); //自旋锁
// redissonClient.getReadWriteLock(); //读写锁
// redissonClient.getMultiLock(); //多重锁
// waitTime:设置超时时间(),unit:时间单位
// 超时过期时间我们一般不需要设置redisson内部实现了看门狗自动续时功能。
// 看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。
// lock.lock(expireTime, TimeUnit.SECONDS); //阻塞方式获取锁,设置过期时间
lock.lock();
return lock;
}
/**
* 非阻塞方式获取锁
* @param key
*/
public Boolean tryLock(String key,Integer expireTime){
try {
RLock lock = redissonClient.getLock(LOCK_PREFIX + key);
// waitTime:获取锁重试时间,leaseTime:设置超时时间,unit:时间单位
// lock.tryLock(10,expireTime,TimeUnit.SECONDS);
return lock.tryLock(10,TimeUnit.SECONDS); //非阻塞方式获取锁,设置在指定时间内失败重试获取锁
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
/**
* 释放锁
* @param key
*/
public void unlock(String key) {
RLock lock = redissonClient.getLock(LOCK_PREFIX+key);
if (lock.isLocked()) {
lock.unlock();
}
}
}
通过redsson实现,在业务调用上相对就比较简单了,内部已经做过相应的重试封装了。
内部两个接口分别是调用阻塞和非阻塞方式获取锁去实现分布式锁功能。
import com.zheng.redislock.setnx.service.ProductService;
import com.zheng.redislock.setnx.util.DistributedLuaLock;
import com.zheng.redislock.setnx.util.RedissonLock;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.UUID;
@RequestMapping("/indexson")
@RestController
@Slf4j
public class Indexson {
@Resource
private ProductService productService;
private final Long time = 1000L;
@Resource
private RedissonLock redissonLock;
/**
*非阻塞方式获取锁
*@param:goodsId商品id
*@param:userId用户id
*@return:java.lang.String
**/
@GetMapping("doSeckill1")
public String doSeckill1(String goodsId ,Integer userId ){
Boolean b = redissonLock.tryLock(goodsId, 30);
if (b) {
// 获取到锁,执行业务退出
try {
// 执行具体业务 ****
productService.update(goodsId, userId);
} finally {
// 删除key
redissonLock.unlock(goodsId);
}
log.info("一号直接获取锁成功");
return "库存扣减成功";
}
return "库存扣减失败";
}
/**
*阻塞方式获取锁
*@param:goodsId商品id
*@param:userId用户id
*@return:java.lang.String
**/
@GetMapping("doSeckill2")
public String doSeckill2(String goodsId ,Integer userId ){
RLock lock = redissonLock.lock(goodsId,30);
try {
// 执行具体业务 ****
productService.update(goodsId, userId);
} finally {
if (lock.isLocked()){
lock.unlock();
}
}
return "库存扣减成功";
}
}
使用redisson实现基本上就没有什么漏洞了,因为 Redisson 本身就是基于 Redis 的分布式锁实现。但在使用的时间需要根据转身业务情况看是使用阻塞方式获取还是非阻塞方式获取,如果是非阻塞的方式将需要去设置尝试获取锁的最大等待时间避免线程一直阻塞的去获取锁。
当然如果锁集群环境下可能会存在由于数据分片和主从复制等机制造成的节点未能及时同步等问题;这个的话可以使用Redis 的 RedLock 算法来实现分布式锁。算法可以在多个 Redis 节点之间进行协作,确保锁的正确性和可靠性。具体这里就不做讲解了