Redis企业实战
今日课程介绍
短信登录
导入黑马点评项目
首先, 导入课前资料提供的SQL文件:
其中的表有:
- tb_user: 用户表
- tb_user_info: 用户详情表
- tb_shop: 商品信息表
- tb_shop_type: 商品类型表
- tb_blong: 用户日记表(达人探店日记)
- tb_forllow: 用户关注表
- tb_voucher: 优惠卷表
- tb_voucher_order: 优惠卷的订单表
导入后端项目
在资料中提供了一个项目源码:
将其复制到你的idea工作空间, 然后利用idea打开即可:
启动项目后, 在浏览器访问: https://localhost:8081/shop-type/list, 如果可以看到数据则证明运行没有问题
导入前端项目
在资料中提供了以这个nginx文件夹:
将其复制到任意目录, 要确保该目录不包含中中文, 特殊字符和空格, 例如:
运行前端项目
在nginx所在目录下打开一个CMD窗口, 输入命令:
打开chrome浏览器, 在空白页面点击鼠标右键, 选择检查, 即可打开开发者工具:
打开手机模式:
然后访问: http://127.0.0.1:8080, 即可看到页面:
基于Session实现登录
发送短信验证码
登录验证功能
集群的session共享问题
session共享问题: 多台tomcat并不共享session存储空间, 当请求切换到不同tomcat服务时导致数据丢失的问题.
session的替代方案应该 满足:
- 数据共享
- 内存存储
- key,value结构
基于Redis实现共享session登录
保存登录的用户信息, 可以使用String结构, 以JSON字符串来保存, 比较直观:
Hash结构可以将对象中的每个字段独立储存, 可以针对单个字段作CRUD, 并且内存内存占用更少:
总结
Redis代替session需要考虑的问题:
- 选择合适的数据结构
- 选择适合的key
- 选择适合的储存粒度
登录拦截器的优化
商户查询缓存
什么是缓存
缓存就是数据交换的缓冲区(称作Cache), 是存储数据的历时地方, 一般读写性能高.
添加Redis缓存
练习 给店铺类型查询业务添加缓存
店铺类型在首页和其他多个页面都会用用到, 如图:
需求: 修改ShopTypeController中的queryTypeList方法, 添加查询缓存
缓存更新策略
内存淘汰 | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 不用自己维护, 利用Redis的内部淘汰机制, 当内存不足时自动淘汰部分数据. 下次查询时更新缓存. | 给缓存数据添加TTL时间,到期后自动删除缓存. 下次查询时更新缓存. | 编写业务逻辑, 在修改数据库的同时, 更新缓存. |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
业务场景:
- 低一致性需求: 使用内存淘汰机制. 例如店铺烈性的查询缓存
- 高一致性需求: 主动更新, 并以超时剔除作为兜底方案. 例如店铺详细查询
主动更新策略
操作缓存和数据库时有三个问题需要考虑:
- 删除缓存还是更新缓存?
- 更新缓存: 每次更新数据库都更新缓存, 无效写操作较多
- 删除缓存: 更新数据时让缓存失效, 查询时在更新缓存
- 如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统, 将缓存与数据库操作放在一个事务
- 分布式系统, 利用TCC等分布式事务方案
- 先操作缓存还是先操作数据库?
- 先删除缓存, 在操作数据库
- 先操作数据库, 在删除缓存
Cache Aside Pattern
缓存更新策略的最佳实践方案:
- 低一致性需求: 使用Redis自带的内存淘汰机制
- 高一致性需求: 主动更新, 并以超时剔除作为兜底方案
- 读写操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库, 并写入缓存, 设定超时时间
- 写操作:
- 先写数据库, 然后在删除缓存
- 要确保数据库与缓存操作的原子性
- 读写操作:
案例 给查询店铺的缓存添加超时剔除和主动更新的策略
修改ShopController中的业务逻辑, 满足下面的需求:
- 根据id查询店铺时, 如果缓存未命中, 则查询数据库, 将数据库结果写入缓存, 并设置超时时间
- 根据id修改店铺时, 先修改数据库, 在删除缓存
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在, 这样缓存永远不会生效, 这些请求都会打到数据库.
- 缓存空对象
- 优点: 实现简单, 维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
- 布隆过滤
- 优点: 内存占有较少, 没有多余key
- 缺点:
- 实现复杂
- 存在误判可能
总结
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在, 不断发起这样的请求, 给数据库带来了巨大压力
缓存穿透的解决方案有哪些?
- 缓存null值
- 布隆过滤
- 增加id的复杂度, 避免被猜测id规律
- 加强用户权限校验
- 做好热点参数的限流
缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机, 导致大量请求到达数据库, 带来巨大压力.
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
缓存击穿问题也叫热点Key问题, 就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了, 无数的请求访问会在瞬间给数据库带来巨大的冲击.
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | 没有额外的内存消耗 , 保证一致性 , 实现简单 | 线程需要等待, 性能受影响 , 可能有死锁风险 |
逻辑过期 | 线程无需等待, 性能较好 | 不保证一致性 , 有额外内存消耗 , 实现复杂 |
案例 基于互斥锁方式解决缓存击穿问题
需求: 修改根据id查询商铺的业务, 基于互斥锁方式来解决缓存击穿问题 利用Setnx
案例 基于逻辑过期方式解决缓存击穿问题
需求: 修改根据id查询商铺的业务, 基于逻辑过期方式来解决缓存击穿问题
缓存工具封装
基于StringRedisTemplate封装一个缓存工具类, 满足下列需求:
- 方法一: 将任意Java对象序列化为JSON并储存在String类型的key中, 并且可以设置TTL过期时间
- 方法二: 将任意Java对象序列化为Json并储存在String类型的key中, 并且可以设置逻辑过期时间, 用于处理缓存击穿问题
- 方法三: 根据指定的key查询缓存, 并反序列化指定类型, 利用缓存空值方式解决缓存穿透问题
- 方法四: 根据指定的key查询缓存, 并反序列化为指定类型, 需要利用逻辑过期解决缓存击穿问题
全局id生成器
每个商铺都可以发布优惠券:
当用户抢购时, 就会生成订单并保存到tb_voucher_order这张表中, 而订单如果使用数据库自增ID就存在一些问题:
- id的规律性太明显
- 受单表数据量的限制
全局ID生成器, 是一种在分布式系统下用来生成全局唯一ID的工具, 一般要满足下列特性:
为了增加ID的安全性, 我们可以不直接使用Redis自增的数值, 而是拼接一些其他信息:
ID的组成部分:
- 符号位: 1bit, 永远为0
- 时间戳: 31bit, 以秒为单位, 可以使用69年
- 序列号: 32bit, 秒内的计数器, 支持每秒产生2^32个不同ID
总结
全局唯一ID生成策略:
- UUID
- Redis自增
- snowflake算法
- 数据库自增
Redis自增ID策略:
- 每天一个key, 方便统计订单量
- ID构造是 时间戳+计数器
实现优惠券秒杀下单
每个店铺都可以发布优惠券, 分为平价券和特价券. 平价券可以任意购买, 而特价券需要秒杀抢购:
表关系如下:
- tb_voucher: 优惠券的基本信息, 优惠金额, 使用规则等
- tb_seckill_voucher: 优惠券的库存, 开始抢购时间, 结束抢购时间. 特价优惠券才是需要填写这些信息
在VoucherController中提供了一个接口, 可以添加秒杀优惠券:
用户可以在店铺页面中抢购这些优惠券:
案例 实现优惠券秒杀的下单功能
下单时需要判断两点:
- 秒杀是否开始或结束, 如果尚未开始或已经结束无法下单
- 库存是否充足, 不足则无法下单
超卖问题
超卖问题是典型的多线程安全问题, 针对这一问题的常见解决方案就是加锁:
乐观锁的关键是判断之前查询得到的数据是否有被修改过, 常见的方式有两种:
- 版本号法
- CAS法
总结
超卖这样的线程安全问题, 解决方案有哪些?
- 悲观锁: 添加同步锁, 让线程串行执行
- 优点: 简单粗暴
- 缺点: 性能一般
- 乐观锁: 不加锁, 在更新时判断是否有其他线程在修改
- 优点: 性能好
- 缺点: 存在成功率低的问题
案例 一人一单
需求: 修改秒杀业务, 要求同一个优惠券, 一个用户只能下一单
一人一单的并发安全问题
通过加锁可以解决在单机情况下的一人一单安全问题, 但是在集群模式下就不行了.
- 我们将服务启动两份, 端口分别为8081和8082:
- 然后修改nginx的conf目录下的nginx.conf文件, 配置反向代理和负载均衡:
分布式锁
什么是分布式锁
分布式锁: 满足分布式系统或集群模式下多线程可见并且互斥的锁.
分布式锁的实现
分布式锁的核心就是实现多进程之间互斥, 而满足这一点的方式有很多, 常见的有三种:
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本事的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接, 自动释放锁 | 利用锁超时时间, 到期释放 | 临时节点, 断开连接自动释放 |
基于Redis的分布式锁
实现分布式锁需要实现的两个基本方法:
- 实现锁:
- 互斥: 确保只能有一个线程获取锁
- 释放锁:
- 手动释放
- 超时释放: 获取锁时添加一个超时时间
案例 基于Redis实现分布式锁初级版本
需求: 定义一个类, 实现下面接口, 利用Redis实现分布式锁功能.
案例 改进Redis的分布式锁
需求: 修改之前的分布式锁实现, 满足:
- 在获取锁时存入线程标识(可以用UUID表示)
- 在释放锁时先获取锁中的线程标示, 判断是否与当前线程标示一致
- 如果一致则释放锁
- 如果不一致则不释放锁
保证判断和删除锁的原子性
Redis的Lua脚本
Redis提供了Lua脚本功能, 在一个脚本中编写多条Reids命令, 确保多条命令执行时的原子性. Lua是一种编程语言, 他的基本语法大家可以参考网站: https://www.runoob.com/lua/lua-tutorial.html
这里重点介绍Redis提供的调用函数, 语法如下:
例如, 我们要执行set name jack, 则脚本是这样:
例如, 我们要先执行set name Rose, 在执行get name ,则脚本如下:
写好脚本以后, 需要用Redis命令来调用脚本, 调用脚本的常见命令如下:
例如, 我们要执行redis.call(“set”,”name”,”jack’)这个脚本, 语法如下:
如果脚本中的key, value不想写死, 可以作为参数传递. 可以类型会放入KEYS数组, 其他参数会放入ARGV数组, 在脚本中可以从KEYS和ARGV数组获取这些参数:
释放锁的业务流程是这样的:
- 获取锁中的线程标示
- 判断是否与指定的标示(当前线程标示)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
如果用Lua脚本来表示则是这样的:
案例 再次改进Reids的分布式锁
需求: 基于Lua脚本实现分布式锁的释放锁逻辑
提示: RedisTemplate调用Lua脚本的API如下:
java 使用Lua
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
// 调用lua脚本 让判断和删除成为一步 同时判断删除 有了原子性
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX+name),
ID_PREFIX + Thread.currentThread().getId()
);
}
总结
基于Redis的分布式锁实现思路:
- 利用set nx ex 获取锁, 并设置过期时间, 保存线程标示
- 释放锁时先判断线程标示是否与自己一致, 一致则删除锁
特性:
- 利用set nx满足互斥性
- 利用set ex保证故障时锁依然能释放, 避免死锁, 提高安全性
- 利用Redis集群保证高可用和高并发性
基于Redis的分布式锁优化
基于setnx实现的分布式锁存在下面问题:
Redisson
Redisson是一个在Redis的基础上实现的Java驻内存数据网络(In-Memory Data Grid). 他不仅提供了一系列的分布式的Java常用对象, 还提供了许多分布式服务, 其中就包含了各种分布式锁的实现.
官网地址: https://redisson.org
GitHub地址: https://github.com/redisson/redisson
Redisson入门
- 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
- 配置Redisson客户端:
@Configuration
public class RedisConfig{
@Bean
public RedissonClient redissonClent(){
// 配置类
Config config = new Config();
// 添加redis地址, 这里添加了单点的地址, 也可以使用config.useClusterServers()添加集群地址
config.useSingServer().setAddress("redis://192.168.150.101.6379").setPassword("zhang..0902")
// 创建客户端
return Redisson.create(config);
}
}
- 使用Redisson的分布式锁
@Resource
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException{
// 获取锁(可重入), 指定锁的名称
RLock lock = redisson.getLock("anyLock");
// 尝试获取锁, 参数分别是: 获取锁的最大等待时间(期间会重试), 锁自动释放时间, 时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
if(isLcok){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
Redisson可重入锁原理
获取锁的Lua脚本:
释放锁的Lua脚本:
基于Redis的分布式锁优化
基于setnx实现的分布式锁存在下面的问题:
Redisson分布式锁原理
总结
Redisson分布式锁的原理:
- 可重入: 利用hash结构记录线程id和重入次数
- 可重试: 利用信号量和PubSub功能实现等待, 唤醒, 获取锁失败的重试机制
- 超时续约: 利用watchDog, 每个一段时间(releaseTime / 3), 重置超时时间
Redisson分布式锁主从一致性问题
总结
- 不可重入Redis分布式锁:
- 原理: 利用setnx的互斥性; 利用ex避免死锁; 释放锁时判断线程标示
- 缺陷: 不可重入, 无法重试, 锁超时失效
- 可重入的Redis分布式锁:
- 原理: 利用hash结构, 记录线程标示和重入次数; 利用watchDog延续锁的时间; 利用信号量控制锁重试等待
- Redisson的multiLock:
- 原理: 多个独立的Redis节点, 必须在所有节点都获取重入锁, 才算获取锁成功
- 缺点: 运维成本高, 实现复杂
Redis优化秒杀
案例 改进秒杀业务, 提高并发性能
需求:
- 新增秒杀优惠券的同时, 将优惠券信息保存到Redis中
- 基于Lua脚本, 判断秒杀库存, 一人一单, 决定用户是否抢购成功
- 如果抢购成功, 将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务, 不断从阻塞队列中获取信息, 实现异步下单功能
@PostConstruct 在当前类初始化完毕后来执行
@PostConstruct
private void init(){
}
总结
秒杀业务的优化思路是什么?
- 利用Redis完成库存余量, 一人一单判断, 完成抢单业务
- 在将下单业务放入阻塞队列, 利用独立线程异步下单
基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题
- 数据安全问题
Redis消息队列实现异步秒杀
消息队列(Message Queue) , 字面意思就是存放消息的队列. 最简单的消息队列模型包括3个角色:
- 消息队列: 存储和管理消息, 也被称为消息代理(Message Broker)
- 生产者: 发送消息到消息队列
- 消费者: 从消息队列获取消息并处理消息
Redis提供了三种不同的方式来实现消息队列:
- list结构: 基于List结构模拟消息队列
- Pubsub: 基本的点对点消息模型
- Stream: 比较完善的消息队列模型
基于List结构模拟消息队列
消息队列(Message Queue), 字面意思就是存放消息的队列. 而Redis的list数据结构就是一个双向链表, 很容易模拟出队列效果.
队列是入口和出口不在一边, 因此我们可以利用: LPUSH结合RPOP, 或者RPUSH结合LPOP来实现.
因此这里应该使用BRPOP或者BLPOP来实现阻塞效果.
总结
基于List的消息队列有哪些优缺点?
优点:
- 利用Redis存储, 不受限于JVM内存上限
- 基于Redis的持久化机制, 数据安全性有保证
- 可以满足消息有序性
缺点:
- 无法避免消息丢失
- 只支持单消费者
基于PubSub的消息队列
PubSub(发布订阅)是Redis2.0版本引入的消息传递模型. 顾名思义, 消费者可以订阅一个或者多个channel, 生产者向对应channel发送消息后, 所有订阅者都能收到相关消息.
- SUBSCRIBE channel [channel] : 订阅一个或多频道
- PUBLISH channel msg : 向一个频道发送消息
- PSUBSCRIBE pattern [pattern] : 订阅与pattern格式匹配的所有频道
总结
基于PubSub的消息队列有哪些优缺点?
优点:
- 采用发布订阅模型, 支持多生产, 多消费
缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限, 超出时数据丢失
基于Stream的消息队列
Stream是Redis 5.0 引入的一种新数据类型, 可以实现一个功能非常完善的消息队列.
发送消息的命令:
例如:
读取消息的方式之一: XREAD
例如, 使用XREAD读取第一个消息:
基于Stream的消息队列-XREAD
XREAD\阻塞方式, 读取最新的消息:
在业务开发中, 我们可以循环的调用XREAD阻塞方式来查询最新消息, 从而实现持续监听队列的效果, 伪代码如下:
总结
STREAM类型消息队列的XREAD命令特点:
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读的风险
基于Stream的消息队列-消费者组
消费者组(Consumer Group) : 将多个消费者划分到一个组中, 监听同一个队列. 具备下列特点:
创建消费者组:
- key : 队列名称
- geoupName : 消费者组名称
- ID : 起始ID标示, $代表队列中最后一个消息, 0则标示队列中第一个消息
- MKSTREAM : 队列不存在时自动创建队列
其他常见命令:
从消费者组读取消息:
- group : 消费组名称
- consumer : 消费者名称, 如果消费者不存在, 会自动创建一个消费者
- count : 本次查询的最大数量
- BlOCK milliseconds : 当没有消息时最长等待时间
- NOACK : 无需手动ACK, 获取到消息后自动确认
- STREAMS key : 指定队列名称
- ID : 获取消息的起始ID:
- “>” : 从下一个为消费的消息开始
- 其他 : 根据指定id从pending-list中获取已消费但未确认的消息, 例如0, 是从pending-list中的第一个消息开始
读取完信息要确认信息 XACK 确认完之后就会从消息队列里移除 :
消费者监听消息的基本思路:
总结
STREAM类型消息队列的XREADGROUP命令特点:
- 消息可回溯
- 可以多消费者争抢消息, 加快消费速度
- 可以阻塞读取
- 没有消息漏读的风险
- 有消息确认机制, 保证消息至少被消费一次
Redis消息队列
List | PubSub | Stream | |
---|---|---|---|
消息持久化 | 支持 | 不支持 | 支持 |
阻塞读取 | 支持 | 支持 | 支持 |
消息堆积处理 | 受限于内存空间,可以利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度,可以利用消费者组提高消费速度,减少堆积 |
消息确认机制 | 不支持 | 不支持 | 支持 |
消息回溯 | 不支持 | 不支持 | 支持 |
案例 基于Redis的Stream结构作为消息队列, 实现异步秒杀下单
需求:
- 创建一个Stream类型的消息队列, 名为stream.orders
- 修改之前的秒杀下单Lua脚本, 在认定有抢购资格后, 直接想stream.orders中添加消息, 内容包含voucherId, userId, orderId
- 项目启动时, 开启一个线程任务, 尝试获取stream.orders中的消息, 完成下单
达人探店
- 发布探店笔记
- 点赞
- 点赞排行榜
发布探店笔记
发布探店笔记类型点评网站的评价, 往往都是图文结合. 对应的表有两个:
- tb_blog : 探店笔记表, 包含笔记中的标题, 文字, 图片等
- tb_blog_comments : 其他用户对笔记的评价
点击首页最下方菜单栏中的+号按钮, 即可发布探店图文:
案例 实现参看发布探店笔记的接口
需求: 点击首页的探店笔记, 会进入详情页面, 实现该页面的查询接口:
点赞
在首页的探店笔记排行榜和探店图文详情页面都有点赞的功能:
案例 完善点赞功能
需求:
- 同一个用户只能点赞一次, 再次点击则取消点赞
- 如果当前用户已经点赞, 则点赞按钮高亮显示 (前端已实现, 判断字段Blog类的isLike属性)
实现步骤:
- 给Blog类中添加一个isLike字段, 标示是否被当前用户点赞
- 修改点赞功能, 利用Redis的set集合判断是否点赞过, 未点赞过则点赞数+1, 一点赞过则点赞数-1
- 修改根据id查询Blog的业务, 判断当前登录用户是否点赞过, 赋值给isLike字段
- 修改分页查询Blog业务, 判断当前登录用户是否点赞过, 赋值给isLike字段