1、开篇词

缓存的使用,在低并发下你只需要了解基本的使用方式,但在高并发场景下你需要关注缓存命中率,如何应对缓存穿透,如何避免雪崩,如何解决缓存一致性等问题。

完成业务需求,解决产品问题不应该是你最终的目标,提升技术能力和技术视野才应是你始终不变的追求。

系统模块要做到高内聚、低解耦,消息队列是高并发系统中常见的一种组件,它可以将消息生产方和消费方解耦,减少突发流量对于系统的冲击。

2、基础篇

2.1 应对高并发

  • Scale-out(横向扩展,新增实例,纵向扩展扩容量[cpu,mem]):分而治之是一种常见的高并发系统设计方法,采用分布式部署的方式把流量分流开,让每个服务器都承担一部分并发和流量。
  • 缓存:使用缓存来提高系统的性能,就好比用“拓宽河道”的方式抵抗高并发大流量的冲击。
  • 异步:在某些场景下,未处理完成之前,我们可以让请求先返回,在数据准备好之后再通知请求方,这样可以在单位时间内处理更多的请求。

image.png

2.2 系统分层

分层的设计可以简化系统设计,让不同的人专注做某一层次的事情。可以做到很高的复用,更容易做横向扩展。

  • MVC

“MVC”(Model-View-Controller)架构。它将整体的系统分成了 Model(模型),View(视图)和 Controller(控制器)三个层次,也就是将用户视图和业务处理隔离开,并且通过控制器连接起来,很好地实现了表现和逻辑的解耦,是一种标准的软件分层架构。
image.png

  • TCP/IP

把网络简化成了四层,即链路层、网络层、传输层和应用层

  • 网络层负责端到端的寻址和建立连接
  • 传输层负责端到端的数据传输

image.png

3、演进篇

3.1 性能损耗

  • 用连接池预先建立数据库连接

数据库连接池有两个最重要的配置:最小连接数和最大连接数,它们控制着从连接池中获取连接的流程:

  • 如果当前连接数小于最小连接数,则创建新的连接处理数据库请求;
  • 如果连接池中有空闲连接则复用空闲连接;
  • 如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求;
  • 如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配置是 checkoutTimeout)等待旧的连接可用;
  • 如果等待超过了这个设定时间则向用户抛出错误。

连接可能出现的问题:
MySQL 有个参数是“wait_timeout”,控制着当数据库连接闲置多长时间后,数据库会主动的关闭这条连接。这个机制对于数据库使用方是无感知的,所以当我们使用这个被关闭的连接时就会发生错误。

  • 用线程池预先创建线程

JDK 1.5 中引入的 ThreadPoolExecutor 就是一种线程池的实现,它有两个重要的参数:coreThreadCount 和 maxThreadCount,这两个参数控制着线程池的执行过程。

  • 如果线程池中的线程数少于 coreThreadCount 时,处理新的任务时会创建新的线程;
  • 如果线程数大于 coreThreadCount 则把任务丢到一个队列里面,由当前空闲的线程执行;
  • 当队列中的任务堆积满了的时候,则继续创建线程,直到达到 maxThreadCount;
  • 当线程数达到 maxTheadCount 时还有新的任务提交,那么我们就不得不将它们丢弃了。

image.png

3.2 查询请求突增

3.2.1 读写分离

主从复制的过程是这样的:
首先从库在连接到主节点时会创建一个 IO 线程,用以请求主库更新的 binlog,并且把接收到的 binlog 信息写入一个叫做 relay log 的日志文件中,而主库也会创建一个 log dump 线程来发送 binlog 给从库;同时,从库还会创建一个 SQL 线程读取 relay log 中的内容,并且在从库中做回放,最终实现主从的一致性。这是一种比较常见的主从复制方式。
在这个方案中,使用独立的 log dump 线程是一种异步的方式,可以避免对主库的主体更新流程产生影响,而从库在接收到信息后并不是写入从库的存储中,是写入一个 relay log,是避免写入从库实际存储会比较耗时,最终造成从库和主库延迟变长。
image.png
主从复制的缺陷:
在发微博的过程中会有些同步的操作,像是更新数据库的操作,也有一些异步的操作,比如说将微博的信息同步给审核系统,所以我们在更新完主库之后,会将微博的 ID 写入消息队列,再由队列处理机依据 ID 在从库中获取微博信息再发送给审核系统。此时如果主从数据库存在延迟,会导致在从库中获取不到微博信息,整个流程会出现异常。
image.png
解决方案:

  1. 数据的冗余。你可以在发送消息队列时不仅仅发送微博 ID,而是发送队列处理机需要的所有微博信息,借此避免从数据库中重新查询数据。
  2. 使用缓存。我可以在同步写数据库的同时,也把微博的数据写入到 Memcached 缓存里面,这样队列处理机在获取微博信息的时候会优先查询缓存,这样也可以保证数据的一致性。
  3. 查询主库。我可以在队列处理机中不查询从库而改为查询主库。不过,这种方式使用起来要慎重,要明确查询的量级不会很大,是在主库的可承受范围之内,否则会对主库造成比较大的压力。

3.2.2 分库分表

依照某一种策略将数据尽量平均的分配到多个数据库节点或者多个表中。每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效的提升数据查询的性能。同时,因为数据被分配到多个数据库节点上,那么数据的写入请求也从请求单一主库变成了请求多个数据分片节点,在一定程度上也会提升并发写入的性能。

高并发场景:

1、架构设计

可以参考吞吐量、每秒查询率(QPS)、响应时间(Response Time)、并发用户数,PV等作为辅助指标

  • QPS: 每秒响应请求数。
  • 吞吐量: 单位时间内处理的请求数量,在互联网领域,这个指标与QPS的区分并没有那么明显。
  • 响应时间: 系统对请求做出的响应时间,例如系统处理一个HTTP请求需要200ms,那么这里的200ms就是系统的响应时间。
  • 并发用户数:同时承载正常使用系统功能的用户数量。

网站系统架构图:
网站架构一般分成 网页缓存层、负载均衡层、Web服务器层、数据缓存层及数据库层,其实一般还会多加一层,即文件服务器层
image.png

建议将负载均衡分成两级来处理

  • 一级是流量四层分发
  • 二级是应用层面七层转发(即业务层面)

首先我们可以通过LVS或HAProxy将流量转发给二层负载均衡(一般为Nginx),即实现了流量的负载均衡,此处可以使用如轮询、权重等调度算法来实现负载的转发;然后二层负载均衡会根据请求特征再将请求分发出去。

此处为什么要将负载均衡分为两层呢?

  1. 第一层负载均衡应该是无状态的,方便水平扩容。我们可以在这一层实现流量分组(内网和外网隔离、爬虫和非爬虫流量隔离)、内容缓存、请求头过滤、故障切换(机房故障切换到其他机房)、限流、防火墙等一些通用型功能,无状态设计,可以水平扩容。
  2. 二层Nginx负载均衡可以实现业务逻辑,或者反向代理到如Tomcat,这一层的Nginx与业务相关联,可以实现业务的一些通用逻辑。如果可能的话,这一层也应尽量设计成无状态,以方便水平扩容。

文件服务器
对于静态内容,如CSS、JS、HTML还有图片文件,可以通过租赁CDN的方式来进行处理。
将图片服务器独立出来,并分配独立域名,这里就不要再用二级域名了,原因有如下三点。

  1. 避免Cookie的多次传输和Cookie的跨域安全问题。
  2. 多个域名可以增加浏览器并行下载条数,因为浏览器对同一个域的域名下载条数是有限制的,所以多个域会增加并行下载条数,从而加快加载速度。当然二级域名也不能使用得太多,因为二级域名太多时还要考虑到DNS的解析所花费的时间。
  3. 方便管理,一般来说,图片在站点的加载中是最占带宽的,可以采用独立服务器以方便后期管理;还可以使用异步加载的方式,提升用户体验。同时,图片大多是静态内容,可以更好地使用CDN加速。
  4. 磁盘的优化:将程序的读写Buffer设置得尽可能大一些。这样做的好处是,程序不必每次调用都直接写磁盘,而是先缓存到内存中,等Buffer满了再写入磁盘。

2、治理方案

2.1 纵向层面:

2.1.1 堆机器

1台8核8G的服务器很可能干不过4台2核2G的机器。 而且1台1万块钱的机器也干不过10台1千块钱的机器。

2.1.2 机器调优

  • JVM

JVM层面也有,比如说堆内存分配空间大小,堆内存不足,线程创建失败,频繁GC,影响业务。

  • CPU、内存

CPU占用率、内存占用率,可以使用free、top 观察一下。

  • 内核调优:
    1. 查看各种状态的链接数:
    2. netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

    报错: TCP: time wait bucket table overflow

原因:TCP 连接数太多, 超出内核限制.
解决方案:
增大内核限制.
临时修改, 重启后失效: echo 20000 > /proc/sys/net/ipv4/tcp_max_tw_buckets
永久修改:

  1. 编辑文件 /etc/sysctl.conf, 修改配置: net.ipv4.tcp_max_tw_buckets=20000
  2. 执行命令生效: sysctl -p

# 启用keepalive时,TCP发送keepalive消息的频率。默认值为2小时。将其调低一点以更快地删除无用的连接
# 默认值:net.ipv4.tcp_keepalive_time = 7200
net.ipv4.tcp_keepalive_time = 1200

# 当服务器主动关闭链接时,套接字保持FN-WAIT-2状态的最大时间
# 默认值:net.ipv4.tcp_fin_timeout = 60
net.ipv4.tcp_fin_timeout = 30

# 该参数决定了,网络设备接收数据包的速率比内核处理这些包的速率快时,允许送到队列的数据包的最大数目。
# 默认值:net.core.netdev_max_backlog = 1000
net.core.netdev_max_backlog = 8192


# 指定了接收套接字缓冲区大小的最大值(以字节为单位)。
# 默认值:net.core.rmem_default = 212992
net.core.rmem_default = 262144

# 允许最大数量的TIME-WAIT套接字。超过几位数,TIME-WAIT套接字将立即清除,并显示警告消息。默认值为8192,太多的TIME-WAIT套接字会减慢Web服务器的速度
# 默认值:net.ipv4.tcp_max_tw_buckets = 8192
net.ipv4.tcp_max_tw_buckets = 5000

# TCP接收/发送缓存最小值,默认值,最大值
# 默认值:net.ipv4.tcp_rmem = 4096  131072  6291456
net.ipv4.tcp_rmem = 4096  32768  262142
# 默认值:net.ipv4.tcp_wmem = 4096  16384   4194304
net.ipv4.tcp_wmem = 4096  32768  262142

# 当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭
net.ipv4.tcp_syncookies = 1

# 允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
net.ipv4.tcp_tw_reuse = 1 

# 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
net.ipv4.tcp_tw_recycle = 1 

# 表示用于向外连接的端口范围。缺省情况下很小:32768到61000,改为1024到65000。
net.ipv4.ip_local_port_range = 1024 65000 

# 修改系統默认的TIMEOUT 时间。
net.ipv4.tcp_fin_timeout= 30


# 接受SYN同步包的最大客户端数量,即半连接上限,128M内存情况下是缺省值1024
默认值:net.ipv4.tcp_max_syn_backlog = 1024
net.ipv4.tcp_max_syn_backlog = 8192

# 服务端所能accept即处理数据的最大客户端数量,即完成连接上限
默认值:net.core.somaxconn = 1024
net.core.somaxconn = 10240

2.2 横向层面:

前端优化—>系统拆分—>负载均衡—>缓存—>异步—>分库

2.2.1 前端优化

  • CDN加速

启用浏览器缓存和文件压缩、添加异步请求

  • 禁止外部盗链

外部网站的图片或者文件盗链往往会带来大量的负载压力,因此应该严格限制外部对于自身的图片或者文件 盗链。

2.2.2 系统拆分

系统拆分可以理解为分布式集群,假如目前有30个并发量,你单台机器最大的处理并发量是20,此时你可以加入另一台机器,理论上能处理的最大请求量就是40。
像目前的微服务springBoot、dubbo,其中还有一个重要的通信方式就是RPC调用,传统的服务通信都是HTTP的方式,多个服务之间使用RPC性能要好一点,而且可以进行链路追踪。
而且微服务还有Ribbon和Fegin这些负载均衡组件。
而在代码层面,可以假如接口超时机制、幂等性校验等等。

2.2.3 负载均衡:

还可以通过Nginx负载均衡,分发请求,让两个机器平均处理请求量。
Nginx负载均衡:内置策略:IP Hash,加权轮询;扩展策略:fair策略,通用hash,一致性hash

2.2.4 缓存

Redis、Memcache、布隆过滤器等等。
但要注意缓存穿透、击穿、雪崩。
还可以使用缓存预热,先把一部分的数据加载到缓存,不至于让数据库压力过大影响请求。

2.2.5 异步

引入MQ(还能解耦、削峰),把请求放到MQ,稍后处理,同步转异步。

2.2.6 数据库读写分离

分库分表,读写分离。
基本的原理是让主数据库处理事务性增、改、删操作(INSERT、UPDATE、DELETE),而从数据库处理SELECT查询操作,降低数据库的压力。

2.3 业务层面:

2.3.1 服务降级

当服务器压力剧增的情况下,根据实际业务情况及流量,对一些服务和页面有策略的不处理或换种简单的方式处理,从而释放服务器资源以保证核心交易正常运作或高效运作,解决方案目前有流行的Spring Cloud Hystrix
比如说 直接返回“当前人数较多,请稍后重试”、限流处理、随机丢弃请求(好像淘宝的高并发业务就是这样的,要看业务),必要时进行熔断,触发失败rollback事件,随后慢慢进行数据核对。

2.3.2 数据核对

分布式系统需要考虑分布式事务,相比传统的单机服务,分布式事务的数据更难核对一致性,所以有必要建立数据核对平台。

2.4 监控层面:

2.4.1 监控报警

比如说链路追踪,跟踪调用链的时间和长度,快速追踪到哪个环节最耗时。
服务器CPU、内存、IO、网络,以及JVM、数据库(慢SQL)等等进行监控,在高并发请求业务下,快速定位。

2.4.2 灰度发布

模拟并发量。小流量部署,然后监控。

2.4.3 扩容

K8s可以动态扩容,在监测到服务压力激增的情况下,可以自动申请资源。
如果没有动态扩容机制,是否有备用机、灾备是否可以马上响应。
还有就是一个经验:二八原则:80%的高并发流量都在20%的时间产生,不如好好优化一下业务。