Redis 在业务中的使用场景

面试官:我看你项目中用到了 Redis ,你们哪些业务场景使用了 Redis ?

我们很多业务都用了 Redis ,这里列举几个

  • 我们 APP 的商品分类,包括一级分类,二级分类,三级分类等都存储在 Redis
  • 我们秒杀商品的信息使用 Redis 存储
  • 用户手机验证码等一定时间内会过期的数据我们也存储在 Redis
  • 所有第三方应用的 accessToken
  • 我们还使用 Redis 实现分布式锁,比如扣减库存
  • 还有其他需要高频访问的数据,且一定时间内修改频率较小的数据也存储在 Redis

面试官:你们这么存有什么根据吗?(为什么这些数据存 Redis 里)

商品分类这些数据一般来说修改频率很低,访问频率超高,只要用户浏览商品就一定要访问,鉴于 Redis 的 QPS 10W+/S ,存储在 Redis 要远优于 MySQL 。况且分类一般都有很多层级,数据结构复杂,直接访问 MySQL 中的话,我们查询出来还要写代码组装,Redis 支持的数据类型丰富,我们提前组装好存入 Redis ,拿出来直接用就可以了。

秒杀商品信息存储 Redis 是必然的,秒杀系统访问量巨大,用 Redis 10w+/S 的 QPS 应对较好,MySQL 绝对撑不住。至于手机验证码基本上只有 5-10 分钟有效,我们存入 Redis 设置过期时间即可。其他场景也都类似,一句话概括就是,高频访问,吞吐量要求高的接口涉及的数据都可以考虑存 Redis。当然我们使用 Redis 只是用它在应用程序和 MySQL 之间作一个缓存中间件,绝大部分数据还是会在 MySQL 存一份。




redisson 公平锁宕机后无法 redis 公平信号量_redis


Redis数据类型

面试官:你刚刚说 Redis 支持的数据类型丰富,你都用过哪些数据类型?

常用的数据类型有五种:string,hash,list,set,zset

  • string ,是最简单也是使用最多的,存储字符串,内部是字符数组。
  • list(列表),list 类似于 Java 中的 LinkedList ,是链表,我们都知道链表的数据结构插入和删除操作非常快,时间复杂度为 O(1)。
  • hash(字典),hash 类似于 Java 中的 HashMap,原理几乎是一模一样,当发生 hash 碰撞时就会将碰撞的元素使用链表串接起来,不同的是 Redis 中的 hash 只能存储字符串,所以 Java 代码中如果在 hash 的 value 中传入的是对象,实际上会序列化成字节数组。
  • set(集合),set 相当于 Java 里面的 HashSet,内部的值是无序不重复的。内部实现相当于一个特殊字典,所有的 value 都是一个 NULL 值。
  • sortedSet(有序集合),类似于 Java 中 SortedSet 和 HashMap 的结合,一方面是个 set 保证 value 的唯一,另一方面它给每一个 value 弄了一个权重 score 代表这个 value 的排序权重

面试官:还有其他的吗?

内心:想不到这个面试官水平如此之高,恐怕不好忽悠啊…还有的,我们有些业务还用到了 bitmap,hyperloglog,bloomfilter,geo。

  • bitmap

我们有个用户签到的业务,并且要展示 365 天签到记录。本来直接使用 string 存储的

stringRedisTemplate.opsForValue().set("userId:year:month:day","1");//1表示签到,0表示未签到

考虑到签到、没签到只是一个标识,直接存一个字符串太浪费,因为一个字符串就是多个字节,后来采用 bitmap 存储来节省空间。

bitmap 是按位存储的,存储的是连续的二进制数字 0 和 1,1 byte = 8 bit 。所以一个用户 365 天的签到记录只需要 365 bit < 368 bit = 46 byte 存储,拿个稍微长一点的字符串作为 key 就够了。

//比如这个 userId 和年份拼接,"1125323041914142722:2021" 这个 key 占的是 384 bit
stringRedisTemplate.opsForValue().setBit("1125323041914142722:2021",364,true);//364表示第364天,true代表签到

这种方式比直接用 string 存储要节省很多内存空间。Redis 中 bitmap 其实不是特殊的数据结构,也属于字符串。

  • hyperloglog

这个数据结构用来计算基数(在一个集合中去除掉重复的元素之后剩余的个数),它是有误差的,但是误差率很小。最重要的是它占用的内存空间非常小,hyperloglog 只需要花费 12KB 内存,就可以计算接近 2^64 个不同元素的基数。

我们用它来统计页面的 UV ,虽然结果并不精确,但是对于业务而言,UV 数量是 20W 还是 20W 零 200 其实并不重要,他们只需要一个大概的数据做参考

  • bloomfilter

用来判断一个元素是否存在于一个集合中,底层数据结构和 bitmap 相关,所以很节省内存空间。与 hyperloglog 一样,它存在一些误差,但是误差很小。当它返回一个元素在一个集合中存在时,可能并不存在。但是当它返回一个元素在一个集合中不存在时,就一定不存在。

布隆过滤器可以用来对用户进行喜好推送,比如一个视频 APP,要给用户推荐短视频,那最好推荐的是用户没有看过的,我们把用户已经看过的视频放进布隆过滤器,推送之前判断一下推送的视频是否不存在于布隆过滤器,如果不存在,说明用户一定没有看过,可以推送

  • geo

主要用于存储地理位置信息,并对存储的信息进行各种计算操作,该功能在 Redis 3.2 版本新增。(这个我特么真没用过……吹不下去了)



redisson 公平锁宕机后无法 redis 公平信号量_Redis_02


Redis 线程模型

面试官:你刚刚还说到 Redis 非常快,你知道为什么吗?

Redis快的原因主要是两个:

  • Redis 基于内存,内存的响应时间是 100 纳秒。 1 毫秒 = 1000000 纳秒
  • 由于 Redis 是单线程的,所以采用 IO 多路复用的模型来处理大量的客户端连接。

面试官:你给我简单介绍下 IO 多路复用

IO 多路复用和 Java 中的 NIO (非阻塞 IO)是同样的设计思想。 传统的阻塞 IO,在单线程情况下,必须等服务器处理完当前客户端请求,并且客户端断开。才能与下一个客户端建立连接。

while (true) {
            socket = server.accept();
            new Thread(() -> {
                //read、write
            });
        }

这段代码是最经典的 Java Socket 通信,原本我们每次来一个客户端都开启一个线程处理,那你看如果是单线程的话,那么服务器必须等处理完这次 IO ,客户端断开连接才能继续接受下一个客户端。类似这样



redisson 公平锁宕机后无法 redis 公平信号量_Redis_03


以 Java 的 NIO 为例,简单来说就是引入了 Selector、Channel、Buffer 组件,一个客户端的连接就是一个通道 Channel。每次新来一个 Channel 就会被注册进 Selector,Selector 会一直轮循哪些 Channel 和服务器之间有网络通道的读写事件,当发生读写事件(这个操作是底层系统函数支持实现的)的时候 Selector 会工作。此时服务器一个线程就可以处理成百上千的客户端请求。代码示例:

public static void main(String[] args) throws Exception {
        Selector selector = Selector.open();
        ServerSocketChannel socketChannel = ServerSocketChannel.open();
        socketChannel.socket().bind(new InetSocketAddress(6666));
        socketChannel.configureBlocking(false);
        socketChannel.register(selector, SelectionKey.OP_ACCEPT);//新来的channel注册进selector

        while (true) {
            selector.select();//阻塞,不断轮循有没有 channel 有读写事件,没有就阻塞在这
            Set<SelectionKey> selectionKeys = selector.selectedKeys();//获取发生读写事件的channel
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) { //依次处理channel里面的网络读写
                SelectionKey key = iterator.next();
                if (key.isAcceptable()) {
                    System.out.println("服务器接收到了新的 Accept 链接");
                    SocketChannel socketChannel1 = socketChannel.accept();//socketChannel1客户端
                    socketChannel1.configureBlocking(false);
                    socketChannel1.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                }
                if (key.isReadable()) {
                    System.out.println("服务器接收到了新的 Read 链接");
                    SocketChannel channel = (SocketChannel) key.channel();
                    ByteBuffer buffer = (ByteBuffer) key.attachment();
                    channel.read(buffer);

                }
                iterator.remove();//移除处理过的事件
            }
        }
    }

redisson 公平锁宕机后无法 redis 公平信号量_Redis_04

IO 多路复用就是 Java NIO 这个原理,只不过在 Linux 中实现方式不一样,它通过 select() -> poll() -> epoll() 不断优化到 epoll() ,epoll() 通过内核获取有读写事件发生的文件句柄集合。Redis 中可以用下图理解

redisson 公平锁宕机后无法 redis 公平信号量_缓存_05

Redis 使用 IO 多路复用模型复用一个线程连接多个客户端,将客户端指令按先后顺序放入队列去排队执行,所以命令的执行永远是线程安全的。

这里其实还有必要提一下,很多人以为 Java 中的 NIO 是服务器同时进行多个客户端的连接、读、写事件,这是完全错误的。你看上面 Java NIO 的代码,以及上面这张图,服务器处理 连接、读、写、指令 依然是串行的,并不是一个线程同时进行多个 Channel 的读写,它只是一个线程同时可以和多个客户端保持连接,然后将其中有读写事件发生的 Channel 集合拿过来,但是执行读、写还是在循环中遍历的。当已经处理完读、写事件的 Channel 如果在下一次轮询中发现又有读、写事件的话,还会继续上面的操作。

所以其实这里很明显 IO 多路复用就是适合应对 Redis 这种有大量客户端连接的服务器。假如只有一个客户端,那么服务器处理完之后如果客户端不断开的话,select() 函数将会一直阻塞轮循,耗费 CPU 资源,并没有比传统阻塞 IO 好

Redis持久化

面试官:你刚刚说 Redis 基于内存,那如果 Redis 不小心宕机,数据是不是都丢了?

Redis 是有持久化功能的。不会都丢,但是会丢一些,至于丢多少要看选择的持久化方式。Redis 提供了两种持久化方式, rdb 和 aof。

  • 持久化 - rdb

rdb 叫快照备份,按照 save <seconds > <changes > 规则,在某一时刻做一次备份。在指定时间间隔后,如果对数据进行了指定次数的修改,就进行一次备份,例如:

save 900 1        #如果在 900 秒内,至少修改了 1 次,那么当900秒之后的那个时间点到达将会进行一次持久化
save 300 10       #如果在 300 秒内,至少修改了 10 次.......
save 60 10000     #......

rdb 方式 Redis 重启的话恢复数据会很快,但是这个机制在某种情况下会丢失很多数据。比如我们设置的 save 60 10000 。 60 秒后如果修改了 10000 次,就进行持久化。假设我们 50 秒内修改了 10000 次,按道理再过 10 秒钟就会持久化,但是此时 Redis 宕机了。那么就没有持久化,那就完蛋了,这 10000 次的修改的数据结果都没了。所以我们可以选择第二种机制 aof

  • 持久化 - aof

aof 是以日志的形式记录下来每次修改的命令追加到磁盘文件,但是不可能每一个写命令都持久化到磁盘,这样太影响性能,所以 aof 提拱了可选参数配置写入磁盘的频率

#appendfsync always #每次都同步,保证数据不会丢失,但会慢。这样就相当于每条指令都要进行 IO 落盘...
appendfsync everysec #每一秒同步,系统默认同步策略,生产推荐使用
#appendfsync no #不主动同步,由操作系统决定,快,但数据容易丢失

这种机制最多会丢失 1s 的数据,比 rdb 可接受一点。但是 aof 也有一点问题,它在 Redis 重启的时候是通过日志重放,也就是重新执行一遍日志文件的所有命令来恢复数据,如果生前 Redis 命令过多,重启的时候将会执行非常多的命令要等很久才能起来,执行期间无法提供服务。为了解决这个问题,Redis 4.0 之后又给我们提供了混合持久化

  • 持久化 - rdb & aof
aof-use-rdb-preamble yes #开启混合持久化(默认是 yes ,只要开启 aof 即可)

把 rdb 文件内容写到 aof 文件里面,然后 aof 文件末尾追加增量的日志内容。这样在重启 Redis 时大部分数据都在 aof 文件的 rdb 部分,恢复速度很快,少量增量日志在结尾不会有明显影响

redisson 公平锁宕机后无法 redis 公平信号量_后端_06

Redis过期数据的删除策略

面试官:你之前说把有过期时间的数据存入 Redis,你知道 Redis 是怎么定时删除这些过期数据的吗?

我特么的管它怎么删除的干嘛。。。。能用不就行了??我又不开发它,我只是个用户啊。。。



redisson 公平锁宕机后无法 redis 公平信号量_Redis_07


面带微笑,Redis 提供了两种策略来删除过期数据

  • 定期删除

Redis 会将所有设置了过期时间的 key 放入一个独立的内部字典中,每隔一段时间来扫描判断是否过期,如果过期就删除。这个定期删除是很有讲究的,Redis 每秒会进行 10 次扫描,步骤为:

  1. 从字典中随机选出 20 个 key
  2. 删除这 20 个 key 中已经过期的
  3. 如果过期 key 的比例超过了 1/4 ,就会重复上面的步骤
  • 惰性删除

在访问 Redis 获取数据时判断这个 key 是否过期,如果过期就立即删除

面试官:你这两种并不能保证到期的数据全部都被删除掉,随着业务数据越来越多,删除不掉的 key 会堆积占用内存,新来的业务数据也会占用内存,如果内存不够用怎么办?

。。。。内存不够用你加就好了啊

面带微笑,上述的方法的确在有些情况下可能会丢掉很多过期 key 无法删除,假如业务量很大,Redis 使用频繁,这些漏掉的过期 key 就会堆积在内存,占用内存空间,加上新的业务数据,的确有可能造成内存不够用。别慌我们还有内存淘汰策略!

Redis内存淘汰策略

内存不够的时候,这种情况继续向 Redis 写数据的情况下会发生内存和磁盘的频繁交换来释放空间,严重影响 Redis 性能,所以 Redis 提供了参数 maxmemory 来进行配置限制期望内存超出的大小,当实际内存超出这个参数会按照我们配置的策略来腾出新的内存供使用。Redis 4.0 之后总共有以下 8 种策略

  • volatile-lru: 尝试淘汰设置了过期时间的 key ,最少使用的 key 优先被淘汰
  • volatile-ttl: 跟上面几乎一样,不过淘汰策略不是 LRU,而是比较 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰
  • volatile-random: 从已设置过期时间的 key 集合中任意选择数据淘汰
  • allkeys-lru: 当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这意味着没有设置过期时间的 key 也有可能被淘汰,这个是最常用的,生产环境推荐)
  • allkeys-random: 从设置了过期时间的 key 集合中任意选择数据淘汰
  • noeviction: 禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。(默认策略)这个应该没人使用吧!
  • volatile-lfu: 从设置了过期时间的 key 集合中挑选最不经常使用的数据淘汰
  • allkeys-lfu: 当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

一共 8 种,我们使用的是 allkeys-lru 这个策略

Redis实现分布式锁

面试官:你刚开始还说了用 Redis 做分布式锁,你先给我说说分布式锁和 Java 自带的锁有啥区别?

Java 自带锁是进程锁,只能锁住当前服务,生产环境我们一般都是集群,一个订单服务可能有 10 个实例,进程锁例如 Lock 实现类、synchronized 它们只能锁住当前的服务



redisson 公平锁宕机后无法 redis 公平信号量_缓存_08


Redis 由于资源在公共的地方,所以可以用它锁住所有订单服务

redisson 公平锁宕机后无法 redis 公平信号量_缓存_09

面试官:你们怎么实现分布式锁的?

自己实现的话我们可以使用 setex 命令加上自旋来实现,Java 代码

@Transactional
public void test(){
   String uuid = UUID.randomUUID().toString().replace("-","");//确保下面解锁的时候,解的是自己上的锁
    Boolean flag = stringRedisTemplate.opsForValue(). setIfAbsent("lock", uuid, 5, TimeUnit.SECONDS);//抢占锁成功
    if(flag != null && flag){
        //执行业务...
        //释放锁(先判断锁是自己的,再去删除锁)
        if(uuid.equals(stringRedisTemplate.opsForValue().get("lock"))){
            stringRedisTemplate.delete("lock");
        } else { 
           TimeUnit.MILLISECONDS.sleep(500);//睡 500ms 来降低自旋频率
           test();//自旋获取锁
        }
    }
}

不过我们后来在 Redis 官网发现了一个框架 Redisson,它为 Redis 分布式锁提供了更好的实现和封装,我们现在项目中都是使用 Redisson 做分布式锁的具体代码实现。

RLock lock = redissonClient.getLock("lock");//可重入锁
RLock fairLock = redissonClient.getFairLock("fairLock");//公平锁
RLock multiLock = redissonClient.getMultiLock(lock, fairLock);//联锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("readWriteLock");//读写锁
RLock readLock = readWriteLock.readLock();//读锁
RLock writeLock = readWriteLock.writeLock();//写锁
RSemaphore semaphore = redissonClient.getSemaphore("semaphore");//信号量
RPermitExpirableSemaphore mySemaphore = redissonClient.getPermitExpirableSemaphore("mySemaphore");//可过期信号量
RCountDownLatch latch = redissonClient.getCountDownLatch("anyCountDownLatch");//闭锁

这个框架提供的锁和 Java juc 包下提供的进程锁使用方式几乎完全一样,所以原来 Java 中怎么用,在这就怎么用,只是实现的功能是分布式安全。

它不仅提供了分布式锁,还提供了分布式集合、封装了布隆过滤器等高级用法 API。



redisson 公平锁宕机后无法 redis 公平信号量_Redis_10


结语

文章篇幅有限,很多东西没有去细讲,不过我觉得面试的时候也不需要回答那么细致。Redis 面试题还不止这些,后面还有比较高级的应用问题、集群问题等,以后还会出。



redisson 公平锁宕机后无法 redis 公平信号量_Redis_11