本文主要是总结自己做web服务端开发以来关于web服务系统的架构设计开发经验。文中观点属于自己的理解,欢迎交流探讨,场景主要是基于自己开发过的系统,同事朋友遇到的问题交流,学习过的案例设计等。
关键字:多端高访问,分流,限速,并发,异步, 非阻塞I/O,缓存,动静资源分离(CDN),负载均衡,分久必合,合久必分 ......
多端高访问:服务面向多个不同的客户端与用户,随着业务膨胀,访问量越来越大,并发访问量也会越来越大。 分流:随着请求的增多,如果把所有的请求都集中到一台机器处理,是不现实的,一来造成机器的处理压力,二来容易形成单点故障,可用性脆弱。所以采用分而治之,引入负载均衡技术是必备的工序之一。通过分流,将处理压力分流道各个real-server进行处理。目前流行的负载均衡技术有两种:nginx和LVS,两者的应用场景与原理机制不同,各有各的使用场景,使用时根据自身需要进行有机组合。主要区别如下:
1. nginx是一款反向代理服务器软件,工作于OSI网络模型中的第7层-应用层。通过upstream配置的real-server,将请求转发到real-server处理,并接受real-server的返回内容,由balance-server响应请求的返回。
从示意图中也看到,请求的接受与响应都由balance-server处理,所以对于服务器的I/O性能,cpu要求较高。与LVS相比,nginx对于网络的稳定性依赖较小,而且也不会对请求-响应的数据包进行二次修改。
2. LVS工作于OSI网络模型的第4层-传输层。基本原理在于使用IP负载均衡技术在传输过程中对[请求-响应]报文数据包做修改(改写源ip或目标ip或mac地址)达到分发数据的目的。基本介绍见 - http://www.linuxvirtualserver.org/zh/lvs1.html 。对于网络的稳定性要求较高。
3. OSI网络七层模型介绍 - https://zh.wikipedia.org/wiki/OSI%E6%A8%A1%E5%9E%8B
重要的服务接口有时会受到DDoS攻击。通过肉机构造大量合法的请求占用大量网络资源,使服务器过载无法处理正常的请求,达到瘫痪网络的目的。 攻击者主要利用tcp连接的3次握手过程
,client向server发送syn后,使server处于syn状态,client便断开,但server需要等待一定的默认时间后才会断开这个连接,这样,攻击者client就可以把server的syn连接的队列耗尽,让正常的连接请求不能处理。[其他介绍--> 酷壳]。可以通过
netstat -t
监控服务器的网络情况,是否大量处于SYN状态。解决措施有如下两种方案:
1. 运维层的对抗:这种攻击从后台服务端层面上来讲,能做的事情比较少,主要是看运维层的对抗策略,可以用上防火墙的只能iptables拦截。
2. 服务器的TCP/IP协议栈的调优:
/etc/sysctl.conf 配置文件中的 net.ipv4.tcp_* 参数
, 例如可以减少
tcp_synack_retries
重试次数,增大
tcp_max_syn_backlog
连接数,
tcp_abort_on_overflow
处理不过来干脆就直接拒绝连接 等调优参数的修改。
讲完流量攻击,接下来继续讨论对于正常请求到达后,real-server和web容器的设计处理策略。
静态资源文件与动态请求分离:面向前端的频繁请求,将一些静态资源文件(如js,图片,视频等)放到CDN,不仅可以提高资源文件的传输速度与稳定性;还可以减少server的压力与I/O流量。只有那些经常变化的动态请求才经由real-server和web容器处理返回,达到动静分离的效果。
并发,限速:大量请求情况下的折中处理策略,主要是起到对后端服务器的一些保护作用,防止大量请求涌入,服务器雪崩的情况。
1. client-server相互配合,接口拆分:在访问峰值高的时候,对服务器的处理能力有一定的预判,则需要在客户端进行一定的策略控制后才向服务器发起真正的请求,但对正常用户来讲是正常的,在视图层变现为一种正常的交互结果。举例如下:
1.1 游戏表情系统-接口拆分:yy-im或yy频道每次打开聊天输入框的时候,都需要向服务端请求一次游戏表情的版本信息与本地版本做比较,频道打开次数频繁,大批用户进入频道时,容易造成访问拥塞。而表情版本更新周期久,所以可以采用拆解的方式:后台将版本信息写入到一个cdn文件,在后台操作需要进行换版时更新覆盖这个cdn文件即可。客户端先读取这个cdn文件进行本地匹配之后再决定需不需要服务器发起必要的请求。
1.2 微信摇一摇红包案例:在摇一摇红包高峰期,app端可以判断下用户的操作频率,加入随机的排队策略,对于过于频繁的操作,可以在app端就拦掉,对用户提示摇不到,这样真正到达服务器的请求就减掉了很多,又不影响产品的交互使用。
2. 服务器软件的限速:这里主要指的是利用nginx可以对ip并发限速的策略,让单个ip在一定时间内只能有效访问N次服务接口,这样对正常用户的影响性很小,因为用户的正常交互是需要理解点击的,在一定时限内达到总的N次接口访问可能性微乎其微。相关配置参考 limit_req, limit_conn 。
3. 单用户的限速:第2种方式nginx只能限制到单个ip层面的,那么如果是用户对于某个服务接口的并发限制如何控制呢?我们可以考虑在代码层面加个程序逻辑锁,在拦截器中编写利用redis的原子性操作setnx(${url-userid},"val") 成功后才允许往下执行业务逻辑,并设置过期时间expire(当然,这个可以做成配置性的)。
4. 业务执行的限速:第3种方式能限制到单个请求req_url.do的用户并发限制,但如果有多个请求入口在实际业务代码执行过程中调用到同一个服务方法service.betXXX(userid, betId, xxx);
例如要求同一个用户不能对同一场赛事下注两次,这时候可以参照第3种方式,选择方法参数中的多个参数作为unique_key, 同样利用redis的setnx操作: setnx(key=(arg1+argx+...+argn), val)-success ; bizz_funtion_operation() ; del key ;
以达到目的。
缓存:缓存对于web系统的架构设计中的作用不言而喻。在开发过程中要多思考,哪些该用缓存,采取怎样的缓存策略与读取策略以及数据同步问题。下面主要分3个方面讲述:
1. 代理软件的代理缓存:采用nginx的proxy_cache功能配置。主要是针对那种变动性不频繁,允许存在一定的延迟性的数据,可以交由代理服务器做一定的缓存策略,减缓后端web容器的处理压力。举例:对于平台的游戏列表信息以及游戏的开服列表信息获取接口,传输信息量大,如果每次的前端请求都要到web容器端获取信息并返回,而实际上这些信息的变更性不频繁,短时间内的延迟是允许的。那么作为折中方案就可以采用nginx的proxy_cache配置功能,如果nginx的proxy_cache缓存未失效,则直接由nginx返回,无需走服务端web容器处理了。
# nginx proxy_cache conf demo
proxy_cache_path /var/cache/nginx_cache levels=1:2 keys_zone=gscache:10m inactive=5m max_size=2m;
location ~ /a/b.do$ {
if ($args ~* "(.*)&csrf-token=[^&]+(&?.*)"){ # 过滤掉无关的非必要参数
set $new_var "$1$2";
rewrite /a/b.do /a/b.do?$new_var?;
}
proxy_cache gscache;
proxy_cache_valid 200 304 5m;
proxy_cache_valid 301 302 1m;
proxy_cache_valid any 1m;
proxy_cache_key $host$uri$is_args$args;
proxy_cache_methods GET HEAD POST;
# ............
}
2. NoSQL缓存应用:使用redis常用的数据结构来存储具体需求的热点数据。防止所有的缓存数据写到单个实例,可对数据的用途与作用性做分类,写入相对应的redis实例。而对于用户的缓存数据,可采取类似数据库分表的设计概念,将用户 Math.abs(userid.hashcode())%total_n
映射到对应的操作实例,减缓单个实例的读写与存储压力。
3. JVM内存缓存:对于变更频率极低(后台配置修改时才需要更新)的频繁需要使用的数据,但在程序中的其他服务中又会经常获取使用,例如活动平台中的活动列表配置信息(后台手动配置修改时才变动,但使用频繁)。如果每次需要时都从redis或者mysql中读取,那系统的服务性能就会大大降低了。所以可以采取这样的策略:在容器启动的过程中,同时将数据加载到机器jvm内存,读取时直接从内存获取,效率更高。但在这里有个需要注意的地方就是多机数据同步问题,目前采取的策略是:1. 每台机器有个固定周期的定时任务在运行,专门用来刷新缓存的jvm数据;2. 数据变动时,采用redis的发布订阅模式,每台机器收到通知后,手动再次从mysql获取最新数据更新jvm数据。
MySQL数据库设计:数据库设计主要是根据需求进行合理的分库,分表,归档,主副表拆分,查询索引设计等。共同点都是考虑如何提高响应性,减少单库表的存储压力,提高数据检索的速度以便于信息检索查找与维护。
异步,队列,多线程工作模式:对于处理周期长,又需要依赖外部回调结果的服务,采取异步回调,队列的工作模式,对于提高服务的响应性是必备的工序之一。案例 [奖品订单发放流程]:
1. 异步与队列:可以使得一些比较复杂服务事项处理步骤进行解耦拆解,提高服务的吞吐处理能力,因为需要调用第三方外部接口服务,异步也可以避免因为第三方外部接口异常拖垮自身服务的响应。(同步的话有可能造成这样的情况,一旦N多请求进来全部阻塞在等待第三方的返回,若是第三方接口挂了,那么就会造成自身web容器处理请求的线程阻塞,线程池耗光,无法再处理别的订单请求)
2. 如果遇到第1中提到的有些必须要同步处理的服务接口(例如活动平台的任务完成接口,任务的完成条件需要依赖外部的服务接口,比如判断用户在充值平台的充值金额是否达到一定额度,在用户调用完成任务接口时必须同时判断金额以便后续决定能否生成奖励订单),那么对于这种必须就得同步处理的,我们如何尽量避免上述提到的避免自身服务被拖垮呢?可以引入Spring AOP与java Annotation 注解的方式,设置最多能同时调用外部服务的线程数,开启准入原则以达到保护作用。
@MaxThread(name="outsideDealMethod_name", max="${max_dealing_thread_num}")
public void outsideDealMethod(Object ... params)
{
// ....... actual code exec
}
3. 多线程经常用于处理周期性的重复任务,或者需要循环不断定时处理的事项。例如隔一段时间扫描订单,处理未发放的订单,或者处理堆积的队列等。关于java 并发与多线程的知识,以及该选择哪种线程执行模式(用java.util.concurrent并发包的Executor框架还是其他形式),可以自己获取资料学习,根据场景做响应的选择。相关链接 : Java并发编程实战 , Executor任务执行框架层次结构关系 。
其他:根据业务的重要性,为进一步提高可用性与稳定性,还可以在运维层面下功夫做相应的策略。
1. balance-server加机器做双机互为主备架构,防止balance-server机器一旦挂掉整个服务就歇菜的情况。
2. 数据源在运维层做主从架构设计:包括redis和mysql等。这样不仅可以做数据的备份,还可以做主写从读的读写分离设计。面向前端请求的读写主要利用主库,而后台运行的一些统计信息可以在从库读取进行统计,缓解主库的压力。
3. 运维层做双机房灾备部署:这种主要是核心的营收系统,影响部门收入的才会做双机房的灾备部署,当一个机房的网络环境或其他因素导致不可用,无法对外提供服务时,立刻切到另一个备用机房,降低损失。
最后,系统架构设计都是基于现实业务压力推动,合适够用就好,避免过度设计,没有一成不变的架构,都是随着业务的演进一步一步地完善,在演变过程中存在着各种因素综合后的取舍。