Redis底层原理 (Redis实现分布式锁)(八)

业务场景

  • 防止用户重复下单
  • MQ消息去重
  • 订单操作变更
  • 库存超卖

分析:

业务场景共性:
共享资源:用户id、订单id、商品id。。。
解决方案

  • 共享资源互斥
  • 共享资源串行化

问题转化
     锁的问题 (将需求抽象后得到问题的本质)

锁应用

      单应用中使用锁:(单进程多线程)synchronized、ReentrantLock
      分布式应用中使用锁:(多进程多线程)分布式锁是控制分布式系统之间同步访问共享资源的一种方式。
解决分布式下并发访问:

try{
    if(getLock()){
        扣减库存
    }
}finally{
    unlock();
}

分布式锁特性:

  •  互斥性:我持有则别人不能持有
  • 同一性:我加锁,别人不能解锁
  • 可重入性:我加锁,锁超时,则锁失效,别人可以加锁;我加锁,还可再次加锁(多次持有)

Redis实现分布式锁

原理:利用Redis的单线程特性对共享资源进行串行化处理
获取锁:

方式一

/**
* 使用redis的set命令实现获取分布式锁 推荐使用
* @param lockKey 可以就是锁
* @param requestId 请求ID,保证同一性 uuid+threadID
* @param expireTime 过期时间,避免死锁
* @return
*/
public boolean getLock(String lockKey,String requestId,int expireTime) {
    //NX:保证互斥性
    // hset 原子性操作
    String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
    if("OK".equals(result)) {
        return true;
    } 
    return false;
}

方式二 (使用setnx命令实现) -- 并发会产生问题

public boolean getLock(String lockKey,String requestId,int expireTime) {
    Long result = jedis.setnx(lockKey, requestId);
    if(result == 1) {
    //成功设置 失效时间
    jedis.expire(lockKey, expireTime);
        return true;
    } 
    return false;
}

释放锁

方式1(del命令实现) -- 并发

/
**
* 释放分布式锁
* @param lockKey
* @param requestId
*/
public static void releaseLock(String lockKey,String requestId) {
    if (requestId.equals(jedis.get(lockKey))) {
        jedis.del(lockKey);
    }
}

方式2(redis+lua脚本实现)--推荐

public static boolean releaseLock(String lockKey, String requestId) {
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

    Object result = jedis.eval(script, Collections.singletonList(lockKey),
    Collections.singletonList(requestId));
    if (result.equals(1L)) {
        return true;
    } 
    return false;
}

存在问题

  • 单机:无法保证高可用
  • 主--从:无法保证数据的强一致性,在主机宕机时会造成锁的重复获得
  • 无法续租:超过expireTime后,不能继续使用

本质分析

CAP模型分析
       在分布式环境下不可能满足三者共存,只能满足其中的两者共存,在分布式下P不能舍弃(舍弃P就是单机了)。所以只能是CP(强一致性模型)和AP(高可用模型)。
       分布式锁是CP模型,Redis集群是AP模型。 (base)
       Redis集群不能保证数据的随时一致性,只能保证数据的最终一致性。
为什么还可以用Redis实现分布式锁?
       与业务有关
       当业务不需要数据强一致性时,比如:社交场景,就可以使用Redis实现分布式锁
       当业务必须要数据的强一致性,即不允许重复获得锁,比如金融场景(重复下单,重复转账)就不要使用
       可以使用CP模型实现,比如:zookeeper和etcd。
分布式锁的实现方式

  • 基于Redis的set实现分布式锁
  • 基于 zookeeper 临时节点的分布式锁
  • 基于etcd实现

三者的对比,如下表

生产环境中的分布式锁

落地生产环境用分布式锁,一般采用开源框架,比如Redisson。
POM

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>2.7.0</version>
</dependency

配置Redisson
 

public class RedissonManager {
private static Config config = new Config();
//声明redisso对象
private static Redisson redisson = null;
//实例化redisson
static{
    config.useClusterServers()
    // 集群状态扫描间隔时间,单位是毫秒
    .setScanInterval(2000)
    //cluster方式至少6个节点(3主3从,3主做sharding,3从用来保证主宕机后可以高可用)
    .addNodeAddress("redis://127.0.0.1:6379" )
    .addNodeAddress("redis://127.0.0.1:6380")
    .addNodeAddress("redis://127.0.0.1:6381")
    .addNodeAddress("redis://127.0.0.1:6382")
    .addNodeAddress("redis://127.0.0.1:6383")
    .addNodeAddress("redis://127.0.0.1:6384");
    //得到redisson对象
    redisson = (Redisson) Redisson.create(config);
}
//获取redisson对象的方法
public static Redisson getRedisson(){
    return redisson;
}
}

锁的获取和释放
 

public class DistributedRedisLock {
//从配置类中获取redisson对象
private static Redisson redisson = RedissonManager.getRedisson();
private static final String LOCK_TITLE = "redisLock_";

//加锁
public static boolean acquire(String lockName){
    //声明key对象
    String key = LOCK_TITLE + lockName;
    //获取锁对象
    RLock mylock = redisson.getLock(key);
    //加锁,并且设置锁过期时间3秒,防止死锁的产生 uuid+threadId
    mylock.lock(2,3,TimeUtil.SECOND);
    //加锁成功
    return true;
}

//锁的释放
public static void release(String lockName){
    //必须是和加锁时的同一个key
    String key = LOCK_TITLE + lockName;
    //获取所对象
    RLock mylock = redisson.getLock(key);
    //释放锁(解锁)
    mylock.unlock();
}
}

业务逻辑中使用分布式锁
 

public String discount(){
    String key = "test123";
    //加锁
    DistributedRedisLock.acquire(key);
    //执行具体业务逻辑
    dosoming
    //释放锁
    DistributedRedisLock.release(key);
    //返回结果
    return soming;
}

Redisson分布式锁的实现原理

加锁机制
如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。发送lua脚本到redis服务器上,脚本如下:
 

"if (redis.call('exists',KEYS[1])==0) then "+
"redis.call('hset',KEYS[1],ARGV[2],1) ; "+
"redis.call('pexpire',KEYS[1],ARGV[1]) ; "+
"return nil; end ;" +
"if (redis.call('hexists',KEYS[1],ARGV[2]) ==1 ) then "+
"redis.call('hincrby',KEYS[1],ARGV[2],1) ; "+
"redis.call('pexpire',KEYS[1],ARGV[1]) ; "+
"return nil; end ;" +
"return redis.call('pttl',KEYS[1]) ;"

lua的作用:保证这段复杂业务逻辑执行的原子性。
lua的解释:
KEYS[1]) : 加锁的key
ARGV[1] : key的生存时间,默认为30秒
ARGV[2] : 加锁的客户端ID (UUID.randomUUID()) + “:” + threadId)
第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁。如何加锁呢?很简单,用下面的命令:
hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:
myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":1 }
上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。
锁互斥机制
那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?
很简单,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。
接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。此时客户端2会进入一个while循环,不停的尝试加锁。
自动延时机制
只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
可重入锁机制
第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。
第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”此时就会执行可重入加锁的逻辑,他会用:incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1 通过这个命令,对客户端1的加锁次数,累加1。数据结构会变成:myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":2 }
释放锁机制
执行lua脚本如下:
 

#如果key已经不存在,说明已经被解锁,直接发布(publish)redis消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
# key和field不匹配,说明当前客户端线程没有持有锁,不能主动解锁。
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
# 将value减1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3],-1); " +
# 如果counter>0说明锁在重入,不能删除key
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
# 删除key并且publish 解锁消息
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",

– KEYS[1] :需要加锁的key,这里需要是字符串类型。
– KEYS[2] :redis消息的ChannelName,一个分布式锁对应唯一的一个channelName:“redisson_lockchannel{” + getName() + “}”
– ARGV[1] :reids消息体,这里只需要一个字节的标记就可以,主要标记redis的key已经解锁,再结合redis的Subscribe,能唤醒其他订阅解锁消息的客户端线程申请锁。
– ARGV[2] :锁的超时时间,防止死锁
– ARGV[3] :锁的唯一标识,也就是刚才介绍的 id(UUID.randomUUID()) + “:” + threadId
如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。
每次都对myLock数据结构中的那个加锁次数减1。如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:
“del myLock”命令,从redis里删除这个key。然后呢,另外的客户端2就可以尝试完成加锁了

 


常见缓存问题

数据读

  • 缓存穿透:对不存在的key进行高并发访问,导致数据库压力瞬间增大,这就叫做【缓存穿透】
  • 缓存雪崩:突然间大量的key失效或redis重启,大量访问数据库,导致数据库压力瞬间增加,叫做【缓存雪崩】
  • 缓存击穿:当某一key恰好在失效时,发生了高并发访问,导致数据库压力瞬间增大,叫做【缓存击穿】

缓存穿透
       一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就应该去后端系统查找(比如DB)。如果key对应的value是一定不存在的,并且对该key并发请求量很大,就会对后端系统造成很大的压力。也就是说,对不存在的key进行高并发访问,导致数据库压力瞬间增大,这就叫做【缓存穿透】。
        解决方案:
        对查询结果为空的情况也进行缓存,缓存时间设置短一点,或者该key对应的数据insert了之后清理缓存。
缓存雪崩
当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力。突然间大量的key失效了或redis重启,大量访问数据库
       解决方案:
       1、 key的失效期分散开 不同的key设置不同的有效期
       2、设置二级缓存
       3、高可用

缓存击穿
       对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。
       缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
       解决方案:
      1、 用分布式锁控制访问的线程使用redis的setnx互斥锁先进行判断,这样其他线程就处于等待状态,保证不会有大并发操作去操作数据库。if(redis.sexnx()==1){ //先查询缓存 //查询数据库 //加入缓存 }
      2、不设超时时间,但是会有写一致问题


数据写

数据不一致的根源 : 数据源不一样
如何解决强一致性很难,追求最终一致性
互联网业务数据处理的特点:

  • 高吞吐量
  • 低延迟
  • 数据敏感性低于金融业

时序控制是否可行?
先更新数据库,再更新缓存 或者 先更新缓存,再更新数据库;本质上不是一个原子操作,所以时序控制不可行
保证数据的最终一致性(延时双删)
1、先更新数据库同时删除缓存项(key),等读的时候再填充缓存
2、2秒后再删除一次缓存项(key)
3、设置缓存过期时间 Expired Time 比如 10秒 或1小时
4、将缓存删除失败记录到日志中,利用脚本提取失败记录再次删除(缓存失效期过长 7*24)

升级方案
通过数据库的binlog来异步淘汰key,利用工具(canal)将binlog日志采集发送到MQ中,然后通过ACK机制确认处理删除缓存。
 

  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

仰望星空@脚踏实地

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值