高并发多线程竞争共享资源架构

2020-11-17 06:56林平荣陈泽荣施晓权
计算机工程与设计 2020年11期
关键词:线程队列消息

林平荣,陈泽荣,施晓权

(1.广州大学华软软件学院 软件研究所,广东 广州 510990; 2.华南理工大学 计算机科学与工程学院,广东 广州 510006)

0 引 言

随着互联网的快速发展和线上用户的急剧增加,高并发请求产生的多线程竞争共享资源系统面临着更大的挑战。高并发是一种系统运行过程中出现同时并行处理大量用户请求的现象。多线程竞争共享资源指的是在高并发场景下,多个请求产生多个线程,从而造成多个线程同时竞争访问共享资源。共享资源应用对象可以是火车票或商品等有限数量资源,典型的场景有“12306抢火车票”、“天猫双十一秒杀活动”等。由于并发访问数量多且共享资源有限,因此对系统的并发性能以及资源一致性有更高的要求。

经过查阅相关文献,目前已有大量解决Web系统性能难题的方案[1-9]被提出,解决方案主要围绕以下几个方面:负载均衡[5]、数据缓存[6,9]、数据库优化[7]以及Web前端优化[8]。虽然这些方案在一定程度上可以提高系统性能,但考虑的点比较单一,并没有过多提及多线程竞争共享资源时系统垂直或水平扩展下如何确保资源的安全性和一致性。基于此,本文将从多方面综合考虑,基于数据缓存、分布式锁、消息队列、负载均衡4个方面进行详细的分析与研究,提出一种适合高并发多线程竞争共享资源应用场景的高性能通用系统架构。

1 系统架构

系统架构分为访问层、应用层、存储层。访问层用于Web接入、反向代理、负载均衡等;应用层用于核心业务服务模块处理,具备服务治理、调度、异步通信等核心服务能力;存储层进行最终数据的落地及提供数据的能力。架构如图1所示。

图1 系统架构

架构部署后,应用服务器先从数据库服务器将高并发请求中频繁访问的数据读取到缓存服务器中。客户端的高并发请求先由LVS(linux virtual server)在IP层均衡地转发到负载均衡服务器,负载均衡服务器会先将资源返回给客户端,再将请求转发到应用服务器。应用服务器接收到请求时,在一定时间内不断尝试从分布式缓存服务器中获取锁的操作,获取成功则会进行相应的业务逻辑处理。在缓存服务器中进行数据的增删改查并将数据发送到消息队列服务器中,最后由消费者进行消息监听,将数据同步到数据库服务器中,实现数据异步更新。

架构设计秉持“冗余”+“主动故障转移”两个原则,“冗余”是为了解决单一节点出现故障而backup节点能够继续提供服务,而主动故障转移是探测到故障的发生则自动进行转移,将故障节点流量进行引流。架构的设计从业务的角度出发,第一是纯静态资源的请求,另外一个是涉及业务处理的请求。

纯静态资源请求的处理:前端固化页面展示采用静态化的思想,将Redis缓存数据填充静态模板中,形成静态化页面,再推送到Nginx服务器中,当客户端访问请求时,LVS均衡地在IP层转发至处理服务的Nginx,从负载的Nginx直接返回没有涉及业务逻辑处理的静态页面,减少与数据库服务交互的次数,且不执行任何代码,给予客户端较快的响应速度。当页面数据发生变更时,会触发监听器将变更的消息压入消息队列中,缓存服务感知数据变更,便会调用数据服务,将整理好数据重新推送至Redis中,更新Nginx页面。Nginx本地缓存数据也是有一定的时限,为了避免页面的集中获取,采用随机散列的策略来规避。

涉及业务请求的处理:倘若LVS负载均衡分配Nginx的高并发请求是携带业务性的,就会导致多线程竞争共享资源时数据不一致的窘境。为避免这种问题,采取Redis分布式锁的方式以维护共享资源的数据一致和安全问题,然后再将操作后的相关缓存数据写入消息队列服务器中,最后交由消费者进行监听消费,这样的异步设计是为了实现缓存与数据库的互通,逐级削减对数据库服务器的访问。此外,为了维护缓存和数据库的双写一致性,坚持“Cache Aside Pattern”原则。

设计过程中,尽量考虑到分布式缓存服务和消息队列服务在生产环境会产生的细节问题,针对所在问题依次采取下列措施:

(1)在分布式缓存服务中,考虑缓存应用于高并发场景易发生的雪崩、穿透和击穿情况,分别提供相对应的方案如下:

缓存雪崩情况,分别在缓存雪崩前中后的各时间段,依据业务设计相应的解决方案:

1)在事前:利用Redis高可用的主从+哨兵的Redis Cluster避免全盘崩溃;

2)在事中:采用本地缓存+Nginx限流,避免数据库压力过大;

3)在事后:Redis开启持久化,一旦重启,自动加载磁盘数据,快速恢复数据。

缓存穿透是高并发请求的目标数据都不在缓存与数据库的情况,易导致数据库的直接崩盘,该情况采取Set -999 UNKNOWN,尽量设置有效期,避免下次访问直接从缓存获取。为了防止缓存击穿,可采取一些热点数据尝试设置永不过期。

(2)在消息队列服务中,共享资源的最终持久化是在消息队列服务中进行的,为了维护共享资源的最终一致性,考虑写入消息队列服务器中消息的消费幂等性、可靠性传输、消费顺序性和容器饱满的情况,检查数据库主键的唯一性来确保消费的幂等性,避免重复消费的请求;消息队列的可靠性传输,在生产者设置Ack响应,要求leader接收到消息之后,所有的follower必须进行同步的确认写入成功,如果没有满足该条件,生产者会自动不断地重试。为了避免中间件消息队列自己丢失数据,必须开启持久化功能;消息队列的顺序消费,采取一个queue对应一个consumer,然后这个consumer内部采用内存队列做排队,分发给底层不同的worker来处理;消息队列容器饱满时,将会触发待命的临时程序加入消费的行列中,加快消费速度,以免消息溢出的惨状。

2 关键技术

2.1 数据缓存

现代Web系统一般都会采用缓存策略对直接进行数据库读写的传统数据操作方式进行改进。缓存大致主要分为两种:页面缓存和数据缓存。架构基于Redis集群实现数据缓存,具有故障容错等功能。Redis完全基于内存,通过Key-Value存储能获得良好的性能,尤其表现在良好的并行读写性能[10]。由于投票容错机制要求超过半数节点认为某个节点出现故障才会确定该节点下线,因此Redis集群至少需要3个节点,为了保证集群的高可用性,每个主节点都有从节点。实现Redis集群拓扑结构如图2所示。

图2 Redis集群网络拓扑

Redis集群由6台服务器搭建,分别由3个主节点和3个从节点组成,数据都是以Key-Value形式存储,不同分区的数据存放在不同的节点上,类似于哈希表的结构。使用哈希算法将Key映射到0-16383范围的整数,一定范围内的整数对应的抽象存储称为槽,每个节点负责一定范围内的槽,槽范围如图3所示。

图3 槽范围

集群启动时,会先从数据库服务器读取高并发请求中频繁访问的数据,其中包括共享资源数据,将数据转换为JSON数据格式或对象序列化初始化到Redis服务器中,数据会根据哈希算法新增到对应的节点中。应用服务器接收到高并发请求进行数据查询、修改或删除时,会随机把命令发给某个节点,节点计算并查看这个key是否属于自己的,如果是自己的就进行处理,并将结果返回,如果是其它节点的,会把对应节点信息(IP+地址)转发给应用服务器,让应用服务器重定向访问。Redis返回结果后,应用服务器会将数据发送到消息队列服务器中,并立即将返回结果给客户端。

2.2 分布式锁

在分布式系统中,共享资源可能被多个竞争者同时请求访问,往往会面临数据的一致性问题[11],因此必须保证资源数据访问的正确性和性能。架构使用分布式锁的方式解决该问题。分布式架构下的开源组件很多,如Zookee-per、Redis、Hbase等,相比之下,Redis的性能与成熟度较高。指定一台Redis服务器作为锁的操作节点,保证获取锁的操作是原子性的。Redis本身是单线程程序,可以保证对缓存数据操作都是线程安全的。当应用服务器集群接收到高并发产生的多线程同时请求访问共享资源时,线程必须先从Redis服务器中获取锁,使用setnx指令获取锁成功后,其余的线程请求获取锁的操作会返回失败,返回失败后的线程在一定时间内不断重试获取锁,只有等待已获取锁的线程执行成功后释放锁,才能让下一个线程获取锁后访问共享资源。线程获取锁成功后,若有线程报错会中途退出,获取锁之后没有释放,就会造成死锁。使用expire作为默认过期时间,如果线程获取锁后超过默认过期时间,则锁会自动释放,为了避免业务逻辑处理报错,导致线程中途退出,因此需要再加上捕获异常处理块。示例代码如下:

//获取lock失败

if(!set lock true ex 5 nx)

{ return; }

try{#处理业务逻辑

……

//释放lock

del lock }

catch(Exception ex)

{ //释放lock

del lock }

由于采用分布式锁,在系统垂直或水平扩展的情况下,保证同一时间的一个线程获取到锁,确保共享资源的安全性和一致性。在高并发场景下,如果有大量的线程不断重试获取锁失败的操作,会造成Redis服务器压力过大,Redis服务器与应用服务器之间交互流量过高。因此,在应用系统上使用基于cas算法的乐观锁方式解决该问题。cas算法存在着3个参数,内存值V,预期值E,更新值N。当且仅当内存值V与预期值E相等时,才会将内存值修改为N,否则什么也不做。借助jdk的juc类库所提供的cas算法,以及带有原子性的基本类型封装类AtomicBoolean,实现区别于synchronized同步锁的一种乐观锁,线程不会阻塞,不涉及上下文切换,具有性能开销小等优点。示例代码如下:

long startTime = System.currentTimeMillis();

AtomicBoolean state=new AtomicBoolean(false);

//X秒内不断重试调用compareAndSet方法修改内存

while ((startTime+X)>= System.currentTimeMillis()){

//预期值false,更新值true

if(!state.get()&&state.compareAndSet(false,true)){

//修改内存值成功

try{

#获取分布式锁

//将内存值重新修改为false

state.compareAndSet(true,false);

}catch(Exception ex){

state.compareAndSet(true,false);

}

}

}

各个应用系统的线程通过在一定时间内不断重试修改内存值,如果修改成功,才可以继续获取分布式锁,以解决Redis服务器的压力和流量问题。

2.3 消息队列

消息队列是一种异步传输模式,其主要核心优点为以下3点:业务解耦、通信异步、流量削峰[12,13],因此可应用于很多高并发场景。目前主流的消息队列中间件有Kafka、RabbitMQ、ActiveMQ及Microsoft MSMQ[12]等,其中RabbitMQ是一个开源的AMQP实现,支持多种客户端,总共有6种工作模式,用于在分布式或集群系统中存储转发消息,具有较好的易用性、扩展性、高可用性。架构使用RabbitMQ实现数据库与Redis缓存数据的同步,可以根据实际高并发场景进行判断选择合适的通信模式以及生产者与消费者的对应关系。下面以RabbitMQ的路由模式为例,具体实现的路由模式结构如图4所示。

图4 路由模式结构

按照实际业务,以明显的类别划分,声明一个交换机,4个消息队列,创建4个消费者分别监听4个消息队列,应用系统数量对应生产者数量。当系统接收到高并发请求资源时,会使用Redis分布式锁确保所有系统产生的线程同步执行访问共享资源,保证共享资源的安全性、一致性。释放锁后,再将处理完成的数据更新到Redis中,Redis返回结果后,系统会将数据转化为JSON格式,按照消息路由键,确保同个客户端请求的数据在同个消息队列中,能够在消费者进行数据增删改时按照先后顺序执行,保证系统公平性。设置路由键后将数据发送到交换机中,交换机会根据路由规则将不同类别的数据发送到绑定的对应消息队列中,每个消息队列都有对应的一个消费者,消费者会监听对应的消息队列,监听到消息后会进行JSON格式数据的解析,再将数据同步到数据库中。利用RabbitMQ消息队列,进行强弱依赖梳理分析,将数据同步到数据库的操作异步化,以解决数据库的高并发压力。

由图1~3可知,3种水灰比的试件,经过冻融0次、25次和50次后,其峰值应力均随着应变加载速率的增加而增加。

2.4 负载均衡

应对高并发访问,负载均衡技术是构建高并发Web系统有效的方法[14]。常用负载均衡方法有:①DNS负载均衡;②NAT负载均衡;③软件、硬件负载均衡;④反向代理负载均衡。①方法无法获知各服务器差异,④方法在流量过大时服务器本身容易成为瓶颈,③方法中的软件方式是通过在服务器上安装软件实现负载均衡,如LVS、PCL-SIS等。LVS是Linux虚拟服务器,从操作系统层面考虑,架构访问层采用LVS及Nginx集群组成,分别在IP层和应用层进行请求的负载均衡转发。通过OSI七层模型结构可知,在IP层实现请求的负载均衡比在应用层更加高效,减少了上层的网络调用及分发。架构采用LVS的DR模式,由LVS作为系统整个流量的入口,采用IP负载均衡技术和基于内容请求分发技术,将请求均衡地转发到不同的Nginx服务器上,但是只负责接收请求,结果由Nginx服务器直接返回,避免LVS成为网络流量瓶颈。由多个客户端发送的高并发请求,会先进行DNS寻址,找到对应机房的公网IP,由LVS接入,再将请求均衡的转发到Nginx服务器,再由Nginx服务器将请求均衡的转发到应用服务器,有利于Nginx服务器的扩展,可将整个系统进行水平拓展,加入更多的硬件支持,提高并发请求的处理效率。

3 测试与分析

为了验证架构设计的有效性,选择具有高并发资源竞争需求的选课场景为例,课程的额定容量就是共享资源,学生选课的过程其实就是抢占额定容量的过程。把案例分别部署在普通集群架构和本文提出的集群架构,基于内网同个网段搭建实验平台环境。普通集群采用Nginx技术实现负载均衡,使用了基于表记录的新增与删除操作,利用字段唯一性约束的方式实现数据库分布式锁以及使用Redis实现会话共享。考虑到测试的公平性以及简化测试复杂度,保证两个集群的物理配置以及应用配置参数一致,其中设置Nginx主要参数 keepAlivetimeout、fastcgi_connect_timeout、fastcgi_send_timeout、fastcgi_read_timeout均为8000,以服务器配置为准分配权重,设置 tomcat主要参数connectionTimeout为80 000,maxConnections为80 000,maxThreads为8000,minSpareThreads为100,maxIdleTime为6000。在测试中本文集群排除了LVS,直接以Nginx服务器作为请求负载均衡以及Redis单机方式完成整个测试流程。没有引入LVS的原因是本次实验是以抢课业务为测试场景,LVS的加入只是为了保障Nginx容错性,且目前的并发数实在难以使其出现宕机情况,故剔除LVS的引入,选择直接利用Nginx负载均衡、转发的特性,对深层次的服务进行并发测试,更能获取到架构内部整体的性能指标。

普通集群一共用了5个节点,其中节点2、3作为Tomcat服务节点,其余的分别为节点1(Nginx节点)、节点5(Mysql节点)、节点4(Redis节点)。本文集群与普通集群的区别是在节点4加入了RabbitMq,节点4 作为数据缓存、消息队列和分布式锁节点。两个集群的各节点配置和容器版本等见表1。

表1 服务器节点配置

测试过程采用JMeter测试工具进行性能数据采集,通过不断调高并发数量得到的各项性能指标值见表2和表3。测试过程中通过服务代理的方式监控节点机器,实时抓取各节点的资源使用情况,并重点记录了核心节点的资源使用率,具体见表4和表5,列表头的1-cpu表示节点1的CPU资源使用特征。由于篇幅所限,只罗列比较关键的数据。

表2 普通集群架构指标值

表3 本文集群架构指标值

表4 普通集群下的节点表现/%

表5 本文集群下的节点表现/%

根据不同并发数测试得到的数据,转换成两种集群架构的TPS和响应时间变化曲线,分别如图5、图6所示,并且将相应的节点表现转换为曲线,如图7、图8所示。

图5 TPS变化曲线

图6 响应时间变化曲线

图7 Memory变化曲线

图8 CPU变化曲线

此次测试中并未针对Tomcat容器、数据库连接池等进行过多的配置参数优化,相信后续加强对容器调优,架构的性能还有一定的上升空间。

4 结束语

本文从多角度挖掘高并发请求的痛点,从数据缓存、分布式锁、消息队列、负载均衡4个方面进行分析与研究,提出了一种适合高并发多线程竞争共享资源场景的高性能系统通用架构。架构基于Redis集群实现数据缓存,避免直击数据库;拦截失败请求,提高系统吞吐量;采用分布式锁,在系统垂直或水平扩展的情况下,保证同一时间的一个线程获取到锁,确保共享资源的安全性和一致性,同时采用基于cas算法的乐观锁方式避免Redis服务器与应用服务器之间交互流量过高导致服务器压力过大;借助消息队列组件进行异步处理操作,降低数据库服务陷入堵塞的风险;从LVS调度转发解决Nginx负载均衡的单点故障。本文架构面临流量冲击仍可维持服务高可用,组件服务皆可纵向扩展,具有广泛的通用性,可适用于高校抢课、高峰订票、商品秒杀等典型场景,对于构建高并发应用具有一定参考价值。

猜你喜欢
线程队列消息
基于C#线程实验探究
队列里的小秘密
基于多队列切换的SDN拥塞控制*
一张图看5G消息
基于国产化环境的线程池模型研究与实现
线程池调度对服务器性能影响的研究*
在队列里
丰田加速驶入自动驾驶队列
消息
消息