Redis_实战2
Redis_实战2
分布式锁
是什么:
满足分布式系统或集群模式下多进程可见并且互斥的锁。
还要满足其它特性:高可用、高性能、安全性、(可重入不可重入、阻塞非阻塞、公平非公平)
分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有3种:
- MySQL
- 互斥:利用MySQL本身的互斥锁机制
- 高可用:好
- 高性能:一般
- 安全性:断开链接,自动释放锁
- Redis
- 互斥:利用setnx这样的互斥命令(数据不存在时才能set成功)(释放时只用删除这个key即可)
- 高可用:好
- 高性能:好
- 安全性:利用锁超时时间,到期释放
- Zookeeper
- 互斥:利用节点的唯一性(不重复)和有序性(递增)实现互斥(一般利用有序性实现互斥,比如最小的节点)(释放锁:删除节点)
- 高可用:好
- 高性能:一般
- 安全性:临时节点,断开连接自动释放
Redis的分布式锁的实现思路
实现分布式锁时需要实现的两个基本方法:
- 获取锁
- 互斥:确保只能有一个线程获取锁
SETNX lock thread1
调试点1
EXPIRE lock 10
避免服务宕机引起的死锁
但可能在调试点1发生宕机,这时也会死锁。
可以这样:
SET lock thread1 EX 10 NX
- 非阻塞:成功返回true,失败返回false(避免资源浪费)
- 释放锁:
- 手动释放:
DEL lock
- 超时释放:获取锁时添加一个超时时间。
Redis分布式锁误删问题
场景:
- 线程1业务太长,超过锁的过期时间,锁释放
- 线程2获取锁,进入业务,此时线程1执行完业务,释放了线程2的锁,但此时线程2的业务还没执行完。
- 线程3获取锁,进入业务,此时发生了线程2、3并发执行业务存在线程安全问题。
(极端情况)
原因:
线程1把别人的锁删除了。(Redis分布式锁,不同线程共享一把锁)(删除锁时未判断是否是自己的锁)
解决:
(业务正常结束)释放锁的时候,判断锁的标识(可以用UUID表示)(之前用的是线程的ID,在JVM内部维护的,递增,不同JVM可能发生发生碰撞)是否一致。(宕机情况不用判断,直接释放锁即可)
我们加锁时SET lock thread1 EX 10 NX
thread1就是锁的标识(redis存储的value说明是哪个线程的锁)。
UUID
用UUID区分不同的服务(不同JVM)(Universally Unique Identifier 通用唯一标识码)
用线程ID区分不同线程
分布式锁的原子问题
场景:
- 线程1获取锁,执行业务到判断锁是否是自己的,判断完成,下一步就是释放锁了,此时发生了阻塞(比如JVM垃圾回收),这把锁因为超时被释放了。
- 线程2在锁释放后获取锁,执行业务。
- 线程1执行最后一步释放锁,释放掉了线程2的锁,线程3可以获取锁,并执行业务。此时线程2、3并发执行,会存在并发执行的线程安全问题。
原因:
判断锁 & 释放锁 它们不是原子的,中间存在间隔,在这个间隔内锁被超时释放了。
解决:
保证 判断锁 & 释放锁 这个整体的原子性。
可以考虑redis事务,但是:redis事务支持原子性,不支持一致性,需要结合乐观锁CAS技术,比较复杂。
所以,可以用Lua脚本。
Redis的Lua脚本
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言。
Lua脚本提供了Redis的调用命令:
基本语法:redis.call(‘命令名称’,‘key’,‘其它参数’,...)
redis调用Lua脚本:
基本语法:EVAL "return redis.call('set', KEYS[1], ARGV[1]) " 1 name ROSE
Java中RedisTemplate调用Lua脚本:
excute(RedisScript script, List keys,Object args);
即可
总结:基于Redis的分布式锁实现思路
思路:
- 利用
set nx ex
获取锁,并设置过期时间,保存线程标识。(nx保证互斥,ex保证超时删除、兜底方案) - 释放锁时先判断线程标识是否与自己一致,一致才删除锁
特性:
- 利用
set nx
满足互斥性。 - 利用
set ex
保证故障时锁依然能释放,避免死锁,提高安全性。 - (lua脚本保证原子性,防止误删)
- 利用redis集群保证高可用和高并发特性。
Redisson
基于setnx实现的分布式锁存在下面的问题:
- 不可重入。同一个线程无法多次获取同一把锁。
- 不可重试。获取锁只尝试1次就返回false,没有重试机制。
- 超时释放。锁超时虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。
- 主从一致性。(读写分离模式)如果redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从未同步主中的锁数据,则会出现锁失效。(从升级为主,但是从没有原主的锁数据)
所以,引入redisson
Redisson:
是一个在redis的基础上实现的java驻内存数据网络(In-Memory Data Grid)。它不仅提供了一系列的分布式的java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
redisson分布式锁的实现:
可重入锁、公平锁、联锁、红锁、读写锁、信号量、可过期信号量、闭锁。
使用:
- 引入依赖
- 配置bean(配置redisson地址)
- 使用redisson的分布式锁:1创建锁对象-> 2获取锁 'lock.tryLock(获取锁的最大等待时间(期间会重试),锁的自动释放时间,时间单位);' -> 3释放锁
redisson的可重入锁原理
通过重入次数,记录重入锁。
- 利用redis-map数据结构实现。
key:lock
value:hahsmap(field:thread1,value:次数)
- 通过Lua脚本操作redis的hash数据,保证原子性。
2.1 同一个线程重入时,次数+1。
2.2 释放时,次数-1,如果次数<=0,释放锁(del lock)
2.3 如果不同线程想要获取锁,返回失败(判断线程id不相等)。
Redisson可重试 & leaseTime=-1时超时不释放原理
可重试:订阅并等待锁释放的信号
超时不释放:watchDog
- 获取锁流程
1.1 判断ttl是否null(null:获取锁成功)(剩余有效时间:获取锁失败)
1.2 获取成功:判断leaseTime是否-1(-1永不过期)(!=-1会过期)
1.3 leaseTime-1:开启watchDog(每隔releaseTime1/3的时间间隔,刷新剩余时间)并放回获取锁成功
1.4 leaseTime!=-1,直接返回获取锁成功。
1.5 ttl!=null 判断剩余等待时间是否>0(大于0:订阅并等待释放锁的信号->判断等待时间是否超时->超时返回获取锁失败、不超时循环尝试获取锁进入1.1) - 释放锁流程
2.1 判断是否成功(成功->发送释放锁消息 to 订阅释放锁信号的线程 & 取消watchDog->结束)(失败->记录异常->结束)
总结Redisson分布式锁原理:
- 可重入:利用hash结构记录线程id和重入次数。
- 可重试:利用信号量和PubSub(发布订阅)功能实现等待、唤醒、获取锁失败的重试机制。(等待、唤醒不会过多占用CPU)
- 超时续约:利用watchDog,每隔一段时间(releaseTime/3),重置超时时间。
主从一致问题-mutiLock联锁
lock = redissonClient.getMultiLock(lock1, lock2, lock3);
在这三个锁中都获取到锁才是获取锁成功。
(有一个不成功就不成功,三个主节点-可以配从节点,如果出现某个主宕机,也不会影响获取锁的结果)(可以配置failedLocksLimit获取锁失败上限)(失败的锁>上限,回退,从头开始获取每一把锁)
分布式锁总结
- 不可重入Redis分布式锁
- 原理:利用setnx的互斥性;利用ex避免死锁;释放锁是判断线程标识。
- 缺陷:不可重入、无法重试、锁超时失效。
- 可重入的Redis分布式锁
- 原理:利用hash结构,记录线程标识和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待。
- 缺陷:redis宕机引起锁失效问题。
- Redisson的multiLock
- 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功。
- 缺点:运维成本高、实现复杂。
- 优点:所有方案中最安全的一种方案。
加锁导致秒杀业务性能下降,引入秒杀优化
秒杀优化
异步秒杀流程
- 将判断库存 & 判断重复下单 逻辑交给Redis
- 将耗时长的写数据库操作交给异步线程。
Redis:Key-stock:优惠券id,value-库存String(判断库存充足)
Redis:Key-order:优惠券id,value-用户id Set(判断重复下单)
流程1交给Lua脚本
流程2交给阻塞队列
数据库-扣减库存、创建订单的任务丢给阻塞队列。阻塞队列循环取出任务,并执行。
总结
秒杀业务的优化思路是什么
- 先利用redis完成库存余量、一人一单判断,完成抢单业务。
- 再将下单业务放入阻塞队列,利用独立线程异步下单。
基于阻塞队列的异步秒杀存在哪些问题
- 内存限制问题
- 数据安全问题
(宕机-任务丢失、执行任务时出现事故-该任务未执行完-该任务丢失了)
Redis消息队列
实现异步秒杀
消息队列,字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色。
- 消息队列:存储和管理消息,也被称为消息代理。
- 生产者:发送消息到消息队列。
- 消费者:从消息队列获取消息并处理消息。
Redis提供了3种不同的方式来实现消息队列:
- list结构:基于list结构模拟消息队列
- 优点:
- 利用redis存储,不受限于JVM内存上限
- 基于redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
- 缺点:
- 无法避免消息丢失
- 只支持单消费者
- Pubsub:基本的点对点消息模型
(发布订阅)消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
- SUBSRIBE channel [channel]:订阅一个或多个频道
- PUBLISH channel msg:向一个频道发送消息
- PSUBSCRIBE pattern [pattern]:订阅与pattern格式匹配的所有频道。
- 优点
- 采用发布订阅模型,支持多生产,多消费
- 缺点
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限(堆积在消费者那里),超出时数据丢失
- Stream:比较完善的消息队列模型
- Stream是redis5.0引入的一种新数据类型,可以实现功能相对完善的消息队列。
- 发消息最简用法:
XADD users * name jack age 21
创建名为users的消息队列,并向其中发送一个消息,内容是{name=jack,age=21},并使用redis自动生成id*。 - XREAD阻塞方式,读取最新的消息:
XREAD COUNT 1 BLOCK 1000 STREAMS users $
阻塞1000ms,读一条,$
读最新消息(未被读过的消息。从名为users的消息队列读取。 $
有小bug:当我们指定起始ID为$时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题。
STREAM类型消息队列的XREAD命令特点:
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读的风险
消费者组
将多个消费者划分到一个组中,监听同一个队列。
特点:
- 消息分流:队列中的消息分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度。
- 消息标示:记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息。确保每一个消息都会被消费。
- 消息确认:消费者获取消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除。
STREAM类型消息队列的XREADGROUP命令特点:
- 消息可以回溯
- 可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读的风险
- 有消息确认机制,保证消息至少被消费一次。
独立于JVM之外,不受JVM内存限制
对比List、PubSub、Stream
- 消息持久化:支持---不支持---支持
- 阻塞读取:支持---支持---支持
- 消息堆积处理:受限于内存空间,可以利用多消费者加快处理---受限于消费者缓冲区---受限于队列长度,可以利用消费者组提高消费速度,减少堆积。
- 消息确认机制:不支持---不支持----支持
- 消息回溯:不支持---不支持---支持
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了