上篇博客演示了,在MySQL在高并发下数据异常和解决方案,这里解决redis数据问题。
引入redis依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
这里因为都是默认:地址和端口:localhost:6379,用户名密码都为空,数据库为0,不需要进行额外的配置。
修改service
方法:
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Integer deStock() {
String stock = stringRedisTemplate.opsForValue().get("stock");
if (!Objects.isNull(stock) && stock.length() != 0){
int stock_num = Integer.parseInt(stock);
if (stock_num >= 1){
stringRedisTemplate.opsForValue().set("stock",String.valueOf(--stock_num));
}
}
return 0;
}
发现这里数据出现了异常。
在redis客户端中,使用下方:
watch
:监听指定一个或多个键的值,在当前事务执行之前被监听的键发生了变化,那么就取消执行。multi
:开启事务;exec
:执行事务;修改service
方法:
@Override
public Integer deStock() {
// 监听k
stringRedisTemplate.execute(new SessionCallback<Object>() {
@SneakyThrows
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
// 开启 watch
operations.watch("stock");
String stock = operations.opsForValue().get("stock").toString();
if (!Objects.isNull(stock) && stock.length() != 0){
int stock_num = Integer.parseInt(stock);
if (stock_num >= 1){
// 开启事务
operations.multi();
operations.opsForValue().set("stock",String.valueOf(--stock_num));
// 执行事务
List exec = operations.exec();
// 如果执行失败,递归调用
if (exec == null || exec.isEmpty()){
Thread.sleep(40);
deStock();
}
}
}
return null;
}
});
return 0;
}
缺陷分析:
可以跨服务、跨进程、跨服务器的来使用。在分布式场景下,可以对资源进行加锁。
reids中有
setnx k v
命令,在执行时,如果当前k不存在就返回1,存在就返回0,可以使用当前的k来作为分布式锁,返回1代表获取锁,返回0代表获取锁失败。
setnx k v
;del k
;就是简单的使用StringRedisTemplate
的方法去调用redis的setnx
命令。
@Override
public String deStock(){
// 加锁
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "111");
// 获取锁失败,递归重试
if (Boolean.FALSE.equals(lock)){
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
deStock();
}else {
try {
String stock = stringRedisTemplate.opsForValue().get("stock");
if (Objects.nonNull(stock) && stock.length() != 0){
int stock_num = Integer.parseInt(stock);
if (stock_num > 0){
stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
}
}
}finally {
// 释放锁
stringRedisTemplate.delete("lock");
}
}
return stringRedisTemplate.opsForValue().get("stock");
}
优化: 将递归重试改为循环重试,节省栈内存空间。
public String deStock(){
// 加锁
while (Boolean.FALSE.equals(stringRedisTemplate.opsForValue().setIfAbsent("lock", "111"))){
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
String stock = stringRedisTemplate.opsForValue().get("stock");
if (Objects.nonNull(stock) && stock.length() != 0){
int stock_num = Integer.parseInt(stock);
if (stock_num > 0){
stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
}
}
}finally {
// 释放锁
stringRedisTemplate.delete("lock");
}
return stringRedisTemplate.opsForValue().get("stock");
}
问题分析: 如果服务从redis中获取到锁之后,redis立马宕机,当前服务就可能回触发死锁问题。可以给锁添加过期时间来解决。
优化:
public String deStock(){
// 加锁并给锁设置过期时间避免死锁问题。
while (Boolean.FALSE.equals(
stringRedisTemplate.opsForValue().setIfAbsent("lock", "111", 3, TimeUnit.SECONDS))){
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
String stock = stringRedisTemplate.opsForValue().get("stock");
if (Objects.nonNull(stock) && stock.length() != 0){
int stock_num = Integer.parseInt(stock);
if (stock_num > 0){
stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
}
}
}finally {
// 释放锁
stringRedisTemplate.delete("lock");
}
return stringRedisTemplate.opsForValue().get("stock");
}
问题分析: 如果每个锁都设置相同的key,那么岂不是其他的方法也可以释放我的锁,这怎么行?
我的就是我的!
优化: 设置UUID来解决
@Override
public String deStock(){
// 设置唯一标识UUID
String uuid = UUID.randomUUID().toString();
// 加锁
while (Boolean.FALSE.equals(
stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS))){
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
String stock = stringRedisTemplate.opsForValue().get("stock");
if (Objects.nonNull(stock) && stock.length() != 0){
int stock_num = Integer.parseInt(stock);
if (stock_num > 0){
stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
}
}
}finally {
// 判断是不是自己的锁再解锁,释放锁
if (uuid.equals(stringRedisTemplate.opsForValue().get("lock"))){
stringRedisTemplate.delete("lock");
}
}
return stringRedisTemplate.opsForValue().get("stock");
}
问题分析: 当第一个请求执行时间超过了redis中的过期时间,即当前请求的锁没有了,那么他再执行删除时就会删除后面请求的锁,导致后面的请求无锁可用,那么就回造成数据异常问题。如果我们能够保证这条锁命令(判断和删除)的原子性就可以解决当前问题,但显然无法实现一条语句即判断又删除。请看下部分,lua脚本。
redis 中支持lua脚本,可以一次性发送多个指令给redis,而且redis是单线程执行了,在执行指令过程中不可能会被打断,这样就满足了操作的原子性。
写lua脚本解决分布式锁原子性问题:
-- 判断 k1 获取的值是否等于 v1
-- 也就是传入锁名称和uuid,根据锁名称获取uuid,判断和传入的uuid是否相等
if redis.call('get', KEYS[1]) == ARGV[1]
then
return redis.call('del', KEYS[1]) -- 相等删除key(释放锁)
else
return 0
end
修改Java方法 :
@Override
public String deStock(){
// 设置唯一标识UUID
String uuid = UUID.randomUUID().toString();
// 加锁
while (Boolean.FALSE.equals(
stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS))){
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
String stock = stringRedisTemplate.opsForValue().get("stock");
if (Objects.nonNull(stock) && stock.length() != 0){
int stock_num = Integer.parseInt(stock);
if (stock_num > 0){
stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_num));
}
}
}finally {
try {
// 读取lua脚本
String script = new String(Files.readAllBytes(Paths.get("lock.lua")));
// 使用lua脚本保证原子性
stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Collections.singletonList("lock"), uuid);
} catch (IOException e) {
e.printStackTrace();
}
}
return stringRedisTemplate.opsForValue().get("stock");
}
ReentrantLock
就是一把可重入锁。ReentrantLock
构造函数:默认是一个非公平锁,只有在传入true
时才会使用公平锁。非公平锁就是不按照进入到工作队列的顺序来执行。
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
以非公平锁为例,查看可重入锁的加锁流程:
ReentrantLock.lock()
; // 根据构造的sync对象是公平锁还是非公平锁调用指定方法
public void lock() {
sync.lock();
}
NonfairSync.lock()
; // 加锁方法
final void lock() {
// 使用unSafe类来实现原子性加锁,加锁成功将state设置为1,并且记录当前线程为有锁线程。
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
AQS.acquire(1)
; public final void acquire(int arg) {
//
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
NonfairSync.tryAcquire(1)
; protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
Sync.nonfairTryAcquire(1)
final boolean nonfairTryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取state值
int c = getState();
if (c == 0) {
// 如果等于0 就原子性的设置为1
if (compareAndSetState(0, acquires)) {
// 记录当前线程为有锁线程
setExclusiveOwnerThread(current);
return true;
}
}
// 如果当前线程就是有锁的线程
else if (current == getExclusiveOwnerThread()) {
// 对齐加一
int nextc = c + acquires;
if (nextc < 0) // 值异常抛出异常
throw new Error("Maximum lock count exceeded");
// 设置新的state
setState(nextc);
return true;
}
return false;
}
简要说明加锁流程:
state == 0
,加锁成功就记录当前线程为有锁线程;state != 0
,说明锁已经被占用,判断当前线程是否为有锁线程,如果是就重入(state + 1);以非公平锁为例,查看可重入锁的解锁流程:
ReentrantLock.unlock()
; public void unlock() {
sync.release(1);
}
AQS.release(1)
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
Sync.tryRelease(1)
protected final boolean tryRelease(int releases) {
// state - 1
int c = getState() - releases;
// 判断当前线程是否为有锁线程
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException(); // 不是抛出异常
boolean free = false;
if (c == 0) { // 当state值减为0时,代表当前线程可以直接释放锁
free = true;
setExclusiveOwnerThread(null); // 设置有锁线程为空
}
// 重新设置state值,如果为0就标识其他线程可以获取锁了。
// 如果不为0,那么就当前线程还是有锁的线程,只是state值减一。
setState(c);
return free;
}
数据模型选择Hash模型,让key作为hash的key,uuid作为内部的key,其对应的value为state的值。使用lua脚本来保证原子性实现。
加锁过程分析:
加锁脚本:
-- 可重入锁 加锁
-- KEYS[1]:锁名称
-- ARGV[1]:uuid;ARGV[2]:过期时间
if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1
then
-- 这里有一个细节:使用 hincrby 和 hset 都可以创建hash类型数据
redis.call('hincrby', KEYS[1], ARGV[1] ,1)
redis.call('expire', KEYS[1], ARGV[2])
return 1
else
return 0
end
解锁过程分析:
解锁脚本:
-- 可重入锁 解锁
-- KEYS[1]:锁名称
-- ARGV[1]:uuid
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 -- 判断锁是否存在
then
return nil -- 不存在就是恶意释放,抛出异常
elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 -- 判断减一后是否为 0
then
return redis.call('del', KEYS[1]) -- 为0直接删除key,即释放锁
else
return 0 -- 不为0,不做额外处理
end
代码实现:工厂类返回对应的锁类
@Component
public class ReentryLockFactory {
@Resource
private StringRedisTemplate stringRedisTemplate;
private String uuid;
// 保证一个服务中只有一个uuid
public ReentryLockFactory() {
this.uuid = UUID.randomUUID().toString();
}
public RedisReentryLock getRedisLock(String lockName){
return new RedisReentryLock(stringRedisTemplate, lockName ,uuid);
}
}
redis锁类:
public class RedisReentryLock implements Lock {
private StringRedisTemplate stringRedisTemplate;
private String lockName;
private String uuid;
private Long expire = 30L;
public RedisReentryLock(StringRedisTemplate stringRedisTemplate, String lockName, String uuid) {
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
// uuid用来标识服务,线程id用来标识线程
this.uuid = uuid + ":" + Thread.currentThread().getId();
}
@Override
public void lock() {
this.tryLock();
}
@Override
public boolean tryLock() {
try {
return this.tryLock(-1L, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
/**
* 实现加锁方法
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (time != -1){ // 设置过期时间
expire = unit.toSeconds(time);
}
try {
// 获取加锁lua脚本
Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\reentry-lock.lua");
String script = new String(Files.readAllBytes(path));
while (Boolean.FALSE.equals(stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Collections.singletonList(lockName),
uuid, String.valueOf(expire)))){
Thread.sleep(50);
}
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
/**
* 实现解锁方法
*/
@Override
public void unlock() {
try {
// 获取解锁lua脚本
Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\reentry-unlock.lua");
String script = new String(Files.readAllBytes(path));
Long flag = stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockName),
uuid, String.valueOf(expire));
if (Objects.isNull(flag)){
throw new IllegalStateException("this lock not belong to you");
}
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public Condition newCondition() {
return null;
}
@Override
public void lockInterruptibly() throws InterruptedException {
}
}
service方法:
/**
* lua脚本实现可重入锁
* @return
*/
@Override
public String deStock(){
RedisReentryLock redisLock = reentryLockFactory.getRedisLock("lock");
redisLock.lock();
try {
String stock = stringRedisTemplate.opsForValue().get("stock");
if (Objects.nonNull(stock) && stock.length() != 0){
int stock_mum = Integer.parseInt(stock);
if (stock_mum > 0){
stringRedisTemplate.opsForValue().set("stock", String.valueOf(--stock_mum));
}
}
}finally {
redisLock.unlock();
}
return stringRedisTemplate.opsForValue().get("stock");
}
}
保证加锁方法执行完毕之前锁不会过期。
思路分析:使用Java自带的Timer
定时器来定时执行方法,使用lua脚本来保证更新时间的原子性。
lua脚本:
-- KEYS[1]:锁名称
-- ARGV[1]:uuid;ARGV[2]:过期时间
if redis.call('hexists', KEYS[1], ARGV[1]) == 1
then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
修改RedisReentryLock
:
public class RedisReentryLock implements Lock {
private StringRedisTemplate stringRedisTemplate;
private String lockName;
private String uuid;
private Long expire = 30L;
public RedisReentryLock(StringRedisTemplate stringRedisTemplate, String lockName, String uuid) {
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
// uuid用来标识服务,线程id用来标识线程
this.uuid = uuid + ":" + Thread.currentThread().getId();
}
@Override
public void lock() {
this.tryLock();
}
@Override
public boolean tryLock() {
try {
return this.tryLock(-1L, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
/**
* 实现加锁方法
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (time != -1){ // 设置过期时间
expire = unit.toSeconds(time);
}
try {
// 获取加锁lua脚本
Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\reentry-lock.lua");
String script = new String(Files.readAllBytes(path));
while (Boolean.FALSE.equals(stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Collections.singletonList(lockName),
uuid, String.valueOf(expire)))){
Thread.sleep(50);
}
// 开启定时器自动续期
renewExpire();
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
/**
* 实现解锁方法
*/
@Override
public void unlock() {
try {
// 获取解锁lua脚本
Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\reentry-unlock.lua");
String script = new String(Files.readAllBytes(path));
Long flag = stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockName),
uuid, String.valueOf(expire));
if (Objects.isNull(flag)){
throw new IllegalStateException("this lock not belong to you");
}
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public Condition newCondition() {
return null;
}
@Override
public void lockInterruptibly() throws InterruptedException {
}
/**
* 自动续期方法
*/
private void renewExpire(){
Path path = Paths.get("D:\\NewJava\\JavaFramework\\distributed-lock\\distributed-parent\\distributed-service8001\\src\\main\\resources\\auto-renewal.lua");
try {
String script = new String(Files.readAllBytes(path));
new Timer().schedule(new TimerTask() {
@Override
public void run() {
// 如果续期成功就再次执行该方法,直到续期失败
if (Boolean.TRUE.equals(stringRedisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Collections.singletonList(lockName),
uuid, String.valueOf(expire)))) {
renewExpire();
}
}
}, this.expire * 1000 / 3); // 只执行一次定时任务。
} catch (IOException e) {
e.printStackTrace();
}
}
}
问题分析:当处于redis集群环境时,当客户端A从master中获取锁,但是还没同步slave中,master就挂掉了,此时slave被选为master,那么客户端B在进入方法时是没有锁的,这就导致了锁机制失效。
如下图中,有五个单机的redis服务器,没有任何的关联机制。
消耗时间 = 客户端当前时间 - 获取每个节点redis锁消耗的总时间
;锁定时间 = 锁定时间 - 消耗时间
;