黑马点评面试题

1.介绍一下你写的这个项目

我做的这个项目是一个仿大众点评的评价类项目,实现了优惠券秒杀,好友关注,点赞评论,查看附近娱乐场所等功能

2.在这个项目中你实现了哪些功能

优惠券秒杀:目录9的秒杀业务

好友关注,点赞评论:目录12

查看附近娱乐场所:使用Redis中GEO数据类型实现。将所有的商铺信息按照商铺类型在Redis中建立对应的数据然后进行查询即可。

3.为什么要用redis替代session实现登录注册功能,有什么好处?

因为session的数据是存储在服务器端的,当服务器中数据量很大的时候可能导致服务器内存不足。而Redis是基于内存的读写速度非常快和Session类似。

4.如何解决集群的session共享问题?

在分布式系统下每一个服务器的Session数据是独立的,用户在不同服务器之间切换的时候可能因为session不共享问题而需要反复登录。然而使用Redis则保证多个服务器访问的是同一个Redis就实现了数据的共享。此外Redis集群内部的数据一致性机制也很棒

5.如何解决了缓存穿透、缓存击穿和缓存雪崩问题?

缓存穿透是什么:

缓存穿透是指查询一个缓存和数据库里都没有的数据,每次查询都透过缓存直接查询数据库,最后返回空。当用户使用这条不存在的数据疯狂发起查询请求的时候就会给数据库造成了很大的压力,数据库可能挂掉

缓存穿透解决方法:

1.给这个数据设置一个空对象放在缓存中,并给这个空对象设置一个过期时间。

 缺点:(1)需要缓存层给这些空对象划分存储空间,当这些空对象过多时就很浪费存储空间。

            (2) 即使给这些空对象设置了一个很短的过期时间,还是会导致这一段时间内数据库和缓存中存储的数据不一致

 2.使用布隆过滤器(一种数据结构)

布隆过滤器是一种数据结构用于快速检查一个元素是否属于某个集合中。它可以快速判断一个元素是否在一个大型集合中,且判断速度很快且不占用太多内存空间。

布隆过滤器原理:

布隆过滤器的主要原理是使用一组哈希函数,将元素映射成一组位数组中的索引位置。当要检查一个元素是否在集合中时,将该元素进行哈希处理(使用哈希函数计算出3个值,这3个值即索引),然后查看哈希值(索引)对应的位数组的值是否为1。如果哈希值对应的位数组的值都为1,那么这个元素可能在集合中,否则这个元素肯定不在集合中。

例子:

比如我们一共有3个key,我们对这3个key分别进行3次hash运算,key1经过三次hash运算后的结果分别为2/6/10,那么就把布隆过滤器中下标为2/6/10的元素值更新为1,然后再分别对key2和key3做同样操作,结果如下图:

黑马点评面试题_第1张图片

这样,当客户端查询时,也对查询的key做3次hash运算得到3个位置,然后看布隆过滤器中对应位置元素的值是否为1,如果所有对应位置元素的值都为1,就证明key在库中存在,则继续向下查询;如果3个位置中有任意一个位置的值不为1,那么就证明key在库中不存在,直接返回客户端空即可。如下图:

黑马点评面试题_第2张图片

当客户端查询key4时,key4的3次hash运算中,有一个位置8的值为0,就说明key4在库中不存在,直接返回客户端空即可。

所以,布隆过滤器就相当于一个位于客户端与缓存层中间的拦截器一样,负责判断key是否在集合中存在。如下图:

黑马点评面试题_第3张图片

使用布隆过滤器的好处:

布隆过滤器的好处就是解决了第一种缓存空值的不足,但布隆过滤器也存在缺陷,首先,它有误判的可能,比如在上面客户端查询key4的图中,假如key4经过3次hash运算得到的位置分别是2/4/6,由于这3个位置的值都是1,所以,布隆过滤器就认为key4在库中存在,进而继续向下查询了。所以,布隆过滤器判断存在的key实际上可能是不存在的,但布隆过滤器判断不存在的key是一定不存在的。它的第二个缺点就是删除元素比较难,比如现在要删除key2这个元素,那么需要将2/7/11三个位置的元素值改为0,但这样就会影响到key1和key3的判断。

布隆过滤器优缺点:

布隆过滤器的优点包括:

1.时间和空间效率高:布隆过滤器的时间复杂度和空间复杂度都是O(k),其中k为哈希函数的数量。因此,它可以在较小的空间内快速判断某个元素是否在集合中。

2.误判率低:布隆过滤器虽然可能出现误判,但是误判率可以通过调整哈希函数数量和位数组大小来控制,可以根据实际需求进行调整。

3.支持高并发:布隆过滤器支持并发查询和添加数据,可以在多线程环境下使用。

4.易于实现:布隆过滤器的实现比较简单,只需要实现几个哈希函数和一个位数组即可。

布隆过滤器的缺点包括:

1.无法删除已添加的数据:由于布隆过滤器的哈希函数不具有逆向性,所以无法删除已添加的数据。

2.误判率无法避免:由于布隆过滤器的设计原理,误判率无法避免。当哈希函数的数量不足或位数组的大小不够时,误判率可能会很高。

3.无法精确判断元素是否存在:由于布隆过滤器的设计原理,无法精确判断某个元素是否在集合中,只能判断它可能存在或一定不存在。

减少布隆过滤器的误判:

布隆过滤器的误判率是根据哈希函数的数量和位数组大小来确定的。如果哈希函数的数量太少或者位数组太小,那么误判率会增加。反之,如果哈希函数的数量太多或者位数组太大,那么可能会导致空间浪费和查询效率降低。因此,在实际使用中,需要根据具体的应用场景来确定哈希函数数量和位数组大小,以达到误判率和空间利用率的平衡。

1.使用多个布隆过滤器:将同一个元素添加到多个布隆过滤器中,查询时需要在所有布隆过滤器中查询。这种方法可以显著降低误判率,但是会增加存储空间和查询时间。

2.使用加密哈希函数:加密哈希函数可以使哈希值更难以预测,从而减少哈希冲突的概率。常见的加密哈希函数包括MD5、SHA-1等。

3.使用高质量的哈希函数:使用高质量的哈希函数可以减少哈希冲突的概率。常见的高质量哈希函数包括MurmurHash、CityHash等。

4.对于数据量较小的情况,可以使用简单的线性查找代替布隆过滤器,这样可以避免误判率过高的问题。

需要注意的是,误判率是布隆过滤器的本质限制,无法完全避免。因此,在使用布隆过滤器时,需要根据实际需求来平衡误判率和空间利用率,同时采用多个布隆过滤器、使用高质量的哈希函数等方法来尽量减少误判率。

缓存击穿是什么:

缓存击穿是指当缓存中某个热点数据过期了,在该热点数据重新载入缓存之前,有大量的查询请求穿过缓存,直接查询数据库。这种情况会导致数据库压力瞬间骤增,造成大量请求阻塞,甚至直接挂掉。

缓存击穿解决方案:

        1.设置key永不过期或者正常给key设置过期时间,不过后台同时启动一个定时任务去定时更新这个缓存

         2.使用互斥锁保证同一时刻只能有一个查询请求重新加载热点数据到缓存中,这样,其他的线程只需等待该线程运行完毕,即可重新从Redis中获取数据。

                原理:使用了加锁的方式,锁定的对象key,这样当大量查询同一个key的请求并发发过来,只有一个请求能获得锁,然后获得锁的线程操作数据库,然后将结果放入缓存,然后释放锁,其他处于锁等待的请求即可以继续执行,因为现在缓存中有了数据,故直接从缓存中获取数据返回,并不会去查询数据库。

缓存雪崩是什么:

缓存雪崩是指当缓存中有大量的key在同一时刻过期,或者Redis直接宕机了,导致大量的查询请求全部到达数据库,造成数据库查询压力骤增,甚至直接挂掉。

缓存雪崩解决方案:

        1.大量key同时过期:将每个key的过期时间分散,让他们的过期时间尽量均匀分散开

         2.数据预热:数据预热的含义就是在正式部署之前,先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。

        3.redis发生故障:redis的3钟高可用方案部署(主从、哨兵、redis集群)

                主从复制:部署多台redis节点,其中只有一台节点是主节点(master),其他的节点都是从节点(slave),也叫备份节点(replica)。只有master节点提供数据的事务性操作(增删改),slave节点只提供读操作。所有slave节点的数据都是从master节点同步过来的。

    

黑马点评面试题_第4张图片

所有的slave节点都挂在master节点上,这样做的好处是slave节点与master节点的数据延迟较小;缺点是如果slave节点数量很多,master同步一次数据的耗时就很长。针对这一问题,可以使用下图中的主从架构:

黑马点评面试题_第5张图片

master下面只挂一个slave节点,其他的slave节点挂在这个slave节点下面,这样,master节点每次只需要把数据同步给它下面的那一个slave节点即可,后续slave节点的数据同步由这个slave节点完成。这样做虽然降低了master节点做数据同步的压力,但也导致slave节点与master节点数据不一致的延迟更高。

Redis中主从节点数据同步有两种方式:全量数据同步和增量数据同步。

全量数据同步

全量数据同步一般发生在slave节点初始化阶段,需要将master上的所有数据全部复制过来。全量同步的流程图如下:

黑马点评面试题_第6张图片

  1. slave节点根据配置的master节点信息,连接上master节点,并向master节点发送SYNC命令;

  2. master节点收到SYNC命令后,执行BGSAVE命令异步将内存数据生成到rdb快照文件中,同时将生成rdb文件期间所有的写命令记录到一个缓冲区,保证数据同步的完整性;

  3. master节点的rdb快照文件生成完成后,将该rdb文件发送给slave节点;

  4. slave节点收到rdb快照文件后,丢弃所有内存中的旧数据,并将rdb文件中的数据载入到内存中;

  5. master节点将rdb快照文件发送完毕后,开始将缓冲区(发送快照期间新的鞋命令)中的写命令发送给slave节点;

  6. slave节点完成rdb文件数据的载入后,开始执行接收到的写命令。

以上就是master-slave全量同步的原理,执行完上述动作后,slave节点就可以接受来自用户的读请求,同时,master节点与slave节点进入命令传播阶段,在该阶段master节点会将自己执行的写命令发送给slave节点,slave节点接受并执行写命令,从而保证master节点与slave节点的数据一致性。

增量数据同步

Redis2.8版本之前,是不支持增量数据同步的,只支持全量同步。增量数据同步是指slave节点初始化完成后,master节点执行的写命令同步到slave节点的过程。该过程比较简单,master节点每执行一个写命令后就会将该命令发送给slave节点执行,从而达到数据同步的目的。

但有一点需要注意,当增量复制过程中发生了异常导致同步失败时,是要支持断点续传的,也就是在异常恢复之后,是要支持从上次断掉的地方继续同步的,而不是全量数据同步。这就需要在master节点和slave节点分别维护一个复制偏移量(offset),代表master向slave节点同步的字节数。master节点每次向slave节点发送N个字节后,master节点的offset增加N;slave节点每次接收到master节点发送过来的N个字节后,slave节点的offset增加N。master节点和slave节点的这两个偏移量分别保存在master_repl_offset和slave_repl_offset这两个字段中。

            

6.redis中的zset是一个怎样的数据类型?为什么它能根据点赞时间保证数据的有序性?

 Redis 有序集合(zset)和集合一样也是string类型元素的集合,且不允许重复的成员。
不同的是每个元素都会关联一个double类型的分数,redis正是通过分数来为集合中的成员进行从小到大的排序。
有序集合的成员是唯一的,但分数(score)却可以重复。
集合是通过哈希表实现的。 集合中最大的成员数为 2次方32 - 1 (4294967295, 每个集合可存储40多亿个成员)。Redis的ZSet是有序、且不重复
 

7.该项目如何使用redis实现唯一的全局id?

用户在对优惠券抢购时一个订单就对应一个订单ID在高并发情况下需要保证ID的以下特点

  • 唯一性ID必须保证唯一
  • 高可用可以以极快的速度生成唯一ID
  • 递增型递增的ID有利于在数据库中建立索引

在本项目中自己实现了一个ID生成器。生成的ID结构如下

  • 第一位符号位永远为0
  • 时间戳位31bit以秒为单位可以使用69年。当前时间戳–自定义的起始时间戳
  • 32bit秒内的计数器支持每秒产生2^32个不同ID。这个使用Redis中的incr函数实现

Key的设计incr业务名日期

如果一个业务只建立一个key那么随着时间的推移redis中的value会达到上限此时ID生成器就不可用了。

8.乐观锁和悲观锁是什么?是用来干什么的?(超卖问题)

乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。

乐观锁

乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。

乐观锁的实现方式主要有两种:CAS机制和版本号机制


悲观锁

悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

超卖问题的产生:

假设目前库存位为1且在高并发环境下

  • 线程1执行判断是否有库存的操作然后时间片结束。结束的时候线程1并没有完成创建订单的操作。
  • 此时线程2获得时间片开始判断是否有库存,由于线程1并没有完成下单操作因此此时库存仍然为1。
  • 这时线程1获得时间片完成下单操作。
  • 线程2获得时间片完成下单操作

那么就会出现超卖问题此时库存应该为-1

解决方案

目前解决方案有两种

  • 使用悲观锁在下单的业务上使用sync关键字。但是这样会导致秒杀业务变成串行执行严重降低并发性。
  • 使用乐观锁在更新库存的时候判断一下是否和查询库存的时候结果一样如果一样则说明数据没有被修改过则可以执行。如果库存和之前不一样则回滚。
使用乐观锁解决超卖问题

VoucherOrderServiceImpl 在扣减库存时,改为:

 boolean success = seckillVoucherService.update()
             .setSql("stock= stock -1") //set stock = stock -1
             .eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?

以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败

问题分析以后我们发现实际上在更新库存的时候只需要判断库存不为空就可以满足不超卖的条件因此修改代码为

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0

9.redis分布式锁是什么?是如何实现一人一单的?(优惠卷秒杀业务)

秒杀业务V1.1 实现一人一单秒杀

优惠卷的目的主要是引流如果一个人可以购买多张优惠卷那么就没有意义了。因此还需要完成一人一单的功能。

存在的问题

从理论上来说只需要在代码中判断完库存充足以后然后在判断一下用户是否下过单即可如果下过单则直接返回异常信息。但是在使用JMeter测试时发现依然可以一个用户下多个订单的情况。

产生这个问题的原因还是线程的安全问题。

  • 假设线程1和线程2都是同一个用户多次点击购买发出请求对应的线程。

  • 那么当线程1判断用户有购买资格的时候。线程2突然获得时间片那么线程2也判断用户是否下过单。

  • 由于线程1还没有下单因此线程2查询到用户也是有购买资格的。然后就继续执行下单操作。

  • 此时线程1获取时间片由于已经判断过是否有购买资格线程1直接执行下单操作这样就导致了一个人下单多次的情况。

解决方案

根据刚才解决超卖问题的经验那么解决这个问题也应该可以使用悲观锁或乐观锁但是实际上这个问题只能使用悲观锁。因为这一次时插入数据并不是更新数据因此不存在所谓的版本号也就无法使用乐观锁。

确定加锁范围

经过分析可以发现需要保证判断用户是否下过单和创建订单的操作要满足原子性。也就是说在判断完是否可以下单以后其他的线程不得访问这一部分代码。所以我们要把锁加在判断用户是否下过单和创建订单的逻辑上。

确定锁的粒度

通常情况下在非静态方法中sync的锁监视器都是this对象在代码中this指代应该是VoucherOrderServiceImpl而这个对象是一个bean由Spring创建并且是单粒的。因此如果在sync中使用this作为锁监视器那么所有线程都共享这一个锁监视器那么加锁部分的代码就变成了完全串行执行。

而实际上我们只需要同一个用户发出的不同线程串行执行不同用户的线程可以并发执行。因此不可以使用this作为锁监视器。这里我们考虑使用UserId作为锁监视器userId.toString().intern()通过这个代码把用户id变成常量这样相同的用户会共享一个锁监视器从而完成业务。

Spring事务管理与sync关键字

目前加锁的方法位于createVoucherOrder()中这个方法上面有声明式事物注解那么就有可能出现锁已经释放了但是事物还没有提交那么之中情况也会出现超卖的现象。

锁已经释放了说明其他的线程可以进来而此时还有提交事物也就是说订单还没有写入数据库此时进来道线程还是可以查询到该用户有购买资格那么就会再次下单导致一个人多次下单的问题。

因此需要先提交事务再释放锁。所以需要将这部代码抽取成一个方法然后从外部调用。

黑马点评面试题_第7张图片

Spring实现事务方式

我们知道Spring中实现事务是通过动态代理来实现的也就是说Spring调用的实际上是VoucherOrderServiceImpl的代理类对象而在上面的代码中我们直接使用了this也就是说是直接调用的VoucherOrderServiceImpl中的方法会导致声明式事物失效。因此需要先获取到当前对象的代理类对象然后通过代理类对象对方法进行调用才可以使声明式事务生效。

 redis分布式锁:

秒杀业务V1.2 实现集群环境下一人一单

存在的问题

在上一个版本中提到的解决一人一单的方法是通过加sync代码块实现。但是这种方法在集群环境下不适用。主要原因是因为在集群环境下每一个服务器都是一个独立的JVM线程1和线程2属于同一个服务器那么他们之间可以使用sync代码块实现互斥访问但是线程3线程4位于另一台服务器。由于不同的JVM他们之间的锁监视器是不共享的因此线程12和线程34之间是一种并发的状态。那么一人一单的问题就不能得到保证。

解决方法

利用Redis实现一个分布式锁。需要满足一下条件

  • 利用Redis中的setnx命令并加以设置过期时间。
    • setnx满足互斥性多个线程执行只有一个返回true
    • 使用过期时间可以保证出现故障后锁依然可以释放不会产生死锁问题。
    • 利用redis集群来提高可用性。
  • 释放锁的时候需要判断当前的锁是不是自己加的只有当前的锁是自己加的才可以删除。
  • 删除锁的动作需要具备原子性因此我们使用了Lua脚本实现多条指令的原子性。

因此我们可以利用Redis构建一个分布式锁。

核心思想是利用了Redis的setnx方法当多个线程进入时只有一个线程能够执行setnx方法返回值为true其余线程因为key已经存在返回的都是false这就实现互斥。
另外当该用户的其他线程得到的结果是false的时候应该直接返回"一个用户只能下一单"的提示而不是继续等待。

  • 利用Redis中的setnx命令并加以设置过期时间。
    • setnx满足互斥性多个线程执行只有一个返回true
    • 使用过期时间可以保证出现故障后锁依然可以释放不会产生死锁问题。
    • 利用redis集群来提高可用性。
  • 释放锁的时候需要判断当前的锁是不是自己加的只有当前的锁是自己加的才可以删除。
  • 删除锁的动作需要具备原子性因此我们使用了Lua脚本实现多条指令的原子性。

Redis分布式锁的key value问题

从理论上来说key应该是业务名+用户id但是对value并没有什么要求。但是在实际情况下value需要设置的UUID+线程ID。具体原因如下

试想一下一种情况当线程1获取到锁在执行业务的时候花费时间很长导致锁自动超时释放。此时线程2获取到了锁正在执行业务。此时线程1的业务也执行完了直接释放锁。导致线程1把线程2的锁给释放了产生了线程安全的问题。

这个问题实际上就是一个线程删除了本来不属于自己的锁

我们的解决方法是在value里面保存UUID+线程ID使用UUID的目的是在集群环境下可能会存在线程ID相同的问题。这样在删除锁的时候线程需要先根据value值判断是不是自己加的锁如果不是则说明其他线程已经获取到锁那么自己执行的业务就应该回滚。如果是自己的锁那么可以直接释放。

此外判断锁是不是属于自己和删除锁这两个操作应该保证原子性否则如果一个线程已经判断完是自己的锁还没有删除的时候突然失去时间片导致锁自动释放。另外一个线程又获取到了锁。那么当原来的已经判断完是自己的锁的那么线程再次获取到时间片就会直接释放锁而不会再次判断是不是自己的锁所以还是会释放掉不属于自己的锁。

在这个项目中采用了Lua脚本的方式来保证判断锁和删除锁的原子性。

redis分布式锁总结:

1)setnx:redis提供的分布式锁
存在问题:线程还没释放锁系统宕机了,造成死锁
2)setnx +setex:给锁设置过期时间,到期自动删除。
存在问题:因为加锁和过期时间设置非原子,存在设置超时时间失败情况,导致死锁
3)set(key,value,nx,px):将setnx+setex变成原子操作
存在问题:加锁和释放锁不是同一个线程的问题。假如线程1业务还没执行完,锁过期释放,线程2获取锁执行,线程1执行完业务删除锁删除的就是线程2的,然后其他线程又可获取锁执行,线程2执行完释放锁删除的是别人的,如此往复,导致并发安全问题。
4.方法1:在value中存入uuid(线程唯一标识),删除锁时判断该标识,同时删除锁需保证原子性,否则还是有删除别人锁问题,可通过lua或者redis事务释放锁
方法2:利用redis提供的第三方类库,Redisson也可解决任务超时,锁自动释放问题。其通过开启另一个服务,后台进程定时检查持有锁的线程是否继续持有锁了,是将锁的生命周期重置到指定时间,即防止线程释放锁之前过期,所以将锁声明周期通过重置延长。
Redission也可解决不可重入问题(AQS,计数)

问题:但上述方案能保证单机系统下的并发访问安全,实际为了保证redis高可用,redis一般会集群部署。单机解决方案会出现锁丢失问题。如线程set值后成功获取锁但主节点还没来得及同步就宕机了,从节点选举成为主节点,没有锁信息,此时其他线程就可以加锁成功,导致并发问题。

5)redis集群解决方案,使用redlock解决:

顺序向5个节点请求加锁(5个节点相互独立,没任何关系)
根据超时时间来判断是否要跳过该节点
如果大于等于3节点加锁成功,并且使用时间小于锁有效期,则加锁成功,否则获取锁失败,解锁
————————————————
版权声明:本文为CSDN博主「三月不灭」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_46129192/article/details/126010250

10.lua脚本是什么?为什么要用到lua脚本?

lua脚本:

基本原理为使脚本相当于一个redis命令,可以结合redis原有命令,自定义脚本逻辑

如何在redis中使用lua脚本,可以参考https://redis.io/commands/eval/

Lua脚本如何保证线程安全?

lua脚本作为一个整体执行,具备原子性,对客户端来说,lua要么不可见,要么已经执行完。

Lua脚本之所以可以保证线程安全,是因为我们可以把多个操作写成一个 lua 脚本,使其具备原子性,作为一个整体执行。再由于 redis 是单线程模型,不同线程的 lua 脚本是依次执行的。也就是说,只有一个线程原子性的多个操作执行完,下一个线程才可以执行。实际上也是保证了在 redis 内部不同线程操作的串行执行,从而能够解决并发安全问题。
【简单来说,就是lua脚本包括业务的多个操作,使得整个业务成为一个整体,执行时相当于把整个lua脚本当成一个redis指令】
因为redis使用同一个Lua解释器来执行所有命令,同时,redis保证以一种原子性的方式来执行脚本:当Lua脚本在执行的时候,不会有其他脚本和命令同时执行,这种语义类似于 MULTI/EXEC。

从别的客户端的视角来看,一个lua脚本要么不可见,要么已经执行完。

对程序来说,只需要对执行Lua脚本不同的返回值进行业务编写即可。


Lua脚本的优点


1.lua脚本是作为一个整体执行的,所以中间不会被其他命令插入,无需担心并发;

2.lua脚本把多条命令一次性打包,而代码实现的事务需要向Redis发送多次请求,所以可以有效减少网络开销;

3.lua脚本可以常驻在redis内存中,所以在使用的时候,可以直接拿来复用。

redis事务和Lua两者相同点:


很好的实现了一致性、隔离性和持久性,但没有实现原子性,无论是redis事务,还是lua脚本,如果执行期间出现运行错误,之前的执行过的命令是不会回滚的。

lua 脚本实现的原子性是假的原子性


因为当多个指令执行时,lua脚本中的一条指令报错时,后面的指令执行失败了,但是前面的指令已经成功。且不会回滚。

其中报错原因包括:
1、指令语法错误
2、语法是正确的,但是类型不对,比如对已经存在的string类型的key,执行hset等
3、服务器挂掉了,比如lua脚本执行了一半,但是服务器挂掉了

前面两者,我们可以通过仔细检查脚本逻辑,确保脚本中的所有命令都是正确的,并且按照预期的顺序执行、使用redis.pcall处理潜在的错误以及适时使用事务,可以编写出高效、可靠的Lua脚本,确保脚本的逻辑正确性和健壮性,以避免潜在的问题。

redis.pcall在命令执行失败时不会引发错误,而是返回一个包含错误信息的表。通过检查redis.pcall的返回值,可以在脚本中处理错误情况,从而避免脚本执行失败

Lua脚本回滚?


在Lua脚本中,我们可以使用Redis的WATCH指令,它允许我们监视一个或多个键的变化。当我们监视的键发生变化时,Redis会立刻中断正在执行的Lua脚本,使得整个Lua脚本操作回滚,这种方式可以实现Redis事务的回滚效果。

Lua脚本相对于Redis事务更实用的原因有以下几点:
1、原子性保证:使用Lua脚本可以将多个命令封装成一个原子操作,确保这些命令在执行期间不会被其他命令插入,从而保证操作的原子性。
2、减少网络开销:在Lua脚本中,多个命令可以一次性发送到Redis服务器,并由Redis执行,减少了网络开销。而Redis事务需要通过MULTI和EXEC命令来开启和提交事务,增加了网络往返的次数。
3、高性能:由于Lua脚本在Redis服务器端执行,避免了客户端与服务器之间的通信。这样可以减少通信延迟,并在服务器端以原生代码的方式执行,提高了执行效率。相比之下,Redis事务在客户端和服务器之间进行多次通信,可能降低执行效率。
4、复杂逻辑支持:Lua脚本提供了强大的编程能力,可以实现复杂的业务逻辑。通过脚本的编写,可以实现数据库操作的灵活性,从而适应更多的场景需求。Redis事务对于复杂逻辑的支持相对较弱,更适合简单的操作序列。
————————————————
版权声明:本文为CSDN博主「张凯锋zkf」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/m0_53142956/article/details/131928405

11.redis缓存的数据和数据库的数据,怎么保证一致性?

想要保证缓存与数据库的双写一致,一共有4种方式,即4种同步策略:

  1. 先更新缓存,再更新数据库;

  2. 先更新数据库,再更新缓存;

  3. 先删除缓存,再更新数据库;

  4. 先更新数据库,再删除缓存。

那么我们需要做的就是根据不同的场景来使用合理的方式来解决数据问题。

第一种:先删除缓存,再更新数据库

在出现失败时可能出现的问题:

1:线程A删除缓存成功,线程A更新数据库失败;

2 :线程B从缓存中读取数据;由于缓存被删,进程B无法从缓存中得到数据,进而从数据库读取数据;此时数据库中的数据更新失败,线程B从数据库成功获取旧的数据,然后将数据更新到了缓存。

最终,缓存和数据库的数据是一致的,但仍然是旧的数据。

第二种:先更新数据库,再删除缓存

假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生

(1)缓存刚好失效

(2)请求A查询数据库,得一个旧值

(3)请求B将新值写入数据库

(4)请求B删除缓存

(5)请求A将查到的旧值写入缓存

如果发生上述情况,确实是会发生脏数据。

然而,发生这种情况的概率又有多少呢?

发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。

数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。

先更新数据库,再删缓存依然会有问题,不过,问题出现的可能性会因为上面说的原因,变得比较低。

第三种:给所有的缓存一个失效期

第三种方案可以说是一个大杀器,任何不一致,都可以靠失效期解决,失效期越短,数据一致性越高。但是失效期越短,查数据库就会越频繁。因此失效期应该根据业务来定。

1.并发不高的情况:

读: 读redis->没有,读mysql->把mysql数据写回redis,有的话直接从redis中取;

写: 写mysql->成功,再写redis;

2.并发高的情况:

读: 读redis->没有,读mysql->把mysql数据写回redis,有的话直接从redis中取;

写:异步话,先写入redis的缓存,就直接返回;定期或特定动作将数据保存到mysql,可以做到多次更新,一次保存;

第四种:加锁,使线程顺序执行

如果一个服务部署到了多个机器,就变成了分布式锁,或者是分布式队列按顺序去操作数据库或者 Redis,带来的副作用就是:数据库本来是并发的,现在变成串行的了,加锁或者排队执行的方案降低了系统性能,所以这个方案看起来不太可行。

第五种:采用延时双删

先删除缓存,再更新数据库,当更新数据后休眠一段时间再删除一次缓存。

方案推荐两种:

1:项目整合quartz等定时任务框架,去实现延时3--5s再去执行最后一步任务 。(推荐使用)

2:创建线程池,线程池中拿一个线程,线程体中延时3-5s再去执行最后一步任务(不能忘了启动线程)

第六种:异步更新缓存(基于订阅binlog的同步机制)

MySQL binlog增量订阅消费+消息队列+增量数据更新到redis读Redis

热数据基本都在Redis写MySQL:增删改都是操作MySQL更新Redis数据:MySQ的数据操作binlog,来更新到Redis:

1)数据操作主要分为两大块:一个是全量(将全部数据一次写入到redis)一个是增量(实时更新)。

这里说的是增量,指的是mysql的update、insert、delate变更数据。

2)读取binlog后分析 ,利用消息队列,推送更新各台的redis缓存数据。

这样一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息推送至Redis,Redis再根据binlog中的记录,对Redis进行更新。

其实这种机制,很类似MySQL的主从备份机制,因为MySQL的主备也是通过binlog来实现的数据一致性。

这里可以结合使用canal(阿里的一款开源框架),通过该框架可以对MySQL的binlog进行订阅,而canal正是模仿了mysql的slave数据库的备份请求,使得Redis的数据更新达到了相同的效果。

当然,这里的消息推送工具你也可以采用别的第三方:kafka、rabbitMQ等来实现推送更新Redis。

以上就是redis和数据库数据保持一致的方案。

12.关于好友点赞和好友关注你是如何实现的

好友点赞

好友点赞的问题应该保证一个好友一个笔记只能点赞一次。因此考虑使用Redis中的set集合实现这个功能。

具体实现方法

在Redis中建立set集合每一个日志对应一个set集合set集合中保存的数据就是用户点赞的用户id这样在用户点赞的时候就可以先查询一下是否点过赞如果点过赞则返回错误信息否则则将用户id记录到set集合中。

需求:

  • 同一个用户只能点赞一次,再次点击则取消点赞

  • 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)

实现步骤:

  • 给Blog类中添加一个isLike字段,标示是否被当前用户点赞

  • 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1

  • 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段

  • 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段

为什么采用set集合:

因为我们的数据是不能重复的,当用户操作过之后,无论他怎么操作,都是

具体步骤:

1、在Blog 添加一个字段

 @TableField(exist = false)
 private Boolean isLike;

2、修改代码

  @Override
     public Result likeBlog(Long id){
         // 1.获取登录用户
         Long userId = UserHolder.getUser().getId();
         // 2.判断当前登录用户是否已经点赞
         String key = BLOG_LIKED_KEY + id;
         Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
         if(BooleanUtil.isFalse(isMember)){
              //3.如果未点赞,可以点赞
             //3.1 数据库点赞数+1
             boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
             //3.2 保存用户到Redis的set集合
             if(isSuccess){
                 stringRedisTemplate.opsForSet().add(key,userId.toString());
             }
         }else{
              //4.如果已点赞,取消点赞
             //4.1 数据库点赞数-1
             boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
             //4.2 把用户从Redis的set集合移除
             if(isSuccess){
                 stringRedisTemplate.opsForSet().remove(key,userId.toString());
             }
         }

有些情况下还会显示最近点赞的用户我们可以修改set集合为zsetvalue值就是点赞的时间戳这样倒序排列求前五个用户即可

好友共同关注

好友共同关注可以利用Redis中的集合求交集运算来实现。在Redis中保存set集合每一个集合对应一个用户集合里面的内容就是该用户关注用户的用户id这样两个用户求共同关注只需要使用交集运算(Sinter)即可实现。

13.定时任务

在APP的后台会对每一天每一周和每一个月的相关用户活跃数据等信息进行实时的统计并展示。这些统计运算实际上非常消耗数据库的资源因此如果每一次点击这个统计的页面都需要从数据库中重新计算是非常消耗资源的因此我们的解决方法是将这些统计数据单独的存放到一张数据表中并且使用定时任务在服务器压力相对较小的时候来计算这些数据。

这样的话前台再次访问这些统计数据的时候就可以直接从数据表中获取到计算好的结果从而降低数据库压力的同时提高了程序的响应速度。

实现方式就是使用了Spring Task的定时任务通过编写CORN表达式来控制程序的执行。

你可能感兴趣的:(面试,职场和发展)