核心思想
实现分布式锁,可以借助redis的SETNX命令完成,该命令设置值时,如果key不存在,为key设置指定的值,返回1,如果存在返回0,也就意味着相同的key只能设置成功一次,假设有多个线程同时设置值,只能有一个设置成功,这样就得到互斥的效果,也就可以达到锁的效果
setnx命令演示
192.168.150.101:0>SETNX abc 123
"1" ---设置成功
192.168.150.101:0>SETNX abc 123
"0" ---设置失败
192.168.150.101:0>SETNX abc 123
"0" ---设置失败
192.168.150.101:0>get abc
"123" ---可以正常查询值
用图演示一下商品的并发操作
使用Redis实现分布式锁
1.定义一个锁接口
package com.sl.pay.lock;
public interface ILock {
/**
* 尝试获取锁
*
* @param name 锁的名称
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true表示获取锁成功,false表示获取锁失败
*/
boolean tryLock(String name, Long timeoutSec);
/**
* 释放锁
*/
void unlock(String name);
}
2.创建锁的实现类
package com.sl.pay.lock;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Component
public class SimpleRedisLock implements ILock {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
@Override
public boolean tryLock(String name, Long timeoutSec) {
// 获取线程标示
String threadId = Thread.currentThread().getId() + "";
// 获取锁 setIfAbsent()是SETNX命令在java中的体现
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock(String name) {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
3.业务中注入锁对象,使用锁
package com.sl.pay.lock;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.IdUtil;
import com.sl.pay.entity.TradingEntity;
import com.sl.pay.handler.NativePayHandler;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Service
public class NativePayService {
@Resource(name = "aliNativePayHandler")
private NativePayHandler nativePayHandler;
@Resource
private SimpleRedisLock simpleRedisLock;
/**
* 创建交易单示例代码
*
* @param productOrderNo 订单号
* @return 交易单对象
*/
public TradingEntity createDownLineTrading(Long productOrderNo) {
TradingEntity tradingEntity = new TradingEntity();
tradingEntity.setProductOrderNo(productOrderNo);
//基于订单创建交易单
tradingEntity.setTradingOrderNo(IdUtil.getSnowflakeNextId());
tradingEntity.setCreated(LocalDateTime.now());
tradingEntity.setTradingAmount(BigDecimal.valueOf(1));
tradingEntity.setMemo("运费");
//调用三方支付创建交易
this.nativePayHandler.createDownLineTrading(tradingEntity);
return tradingEntity;
}
/**
* 创建交易单示例代码
*
* @param productOrderNo 订单号
* @return 交易单对象
*/
public TradingEntity createDownLineTradingLock(Long productOrderNo) {
//获取锁
String lockName = Convert.toStr(productOrderNo);
boolean lock = this.simpleRedisLock.tryLock(lockName, 5L);
if (!lock) {
System.out.println("没有获取到锁,线程id = " + Thread.currentThread().getId());
return null;
}
System.out.println("获取到锁,线程id = " + Thread.currentThread().getId());
TradingEntity tradingEntity = createDownLineTrading(productOrderNo);
//释放锁
this.simpleRedisLock.unlock(lockName);
return tradingEntity;
}
}
分布式锁的问题分析
自己基于Redis实现基本上是ok的,但是仔细分析会发现一些问题,比如:设置持有锁的时间为5秒,而程序所运行的时间大于5秒,这样就会出现,程序还没结束锁已经释放了,其他线程就可以获取到这个锁,而当前线程在释放锁时,就会把其他线程的锁删除了,最终可能会导致脏数据。
为了解决这个问题,我们可以在删除时判断一下,看是存储的值是否是当前线程的id,是就删除,不是就不删除。
代码实现:
@Override
public void unlock(String name) {
// 获取线程标示
String threadId = Thread.currentThread().getId() + "";
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
各种问题
基于Redis实现分布式锁需要解决的问题非常多,实现非常的复杂,而Redisson已经完美的实现并且解决了这些问题,我们可以直接使用
Redisson快速入门
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现
GitHub地址:https://github.com/redisson/redisson
使用步骤:
1.导入地址
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
</dependency>
2.编写配置类
package com.sl.pay.config;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import lombok.Data;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.annotation.Resource;
@Data
@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class RedissonConfiguration {
@Resource
private RedisProperties redisProperties;
@Bean
public RedissonClient redissonSingle() {
Config config = new Config();
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort());
if (null != (redisProperties.getTimeout())) {
//设置持有时间
serverConfig.setTimeout(1000 * Convert.toInt(redisProperties.getTimeout().getSeconds()));
}
if (StrUtil.isNotEmpty(redisProperties.getPassword())) {
//设置密码
serverConfig.setPassword(redisProperties.getPassword());
}
//创建RedissonClient
return Redisson.create(config);
}
}
3.项目使用示例
@Resource
private RedissonClient redissonClient;
/**
* 创建交易单示例代码
*
* @param productOrderNo 订单号
* @return 交易单对象
*/
public TradingEntity createDownLineTradingRedissonLock(Long productOrderNo) {
//获取锁
String lockName = Convert.toStr(productOrderNo);
//获取公平锁,优先分配给先发出请求的线程
RLock lock = redissonClient.getFairLock(lockName);
try {
//尝试获取锁,最长等待获取锁的时间为5秒
if (lock.tryLock(5L, TimeUnit.SECONDS)) {
System.out.println("获取到锁,线程id = " + Thread.currentThread().getId());
//休眠5s目的是让线程执行慢一些,容易测试出并发效果
Thread.sleep(5000);
return createDownLineTrading(productOrderNo);
}
System.out.println("没有获取到锁,线程id = " + Thread.currentThread().getId());
} catch (Exception e) {
e.printStackTrace();
} finally {
//释放锁,需要判断当前线程是否获取到锁
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return null;
}
看门狗机制
在使用Redisson分布式锁时,我们有没有指定存储到Redis中锁的有效期时间?如果有的话是多久?如果程序执行时间超出这个时间会怎么样?
其实,在程序中我们并没有指定存储到Redis中锁的有效期时间,而是Redisson的默认存储时间,默认时间是30秒。如果程序的执行时间超出30秒,锁是自动删除吗,是不会的,Redisson一旦加锁成功就会启动一个watch dog【看门狗】,当时间每过期1/3时,就检查一下,如果当前线程还继续持有锁,就会重新刷新到30秒,直到最后的锁释放
可以看到,通过watch dog机制确保不会在业务程序结束之前存储到Redis的锁过期。
可以在Redisson的Config对象中设置锁的默认存时间:config.setLockWatchdogTimeout(10 * 1000);
需要注意的是,如果在获取锁时指定了leaseTime
参数,看门狗程序是不会生效的,如下:
上述的配置,锁的有效期时间为10秒,10秒后锁会自动释放,不会续期
加锁的实现原理
Redisson实现分布式锁的原理并非直接采用SETNX
命令实现,而是采用Lua
脚本实现,主要是确保redis操作的原子性操作。
原理是,获取名为myLock
的锁,其存储的是hash结构的数据,大key是myLock
,小key是<uuid>:<threadId>
,value是获取锁的次数,类似结构如下:
其具体核心源码中,加锁的核心lua脚本如下:
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
//keys[1]:为锁名,即redissonClient.getFairLock("myLock")中的myLock
//ARGV[1]:锁时间,即lock.tryLock(5L, 10L, TimeUnit.SECONDS),10*60s,默认internalLockLeaseTime=30s
//ARGV[2]:锁的唯一标识,<uuid>:<threadId>
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
//1.若key不存在,则进行加锁,返回nil,即null
"if (redis.call('exists', KEYS[1]) == 0) then " +
//1.1对key加锁,并设置加锁次数为1
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
//1.2设置key的过期时间,单位为毫秒
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//2.通过 大key + 小key的方式判断锁是否存在,如果存在,获取锁的数量次数+1,重新设置时间,返回nil,即null
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
//2.1 加锁次数+1
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
//2.2 重置过期时间
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//3.兜底处理,锁获取不成功,也就是被其它线程已占用,返回锁的剩余时间
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
加锁流程总结:
- 通过锁名进行判断,如果不存在直接加锁,并且
<uuid>:<threadId>
作为小key,value为1存储数据,就表示成功获取到锁 - 如果锁名存在,就需要判断是否是当前线程加的锁,如果是,加锁次数+1,并且重置过期时间
- 已经判断都没有满足,说明此次加锁失败,返回锁剩余的有效期时间。