http://mp.weixin.qq.com/s/8iaO_uW0V8MIpLdg3kmd8g
分类:架构
记一次JavaWeb网站技术架构总结
题记
工作也有几多年了,无论是身边遇到的还是耳间闻到的,多多少少也积攒了自己的一些经验和思考,当然,博主并没有太多接触高大上的分布式架构实践,相对比较零碎,随时补充(附带架构装逼词汇)。
俗话说的好,冰冻三尺非一日之寒,滴水穿石非一日之功,罗马也不是一天就建成的,当然对于我们开发人员来说,一个好的架构也不是一蹴而就的。
初始搭建
开始的开始,就是各种框架一搭,然后扔到Tomcat容器中跑就是了,这时候我们的文件,数据库,应用都在一个服务器上。
服务分离
随着系统的的上线,用户量也会逐步上升,很明显一台服务器已经满足不了系统的负载,这时候,我们就要在服务器还没有超载的时候,提前做好准备。
由于我们是单体架构,优化架构在短时间内是不现实的,增加机器是一个不错的选择。这时候,我们可能要把应用和数据库服务单独部署,如果有条件也可以把文件服务器单独部署。
反向代理
为了提升服务处理能力,我们在Tomcat容器前加一个代理服务器,我一般使用Nginx,当然你如果更熟悉apache也未尝不可。
用户的请求发送给反向代理,然后反向代理把请求转发到后端的服务器。
严格意义上来说,Nginx是属于web服务器,一般处理静态html、css、js请求,而Tomcat属于web容器,专门处理JSP请求,当然Tomcat也是支持html的,只是效果没Nginx好而已。
反向代理的优势,如下:
- 隐藏真实后端服务
- 负载均衡集群
- 高可用集群
- 缓存静态内容实现动静分离
- 安全限流
- 静态文件压缩
- 解决多个服务跨域问题
- 合并静态请求(HTTP/2.0后已经被弱化)
- 防火墙
- SSL以及http2
动静分离
基于以上Nginx反向代理,我们还可以实现动静分离,静态请求如html、css、js等请求交给Nginx处理,动态请求分发给后端Tomcat处理。
Nginx 升级到1.9.5+可以开启HTTP/2.0时代,加速网站访问。
当然,如果公司不差钱,CDN也是一个不错的选择。
服务拆分
在这分布式微服务已经普遍流行的年代,其实我们没必要踩过多的坑,就很容易进行拆分。市面上已经有相对比较成熟的技术,比如阿里开源的Dubbo(官方明确表示已经开始维护了),spring家族的spring cloud,当然具体如何去实施,无论是技术还是业务方面都要有很好的把控。
Dubbo
SpringCloud
- 服务发现——Netflix Eureka
- 客服端负载均衡——Netflix Ribbon
- 断路器——Netflix Hystrix
- 服务网关——Netflix Zuul
- 分布式配置——Spring Cloud Config
微服务与轻量级通信
- 同步通信和异步通信
- 远程调用RPC
- REST
- 消息队列
持续集成部署
服务拆分以后,随着而来的就是持续集成部署,你可能会用到以下工具。
Docker、Jenkins、Git、Maven
图片源于网络,基本拓扑结构如下所示:
整个持续集成平台架构演进到如下图所示:
服务集群
Linux集群主要分成三大类( 高可用集群, 负载均衡集群,科学计算集群)。其实,我们最常见的也是生产中最常接触到的就是负载均衡集群。
负载均衡实现
- DNS负载均衡,一般域名注册商的dns服务器不支持,但博主用的阿里云解析已经支持
- 四层负载均衡(F5、LVS),工作在TCP协议下
- 七层负载均衡(Nginx、haproxy),工作在Http协议下
分布式session
大家都知道,服务一般分为有状态和无状态,而分布式sessoion就是针对有状态的服务。
分布式Session的几种实现方式
- 基于数据库的Session共享
- 基于resin/tomcat web容器本身的session复制机制
- 基于oscache/Redis/memcached 进行 session 共享。
- 基于cookie 进行session共享
分布式Session的几种管理方式
- Session Replication 方式管理 (即session复制)
简介:将一台机器上的Session数据广播复制到集群中其余机器上
使用场景:机器较少,网络流量较小
优点:实现简单、配置较少、当网络中有机器Down掉时不影响用户访问
缺点:广播式复制到其余机器有一定廷时,带来一定网络开销 - Session Sticky 方式管理
简介:即粘性Session、当用户访问集群中某台机器后,强制指定后续所有请求均落到此机器上
使用场景:机器数适中、对稳定性要求不是非常苛刻
优点:实现简单、配置方便、没有额外网络开销
缺点:网络中有机器Down掉时、用户Session会丢失、容易造成单点故障 - 缓存集中式管理
简介:将Session存入分布式缓存集群中的某台机器上,当用户访问不同节点时先从缓存中拿Session信息
使用场景:集群中机器数多、网络环境复杂
优点:可靠性好
缺点:实现复杂、稳定性依赖于缓存的稳定性、Session信息放入缓存时要有合理的策略写入
目前生产中使用到的
- 基于tomcat配置实现的MemCache缓存管理session实现(麻烦)
- 基于OsCache和shiro组播的方式实现(网络影响)
- 基于spring-session+redis实现的(最适合)
负载均衡策略
负载均衡策略的优劣及其实现的难易程度有两个关键因素:一、负载均衡算法,二、对网络系统状况的检测方式和能力。
1、rr 轮询调度算法。顾名思义,轮询分发请求。
优点:实现简单
缺点:不考虑每台服务器的处理能力
2、wrr 加权调度算法。我们给每个服务器设置权值weight,负载均衡调度器根据权值调度服务器,服务器被调用的次数跟权值成正比。
优点:考虑了服务器处理能力的不同
3、sh 原地址散列:提取用户IP,根据散列函数得出一个key,再根据静态映射表,查处对应的value,即目标服务器IP。过目标机器超负荷,则返回空。
4、dh 目标地址散列:同上,只是现在提取的是目标地址的IP来做哈希。
优点:以上两种算法的都能实现同一个用户访问同一个服务器。
5、lc 最少连接。优先把请求转发给连接数少的服务器。
优点:使得集群中各个服务器的负载更加均匀。
6、wlc 加权最少连接。在lc的基础上,为每台服务器加上权值。算法为:(活动连接数*256+非活动连接数)÷权重 ,计算出来的值小的服务器优先被选择。
优点:可以根据服务器的能力分配请求。
7、sed 最短期望延迟。其实sed跟wlc类似,区别是不考虑非活动连接数。算法为:(活动连接数+1)*256÷权重,同样计算出来的值小的服务器优先被选择。
8、nq 永不排队。改进的sed算法。我们想一下什么情况下才能“永不排队”,那就是服务器的连接数为0的时候,那么假如有服务器连接数为0,均衡器直接把请求转发给它,无需经过sed的计算。
9、LBLC 基于局部性的最少连接。均衡器根据请求的目的IP地址,找出该IP地址最近被使用的服务器,把请求转发之,若该服务器超载,最采用最少连接数算法。
10、LBLCR 带复制的基于局部性的最少连接。均衡器根据请求的目的IP地址,找出该IP地址最近使用的“服务器组”,注意,并不是具体某个服务器,然后采用最少连接数从该组中挑出具体的某台服务器出来,把请求转发之。若该服务器超载,那么根据最少连接数算法,在集群的非本服务器组的服务器中,找出一台服务器出来,加入本服务器组,然后把请求转发之。
读写分离
MySql主从配置,读写分离并引入中间件,开源的MyCat,阿里的DRDS都是不错的选择。
如果是对高可用要求比较高,但是又没有相应的技术保障,建议使用阿里云的RDS或者Redis相关数据库,省事省力又省钱。
全文检索
如果有搜索业务需求,引入solr或者elasticsearch也是一个不错的选择,不要什么都塞进关系型数据库。
缓存优化
引入缓存无非是为了减轻后端数据库服务的压力,防止其”罢工”。
常见的缓存服务有,Ehcache、OsCache、MemCache、Redis,当然这些都是主流经得起考验的缓存技术实现,特别是Redis已大规模运用于分布式集群服务中,并证明了自己优越的性能。
消息队列
异步通知:比如短信验证,邮件验证这些非实时反馈性的逻辑操作。
流量削锋:应该是消息队列中的常用场景,一般在秒杀或团抢活动中使用广泛。
日志处理:系统中日志是必不可少的,但是如何去处理高并发下的日志确是一个技术活,一不小心可能会压垮整个服务。工作中我们常用到的开源日志ELK,为嘛中间会加一个Kafka或者redis就是这么一个道理(一群人涌入和排队进的区别)。
消息通讯:点对点通信(个人对个人)或发布订阅模式(聊天室)。
日志服务
消息队列中提到的ELK开源日志组间对于中小型创业供公司是一个不错的选择。
安全优化
以上种种,没有安全做保证可能都会归于零。
- 阿里云的VPN虚拟专有网络以及安全组配置
- 自建机房的话,要自行配置防火墙安全策略
- 相关服务访问,比如Mysql、Redis、Solr等如果没有特殊需求尽量使用内网访问并设置鉴权
- 尽量使用代理服务器,不要对外开放过多的端口
- https配合HTTP/2.0也是个不错的选择
架构装逼必备词汇
高可用
- 负载均衡(负载均衡算法)
- 反向代理
- 服务隔离
- 服务限流
- 服务降级(自动优雅降级)
- 失效转移
- 超时重试
- 回滚机制
高并发
- 应用缓存
- HTTP缓存
- 多级缓存
- 分布式缓存
- 连接池
- 异步并发
分布式事务
- 二阶段提交(强一致)
- 三阶段提交(强一致)
- 消息中间件(最终一致性),推荐阿里的RocketMQ
队列
- 任务队列
- 消息队列
- 请求队列
扩容
- 单体垂直扩容
- 单体水平扩容
- 应用拆分
- 数据库拆分
- 数据库分库分表
- 数据异构
- 分布式任务
网络安全
- SQL注入
- XSS攻击
- CSRF攻击
- 拒绝服务(DoS,Denial of Service)攻击
架构装逼必备工具
操作系统
Linux(必备)、某软的
负载均衡
DNS、F5、LVS、Nginx、HAproxy、负载均衡SLB(阿里云)
分布式框架
Dubbo、Motan、Spring-Could
数据库中间件
DRDS (阿里云)、Mycat、360 Atlas、Cobar (不维护了)
消息队列
RabbitMQ、ZeroMQ、Redis、ActiveMQ、Kafka
注册中心
Zookeeper、Redis
缓存
Redis、Oscache、Memcache、Ehcache
集成部署
Docker、Jenkins、Git、Maven
存储
OSS、NFS、FastDFS、MogileFS
数据库
MySql、Redis、MongoDB、PostgreSQL、Memcache、HBase
网络
专用网络VPC、弹性公网IP、CDN
如何打造类似数据虫巢官网系列教程之二:爬虫是怎么炼成的
文·blogchong
本文接上一篇《如何打造类似数据虫巢官网系列教程之一:介绍已经准备工作》,不清楚前面剧情的童鞋可以先看看。
这篇文章重点在于解决“数据虫巢官网”的底层数据问题,即那些分析数据的原始数据的来源。
结论很明显,当然是爬过来的,所以这篇我们将重点讲讲如何进行数据爬取,并且以虫巢官网的底层数据爬取代码为例子进行讲解。
当然,其中会一些常规的防爬机制破解以及应对的话题,整体来说这篇会偏重互联网公开数据集的爬取,即爬虫。
此外,整个数据虫巢官网的站点源代码,目前已经整理到github上咯,先放上github的链接:github.com/blogchong/mite8-com。
这是一个完整的项目,这部分代码包括以下部分:
1 整个数据虫巢数据处理后端框架。
2 前端每个页面JSP代码部分,以及涉及数据可视化渲染部分。
3 几个重点数据源的爬虫逻辑,以及定期更新爬虫数据的入口逻辑。
4 数据处理中涉及到的NLP部分,有几个侧重点,包括重构加工的分词工具,以及简单的情感分析,并且提供了分词的一个工具接口。
PS:如果感兴趣,clone之前别忘了给个star,哈哈。
爬虫框架 – Webcollector
回到主题,说到爬虫,由于我之前对于Python并不是很熟悉,而Java则是我的拿手好戏,并且目前市面上封装的爬虫工具很多,所以,我的考虑就是Java语言封装的Webcollector。
简单说一下这个框架,大伙儿感兴趣的可以去开源中国搜一下他的主页,对于Java不熟悉的朋友,其实也无所谓的,使用其他的Python框架一样是可以的,那这部分关于框架这块的就可以略过啦。
Webcollector支持各种自定义的遍历策略,这种在于路径规则不明确的时候使用是很有用的,比如我当时在爬取各大主流招聘网站的JD数据时,就通过这种模式做的,但如果是目的明确的,其实就是按照自己的业务逻辑去固定路径一次性获取数据了。
Webcollector对于Cookie、请求头之类的信息,提供了设置接口,可以很方便的伪装成浏览器,以及登录状态去爬取数据。
Webcollector集成了传统的JDBC持久化策略,可以很方便的将爬取的数据进行MySQL落地,以及MongDB落地等。
使用上也很方面,集成在Maven中,并且更新还是蛮及时的,所以需要集成到自己的Java代码中,只需要引入Jar包即可开整。
除此之外,Webcollector内部封装了selenium,对于动态加载的JS数据来说,也可以很轻松的拿到相关的数据。
其实上面基本都是它的一些特性,对于新手来说都太模糊,这个框架最好的地方在于作者提供了大量的博客实例,来解释各种特性,以及各种简单的爬虫实例可供参考,简直就是初学者的福音。
具体不再多说,想了解更多的,可以搜索然后进入进行学习。
爬取数据
不同的网站对于数据的展现以及输出方式可能都有点不同,静态的网页数据是最好获取的,比如我之前爬取一些偏传统的招聘网站的数据,直接通过入口就可以拿到数据,基本不设防。
代码例子:
CrawlDatum crawlDatum = new CrawlDatum(listUrl).putMetaData(“method”, “POST”);
HttpRequest request = new HttpRequest(crawlDatum.getUrl());
request.setMethod(crawlDatum.getMetaData(“method”));
HttpResponse httpResponse = request.getResponse();
Page page = new Page(crawlDatum, httpResponse);
我们拿到了HttpResponse对象,并且封装成Page对象,通过Page对象提供的Html解析方法,进行数据拆解。
其实Page底层的实现依然是Jsoup,一种很常规的Html结构解析包,我们来看一下具体的使用:
page.select(“div[class=review-content clearfix]”).text()
这是一个很常见的解析过程语法,在page对象中查找class名为“review-content clearfix ”的div,并且调用text方法,将内容转换为String。
静态页面,基本上会上门两招就够了,访问页面数据,然后解析数据,将非结构化的数据转换为结构化数据,当然具体怎么入库,在Java里方式就很多了。
除了静态页面之外,还有其他形式的数据获取。
比如现在很流行的一种做法,那就是前后端进行分离,即后端数据由额外的请求进行获取,再通过前端进行异步渲染。
其实这种做法也是有理由的,因为后端数据的请求跟前端其他部分渲染效率是不同,所以一般做成异步请求,这样在整个页面在后端效率不高时不会造成整个页面等待,提升用户效率。
这个时候,你单纯的看页面源码已经不行啦,你需要会使用浏览器的元素审查,把这些异步请求的链接给逮出来。
我在做雾霾影响分析报告时,基础原始数据是京东的口罩购买数据,并且是评论数据,其评论就是异步加载获取的。

通过F12做元素审查,找到评论数据的真正调用接口,一般异步操作都是放到JS里,并且接口在命名上有一定的提示,如上图就是京东商品的评论数据接口。
大概链接长这样子:
https://sclub.jd.com/comment/productPageComments.action?productId=2582352&score=3&sortType=3&page=0&pageSize=10&isShadowSku=0&callback=fetchJSON_comment98vv47364

里头有控制翻页的参数,我们控制部分参数就可以愉快的获取到数据啦,我们再把callback参数去掉,就是实打实的JSON数据了,连清洗数据的活都省了。
除此之外,还有一个需要注意的点就是,控制访问频度,不管你是单机爬着玩也好,或者是工作大范围爬用代理池也好,频度是一个很基础的防爬机制。
具体的虫巢涉及的代码呢,我就不一一列出来了,这里列一下开源出来的代码,涉及到爬虫的部分,做个备注,有兴趣的可以去我github上clone下来,然后按下面的路径去分析分析逻辑,克隆完了记得给个star哟。
mite8-com开源项目涉及到爬虫的部分:
1 京东雾霾相关的爬虫逻辑:package com.mite8.Insight.jd_wumai;
2 电影《长城》相关的爬虫逻辑:package com.mite8.Insight.movie_great_wall;
3 政务舆情相关的爬虫逻辑:package com.mite8.jx.gz.dn.service; //service下对应的几个子目录,下面的utils,入口是OptXXX类。
防爬的一些机制,以及对应的破解之道
在这里再说一些玩爬虫时,会遇到的一些常见的防爬手段,以及对应的破解之道。
由于俺不是专业的爬虫,所以这部分这么完善的东西显然不是出自我之手,是我团队里爬虫大神在内部技术分享时总结的,俺只是个搬运工。
第一种,伪装成合法的浏览器
在一般情况下,我们会对请求头进行伪装,最重点的key就是user-agent,这部分信息就是浏览器的内核信息。
由于很多公司,甚至是大楼都是用同一个对外IP,所以单纯的使用频度进行防爬封锁,这种情况很容易造成误杀,这也是目标网站不愿意看到的情况。
但是这种情况下,一般不同的电脑其浏览器是不同的,包括内核版本等等,防爬时会分析这个user-agent是不是一样的,或者说非法的字符。
因为很多爬虫框架,或者进程方位URL时会有默认的标志,通过分析这个频度可以明显知道是不是机器在访问页面。
所以,我们通常会获取一批正常的user-agent做随机封装,去获取数据,这种措施会导致上面说的那种防爬机制时效。
第二种,IP频度封锁
在一个IP过于频繁的访问页面时,网站根据一定的判定策略,会判断这个IP是非法的机器,进行IP封锁,导致这个IP无法访问目标页面。
这个时候,我们可以控制访问频度避免被封,但很多时候我们爬取的量很大,控制频度很难完成任务,那么我们就需要使用代理池来做了。
通过代理池的IP,进行IP伪装,这样就破解了频度的控制。
通常代理池分免费与收费,一般免费的代理池都是被人用烂了的,里头的IP都是在各大主流网站的黑名单里。
最后,至于说每个网站的频度是什么样子的,以及控制力度(禁封几分钟,或者是一天等等),就需要自己多测试尝试了。
第三种,用户验证机制
用户验证,这是个很常见的东西,很多页面只有用户登录之后可以访问。
一般通常的做法都是cookie验证,所以,关键是我们如何获取这个cookie。
一次性爬取比较容易,直接把cookie帖进去,做访问即可,但是遇到自动化的时候,我们就需要研究用户登录的过程了,使用POST做表单提交,获取cookie,后面的流程就通啦。
第四种,验证码
很多操作是需要验证码才能下一步操作的,这个时候除了破解验证码无法可破。
不错对于简单的验证码,或者说自己技术犀利的话,写个图像识别的东东,做图像识别,识别验证码也行,但是,目前验证码设计的都很变态,详情参考12306,所以这个方法打折的厉害。
还有一种手段,购买付费的打码平台服务,直接完破之,就是费钱而已。
第五种,动态页面
所谓动态页面,即很多时候数据是通过js动态加载出来的,或者JS加密的,这个时候,直接访问是拿不到数据。
也有破解之道,使用JS引擎做JS解析,目前不管是Python的还是Java的,有不少这种引擎可以供调用。
最后一种方法,使用浏览器内核去访问这个链接,就跟真正的浏览器访问页面没有什么差别啦,Java中经典的selenium就是其中一种。
据闻,技术高端点的公司还有更变态的,通过机器学习来学习真实用户的访问轨迹,通过算法来判断这种访问轨迹是否是机器造成的,然后再做判断是否做禁封。
好吧,玩高深的爬虫,其实就一部防爬与反爬的斗争史,其乐无穷。
下一篇,接着话题,我们讲讲述云平台搭建,服务器部署,环境配置相关的东西:《如何打造类似数据虫巢官网系列教程之三:服务器》。
最后,再贴一遍,数据虫巢官网(www.mite8.com)的开源代码地址(可以随意fork、star 哈哈):github.com/blogchong/mite8-com。
相关阅读:
广告Time:
要不要一起探讨大数据的相关的话题,是不是想要跨界大数据,进一步了解、讨论mite-com的开源代码,欢迎加入“数据虫巢读者私密群”,=>>戳此进入。
十大经典排序算法
https://sort.hust.cc/
大型网站技术架构-入门梳理
罗列了大型网站架构涉及到的概念,附上了简单说明
前言
- 本文是对《大型网站架构设计》(李智慧 著)一书的梳理,类似文字版的“思维导图”
- 全文主要围绕“性能,可用性,伸缩性,扩展性,安全”这五个要素
- 性能,可用性,伸缩性这几个要素基本都涉及到应用服务器,缓存服务器,存储服务器这几个方面
概述
- 三个纬度:演化、模式、要素
- 五个要素: 性能,可用性,伸缩性,扩展性,安全
演化历程
图例可参考 大型网站架构演化历程:
- 初始阶段的网站架构:一台服务器,上面同时拥有应用程序,数据库,文件,等所有资源。例如 LAMP 架构
- 应用和数据服务分离:三台服务器(硬件资源各不相同),分别是应用服务器,文件服务器和数据库服务器
- 使用缓存改善网站性能:分为两种,缓存在应用服务器上的本地缓存和缓存在专门的分布式缓存服务器的远程缓存
- 使用应用服务器集群改善网站并发处理能力:通过负载均衡调度服务器来将访问请求分发到应用服务器集群中的任何一台机器
- 数据库读写分离:数据库采用主从热备,应用服务器在写数据时访问主数据库,主数据库通过主从复制机制将数据更新同步到从数据库。应用服务器使用专门的数据访问模块从而对应用透明
- 使用反向代理和 CDN 加速网站响应:这两者基本原理都是缓存。反向代理部署在网站的中心机房,CDN 部署在网络提供商的机房
- 使用分布式文件系统和分布式数据库系统:数据库拆分的最后手段,更常用的是业务分库
- 使用 NoSQL 和搜索引擎:对可伸缩的分布式有更好的支持
- 业务拆分:将整个网站业务拆分成不同的应用,每个应用独立部署维护,应用之间通过超链接建立联系/消息队列进行数据分发/访问同一数据存储系统
- 分布式服务:公共业务提取出来独立部署
演化的价值观
- 大型网站架构的核心价值是随网站所需灵活应对
- 驱动大型网站技术发展的主要力量是网站的业务发展
误区
- 一味追随大公司的解决方案
- 为了技术而技术
- 企图用技术解决所有问题
架构模式
模式的关键在于模式的可重复性
- 分层:横向切分
- 分割:纵向切分
- 分布式:分层和分割的主要目的是为了切分后的模块便于分布式部署。常用方案:
- 分布式应用和服务
- 分布式静态资源
- 分布式数据和存储
- 分布式计算
- 分布式配置,分布式锁,分布式文件,等等
- 集群:多台服务器部署相同的应用构成一个集群,通过负载均衡设备共同对外提供服务
- 缓存:将数据放距离计算最近的位置加快处理速度,改善性能第一手段,可以加快访问速度,减小后端负载压力。使用缓存 两个前提条件 :1.数据访问热点不均衡;2.数据某时段内有效,不会很快过期
- CDN
- 反向代理
- 本地缓存
- 分布式缓存
- 异步:旨在系统解耦。异步架构是典型的消费者生产者模式,特性如下:
- 提高系统可用性
- 加快网站访问速度
- 消除并发访问高峰
- 冗余:实现高可用。数据库的冷备份和热备份
- 自动化:包括发布过程自动化,自动化代码管理,自动化测试,自动化安全检测,自动化部署,自动化监控,自动化报警,自动化失效转移,自动化失效恢复,自动化降级,自动化分配资源
- 安全:密码,手机校验码,加密,验证码,过滤,风险控制
核心要素
架构是“最高层次的规划,难以改变的规定”。主要关注五个要素:
- 性能
- 可用性(Availability)
- 伸缩性(Scalability)
- 扩展性(Extensibility)
- 安全性
架构
下面依次对这五个要素进行归纳
高性能
性能的测试指标主要有:
- 响应时间:指应用执行一个操作需要的时间
- 并发数:指系统能够同时处理请求的数目
- 吞吐量:指单位时间内系统处理的请求数量
- 性能计数器:描述服务器或者操作系统性能的一些数据指标
性能测试方法:
- 性能测试
- 负载测试
- 压力测试
- 稳定性测试
性能优化,根据网站分层架构,可以分为三大类:
- Web 前端性能优化
- 浏览器访问优化
- 减少 http 请求
- 使用浏览器缓存
- 启用压缩
- CSS 放在页面最上面,JavaScript 放在页面最下面
- 减少 Cookie 传输
- CDN 加速:本质是一个缓存,一般缓存静态资源
- 反向代理
- 保护网站安全
- 通过配置缓存功能加速 Web 请求
- 实现负载均衡
- 浏览器访问优化
- 应用服务器性能优化:主要手段有 缓存、集群、异步
- 分布式缓存(网站性能优化第一定律:优化考虑使用缓存优化性能)
- 异步操作(消息队列,削峰作用)
- 使用集群
- 代码优化
- 多线程(设计为无状态,使用局部对象,并发访问资源使用锁)
- 资源复用(单例,对象池)
- 数据结构
- 垃圾回收
- 存储服务器性能优化
- 机械硬盘 vs. 固态硬盘
- B+ 树 vs. LSM 树
- RAID vs. HDFS
高可用
- 高可用的网站架构:目的是保证服务器硬件故障时服务依然可用、数据依然保存并能够被访问,主要手段数据和服务的冗余备份及失效转移
- 高可用的应用:显著特点是应用的无状态性
- 通过负载均衡进行无状态服务的失效转移
- 应用服务器集群的 Session 管理
- Session 复制
- Session 绑定
- 利用 Cookie 记录 Session
- Session 服务器
- 高可用的服务:无状态的服务,可使用类似负载均衡的失效转移策略,此外还有如下策略
- 分级管理
- 超时设置
- 异步调用
- 服务降级
- 幂等性设计
- 高可用的数据:主要手段是数据备份和失效转移机制
- CAP 原理
- 数据一致性(Consisitency)
- 数据可用性(Availibility)
- 分区耐受性(Partition Tolerance)
- 数据备份
- 冷备:缺点是不能保证数据最终一致和数据可用性
- 热备:分为异步热备和同步热备
- 失效转移:由以下三部分组成
- 失效确认
- 访问转移
- 数据恢复
- CAP 原理
- 高可用网站的软件质量保证
- 网站发布
- 自动化测试
- 预发布验证
- 代码控制
- 主干开发、分支发布
- 分支开发、主干发布
- 自动化发布
- 灰度发布
- 网站运行监控
- 监控数据采集
- 用户行为日志采集(服务器端和客户端)
- 服务器性能监控
- 运行数据报告
- 监控管理
- 警报系统
- 失效转移
- 自动优雅降级
- 监控数据采集
伸缩性
大型网站的“大型”是指:
- 用户层面:大量用户及大量访问
- 功能方面:功能庞杂,产品众多
- 技术层面:网站需要部署大量的服务器
伸缩性的分为如下几个方面
- 网站架构的伸缩性设计
- 不同功能进行物理分离实现伸缩
- 纵向分离(分层后分离)
- 横向分离(业务分割后分离)
- 单一功能通过集群规模实现伸缩
- 不同功能进行物理分离实现伸缩
- 应用服务器集群的伸缩性设计
- HTTP 重定向负载均衡
- DNS 域名解析负载均衡
- 反向代理负载均衡(在 HTTP 协议层面,应用层负载均衡)
- IP 负载均衡(在内核进程完成数据分发)
- 数据链路层负载均衡(数据链路层修改 mac 地址,三角传输模式,LVS)
- 负载均衡算法
- 轮询(Round Robin, RR)
- 加权轮询(Weighted Round Robin, WRR)
- 随机(Random)
- 最少链接(Least Connections)
- 源地址散列(Source Hashing)
- 分布式缓存集群的伸缩性设计
- Memcached 分布式缓存集群的访问模型
- Memcached 客户端(包括 API,路由算法,服务器列表,通信模块)
- Memcached 服务器集群
- Memcached 分布式缓存集群的伸缩性挑战
- 分布式缓存的一致性 Hash 算法(一致性 Hash 环,虚拟层)
- Memcached 分布式缓存集群的访问模型
- 数据存储服务集群的伸缩性设计
- 关系数据库集群的伸缩性设计
- NoSQL 数据库的伸缩性设计
可扩展
系统架构设计层面的“开闭原则”
- 构建可扩展的网站架构
- 利用分布式消息队列降低耦合性
- 事件驱动架构(Event Driven Architecture)
- 分布式消息队列
- 利用分布式服务打造可复用的业务平台
- Web Service 与企业级分布式服务
- 大型网站分布式服务的特点
- 分布式服务框架设计(Thrift, Dubbo)
- 可扩展的数据结构(如 ColumnFamily 设计)
- 利用开放平台建设网站生态圈
网站的安全架构
XSS 攻击和 SQL 注入攻击是构成网站应用攻击最主要的两种手段,此外还包括 CSRF,Session 劫持等手段。
- 攻击与防御
- XSS 攻击:跨站点脚本攻击(Cross Site Script)
- 反射型
- 持久型
- XSS 防御手段
- 消毒(即对某些 html 危险字符转义)
- HttpOnly
- 注入攻击
- SQL 注入攻击
- OS 注入攻击
- 注入防御
- 避免被猜到数据库表结构信息
- 消毒
- 参数绑定
- CSRF 攻击:跨站点请求伪造(Cross Site Request Forgery)
- CSRF 防御:主要手段是识别请求者身份
- 表单 Token
- 验证码
- Referer Check
- 其他攻击和漏洞
- Error Code
- HTML 注释
- 文件上传
- 路径遍历
- Web 应用防火墙(ModSecurity)
- 网站安全漏洞扫描
- XSS 攻击:跨站点脚本攻击(Cross Site Script)
- 信息加密技术及密钥安全管理
- 单向散列加密:不同输入长度的信息通过散列计算得到固定长度的输出
- 不可逆,非明文
- 可加盐(salt)增加安全性
- 输入的微小变化会导致输出完全不同
- 对称加密:加密和解密使用同一个密钥
- 非对称加密
- 信息传输:公钥加密,私钥解密
- 数字签名:私钥加密,公钥解密
- 密钥安全管理:信息安全传输是靠密钥保证的,改善手段有:
- 把密钥和算法放在一个独立的服务器上
- 将加解密算法放在应用系统中,密钥放在独立服务器
- 单向散列加密:不同输入长度的信息通过散列计算得到固定长度的输出
- 信息过滤与反垃圾
- 文本匹配
- 分类算法
- 黑名单
亿级用户PC主站的PHP7升级实践
伴随业务的增长,系统压力也在不断增加,再加上机房机架趋于饱和,无法更加有效应对各种突发事件。在这样的情况下,PC主站升级为PHP 7,有哪些技术细节可以分享?
背景
新浪微博在2016年Q2季度公布月活跃用户(MAU)较上年同期增长33%,至2.82亿;日活跃用户(DAU)较上年同期增长36%,至1.26亿,总注册用户达8亿多。PC主站作为重要的流量入口,承载部分用户访问和流量落地,其中我们提供的部分服务(如:头条文章)承担全网所有流量。
随着业务的增长,系统压力也在不断的增加。峰值时,服务器Hits达10W+,CPU使用率也达到了80%,远超报警阈值。另外,当前机房的机架已趋于饱和,遇到突发事件,只能对非核心业务进行降低,挪用这些业务的服务器来进行临时扩容,这种方案只能算是一种临时方案,不能满足长久的业务增长需求。再加上一年一度的三节(圣诞、元旦、春节),系统需预留一定的冗余来应对,所以当前系统面临的问题非常严峻,解决系统压力的问题也迫在眉急。
面对当前的问题,我们内部也给出两套解决方案同步进行。
- 方案一:申请新机房,资源统一配置,实现弹性扩容。
- 方案二:对系统进行优化,对性能做进一步提升。
针对方案一,通过搭建与新机房之间的专线与之打通,高峰时,运用内部自研的混合云DCP平台,对所有资源进行调度管理,实现了真正意义上的弹性扩容。目前该方案已经在部分业务灰度运行,随时能对重点业务进行小流量测试。
针对方案二,系统层面,之前做过多次大范围的优化,比如:
- 将Apache升级至Nginx
- 应用框架升级至Yaf
- CPU计算密集型的逻辑扩展化
- 弃用smarty
- 并行化调用
优化效果非常明显,如果再从系统层面进行优化,性能可提升的空间非常有限。好在业界传出了两大福音,分别为HHVM和PHP7。
方案选型
在PHP7还未正式发布时,我们也研究过HHVM(HipHop Virtual Machine),关于HHVM更多细节,这里就不再赘述,可参考官方说明。下面对它提升性能的方式进行一个简单的介绍。
默认情况下,Zend引擎先将PHP源码编译为opcode,然后Zend解析引擎逐条执行。这里的opcode码,可以理解成C语言级的函数。而HHVM提升性能方式为替代Zend引擎将PHP代码转换成中间字节码(HHVM自己的中间字节码,通常称为中间语言),然后在运行时通过即时(JIT)编译器将这些字节码转换成x64的机器码,类似于Java的JVM。
HHVM为了达到最佳优化效果,需要将PHP的变量类型固定下来,而不是让编译器去猜测。Facebook的工程师们就定义一种Hack写法,进而来达到编译器优化的目的,写法类似如下:
<?hh class point { public float $x, $y; function __construct(float $x, float $y) { $this->x = $x; $this->y = $y; } }
通过前期的调研,如果使用HHVM解析器来优化现有业务代码,为了达到最佳的性能提升,必须对代码进行大量修改。另外,服务部署也比较复杂,有一定的维护成本,综合评估后,该方案我们也就不再考虑。
当然,PHP7的开发进展我们也一直在关注,通过官方测试数据以及内部自己测试,性能提升非常明显。
令人兴奋的是,在去年年底(2015年12月04日),官方终于正式发布了PHP7,并且对原生的代码几乎可以做到完全兼容,性能方面与PHP5比较能提升达一倍左右,和HHVM相比已经是不相上下。
无论从优化成本、风险控制,还是从性能提升上来看,选择PHP7无疑是我们的最佳方案。
系统现状以及升级风险
微博PC主站从2009年8月13日发布第一版开始,先后经历了6个大的版本,系统架构也随着需求的变化进行过多次重大调整。截止目前,系统部分架构如下。
从系统结构层面来看,系统分应用业务层、应用服务层,系统所依赖基础数据由平台服务层提供。
从服务部署层面来看,业务主要部署在三大服务集群,分别为Home池、Page池以及应用服务池。
为了提升系统性能,我们自研了一些PHP扩展,由于PHP5和PHP7底层差别太大,大部分Zend API接口都进行了调整,所有扩展都需要修改。
所以,将PHP5环境升级至PHP7过程中,主要面临如下风险:
- 使用了自研的PHP扩展,目前这些扩展只有PHP5版本,将这些扩展升级至PHP7,风险较大。
- PHP5与PHP7语法在某种程度上,多少还是存在一些兼容性的问题。由于涉及主站代码量庞大,业务逻辑分支复杂,很多测试范围仅仅通过人工测试是很难触达的,也将面临很多未知的风险。
- 软件新版本的发布,都会面临着一些未知的风险和版本缺陷。这些问题,是否能快速得到解决。
- 涉及服务池和项目较多,基础组件的升级对业务范围影响较大,升级期间出现的问题、定位会比较复杂。
对微博这种数亿用户级别的系统的基础组件进行升级,影响范围将非常之大,一旦某个环节考虑不周全,很有可能会出现比较严重的责任事故。
PHP7升级实践
1. 扩展升级
一些常用的扩展,在发布PHP7时,社区已经做了相应升级,如:Memcached、PHPRedis等。另外,微博使用的Yaf、Yar系列扩展,由于鸟哥(laruence)的支持,很早就全面支持了PHP7。对于这部分扩展,需要详细的测试以及现网灰度来进行保障。
PHP7中,很多常用的API接口都做了改变,例如HashTable API等。对于自研的PHP扩展,需要做升级,比如我们有个核心扩展,升级涉及到代码量达1500行左右。
新升级的扩展,刚开始也面临着各式各样的问题,我们主要通过官方给出的建议以及测试流程来保证其稳定可靠。
官方建议
- 在PHP7下编译你的扩展,编译错误与警告会告诉你绝大部分需要修改的地方。
- 在DEBUG模式下编译与调试你的扩展,在run-time你可以通过断言捕捉一些错误。你还可以看到内存泄露的情况。
测试流程
- 首先通过扩展所提供的单元测试来保证扩展功能的正确性。
- 其次通过大量的压力测试来验证其稳定性。
- 然后再通过业务代码的自动化测试来保证业务功能的可用性。
- 最后再通过现网流量灰度来确保最终的稳定可靠。
整体升级过程中,涉及到的修改比较多,以下只简单列举出一些参数变更的函数。
(1)addassocstringl参数4个改为了3个。
//PHP5 add_assoc_stringl(parray, key, value, value_len); //PHP7 add_assoc_stringl(parray, key, value);
(2)addnextindex_stringl 参数从3个改为了2个。
//PHP5 add_assoc_stringl(parray, key, value, value_len); //PHP7 add_assoc_stringl(parray, key, value);
(3)RETURN_STRINGL 参数从3个改为了2个。
//PHP5 RETURN_STRINGL(value, length,dup); //PHP7 RETURN_STRINGL(value, length);
(4)变量声明从堆上分配,改为栈上分配。
//PHP5 zval* sarray_l; ALLOC_INIT_ZVAL(sarray_l); array_init(sarray_l); //PHP7 zval sarray_l; array_init(&sarray_l);
(5)zendhashgetcurrentkey_ex参数从6个改为4个。
//PHP5 ZEND_API int ZEND_FASTCALL zend_hash_get_current_key_ex ( HashTable* ht, char** str_index, uint* str_length, ulong* num_index, zend_bool duplicate, HashPosition* pos); //PHP7 ZEND_API int ZEND_FASTCALL zend_hash_get_current_key_ex( const HashTable *ht, zend_string **str_index, zend_ulong *num_index, HashPosition *pos);
更详细的说明,可参考官方PHP7扩展迁移文档:https://wiki.PHP.net/PHPng-upgrading。
2. PHP代码升级
整体来讲,PHP7向前的兼容性正如官方所描述那样,能做到99%向前兼容,不需要做太多修改,但在整体迁移过程中,还是需要做一些兼容处理。
另外,在灰度期间,代码将同时运行于PHP5.4和PHP7环境,现网灰度前,我们首先对所有代码进行了兼容性修改,以便同一套代码能同时兼容两套环境,然后再按计划对相关服务进行现网灰度。
同时,对于PHP7的新特性,升级期间,也强调不允许被使用,否则代码与低版本环境的兼容性会存在问题。
接下来简单介绍下升级PHP7代码过程中,需要注意的地方。
(1)很多致命错误以及可恢复的致命错误,都被转换为异常来处理,这些异常继承自Error类,此类实现了 Throwable 接口。对未定义的函数进行调用,PHP5和PHP7环境下,都会出现致命错误。
undefine_function();
错误提示:
PHP Fatal error: Call to undefined function undefine_function() in /tmp/test.PHP on line 4
在PHP7环境下,这些致命的错误被转换为异常来处理,可以通过异常来进行捕获。
try { undefine_function(); } catch (Throwable $e) { echo $e; }
提示:
Error: Call to undefined function undefine_function() in /tmp/test.PHP:5 Stack trace: #0 {main}
(2)被0除,PHP 7 之前,被0除会导致一条 E_WARNING 并返回 false 。一个数字运算返回一个布尔值是没有意义的,PHP 7 会返回如下的 float 值之一。
- +INF
- -INF
- NAN
如下:
var_dump(42/0); // float(INF) + E_WARNING var_dump(-42/0); // float(-INF) + E_WARNING var_dump(0/0); // float(NAN) + E_WARNING
当使用取模运算符( % )的时候,PHP7会抛出一个 DivisionByZeroError 异常,PHP7之前,则抛出的是警告。
echo 42 % 0;
PHP5输出:
PHP Warning: Division by zero in /tmp/test.PHP on line 4
PHP7输出:
PHP Fatal error: Uncaught DivisionByZeroError: Modulo by zero in /tmp/test.PHP:4 Stack trace: # 0 {main} thrown in /tmp/test.PHP on line 4
PHP7环境下,可以捕获该异常:
try { echo 42 % 0; } catch (DivisionByZeroError $e) { echo $e->getMessage(); }
输出:
Modulo by zero
(3)pregreplace() 函数不再支持 “\e” (PREGREPLACEEVAL). 使用 pregreplace_callback() 替代。
$content = preg_replace("/#([^#]+)#/ies", "strip_tags('#\\1#')", $content);
PHP7:
$content = preg_replace_callback("/#([^#]+)#/is", "self::strip_str_tags", $content); public static function strip_str_tags($matches){ return "#".strip_tags($matches[1]).'#'; }
(4)以静态方式调用非静态方法。
class foo { function bar() { echo ‘I am not static!’; } } foo::bar();
以上代码PHP7会输出:
PHP Deprecated: Non-static method foo::bar() should not be called statically in /tmp/test.PHP on line 10 I am not static!
(5)E_STRICT 警告级别变更。
原有的 ESTRICT 警告都被迁移到其他级别。 ESTRICT 常量会被保留,所以调用 errorreporting(EALL|E_STRICT) 不会引发错误。
关于代码兼容PHP7,基本上是对代码的规范要求更严谨。以前写的不规范的地方,解析引擎只是输出NOTICE或者WARNING进行提示,不影响对代码上下文的执行,而到了PHP7,很有可能会直接抛出异常,中断上下文的执行。
如:对0取模运行时,PHP7之前,解析引擎只抛出警告进行提示,但到了PHP7则会抛出一个DivisionByZeroError异常,会中断整个流程的执行。
对于警告级别的变更,在升级灰度期间,一定要关注相关NOTICE或WARNING报错。PHP7之前的一个NOTICE或者WARNING到了PHP7,一些报警级变成致命错误或者抛出异常,一旦没有对相关代码进行优化处理,逻辑被触发,业务系统很容易因为抛出的异常没处理而导致系统挂掉。
以上只列举了PHP7部分新特性,也是我们在迁移代码时重点关注的一些点,更多细节可参考官方文档http://PHP.net/manual/zh/migration70.PHP。
3. 研发流程变更
一个需求的开发到上线,首先我们会通过统一的开发环境来完成功能开发,其次经过内网测试、仿真测试,这两个环境测试通过后基本保证了数据逻辑与功能方面没有问题。然后合并至主干分支,并将代码部署至预发环境,再经过一轮简单回归,确保合并代码没有问题。最后将代码发布至生产环境。
为了确保新编写的代码能在两套环境(未灰度的PHP5.4环境以及灰度中的PHP7环境)中正常运行,代码在上线前,也需要在两套环境中分别进行测试,以达到完全兼容。
所以,在灰度期间,对每个环节的运行环境除了现有的PHP5.4环境外,我们还分别提供了一套PHP7环境,每个阶段的测试中,两套环境都需要进行验证。
4. 灰度方案
之前有过简单的介绍,系统部署在三大服务池,分别为Home池、Page池以及应用服务池。
在准备好安装包后,先是在每个服务池分别部署了一台前端机来灰度。运行一段时间后,期间通过错误日志发现了不少问题,也有用户投诉过来的问题,在问题都基本解决的情况下,逐渐将各服务池的机器池增加至多台。
经过前期的灰度测试,主要的问题得到基本解决。接下是对应用服务池进行灰度,陆续又发现了不少问题。前后大概经历了一个月左右,完成了应用服务池的升级。然后再分别对Home池以及Page池进行灰度,经过漫长灰度,最终完成了PC主站全网PHP7的升级。
虽然很多问题基本上在测试或者灰度期间得到了解决,但依然有些问题是全量上线后一段时间才暴露出来,业务流程太多,很多逻辑需要一定条件才能被触发。为此BUG都要第一时间同步给PHP7升级项目组,对于升级PHP引起的问题,要求必须第一时间解决。
5. 优化方案
(1)启用Zend Opcache,启用Opcache非常简单, 在PHP.ini配置文件中加入:
zend_extension=opcache.so opcache.enable=1 opcache.enable_cli=1"
(2)使用GCC4.8以上的编译器来编译安装包,只有GCC4.8以上编译出的PHP才会开启Global Register for opline and execute_data支持。
(3)开启HugePage支持,首先在系统中开启HugePages, 然后开启Opcache的hugecodepages。
关于HugePage
操作系统默认的内存是以4KB分页的,而虚拟地址和内存地址需要转换, 而这个转换要查表,CPU为了加速这个查表过程会内建TLB(Translation Lookaside Buffer)。 显然,如果虚拟页越小,表里的条目数也就越多,而TLB大小是有限的,条目数越多TLB的Cache Miss也就会越高, 所以如果我们能启用大内存页就能间接降低这个TLB Cache Miss。
PHP7与HugePage
PHP7开启HugePage支持后,会把自身的text段, 以及内存分配中的huge都采用大内存页来保存, 减少TLB miss, 从而提高性能。相关实现可参考Opcache实现中的accel_move_code_to_huge_pages()函数。
开启方法
以CentOS 6.5为例, 通过命令:
sudo sysctl vm.nr_hugepages=128
分配128个预留的大页内存。
$ cat /proc/meminfo | grep Huge AnonHugePages: 444416 kB HugePages_Total: 128 HugePages_Free: 128 HugePages_Rsvd: 0 HugePages_Surp: 0 Hugepagesize: 2048 kB
然后在PHP.ini中加入
opcache.huge_code_pages=1
6. 关于负载过高,系统CPU使用占比过高的问题
当我们升级完第一个服务池时,感觉整个升级过程还是比较顺利,当灰度Page池,低峰时一切正常,但到了流量高峰,系统CPU占用非常高,如图:
系统CPU的使用远超用户程序CPU的使用,正常情况下,系统CPU与用户程序CPU占比应该在1/3左右。但我们的实际情况则是,系统CPU是用户CPU的2~3倍,很不正常。
对比了一下两个服务池的流量,发现Page池的流量正常比Home池高不少,在升级Home池时,没发现该问题,主要原因是流量没有达到一定级别,所以未触发该问题。当单机流量超过一定阈值,系统CPU的使用会出现一个直线的上升,此时系统性能会严重下降。
这个问题其实困扰了我们有一段时间,通过各种搜索资料,均未发现任何升级PHP7会引起系统CPU过高的线索。但我们发现了另外一个比较重要的线索,很多软件官方文档里非常明确的提出了可以通过关闭Transparent HugePages(透明大页)来解决系统负载过高的问题。后来我们也尝试对其进行了关闭,经过几天的观察,该问题得到解决,如图:
什么是Transparent HugePages(透明大页)
简单的讲,对于内存占用较大的程序,可以通过开启HugePage来提升系统性能。但这里会有个要求,就是在编写程序时,代码里需要显示的对HugePage进行支持。
而红帽企业版Linux为了减少程序开发的复杂性,并对HugePage进行支持,部署了Transparent HugePages。Transparent HugePages是一个使管理Huge Pages自动化的抽象层,实现方案为操作系统后台有一个叫做khugepaged的进程,它会一直扫描所有进程占用的内存,在可能的情况下会把4kPage交换为Huge Pages。
为什么Transparent HugePages(透明大页)对系统的性能会产生影响
在khugepaged进行扫描进程占用内存,并将4kPage交换为Huge Pages的这个过程中,对于操作的内存的各种分配活动都需要各种内存锁,直接影响程序的内存访问性能。并且,这个过程对于应用是透明的,在应用层面不可控制,对于专门为4k page优化的程序来说,可能会造成随机的性能下降现象。
怎么关闭Transparent HugePages(透明大页)
(1)查看是否启用透明大页。
[root@venus153 ~]# cat /sys/kernel/mm/transparent_hugepage/enabled [always] madvise never
使用命令查看时,如果输出结果为[always]表示透明大页启用了,[never]表示透明大页禁用。
(2)关闭透明大页。
echo never > /sys/kernel/mm/transparent_hugepage/enabled echo never > /sys/kernel/mm/transparent_hugepage/defrag
(3)启用透明大页。
echo always > /sys/kernel/mm/transparent_hugepage/enabled echo always > /sys/kernel/mm/transparent_hugepage/defrag
(4)设置开机关闭。
修改/etc/rc.local文件,添加如下行:
if test -f /sys/kernel/mm/redhat_transparent_hugepage/enabled; then echo never > /sys/kernel/mm/transparent_hugepage/enabled echo never > /sys/kernel/mm/transparent_hugepage/defrag fi
升级效果
由于主站的业务比较复杂,项目较多,涉及服务池达多个,每个服务池所承担业务与流量也不一样,所以我们在对不同的服务池进行灰度升级,遇到的问题也不尽相同,导致整体升级前后达半年之久。庆幸的是,遇到的问题,最终都被解决掉了。最让人兴奋的是升级效果非常好,基本与官方一致,也为公司节省了不少成本。
以下简单地给大家展示下这次PHP7升级的成果。
(1)PHP5与PHP7环境下,分别对我们的某个核心接口进行压测(压测数据由QA团队提供),相关数据如下:
同样接口,分别在两个不现的环境中进行测试,平均TPS从95提升到220,提升达130%。
(2)升级前后,单机CPU使用率对比如下。
升级前后,1小时流量情况变化:
升级前后,1小时CPU使用率变化:
升级前后,在流量变化不大的情况下,CPU使用率从45%降至25%,CPU使用率降低44.44%。
(3)某服务集群升级前后,同一时间段1小时CPU使用对比如下。
PHP5环境下,集群近1小时CPU使用变化:
PHP7环境下,集群近1小时CPU使用变化:
升级前后,CPU变化对比:
升级前后,同一时段,集群CPU平均使用率从51.6%降低至22.9%,使用率降低56.88%。
以上只简单从三个维度列举了一些数据。为了让升级效果更加客观,我们实际的评估维度更多,如内存使用、接口响应时间占比等。最终综合得出的结论为,通过本次升级,PC主站整体性能提升在48.82%,效果非常好。团队今年的职能KPI就算是提前完成了。
总结
整体升级从准备到最终PC主站全网升级完成,时间跨度达半年之久,无论是扩展编写、准备安装脚本、PHP代码升级还是全网灰度,期间一直会出现各式各样的问题。最终在团队的共同努力下,这些问题都彻底得到了解决。
一直以来,对社区的付出深怀敬畏之心,也是因为他们对PHP语言性能极限的追求,才能让大家的业务坐享数倍性能的提升。同时,也让我们更加相信,PHP一定会是一门越来越好的语言。
作者简介
侯青龙,微博主站研发负责人。2010年加入新浪微博,先后参与过微博主站V2版至V6版的研发,主导过主站V6版以及多机房消息同步系统等重大项目的架构设计工作。致力于提升产品研发效率以及优化系统性能。
感谢韩婷对本文的审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号:InfoQChina)关注我们。
2017 年你应该学习的编程语言、框架和工具
链接:https://zhuanlan.zhihu.com/p/24369470
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
在过去的一年里,软件开发行业继续大踏步地向前迈进。回顾 2016 年,我们看到了更多新兴的流行语言、框架和工具,它们改变着我们的工作方式,让我们看到更多的可能。但在这个行业,紧随潮流是很难的。所以在每年年底,我们都会给你提供一些建议,它涉及什么是最重要的,以及你在未来一年中应该学习什么。
大趋势
渐进式 Web Apps
在 2016 年里,我们见证了 Progressive Web App 概念的蓬勃兴起。它意味着 Web 应用程序可以离线工作,并能提供原生移动应用的体验。它们可以添加到你的智能设备的主屏幕上,甚至可以给你发送推送通知,从而弥补与原生移动应用程序的差距。我们认为,在 2017 年,渐进式 Web Apps 将变得更加重要,也值得我们去探究。在这里查看相关概述。
聊天机器人
从运行聊天机器人的平台到构建其的框架,现在每个人都在谈论它。而社区里也正忙于此活动。(阅读我们的介绍)机器人是一款新兴的移动应用程序,它让我们感到兴奋。如果你快点的话,还可以赶得上这波浪潮。然而一旦新鲜感消失,那么它只会承担一些无聊的角色,例如自动化的客服支持。但是,相信我们可以实现梦想。
前端框架的合并
在 JavaScript 社区,随着令人难以置信的框架和工具的混合,每周都会出现新的东西。直到最近,人们希望旧工具将被新工具所取代,但这不是 2016 年我们所想看到的。相反,我们看到了流行框架交换的想法,以及纳入新诞生框架中的创新元素。所以在 2017 年,你该选择哪个 JS 框架无关紧要,因为它们的功能大多是可以比较的。
云端
就目前的形势看,众多的公司与开发者们都在积极地拥抱“云”。云是可根据不同的需求,并通过控制面板来完全配置的虚拟化计算机基础设施。目前三大云提供商为亚马逊 AWS、Google Cloud 和 微软 Azure. 由于它们的竞争价格一直在下跌,使得小公司和个人开发者也可以将云纳入其预算中,所以熟悉云工作流程将是 2017 年的一笔不错的投资。
机器学习
机器学习(ML)在去年一年中呈现爆炸式的增长。三月份 AlphaGo 与李世石的精彩对决,也让它成为了焦点。从原始数据中学习的智能计算机系统,正在改变我们与移动设备的交互方式。看样子,机器学习将在 2017 年成为更大的影响因素。
编程语言
JavaScript 继续迈着令人难以置信的创新步伐在前进。由于 Web 浏览器的快速发布计划,JS 的标准定为了每年更新。故“ES2017”预计将在 2017 年中期完成,它也将带来 JS 开发者梦寐以求的新特性——用于处理异步函数的аsync/аwait。同时要感谢 Babel ,因为你现在可以在每个浏览器中编写 ES2017 了。
TypeScript 2.1 于 2016 年年底发布,它将为旧浏览器带来Async/Await异步解决方案,并改进了类型推断。TypeScript 是一种编译为纯 JavaScript 的静态类型语言。它增强了经典的 OOP 模型和可选的静态类型,使大代码库更易于维护。同时,它也是编写 Angular 2 应用程序的首选语言,我们建议你可以尝试下。 这是关于它的快速入门指南。
C#7.0 预计在 2017 年发布,作为一门优秀的编程语言,它也将得到更大的改进。当微软推出开源的 Visual Studio 代码编辑器和 .Net Core 时,这一举动让众人都感到惊讶万分。它们不仅可以在 Linux、Windows 和 macOS 操作系统中运行,而且你可以在 C# 中编写快速、高效的应用程序(在这里阅读更多)。同时,这两种工具也都形成了充满活力的社区。相信,它们将在 2017 年会给我们带来更多的惊喜。
Python 3.6 版本将于 12 月发布。它正在巩固自身在开发人员、IT 专业人员和科学家在脚本语言选择中的地位。它适用于自动化、Web开发、机器学习和科学计算。虽然 Python 2.X 与 3.X 版本的割裂,对于社区来说是一个长达数年的斗争,但是就目前而言,你可以自信地选择 Python 3 并享受完整的库支持。而对于那些需要额外性能的朋友,建议你们看看 PyPy,一个可启用 Python 运行时 JIT 的替代品。
Ruby 2.3 已在今年早些时候发布了,并带来了一些性能上的改进。同时,Ruby 也是学习通用脚本语言的一个好选择,但是只有当它和 Rails 相配合的时候才能发挥出其最大的功效。伴随 Ruby 3×3 计划的宣布,也促使了即将到来的 Ruby 3 版本比当前版本的运行速度快 3 倍。而你也可以在更多的情景中,打开使用 Ruby 的大门。
PHP 7.1 版本已在 12 月发布,并对该语言进行了小范围的增强。这个版本基于了去年 7.0 版本主要性能的改进,将 PHP 转变为构建 Web 应用程序的快速平台。如果你打算学习,我们推荐你看看 PHP 之道中的最佳实践。
Java 9 预计在 2017 年发布,它将带来一些备受开发者们所欢迎的新功能,例如评估代码的 repl、HTTP 2.0 的支持以及一些新的 API . 对于有才能的 Java 开发人员和广泛使用该语言进行项目研发的人来说,他们对这些新特性是有强烈需求的。如果 Java 不是你的“菜”,这里还有一些基于 JVM 的编程语言,像 Kotlin 和 Scala,你也可以了解下。
Swift 3 已经在今年早些时候发布了。简化 iOS 和 MacOS 上应用程序的开发,是苹果公司对现代编程语言的愿景。由于 Swift 是开源的,所以也涌现了大量的社区。Swift 4 计划于 2017 年发布,此版本将会改进语言并引入服务器 API,致力使其成为编写 Web 应用程序和后端的不错选择。
如果你在寻找一些让你感到兴奋的东西,你可以尝试下 Crystal 和 Elixir。它们都拥有类似与 Ruby 的友好语法以及卓越的性能,或者你也可以看看类似于 Haskell 或 Clojure 这类函数式语言。另外两种快速编程语言,我们推荐给你 Rust 和 Go 语言。
挑一个或多个学习: JS (ES2017)、TypeScript、C#、Python、Ruby、PHP7、Java/Kotlin/Scala.
前端
近期 Web 平台取得了两个重大的进展:Web Assembly 字节码技术和 Service Workers 技术。它们打开了快速、高效的 Web 应用程序的大门,并且有效的弥补了编译本地应用上的差距。Service Workers 是针对渐进式 Web App 的启动技术,它为 Web 平台提供了通知上的支持,将来也会有更多的 API.
Angular.js 2 在今年也已经发布了。该框架由 Google 进行维护,受到了众多企业和大公司的青睐。它所具备众多的功能,也为从网络到桌面以及移动应用程序中编写任何东西成为了可能。而它的框架也是用 TypeScript 所编写的,这也是写应用程序推荐的编程语言。虽然学习它还需要阅读更多的内容,但我们认为在 2017 年学习 Angular 2 将是一个很不错的投资。
在今年我们也看到了 Vue.js2.0 版本的发布,它借鉴了 Angular,React 和 Ember 中好的想法,并且比前两个框架更轻量、更快速。我们建议你今年要试一试,你可以从我们的 Vue.js 教程开始。
Ember 是 JavaScript 框架的另一个不错的选择。它支持数据双向绑定,并能够自动更新模板、组件以及服务器端渲染。与其他竞争者相比,使用它的好处是它更加成熟与稳定,而其框架的重大更改频率之低,社区重视向后的兼容性,也使得此框架成为开发较长生命周期的应用程序的不二之选。
另外两个值得一提的框架是 Aurelia 和 React。在过去的一年里 React 的生态系统变得越来越复杂,因此很难推荐给初学者。但经验丰富的开发者可以将库与 GraphQL、Relay、Flux 和 Immutable.js 组合成一个全面完整的全栈解决方案。
没有提及 Bootstrap 的前端终归是不完整的。而 Bootstrap 4 目前也正处于 Alpha 阶段,预计在 2017 年发布。值得关注的变化是新的通用卡片组件和 Flexbox 网格(查看与常规网格的对比),这使得框架更加现代化,并且让用户使用它进行工作时更加得舒心。
SASS 和 LESS 仍然是当前最流行的两种 CSS 预处理器。尽管 Vanilla CSS 已经实现了对变量的支持,但对 mixins、函数和代码组织上的支持,SASS 和 LESS 依然更胜一筹。如果您还没有了解它们,可以看看我们的 SASS 和 LESS 快速入门指南。
挑一个或多个学习:Angular 2、Vue.js、Ember、Bootstrap、LESS/SASS
后端
后端有众多的选择,但所有的选择都取决于你对编程语言或特定性能需求的偏好上。Web 开发中的一个持续趋势是远离后端的业务逻辑,并将该层转换为由前端和移动应用程序使用的 API 上。但一个全栈的框架通常是能够更简单、快速的应用于开发,并且它仍然是 Web 应用程序最有效的选择。
Node.js 是在浏览器之外运行 JS 的主要方式。在今年,我们也看到了它发布了许多新的版本。除了提升了性能外,也添加了对整个 ES6 规范的覆盖。Node 具有构建快速 API、服务器、桌面应用程序甚至机器人的框架,同时它可以创建想象到的各种模块的庞大社区。这里有一些你可能想研究的框架:Express、Koa、Next、Nodal.
PHP 是一种拥有大量 Web 框架可供你选择的 Web 开发语言。由于其拥有出色的文档和功能,Laravel 已建成了一个活跃的社区。Zend Framework 发布了第 3 版,这标志着面向业务框架的巨大升级。在今年,我们也看到了 Symfony 发行了很多新的版本,使它成为了全栈解决方案中更好的选择。
对于 Ruby 来说,Rails 框架是首选的。Rails 5.0 版本已于今年发布,并为 Web Sockets、API 模型等方面提供了支持。对于小型应用程序而言,Sinatra 也是一个不错的选择,Sinatra 2.0版本预计在 2017 年发布。
Python 有着以 Django 和 Flask 为组合的全栈/迷你型框架。Django 1.10 已在今年 8 月发布了,它为 Postgres 引入了全文搜索和一个重大修改的中间件层。
Java 的生态系统中,依旧有很多流行的 Web 框架可供你选择。Play 和 Spark 便是两个必备的选择,同时它们也可以与 Scala 一起使用。
对于编程爱好者来说,你还可以选择 Phoenix,它是用 Elixir 编写的,它试图成为一个具有卓越的性能,并能完整替代 Rails 功能的框架。如果 Elixir 是你想在 2017 年学习的语言之一,不妨尝试下 Phoenix .
学习其中之一:全栈后端框架、一个微框架
数据库
PostgreSQL 在今年已经发行了两个完整的版本——9.5和9.6.它们带来了我们从 MySQL 就开始期盼的 UPSERT (aka ON DUPLICATE KEY UPDATE)功能,以及更好的全文搜索和速度改进功能,这多亏了并行查询,更高效的复制、聚合、索引和排序。Postgres 适用于大规模、TB 级规模的数据集以及繁忙的 Web Apps,这些优化都是很受欢迎的。
MySQL 8.0 将是数据库的下一个主要版本。预计在 2017 年发布,它将给系统带来更多的改进。MySQL 仍然是最受欢迎的数据库管理系统,整个行业都受益于这些新的版本。
对于 NoSQL 的粉丝们,我们推荐 CouchDB。它是一个快速、可扩展的 JSON 存储系统,同时公开了一个 REST-ful HTTP API.此数据库易于使用,同时性能卓越。与 CouchDB 对应的是 PouchDB ,它可以完全在浏览器中工作,并且可以与 Couch 同步数据。所以你可以在离线应用程序上使用 PouchDB ,联网后它会自动同步数据。
Redis 是我们最喜欢的键-值存储型数据库。它体积小、快速并且有丰富的特性。作为 NoSQL 数据存储或进程消息和同步通道,你可以使用它作为智能分布式高速缓存系统的可替代方案。它提供了大量的数据结构可供选择,并且在即将到来的 4.0 版本中会有一个模块系统,并将改进复制功能。
学习其中之一:Postgres、MySQL、CouchDB、Redis.
工具
Yarn 是由 Facebook 开发的 Node.js 包管理器。它是对 npm 命令行工具的升级,并提供了更快速地安装,更好的安全性以及确定性的构建。它仍然使用 npm 包注册表作为其后端,因此您甚至可以访问同一个 JavaScript 模块的生态系统。Yarn 与 npm 使用的 package.json 格式是兼容的,区别在于前者能实现快速安装。
作为两个最受开发者欢迎的开源代码编辑器——Visual Studio Code 和 Atom ,在过去一年中,我们看到了它们进行了很多不可思议的创新。这两个项目都是使用 Web 技术构建的,社区中也吸引了大量的粉丝。编辑器具备高扩展,提供了诸如语法检查、linting 和重构工具的相关插件。
作为最流行的源代码版本控制系统,Git 当之无愧。虽然它无服务器,但你可以将计算机上的任何文件夹转换为存储库。如果你想共享代码,像 GitLab、Bitbucket 和GitHub 都是不错的选择。在 2017 年,我们建议你熟悉 git 命令行,因为它会比您想象的更加方便。
桌面应用程序依然没有消失。即使 Web App 变得越来越强大,有时你依然会需要强大的功能和 API,这是 Web 平台无法提供的。你可以使用诸如 Electron 和 NW.js 之类的工具,利用 Web 技术来创建桌面应用程序,同时你也可以完全访问操作系统和 npm 可用的广度模块。要了解这些工具的更多信息,请阅读有关 Electron 和 NW.js 的教程。
软件开发团队的最新趋势是让开发人员负责自己软件项目中的部署,也称为 DevOps.这能产生更快地发布和更迅速地修复生产中出现的问题。而具有运维经验的开发人员将得到公司的高度重视,因此从现在开始熟悉能够实现这一目标的技术,将对你来说是一个巨大的提升。我们推荐的工具是 Ansible 和 Docker 。同时,具备 Linux 命令行和基本系统管理技能,也将为你的职场生涯大大的加分。
尝试一个或多个学习:Yarn、Git、Visual Studio Code、Electron、Ansible、Docker.
技术
随着大型公司数据中心的关闭,并调整其整体的基础设施到云上,我们可以看到云已经赢得了整个软件行业。目前三个主要的平台是 AWS, Google Cloud 和 Azure。这三大平台都有着强大的功能,同时不断地扩展其功能集,涉及虚拟机、数据库托管、机器学习服务等。由于价格的迅速下降,小公司和个人开发者也都可以接触到云。对于 2017 年,在云上部署一个业余项目将是一个很好的学习积累。
人工智能是 2016 年的流行词。语音识别和图像分类只是该技术在面向用户应用程序的两个部分,人工智能设备的性能达到甚至超越了人类的水平。当下众多的创业公司也将 AI 和机器学习应用到其新的领域,同时许多相关的开源项目也已经发布,例如谷歌的 Tensor Flow 和微软的 Cognitive Toolkit。机器学习是一个与数学非常相关的主题,对于刚刚开始的人,这里有全面的在线课程供你学习。
虚拟现实(VR)和增强现实(AR)已经存在了一段时间,而最终该技术已经成熟到足以提供引人注目的体验。Facebook(Oculus Rift),Google(Daydream)和 Microsoft(Windows Holographic)都有欢迎第三方开发者加入的虚拟现实平台。然而 VR 穿戴设备依然面临着艰巨的挑战。例如如何消除穿戴者恶心的感觉,以及脱离了游戏圈,又如何创造令人信服的使用案例。
挑一种学习:云部署、机器学习库、VR 开发
如果觉得文章不错,不妨点个赞。^_^
注:
- 若有翻译不当之处,还请大家多多指正,我会及时修改;
- 本文版权归原作者所有。如需转载译文,烦请注明出处,谢谢!
英文原文:The Languages, Frameworks and Tools You Should Learn in 2017
作者:Martin Angelov
译者:IT程序狮
译文源自:https://zhuanlan.zhihu.com/p/24369470
日交易额百亿级交易系统的超轻量日志实现
首先来聊聊往事吧~~两年前就职于一家传统金融软件公司,为某交易所开发一套大型交易系统,交易标的的价格为流式数据,采用价格触发成交方式,T+0交易制度(类似炒股,只是炒的不是股票而是其他标的物,但可以随时开平仓)。鉴于系统需要记录大量价格数据、交易信息及订单流水,且系统对性能要求极高(敏感度达毫秒级),因此需要避免日志服务成为系统性能瓶颈。通过对几个通用型日志(如log4j、logback)的性能压测,以及考虑到它们作为通用型日志相对比较臃肿,就决定自个儿写个日志工具以支撑系统功能和性能所需。当时的做法只是简单的将日志的实现作为一个 util 类写在项目中,只有几百行的代码量。
系统上线两个月后日均成交额200亿RMB,最高达440亿RMB,峰值成交4000笔/秒。系统非常庞大,但几百行的代码却完美支撑住了重要的日志服务!
鉴于其优秀的表现,就花了一点点时间把它抽取出来作为一个独立的日志组件,取名叫 FLogger,代码几乎没有改动,现已托管到GitHub(FLogger),有兴趣的童鞋可以clone下来了解并改进,目前它的实现是非常简(纯)单(粹)的。
以上就是 FLogger 的诞生背景。好吧,下面进入正题。
特性
虽然 FLogger 只有几百行的代码,但是麻雀虽小五脏俱全,它可是拥有非常丰富的特性呢:
- 双缓冲队列
- 多种刷盘机制,支持时间触发、缓存大小触发、服务关闭强制触发等刷盘方式
- 多种 RollingFile 机制,支持文件大小触发、按天触发等 Rolling 方式
- 多日志级别,支持 debug、info、warn、error和 fatal 等日志级别
- 热加载,由日志事件触发热加载
- 超轻量,不依赖任何第三方库
- 性能保证,成功用于日交易额百亿级交易系统
使用
既然是个超轻量级日志,使用肯定要很简单。为最大程度保持用户的使用习惯,Flogger 提供了与 log4j 几乎一样的日志 API。你只需要先获取一个实例,接下来的使用方式就非常简单了:
1
2
3
4
5
6
7
8
|
//获取单例 FLogger logger = FLogger.getInstance(); //简便api,只需指定内容 logger.info( "Here is your message..." ); //指定日志级别和内容,文件名自动映射 logger.writeLog(Constant.INFO, "Here is your customized level message..." ); //指定日志输出文件名、日志级别和内容 logger.writeLog( "error" , Constant.ERROR, "Here is your customized log file and level message..." ); |
使用前你需要在项目根路径下创建 log.properties 文件,配置如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
########## 公共环境配置 ########## # 字符集 CHARSET_NAME = UTF-8 ########## 日志信息配置 ########## # 日志级别 0:调试信息 1:普通信息 2:警告信息 3:错误信息 4:严重错误信息 LOG_LEVEL = 0,1,2,3,4 # 日志文件存放路径 LOG_PATH =./log # 日志写入文件的间隔时间(默认为1000毫秒) WRITE_LOG_INV_TIME = 1000 # 单个日志文件的大小(默认为10M) SINGLE_LOG_FILE_SIZE = 10485760 # 单个日志文件缓存的大小(默认为10KB) SINGLE_LOG_CACHE_SIZE = 10240 |
当然,为了提供最大程度的便捷性,日志内部针对所有配置项都提供了默认值,你大可不必担心缺少配置文件会抛出异常。
至此,你可能很好奇使用 FLogger 打印出来的日志格式到底是怎样的,会不会杂乱无章无法理解,还是信息不全根本无法判断上下文呢?好吧,你多虑了,FLogger 提供了非常规范且实用的日志格式,能使让你很容易理解且找到相关上下文。
先来看看上面的 demo 代码打印出来的结果:
info.log
1
|
[INFO] 2016-12-06 21:07:32:840 [main] Here is your message... |
warn.log
1
|
[WARN] 2016-12-06 21:07:32:842 [main] Here is your customized level message... |
error.log
1
|
[ERROR] 2016-12-06 21:07:32:842 [main] Here is your customized log file and level message... |
从上面可以看到,你可以很清楚的分辨出日志的级别、时间和内容等信息。到这其实很明了了,日志由以下几个元素组成:
1
|
[日志级别] 精确到毫秒的时间 [当前线程名] 日志内容 |
当然,处于便捷性的考虑,FLogger 目前并不支持用户定义日志格式,毕竟它的目的也不是要做成一个通用性或者可定制性非常高的日志来使用。
源码解析
上面这么多都是围绕如何使用进行说明,下面就针对 FLogger 的特性进行实现逻辑的源码解析。
双缓冲队列
FLogger 在内部采用双缓冲队列,那何为双缓冲队列呢?它的作用又是什么呢?
FLogger 为每个日志文件维护了一个内部对象 LogFileItem ,定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
public class LogFileItem { /** 不包括路径,不带扩展名的日志文件名称 如:MsgInner */ public String logFileName = "" ; /** 包括路径的完整日志名称 */ public String fullLogFileName = "" ; /** 当前日志文件大小 */ public long currLogSize = 0; /** 当前正在使用的日志缓存 */ public char currLogBuff = 'A' ; /** 日志缓冲列表A */ public ArrayList<StringBuffer> alLogBufA = new ArrayList<StringBuffer>(); /** 日志缓冲列表B */ public ArrayList<StringBuffer> alLogBufB = new ArrayList<StringBuffer>(); /** 下次日志输出到文件时间 */ public long nextWriteTime = 0 ; /** 上次写入时的日期 */ public String lastPCDate = "" ; /** 当前已缓存大小 */ public long currCacheSize = 0; } |
在每次写日志时,日志内容作为一个 StringBuffer 添加到当前正在使用的 ArrayList<StringBuffer> 中,另一个则空闲。当内存中的日志输出到磁盘文件时,会将当前使用的 ArrayList<StringBuffer> 与空闲的 ArrayList<StringBuffer> 进行角色交换,交换后之前空闲的 ArrayList<StringBuffer> 将接收日志内容,而之前拥有日志内容的 ArrayList<StringBuffer> 则用来输出日志到磁盘文件。这样就可以避免每次刷盘时影响日志内容的接收(即所谓的 stop-the-world 效应)及多线程问题。流程如下:
关键代码如下:
日志接收代码
1
2
3
4
5
6
7
8
9
|
//同步单个文件的日志 synchronized(lfi){ if (lfi.currLogBuff == 'A' ){ lfi.alLogBufA.add(logMsg); } else { lfi.alLogBufB.add(logMsg); } lfi.currCacheSize += CommUtil.StringToBytes(logMsg.toString()).length; } |
日志刷盘代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
//获得需要进行输出的缓存列表 ArrayList<StringBuffer> alWrtLog = null ; synchronized(lfi){ if (lfi.currLogBuff == 'A' ){ alWrtLog = lfi.alLogBufA; lfi.currLogBuff = 'B' ; } else { alWrtLog = lfi.alLogBufB; lfi.currLogBuff = 'A' ; } lfi.currCacheSize = 0; } //创建日志文件 createLogFile(lfi); //输出日志 int iWriteSize = writeToFile(lfi.fullLogFileName,alWrtLog); lfi.currLogSize += iWriteSize; |
多刷盘机制
FLogger 支持多种刷盘机制:
- 刷盘时间间隔触发
- 内存缓冲大小触发
- 退出强制触发
下面就来一一分析。
刷盘时间间隔触发
配置项如下:
1
2
|
# 日志写入文件的间隔时间(默认为1000毫秒) WRITE_LOG_INV_TIME = 1000 |
当距上次刷盘时间超过间隔时间,将执行内存日志刷盘。
内存缓冲大小触发
配置项如下:
1
2
|
# 单个日志文件缓存的大小(默认为10KB) SINGLE_LOG_CACHE_SIZE = 10240 |
当内存缓冲队列的大小超过配置大小时,将执行内存日志刷盘。
退出强制触发
FLogger 内部注册了 JVM 关闭钩子 ShutdownHook ,当 JVM 正常关闭时,由钩子触发强制刷盘,避免内存日志丢失。相关代码如下:
1
2
3
4
5
6
7
8
|
public FLogger(){ Runtime.getRuntime().addShutdownHook( new Thread( new Runnable() { @Override public void run() { close(); } })); } |
当 JVM 异常退出时无法保证内存中的日志全部落盘,但可以通过一种妥协的方式来提高日志刷盘的实时度:设置 SINGLE_LOG_CACHE_SIZE = 0 或者 WRITE_LOG_INV_TIME = 0 。
刷盘代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
/** 线程方法 */ public void run(){ int i = 0 ; while (bIsRun){ try { //输出到文件 flush( false ); //重新获取日志级别 if (i++ % 100 == 0){ Constant.CFG_LOG_LEVEL = CommUtil.getConfigByString( "LOG_LEVEL" , "0,1,2,3,4" ); i = 1; } } catch (Exception e){ System. out .println( "开启日志服务错误..." ); e.printStackTrace(); } } } /** 关闭方法 */ public void close(){ bIsRun = false ; try { flush( true ); } catch (Exception e){ System. out .println( "关闭日志服务错误..." ); e.printStackTrace(); } } /** * 输出缓存的日志到文件 * @param bIsForce 是否强制将缓存中的日志输出到文件 */ private void flush(boolean bIsForce) throws IOException{ long currTime = System.currentTimeMillis(); Iterator<String> iter = logFileMap.keySet().iterator(); while (iter.hasNext()){ LogFileItem lfi = logFileMap. get (iter.next()); if (currTime >= lfi.nextWriteTime || SINGLE_LOG_CACHE_SIZE <= lfi.currCacheSize || bIsForce == true ){ //获得需要进行输出的缓存列表 ArrayList<StringBuffer> alWrtLog = null ; synchronized(lfi){ if (lfi.currLogBuff == 'A' ){ alWrtLog = lfi.alLogBufA; lfi.currLogBuff = 'B' ; } else { alWrtLog = lfi.alLogBufB; lfi.currLogBuff = 'A' ; } lfi.currCacheSize = 0; } //创建日志文件 createLogFile(lfi); //输出日志 int iWriteSize = writeToFile(lfi.fullLogFileName,alWrtLog); lfi.currLogSize += iWriteSize; } } } |
多 RollingFile 机制
同 log4j/logback,FLogger 也支持多种 RollingFile 机制:
- 按文件大小 Rolling
- 按天 Rolling
其中按文件大小 Rolling,配置项为:
1
2
|
# 单个日志文件的大小(默认为10M) SINGLE_LOG_FILE_SIZE = 10485760 |
即当文件大小超过配置大小时,将创建新的文件记录日志,同时重命名旧文件为”日志文件名_日期_时间.log”(如 info_20161208_011105.log)。
按天 Rolling 即每天产生不同的文件。
产生的日志文件列表可参考如下:
1
2
3
4
5
|
info_20161207_101105.log info_20161207_122010.log info_20161208_011110.log info_20161208_015010.log info.log |
当前正在写入的日志文件为 info.log。
关键代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
/** * 创建日志文件 * @param lfi */ private void createLogFile(LogFileItem lfi){ //当前系统日期 String currPCDate = TimeUtil.getPCDate( '-' ); //如果超过单个文件大小,则拆分文件 if (lfi.fullLogFileName != null && lfi.fullLogFileName.length() > 0 && lfi.currLogSize >= LogManager.SINGLE_LOG_FILE_SIZE ){ File oldFile = new File(lfi.fullLogFileName); if (oldFile.exists()){ String newFileName = Constant.CFG_LOG_PATH + "/" + lfi.lastPCDate + "/" + lfi.logFileName + "_" + TimeUtil.getPCDate() + "_" + TimeUtil.getCurrTime() + ".log" ; File newFile = new File(newFileName); boolean flag = oldFile.renameTo(newFile); System.out.println( "日志已自动备份为 " + newFile.getName() + ( flag ? "成功!" : "失败!" ) ); lfi.fullLogFileName = "" ; lfi.currLogSize = 0 ; } } //创建文件 if ( lfi.fullLogFileName == null || lfi.fullLogFileName.length() <= 0 || lfi.lastPCDate.equals(currPCDate) == false ){ String sDir = Constant.CFG_LOG_PATH + "/" + currPCDate ; File file = new File(sDir); if (file.exists() == false ){ file.mkdir(); } lfi.fullLogFileName = sDir + "/" + lfi.logFileName + ".log" ; lfi.lastPCDate = currPCDate; file = new File(lfi.fullLogFileName); if (file.exists()){ lfi.currLogSize = file.length(); } else { lfi.currLogSize = 0 ; } } } |
多日志级别
FLogger 支持多种日志级别:
- DEBUG
- INFO
- WARN
- ERROR
- FATAL
FLogger 为每个日志级别都提供了简易 API,在此就不再赘述了。
打印 error 和 fatal 级别日志时,FLogger 默认会将日志内容输出到控制台。
热加载
FLogger 支持热加载,FLogger 内部并没有采用事件驱动方式(即新增、修改和删除配置文件时产生相关事件通知 FLogger 实时热加载),而是以固定频率的方式进行热加载,具体实现就是每执行完100次刷盘后才进行热加载(频率可调),关键代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
int i = 0 ; while (bIsRun){ try { //等待一定时间 Thread.sleep( 200 ); //输出到文件 flush( false ); //重新获取日志级别 if (i++ % 100 == 0 ){ Constant.CFG_LOG_LEVEL = CommUtil.getConfigByString( "LOG_LEVEL" , "0,1,2,3,4" ); //其他配置项热加载...... i = 1 ; } } catch (Exception e){ System.out.println( "开启日志服务错误..." ); e.printStackTrace(); } } |
这么做完全是为了保持代码的精简和功能的纯粹性。事件驱动热加载无疑是更好的热加载方式,但需要新建额外的线程并启动对配置文件的事件监听,有兴趣的童鞋可自行实现。
性能保证
FLogger 成功支撑了日交易额百亿级交易系统的日志服务,它的性能是经历过考验的。下面我们就来拿 FLogger 跟 log4j 做个简单的性能对比。
测试环境:Intel(R) Core(TM) i5-3470 CPU @ 3.20GHz 3.20 GHz 4.00 GB Memory 64位操作系统
测试场景:单条记录72byte 共1000000条 写单个日志文件
FLogger 配置如下:
1
2
3
4
5
6
|
# 日志写入文件的间隔时间 WRITE_LOG_INV_TIME = 0 # 单个日志文件的大小 SINGLE_LOG_FILE_SIZE = 104857600 # 单个日志文件缓存的大小 SINGLE_LOG_CACHE_SIZE = 0 |
以上配置保证所有日志写入到单个文件,且尽量保证每一条记录不在内存中缓存,减少测试误差。
测试代码:
1
2
3
4
5
6
7
8
9
|
FLogger logger = FLogger.getInstance(); //FLogger //Logger logger = Logger.getLogger(Log4jTest.class); //log4j String record = "Performance Testing about log4j and cyfonly customized java project log." ; //72字节 long st = System.currentTimeMillis(); for ( int i=0; i<1000000; i++){ logger.info(record); } long et = System.currentTimeMillis(); System. out .println( "FLogger/log4j write 1000000 records with each record 72 bytes, cost :" + (et - st) + " millseconds" ); |
日志内容:
1
2
3
4
5
|
FLogger: [INFO] 2016 - 12 - 06 21 : 40 : 06 : 842 [main] Performance Testing about log4j and cyfonly customized java project log. log4j: [INFO ] 2016 - 12 - 06 21 : 41 : 12 , 852 , [main]Log4jTest: 12 , Performance Testing about log4j and cyfonly customized java project log. |
测试结果(执行10次取平均值):
1
2
|
FLogger write 1000000 records with each record 72 bytes, cost : 2144 millseconds log4j write 1000000 records with each record 72 bytes, cost :cost : 12691 millseconds |
说明:测试结果为日志全部刷盘成功的修正时间,加上各种环境的影响,有少许误差,在此仅做简单测试,并不是最严格最公平的测试对比。有兴趣的童鞋可进行精确度更高的测试。欢迎私下探讨,本人QQ:869827095。
设计模式总结
http://mp.weixin.qq.com/s/VHHK3Mt4FKOMUst3NJpRfw
从七月份开始一直到九月底才看完设计模式,在这个过程中我不敢说我已经掌握了那本书里面的内容,或者说1/5,没能力说也没有资格说。但是结果不重要,重要的是这个过程我的收获!主要包括如下几个方面:
1、认识了这么多设计模式。刚刚接触java没多久就在学长那里听过设计模式的大名,但是由于能力有限,一直不敢触碰。而今有幸将其都认识了。
2、开始有设计的理论了。在接触设计模式之前没有怎么想过设计方面东东,看到问题就立马动手解决,没有想到怎么样来设计更好,如何来是这块更加优化、漂亮。
3、开始考虑系统的可扩展性了。
4、在遇到问题后开始想有那个设计模式会适用这个场景。
5、对面向对象有了更深一步的了解。
鄙人天资不聪慧,既不是聪明人,更不是那种天才,所有顿悟有限!!!闲话过多,先看如下两幅图片
设计模式之间的关系:
设计模式总概况:
一、设计原则
1、单一职责原则
一个类,只有一个引起它变化的原因。应该只有一个职责。每一个职责都是变化的一个轴线,如果一个类有一个以上的职责,这些职责就耦合在了一起。这会导致脆弱的设计。当一个职责发生变化时,可能会影响其它的职责。另外,多个职责耦合在一起,会影响复用性。例如:要实现逻辑和界面的分离。from:百度百科
2、开闭原则(Open Close Principle)
开闭原则就是说对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。所以一句话概括就是:为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,我们需要使用接口和抽象类,后面的具体设计中我们会提到这点。
3、里氏代换原则(Liskov Substitution Principle)
里氏代换原则(Liskov Substitution Principle LSP)面向对象设计的基本原则之一。 里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。 LSP是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。里氏代换原则是对“开-闭”原则的补充。实现“开-闭”原则的关键步骤就是抽象化。而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。from:百度百科
4、依赖倒转原则(Dependence Inversion Principle)
所谓依赖倒置原则(Dependence Inversion Principle)就是要依赖于抽象,不要依赖于具体。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。
实现开闭原则的关键是抽象化,并且从抽象化导出具体化实现,如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是面向对象设计的主要手段。 from:百度百科
5、接口隔离原则(Interface Segregation Principle)
这个原则的意思是:使用多个隔离的接口,比使用单个接口要好。还是一个降低类之间的耦合度的意思,从这儿我们看出,其实设计模式就是一个软件的设计思想,从大型软件架构出发,为了升级和维护方便。所以上文中多次出现:降低依赖,降低耦合。
6、合成复用原则(Composite Reuse Principle)
合成复用原则就是指在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用其已有功能的目的。简言之:要尽量使用组合/聚合关系,少用继承。
7、迪米特法则(最少知道原则)(Demeter Principle)
为什么叫最少知道原则,就是说:一个实体应当尽量少的与其他实体之间发生相互作用,使得系统功能模块相对独立。也就是说一个软件实体应当尽可能少的与其他实体发生相互作用。这样,当一个模块修改时,就会尽量少的影响其他的模块,扩展会相对容易,这是对软件实体之间通信的限制,它要求限制软件实体之间通信的宽度和深度。
二、创建型模式
在软件工程中,创建型模式是处理对象创建的设计模式,试图根据实际情况使用合适的方式创建对象。基本的对象创建方式可能会导致设计上的问题,或增加设计的复杂度。创建型模式通过以某种方式控制对象的创建来解决问题。
创建型模式由两个主导思想构成。一是将系统使用的具体类封装起来,二是隐藏这些具体类的实例创建和结合的方式。
创建型模式又分为对象创建型模式和类创建型模式。对象创建型模式处理对象的创建,类创建型模式处理类的创建。详细地说,对象创建型模式把对象创建的一部分推迟到另一个对象中,而类创建型模式将它对象的创建推迟到子类中。
1、抽象工厂模式(Abstract Factory)
所谓抽象工厂模式就是她提供一个接口,用于创建相关或者依赖对象的家族,而不需要明确指定具体类。他允许客户端使用抽象的接口来创建一组相关的产品,而不需要关系实际产出的具体产品是什么。这样一来,客户就可以从具体的产品中被解耦。它的优点是隔离了具体类的生成,使得客户端不需要知道什么被创建了,而缺点就在于新增新的行为会比较麻烦,因为当添加一个新的产品对象时,需要更加需要更改接口及其下所有子类。其UML结构图如下:
参与者:
AbstractFactory:抽象工厂。抽象工厂定义了一个接口,所有的具体工厂都必须实现此接口,这个接口包含了一组方法用来生产产品。
ConcreteFactory:具体工厂。具体工厂是用于生产不同产品族。要创建一个产品,客户只需要使用其中一个工厂完全不需要实例化任何产品对象。
AbstractProduct:抽象产品。这是一个产品家族,每一个具体工厂都能够生产一整组产品。
Product:具体产品。
2、建造者模式(Builder)
对于建造者模式而已,它主要是将一个复杂对象的构建与表示分离,使得同样的构建过程可以创建不同的表示。适用于那些产品对象的内部结构比较复杂。
建造者模式将复杂产品的构建过程封装分解在不同的方法中,使得创建过程非常清晰,能够让我们更加精确的控制复杂产品对象的创建过程,同时它隔离了复杂产品对象的创建和使用,使得相同的创建过程能够创建不同的产品。但是如果某个产品的内部结构过于复杂,将会导致整个系统变得非常庞大,不利于控制,同时若几个产品之间存在较大的差异,则不适用建造者模式,毕竟这个世界上存在相同点大的两个产品并不是很多,所以它的使用范围有限。其UML结构图:
Builder:抽象建造者。它声明为创建一个Product对象的各个部件指定的抽象接口。
ConcreteBuilder:具体建造者。实现抽象接口,构建和装配各个部件。
Director:指挥者。构建一个使用Builder接口的对象。它主要是用于创建一个复杂的对象,它主要有两个作用,一是:隔离了客户与对象的生产过程,二是:负责控制产品对象的生产过程。
Product:产品角色。一个具体的产品对象。
3、工厂方法模式(Factory Method)
作为抽象工厂模式的孪生兄弟,工厂方法模式定义了一个创建对象的接口,但由子类决定要实例化的类是哪一个,也就是说工厂方法模式让实例化推迟到子类。
工厂方法模式非常符合“开闭原则”,当需要增加一个新的产品时,我们只需要增加一个具体的产品类和与之对应的具体工厂即可,无须修改原有系统。同时在工厂方法模式中用户只需要知道生产产品的具体工厂即可,无须关系产品的创建过程,甚至连具体的产品类名称都不需要知道。虽然他很好的符合了“开闭原则”,但是由于每新增一个新产品时就需要增加两个类,这样势必会导致系统的复杂度增加。其UML结构图:
参与者:
Product:抽象产品。所有的产品必须实现这个共同的接口,这样一来,使用这些产品的类既可以引用这个接口。而不是具体类 。
ConcreteProduct:具体产品。
Creator:抽象工厂。它实现了所有操纵产品的方法,但不实现工厂方法。Creator所有的子类都必须要实现factoryMethod()方法。
ConcreteCreator:具体工厂。制造产品的实际工厂。它负责创建一个或者多个具体产品,只有ConcreteCreator类知道如何创建这些产品。
4、原型模式(Prototype)
在我们应用程序可能有某些对象的结构比较复杂,但是我们又需要频繁的使用它们,如果这个时候我们来不断的新建这个对象势必会大大损耗系统内存的,这个时候我们需要使用原型模式来对这个结构复杂又要频繁使用的对象进行克隆。所以原型模式就是用原型实例指定创建对象的种类,并且通过复制这些原型创建新的对象。
它主要应用与那些创建新对象的成本过大时。它的主要优点就是简化了新对象的创建过程,提高了效率,同时原型模式提供了简化的创建结构。UML结构图:
参与者:
Prototype:抽象原型类。声明克隆自身的接口。
ConcretePrototype:具体原型类。实现克隆的具体操作。
Client:客户类。让一个原型克隆自身,从而获得一个新的对象。
5、单例模式(Singleton)
单例模式,从字面上看就是一个实例的意思。所以它的定义就是确保某一个类只有一个实例,并且提供一个全局访问点。
单例模式具备如下几个特点:
1、只有一个实例。
2、能够自我实例化。
3、提供全局访问点。
所以说当系统中只需要一个实例对象或者系统中只允许一个公共访问点,除了这个公共访问点外,不能通过其他访问点访问该实例时,可以使用单例模式。
单例模式的主要优点就是节约系统资源、提高了系统效率,同时也能够严格控制客户对它的访问。也许就是因为系统中只有一个实例,这样就导致了单例类的职责过重,违背了“单一职责原则”,同时也没有抽象类,所以扩展起来有一定的困难。其UML结构图非常简单,就只有一个类:
参与者:
Singleton:单例。
三、结构型模式
结构型模式主要是用于处理类或者对象的组合,它描述了如何来类或者对象更好的组合起来,是从程序的结构上来解决模块之间的耦合问题。它主要包括适配器模式、桥接模式、组合模式、装饰模式、外观模式、享元模式、代理模式这个七个模式。
1、适配器模式(Adapter)
在我们的应用程序中我们可能需要将两个不同接口的类来进行通信,在不修改这两个的前提下我们可能会需要某个中间件来完成这个衔接的过程。这个中间件就是适配器。所谓适配器模式就是将一个类的接口,转换成客户期望的另一个接口。它可以让原本两个不兼容的接口能够无缝完成对接。
作为中间件的适配器将目标类和适配者解耦,增加了类的透明性和可复用性。
参与者:
Target:目标抽象类 。
Adapter:适配器类 。通过在内部包装一个Adaptee,将源接口转成目标接口。
Adaptee:适配者类 。需要适配的类。
Client:客户类。
2、桥接模式(Bridge)
如果说某个系统能够从多个角度来进行分类,且每一种分类都可能会变化,那么我们需要做的就是讲这多个角度分离出来,使得他们能独立变化,减少他们之间的耦合,这个分离过程就使用了桥接模式。所谓桥接模式就是讲抽象部分和实现部分隔离开来,使得他们能够独立变化。
桥接模式将继承关系转化成关联关系,封装了变化,完成了解耦,减少了系统中类的数量,也减少了代码量。
参与者
Abstraction:抽象类。
RefinedAbstraction:扩充抽象类。
Implementor:实现类接口。
ConcreteImplementor:具体实现类 。
3、组合模式(Composite)
组合模式组合多个对象形成树形结构以表示“整体-部分”的结构层次。它定义了如何将容器对象和叶子对象进行递归组合,使得客户在使用的过程中无须进行区分,可以对他们进行一致的处理。
在使用组合模式中需要注意一点也是组合模式最关键的地方:叶子对象和组合对象实现相同的接口。这就是组合模式能够将叶子节点和对象节点进行一致处理的原因。
虽然组合模式能够清晰地定义分层次的复杂对象,也使得增加新构件也更容易,但是这样就导致了系统的设计变得更加抽象,如果系统的业务规则比较复杂的话,使用组合模式就有一定的挑战了。
参与者:
Component :组合中的对象声明接口,在适当的情况下,实现所有类共有接口的默认行为。声明一个接口用于访问和管理Component子部件。
Leaf:叶子对象。叶子结点没有子结点。
Composite:容器对象,定义有枝节点行为,用来存储子部件,在Component接口中实现与子部件有关操作,如增加(add)和删除(remove)等。
4、装饰者模式(Decorator)
我们可以通过继承和组合的方式来给一个对象添加行为,虽然使用继承能够很好拥有父类的行为,但是它存在几个缺陷:一、对象之间的关系复杂的话,系统变得复杂不利于维护。二、容易产生“类爆炸”现象。三、是静态的。在这里我们可以通过使用装饰者模式来解决这个问题。
装饰者模式,动态地将责任附加到对象上。若要扩展功能,装饰者提供了比继承更加有弹性的替代方案。虽然装饰者模式能够动态将责任附加到对象上,但是他会产生许多的细小对象,增加了系统的复杂度。
参与者:
Component: 抽象构件。是定义一个对象接口,可以给这些对象动态地添加职责。
ConcreteComponent:具体构件。是定义了一个具体的对象,也可以给这个对象添加一些职责。
Decorator: 抽象装饰类。是装饰抽象类,继承了Component,从外类来扩展Component类的功能,但对于Component来说,是无需知道Decorator存在的。
ConcreteDecorator:具体装饰类,起到给Component添加职责的功能。
5、外观模式(Facade)
我们都知道类与类之间的耦合越低,那么可复用性就越好,如果两个类不必彼此通信,那么就不要让这两个类发生直接的相互关系,如果需要调用里面的方法,可以通过第三者来转发调用。外观模式非常好的诠释了这段话。外观模式提供了一个统一的接口,用来访问子系统中的一群接口。它让一个应用程序中子系统间的相互依赖关系减少到了最少,它给子系统提供了一个简单、单一的屏障,客户通过这个屏障来与子系统进行通信。
通过使用外观模式,使得客户对子系统的引用变得简单了,实现了客户与子系统之间的松耦合。但是它违背了“开闭原则”,因为增加新的子系统可能需要修改外观类或客户端的源代码。
参与者:
Facade: 外观角色。知道哪些子系统类负责处理请求,将客户的请求代理给适合的子系统处理。
SubSystem:子系统角色。实现子系统功能,处理Facade对象发来的请求。
6、享元模式(Flyweight)
在一个系统中对象会使得内存占用过多,特别是那些大量重复的对象,这就是对系统资源的极大浪费。享元模式对对象的重用提供了一种解决方案,它使用共享技术对相同或者相似对象实现重用。
享元模式就是运行共享技术有效地支持大量细粒度对象的复用。系统使用少量对象,而且这些都比较相似,状态变化小,可以实现对象的多次复用。这里有一点要注意:享元模式要求能够共享的对象必须是细粒度对象。
享元模式通过共享技术使得系统中的对象个数大大减少了,同时享元模式使用了内部状态和外部状态,同时外部状态相对独立,不会影响到内部状态,所以享元模式能够使得享元对象在不同的环境下被共享。同时正是分为了内部状态和外部状态,享元模式会使得系统变得更加复杂,同时也会导致读取外部状态所消耗的时间过长。
参与者:
Flyweight: 抽象享元类。所有具体享元类的超类或者接口,通过这个接口,Flyweight可以接受并作用于外部专题。
ConcreteFlyweight: 具体享元类。指定内部状态,为内部状态增加存储空间。
UnsharedConcreteFlyweight: 非共享具体享元类。指出那些不需要共享的Flyweight子类。
FlyweightFactory: 享元工厂类。用来创建并管理Flyweight对象,它主要用来确保合理地共享Flyweight,当用户请求一个Flyweight时,FlyweightFactory就会提供一个已经创建的Flyweight对象或者新建一个(如果不存在)。
7、代理模式(Proxy)、
代理模式就是给一个对象提供一个代理,并由代理对象控制对原对象的引用。它使得客户不能直接与真正的目标对象通信。代理对象是目标对象的代表,其他需要与这个目标对象打交道的操作都是和这个代理对象在交涉。
代理对象可以在客户端和目标对象之间起到中介的作用,这样起到了的作用和保护了目标对象的,同时也在一定程度上面减少了系统的耦合度。
参与者:
Subject: 抽象角色。声明真实对象和代理对象的共同接口。
Proxy: 代理角色。代理对象与真实对象实现相同的接口,所以它能够在任何时刻都能够代理真实对象。代理角色内部包含有对真实对象的引用,所以她可以操作真实对象,同时也可以附加其他的操作,相当于对真实对象进行封装。
RealSubject: 真实角色。它代表着真实对象,是我们最终要引用的对象。
四、行为型模式
行为型模式主要是用于描述类或者对象是怎样交互和怎样分配职责的。它涉及到算法和对象间的职责分配,不仅描述对象或者类的模式,还描述了他们之间的通信方式,它将你的注意力从控制流转移到了对象间的关系上来。行为型类模式采用继承机制在类间分派行为,而行为型对象模式使用对象复合而不是继承。它主要包括如何11中设计模式:职责链模式、命令模式、解释器模式、迭代器模式、中介者模式、备忘录模式、观察者模式、状态模式、策略模式、模板方法模式、访问者模式。
1、职责链模式(Chain of Responsibility)
职责链模式描述的请求如何沿着对象所组成的链来传递的。它将对象组成一条链,发送者将请求发给链的第一个接收者,并且沿着这条链传递,直到有一个对象来处理它或者直到最后也没有对象处理而留在链末尾端。
避免请求发送者与接收者耦合在一起,让多个对象都有可能接收请求,将这些对象连接成一条链,并且沿着这条链传递请求,直到有对象处理它为止,这就是职责链模式。在职责链模式中,使得每一个对象都有可能来处理请求,从而实现了请求的发送者和接收者之间的解耦。同时职责链模式简化了对象的结构,它使得每个对象都只需要引用它的后继者即可,而不必了解整条链,这样既提高了系统的灵活性也使得增加新的请求处理类也比较方便。但是在职责链中我们不能保证所有的请求都能够被处理,而且不利于观察运行时特征。
参与者:
Handler: 抽象处理者。定义了一个处理请求的方法。所有的处理者都必须实现该抽象类。
ConcreteHandler: 具体处理者。处理它所负责的请求,同时也可以访问它的后继者。如果它能够处理该请求则处理,否则将请求传递到它的后继者。
Client: 客户类。
2、命令模式(Command)
有些时候我们想某个对象发送一个请求,但是我们并不知道该请求的具体接收者是谁,具体的处理过程是如何的,们只知道在程序运行中指定具体的请求接收者即可,对于这样将请求封装成对象的我们称之为命令模式。所以命令模式将请求封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象。同时命令模式支持可撤销的操作。
命令模式可以将请求的发送者和接收者之间实现完全的解耦,发送者和接收者之间没有直接的联系,发送者只需要知道如何发送请求命令即可,其余的可以一概不管,甚至命令是否成功都无需关心。同时我们可以非常方便的增加新的命令,但是可能就是因为方便和对请求的封装就会导致系统中会存在过多的具体命令类。
参与者:
Command: 抽象命令类。用来声明执行操作的接口。
ConcreteCommand: 具体命令类。将一个接收者对象绑定于一个动作,调用接收者相应的操作,以实现Excute。
Invoker: 调用者。要求该命令执行这个请求。
Receiver: 接收者。知道如何实施与执行一个请求相关的操作,任何类都有可能成为一个接收者。
Client:客户类。
3、解释器模式(Interpreter)
所谓解释器模式就是定义语言的文法,并且建立一个解释器来解释该语言中的句子。解释器模式描述了如何构成一个简单的语言解释器,主要应用在使用面向对象语言开发的编译器中。它描述了如何为简单的语言定义一个文法,如何在该语言中表示一个句子,以及如何解释这些句子。
参与者:
AbstractExpression: 抽象表达式。声明一个抽象的解释操作,该接口为抽象语法树中所有的节点共享。
TerminalExpression: 终结符表达式。实现与文法中的终结符相关的解释操作。实现抽象表达式中所要求的方法。文法中每一个终结符都有一个具体的终结表达式与之相对应。
NonterminalExpression: 非终结符表达式。为文法中的非终结符相关的解释操作。
Context: 环境类。包含解释器之外的一些全局信息。
Client: 客户类。
4、迭代器模式(Iterator)
对于迭代在编程过程中我们经常用到,能够游走于聚合内的每一个元素,同时还可以提供多种不同的遍历方式,这就是迭代器模式的设计动机。在我们实际的开发过程中,我们可能会需要根据不同的需求以不同的方式来遍历整个对象,但是我们又不希望在聚合对象的抽象接口中充斥着各种不同的遍历操作,于是我们就希望有某个东西能够以多种不同的方式来遍历一个聚合对象,这时迭代器模式出现了。
何为迭代器模式?所谓迭代器模式就是提供一种方法顺序访问一个聚合对象中的各个元素,而不是暴露其内部的表示。迭代器模式是将迭代元素的责任交给迭代器,而不是聚合对象,我们甚至在不需要知道该聚合对象的内部结构就可以实现该聚合对象的迭代。
通过迭代器模式,使得聚合对象的结构更加简单,它不需要关注它元素的遍历,只需要专注它应该专注的事情,这样就更加符合单一职责原则了。
参与者:
Iterator: 抽象迭代器:所有迭代器都需要实现的接口,提供了游走聚合对象元素之间的方法。
ConcreteIterator: 具体迭代器。利用这个具体的迭代器能够对具体的聚合对象进行遍历。每一个聚合对象都应该对应一个具体的迭代器。
Aggregate: 抽象聚合类。
ConcreteAggregate: 具体聚合类。实现creatorIterator()方法,返回该聚合对象的迭代器。
5、中介者模式(Mediator)
租房各位都有过的经历吧!在这个过程中中介结构扮演着很重要的角色,它在这里起到一个中间者的作用,给我们和房主互相传递信息。在外面软件的世界里同样需要这样一个中间者。在我们的系统中有时候会存在着对象与对象之间存在着很强、复杂的关联关系,如果让他们之间有直接的联系的话,必定会导致整个系统变得非常复杂,而且可扩展性很差!在前面我们就知道如果两个类之间没有不必彼此通信,我们就不应该让他们有直接的关联关系,如果实在是需要通信的话,我们可以通过第三者来转发他们的请求。同样,这里我们利用中介者来解决这个问题。
所谓中介者模式就是用一个中介对象来封装一系列的对象交互,中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。在中介者模式中,中介对象用来封装对象之间的关系,各个对象可以不需要知道具体的信息通过中介者对象就可以实现相互通信。它减少了对象之间的互相关系,提供了系统可复用性,简化了系统的结构。
在中介者模式中,各个对象不需要互相知道了解,他们只需要知道中介者对象即可,但是中介者对象就必须要知道所有的对象和他们之间的关联关系,正是因为这样就导致了中介者对象的结构过于复杂,承担了过多的职责,同时它也是整个系统的核心所在,它有问题将会导致整个系统的问题。所以如果在系统的设计过程中如果出现“多对多”的复杂关系群时,千万别急着使用中介者模式,而是要仔细思考是不是您设计的系统存在问题。
参与者:
Mediator: 抽象中介者。定义了同事对象到中介者对象之间的接口。
ConcreteMediator: 具体中介者。实现抽象中介者的方法,它需要知道所有的具体同事类,同时需要从具体的同事类那里接收信息,并且向具体的同事类发送信息。
Colleague: 抽象同事类。
ConcreteColleague: 具体同事类。每个具体同事类都只需要知道自己的行为即可,但是他们都需要认识中介者。
6、备忘录模式(Memento)
后悔药人人都想要,但是事实却是残酷的,根本就没有后悔药可买,但是也不仅如此,在软件的世界里就有后悔药!备忘录模式就是一种后悔药,它给我们的软件提供后悔药的机制,通过它可以使系统恢复到某一特定的历史状态。
所谓备忘录模式就是在不破坏封装的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,这样可以在以后将对象恢复到原先保存的状态。它实现了对信息的封装,使得客户不需要关心状态保存的细节。保存就要消耗资源,所以备忘录模式的缺点就在于消耗资源。如果类的成员变量过多,势必会占用比较大的资源,而且每一次保存都会消耗一定的内存。
参与者:
Originator: 原发器。负责创建一个备忘录,用以记录当前对象的内部状态,通过也可以使用它来利用备忘录恢复内部状态。同时原发器还可以根据需要决定Memento存储Originator的那些内部状态。
Memento: 备忘录。用于存储Originator的内部状态,并且可以防止Originator以外的对象访问Memento。在备忘录Memento中有两个接口,其中Caretaker只能看到备忘录中的窄接口,它只能将备忘录传递给其他对象。Originator可以看到宽接口,允许它访问返回到先前状态的所有数据。
Caretaker: 负责人。负责保存好备忘录,不能对备忘录的内容进行操作和访问,只能够将备忘录传递给其他对象。
7、观察者模式(Observer)
何谓观察者模式?观察者模式定义了对象之间的一对多依赖关系,这样一来,当一个对象改变状态时,它的所有依赖者都会收到通知并且自动更新。
在这里,发生改变的对象称之为观察目标,而被通知的对象称之为观察者。一个观察目标可以对应多个观察者,而且这些观察者之间没有相互联系,所以么可以根据需要增加和删除观察者,使得系统更易于扩展。
所以观察者提供了一种对象设计,让主题和观察者之间以松耦合的方式结合。
参与者:
Subject:目标。他把所有对观察者对戏的引用保存在一个聚集里,每一个主题都可以有多个观察者。
Observer:观察者。为所有的具体观察者定义一个接口,在得到主题的通知时能够及时的更新自己。
ConcreteSubject:具体主题。将有关状态存入具体观察者对象。在具体主题发生改变时,给所有的观察者发出通知。
ConcreteObserver:具体观察者。实现抽象观察者角色所要求的更新接口,以便使本身的状态与主题状态相协调。
8、状态模式(State)
在很多情况下我们对象的行为依赖于它的一个或者多个变化的属性,这些可变的属性我们称之为状态,也就是说行为依赖状态,即当该对象因为在外部的互动而导致他的状态发生变化,从而它的行为也会做出相应的变化。对于这种情况,我们是不能用行为来控制状态的变化,而应该站在状态的角度来思考行为,即是什么状态就要做出什么样的行为。这个就是状态模式。
所以状态模式就是允许对象在内部状态发生改变时改变它的行为,对象看起来好像修改了它的类。
在状态模式中我们可以减少大块的if…else语句,它是允许态转换逻辑与状态对象合成一体,但是减少if…else语句的代价就是会换来大量的类,所以状态模式势必会增加系统中类或者对象的个数。
同时状态模式是将所有与某个状态有关的行为放到一个类中,并且可以方便地增加新的状态,只需要改变对象状态即可改变对象的行为。但是这样就会导致系统的结构和实现都会比较复杂,如果使用不当就会导致程序的结构和代码混乱,不利于维护。
参与者:
Context:环境类。可以包括一些内部状态。
State: 抽象状态类。State定义了一个所有具体状态的共同接口,任何状态都实现这个相同的接口,这样一来,状态之间就可以互相转换了。
ConcreteState:具体状态类。具体状态类,用于处理来自Context的请求,每一个ConcreteState都提供了它对自己请求的实现,所以,当Context改变状态时行为也会跟着改变。
9、策略模式(Strategy)
我们知道一件事可能会有很多种方式来实现它,但是其中总有一种最高效的方式,在软件开发的世界里面同样如此,我们也有很多中方法来实现一个功能,但是我们需要一种简单、高效的方式来实现它,使得系统能够非常灵活,这就是策略模式。
所以策略模式就是定义了算法族,分别封装起来,让他们之前可以互相转换,此模式然该算法的变化独立于使用算法的客户。
在策略模式中它将这些解决问题的方法定义成一个算法群,每一个方法都对应着一个具体的算法,这里的一个算法我就称之为一个策略。虽然策略模式定义了算法,但是它并不提供算法的选择,即什么算法对于什么问题最合适这是策略模式所不关心的,所以对于策略的选择还是要客户端来做。客户必须要清楚的知道每个算法之间的区别和在什么时候什么地方使用什么策略是最合适的,这样就增加客户端的负担。
同时策略模式也非常完美的符合了“开闭原则”,用户可以在不修改原有系统的基础上选择算法或行为,也可以灵活地增加新的算法或行为。但是一个策略对应一个类将会是系统产生很多的策略类。
参与者:
Context: 环境类。维护一个Strategy对象的引用,用一个ConcreteStrategy来配置,可定义一个接口来让Strategy访问它的数据。
Strategy: 抽象策略类。定义所有支持算法的公共接口。Context使用这个接口来调用某个Concretestrategy定义的算法。
ConcreteStrategy: 具体策略类。封装了具体的算法实现。
10、模板方法模式(Template Method)
有些时候我们做某几件事情的步骤都差不多,仅有那么一小点的不同,在软件开发的世界里同样如此,如果我们都将这些步骤都一一做的话,费时费力不讨好。所以我们可以将这些步骤分解、封装起来,然后利用继承的方式来继承即可,当然不同的可以自己重写实现嘛!这就是模板方法模式提供的解决方案。
所谓模板方法模式就是在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
模板方法模式就是基于继承的代码复用技术的。在模板方法模式中,我们可以将相同部分的代码放在父类中,而将不同的代码放入不同的子类中。也就是说我们需要声明一个抽象的父类,将部分逻辑以具体方法以及具体构造函数的形式实现,然后声明一些抽象方法让子类来实现剩余的逻辑,不同的子类可以以不同的方式来实现这些逻辑。所以模板方法的模板其实就是一个普通的方法,只不过这个方法是将算法实现的步骤封装起来的。
参与者:
AbstractClass: 抽象类。实现了一个模板,实现算法的基本骨架,具体子类将重定义primitiveOperation()方法以实现一个算法步骤。
ConcreteClass: 具体子类。实现primitiveOperation()方法以完成算法中与特定子类相关的步骤。
11、访问者模式(Visitor)
访问者模式俗称23大设计模式中最难的一个。除了结构复杂外,理解也比较难。在我们软件开发中我们可能会对同一个对象有不同的处理,如果我们都做分别的处理,将会产生灾难性的错误。对于这种问题,访问者模式提供了比较好的解决方案。
访问者模式即表示一个作用于某对象结构中的各元素的操作,它使我们可以在不改变各元素的类的前提下定义作用于这些元素的新操作。
访问者模式的目的是封装一些施加于某种数据结构元素之上的操作,一旦这些操作需要修改的话,接受这个操作的数据结构可以保持不变。为不同类型的元素提供多种访问操作方式,且可以在不修改原有系统的情况下增加新的操作方式。同时我们还需要明确一点那就是访问者模式是适用于那些数据结构比较稳定的,因为他是将数据的操作与数据结构进行分离了,如果某个系统的数据结构相对稳定,但是操作算法易于变化的话,就比较适用适用访问者模式,因为访问者模式使得算法操作的增加变得比较简单了
参与者:
Vistor: 抽象访问者。为该对象结构中的ConcreteElement的每一个类声明的一个操作。
ConcreteVisitor: 具体访问者。实现Visitor申明的每一个操作,每一个操作实现算法的一部分。
Element: 抽象元素。定义一个Accept操作,它以一个访问者为参数。
ConcreteElement: 具体元素 。实现Accept操作。
ObjectStructure: 对象结构。能够枚举它的元素,可以提供一个高层的接口来允许访问者访问它的元素。
版权声明
作者: chenssy
出处: http://www.cnblogs.com/chenssy/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。
如何实现1080P延迟低于500ms的实时超清直播传输技术
最近由于公司业务关系,需要一个在公网上能实时互动超清视频的架构和技术方案。众所周知,视频直播用 CDN + RTMP 就可以满足绝大部分视频直播业务,我们也接触了和测试了几家 CDN 提供的方案,单人直播没有问题,一旦涉及到多人互动延迟非常大,无法进行正常的互动交谈。对于我们做在线教育的企业来说没有互动的直播是毫无意义的,所以我们决定自己来构建一个超清晰(1080P)实时视频的传输方案。
先来解释下什么是实时视频,实时视频就是视频图像从产生到消费完成整个过程人感觉不到延迟,只要符合这个要求的视频业务都可以称为实时视频。关于视频的实时性归纳为三个等级:
- 伪实时:视频消费延迟超过 3 秒,单向观看实时,通用架构是 CDN + RTMP + HLS,现在基本上所有的直播都是这类技术。
- 准实时: 视频消费延迟 1 ~ 3 秒,能进行双方互动但互动有障碍。有些直播网站通过 TCP/UDP + FLV 已经实现了这类技术,YY 直播属于这类技术。
- 真实时:视频消费延迟 < 1秒,平均 500 毫秒。这类技术是真正的实时技术,人和人交谈没有明显延迟感。QQ、微信、Skype 和 WebRTC 等都已经实现了这类技术。
市面上大部分真实时视频都是 480P 或者 480P 以下的实时传输方案,用于在线教育和线上教学有一定困难,而且有时候流畅度是个很大的问题。在实现超清晰实时视频我们做了大量尝试性的研究和探索,在这里会把大部分细节分享出来。
要实时就要缩短延迟,要缩短延迟就要知道延迟是怎么产生的,视频从产生、编码、传输到最后播放消费,各个环节都会产生延迟,总体归纳为下图:
( 点击图片可以全屏缩放)
成像延迟,一般的技术是毫无为力的,涉及到 CCD 相关的硬件,现在市面上最好的 CCD,一秒钟 50 帧,成像延迟也在 20 毫秒左右,一般的 CCD 只有 20 ~ 25 帧左右,成像延迟 40 ~ 50 毫秒。
编码延迟,和编码器有关系,在接下来的小结介绍,一般优化的空间比较小。
我们着重针对网络延迟和播放缓冲延迟来进行设计,在介绍整个技术细节之前先来了解下视频编码和网络传输相关的知识和特点。
一、视频编码那些事
我们知道从 CCD 采集到的图像格式一般的 RGB 格式的(BMP),这种格式的存储空间非常大,它是用三个字节描述一个像素的颜色值,如果是 1080P 分辨率的图像空间:1920 x 1080 x 3 = 6MB,就算转换成 JPG 也有近 200KB,如果是每秒 12 帧用 JPG 也需要近 2.4MB/S 的带宽,这带宽在公网上传输是无法接受的。
视频编码器就是为了解决这个问题的,它会根据前后图像的变化做运动检测,通过各种压缩把变化的发送到对方,1080P 进行过 H.264 编码后带宽也就在 200KB/S ~ 300KB/S 左右。在我们的技术方案里面我们采用 H.264 作为默认编码器(也在研究 H.265)。
1.1 H.264 编码
前面提到视频编码器会根据图像的前后变化进行选择性压缩,因为刚开始接收端是没有收到任何图像,那么编码器在开始压缩的视频时需要做个全量压缩,这个全量压缩在 H.264 中 I 帧,后面的视频图像根据这个I帧来做增量压缩,这些增量压缩帧叫做 P 帧,H.264 为了防止丢包和减小带宽还引入一种双向预测编码的 B 帧,B 帧以前面的 I 或 P 帧和后面的 P 帧为参考帧。H.264 为了防止中间 P 帧丢失视频图像会一直错误它引入分组序列(GOP)编码,也就是隔一段时间发一个全量 I 帧,上一个 I 帧与下一个 I 帧之间为一个分组 GOP。它们之间的关系如下图:
PS:在实时视频当中最好不要加入 B 帧,因为 B 帧是双向预测,需要根据后面的视频帧来编码,这会增大编解码延迟。
1.2 马赛克、卡顿和秒开
前面提到如果 GOP 分组中的P帧丢失会造成解码端的图像发生错误,其实这个错误表现出来的就是马赛克。因为中间连续的运动信息丢失了,H.264 在解码的时候会根据前面的参考帧来补齐,但是补齐的并不是真正的运动变化后的数据,这样就会出现颜色色差的问题,这就是所谓的马赛克现象,如图:
这种现象不是我们想看到的。为了避免这类问题的发生,一般如果发现 P 帧或者 I 帧丢失,就不显示本 GOP 内的所有帧,直到下一个 I 帧来后重新刷新图像。但是 I 帧是按照帧周期来的,需要一个比较长的时间周期,如果在下一个 I 帧来之前不显示后来的图像,那么视频就静止不动了,这就是出现了所谓的卡顿现象。如果连续丢失的视频帧太多造成解码器无帧可解,也会造成严重的卡顿现象。视频解码端的卡顿现象和马赛克现象都是因为丢帧引起的,最好的办法就是让帧尽量不丢。
知道 H.264 的原理和分组编码技术后所谓的秒开技术就比较简单了,只要发送方从最近一个 GOP 的 I 帧开发发送给接收方,接收方就可以正常解码完成的图像并立即显示。但这会在视频连接开始的时候多发一些帧数据造成播放延迟,只要在接收端播放的时候尽量让过期的帧数据只解码不显示,直到当前视频帧在播放时间范围之内即可。
1.3 编码延迟与码率
前面四个延迟里面我们提到了编码延迟,编码延迟就是从 CCD 出来的 RGB 数据经过 H.264 编码器编码后出来的帧数据过程的时间。我们在一个 8 核 CPU 的普通客户机测试了最新版本 X.264 的各个分辨率的延迟,数据如下:
从上面可以看出,超清视频的编码延迟会达到 50ms,解决编码延迟的问题只能去优化编码器内核让编码的运算更快,我们也正在进行方面的工作。
在 1080P 分辨率下,视频编码码率会达到 300KB/S,单个 I 帧数据大小达到 80KB,单个 P 帧可以达到 30KB,这对网络实时传输造成严峻的挑战。
二、网络传输质量因素
实时互动视频一个关键的环节就是网络传输技术,不管是早期 VoIP,还是现阶段流行的视频直播,其主要手段是通过 TCP/IP 协议来进行通信。但是 IP 网络本来就是不可靠的传输网络,在这样的网络传输视频很容易造成卡顿现象和延迟。先来看看 IP 网络传输的几个影响网络传输质量关键因素。
2.1 TCP 和 UDP
对直播有过了解的人都会认为做视频传输首选的就是 TCP + RTMP,其实这是比较片面的。在大规模实时多媒体传输网络中,TCP 和 RTMP 都不占优势。TCP 是个拥塞公平传输的协议,它的拥塞控制都是为了保证网络的公平性而不是快速到达,我们知道,TCP 层只有顺序到对应的报文才会提示应用层读数据,如果中间有报文乱序或者丢包都会在 TCP 做等待,所以 TCP 的发送窗口缓冲和重发机制在网络不稳定的情况下会造成延迟不可控,而且传输链路层级越多延迟会越大。
关于 TCP 的原理:
http://coolshell.cn/articles/11564.html
关于 TCP 重发延迟:
http://weibo.com/p/1001603821691477346388
在实时传输中使用 UDP 更加合理,UDP 避免了 TCP 繁重的三次握手、四次挥手和各种繁杂的传输特性,只需要在 UDP 上做一层简单的链路 QoS 监测和报文重发机制,实时性会比 TCP 好,这一点从 RTP 和 DDCP 协议可以证明这一点,我们正式参考了这两个协议来设计自己的通信协议。
2.2 延迟
要评估一个网络通信质量的好坏和延迟一个重要的因素就是 Round-Trip Time(网络往返延迟),也就是 RTT。评估两端之间的 RTT 方法很简单,大致如下:
- 发送端方一个带本地时间戳 T1 的 ping 报文到接收端;
- 接收端收到 ping 报文,以 ping 中的时间戳 T1 构建一个携带 T1 的 pong 报文发往发送端;
- 发送端接收到接收端发了的 pong 时,获取本地的时间戳 T2,用 T2 – T1 就是本次评测的 RTT。
示意图如下:
( 点击图片可以全屏缩放)
上面步骤的探测周期可以设为 1 秒一次。为了防止网络突发延迟增大,我们采用了借鉴了 TCP 的 RTT 遗忘衰减的算法来计算,假设原来的 RTT 值为 rtt,本次探测的 RTT 值为 keep_rtt。那么新的 RTT 为:
new_rtt = (7 * rtt + keep_rtt) / 8
可能每次探测出来的 keep_rtt 会不一样,我们需要会计算一个 RTT 的修正值 rtt_var,算法如下:
new_rtt_var = (rtt_var * 3 + abs(rtt – keep_rtt)) / 4
rtt_var 其实就是网络抖动的时间差值。
如果 RTT 太大,表示网络延迟很大。我们在端到端之间的网络路径同时保持多条并且实时探测其网络状态,如果 RTT 超出延迟范围会进行传输路径切换(本地网络拥塞除外)。
2.3 抖动和乱序
UDP 除了延迟外,还会出现网络抖动。什么是抖动呢?举个例子,假如我们每秒发送 10 帧视频帧,发送方与接收方的延迟为 50MS,每帧数据用一个 UDP 报文来承载,那么发送方发送数据的频率是 100ms 一个数据报文,表示第一个报文发送时刻 0ms, T2 表示第二个报文发送时刻 100ms . . .,如果是理想状态下接收方接收到的报文的时刻依次是(50ms, 150ms, 250ms, 350ms….),但由于传输的原因接收方收到的报文的相对时刻可能是(50ms, 120ms, 240ms, 360ms ….),接收方实际接收报文的时刻和理想状态时刻的差值就是抖动。如下示意图:
( 点击图片可以全屏缩放)
我们知道视频必须按照严格是时间戳来播放,否则的就会出现视频动作加快或者放慢的现象,如果我们按照接收到视频数据就立即播放,那么这种加快和放慢的现象会非常频繁和明显。也就是说网络抖动会严重影响视频播放的质量,一般为了解决这个问题会设计一个视频播放缓冲区,通过缓冲接收到的视频帧,再按视频帧内部的时间戳来播放既可以了。
UDP 除了小范围的抖动以外,还是出现大范围的乱序现象,就是后发的报文先于先发的报文到达接收方。乱序会造成视频帧顺序错乱,一般解决的这个问题会在视频播放缓冲区里做一个先后排序功能让先发送的报文先进行播放。
播放缓冲区的设计非常讲究,如果缓冲过多帧数据会造成不必要的延迟,如果缓冲帧数据过少,会因为抖动和乱序问题造成播放无数据可以播的情况发生,会引起一定程度的卡顿。关于播放缓冲区内部的设计细节我们在后面的小节中详细介绍。
2.4 丢包
UDP 在传输过程还会出现丢包,丢失的原因有多种,例如:网络出口不足、中间网络路由拥堵、socket 收发缓冲区太小、硬件问题、传输损耗问题等等。在基于 UDP 视频传输过程中,丢包是非常频繁发生的事情,丢包会造成视频解码器丢帧,从而引起视频播放卡顿。这也是大部分视频直播用 TCP 和 RTMP 的原因,因为 TCP 底层有自己的重传机制,可以保证在网络正常的情况下视频在传输过程不丢。基于 UDP 丢包补偿方式一般有以下几种:
报文冗余
报文冗余很好理解,就是一个报文在发送的时候发送 2 次或者多次。这个做的好处是简单而且延迟小,坏处就是需要额外 N 倍(N 取决于发送的次数)的带宽。
FEC
Forward Error Correction,即向前纠错算法,常用的算法有纠删码技术(EC),在分布式存储系统中比较常见。最简单的就是 A B 两个报文进行 XOR(与或操作)得到 C,同时把这三个报文发往接收端,如果接收端只收到 AC,通过 A 和 C 的 XOR 操作就可以得到 B 操作。这种方法相对增加的额外带宽比较小,也能防止一定的丢包,延迟也比较小,通常用于实时语音传输上。对于 1080P 300KB/S 码率的超清晰视频,哪怕是增加 20% 的额外带宽都是不可接受的,所以视频传输不太建议采用 FEC 机制。
丢包重传
丢包重传有两种方式,一种是 push 方式,一种是 pull 方式。Push 方式是发送方没有收到接收方的收包确认进行周期性重传,TCP 用的是 push 方式。pull 方式是接收方发现报文丢失后发送一个重传请求给发送方,让发送方重传丢失的报文。丢包重传是按需重传,比较适合视频传输的应用场景,不会增加太对额外的带宽,但一旦丢包会引来至少一个 RTT 的延迟。
2.5 MTU 和最大 UDP
IP 网定义单个 IP 报文最大的大小,常用 MTU 情况如下:
超通道 65535
16Mb/s 令牌环 179144
Mb/s 令牌环 4464
FDDI 4352
以太网 1500
IEEE 802.3/802.2 1492
X.25 576
点对点(低时延)296
红色的是 Internet 使用的上网方式,其中 X.25 是个比较老的上网方式,主要是利用 ISDN 或者电话线上网的设备,也不排除有些家用路由器沿用 X.25 标准来设计。所以我们必须清晰知道每个用户端的 MTU 多大,简单的办法就是在初始化阶段用各种大小的 UDP 报文来探测 MTU 的大小。MTU 的大小会影响到我们视频帧分片的大小,视频帧分片的大小其实就是单个 UDP 报文最大承载的数据大小。
分片大小 = MTU – IP 头大小 – UDP 头大小 – 协议头大小;
IP 头大小 = 20 字节, UDP 头大小 = 8 字节。
为了适应网络路由器小包优先的特性,我们如果得到的分片大小超过 800 时,会直接默认成 800 大小的分片。
三、传输模型
我们根据视频编码和网络传输得到特性对 1080P 超清视频的实时传输设计了一个自己的传输模型,这个模型包括一个根据网络状态自动码率的编解码器对象、一个网络发送模块、一个网络接收模块和一个 UDP 可靠到达的协议模型。各个模块的关系示意图如下:
( 点击图片可以全屏缩放)
3.1 通信协议
先来看通信协议,我们定义的通信协议分为三个阶段:接入协商阶段、传输阶段、断开阶段。
接入协商阶段:
主要是发送端发起一个视频传输接入请求,携带本地的视频的当前状态、起始帧序号、时间戳和 MTU 大小等,接收方在收到这个请求后,根据请求中视频信息初始化本地的接收通道,并对本地 MTU 和发送端 MTU 进行比较取两者中较小的回送给发送方, 让发送方按协商后的 MTU 来分片。示意图如下:
( 点击图片可以全屏缩放)
传输阶段:
传输阶段有几个协议,一个测试量 RTT 的 PING/PONG 协议、携带视频帧分片的数据协议、数据反馈协议和发送端同步纠正协议。其中数据反馈协议是由接收反馈给发送方的,携带接收方已经接收到连续帧的报文 ID、帧 ID 和请求重传的报文 ID 序列。同步纠正协议是由发送端主动丢弃发送窗口缓冲区中的报文后要求接收方同步到当前发送窗口位置,防止在发送主动丢弃帧数据后接收方一直要求发送方重发丢弃的数据。示意图如下:
( 点击图片可以全屏缩放)
断开阶段:
就一个断开请求和一个断开确认,发送方和接收方都可以发起断开请求。
3.2 发送
发送主要包括视频帧分片算法、发送窗口缓冲区、拥塞判断算法、过期帧丢弃算法和重传。先一个个来介绍。
帧分片
前面我们提到 MTU 和视频帧大小,在 1080P 下大部分视频帧的大小都大于 UDP 的 MTU 大小,那么就需要对帧进行分片,分片的方法很简单,按照先连接过程协商后的 MTU 大小来确定分片大小(确定分片大小的算法在 MTU 小节已经介绍过),然后将 帧数据按照分片大小切分成若干份,每一份分片以 segment 报文形式发往接收方。
重传
重传比较简单,我们采用 pull 方式来实现重传,当接收方发生丢包,如果丢包的时刻 T1 + rtt_var< 接收方当前的时刻 T2,就认为是丢包了,这个时候就会把所有满足这个条件丢失的报文 ID 构建一个 segment ack 反馈给发送方,发送方收到这个反馈根据 ID 到重发窗口缓冲区中查找对应的报文重发即可。
为什么要间隔一个 rtt_var 才认为是丢包了?因为报文是有可能乱序到达,所有要等待一个抖动周期后认为丢失的报文还没有来才确认是报文丢失了,如果检测到丢包立即发送反馈要求重传,有可能会让发送端多发数据,造成带宽让费和网络拥塞。
发送窗口缓冲区
发送窗口缓冲区保存这所有正在发送且没有得到发送方连续 ID 确认的报文。当接收方反馈最新的连续报文 ID,发送窗口缓冲就会删除所有小于最新反馈连续的报文 ID,发送窗口缓冲区缓冲的报文都是为了重发而存在。这里解释下接收方反馈的连续的报文 ID,举个例子,假如发送方发送了 1. 2. 3. 4. 5,接收方收到 1.2. 4. 5。这个时候最小连续 ID = 2,如果后面又来了 3,那么接收方最小连续 ID = 5。
拥塞判断
我们把当前时间戳记为 curr_T,把发送窗口缓冲区中最老的报文的时间戳记为 oldest_T,它们之间的间隔记为 delay,那么
delay = curr_T – oldest_T
在编码器请求发送模块发送新的视频帧时,如果 delay > 拥塞阈值 Tn,我们就认为网络拥塞了,这个时候会根据最近 20 秒接收端确认收到的数据大小计算一个带宽值,并把这个带宽值反馈给编码器,编码器收到反馈后,会根据带宽调整编码码率。如果多次发生要求降低码率的反馈,我们会缩小图像的分辨率来保证视频的流畅性和实时性。Tn 的值可以通过 rtt 和 rtt_var 来确定。
但是网络可能阶段性拥塞,过后却恢复正常,我们设计了一个定时器来定时检查发送方的重发报文数量和 delay,如果发现恢复正常,会逐步增大编码器编码码率,让视频恢复到指定的分辨率和清晰度。
过期帧丢弃
在网络拥塞时可能发送窗口缓冲区中有很多报文正在发送,为了缓解拥塞和减少延迟我们会对整个缓冲区做检查,如果有超过一定阈值时间的 H.264 GOP 分组存在,我们会将这个 GOP 所有帧的报文从窗口缓冲区移除。并将它下一个 GOP 分组的 I 的帧 ID 和报文 ID 通过 wnd sync 协议同步到接收端上,接收端接收到这个协议,会将最新连续 ID 设置成同步过来的 ID。这里必须要说明的是如果频繁出现过期帧丢弃的动作会造成卡顿,说明当前网络不适合传输高分辨率视频,可以直接将视频设成更小的分辨率
3.3 接收
接收主要包括丢包管理、播放缓冲区、缓冲时间评估和播放控制,都是围绕播放缓冲区来实现的,一个个来介绍。
丢包管理
丢包管理包括丢包检测和丢失报文 ID 管理两部分。丢包检测过程大致是这样的,假设播放缓冲区的最大报文 ID 为 max_id,网络上新收到的报文 ID 为 new_id,如果 max_id + 1 < new_id,那么可能发生丢包,就会将 [max_id + 1, new_id -1] 区间中所有的 ID 和当前时刻作为 K/V 对加入到丢包管理器当中。如果 new_id < max_id,那么就将丢包管理中的 new_id 对应的 K/V 对删除,表示丢失的报文已经收到。当收包反馈条件满足时,会扫描整个丢包管理,将达到请求重传的丢包 ID 加入到 segment ack 反馈消息中并发往发送方请求重传,如果 ID 被请求了重传,会将当前时刻设置为 K/V 对中,增加对应报文的重传计数器 count,这个扫描过程会统计对包管理器中单个重发最多报文的重发次数 resend_count。
缓冲时间评估
在前面的抖动与乱序小节中我们提到播放端有个缓冲区,这个缓冲区过大时延迟就大,缓冲区过小时又会出现卡顿现象,我们针对这个问题设计了一个缓冲时间评估的算法。缓冲区评估先会算出一个 cache timer,cache timer 是通过扫描对包管理得到的 resend count 和 rtt 得到的,我们知道从请求重传报文到接收方收到重传的报文的时间间隔是一个 RTT 周期,所以 cache timer 的计算方式如下。
cache timer = (2 * resend_count+ 1) * (rtt + rtt_var) / 2
有可能 cache timer 计算出来很小(小于视频帧之间间隔时间 frame timer),那么 cache timer = frame timer,也就是说网络再好,缓冲区缓冲区至少 1 帧视频的数据,否则缓冲区是毫无意义的。
如果单位时间内没有丢包重传发生,那么 cache timer 会做适当的缩小,这样做的好处是当网络间歇性波动造成 cache timer 很大,恢复正常后 cache timer 也能恢复到相对小位置,缩减不必要的缓冲区延迟。
播放缓冲区
我们设计的播放缓冲区是按帧 ID 为索引的有序循环数组,数组内部的单元是视频帧的具体信息:帧 ID、分片数、帧类型等。缓冲区有两个状态:waiting 和 playing,waiting 状态表示缓冲区处于缓冲状态,不能进行视频播放直到缓冲区中的帧数据达到一定的阈值。Playing 状态表示缓冲区进入播放状态,播放模块可以从中取出帧进行解码播放。我们来介绍下这两个状态的切换关系:
- 当缓冲区创建时会被初始化成 waiting 状态。
- 当缓冲区中缓冲的最新帧与最老帧的时间戳间隔 > cache timer 时,进入 playing 状态并更当前时刻设成播放绝对时间戳 play ts。
- 当缓冲区处于 playing 状态且缓冲区是没有任何帧数据,进入 waiting 状态直到触发第 2 步。
播放缓冲区的目的就是防止抖动和应对丢包重传,让视频流能按照采集时的频率进行播放,播放缓冲区的设计极其复杂,需要考虑的因素很多,实现的时候需要慎重。
播放控制
接收端最后一个环节就是播放控制,播放控制就是从缓冲区中拿出有效的视频帧进行解码播放。但是怎么拿?什么时候拿?我们知道视频是按照视频帧从发送端携带过来的相对时间戳来做播放,我们每一帧视频都有一个相对时间戳 TS,根据帧与帧之间的 TS 的差值就可以知道上一帧和下一帧播放的时间间隔,假如上一帧播放的绝对时间戳为 prev_play_ts,相对时间戳为 prev_ts,当前系统时间戳为 curr_play_ts,当前缓冲区中最小序号帧的相对时间戳为 frame_ts,只要满足:
Prev_play_ts + (frame_ts – prev_ts) < curr_play_ts 且这一帧数据是所有的报文都收齐了
这两个条件就可以进行解码播放,取出帧数据后将 Prev_play_ts = cur_play_ts,但更新 prev_ts 有些讲究,为了防止缓冲延迟问题我们做了特殊处理。
如果 frame_ts + cache timer < 缓冲区中最大帧的 ts,表明缓冲的时延太长,则 prev_ts = 缓冲区中最大帧的 ts – cache timer。 否则 prev_ts = frame_ts。
四、测量
再好的模型也需要有合理的测量方式来验证,在多媒体这种具有时效性的传输领域尤其如此。一般在实验室环境我们采用 netem 来进行模拟公网的各种情况进行测试,如果在模拟环境已经达到一个比较理想的状态后会组织相关人员在公网上进行测试。下面来介绍怎么来测试我们整个传输模型的。
4.1 netem 模拟测试
Netem 是 Linux 内核提供的一个网络模拟工具,可以设置延迟、丢包、抖动、乱序和包损坏等,基本能模拟公网大部分网络情况。
关于 netem 可以访问它的官网:
https://wiki.linuxfoundation.org/networking/netem
我们在实验环境搭建了一个基于服务器和客户端模式的测试环境,下面是测试环境的拓扑关系图:
我们利用 Linux 来做一个路由器,服务器和收发端都连接到这个路由器上,服务器负责客户端的登记、数据转发、数据缓冲等,相当于一个简易的流媒体服务器。Sender 负责媒体编码和发送,receiver 负责接收和媒体播放。为了测试延迟,我们把 sender 和 receiver 运行在同一个 PC 机器上,在 sender 从 CCD 获取到 RGB 图像时打一个时间戳,并把这个时间戳记录在这一帧数据的报文发往 server 和 receiver,receiver 收到并解码显示这帧数据时,通过记录的时间戳可以得到整个过程的延迟。我们的测试用例是用 1080P 码率为 300KB/S 视频流,在 router 用 netem 上模拟了以下几种网络状态:
- 环路延迟 10m,无丢包,无抖动,无乱序
- 环路延迟 30ms,丢包 0.5%,抖动 5ms, 2% 乱序
- 环路延迟 60ms,丢包 1%,抖动 20ms, 3% 乱序,0.1% 包损坏
- 环路延迟 100ms,丢包 4%,抖动 50ms, 4% 乱序,0.1% 包损坏
- 环路延迟 200ms,丢包 10%,抖动 70ms, 5% 乱序,0.1% 包损坏
- 环路延迟 300ms,丢包 15%,抖动 100ms, 5% 乱序,0.1% 包损坏
因为传输机制采用的是可靠到达,那么检验传输机制有效的参数就是视频延迟,我们统计 2 分钟周期内最大延迟,以下是各种情况的延迟曲线图:
从上图可以看出,如果网络控制在环路延迟在 200ms 丢包在 10% 以下,可以让视频延迟在 500ms 毫秒以下,这并不是一个对网络质量要求很苛刻的条件。所以我们在后台的媒体服务部署时,尽量让客户端到媒体服务器之间的网络满足这个条件,如果网路环路延迟在 300ms 丢包 15% 时,依然可以做到小于 1 秒的延迟,基本能满足双向互动交流。
4.2 公网测试
公网测试相对比较简单,我们将 Server 部署到 UCloud 云上,发送端用的是上海电信 100M 公司宽带,接收端用的是河北联通 20M 小区宽带,环路延迟在 60ms 左右。总体测试下来 1080P 在接收端观看视频流畅自然,无抖动,无卡顿,延迟统计平均在 180ms 左右。
五、坑
在整个 1080P 超清视频的传输技术实现过程中,我们遇到过比较多的坑。大致如下:
Socket 缓冲区问题
我们前期开发阶段都是使用 socket 默认的缓冲区大小,由于 1080P 图像帧的数据非常巨大(关键帧超过 80KB),我们发现在在内网测试没有设置丢包的网络环境发现接收端有严重的丢包,经查证是 socket 收发缓冲区太小造成丢包的,后来我们把 socket 缓冲区设置到 128KB 大小,问题解决了。
H.264 B 帧延迟问题
前期我们为了节省传输带宽和防丢包开了 B 帧编码,由于 B 帧是前后双向预测编码的,会在编码期滞后几个帧间隔时间,引起了超过 100ms 的编码延时,后来我们为了实时性干脆把 B 帧编码选项去掉。
Push 方式丢包重传
在设计阶段我们曾经使用发送端主动 push 方式来解决丢包重传问题,在测试过程发现在丢包频繁发生的情况下至少增加了 20% 的带宽消耗,而且容易带来延迟和网络拥塞。后来几经论证用现在的 pull 模式来进行丢包重传。
Segment 内存问题
在设计阶段我们对每个视频缓冲区中的帧信息都是动态分配内存对象的,由于 1080P 在传输过程中每秒会发送 400 – 500 个 UDP 报文,在 PC 端长时间运行容易出现内存碎片,在服务器端出现莫名其妙的 clib 假内存泄露和并发问题。我们实现了一个 memory slab 管理频繁申请和释放内存的问题。
音频和视频数据传输问题
在早期的设计之中我们借鉴了 FLV 的方式将音频和视频数据用同一套传输算法传输,好处就是容易实现,但在网络波动的情况下容易引起声音卡顿,也无法根据音频的特性优化传输。后来我们把音频独立出来,针对音频的特性设计了一套低延迟高质量的音频传输体系,定点对音频进行传输优化。
后续的工作是重点放在媒体器多点分布、多点并发传输、P2P 分发算法的探索上,尽量减少延迟和服务带宽成本,让传输变的更高效和更低廉。
Q&A
提问:在优化到 500ms 方案中,哪一块是最关键的?
袁荣喜:主要是丢包重传 拥塞和播放缓冲这三者之间的协调工作最为关键,要兼顾延迟控制和视频流畅性。
提问:多方视频和单方有哪些区别,用到了 CDN 推流吗?
袁荣喜:我们公司是做在线教育的,很多场景需要老师和学生交谈,用 CDN 推流方式延迟很大,我们这个视频主要是解决多方通信之间交谈延迟的问题。我们现在观看放也有用 CDN 推流,但只是单纯的观看。我们也在研发基于 UDP 的观看端分发协议,目前这部分工作还没有完成。