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时间,到期后自动删除缓存. 下次查询时更新缓存. 编写业务逻辑, 在修改数据库的同时, 更新缓存.
一致性 一般
维护成本

业务场景:

  • 低一致性需求: 使用内存淘汰机制. 例如店铺烈性的查询缓存
  • 高一致性需求: 主动更新, 并以超时剔除作为兜底方案. 例如店铺详细查询

主动更新策略

操作缓存和数据库时有三个问题需要考虑:

  1. 删除缓存还是更新缓存?
  • 更新缓存: 每次更新数据库都更新缓存, 无效写操作较多
  • 删除缓存: 更新数据时让缓存失效, 查询时在更新缓存
  1. 如何保证缓存与数据库的操作的同时成功或失败?
  • 单体系统, 将缓存与数据库操作放在一个事务
  • 分布式系统, 利用TCC等分布式事务方案
  1. 先操作缓存还是先操作数据库?
  • 先删除缓存, 在操作数据库
  • 先操作数据库, 在删除缓存

Cache Aside Pattern

缓存更新策略的最佳实践方案:

  1. 低一致性需求: 使用Redis自带的内存淘汰机制
  2. 高一致性需求: 主动更新, 并以超时剔除作为兜底方案
    • 读写操作:
      • 缓存命中则直接返回
      • 缓存未命中则查询数据库, 并写入缓存, 设定超时时间
    • 写操作:
      • 先写数据库, 然后在删除缓存
      • 要确保数据库与缓存操作的原子性

案例 给查询店铺的缓存添加超时剔除和主动更新的策略

修改ShopController中的业务逻辑, 满足下面的需求:

  1. 根据id查询店铺时, 如果缓存未命中, 则查询数据库, 将数据库结果写入缓存, 并设置超时时间
  2. 根据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法

总结

超卖这样的线程安全问题, 解决方案有哪些?

  1. 悲观锁: 添加同步锁, 让线程串行执行
  • 优点: 简单粗暴
  • 缺点: 性能一般
  1. 乐观锁: 不加锁, 在更新时判断是否有其他线程在修改
  • 优点: 性能好
  • 缺点: 存在成功率低的问题

案例 一人一单

需求: 修改秒杀业务, 要求同一个优惠券, 一个用户只能下一单

一人一单的并发安全问题

通过加锁可以解决在单机情况下的一人一单安全问题, 但是在集群模式下就不行了.

  1. 我们将服务启动两份, 端口分别为8081和8082:
  1. 然后修改nginx的conf目录下的nginx.conf文件, 配置反向代理和负载均衡:

分布式锁

什么是分布式锁

分布式锁: 满足分布式系统或集群模式下多线程可见并且互斥的锁.

分布式锁的实现

分布式锁的核心就是实现多进程之间互斥, 而满足这一点的方式有很多, 常见的有三种:

MySQL Redis Zookeeper
互斥 利用mysql本事的互斥锁机制 利用setnx这样的互斥命令 利用节点的唯一性和有序性实现互斥
高可用
高性能 一般 一般
安全性 断开连接, 自动释放锁 利用锁超时时间, 到期释放 临时节点, 断开连接自动释放

基于Redis的分布式锁

实现分布式锁需要实现的两个基本方法:

  • 实现锁:
    • 互斥: 确保只能有一个线程获取锁
  • 释放锁:
    • 手动释放
    • 超时释放: 获取锁时添加一个超时时间

案例 基于Redis实现分布式锁初级版本

需求: 定义一个类, 实现下面接口, 利用Redis实现分布式锁功能.

案例 改进Redis的分布式锁

需求: 修改之前的分布式锁实现, 满足:

  1. 在获取锁时存入线程标识(可以用UUID表示)
  2. 在释放锁时先获取锁中的线程标示, 判断是否与当前线程标示一致
    • 如果一致则释放锁
    • 如果不一致则不释放锁

保证判断和删除锁的原子性

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数组获取这些参数:

释放锁的业务流程是这样的:

  1. 获取锁中的线程标示
  2. 判断是否与指定的标示(当前线程标示)一致
  3. 如果一致则释放锁(删除)
  4. 如果不一致则什么都不做

如果用Lua脚本来表示则是这样的:

案例 再次改进Reids的分布式锁

需求: 基于Lua脚本实现分布式锁的释放锁逻辑

提示: RedisTemplate调用Lua脚本的API如下:

java 使用Lua

  1. private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
  2. static {
  3. UNLOCK_SCRIPT = new DefaultRedisScript<>();
  4. UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
  5. UNLOCK_SCRIPT.setResultType(Long.class);
  6. }
  7. @Override
  8. public void unlock() {
  9. // 调用lua脚本 让判断和删除成为一步 同时判断删除 有了原子性
  10. stringRedisTemplate.execute(
  11. UNLOCK_SCRIPT,
  12. Collections.singletonList(KEY_PREFIX+name),
  13. ID_PREFIX + Thread.currentThread().getId()
  14. );
  15. }

总结

基于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入门

  1. 引入依赖
  1. <dependency>
  2. <groupId>org.redisson</groupId>
  3. <artifactId>redisson</artifactId>
  4. <version>3.13.6</version>
  5. </dependency>
  1. 配置Redisson客户端:
  1. @Configuration
  2. public class RedisConfig{
  3. @Bean
  4. public RedissonClient redissonClent(){
  5. // 配置类
  6. Config config = new Config();
  7. // 添加redis地址, 这里添加了单点的地址, 也可以使用config.useClusterServers()添加集群地址
  8. config.useSingServer().setAddress("redis://192.168.150.101.6379").setPassword("zhang..0902")
  9. // 创建客户端
  10. return Redisson.create(config);
  11. }
  12. }
  1. 使用Redisson的分布式锁
  1. @Resource
  2. private RedissonClient redissonClient;
  3. @Test
  4. void testRedisson() throws InterruptedException{
  5. // 获取锁(可重入), 指定锁的名称
  6. RLock lock = redisson.getLock("anyLock");
  7. // 尝试获取锁, 参数分别是: 获取锁的最大等待时间(期间会重试), 锁自动释放时间, 时间单位
  8. boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
  9. if(isLcok){
  10. try{
  11. System.out.println("执行业务");
  12. }finally{
  13. //释放锁
  14. lock.unlock();
  15. }
  16. }
  17. }

Redisson可重入锁原理

获取锁的Lua脚本:

释放锁的Lua脚本:

基于Redis的分布式锁优化

基于setnx实现的分布式锁存在下面的问题:

Redisson分布式锁原理

总结

Redisson分布式锁的原理:

  • 可重入: 利用hash结构记录线程id和重入次数
  • 可重试: 利用信号量和PubSub功能实现等待, 唤醒, 获取锁失败的重试机制
  • 超时续约: 利用watchDog, 每个一段时间(releaseTime / 3), 重置超时时间

Redisson分布式锁主从一致性问题

总结

  1. 不可重入Redis分布式锁:
    • 原理: 利用setnx的互斥性; 利用ex避免死锁; 释放锁时判断线程标示
    • 缺陷: 不可重入, 无法重试, 锁超时失效
  2. 可重入的Redis分布式锁:
    • 原理: 利用hash结构, 记录线程标示和重入次数; 利用watchDog延续锁的时间; 利用信号量控制锁重试等待
  3. Redisson的multiLock:
    • 原理: 多个独立的Redis节点, 必须在所有节点都获取重入锁, 才算获取锁成功
    • 缺点: 运维成本高, 实现复杂

Redis优化秒杀

案例 改进秒杀业务, 提高并发性能

需求:

  1. 新增秒杀优惠券的同时, 将优惠券信息保存到Redis中
  2. 基于Lua脚本, 判断秒杀库存, 一人一单, 决定用户是否抢购成功
  3. 如果抢购成功, 将优惠券id和用户id封装后存入阻塞队列
  4. 开启线程任务, 不断从阻塞队列中获取信息, 实现异步下单功能

@PostConstruct 在当前类初始化完毕后来执行

  1. @PostConstruct
  2. private void init(){
  3. }

总结

秒杀业务的优化思路是什么?

  1. 利用Redis完成库存余量, 一人一单判断, 完成抢单业务
  2. 在将下单业务放入阻塞队列, 利用独立线程异步下单

基于阻塞队列的异步秒杀存在哪些问题?

  • 内存限制问题
  • 数据安全问题

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结构作为消息队列, 实现异步秒杀下单

需求:

  1. 创建一个Stream类型的消息队列, 名为stream.orders
  2. 修改之前的秒杀下单Lua脚本, 在认定有抢购资格后, 直接想stream.orders中添加消息, 内容包含voucherId, userId, orderId
  3. 项目启动时, 开启一个线程任务, 尝试获取stream.orders中的消息, 完成下单

达人探店

  • 发布探店笔记
  • 点赞
  • 点赞排行榜

发布探店笔记

发布探店笔记类型点评网站的评价, 往往都是图文结合. 对应的表有两个:

  • tb_blog : 探店笔记表, 包含笔记中的标题, 文字, 图片等
  • tb_blog_comments : 其他用户对笔记的评价

点击首页最下方菜单栏中的+号按钮, 即可发布探店图文:

案例 实现参看发布探店笔记的接口

需求: 点击首页的探店笔记, 会进入详情页面, 实现该页面的查询接口:

点赞

在首页的探店笔记排行榜和探店图文详情页面都有点赞的功能:

案例 完善点赞功能

需求:

  • 同一个用户只能点赞一次, 再次点击则取消点赞
  • 如果当前用户已经点赞, 则点赞按钮高亮显示 (前端已实现, 判断字段Blog类的isLike属性)

实现步骤:

  1. 给Blog类中添加一个isLike字段, 标示是否被当前用户点赞
  2. 修改点赞功能, 利用Redis的set集合判断是否点赞过, 未点赞过则点赞数+1, 一点赞过则点赞数-1
  3. 修改根据id查询Blog的业务, 判断当前登录用户是否点赞过, 赋值给isLike字段
  • 修改分页查询Blog业务, 判断当前登录用户是否点赞过, 赋值给isLike字段