Redis高级_分布式锁
分布式锁
基本原理和实现方式对比
- 分布式锁:满足分布式系统或集群模式下多线程课件并且可以互斥的锁
- 分布式锁的核心思想就是让大家共用同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
- 那么分布式锁应该满足一些什么条件呢?
- 可见性:多个线程都能看到相同的结果。
- 注意:这里说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
- 互斥:互斥是分布式锁的最基本条件,使得程序串行执行
- 高可用:程序不易崩溃,时时刻刻都保证较高的可用性
- 高性能:由于加锁本身就让性能降低,所以对于分布式锁需要他较高的加锁性能和释放锁性能
- 安全性:安全也是程序中必不可少的一环
- 常见的分布式锁有三种
- MySQL:MySQL本身就带有锁机制,但是由于MySQL的性能一般,所以采用分布式锁的情况下,使用MySQL作为分布式锁比较少见
- Redis:Redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都是用Redis或者Zookeeper作为分布式锁,利用SETNX这个方法,如果插入Key成功,则表示获得到了锁,如果有人插入成功,那么其他人就回插入失败,无法获取到锁,利用这套逻辑完成互斥,从而实现分布式锁
- Zookeeper:Zookeeper也是企业级开发中较好的一种实现分布式锁的方案,但本文是学Redis的,所以这里就不过多阐述了
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
Redis分布式锁的实现核心思路
- 实现分布式锁时需要实现两个基本方法
- 获取锁
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
- 获取锁
1 | SET lock thread01 NX EX 10 |
- 释放锁
- 手动释放
- 超时释放:获取锁的时候添加一个超时时间
1 | DEL lock |
- 核心思路
- 我们利用redis的SETNX方法,当有多个线程进入时,我们就利用该方法来获取锁。第一个线程进入时,redis 中就有这个key了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁(返回了0)的线程,等待一定时间之后重试
实现分布式锁
- 锁的基本接口
1 | public interface ILock { |
- 然后创建一个SimpleRedisLock类实现接口
1 | public class SimpleRedisLock implements ILock { |
- 修改业务代码
1 |
|
- 使用Jmeter进行压力测试,请求头中携带登录用户的token,最终只能抢到一张优惠券
Redis分布式锁误删情况说明
- 逻辑说明
- 持有锁的线程1在锁的内部出现了阻塞,导致他的锁TTL到期,自动释放
- 此时线程2也来尝试获取锁,由于线程1已经释放了锁,所以线程2可以拿到
- 但是现在线程1阻塞完了,继续往下执行,要开始释放锁了
- 那么此时就会将属于线程2的锁释放,这就是误删别人锁的情况
- 解决方案
解决Redis分布式锁误删问题
- 需求:修改之前的分布式锁实现
- 满足:在获取锁的时候存入线程标识(用UUID标识,在一个JVM中,ThreadId一般不会重复,但是我们现在是集群模式,有多个JVM,多个JVM之间可能会出现ThreadId重复的情况),在释放锁的时候先获取锁的线程标识,判断是否与当前线程标识一致
- 如果一致则释放锁
- 如果不一致则不释放锁
- 核心逻辑:在存入锁的时候,放入自己的线程标识,在删除锁的时候,判断当前这把锁是不是自己存入的
- 如果是,则进行删除
- 如果不是,则不进行删除
- 具体实现代码如下
1 | private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; |
分布式锁的原子性问题
- 更为极端的误删逻辑说明
- 假设线程1已经获取了锁,在判断标识一致之后,准备释放锁的时候,又出现了阻塞(例如JVM垃圾回收机制)
- 于是锁的TTL到期了,自动释放了
- 那么现在线程2趁虚而入,拿到了一把锁
- 但是线程1的逻辑还没执行完,那么线程1就会执行删除锁的逻辑
- 但是在阻塞前线程1已经判断了标识一致,所以现在线程1把线程2的锁给删了
- 那么就相当于判断标识那行代码没有起到作用
- 这就是删锁时的原子性问题
- 因为线程1的拿锁,判断标识,删锁,不是原子操作,所以我们要防止刚刚的情况
Lua脚本解决多条命令原子性问题
- Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
- Lua是一种编程语言,它的基本语法可以上菜鸟教程看看,链接:https://www.runoob.com/lua/lua-tutorial.html
- 这里重点介绍Redis提供的调用函数,我们可以使用Lua去操作Redis,而且还能保证它的原子性,这样就可以实现拿锁,判断标识,删锁是一个原子性动作了
- Redis提供的调用函数语法如下
1 | redis.call('命令名称','key','其他参数', ...) |
- 例如我们要执行set name Kyle,则脚本是这样
1 | redis.call('set', 'name', 'Kyle') |
- 例如我我们要执行set name David,在执行get name,则脚本如下
1 | ## 先执行set name David |
- 写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下
1 | EVAL script numkeys key [key ...] arg [arg ...] |
- 例如,我们要调用redis.call(‘set’, ‘name’, ‘Kyle’) 0这个脚本,语法如下
1 | EVAL "return redis.call('set', 'name', 'Kyle')" 0 |
- 如果脚本中的key和value不想写死,可以作为参数传递,key类型参数会放入KEYS数组,其他参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组中获取这些参数
注意:在Lua中,数组下标从1开始
1 | EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Lucy |
- 那现在我们来使用Lua脚本来代替我们释放锁的逻辑
- 原逻辑
1 |
|
- 改写为Lua脚本01
- 但是现在是写死了的,我们可以通过传参的方式来变成动态的Lua脚本
1 | -- 线程标识 |
- 改写为Lua脚本02
- 但是现在是写死了的,我们可以通过传参的方式来变成动态的Lua脚本
1 | -- 这里的KEYS[1]就是传入锁的key |
利用Java代码调用Lua脚本改造分布式锁
- 在RedisTemplate中,可以利用execute方法去执行lua脚本
1 | public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) { |
- 对应的Java代码如下
1 | private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; |
- 但是现在的分布式锁还存在一个问题:锁不住
- 那什么是锁不住呢?
- 如果锁的TTL快到期的时候,我们可以给它续期一下,比如续个30s,就好像是网吧上网,快没网费了的时候,让网管再给你续50块钱的,然后该玩玩,程序也继续往下执行
- 那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission了
- 那什么是锁不住呢?
- 小结:基于Redis分布式锁的实现思路
- 利用SET NX EX获取锁,并设置过期时间,保存线程标识
- 释放锁时先判断线程标识是否与自己一致,一致则删除锁
- 特性
- 利用SET NX满足互斥性
- 利用SET EX保证故障时依然能释放锁,避免死锁,提高安全性
- 利用Redis集群保证高可用和高并发特性
- 特性
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 十一的博客!
评论