写点什么

如何通过使用“缓存”相关技术,解决“高并发”的业务场景案例?

作者:冉然学Java
  • 2022 年 8 月 04 日
  • 本文字数:13921 字

    阅读完需:约 46 分钟

如何通过使用“缓存”相关技术,解决“高并发”的业务场景案例?

01 前言

我们将先从 Redis、Nginx+Lua 等技术点出发,了解缓存应用的场景。通过使用缓存相关技术,解决高并发的业务场景案例,来深入理解一套成熟的企业级缓存架构是如何设计的。

02 Redis 基础

2.1 简介

Redis 是一个开源的使用 ANSI C 语言编写、遵守 BSD 协议、支持网络、可基于内存亦可持久化的日志型、Key-Value 数据库,并提供多种语言的 API。

它通常被称为数据结构服务器,因为值(value)可以是 字符串(String), 哈希(Hash), 列表(list), 集合(sets) 和 有序集合(sorted sets)等类型。

Redis 与其他 key - value 缓存产品有以下三个特点:

  • Redis 支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。

  • Redis 不仅仅支持简单的 key-value 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。

  • Redis 支持数据的备份,即 master-slave 模式的数据备份。

优势

  • 性能极高 – Redis 能读的速度是 110000 次/s,写的速度是 81000 次/s 。

  • 丰富的数据类型 – Redis 支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。

  • 原子 – Redis 的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过 MULTI 和 EXEC 指令包起来。

  • 丰富的特性 – Redis 还支持 publish/subscribe, 通知, key 过期等等特性。

2.2 数据类型

2.2.1 String(字符串)

string 是 redis 最基本的类型,你可以理解成与 Memcached 一模一样的类型,一个 key 对应一个 value。

string 类型是二进制安全的。意思是 redis 的 string 可以包含任何数据。比如 jpg 图片或者序列化的对象。

string 类型是 Redis 最基本的数据类型,string 类型的值最大能存储 512MB。

redis 127.0.0.1:6379> SET runoob "laowang"OKredis 127.0.0.1:6379> GET runoob"laowang"
复制代码

2.2.2 Hash(哈希)

Redis hash 是一个键值(key=>value)对集合。

Redis hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象。

每个 hash 可以存储 2^32 -1 键值对(40 多亿)。

redis 127.0.0.1:6379> HMSET runoob field1 "Hello" field2 "World""OK"redis 127.0.0.1:6379> HGET runoob field1"Hello"redis 127.0.0.1:6379> HGET runoob field2"World"
复制代码

2.2.3 List(列表)

Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。

列表最多可存储 2^32 - 1 元素 (4294967295, 每个列表可存储 40 多亿)。

redis 127.0.0.1:6379> lpush runoob redis(integer) 1redis 127.0.0.1:6379> lpush runoob mongodb(integer) 2redis 127.0.0.1:6379> lpush runoob rabitmq(integer) 3redis 127.0.0.1:6379> lrange runoob 0 101) "rabitmq"2) "mongodb"3) "redis"
复制代码

2.2.4 Set(集合)

Redis 的 Set 是 string 类型的无序集合。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。

sadd 命令 :添加一个 string 元素到 key 对应的 set 集合中,成功返回 1,如果元素已经在集合中返回 0。

集合中最大的成员数为 2^32 - 1(4294967295, 每个集合可存储 40 多亿个成员)。

redis 127.0.0.1:6379> DEL runoobredis 127.0.0.1:6379> sadd runoob redis(integer) 1redis 127.0.0.1:6379> sadd runoob mongodb(integer) 1redis 127.0.0.1:6379> sadd runoob rabitmq(integer) 1redis 127.0.0.1:6379> sadd runoob rabitmq(integer) 0redis 127.0.0.1:6379> smembers runoob1) "redis"2) "rabitmq"3) "mongodb"
复制代码

2.2.5 zset(sorted set:有序集合)

Redis zset 和 set 一样也是 string 类型元素的集合,且不允许重复的成员。

不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。

zset 的成员是唯一的,但分数(score)却可以重复。

zadd 命令 :添加元素到集合,元素在集合中存在则更新对应 score

redis 127.0.0.1:6379> zadd runoob 0 redis(integer) 1redis 127.0.0.1:6379> zadd runoob 0 mongodb(integer) 1redis 127.0.0.1:6379> zadd runoob 0 rabitmq(integer) 1redis 127.0.0.1:6379> zadd runoob 0 rabitmq(integer) 0redis 127.0.0.1:6379> > ZRANGEBYSCORE runoob 0 10001) "mongodb"2) "rabitmq"3) "redis"
复制代码

03 Redis 深入:带着问题出发?

3.1 如果让你设计一个 KV 数据库,该如何设计

对这个问题的思考,将有助于我们从整体架构上去学习 Redis。

假设现在我们已经设计好了一个 KV 数据库,首先如果我们要使用,是不是得有入口,我们是通过动态链接库还是通过网络 socket 对外提供访问入口,这就涉及到了访问模块。Redis 就是通过

通过访问模块访问 KV 数据库之后,我们的数据存储在哪里?为了保证访问的高性能,我们选择存储在内存中,这又需要有存储模块。存在内存中的数据,虽然访问速度快,但存在的的问题就是断电后,无法恢复数据,所以我们还需要支持持久化操作

有了存储模块,我们还需要考虑,数据是以什么样的形式存储?怎样设计才能让数据操作更优,这就设计到了,数据类型的支持,索引模块。 索引的作用是让键值数据库根据 key 找到相应 value 的存储位置,进而执行操作。

有了以上模块的只是,我们是不是要对数据进行操作了?比如往 KV 数据库中插入或更新一条数据,删除和查询,这就是需要有操作模块了。

至此我们已经构造出了一个 KV 数据库的基本框架了,带着这些架构,我们再深入到每个点中去探究,这样就会轻松很多,不会迷失在末枝细节中了。

3.2 Redis 为什么那么快?

我们都知道 Redis 访问快,这是因为 redis 的操作都是在内存上的,内存的访问本身就很快,另外 Redis 底层的数据结构也对“快”起到了至关重要的作用。

我们平常所以所说 Redis 的 5 种数据结构:String、Hash、Set、ZSet 和 List 指的只是键值对中值的数据结构,而我这里所说的数据结构,指的是它们底层实现。

Redis 的底层数据结构有:简单动态字符串、整数数组、压缩列表、跳表、hash 表、双向列表 6 种。

简单动态数组:就是 String 的底层实现

其中整数数组、hash 表、双向列表都是我们常见的数据结构

压缩列表和跳表属于特殊的数据结构

压缩列表是 Redis 实现的特殊的数组:它本质就是一个数组,只不过,我们常见的数组的每个元素分配的空间大小是一致的,这样就会导致有多余的内存空间被浪费了。压缩列表就是为了解决这样的问题,它的每个元素大小是按实际大小分配的,避免了内存的浪费,同时在压缩列表的表头还存了关于该列表的相关属性:用于记录列表个数 zllen,表尾偏移量 zltail 和列表长度 zlbytes。表尾还有一个 zlend 标记列表的结束。

跳表:有序链表查询元素只能逐一查询,跳表本质上就是链表的基础上加了多级索引,通过多级索引的几个跳转,快递定位到元素所在位置。


不同数据结构的查询时间复杂度


上面从存储方面解释了,redis 为什么快.

3.2.1 为什么用单线程?

逆向思维可以说为什么不用多线程,这个我们得先看下多线程存在哪些问题?在正常应用操作中,使用多线程可以大大提高处理的时间。那是不是可以无限地加大线程数量,以获取更快的处理速度?实际试验后,发现在机器资源有限的情况下,不断增加线程处理时间,并没有像我们想象的那样成线性增长,而是到达一定阶段就趋于平衡,甚至有下降的趋势,这是为什么呢?

其实主要有两个方面,我们知道线程是 CPU 调度的最小单元,当线程多的时候,CPU 需要不停的切换线程,线程切换是需要消耗时间的,当大量线程需要来回切换,那么 CPU 在这切换的损耗了很多时间。

另外当多个线程,需要对共享资源进行操作的时候,为了保证并发安全性,需要有额外的机制保证,比如加锁。这样就使得当多个线程在操作共享数据时,变成了串行。

所以为了避免这些问题,Redis 采用了单线程操作数据。

3.2.2 单线程为什么还真这么快?

我们知道 Redis 单线程操作的,但是只是指的 Redis 对外提供键值对存储服务是单线程的。Redis 的其他功能并不是,比如持久化,异步删除,集群同步等,都是由额外的线程去执行的。

除了上面说的,Redis 的大部分操作都是在内存上完成的,加上高效的数据结构,是他实现高性能的一方面。另外一方面 Redis 采用的多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求。

在网络 IO 操作中,有潜在的阻塞点,分别是 accept() 和 recv()。当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。 这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。不过,幸运的是,socket 网络模型本身支持非阻塞模式。

Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上,如果想要使用 socket 非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。接下来,我们就重点学习下它们。在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。


针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。

类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。这样才能保证 Redis 线程,既不会像基本 IO 模型中一直在阻塞点等待,也不会导致 Redis 无法处理实际到达的连接请求或数据。

Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。

3.3 Redis 是如何保证数据不丢失的?

因为 Redis 是操作是基于内存的,所有一点系统宕机存在内存中的数据就会丢失,为了实现数据的持久化,Redis 中存在两个持久化机制 AOF 和 RBD。

3.3.1 AOF(Append Only File)介绍

AOF 的原理就是,通过记录下 Redis 的所有命令操作,在需要数据恢复的时候,再按照顺序把所有命令执行一次,从而恢复数据。

但跟数据库的写前日志不同的,AOF 采用的写后日志,也就是在 Redis 执行过操作之后,再写入 AOF 日志。之所以为什么采用写后日志,可以避免因为写日志的占用 redis 调用的时间,另外为了保证 Redis 的高性能,在写 aof 日志的时候,不会做校验,若采用写前日志,如果命令是错误非法的,在恢复数据的时候就会出现异常。采用写后日志,只有命令执行成功的才会被保存。

3.3.2 AOF 策略

AOF 的执行策略有三种

all:每次写入/删除命令都会被写入日志文件中,保证了数据可靠性,但是写入日志,涉及到了磁盘的 IO,必然会影响性能

everysec:每秒钟执行一次日志写入,在一秒之内的命令操作会记录在 aof 内存缓冲区,每一秒会写回到日志文件中,相对于每次写入性能得以提升,但是在 aof 缓冲区没有来得及回写到日志文件中时,系统发生宕机就会丢失这部分数据。

no:内存缓冲区的命令记录不会不主动写回到日志文件中,而交给操作系统决定。这种策略性能最高,但是丢失数据的风险也最大。

3.3.3 AOF 重写机制

但是 AOF 文件过大,会带来性能问题,所有 AOF 重写机制就登场了。

AOF 重写的原理是,将多个命令对同一个 key 的操作合并成一个,因为数据恢复时,我们只要关心数据最后的状态就可以了。

需要注意的是,与 AOF 日志由主线程写回不同,重写过程是由后台子线程 bgwriteaof 来完成的,这个避免阻塞主线程,导致数据库性能下降。


每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。

3.4 内存快照 RDB

3.4.1 RDB Redis DataBase

所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。对 Redis 来说,就是把某一时刻的状态以文件的形式写到磁盘上。

Redis 执行 RDB 的策略是什么?

Redis 进行快照的时候,是进行全量的快照,并且为了不阻塞主线程,会默认使用 bgsave 命令创建一个子线程,专门用于写入 RDB 文件。

快照期间数据还能修改吗?

如果不能修改,那么在快照期间,这块数据就会只能读取不能修改,那么必然影响使用。如果可以修改,那么 Redis 是如何实现的?其实 Redis 是借助操作系统的写时复制,在执行快照期间,让修改的数据,会在内存中拷贝出一份副本,副本的数据可以被写入 rdb 文件中,而主线程仍然可以修改原数据。

多久执行一次呢?

跟 aof 同样的问题,如果快照频率低,那么在两次快照期间出现宕机,就会出现数据不完整的情况,如果快照频率过快,那么又会出现两个问题,一个是不停的对磁盘写出,增大磁盘压力,可能上一次写入还没完成,新的快照又来了,造成恶性循环.另外虽然执行快照是主线程 fork 出来的,但是不停的 fork 的过程是阻塞主线程的。

那么如何配置才合适呢?

其实我们只需要第一次全量快照,后续只快照有数据变动的地方就可以大大降低快照的资源损耗了,那么如何记录这变动的数据呢,这里我们可以想到 aof 具有这样的功能。Redis4.0 就提使用 RDB+AOF 混合模式来完成 Redis 的持久化。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。

3.5 主从库是如何实现数据一致的?

前面我们通过 Redis 的持久化机制,来保证服务器宕机之后,通过回放日志和重新读取 RDB 文件恢复数据,减少数据丢失的风险。但是在单台及其的情况下,机器发生宕机,就无法对外提供服务了。我们所说的 Redis 具有高可靠性,指的一是,数据尽量少丢失,之前持久化机制就解决了这一问题,另一个是服务尽量少中断,Redis 的做法是增加副本冗余量。Redis 提供的主从模式,主从库之间采用了读写分离的方式。


从库只读取,主库执行读与写,写的数据主库会同步给从库。之所以只让主库写,是因为,如果从库也写,那么当客户端对一个数据修改了 3 次,为了保证数据的正确性,就要设法让主从库对于写操作协同,这会带来巨额的开销。

主从库间如何进行第一次同步的?

当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。


主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。

这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。

在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。

具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。

在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。

最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

3.6 Redis 如何保证高可用的

3.6.1 主库挂了之后,还能接收写操作吗?

Redis 在有了主从集群后,如果从库挂了,Redis 对外提供服务不受影响,主库和其他从库,依然可以提供读写服务,但是当主库挂了之后,因为是读写分离的,如果此时有写的请求,那么就无法处理了。Redis 是如果解决这样的问题的呢,这就要引入哨兵机制了。

当主库挂了,我们需要从从库中选出一个当做主库,这样就可以正常对外提供服务了。哨兵的本质就是一个 Redis 示例,只不过它是运行在特殊模式下的 Redis 进程。它主要有三个作用:监控、选举、通知

哨兵在监控到主库下线的时候,会从从库中通过一定的规则,选举出适合的从库当主库,并通知其他从库变更主库的信息,让他们执行 replicaof 命令,和新主库建立连接,并进行数据复制。那么具体每一步都是怎么做的呢?

监控:哨兵会周期性向主从库发送 PING 命令,检测主库是否正常运行,如果主从库没有在规定的时间内回应哨兵的 PING 命令,则会被判定为“下线状态”,如果是主库下线,则开始自动切换主库的流程。但是一般如果只有一个哨兵,那么它的判断可能不具有可靠性,所以一般哨兵都是采用集群模式部署,称为哨兵集群。单多个哨兵均判断该主库下线了,那么可能他就真的下线了,这是一个少数服从多数的规则。


选举: 哨兵选择新主库的过程称为“筛选 + 打分”。简单来说,我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库,如下图所示:


1、排除那些已经下线的从库,以及连接不稳定的从库。连接不稳定是通过配置项 down-after-milliseconds,当主从连接超时达到一定阈值,就会被记录下来,比如设置的 10 次,那么就会标记该从库网络不好,不适合做为主库。

2、筛选出从库后,第二部就要开始打分了,主要从三方面打分,

1.从库优先级,这是可以通过 slave-property 设置的,设置的高,打分的就高,就会被选为主库,比如你可以给从库中内存带宽资源充足设置高优先级,当主库挂了之后被优先选举为主库。

2.从库与旧主库之间的复制进度,之前我们知道主从之间增量复制,有个参数 slave-repl-offset 记录当前的复制进度。这个数值越大,说明与主库复制进度越靠近,打分也会越高。

​ 3.每个从库创建实例的时候,会随机生成一个 id,id 越小的得分越高。

通知:哨兵提升一个从库为新主库后,哨兵会把新主库的地址写入自己实例的 pubsub(switch-master)中。客户端需要订阅这个 pubsub,当这个 pubsub 有数据时,客户端就能感知到主库发生变更,同时可以拿到最新的主库地址,然后把写请求写到这个新主库即可,这种机制属于哨兵主动通知客户端。

如果客户端因为某些原因错过了哨兵的通知,或者哨兵通知后客户端处理失败了,安全起见,客户端也需要支持主动去获取最新主从的地址进行访问。

所以,客户端需要访问主从库时,不能直接写死主从库的地址了,而是需要从哨兵集群中获取最新的地址(sentinel get-master-addr-by-name 命令),这样当实例异常时,哨兵切换后或者客户端断开重连,都可以从哨兵集群中拿到最新的实例地址。


3.6.2 哨兵集群

部署哨兵集群的时候,我们知道只需要配置:sentinel monitor 跟主库通信就可以了,并不知道其他哨兵的信息,那么是如何知道的呢?

Redis 有提供了 pub/sub 机制,哨兵跟主库建立了连接之后,将自己的信息发布到 “sentinel:hello”频道上,其他哨兵发布并订阅了该频道,就可以获取其他哨兵的信息,那么哨兵之间就可以相互通信了。

那么哨兵如何知道从库的连接信息呢,那是因为 INFO 命令,哨兵向主库发送该命令后,获得了所有从库的连接信息,就能分从库建立连接,并进行监控了。

从本质上说,哨兵就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供 pub/sub 机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。

3.6.3 切片集群

与 mysql 一样,当一张表的数据很大时,查询耗时可能就会越来越大,我们采取的措施是分表分库。同样的 Redis 也样,当数据量很大时,比如高达 25G,在单分片下,我们需要机器有 32G 的内存。但是我们会发现,有时候 redis 响应会变得很慢,通过 INFO 查询 Redis 的 latest_fork_usec 指标,最近 fork 耗时,发现耗时很大,快到秒级别了,fork 这个动作会阻塞主线程,于是就导致了 Redis 变慢了。

于是就有 redis 分片集群, 启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。回到我们刚刚的场景中,如果把 25GB 的数据平均分成 5 份(当然,也可以不做均分),使用 5 个实例来保存,每个实例只需要保存 5GB 数据。

那么,在切片集群中,实例在为 5GB 数据生成 RDB 时,数据量就小了很多,fork 子进程一般不会给主线程带来较长时间的阻塞。采用多个实例保存数据切片后,我们既能保存 25GB 数据,又避免了 fork 子进程阻塞主线程而导致的响应突然变慢。

那么数据是如何决定存在在哪个分片上的呢?

Redis Cluster 方案采用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。具体的映射过程分为两大步:首先根据键值对的 key,按照 CRC16 算法计算一个 16 bit 的值;然后,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

我们在部署 Redis Cluster 方案时,可以使用 cluster create 命令创建集群,此时,Redis 会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。 也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。

前面介绍了 Redis 相关知识,了解了 Redis 的高可用,高性能的原因。很多人认为提到缓存,就局限于 Redis,其实缓存的应用不仅仅在于 Redis 的使用,比如还有 Nginx 缓存,缓存队列等等。下面我们会将讲解 Nginx+Lua 实现多级缓存方法,来解决高并发访问的场景。

04 缓存的应用

我们来看一张微服务架构缓存的使用


我们可以看到微服务架构中,会大量使用到缓存

1.客户端缓存(手机、PC)2.Nginx 缓存 3.微服务网关限流令牌缓存 4.Nacos 缓存服务列表、配置文件 5.各大微服务自身也具有缓存 6.数据库查询 Query Cache7.Redis 集群缓存 8.Kafka 也属于缓存

应对高并发的最有效手段之一就是分布式缓存,分布式缓存不仅仅是缓存要显示的数据这么简单,还可以在限流、队列削峰、高速读写、分布式锁等场景发挥重大作用。分布式缓存可以说是解决高并发场景的有效利器。以以下场景为例:

1、凌晨突然涌入的巨大流量。【队列术】【限流术】2、高并发场景秒杀、抢红包、抢优惠券,快速存取。【缓存取代MySQL操作】3、高并发场景超卖、超额抢红包。【Redis单线程取代数据库操作】4、高并发场景重复抢单。【Redis抢单计数器】
复制代码

一谈到缓存架构,很多人想到的是 Redis,但其实整套体系的缓存架构并非只有 Redis,而应该是多个层面多个软件结合形成一套非常良性的缓存体系。比如咱们的缓存架构设计就涉及到了多个层面的缓存软件。


1、HTML页面做缓存,浏览器端可以缓存HTML页面和其他静态资源,防止用户频繁刷新对后端造成巨大压力2、Lvs实现记录不同协议以及不同用户请求链路缓存3、Nginx这里会做HTML页面缓存配置以及Nginx自身缓存配置4、数据查找这里用Lua取代了其他语言查找,提高了处理的性能效率,并发处理能力将大大提升5、数据缓存采用了Redis集群+主从架构,并实现缓存读写分离操作6、集成Canal实现数据库数据增量实时同步Redis
复制代码

05 Nginx 缓存

5.1 浏览器缓存

客户端侧缓存一般指的是浏览器缓存、app 缓存等等,目的就是加速各种静态资源的访问,降低服务器压力。我们通过配置 Nginx 设置网页缓存信息,从而降低用户对服务器频繁访问造成的巨大压力。


HTTP 中最基本的缓存机制,涉及到的 HTTP 头字段,包括 Cache‐Control, Last‐Modified, If‐Modified‐Since, Etag,If‐None‐Match 等。 Last‐Modified/If‐Modified‐Since Etag是服务端的一个资源的标识,在 HTTP 响应头中将其传送到客户端。所谓的服务端资源可以是一个Web页面,也可以是JSON或XML等。服务器单独负责判断记号是什么及其含义,并在HTTP响应头中将其传送到客户端。比如,浏览器第一次请求一个资源的时候,服务端给予返回,并且返回了ETag: "50b1c1d4f775c61:df3" 这样的字样给浏览器,当浏览器再次请求这个资源的时候,浏览器会将If‐None‐Match: W/"50b1c1d4f775c61:df3" 传输给服务端,服务端拿到该ETAG,对比资源是否发生变化,如果资源未发生改变,则返回304HTTP状态码,不返回具体的资源。 Last‐Modified :标示这个响应资源的最后修改时间。web服务器在响应请求时,告诉浏览器资源的最后修改时间。 If‐Modified‐Since :当资源过期时(使用Cache‐Control标识的max‐age),发现资源具有 Last‐Modified 声明,则再次向web服务器请求时带上头。 If‐Modified‐Since ,表示请求时间。web服务器收到请求后发现有头 If‐Modified‐Since 则与被请求资源的最后修改时间进行比对。若最后修改时间较新,说明资源有被改动过,则响应整片资源内容(写在响应消息包体内),HTTP 200;若最后修改时间较旧,说明资源无新修改,则响应 HTTP 304 (无需包体,节省浏览),告知浏览器继续使用所保存的 cache 。 Pragma行是为了兼容 HTTP1.0 ,作用与 Cache‐Control: no‐cache 是一样的 Etag/If‐None‐MatchEtag :web服务器响应请求时,告诉浏览器当前资源在服务器的唯一标识(生成规则由服务器决定),如果给定URL中的资源修改,则一定要生成新的Etag值。 If‐None‐Match :当资源过期时(使用Cache‐Control标识的max‐age),发现资源具有Etage声明,则再次向web服务器请求时带上头 If‐None‐Match (Etag的值)。web服务器收到请求后发现有头 If‐None‐Match 则与被请求资源的相应校验串进行比对,决定返回200或304。 Etag:Last‐Modified 标注的最后修改只能精确到秒级,如果某些文件在1秒钟以内,被修改多次的话,它将不能准确标注文件的修改时间,如果某些文件会被定期生成,当有时内容并没有任何变化,但 Last‐Modified 却改变了,导致文件没法使用缓存有可能存在服务器没有准确获取文件修改时间,或者与代理服务器时间不一致等情形 Etag是服务器自动生成或者由开发者生成的对应资源在服务器端的唯一标识符,能够更加准确的控制缓存。 Last‐Modified 与 ETag 是可以一起使用的,服务器会优先验证 ETag ,一致的情况下,才会继续比对 Last‐Modified ,最后才决定是否返回304。
复制代码

5.2 代理缓存


用户如果请求获取的数据不是需要后端服务器处理返回,如果我们需要对数据做缓存来提高服务器的处理能力,我们可以按照如下步骤实现:

1、请求Nginx,Nginx将请求路由给后端服务2、后端服务查询Redis或者MySQL,再将返回结果给Nginx3、Nginx将结果存入到Nginx缓存,并将结果返回给用户4、用户下次执行同样请求,直接在Nginx中获取缓存数据
复制代码

06 多级缓存架构


具体流程

1、用户请求经过Nginx2、Nginx检查是否有缓存,如果Nginx有缓存,直接响应用户数据3、Nginx如果没有缓存,则将请求路由给后端Java服务4、Java服务查询Redis缓存,如果有数据,则将数据直接响应给Nginx,并将数据存入缓存,Nginx将数据响应给用户5、如果Redis没有缓存,则使用Java程序查询MySQL,并将数据存入到Reids,再将数据存入到Nginx中
复制代码

优缺点

优点:1、采用了Nginx缓存,减少了数据加载的路径,从而提升站点数据加载效率2、多级缓存有效防止了缓存击穿、缓存穿透问题缺点Tomcat并发量偏低,导致缓存同步并发量失衡,缓存首次加载效率偏低,Tomcat 大规模集群占用资源高
复制代码



优点1、采用了Nginx缓存,减少了数据加载的路径,从而提升站点数据加载效率2、多级缓存有效防止了缓存击穿、缓存穿透问题3、使用了Nginx+Lua集成,无论是哪次缓存加载,效率都高4、Nginx并发量高,Nginx+Lua集成,大幅提升了并发能力
复制代码

6.1 抢红包案例架构设计分享


上面我们已经分析过红包雨的特点,要想实现一套高效的红包雨系统,缓存架构是关键。我们根据红包雨的特点设计了如上图所示的红包雨缓存架构体系。

1、红包雨分批次导入到Redis缓存而不要每次操作数据库2、很多用户抢红包的时候,为了避免1个红包被多人抢到,我们要采用Redis的队列存储红包3、追加红包的时候,可以追加延时发放红包,也可以直接追加立即发放红包4、用户抢购红包的时候,会先经过Nginx,通过Lua脚本查看缓存中是否存在红包,如果不存在红包,则直接终止抢红包5、如果还存在红包,为了避免后台同时处理很多请求,这里采用队列术缓存用户请求,后端通过消费队列执行抢红包
复制代码

6.2 缓存队列使用场景

1、队列控制并发溢出:并发量非常大的系统,例如秒杀、抢红包、抢票等操作,都是存在溢出现象,比如秒杀超卖、抢红包超额、一票多单等溢出现象,如果采用数据库锁来控制溢出问题,效率非常低,在高并发场景下,很有可能直接导致数据库崩溃,因此针对高并发场景下数据溢出解决方案我们可以采用 Redis 缓存提升效率。

2、队列限流:解决大量并发用户蜂拥而上的方法可以采用队列术将用户的请求用队列缓存起来,后端服务从队列缓存中有序消费,可以防止后端服务同时面临处理大量请求。缓存用户请求可以用 RabbitMQ、Kafka、RocketMQ、ActiveMQ 等。用户抢红包的时候,我们用 Lua 脚本实现将用户抢红包的信息以生产者角色将消息发给 RabbitMQ,后端应用服务以消费者身份从 RabbitMQ 获取消息并抢红包,再将抢红包信息以 WebSocket 方式通知给用户。

6.3 Nginx 限流

nginx 提供两种限流的方式:一是控制速率,二是控制并发连接数。

1、速率限流

控制速率的方式之一就是采用漏桶算法。具体配置如下:


2、控制并发量

ngx_http_limit_conn_module 提供了限制连接数的能力。主要是利用 limit_conn_zone 和 limit_conn 两个指令。利用连接数限制 某一个用户的 ip 连接的数量来控制流量。

(1)配置限制固定连接数如下,配置如下:配置限流缓存空间:

根据IP地址来限制,存储内存大小10Mlimit_conn_zone $binary_remote_addr zone=addr:1m;
复制代码

location 配置:

limit_conn addr 2;
复制代码

参数说明:

limit_conn_zone $binary_remote_addr zone=addr:10m;  表示限制根据用户的IP地址来显示,设置存储地址为的内存大小10M limit_conn addr 2;   表示 同一个地址只允许连接2次。
复制代码

(2)限制每个客户端 IP 与服务器的连接数,同时限制与虚拟服务器的连接总数。限流缓存空间配置:

limit_conn_zone $binary_remote_addr zone=perip:10m;limit_conn_zone $server_name zone=perserver:10m;
复制代码

location 配置

limit_conn perip 10;#单个客户端ip与服务器的连接数limit_conn perserver 100; #限制与服务器的总连接数
复制代码

每个 IP 限流 3 个总量 5 个

07 缓存灾难问题如何解决

7.1 缓存穿透

产生原因

当我们查询一个缓存不存在的数据,就去查数据库,但此时如果数据库也没有这个数据,后面继续访问依然会再次查询数据库,当有用户大量请求不存在的数据,必然会导致数据库的压力升高,甚至崩溃。

如何解决

1、当查询到不存在的数据,也将对应的 key 放入缓存,值为 nul,这样再次查询会直接返回 null,如果后面新增了该 key 的数据,就覆盖即可。

2、使用布隆过滤器。布隆过滤器主要是解决大规模数据下不需要精确过滤的业务场景,如检查垃圾邮件地址,爬虫 URL 地址去重,解决缓存穿透问题等。

7.2 缓存击穿

产生原因

当缓存在某一刻过期了,一般如果再查询这个缓存,会从数据库去查询一次再放到缓存,如果正好这一刻,大量的请求该缓存,那么请求都会打到数据库中,可能导致数据库打垮。

如何解决

1、尽量避免缓存过期时间都在同一时间。

2、定时任务主动刷新更新缓存,或者设置缓存不过去,适合那种 key 相对固定,粒度较大的业务。

​ 分享下我在公司的负责的系统是如何防止缓存击穿的,由于业务场景,缓存的数据都是当天有效的,当天查询的只查当日有效的数据,所以当时数据都是设置当天凌晨过期,并且缓存是懒加载,这样导致 0 点高峰期数据库压力明显增大。后来改造了下,做了个定时任务,每天凌晨 3 点,跑第二天生效的数据,并且设置失效时间延长一天。有效解决了该问题,相当于缓存预热。


3、多级缓存


采用多级缓存也可以有效防止击穿现象,首先通过程序将缓存存入到 Redis 缓存,且永不过期,用户查询的时候,先查询 Nginx 缓存,如果 Nginx 缓存没有,则查询 Redis 缓存,并将 Redis 缓存存入到 Nginx 一级缓存中,并设置更新时间。这种方案不仅可以提升查询速度,同时又能防止击穿问题,并且提升了程序的抗压能力。

4、分布式锁与队列。解决思路主要是防止多请求同时打过去。分布式锁,推荐使用 Redisson。队列方案可以使用 nginx 缓存队列,配置如下。



7.3 缓存雪崩

产生原因

缓存雪崩是指,由于缓存层承载着大量请求,有效的保护了存储层,但是如果缓存层由于某些原因整体不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。

如何解决

1、做缓存集群。即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,比如 Redis Sentinel 和 Redis Cluster 都实现了高可用。

2、做好限流。微服务网关或者 Nginx 做好限流操作,防止大量请求直接进入后端,使后端载荷过重最后宕机。

3、缓存预热。预先去更新缓存,再即将发生大并发访问前手动触发加载缓存不同的 key,设置不同的过期时间,让缓存失效的时间点尽量均匀,不要同时失效。

4、加锁。数据操作,如果是带有缓存查询的,均使用分布式锁,防止大量请求直接操作数据库。

5、多级缓存。采用多级缓存,Nginx+Redis+MyBatis 二级缓存,当 Nginx 缓存失效时,查找 Redis 缓存,Redis 缓存失效查找 MyBatis 二级缓存。


7.4 缓存一致性

问题描述

数据的在增量数据,未同步到缓存。导致缓存与数据库数据不一致。

解决方案 Canal


用户每次操作数据库的时候,使用 Canal 监听数据库指定表的增量变化,在 Java 程序中消费 Canal 监听到的增量变化,并在 Java 程序中实现对 Redis 缓存或者 Nginx 缓存的更新。用户查询的时候,先通过 Lua 查询 Nginx 的缓存,如果 Nginx 缓存没有数据,则查询 Redis 缓存,Redis 缓存如果也没有数据,可以去数据库查询。

好了,文章到这里就结束啦,喜欢的朋友欢迎点赞+收藏哦!

用户头像

冉然学Java

关注

还未添加个人签名 2022.07.07 加入

努力学好Java、爱生活、爱旅游的冉冉; 分享自己工作上的经验,交流、共进步、共成长!

评论

发布
暂无评论
如何通过使用“缓存”相关技术,解决“高并发”的业务场景案例?_高并发_冉然学Java_InfoQ写作社区