【黑马点评】项目记录与功能改进

1. 黑马点评

在这里插入图片描述

1.1 前置信息

1.1.1 数据库

  • ltb_user:用户表
  • ltb_user_info:用户详情表
  • ltb_shop:商户信息表
  • ltb_shop_type:商户类型表
  • ltb_blog:用户日记表(达人探店日记)
  • ltb_follow:用户关注表 ltb_voucher:优惠券
  • ltb_voucher_order:优惠券的订单表

1.1.2 项目架构

在这里插入图片描述

前端:8080

1.2 项目启动

1.2.1 启动项目报错

  • 问题:NOGROUP No such key ‘stream.orders’ or consumer group ‘g1’ in XREADGROUP with GROUP option
    XGROUP CREATE stream.orders g1 0 MKSTREAM
    
  • 问题:Error in execution; nested exception is io.lettuce.core.RedisCommandExecutionException: ERR unknown command GEOSEARCH, with args beginning with: shop:geo:1, FROMLONLAT, 120.149993, 30.334229, BYRADIUS, 5000.0, m, WITHDIST, COUNT, 5,
    需要redis版本在6.2以上,下载路径来源https://zhuanlan.zhihu.com/p/474260153

1.3 登录

1.3.1 基于session实现登录

&pos_id=img-xIWzKnrd-1743909646985)

1.3.2 基于Redis实现短信登录

在这里插入图片描述
在这里插入图片描述

1.4 商户查询缓存

缓存:读写性能较高

1.4.1 添加Redis缓存

在这里插入图片描述

之前:20.94ms 之后:11.94ms
店铺缓存:string类型
店铺类型缓存:list类型
用户缓存:hash类型

1.4.2 缓存更新策略

在这里插入图片描述

  • 业务场景:
    低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
    高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
1.4.2.1 主动更新策略
  1. 删除缓存还是更新缓存?
    更新缓存:每次更新数据库都更新缓存,无效写操作较多
    删除缓存:更新数据库时让缓存失效,查询时再更新缓存√
  2. 如何保证缓存与数据库的操作的同时成功或失败?
    单体系统,将缓存与数据库操作放在一个事务
    分布式系统,利用TCC等分布式事务方案
  3. 先操作缓存还是先操作数据库?(多线程并发)
    • 先删除缓存,再操作数据库
    • 先操作数据库,再删除缓存

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

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

1.4.3 缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库
解决方案
在这里插入图片描述

  • 缓存空对象
    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗(设置短期TTL);可能造成短期的不一致
  • 布隆过滤
    • 保存二进制数据,不是百分百准确
    • 优点:内存占用较少,没有多余key
    • 缺点:实现复杂;存在误判可能
  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
1.4.3.1 布隆过滤器实现

布隆过滤器的核心思想是存储的是元素的哈希值,可以快速判断一个元素是否可能存在,这样可以有效拦截非法请求,减少对数据库的直接访问。

操作说明
可能存在数据可能存在,但不能 100% 确定
一定不存在该数据绝对不存在
  • 引入 Guava 依赖
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>31.1-jre</version> <!-- 最新版本 -->
    </dependency>
    
  • 初始化布隆过滤器
    @Component  
    @Slf4j  
    public class BloomFilterInitializer {  
      
        private static BloomFilter<Long> bloomFilter;  
        @Resource  
        private ShopMapper shopMapper;  
      
        @PostConstruct  
        public void initBloomFilter() {  
            //1.从数据库中加载所有存在的店铺ID  
            List<Long> ids = shopMapper.selectObjs(Wrappers.<Shop>query().select("id")  
            )       .stream()  
                    .map(obj -> ((Number) obj).longValue())  
                    .collect(Collectors.toList());  
            //2. 初始化布隆过滤器(预计元素100万,误判率1%)  
            bloomFilter = BloomFilter.create(  
                    Funnels.longFunnel(),  
                    1_000_000L,  
                    0.01  
            );  
            //3. 将数据库中的店铺id加入布隆过滤器  
            ids.forEach(bloomFilter::put);  
            log.info("布隆过滤器初始化完成,共存入 {} 个店铺 ID", ids.size());  
        }  
        public static BloomFilter<Long> getBloomFilter() {  
            return bloomFilter;  
        }  
    }
    
  • 在查询逻辑中使用布隆过滤器
    @Override  
    public Result queryById(Long id) {  
        // 使用布隆过滤器拦截不存在的ID  
        if (!BloomFilterInitializer.getBloomFilter().mightContain(id)){  
            log.info("店铺不存在,id={}",id);  
            return Result.fail("店铺不存在");  
        }  
        String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);  
        //缓存命中,直接返回  
        if (StrUtil.isNotBlank(shopJson)){  
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);  
            return Result.ok(shop);  
        }  
        // 缓存穿透,判断是否为空值  
        if(shopJson != null){  
            return Result.fail("店铺不存在");  
        }  
        Shop shop = getById(id);  
        if(shop == null){  
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,"",RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);  
            return Result.fail("店铺不存在");  
        }  
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); 
        return Result.ok(shop);  
    }
    

1.4.4 缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力

  • 解决方案
    • 给不同的Key的TTL添加随机值
    • 利用Redis集群提高服务的可用性(解决宕机情况 Redis哨兵机制、主从机制)
    • 给缓存业务添加降级限流策略
    • 给业务添加多级缓存(Nginx缓存…)
      • 集成Caffine、Redis实现双重缓存
      • https://blog.csdn.net/m0_52031708/article/details/142862864

1.4.5 缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击

  • 常见解决方案
    在这里插入图片描述

    • 互斥锁
      在这里插入图片描述

    • 逻辑过期
      不设置TTL,存储时添加过期时间信息
      在这里插入图片描述

1.4.5.1 基于互斥锁方式解决缓存击穿问题

在这里插入图片描述

setnx 只有第一个可以写成功
设置5s启动1000个线程,吞吐量199.7

1.4.5.2 基于逻辑过期方式解决缓存击穿问题

在这里插入图片描述

所有的热点key都会一直存在,直到活动结束

1.4.5 缓存工具封装

1.5 优惠券秒杀

1.5.1 全局唯一ID

当用户抢购时,会生成订单并保存到tb_voucher_order这张表中,订单如果使用数据库自增ID就存在一些问题:

  • id规律性太明显
  • 受单表数据量的限制
    • 分布式存储,单表自增会出现id重复的问题

解决方案

  • UUID(无法自增)
  • Redis自增
  • snowflake算法
  • 数据库自增(单独维护一张表记录id)

1.5.1.1 全局ID生成器

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:唯一性、高可用、高性能、递增性、安全性

  • Redis自增ID策略
    • 每天一个key,方便统计订单量
    • ID构造是 时间戳 + 计数器

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
在这里插入图片描述

ID的组成部分:

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

1.5.2 实现优惠券秒杀下单

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀采购

  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等
  • tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
1.5.2.1 超卖问题
  • 悲观锁:添加同步锁,让线程串行执行
    • 优点:简单粗暴;缺点:性能一般
  • 乐观锁:不加锁,在更新时判断是否有其他线程在修改
    • 优点:性能好;缺点:存在成功率低的问题
  • 分段锁:解决成功率低的问题
    在这里插入图片描述

乐观锁的关键是判断之前查询到的数据是否被修改过,常见的方式有两种:

  • 版本号法
    在这里插入图片描述

  • CAS法(Compare and Set)
    不需要version,比较stock值是否相同
    在这里插入图片描述

问题:没有超卖,但是没卖完。成功率太低

1.5.2.2 一人一单

根据优惠券id和用户id查询订单,判断订单是否已经存在
给用户id加锁,悲观锁

  • 添加依赖

    <dependency>  
        <groupId>org.aspectj</groupId>  
        <artifactId>aspectjweaver</artifactId>  
    </dependency>
    
  • 启动类添加注解,暴露代理对象

    @EnableAspectJAutoProxy(exposeProxy = true)
    
  • 实现
    mysql默认采用可重复读,先开事务,线程A和线程B拿到的库存都是1000。如果在createVoucherOrder内部加锁,锁释放后,事务才会提交。当A线程释放锁,库存变为999,但事务还没提交时,此时B线程进入,库存仍为1000

方案一:在调用方法时加锁,问题:分布式场景下失效
方案二:使用mysql行锁,不适合用于并发量大的场景,会将压力都给数据库
方案三:使用redis分布式锁 setnx命令
```
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
// Voucher voucher = voucherMapper.selectById(voucherId);
SeckillVoucher seckillVoucher = seckillVoucherMapper.selectById(voucherId);
if (seckillVoucher==null){
return Result.fail(“没有该优惠券!”);
}
// 2.判断秒杀是否开始和结束
if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail(“秒杀尚未开始!”);
}
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail(“秒杀已经结束!”);
}
// 3.判断库存是否充足
if(seckillVoucher.getStock()<1){
return Result.fail(“库存不足!”);
}
Long userId = UserHolder.getUser().getId();
// 对单一用户加锁,同时保证先提交事务,再释放锁
synchronized (userId.toString().intern()){
//默认使用this调用,拿到的是当前的voucherOrderServiceImpl对象,而不是代理对象
//事务生效,是因为spring对当前类做了代理,拿到了代理对象,用代理对象做事务处理
//会导致spring事务失效

           //拿到当前对象 IVocherOrderService 的代理对象  
           // 获取代理对象(事务)  
           IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();  
           return proxy.createVoucherOrder(voucherId);  
       }  
   }  
 
   // 锁不能直接加在方法上,要对单个用户加锁  
   @Transactional  
   public Result createVoucherOrder(Long voucherId) {  
       // 一人一单  
       Long userId = UserHolder.getUser().getId();  
       // 事务在锁结束之后才提交  
       int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();  
       // 判断用户是否已经购买过  
       if(count>0){  
           return Result.fail("不能重复下单!");  
       }  
       // 4.扣减库存  
       boolean success = seckillVoucherService.update().  
               setSql("stock=stock-1").  
               eq("voucher_id", voucherId).gt("stock",0).update();  
       if (!success){  
           return Result.fail("库存不足!");  
       }  
       long id = redisIdWorker.nexId("order");  
 
       VoucherOrder voucherOrder = new VoucherOrder();  
       voucherOrder.setId(id);  
       voucherOrder.setUserId(userId);  
       voucherOrder.setVoucherId(voucherId);  
       save(voucherOrder);  
 
       return Result.ok(id);  
    }
```
1.5.2.3 一人一单的并发安全问题

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

锁的原理是在JVM内部维护一个锁监视器,userId在常量池里,id相同时是同一个锁,锁的监视器是同一个。当做集群部署时,有多套JVM,有各自的堆栈方法区,就会有新的锁监视器

产生安全问题原因:在集群模式下,或者有些是在分布式系统下,有多个JVM的存在,每个JVM内部都有自己的锁,导致每个锁都可以有一个线程获取,于是就出现并行运行,那么就可能出现安全问题

1.5.3 分布式锁

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

  • 分布式锁的实现
    • MySQL:在业务执行前先去MySQL申请一个互斥锁,然后去执行业务,业务执行完之后再提交事务,释放锁。当操作异常时,触发回滚,锁自动释放
    • Redis:利用setnx的互斥命令,只有数据不存在时才能成功
    • Zookeeper:利用节点的唯一性和有序性实现互斥
      在这里插入图片描述
1.5.3.1 基于Redis的分布式锁

基于Redis的分布式锁实现思路:

  • 利用set nx ex获取锁,并设置过期时间,保存线程标示
  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁
    特性:
  • 利用set nx满足互斥性
  • 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
  • 利用Redis集群保证高可用和高并发特性
1.5.3.1.1 问题1:获取锁之后,服务宕机了,没有释放锁机制,会出现死锁
  • 解决方法:添加超时释放,获取锁时添加一个超时时间。但是必须保证setnx和expire两个操作的原子性,SET lock thread1 NX EX 10一条命令实现
  • 如果获取失败,有两种方式:阻塞式(等待重试)和非阻塞式(直接返回)
    在这里插入图片描述
1.5.3.1.2 问题2:线程阻塞,会释放其他线程的锁
  • 解决方法:利用锁标识判断是否一致。线程ID仅仅能用来区分同一JVM线程,UUID可以用来区分不同的JVM线程
  • 在获取锁时存入线程标示(可以用UUID表示)(原来存的线程ID,线程ID在JVM是递增的,多个JVM可能会存在相同的线程ID)
  • 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
    • 如果一致则释放锁
    • 如果不一致则不释放锁
      在这里插入图片描述
1.5.3.1.3 问题3:释放锁时发生阻塞,误删锁

在线程1要释放锁时发生阻塞(JVM垃圾回收,会阻塞所有代码),阻塞时间过长,超时释放锁,此时线程1判断已经通过,但如果已经有其他线程新加了自己的锁,线程1就会删掉线程2的锁

  • 必须确保判断锁标识的操作和删除锁的操作是原子性操作
  • Redis事务能够保证原子性,不能保证一致性,其实是批处理
  • 利用Lua脚本,调用RedisTemplate execute命令
1.5.3.2 Lua脚本

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言

  • Redis提供的调用函数:
    # 执行redis命令
    redis.call('命令名称', 'key', '其它参数', ...)
    
    如:
    # 执行 set name jack
    redis.call('set', 'name', 'jack')
    
    # 先执行 set name jack
    redis.call('set', 'name', 'jack')
    # 再执行 get name
    local name = redis.call('get', 'name')
    # 返回
    return name
    
  • 调用脚本常见命令
    例如,我们要执行 redis.call(‘set’, ‘name’, ‘jack’) 这个脚本,语法如下:
    在这里插入图片描述

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
在这里插入图片描述

1.5.3.3 基于Redis的分布式锁优化

可重入:同一个线程可以多次获取同一把锁
在这里插入图片描述

要解决上述问题,实际操作很麻烦,考虑借助别的工具

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

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现

  • 入门
    <dependency>  
    	<groupId>org.redisson</groupId>  
    	<artifactId>redisson</artifactId>  
    	<version>3.13.6</version>  
    </dependency>
    
  • 配置Redisson客户端
    @Configuration  
    public class RedisConfig {  
    	@Bean  
    	public RedissonClient redissonClient() {  
    		// 配置类  
    		Config config = new Config();  
    		// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址  
    		config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassowrd("123321");  
    		// 创建客户端  
    		return Redisson.create(config);  
    	}  
    }
    
  • 使用Redission的分布式锁
    @Resource  
    private RedissonClient redissonClient;  
      
    @Test  
    void testRedisson() throws InterruptedException {  
    	// 获取锁(可重入),指定锁的名称  
    	RLock lock = redissonClient.getLock("anyLock");  
    	// 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位  
    	boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);  
    	// 判断释放获取成功  
    	if(isLock){  
    		try {  
    			System.out.println("执行业务");  
    		}finally {  
    			// 释放锁  
    			lock.unlock();  
    		}  
    	}  
    }
    
1.5.3.3.2 Redisson可重入锁原理

可重入:利用hash记录锁名称、获取锁的线程、重入次数
tryLock:判断是否有锁->线程标识是否一致->重入次数+1
unLock:释放一次,重入次数-1->判断是否为0->删除锁
在这里插入图片描述

1.5.3.3.3 Redisson的锁重试和WatchDog机制

可重试:利用信号量和PubSub功能实现等待、唤醒、获取锁失败的重试机制
超时续约:利用watchDog,每隔一段时间(releaseTime/3),重置超时时间
自己设置有效期leaseTime了,就不会有看门狗;看门狗默认的锁释放时间是30s
对于源码中Lua脚本,获取锁成功会返回nil,更新有效期,只要持有锁的线程在,会一直不停的续期;获取锁失败,会返回锁剩余有效期
剩余有效期-操作的时间,时间有剩余,重试
订阅别人释放锁的信号,异步等待,等待时间为最大剩余时间
获取当前的最大剩余时间,开始重试
在这里插入图片描述

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

搭建redis主从集群,主节点负责增删改、从节点负责读,主从节点数据要同步
问题描述:主节点写入锁,还没有同步到从节点时,主节点宕机。哨兵机制会在从节点中选出新的主节点,但新的主节点没有写入的锁信息,出现主从一致性问题
解决方法:多个redis都可以进行读写,在所有redis中都拿到锁才算成功
在这里插入图片描述

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

1.5.4 Redis秒杀优化

  • 秒杀业务的优化思路是什么?
    ①先利用Redis完成库存余量、一人一单判断,完成抢单业务
    ②再将下单业务放入阻塞队列,利用独立线程异步下单
  • 基于阻塞队列的异步秒杀存在哪些问题?
    内存限制问题:JVM内存有限
    数据安全问题:JVM内存没有持久化处理,重启或宕机会让订单任务丢失

在秒杀业务中,减库存和创建订单是对数据库的写操作,mysql处理并发的效率低
判断秒杀库存和校验一人一单是对数据库的读操作,为提高效率,将该操作在redis中进行
同步下单变异步下单
在这里插入图片描述

优惠券id:string存储
用户id:set存储。voucherID作为key,多个userId作为value(用户唯一)
0有资格购买、1库存不足、2重复下单
在这里插入图片描述

1.5.4.1 需求

①新增秒杀优惠券的同时,将优惠券信息保存到Redis中
②基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
③如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
④开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
阻塞队列就是可以阻塞线程的队列,当一个线程尝试从队列里获取元素时,如果没有元素,该线程会被阻塞,直到有元素,该线程才会被唤醒

  • 将订单信息放入阻塞队列
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024);
    
    orderTasks.add(voucherOrder);
    
  • 执行异步下单
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();  
      
    //在当前类初始化之后就执行  
    @PostConstruct  
    private void init(){  
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());  
    }  
      
    private class VoucherOrderHandler implements Runnable{  
        @Override  
        public void run() {  
            while (true){  
                try {  
                    //获取队列中的订单信息  
                    VoucherOrder order = orderTasks.take();  
                    //创建订单  
                    handleVoucherOrder(order);  
                } catch (InterruptedException e) {  
                   log.error("处理订单异常",e);  
                }  
            }  
        }  
    }
    

1.5.5 Redis消息队列实现异步秒杀

消息队列(Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
消息队列:存储和管理消息,也被称为消息代理(Message Broker)
生产者:发送消息到消息队列
消费者:从消息队列获取消息并处理消息
与阻塞队列区别:不依赖内存,不用担心内存不足;持久化保存,安全;进行消息确认;
在这里插入图片描述

Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于List结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型
    在这里插入图片描述
1.5.5.1 基于List结构模拟消息队列

消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。

队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。

不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。

  • 优点:
    • 利用Redis存储,不受限于JVM内存上限
    • 基于Redis的持久化机制,数据安全性有保证
    • 可以满足消息有序性
  • 缺点:
    • 无法避免消息丢失
    • 只支持单消费者
1.5.5.2 基于PubSub的消息队列

PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。

SUBSCRIBE channel [channel] :订阅一个或多个频道
PUBLISH channel msg :向一个频道发送消息
PSUBSCRIBE pattern[pattern] :订阅与pattern格式匹配的所有频道
  • 优点:
    • 采用发布订阅模型,支持多生产、多消费
  • 缺点:
    • 不支持数据持久化
    • 无法避免消息丢失
    • 消息堆积有上限,超出时数据丢失
1.5.5.3 基于Stream的消息队列
  • 发送消息的命令——XADD
    在这里插入图片描述
    在这里插入图片描述

  • 读取消息的方式之一——XREAD
    在这里插入图片描述

  • 使用方式
    在这里插入图片描述

注意:当我们指定起始ID为$时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题。

  • STREAM类型消息队列的XREAD命令特点:
    • 消息可回溯
    • 一个消息可以被多个消费者读取
    • 可以阻塞读取
    • 有消息漏读的风险
1.5.5.4 基于Stream的消息队列—消费者组

消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:
在这里插入图片描述

  • 创建消费者组

    XGROUP CREATE key groupName ID [MKSTREAM]
    
    • key:队列名称
    • groupName:消费者组名称
    • ID:起始ID标示,$代表队列中最后一个消息,0则代表队列中第一个消息
    • MKSTREAM:队列不存在时自动创建队列
  • 其他常见命令

    # 删除指定的消费者组
    XGROUP DESTROY key groupName
    # 给指定的消费者组添加消费者
    XGROUP CREATECONSUMER key groupname consumername
    # 删除消费者组中的指定消费者
    XGROUP DELCONSUMER key groupname consumername
    
  • 从消费者组读取消息

    XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
    
    • group:消费组名称
    • consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
    • count:本次查询的最大数量
    • BLOCK:milliseconds:当没有消息时最长等待时间
    • NOACK:无需手动ACK,获取到消息后自动确认
    • STREAMS key:指定队列名称
    • ID:获取消息的起始ID:
      • “>”:从下一个未消费的消息开始
      • 其它:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始
  • 消费者监听消息的基本思路
    在这里插入图片描述

  • STREAM类型消息队列的XREADGROUP命令特点:

    • 消息可回溯
    • 可以多消费者争抢消息,加快消费速度
    • 可以阻塞读取
    • 没有消息漏读的风险
    • 有消息确认机制,保证消息至少被消费一次
  • 缺点

    • 基于Redis实现的持久化,不能完全安全
    • 消息确认机制针对消费者,没有生产者的消息确认
    • 事务机制、有序性

1.6 达人探店

1.6.1 发布探店笔记

换成minio

1.6.2 点赞

在这里插入图片描述

1.6.3 点赞排行榜

在这里插入图片描述
zscore:按照分数是否存在,
zrange

1.7 好友关注

1.7.1 关注和取关

关注:在redis sortedset中添加被关注人id,存入数据库

1.7.2 共同关注

zset intersect

1.7.3 关注推送

关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
Feed流产品有两种常见模式:

  • Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
    • 优点:信息全面,不会有缺失。并且实现也相对简单
    • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
  • 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
    • 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
    • 缺点:如果算法不精准,可能起到反作用
      本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
      ①拉模式
      ②推模式
      ③推拉结合
      在这里插入图片描述
1.7.3.1 拉模式

拉模式:也叫做读扩散
在这里插入图片描述
发件箱发消息同时带上时间戳
收件箱是空的,只有在读的时候才会去拉取消息,做消息排序

1.7.3.2 推模式

推模式:也叫写扩散
在这里插入图片描述
用户发消息时,会直接推送到所有粉丝的收件箱,收件箱的消息做一个排序
消息会发好几份,内存占用比较高

1.7.3.3 推拉结合模式

推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。
在这里插入图片描述
对于大V,活跃粉丝采用推模式、普通粉丝采用拉模式
对于普通用户,采用推模式

1.7.3.4 实际实现

①修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
②收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
③查询收件箱数据时,可以实现分页查询

1.7.3.4.1 Feed流的滚动分页

Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。

  • 推送:redis 以粉丝id为key,添加博客id
  • 查询
    按照score查询,score为时间戳
    ZREVRANGEBYSCORE 分数最大值 分数最小值 偏移量 查询数量
    分数最小值不需要管,查询数量不需要管
    分数最大值:上一次查询出的最小的分数
    偏移量:0是包含,1是从下一个开始。与上一次查询最小分数一样的值
  • 滚动分页查询参数:
    • max:第一次,当前时间戳 | 上一次查询的最小值
    • min:0
    • offset:第一次,0 | 上一次查询的结果中,与最小值相同的元素的个数
    • count:3

1.8 附近商户

替换为es

1.8.1 GEO数据结构

GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:

[GEOADD](https://redis.io/commands/geoadd):添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
[GEODIST](https://redis.io/commands/geodist):计算指定的两个点之间的距离并返回
[GEOHASH](https://redis.io/commands/geohash):将指定member的坐标转为hash字符串形式并返回
[GEOPOS](https://redis.io/commands/geopos):返回指定member的坐标
[GEORADIUS](https://redis.io/commands/georadius):指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
[GEOSEARCH](https://redis.io/commands/geosearch):在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
[GEOSEARCHSTORE](https://redis.io/commands/geosearchstore):与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能

底层用SortedSet
哈希降维:先将经纬度坐标值转换成二进制的数字,然后再利用特殊的编码转换成对应的字符串(转换成字符串后,占用的空间就会小一点,节省内存)。

1.8.2 附近商户搜索

考虑:附近舞室搜索

1.9 用户签到

1.9.1 BitMap用法

假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条

每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节

用二进制数据串表示,按月来统计用户签到信息,签到记录为1,未签到则记录为0。把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)

Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位。

  • BitMap的操作命令有:
    [SETBIT](https://redis.io/commands/setbit):向指定位置(offset)存入一个0或1
    [GETBIT](https://redis.io/commands/getbit) :获取指定位置(offset)的bit值
    [BITCOUNT](https://redis.io/commands/bitcount) :统计BitMap中值为1的bit位的数量
    [BITFIELD](https://redis.io/commands/bitfield) :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
    [BITFIELD_RO](https://redis.io/commands/bitfield_ro) :获取BitMap中bit数组,并以十进制形式返回
    [BITOP](https://redis.io/commands/bitop) :将多个BitMap的结果做位运算(与 、或、异或)
    [BITPOS](https://redis.io/commands/bitpos) :查找bit数组中指定范围内第一个0或1出现的位置
    

1.9.2 签到功能

1.9.3 签到统计

连续签到统计

  • 问题1:如何得到本月到今天为止的所有签到数据?
    BITFIELD key GET u[dayOfMonth] 0
    
  • 问题2:如何从后向前遍历每个bit位?
    与 1 做与运算,就能得到最后一个bit位。
    随后右移1位,下一个bit位就成为了最后一个bit位。

1.10 UV统计

1.10.1 HyperLogLog用法

首先我们搞懂两个概念:

  • UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。

  • PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0

Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
在这里插入图片描述

1.10.2 实现UV统计

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

大田斗小木子

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

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

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

打赏作者

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

抵扣说明:

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

余额充值