Redis实现分布式锁

文章目录

  • 前言
  • 一、概述
    • 为什么使用分布式锁
    • 基本原理
    • 分布式锁应该具备哪些条件
    • 常见的三种分布式锁
  • 二、基于Redis实现分布式锁
    • 误删锁问题
    • 原子性问题
    • 最终代码实现
  • 总结


前言

Redis实现简单分布式锁。


一、概述

为什么使用分布式锁

  • 在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。
  • 举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 3 个。如果不对共享资源进行互斥访问,就可能出现以下情况:
    • 线程 1、2、3 等多个线程同时进入抢购方法,每一个线程对应一个用户。
    • 线程 1 查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
    • 线程 2 也执行查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
    • 线程 1 继续执行,将库存数量减少 1 个,然后返回成功。
    • 线程 2 继续执行,将库存数量减少 1 个,然后返回成功。
    • 此时就发生了超卖问题,导致商品被多卖了一份。

为了保证共享资源被安全地访问,我们需要使用互斥操作对共享资源进行保护,即同一时刻只允许一个线程访问共享资源,其他线程需要等待当前线程释放后才能访问。这样可以避免数据竞争和脏数据问题,保证程序的正确性和稳定性。如何才能实现共享资源的互斥访问呢? 锁是一个比较通用的解决方案,更准确点来说是悲观锁。悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

基本原理

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路

Redis实现分布式锁_第1张图片

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

  • 互斥:任意一个时刻,锁只能被一个线程持有。
  • 高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
  • 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。
  • 可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
  • 高可用:程序不易崩溃,时时刻刻都保证较高的可用性

常见的三种分布式锁

  • 基于关系型数据库比如 MySQL 实现分布式锁:mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见。
  • 基于分布式键值存储系统比如 Redis 、Etcd 实现分布式锁。
  • 基于分布式协调服务 ZooKeeper 实现分布式锁。

Redis实现分布式锁_第2张图片

二、基于Redis实现分布式锁

  • Redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁。
  • Redis实现分布式锁原理:
    • 利用setnx(如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, setnx 啥也不做),所以如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁。
    • 释放锁的话,直接通过 DEL 命令删除对应的 key 即可。
    • 获取锁时添加超时时间,防止死锁。
  • 释放锁时,防止误删到其他的锁,在获取锁时将value值设置唯一。
  • 核心思路:我们利用redis 的setnx 方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的哥们,等待一定时间后重试即可。

误删锁问题

  • 逻辑说明:持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明。

Redis实现分布式锁_第3张图片

  • 解决方案:在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致。一致则释放,不一致则不释放。
  • 核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。

Redis实现分布式锁_第4张图片

原子性问题

逻辑说明:线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是删除之前他的锁到期了,那么此时线程2能拿到锁进来,但是线程1他会接着往后执行,当他真正删除时,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生。

Redis实现分布式锁_第5张图片

  • 解决方案:Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。

这里简单讲诉一下所使用到的lua语法:

(1)Redis提供的调用函数,语法如下:

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

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

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

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

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

(4)写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
Redis实现分布式锁_第6张图片
(5)例如,我们要执行 redis.call(‘set’, ‘name’, ‘jack’) 这个脚本,语法如下:

EVAL 'return redis.call('set','name','jack')'  0
-- 'return redis.call('set','name','jack')' 脚本内容
--  0 脚本所需要的key类型的参数个数

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
在这里插入图片描述
(6)利用Java代码调用Lua脚本改造分布式锁:
我们的RedisTemplate中,可以利用execute方法去执行lua脚本。
Redis实现分布式锁_第7张图片

最终代码实现

接口:

public interface ILock {
    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功; false代表获取锁失败
     */
    boolean tryLock(long timeoutSec);
    /**
     * 释放锁
     */
    void unlock();
}

实现:

public class SimpleRedisLock implements ILock{
    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX ="lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString() + "-";
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取当前线程标识
        String threadId =ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }
    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 void unlock() {
        // 调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }

}

补充一下lua脚本:

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

总结

但是目前还剩下一个问题锁不住,什么是锁不住呢,你想一想,如果当过期时间到了之后,我们可以给他续期一下,比如续个30s,就好像是网吧上网, 网费到了之后,然后说,来,网管,再给我来10块的,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于redission。

你可能感兴趣的:(实战笔记,redis,分布式,wpf,缓存,java)