redis:从入门到入土:3.分布式锁和redis锁

一:分布式锁

1.起源

在传统单机部署的情况下,可以使用Java并发处理,使用Lock/sync进行互斥控制,但注意这是单机应用,也就是所有的请求都会分配到当前服务器的JVM内部,然后映射为操作系统的线程进行处理!而这个共享变量只是在这个JVM内部的一块内存空间!

随着业务的发展,需要集群,搭建分布式之后, 一个共享变量就会在不同的JVM中分配内存.多个请求会分别操作不同的JVM内存区域的数据,变量之间不存在共享,也不具备可见性,处理的结果显而易见有问题.因此引入了分布式锁;

2.分布式锁应该具备哪些条件

可见性: 多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思;

互斥: 互斥是分布式锁的最基本的条件,使得程序串行执行;

高可用: 程序不易崩溃,时时刻刻都保证较高的可用性;

高性能: 由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能;

安全性: 安全也是程序中必不可少的一环,具备锁失效机制,防止死锁,具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败;

3.分布式锁常见实现

分布式锁的核心是实现多进程之间的互斥

3.1 数据库实现方式

数据库,如mysql,本身具有互斥锁机制;

3.1.1 思路

实现方式:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

3.1.2 逻辑
  • 1.创建一个表:
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
  `desc` varchar(255) NOT NULL COMMENT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

  • 2.想要执行某个方法,就使用这个方法名向表中插入数据:
 INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');

因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

  • 3.成功插入则获取锁,执行完成后删除对应的行数据释放锁:
delete from method_lock where method_name ='methodName';
3.1.3 问题
3.1.3.1 可用性和性能

因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;

3.1.3.2 不具备可重入的特性

因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;

3.1.3.3 没有锁失效机制

有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;

3.1.3.4 不具备阻塞锁特性

获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。

3.2 Redis实现方式

利用redis 里面的setnx 和 过期时间来实现分布式锁,利用过期时间来保证安全
具体内容参考Redis分布式锁

3.3 Zookeeper实现方式

ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名.

3.3.1 思路
  • 创建一个目录mylock;
  • 线程A想获取锁就在mylock目录下创建临时顺序节点;
  • 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
  • 线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
  • 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

这里推荐一个Apache的开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。

3.3.2 优缺点

优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。
缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。

二:Redis分布式锁

1.基本原理

实现分布式锁时需要实现的两个基本方法:

1.1 获取锁

[*] 形式为变量;
互斥: 确保只能有一个线程获取锁

# 添加锁,利用setnx的互斥性
setnx [key] [value]

# 添加锁过期时间,避免服务器宕机引起的死锁问题
expire [key] [time]

# 若setnx 成功,expire 失败,这个时候导致锁一直存在怎么处理?
set [key] [value] ex [time] nx

非阻塞: 尝试一次,成功返回true,失败返回false

1.2 释放锁

手动释放
超时释放: 获取锁时添加一个超时时间

del [key]

2.redis分布式锁1.0

interface

package com.***.flow.service;

/**
 * 自定义锁接口
 */
public interface ILock {

    /**
     * 尝试获得锁
     * 锁持有超时时间,过期后自动释放锁
     * true 代表获取锁成功 flase 代表获取表失败
     * @return
     */
    boolean tryLock();

    /**
     * 释放锁
     */
    void unLock();
}

service

package com.***.flow.impl;

import com.***.flow.service.ILock;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class RedisLock implements ILock {

    //谁注入
    private StringRedisTemplate stringRedisTemplate;
    //业务
    private String key;
    //过期时间
    private Long time;
    //时间单位
    private TimeUnit unit;

    //前缀
    private static final String prefix = "lock:";

    public RedisLock(StringRedisTemplate stringRedisTemplate, String key, Long time, TimeUnit unit) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.key = key;
        this.time = time;
        this.unit = unit;
    }


    @Override
    public boolean tryLock() {
        //获取线程id
        long id = Thread.currentThread().getId();
        String lockKey = prefix+key;
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, id + "", time, unit);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        //释放锁
        String lockKey = prefix+key;
        stringRedisTemplate.delete(lockKey);
    }
}

service

public String get(String id) {
        //获取锁对象
        RedisLock redisLock = new RedisLock(stringRedisTemplate,"user:"+id,10L, TimeUnit.MINUTES);

        //获取到锁对象
        if (!redisLock.tryLock()) {
            // 失败,重试或处理异常
            return "";
        }

        try{
            // todo:
        }catch (Exception e){

        }finally{
            // TODO 释放锁
            redisLock.unLock();
        }
        return "success";

    }

3.redis分布式锁1.0的问题和更正

3.1 问题:

锁误删: [a,b] ->(开始时间,结束时间)
举例:同时有三个线程并发

线程1获取锁 [0,1],持有锁[1,5],业务运行阻塞[1,6],这时锁由于设置了超时时间已经被释放了;

线程2 趁虚而入在[5,6]时刻,获取了锁; 线程1继续执行[6,7],把线程2的锁进行了删除. 这个行为是锁误删除

线程3在[7,+] 又可以获取到锁;就会导致线程锁被同时持有的情况;

3.2 解决方案

判断属于自己的线程才能删除;

3.2.1 逻辑
  • 尝试获取锁
    • T:获取成功,执行业务,获取锁时,存入线程标识(UUID)
      • 这个过程中发生业务超时或者宕机,自动释放锁;
      • 正常执行,判断锁标记是否属于自己?
        • T 释放锁 return true;
        • F 继续执行 return true;
    • F:return errMsg;
3.2.2 redis分布式锁1.0优化
package com.***.flow.impl;

import cn.hutool.core.lang.UUID;
import com.***.flow.service.ILock;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class RedisLock implements ILock {

    //谁注入
    private StringRedisTemplate stringRedisTemplate;
    //业务
    private String key;
    //过期时间
    private Long time;
    //时间单位
    private TimeUnit unit;

    private static final String sign_prefix = UUID.randomUUID().toString(true);

    //前缀
    private static final String prefix = "lock:";
    public RedisLock(StringRedisTemplate stringRedisTemplate, String key, Long time, TimeUnit unit) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.key = key;
        this.time = time;
        this.unit = unit;
    }

    @Override
    public boolean tryLock() {
        //获取线程id
        long id = Thread.currentThread().getId();
        String lockKey = prefix+key;
        String sign =  sign_prefix+"_"+ id;
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, sign, time, unit);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        //释放锁
        String sign = sign_prefix+"_"+ Thread.currentThread().getId();
        String value = stringRedisTemplate.opsForValue().get(prefix + key);
        if (sign.equals(value)){
            stringRedisTemplate.delete(prefix + key);
        }
        
    }
}


3.3 分布式锁原子性问题

3.3.1 起因

原子性问题属于极端情况:在上述过程中,比较锁成功之后删除锁过程中出现问题;

线程1:比较锁内容成功[0,1],锁过期,但是代码会继续执行删除锁的逻辑;
线程2:在[1,2]时候,获取了锁,[2,3] 时,线程1 删除锁,删除的锁依旧是线程2的锁; 

出现这种情况的原因:线程1的获得锁,比较锁,删除锁都不是一个原子性的操作;

3.3.2 处理方式
3.3.2.1 Lua脚本

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作.

3.3.2.2 基本使用

这里重点介绍Redis提供的调用函数,语法如下:

redis.call('命令名称', 'key', '其它参数', ...)

例如,我们要执行set name jack,则脚本是这样:

# 执行 set name jack
redis.call('set', 'name', 'jack')

例如,我们要先执行set name Rose,再执行get name,则脚本如下:

# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name

写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:

// EVAL  "脚本" 参数个数 0或多个key    0或多个参数
   EVAL script numkeys key [key ...] arg [arg ...]

使用示例

//执行 redis.call('set', 'name', 'jack') 这个脚本
EVAL "return redis.call('set','name','jack')" 0

//如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会
//放入KEYS数组,其它参数会放入ARGV数组.
//在脚本中可以从KEYS和ARGV数组获取这些参数;
 EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name jack
3.3.2.3 redis + Lua处理分布式锁
3.3.2.3.1 锁释放逻辑

释放锁的业务流程是这样的

​ 1、获取锁中的线程标示

​ 2、判断是否与指定的标示(当前线程标示)一致

​ 3、如果一致则释放锁(删除)

​ 4、如果不一致则什么都不做

3.3.2.3.2 Lua 脚本表达
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

三:Java 使用Redis Lua

1.相关源码

  public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
        return this.scriptExecutor.execute(script, keys, args);
    }

2.使用

2.1 编写脚本

该脚本放入resource路径下,名称定义为unlock.lua

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

2.2 锁代码

package com.***.flow.impl;

import cn.hutool.core.lang.UUID;
import com.gisinfo.flow.service.ILock;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.util.Collections;
import java.util.concurrent.TimeUnit;

public class RedisLock implements ILock {

    //谁注入谁提供
    private StringRedisTemplate stringRedisTemplate;
    //业务
    private String key;
    //过期时间
    private Long time;
    //时间单位
    private TimeUnit unit;
    //标识
    private static final String sign_prefix = UUID.randomUUID().toString(true);
    //前缀
    private static final String prefix = "lock:";
    
    //脚本类
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    //加载脚本内容
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

   
    
    public RedisLock(StringRedisTemplate stringRedisTemplate, String key, Long time, TimeUnit unit) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.key = key;
        this.time = time;
        this.unit = unit;
    }

    @Override
    public boolean tryLock() {
        //获取线程id
        long id = Thread.currentThread().getId();
        String lockKey = prefix+key;
        String sign =  sign_prefix+"_"+ id;
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, sign, time, unit);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        // 调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(prefix+key),
                sign_prefix + Thread.currentThread().getId());
    }
    
}

四.总结

  • 分布式的由来,三种实现方式
  • db,zk不是文章核心,只是简单说明
  • redis 每种解决方案的异常情况与分析思路
  • lua 脚本的基本使用
  • 引入:redisson
  • 异常情况和思路分析值得复习
  • 碎碎念:学习果然是一件很痛苦的事情!!!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值