Quantcast
Channel: IT瘾技术推荐
Viewing all 330 articles
Browse latest View live

京东亿级商品搜索核心技术解密

$
0
0

作者:王春明,现任京东搜索平台部负责人,2011年加入京东搜索团队,期间一直负责京东搜索引擎研发工作,主导了多次搜索架构升级工作保障其满足京东发展需求,擅长搜索引擎、高性能服务开发、分布式系统架构。

招聘: 京东搜索平台部木有有高级/资深搜索引擎研发工程师(C/C++)  、高级/资深算法工程师(C/C++)、高级/资深数据系统工程师(java)等职位,期待您的加入,一起打造弹性搜索平台。简历投递至:wangchunming@jd.com,工作地点:北京-北辰世纪中心A座。

京东商品搜索简介

京东商品搜索引擎是搜索推荐部自主研发的商品搜索引擎,主要功能是为海量京东用户提供精准、快速的购物体验。目前入口主要有PC/移动/微信/手Q搜索、移动列表页、店铺搜索、店铺列表等。虽然只有短短几年的时间,系统已经能够支持日均PV过亿的请求,并且经过了多次618店庆和双11的考验。

与人们日常使用的如谷歌、百度等大搜索(或称为“全文搜索”)引擎相比,京东商品搜索引擎与前者有相通之处,比如“覆盖海量数据”、“超高并发查询”以及“超快速的请求响应时间”,同时又有自身显著的业务特点:

  • 结构化的商品数据,需要从商品、库存、价格、促销、仓储等多个系统进行抽取;
  • 极高的召回率要求,保证每一个状态正常的商品都能够被搜索到;
  • 商品信息的及时更新,目的是为了保证用户极佳的购物体验——比如不能给用户展示出下柜的商品,或者商品的实时价格超出了用户搜索限定的范围。这就要求我们的搜索引擎要做到和各个系统的信息时刻保持同步,目前每天更新次数过亿;
  • 逻辑复杂的商品业务,需要存储的商品属性信息是倒排索引信息的2倍之多;
  • 用户购物的个性化需求,要求系统实现用户标签与商品标签的匹配。

正是由于既要兼顾大搜索引擎的通用需求,同时要契合京东的业务特点,我们将系统架构分为四个部分:1. 爬虫系统、2. 离线信息处理系统、3. 索引系统、4. 搜索服务系统。

为了使各位读者能够深入了解京东商品搜索引擎的架构,本文首先介绍了商品搜索的总体架构,然后依次介绍了爬虫系统、离线信息处理系统等各个部分,并且对搜索技术的最新研究方向做展望,希望对各位读者有所帮助。

总体架构

京东商品搜索引擎的整体架构如下图所示:

京东商品搜索引擎

从上到下共分为3层。最上层是由搜索的前端UI层,负责页面展示。

中间层是由搜索索引服务、SUG搜索、相关搜索、划词服务和兜底服务组成。其中,SUG搜索提供输入框下拉提示词功能;相关搜索提供与query相关的其他搜索词服务;划词服务提供去除query部分词的功能;兜底服务用于索引服务异常情况下提供托底,保证用户基本的搜索可用。

最下层是索引生产端,主要功能是对接商品、库存、价格、促销、仓储等众多外部系统,整合相关数据生产全量和增量数据的索引,为在线检索服务集群提供全量索引和实时索引数据。

爬虫系统

商品搜索引擎的核心是建立商品索引,而建立索引需要详细的商品信息数据。我们利用大数据平台的数据库抽取接口和中间件系统,实现了站内商品爬虫系统,用来抽取数据库中的商品信息和及时发现变化的商品信息。从实践的效果上来看,爬虫系统表现是非常稳定和可靠的。

离线信息处理系统

离线信息处理系统主要功能是用来建立商品搜索引擎的待索引数据,包括全量待索引数据和增量待索引数据。

目前商品全量待索引数据按天进行更新,一部分是商品的基础属性信息,如商品sku、商品名称、颜色、规格、风格、材质面料等等,属于比较稳定、短时期内不会变化的数据。另外一部分是商品销售信息,如商品销量、销售额、评论等,属于易变数据。这些数据散布于多个系统中,使用的存储也各不相同。因此需要对这些来源分散的数据在商品维度进行合并,生成“商品全量待索引宽表”。目前我们建立的全量待索引宽表,不仅应用于搜索引擎服务,还同时应用于个性化推荐等其他产品服务当中。但是仅生成宽表是无法完成搜索引擎的索引需求的,因此我们利用Hadoop/MapReduce计算框架对宽表数据进行清洗,并且依照离线业务逻辑规则对数据进行二次“加工”,最终生成一份全量待索引数据。

有些商品信息,比如“价格”、“库存”、“上下架”等,经常会产生变化,因此对这些数据做全量索引满足不了商品搜索引擎的需求。为了解决数据实时性的强需求,我们建立了增量索引作为全量索引的补充。具体细节上,采用和全量索引类似的方法对数据进行处理,生成增量待索引数据。为了保证增量数据的及时性和准确性,离线信息处理系统会实时调用各商品信息接口获取数据,完成增量待索引数据的在线组装和生产。

索引系统

索引系统是商品搜索引擎的核心,主要功能是把以商品为维度进行存储的待索引数据,转换成以关键字为维度进行存储的数据,用于搜索引擎上层服务进行调用。这里待索引数据指前面离线信息处理系统生成的全量待索引数据和增量待索引数据。

此系统对于全量和增量的处理是一致的,唯一的区别在于待处理数据量的差异。一般情况下,全量数据索引由于数据量庞大,采用Hadoop/MapReduce进行;实时数据量小,采用单机进行索引生产。

为了满足分布式检索的需求,索引系统还会对索引数据进行分片处理,即按照一定策略将索引数据拆分成较小索引片,用于搜索服务系统调用。

搜索服务系统

搜索索引服务系统主要功能是接受用户请求并响应,返回搜索结果。搜索服务系统的发展也经历了从无到有,从简单到丰富到过程。主要分为如下几个阶段:

  • 最初,搜索服务只有1列searcher组成在线检索服务,能够完成一些简单的商品搜索;
  • 随着访问量的增长,搜索服务系统增加了缓存模块,大大加快了请求处理的速度;
  • 接下来为了提高用户体验,我们增加了Query Processor服务,负责用户查询意图分析,提升搜索的准确性。目前Query Processor已经成为了一个融合自然语言处理、机器学习等先进技术的成熟服务,并且还在不断的进行优化;
  • 为了支持个性化,增加了User Profile服务,负责查询用户标签。将商品的标签与用户标签是否匹配,作为一个特征加入排序因子,实现搜索的千人千面;
  • 接着随着数据量(商品量)的增长,我们将结果包装功能从检索服务中独立出去,成为detail服务(基于缓存云实现的商品信息KV查询服务);
  • 将检索服务进行分片化处理,即采用类似数据库分库分表的思想,对商品id,进行hash处理后进行分片,保证各个分片数据均匀。查询时,将一个搜索请求分配到多个searcher列上,并行检索,进行局部排序后返回给merger。然后merger服务,将多个分片的检索结果进行归并,然后再进行业务排序和加工,确定要返回的商品,最后调用detail服务包装,将结果返给给blender。blender将多个搜索的结果进行融合,返回给前端。需要说明的是,此时搜索服务系统已经成为了一个“多blender&多Searcher&多merger”的系统。今后无论是访问量的增长或者数据量的增长,都可以通过扩容来满足。尤其对于618店庆、11.11之类的峰值搜索量剧增的情况下,可通过增加每个searcher列服务器的数量来满足需求。随着商品数据的不断增加,只要适时对数据做更多的分片,相应增加searcher列就可以了。检索服务分片化机制的建立也标志着京东搜索基础服务系统已经趋于完备。

完整的搜索索引服务架构,如下图所示:

搜索索引

搜索请求流程如下:

  1. 外部请求通过vip到达blender;
  2. Blender调用QP,QP调用运营平台,其中运营平台主要负责将日常运营数据服务化,QP负责分析query;
  3. Blender同时请求Merger和其他垂直搜索服务;
  4. Merger调用UserProfile获取用户标签信息;
  5. Merger将请求发给每列searcher;
  6. 每个searcher召回商品并返给Merger;
  7. Merger合并多列searcher的结果,确定需要输出的商品,请求Datail包装对应的商品信息;
  8. Detail包装商品信息返给Merger;
  9. Merger将包装好的商品返给blender;
  10. Blender将merger返回的结果与其他垂直搜索结果进行合并,最终返回给前端。

Blender、Merger、Searcher和Detail是整个系统的核心组件,它们之间的调用关系由Clustermap管理。各个模块将自己的服务注册到ClusterMap,同时从ClusterMap订阅其调用模块的信息来确定实际调用关系。

简要搜索服务流程,如下图所示(搜索服务系统内部处理流程):

搜索服务流程

图中名词解释如下:

  • Page cache:页面缓存,blender模块直接缓存输出的页面,merger缓存了多页商品id;
  • Attr cache:属性缓存,缓存的搜索属性导航区的数据;
  • Doc cache:缓存查询词从全量索引召回的结果;
  • OP:运营平台服务,负责搜索运营数据的服务化;
  • QP:query processor,负责query意图识别。

用户请求发送到blender,首先解析参数。如果命中blender page cache直接返回给用户。如果没有命中,则调用运营平台服务(OP)和QP,并将其传给Merger,Merge会检查是否命中Attr cache,如果命中并且恰好仅请求属性汇总结果,直接返回给blender。否则进一步查看是否命中merger page cahce,如果命中直接调用detail包装,返给blender。如果没有命中,则调用User Profile获取用户标签,将其传给searcher(篇幅所限,图中只列了一个searcher,实际是多个)。Searcher接到请求,判断是否命中doc cache,如果命中doc cache,则拉取增量结果;如果没有命中doc cahe,则拉取全量和增量结果。然后依次进行排序、在线业务处理,把结果返给merger。Merger合并多个searcher结果,排序、在线业务处理,最后调用detail包装,最后将结果返给blender,blender合并多个搜索结果后返回给用户。

作为一个高并发系统,为了保证高召回率和低响应延时,我们把整个搜索服务流程的处理全部放在内存当中进行计算。多个searcher并发处理请求,同时单个searcher内部采用线程池技术,即所有线程之间共享倒排索引和商品属性信息,提高内存使用效率;每个查询使用一个独立线程串行执行,保证并发的多个查询线程之间互不影响。此外通过合理的设置线程池的大小,我们可以保证系统的CPU资源得到充分利用。在上述两个方面对系统进行优化之后,整个搜索服务系统的稳定性、召回率、内存使用率、计算速度等指标都有大幅度的提高。但是我们改进系统的步伐并没有停歇,因为通过实践发现基于内存和线程池的搜索服务仍然有几个瓶颈点亟需解决,主要包括:拉取倒排、排序和在线业务处理。针对这些问题,我们进行了二次优化,主要包括如下措施:

1. 多级缓存策略

  1. Blender Page cache:由于搜索符合互联网的二八法则,20%热门查询频度非常高,占每天搜索请求量80%。针对这一特点,搜索第一级缓存以查询请求为key,将返回给用户的页面作为value。对于完全相同的请求,直接从缓存返回结果。页面缓存策略上线伊始,缓存命中率就接近了30%,基本解决了当时的性能问题。
  2. Merge Page cache:随着业务的发展,排序结果需要针对不同用户实现个性化订制,这就导致请求中会包含用户的user pin。如果直接将user pin放入缓存作为key,会导致blender cache的key数量暴增,不但需要超大的缓存空间,同时缓存的命中率也会极低,最终会导致线上个性化服务的体验满意度降低。为了解决这个问题,将user_pin加入key,但是value只保存排序好的商品id,这样需要的缓存空间远远小于blender cache。当命中缓存后,调用detail直接进行结果包装。为了进一步提高缓存命中率,利用用户搜索的翻页习惯,即离线统计出用户的翻页数TP99,然后在value中缓存这些页面涉及到所有的商品id,从实践效果来看,用户后续的翻页请求大部分会命中cache。
  3. 在深入分析了业务和排序的需求之后,我们发现拉取倒排的结果只和“查询词&筛选条件”有关,而与用户无关,因此可以按照“查询词&筛选条件”作为key的方式对其进行缓存。

虽然拉取倒排结果缓存的key很快就解决了,但是我们在解决Value的存储时遇到了两个问题:1)拉取倒排的结果非常之多,导致缓存过大;2)对此结果缓存,会降低实时索引的时效性。

对于问题1),在分析了业务之后,对需要缓存的信息进行了大量的精简并采用压缩存储,最终将一个查询的缓存控制在0.5M以下。

对于问题2),我们将拉取倒排结果分为两部分,第一部分是从全量索引拉取倒排的结果,第二部分是从实时索引拉取倒排的结果。为了和全量索引的更新频率保持同步,我们把第一部分数据进行缓存的周期置为1天。对于第二部分数据,由于增量结果远远少于全量结果(一般增量只有全量5%不到),每次缓存都进行实时计算,这就是图3中的doc cache机制。从实践中来看,命中doc cache的响应时间比未命中的降低了1-2个数量级。将来随着增量结果的积累,如果实时拉取倒排结果成为性能瓶颈,可以对增量索引分段也进行缓存。

2. 截断策略

对于有些热门查询,由于其结果较多,比如“男装”、“鞋”之类的query,原始查询结果几千万个,如果对这些结果挨个进行处理,性能会非常差。同时,从用户角度分析,一个查询只有排在最前面的结果对用户才有意义。通过分析用户翻页次数,可以得到截断保留topN结果。如何保证截断不影响用户体验呢?首先我们对商品建立离线模型,即为每个商品计算出一个质量分数据。然后在索引阶段,将所有商品按照质量分降序排列,保证在倒排链中,排在前面的商品质量分总是高于后面的。在线从前往后拉取倒排过程中,如果结果数达到10*topN时,停止拉取倒排。随后对结果计算文本相关性,再按照文本相关性取topN个。截断算法上线前后,虽然KPI指标无明显变化,但是对大结果查询性能提升了一个数量级。

3. 均匀分片策略

从总体架构图中我们可以看到,如果我们将一个term的倒排链进行均分,那么相应term的拉取倒排也会被分配至各个searcher列。正是由于各个searcher列是并行计算的,这样的均分操作就可以大大减少每个查询的平均响应时间。从理论上来讲,我们采用的均匀分片策略,也有效的契合了拉取倒排、排序、在线业务处理等CPU密集型的任务。但是分片增加,会带来硬件成本增高的后果,同时集群节点间的通信成本也会增加,需要进一步权衡折衷。

4. 业务优化

京东的搜索业务并不只有上面所述的策略和工程逻辑,还必须融合很多业务逻辑。由于每一次搜索几乎都会召回很多结果,如果业务逻辑处理不好,也会导致搜索体验不好。针对这一问题并没有通用的解决方法,但是通过实践我们总结出一个基本原则:在离线阶段完成尽可能多的业务逻辑,减少在线计算量!例如进行搜索排序时,我们需要根据用户搜索历史行为(浏览、点击、购买等)对召回的结果进行排序上的调整,在工程实现上我们会先离线统计出同一个query下所有用户对每个展示商品的行为,然后建立模型,计算出该query下每个商品的权重,将其以hash结构存储;在线排序时,直接以query+商品id为key,取出权重作为反馈特征参与综合排序。

搜索技术的新发展

我们在当前的架构基础之上,正在进行一些新的探索,比如场景搜索和图像搜索。

场景搜索

随着目前京东集团的业务的扩展,用户在使用搜索时,目的不仅仅是查找商品,还可能查询促销活动信息。为了满足这些新的需求,我们在目前商品索引融合了促销系统的数据。我们首先在Query Processor中增加对应意图的识别,然后将促销等数据转换为索引数据。只要Query Processor识别出用户提出这方便的查询意图,将对应的结果返回。

图像搜索

传统搜索仅仅针对文字,但是电商系统的商品图片非常重要,很多购买决策依赖于它。目前我们利用deep learning技术离线训练图片特征,并将其做成索引。当用户使用实拍图或者网图来搜索时,采用相同的方式提取特征,然后从索引中召回最相似商品返回给用户。

文章出处:开涛的博客(订阅号ID:kaitao-1234567)


Java 性能优化的五大技巧

$
0
0
要对你的 java 代码进行优化,需要理解 java 不同要素之间的相互作用,以及它是如何与其运行时的操作系统进行交互的。使用下面这五个技巧和资源,开始学习如何分析和优化你的代码吧。 在我们开始之前, 你也许会担心许可的问题. Java 为 Oracle 公司所有,遵循 Oracle 的 BCL 许可,该许可证不是一个免费/开源许可证。即便如此, 仍然有 许多开源项目由 Oracle 公司的 Java 开发。  OpenJDK 是 java 平台自由软件的实现,遵循 GPL v2 许可。 (更多信息请参见维基百科  Free Java implementations。)

让我们开始吧

性能优化取决于多个因素,包括垃圾收集、虚拟机和底层操作系统(OS)设置。有多个工具可供开发人员进行分析和优化时使用,你可以通过阅读  Java Tools for Source Code Optimization and Analysis 来学习和使用它们。如果你正苦苦挣扎于术语和 Java 的原理,可以先去查看  Livecoding Java category page,上面有直播,存档的视频,以及一些其他有用的信息。

“视情况而定”

必须要明白的是,没有两个应用程序可以使用相同的优化方式,也没有完美的优化 java 应用程序的参考路径。使用最佳实践并且坚持采用适当的方式处理性能优化。想要达到真正最高的性能优化,你作为一个 Java 开发人员,需要对 Java 虚拟机(JVM)和底层操作系统有正确的理解:

  • JVM 和底层操作系统:Java 虚拟机是任何 Java 程序的家。阅读  JVM internals guide 了解更多有关于 JVM 内部和操作系统差异的内容。
  • JVM 分布模型:Java 分布模型为您的应用程序处理多个JVM实例。分布模型提高了应用程序的性能,因为它获得更多的资源来工作。你可以用两种方法继续优化。第一种方法是在一个堆大小为2GB或8GB的单服务器运行多个 JVM。第二种方法是在多个服务器上运行单个 JVM。正确方法的选择取决于多个因素,包括可用性和响应性。
  • JVM 体系结构:选择正确的 JVM 体系结构对于性能来说是很重要的。你可以选择 64 位或者 32 位的 JVM 机器。 一般来说,32 位 JVM 的性能比它对应的 64 位 JVM 要好。 只有当你需要的堆大小大于 3 GB 时,才选择 64 位的 JVM。
清楚了性能优化和其要素,现在我们可以专注于那些可以优化你的Java应用的技巧.

1.  调整垃圾收集(GC)

由于垃圾收集的复杂性,很难发现你的应用的准确性能.不过,如果你真的想优化你的应用,你应该相应地处理垃圾收集.通用的准则是调整GC设置并同时执行性能分析.

一旦你对结果感到满意,你可以停止该过程并寻求其他优化方式.确保除了在平均事务处理时间之外,你还留心了异常值.这些异常值是造成Java应用缓慢的真正的罪魁祸首并且很难找到.

此外,你要明白应用运行期间性能下降的效应.在每单个cpu时钟内的缓慢操作是可以忽略的,但在每单个数据库事务中的缓慢操作则是非常昂贵的消耗.但是你应该根据性能短板选择你的优化策略,并应该根据工作负载来优化应用.

2. 正确地选择适合你的GC算法

让我们更深入地探讨GC优化.毕竟,GC优化是要处理的整个优化问题中最基本的.目前,Java中有四种供你选择的垃圾收集算法.每种算法满足不同的需求,因此你要选择(适合你的需求的).很多开发人员正是因为不了解GC算法而未能优化他们的应用.

这四个算法分别是串行回收器,并行/吞吐量回收器,CMS回收器和G1回收器.想要了解更多关于每种垃圾收集器的信息及它们是如何工作的,请查看这篇来自Takipi博客的非常棒的文章 Garbage Collectors—Serial vs. Parallel vs. CMS vs. G1.  这篇文章同时还讨论了Java8对GC算法的影响及其他细节上的改变.让我们再回到GC算法上,根据 Understanding Java Garbage Collection这篇文章所述,并发标记和清除GC(即”CMS”)算法才是适合网络服务端应用的最佳算法.并行GC算法适合那些内部可预测的应用.
G1和CMS是并发操作的理想选择,但仍然会引起(应用)频繁停顿.实际的选择取决于你如何取舍.举例来说,尽管选择并行算法会带来更长的GC停顿时间,但相较于其他GC算法,选择并行算法仍是一个好主意.

3.Java 堆

Java内存堆在迎合内存需求方面担任了至关重要角色.通常更好的做法是初始时分配最小的堆,然后通过持续的测试不断增加它的大小.大多数时候优化问题都可以通过增加堆的大小解决,但如果存在大量的GC开销,则该解决方案不起作用.

GC开销还会使吞吐量急剧下降,进而使得应用难以形容的慢.此外,及早调整GC可以帮助你避免堆大小分配的问题.开始的时候,你可以选择任何1GB到8GB的堆大小.当你选择正确的堆大小,老生代和新生代对象的概念也就不需要了.总而言之,堆大小应该取决于老生代和新生代对象的比率,之前的GC优化和对象集合(即所有对象占用的内存大小).

4. 关键应用优化

关键代码优化是优化你的Java应用最好的方式.如果你的应用对GC和堆优化没有反应,那么最好是做架构改进并关注于你的应用是如何处理信息的.使用聪明的算法并管理好对象就能解决大量的问题,包括内存碎片,堆大小问题和垃圾收集的问题.

5.使用最优的函数

Java提供了多个函数来提升算法效率.如果你使用StringBuilder代替简单的String,你可以得到微乎其微的性能提升.不过,我们还有其他方式在代码层面进行优化.让我们看看下面这些优化方法.

  • 使用StringBuilder代替+操作符.
  • 避免使用iterator().
  • 多使用栈带来的好处.
  • 避免使用正则表达式,使用 Apache Commons Lang作为代替.
  • 远离递归.递归会占用大量资源!

.查看更多关于代码的优化 Top 10 Easy Performance. Optimisations in Java.

结论

java的性能优化可是一个大课题, 藉着这片文章抛砖引玉。如果您认为文章还需要添加补充,别忘了在下面的评论中分享您的观点。

相关文章

LB 负载均衡的层次结构

$
0
0

作为后端应用的开发者,我们经常开发、调试、测试完我们的应用并发布到生产环境,用户就可以直接访问到我们的应用了。但对于互联网应用,在你的应用和用户之间还隔着一层低调的或厚或薄的负载均衡层软件,它们不显山不露水默默的发挥着重要的作用,以至于我们经常忽略了它们的存在。因为负载均衡层通常不在一般开发人员的问题域内,而且它们一般都是现成且成熟的解决方案,以至于我们习惯性的忽略和认为乏善可陈。其实不然,本文就写写我对负载均衡层次结构的认知和理解。

硬负载

所谓「硬负载」就是采用硬件设备来提供负载均衡。

在七、八年前那时我在做 Java 的企业软件开发,开发出来的企业级 Java 应用程序就部署在像 Weblogic 之类的应用容器中。而这类应用容器软件又跑在 Unix 的小型机上。把硬件和软件一体打包作为企业应用解决方案卖给客户。这类应用部署的方案十分简单,层级也比较浅。为了保证可靠性,使用两套小型机上各部署一个 Weblogic Server,在应用服务前面使用像 F5 之类的硬件负载均衡器,如下图所示。

由于小型机和前面的 F5 负载均衡硬件都比较贵,所以出于可靠性、可维护性和成本的综合考虑,一般应用部署两套跑在两台小型机上,在前面共享一个 F5 做负载均衡。而一般 F5 和小型机这类硬件设备都至少是 5 个 9 的可靠性保障,所以整体的系统可靠性基本有保障。

进入互联网时代后,应用开发拥抱开源,部署使用更廉价的 PC Server 和免费开源的应用容器。负载均衡也逐步从硬负载向软负载变迁,由于互联网应用的海量特性和部署规模的急剧膨胀,前端负载均衡也开始变得丰富起来。

软负载

进入互联网公司后,我们刚开始开发应用时,业务规模小用户量还不大,机器数量也少(<10)。所以一开始的负载均衡的结构也是很简单的,类似硬负载只是把硬件换成了免费的开源软件并跑在可用性是有 3 个 9 的廉价 PC Server 上。

前面一个 LVS 后面跟着几个应用服务,后来为了方便做按域名的分流和适配切流量上线,中间又加了一层 Nginx。

这样就变成了两层软负载结构了,LVS 负责 4 层,Nginx 负责 7 层。 但 Nginx 只负责了单机内多实例的负载均衡,这里主要是因为当时 PC Server 是物理机,CPU 16/32 core,内存 32/64G 不等,为了更充分的利用资源,一台物理机上都部署了多个应用服务实例,而考虑到 Nginx 工作在 7 层的开销远高于 LVS/DR 模式,所以一般在一个 Nginx 后面挂的实例数也不会超过 10 个。

但随着业务发展和用户流量上升,机器规模也在不断扩张,导致一个网段内的 IP 都不够用了,这套负载结构又遇到了横向扩展的瓶颈,因为 LVS/DR 模式下跨不了网段。所以后来又在 LVS 和 Nginx 之间加了一层 HAProxy,负载结构就变成了下面这样。

其实加了 HAProxy 之后,它也是工作在 7 层,这样 Nginx 这层看起来就不是很有必要。但三层的负载结构能支撑更大规模的集群,而原本在 Nginx 层做了一套方便研发切流量上线的运维管理系统,所以牺牲一点性能换取现在的可维护性和将来扩展性,Nginx 这层就一直保留下来了。而且 Nginx 相比 HAProxy 不是纯粹的负载均衡器,它还能提供 cache 功能,对于某些 HTTP 请求实际只走到 Nginx 这层就可以通过缓存命中而返回。

DNS负载

随着业务发展,公司开始了多个 IDC 的建设,考虑到 IDC 级别的容灾,集群开始部署到多个 IDC。跨 IDC 的负载均衡方案可以简单通过 DNS 轮询来实现,但可控性不好。所以我们没有采用这种,而是采用一主加多子域名的方式来基于业务场景实现动态域名调度和负载。主域名下实际是一个动态流量调度器,跨多个 IDC 部署,对于 HTTP 请求基于重定向方式跳子域名,对于 TCP 方式每次建立长连接前请求分配实际连接的子域名,如下图所示。

CDN负载

最后再加上互联网应用必不可少的 CDN 将静态资源请求的负载分流,那么整个负载的层次结构就完整了。

SSL 带来的负载结构变化

随着互联网的普及,安全问题益发严重,原本早期只有银行网银等使用 HTTPS 方式访问,现在电商类网站也开始启用全站 HTTPS 了。引入 SSL 后对负载结构带来了什么影响么?SSL 属于应用层的协议,所以只能在 7 层上来做,而 HAProxy 也是支持 SSL 协议的,所以一种方式是只需简单的让 HAProxy 开启 SSL 支持完成对内解密对外加密的处理。

但 HAProxy 的作者不太赞同这种方案,因为引入 SSL 处理是有额外的性能开销的。那么在承担确定流量的情况下,假设原本需要 M 台 HAProxy,在开启了 SSL 后可能需要 M + N 台 HAProxy。随着流量增长,这种方式的横向扩展成本较高(毕竟 SSL 证书按服务器数量来收费的)。他给出的解决方案是再独立一层 SSL 代理缓存层,像下面这样。

L4 和 L7 之间独立的 SSL 代理缓存层只负责 SSL 协议的处理,把 HTTPS 转换成 HTTP,并检查本地缓存是否命中。若未命中再转发请求到后端的 L7 层应用负载均衡层。这样的好处是每个层次都可以根据流量来独立伸缩,而且 SSL 层显然可以跨多个应用共享,更节省成本。如果按这个思路来重新调整我们前面的负载均衡结构层次,将会演变成下面这样。

其实,这时我觉得应用前面的那层 Nginx 可能就显得多余了点,不是必需的。但如果现实这么演进下来很可能就会有这么一层冗余的东西存在很长一段时间,这就是理想和现实之间的差距吧。

总结

好了,本文到此为止。作为一名后台开发我其实对上面提及的各类开源软件如何配置、调优和管理并不熟悉,这属于运维开发的问题域范畴。但这并不妨碍我去了解我所开发的应用所处的整个环境是怎样的,多了解些你工作领域范围边界外的 What 和 Why,有时也能帮助我们更好的设计和解决自身问题域内的问题,别为自己设限而最终画地为牢。

本来以为负载均衡这个古老的课题已经定型了,在写本文时又看到新闻,在近日举办的第十三届网络系统设计与实现 USENIX 研讨会上,来自 Google 的工程师又分享了其自研的 Maglev 负载均衡器。刚下了论文还没看,回头看了再来写写。

参考

[1] HAProxy Documentation.  HAProxy Management Guide
[2] HAProxy Documentation.  HAProxy Starter Guide
[3] Willy Tarreau.  Making applications scalable with Load Balancing
[4] LVS wiki.  Load balancing
[5] Wikipedia.  Virtual Router Redundancy Protocol
[6] shuming.  LVS 工作模式以及工作原理

相关文章

JAVA虚拟机关闭钩子(Shutdown Hook)

$
0
0

Java程序经常也会遇到进程挂掉的情况,一些状态没有正确的保存下来,这时候就需要在JVM关掉的时候执行一些清理现场的代码。JAVA中的ShutdownHook提供了比较好的方案。

JDK提供了Java.Runtime.addShutdownHook(Thread hook)方法,可以注册一个JVM关闭的钩子,这个钩子可以在一下几种场景中被调用:

  1. 程序正常退出
  2. 使用System.exit()
  3. 终端使用Ctrl+C触发的中断
  4. 系统关闭
  5. OutOfMemory宕机
  6. 使用Kill pid命令干掉进程(注:在使用kill -9 pid时,是不会被调用的)

下面是JDK1.7中关于钩子的定义:

    public void addShutdownHook(Thread hook)
参数:
    hook - An initialized but unstarted Thread object 
抛出: 
    IllegalArgumentException - If the specified hook has already been registered, or if it can be determined that the hook is already running or has already been run 
    IllegalStateException - If the virtual machine is already in the process of shutting down 
    SecurityException - If a security manager is present and it denies RuntimePermission("shutdownHooks")
从以下版本开始: 
    1.3 
另请参见:
    removeShutdownHook(java.lang.Thread), halt(int), exit(int)

首先来测试第一种,程序正常退出的情况:

package com.hook;  

import java.util.concurrent.TimeUnit;  

public class HookTest  
{  
    public void start()  
    {  
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {  
            @Override  
            public void run()  
            {  
                System.out.println("Execute Hook.....");  
            }  
        }));  
    }  

    public static void main(String[] args)  
    {  
        new HookTest().start();  
        System.out.println("The Application is doing something");  

        try  
        {  
            TimeUnit.MILLISECONDS.sleep(5000);  
        }  
        catch (InterruptedException e)  
        {  
            e.printStackTrace();  
        }  
    }  
}

运行结果:

The Application is doing something  
Execute Hook.....

如上可以看到,当main线程运行结束之后就会调用关闭钩子。

下面再来测试第五种情况(顺序有点乱,表在意这些细节):

package com.hook;  

import java.util.concurrent.TimeUnit;  

public class HookTest2  
{  
    public void start()  
    {  
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {  
            @Override  
            public void run()  
            {  
                System.out.println("Execute Hook.....");  
            }  
        }));  
    }  

    public static void main(String[] args)  
    {  
        new HookTest().start();  
        System.out.println("The Application is doing something");  
        byte[] b = new byte[500*1024*1024];  
        try  
        {  
            TimeUnit.MILLISECONDS.sleep(5000);  
        }  
        catch (InterruptedException e)  
        {  
            e.printStackTrace();  
        }  
    }  

}

运行参数设置为:-Xmx20M  这样可以保证会有OutOfMemoryError的发生。

运行结果:

The Application is doing something  
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space  
    at com.hook.HookTest2.main(HookTest2.java:22)  
Execute Hook.....

可以看到程序遇到内存溢出错误后调用关闭钩子,与第一种情况中,程序等待5000ms运行结束之后推出调用关闭钩子不同。

接下来再来测试第三种情况:

package com.hook;  

import java.util.concurrent.TimeUnit;  

public class HookTest3  
{  
    public void start()  
    {  
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {  
            @Override  
            public void run()  
            {  
                System.out.println("Execute Hook.....");  
            }  
        }));  
    }  

    public static void main(String[] args)  
    {  
        new HookTest3().start();  
        Thread thread = new Thread(new Runnable(){  

            @Override  
            public void run()  
            {  
                while(true)  
                {  
                    System.out.println("thread is running....");  
                    try  
                    {  
                        TimeUnit.MILLISECONDS.sleep(100);  
                    }  
                    catch (InterruptedException e)  
                    {  
                        e.printStackTrace();  
                    }  
                }  
            }  

        });  
        thread.start();  
    }  

}

在命令行中编译:javac com/hook/HookTest3.java

在命令行中运行:Java com.hook.HookTest3  (之后按下Ctrl+C)

运行结果:


可以看到效果如预期。
还有几种情况就不一一列出了,有兴趣的读者可以试一下。

相关文章

使用Spring Boot开发Web项目

$
0
0

前面两篇博客中我们简单介绍了spring Boot项目的创建、并且也带小伙伴们来DIY了一个Spring Boot自动配置功能,那么这些东西说到底最终还是要回归到Web上才能体现出它的更大的价值,so,今天我们就来看一下如何使用Spring Boot来开发Web项目。当然,如果小伙伴对Spring Boot尚不熟悉的话,可以先参考一下这两篇博客:

1. 初识Spring Boot框架
2. 初识Spring Boot框架(二)之DIY一个Spring Boot的自动配置

Spring Boot 提供了spring-boot-starter-web来为Web开发予以支持,spring-boot-starter-web为我们提供了嵌入的Tomcat以及SpringMVC的依赖,用起来很方便。另外,我们这里还要用到模板引擎,我们做web开发可选的模板引擎还是挺多的,这里我主要使用Thymeleaf作为模板引擎,事实上,Spring Boot提供了大量的模板引擎,包括FreeMarker、Groovy、Thymeleaf、Velocity和Mustache,在 提供的这么多中它推荐使用Thymeleaf。Thymeleaf在使用的过程中通过ThymeleafAutoConfiguration类对集成所需要的Bean进行自动配置,通过ThymeleafProperties来配置Thymeleaf,包括前缀后缀什么的,我们可以查看ThymeleafProperties一段源码:

@ConfigurationProperties("spring.thymeleaf")
public class ThymeleafProperties {
    private static final Charset DEFAULT_ENCODING = Charset.forName("UTF-8");
    private static final MimeType DEFAULT_CONTENT_TYPE = MimeType.valueOf("text/html");
    public static final String DEFAULT_PREFIX = "classpath:/templates/";
    public static final String DEFAULT_SUFFIX = ".html";
    private boolean checkTemplate = true;
    private boolean checkTemplateLocation = true;
    private String prefix = "classpath:/templates/";
    private String suffix = ".html";
    private String mode = "HTML5";

    ......
    ......
    ......
}

OK,从这一段源码中我们可以看到默认的页面后缀名为.html,前缀为classpath:/templates/,实际上也就是我们需要把html页面放到resources文件夹下的templates文件夹中。同时我们也看到了要如何修改这个配置,在application.properties文件中以spring.thymeleaf为前缀来配置相关属性。
关于Thymeleaf如果小伙伴们还不太了解可以先看看我在网上找到的这个资料 Thymeleaf中文文档最新版。OK,这些算是准备工作了。接下来我们就从项目的创建开始看起吧。

创建Project

注意创建的时候要选择Thymeleaf作为依赖,这样创建成功的Project中将自动包含spring-boot-starter-web,如下图:
这里写图片描述

创建JavaBean

我一会要从后台传递数据给前台页面,数据的载体就是这个JavaBean,如下:

public class Person {
    private String name;
    private Integer age;

    public Person() {
        super();
    }

    public Person(String name, Integer age) {
        super();
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}

后台数据构造

在入口类中添加如下代码,由后台向前台页面返回两条数据,一个单个的Person对象,还有一个people对象是一个List集合,集合中放了3个Person对象,到时候我们直接将这两条数据在html页面上显示出来,代码如下:

@RequestMapping("/")
    public String index(Model model) {
        Person single = new Person("aa", 11);
        List<Person> people = new ArrayList<>();
        Person p1 = new Person("zhangsan", 11);
        Person p2 = new Person("lisi", 22);
        Person p3 = new Person("wangwu", 33);
        people.add(p1);
        people.add(p2);
        people.add(p3);
        model.addAttribute("singlePerson", single);
        model.addAttribute("people", people);
        return "index";
    }

这里的代码都很简单,不必我多说了,就是返回给前台页面两个对象,一个singlePerson,一个people,另外,我们的前台页面叫做index.html。

引入相关的静态文件

这里我使用到了Bootstrap和jQuery两个库,当然这个并不是必须的,只是为了让我们显示的效果更好看一些,静态文件我们要放在src/main/resources/static目录下。

1. Bootstrap下载

2. jQuery下载
放置之后目录如下:
这里写图片描述

前台展示页面

刚才小伙伴们都看到了,默认情况下前台页面要放在src/main/resources/templates目录下,so,我们在该目录下新建文件就叫index.html,如下:

<html lang="en" xmlns:th="http://www.thymeleaf.org"><head><meta charset="UTF-8" /><title>Test20</title><link th:href="@{bootstrap/css/bootstrap.min.css}" rel="stylesheet" /><link th:href="@{bootstrap/css/bootstrap-theme.min.css}" rel="stylesheet" /></head><body><div class="panel panel-primary"><div class="panel-heading"><h3 class="panel-title">访问Model</h3></div><div class="panel-body"><span th:text="${singlePerson.name}"></span></div></div><div th:if="${not #lists.isEmpty(people)}"><div class="panel panel-primary"><div class="panel-heading"><h3 class="panel-title">列表</h3></div><div class="panel-body"><ul class="list-group"><li class="list-group-item" th:each="person:${people}"><span th:text="${person.name}"></span><span th:text="${person.age}"></span><button class="btn" th:onclick="'getName(\''+${person.name}+'\');'">获得名字</button></li></ul></div></div></div><script th:src="@{jquery-3.1.1.js}" type="text/javascript"></script><script th:src="@{bootstrap/js/bootstrap.min.js}" type="text/javascript"></script><script th:inline="javascript">
    var single = [[${singlePerson}]];
    console.log(single.name+"/"+single.age);
    function getName(name) {
        console.log(name);
    }</script></body></html>

关于这一段html文件我简单介绍一下,首先通过 xmlns:th="http://www.thymeleaf.org"导入命名空间,在后期时候的时候,由于html本身是静态视图,在使用相关属性的时候加上th:前缀可以使之变为动态视图。 th:href="@{bootstrap/css/bootstrap.min.css}"表示引用Web静态资源。OK,这是head部分。body部分整体上分为了两大块,第一块显示我那个单独的Person对象,第二部分显示List集合中的Person对象。div的样式这个没啥好说的,照着Bootstrap的官网写就行了, th:text="${singlePerson.name}"表示访问model中singlePerson的name属性, th:if="${not #lists.isEmpty(people)}"表示判断model中的people集合是否为空, th:each="person:${people}"表示遍历people中的元素,这个和Java里的foreach差不多,person表示迭代元素。 th:onclick="'getName(\''+${person.name}+'\');'"表示添加点击事件,点击事件由JavaScript来处理。 th:inline="javascript"这样添加到的script标签可以通过 [[${singlePerson}]]访问model中的属性。

如此之后,我们便可以运行我们自己的项目了,然后在浏览器中访问,结果如下:
这里写图片描述

点击Button也可以在浏览器控制台看到log输出:

这里写图片描述

OK,perfect!

Tomcat相关配置

上面几乎没做什么特别的配置,大部分都使用了SpringBoot提供的默认的配置方式。有的时候我们可能需要有一些自定义的配置,比如Tomcat的配置,很简单,和 上上篇博客说的基本一致,有两种不同的配置方式:

在application.properties中配置

直接在application.properties中进行配置即可,如下:

server.port=8081#配置服务器端口,默认为8080
server.session-timeout=1000000#用户回话session过期时间,以秒为单位
server.context-path=/index#配置访问路径,默认为/
server.tomcat.uri-encoding=UTF-8#配置Tomcat编码,默认为UTF-8
server.tomcat.compression=on#Tomcat是否开启压缩,默认为关闭

在代码中进行配置

@Component
public class CustomServletContainer implements EmbeddedServletContainerCustomizer {
    @Override
    public void customize(ConfigurableEmbeddedServletContainer container) {
        container.setPort(8080);
        container.addErrorPages(new ErrorPage(HttpStatus.NOT_FOUND,"/404.html"));
        container.setSessionTimeout(10, TimeUnit.MINUTES);
    }
}

自定义类实现
EmbeddedServletContainerCustomizer接口,然后设置端口、设置错误请求页面、设置会话超时时间等,大家注意这里的404页面放在src/main/resources/static文件夹下,有了这个之后,当我访问一个不存在的页面的时候就会跳转到404.html页面了。

SpringMVC相关配置

虽然Spring Boot默认的配置很多情况都可以满足我们的项目需求,可是有的时候我们可能还是会需要更加灵活的SpringMVC配置,这个时候我们只需要自定义类继承自WebMvcConfigurerAdapter,然后使用@Configuration和@EnableWebMvc注解,这样我们会完全屏蔽掉Spring Boot的默认配置,但是正常情况下我们可能只是希望在Spring Boot已有默认配置的基础上再添加一些配置即Spring Boot提供的默认配置和我自定义的配置并存的情况,这个也简单,只需要去掉@EnableWebMvc注解就行了。如下代码:

@Configuration
//@EnableWebMvc//无需使用该注解,否则会覆盖掉SpringBoot的默认配置值
public class WebMVCConfig extends WebMvcConfigurerAdapter {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/hello").setViewName("/hello");
    }

}

至于这个类里边的其他方法我就不再赘述了,有兴趣的小伙伴可以查看我们之前的文章 SpringMVC常用配置

本案例下载地址:
本案例GitHub地址

以上。

参考资料:
《JavaEE开发的颠覆者 Spring Boot实战》第七章

相关文章

spring + redis 实现数据的缓存

$
0
0

1、实现目标

通过redis缓存数据。(目的不是加快查询的速度,而是减少数据库的负担)

2、所需jar包

注意:jdies和commons-pool两个jar的版本是有对应关系的,注意引入jar包是要配对使用,否则将会报错。因为commons-pooljar的目录根据版本的变化,目录结构会变。前面的版本是org.apache.pool,而后面的版本是org.apache.pool2…

style=”background-color: #0098dd; color: white; font-size: 17px; font-weight: bold;”3、redis简介

redis是一个key-value存储系统。和Memcached类似,它支持存储的value类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set –有序集合)和hash(哈希类型)。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。在此基础上,redis支持各种不同方式的排序。与memcached一样,为了保证效率,数据都是缓存在内存中。区别的是redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)

4、编码实现

1)、配置的文件(properties)

将那些经常要变化的参数配置成独立的propertis,方便以后的修改

redis.properties

redis.hostName=127.0.0.1
redis.port=6379
redis.timeout=15000
redis.usePool=true

redis.maxIdle=6
redis.minEvictableIdleTimeMillis=300000
redis.numTestsPerEvictionRun=3
redis.timeBetweenEvictionRunsMillis=60000

2)、spring-redis.xml

redis的相关参数配置设置。参数的值来自上面的properties文件

<beans xmlns="http://www.springframework.org/schema/beans"   
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" default-autowire="byName">  <bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">  <!-- <property name="maxIdle" value="6"></property>  <property name="minEvictableIdleTimeMillis" value="300000"></property>  <property name="numTestsPerEvictionRun" value="3"></property>  <property name="timeBetweenEvictionRunsMillis" value="60000"></property>   --><property name="maxIdle" value="${redis.maxIdle}"></property>  <property name="minEvictableIdleTimeMillis" value="${redis.minEvictableIdleTimeMillis}"></property>  <property name="numTestsPerEvictionRun" value="${redis.numTestsPerEvictionRun}"></property>  <property name="timeBetweenEvictionRunsMillis" value="${redis.timeBetweenEvictionRunsMillis}"></property></bean>  <bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" destroy-method="destroy">  <property name="poolConfig" ref="jedisPoolConfig"></property>  <property name="hostName" value="${redis.hostName}"></property>  <property name="port" value="${redis.port}"></property>  <property name="timeout" value="${redis.timeout}"></property>  <property name="usePool" value="${redis.usePool}"></property>  </bean>  <bean id="jedisTemplate" class="org.springframework.data.redis.core.RedisTemplate">  <property name="connectionFactory" ref="jedisConnectionFactory"></property>  <property name="keySerializer">  <bean class="org.springframework.data.redis.serializer.StringRedisSerializer"/>  </property>  <property name="valueSerializer">  <bean class="org.springframework.data.redis.serializer.JdkSerializationRedisSerializer"/>  </property>  </bean>  </beans>

3)、applicationContext.xml

spring的总配置文件,在里面假如一下的代码

<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer"><property name="systemPropertiesModeName" value="SYSTEM_PROPERTIES_MODE_OVERRIDE" /><property name="ignoreResourceNotFound" value="true" /><property name="locations"><list><value>classpath*:/META-INF/config/redis.properties</value></list></property></bean><import resource="spring-redis.xml" />

4)、web。xml

设置spring的总配置文件在项目启动时加载

<context-param><param-name>contextConfigLocation</param-name><param-value>classpath*:/META-INF/applicationContext.xml</param-value><!--  --></context-param>

5)、redis缓存工具类

ValueOperations  ——基本数据类型和实体类的缓存
ListOperations     ——list的缓存
SetOperations    ——set的缓存

HashOperations  Map的缓存

import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;

@Service
public class RedisCacheUtil<T>
{

    @Autowired @Qualifier("jedisTemplate")
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     * @param key    缓存的键值
     * @param value    缓存的值
     * @return        缓存的对象
     */
    public <T> ValueOperations<String,T> setCacheObject(String key,T value)
    {

        ValueOperations<String,T> operation = redisTemplate.opsForValue(); 
        operation.set(key,value);
        return operation;
    }

    /**
     * 获得缓存的基本对象。
     * @param key        缓存键值
     * @param operation
     * @return            缓存键值对应的数据
     */
    public <T> T getCacheObject(String key/*,ValueOperations<String,T> operation*/)
    {
        ValueOperations<String,T> operation = redisTemplate.opsForValue(); 
        return operation.get(key);
    }

    /**
     * 缓存List数据
     * @param key        缓存的键值
     * @param dataList    待缓存的List数据
     * @return            缓存的对象
     */
    public <T> ListOperations<String, T> setCacheList(String key,List<T> dataList)
    {
        ListOperations listOperation = redisTemplate.opsForList();
        if(null != dataList)
        {
            int size = dataList.size();
            for(int i = 0; i < size ; i ++)
            {

                listOperation.rightPush(key,dataList.get(i));
            }
        }

        return listOperation;
    }

    /**
     * 获得缓存的list对象
     * @param key    缓存的键值
     * @return        缓存键值对应的数据
     */
    public <T> List<T> getCacheList(String key)
    {
        List<T> dataList = new ArrayList<T>();
        ListOperations<String,T> listOperation = redisTemplate.opsForList();
        Long size = listOperation.size(key);

        for(int i = 0 ; i < size ; i ++)
        {
            dataList.add((T) listOperation.leftPop(key));
        }

        return dataList;
    }

    /**
     * 缓存Set
     * @param key        缓存键值
     * @param dataSet    缓存的数据
     * @return            缓存数据的对象
     */
    public <T> BoundSetOperations<String,T> setCacheSet(String key,Set<T> dataSet)
    {
        BoundSetOperations<String,T> setOperation = redisTemplate.boundSetOps(key);    
        /*T[] t = (T[]) dataSet.toArray();
             setOperation.add(t);*/

        Iterator<T> it = dataSet.iterator();
        while(it.hasNext())
        {
            setOperation.add(it.next());
        }

        return setOperation;
    }

    /**
     * 获得缓存的set
     * @param key
     * @param operation
     * @return
     */
    public Set<T> getCacheSet(String key/*,BoundSetOperations<String,T> operation*/)
    {
        Set<T> dataSet = new HashSet<T>();
        BoundSetOperations<String,T> operation = redisTemplate.boundSetOps(key);    

        Long size = operation.size();
        for(int i = 0 ; i < size ; i++)
        {
            dataSet.add(operation.pop());
        }
        return dataSet;
    }

    /**
     * 缓存Map
     * @param key
     * @param dataMap
     * @return
     */
    public <T> HashOperations<String,String,T> setCacheMap(String key,Map<String,T> dataMap)
    {

        HashOperations hashOperations = redisTemplate.opsForHash();
        if(null != dataMap)
        {

            for (Map.Entry<String, T> entry : dataMap.entrySet()) {  

                /*System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());  */
                hashOperations.put(key,entry.getKey(),entry.getValue());
            } 

        }

        return hashOperations;
    }

    /**
     * 获得缓存的Map
     * @param key
     * @param hashOperation
     * @return
     */
    public <T> Map<String,T> getCacheMap(String key/*,HashOperations<String,String,T> hashOperation*/)
    {
        Map<String, T> map = redisTemplate.opsForHash().entries(key);
        /*Map<String, T> map = hashOperation.entries(key);*/
        return map;
    }

    /**
     * 缓存Map
     * @param key
     * @param dataMap
     * @return
     */
    public <T> HashOperations<String,Integer,T> setCacheIntegerMap(String key,Map<Integer,T> dataMap)
    {
        HashOperations hashOperations = redisTemplate.opsForHash();
        if(null != dataMap)
        {

            for (Map.Entry<Integer, T> entry : dataMap.entrySet()) {  

                /*System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());  */
                hashOperations.put(key,entry.getKey(),entry.getValue());
            } 

        }

        return hashOperations;
    }

    /**
     * 获得缓存的Map
     * @param key
     * @param hashOperation
     * @return
     */
    public <T> Map<Integer,T> getCacheIntegerMap(String key/*,HashOperations<String,String,T> hashOperation*/)
    {
        Map<Integer, T> map = redisTemplate.opsForHash().entries(key);
        /*Map<String, T> map = hashOperation.entries(key);*/
        return map;
    }
}

6)、测试

这里测试我是在项目启动的时候到数据库中查找出国家和城市的数据,进行缓存,之后将数据去出

6.1  项目启动时缓存数据

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Service;

import com.test.model.City;
import com.test.model.Country;
import com.zcr.test.User;

/*
 * 监听器,用于项目启动的时候初始化信息
 */
@Service
public class StartAddCacheListener implements ApplicationListener<ContextRefreshedEvent>
{
    //日志
    private final Logger log= Logger.getLogger(StartAddCacheListener.class);

    @Autowired
    private RedisCacheUtil<Object> redisCache;

    @Autowired
    private BrandStoreService brandStoreService;

    @Override
    public void onApplicationEvent(ContextRefreshedEvent  event) 
    {
        //spring 启动的时候缓存城市和国家等信息
        if(event.getApplicationContext().getDisplayName().equals("Root WebApplicationContext"))
        {
            System.out.println("\n\n\n_________\n\n缓存数据 \n\n ________\n\n\n\n");
            List<City> cityList = brandStoreService.selectAllCityMessage();
            List<Country> countryList = brandStoreService.selectAllCountryMessage();

            Map<Integer,City> cityMap = new HashMap<Integer,City>();

            Map<Integer,Country> countryMap = new HashMap<Integer, Country>();

            int cityListSize = cityList.size();
            int countryListSize = countryList.size();

            for(int i = 0 ; i < cityListSize ; i ++ )
            {
                cityMap.put(cityList.get(i).getCity_id(), cityList.get(i));
            }

            for(int i = 0 ; i < countryListSize ; i ++ )
            {
                countryMap.put(countryList.get(i).getCountry_id(), countryList.get(i));
            }

            redisCache.setCacheIntegerMap("cityMap", cityMap);
            redisCache.setCacheIntegerMap("countryMap", countryMap);
        }
    }

}

6.2  获取缓存数据

@Autowired
    private RedisCacheUtil<User> redisCache;

    @RequestMapping("testGetCache")
    public void testGetCache()
    {
        /*Map<String,Country> countryMap = redisCacheUtil1.getCacheMap("country");
        Map<String,City> cityMap = redisCacheUtil.getCacheMap("city");*/
        Map<Integer,Country> countryMap = redisCacheUtil1.getCacheIntegerMap("countryMap");
        Map<Integer,City> cityMap = redisCacheUtil.getCacheIntegerMap("cityMap");

        for(int key : countryMap.keySet())
        {
            System.out.println("key = " + key + ",value=" + countryMap.get(key));
        }

        System.out.println("------------city");
        for(int key : cityMap.keySet())
        {
            System.out.println("key = " + key + ",value=" + cityMap.get(key));
        }
    }

由于Spring在配置文件中配置的bean默认是单例的,所以只需要通过Autowired注入,即可得到原先的缓存类。

相关文章

MySQL 大表优化方案

$
0
0

当MySQL单表记录数过大时,增删改查性能都会急剧下降,可以参考以下步骤来优化:

单表优化

除非单表数据未来会一直不断上涨,否则不要一开始就考虑拆分,拆分会带来逻辑、部署、运维的各种复杂度,一般以整型值为主的表在 千万级以下,字符串为主的表在 五百万以下是没有太大问题的。而事实上很多时候MySQL单表的性能依然有不少优化空间,甚至能正常支撑千万级以上的数据量:

字段

  • 尽量使用 TINYINTSMALLINTMEDIUM_INT作为整数类型而非 INT,如果非负则加上 UNSIGNED
  • VARCHAR的长度只分配真正需要的空间
  • 使用枚举或整数代替字符串类型
  • 尽量使用 TIMESTAMP而非 DATETIME
  • 单表不要有太多字段,建议在20以内
  • 避免使用NULL字段,很难查询优化且占用额外索引空间
  • 用整型来存IP

索引

  • 索引并不是越多越好,要根据查询有针对性的创建,考虑在 WHEREORDER BY命令上涉及的列建立索引,可根据 EXPLAIN来查看是否用了索引还是全表扫描
  • 应尽量避免在 WHERE子句中对字段进行 NULL值判断,否则将导致引擎放弃使用索引而进行全表扫描
  • 值分布很稀少的字段不适合建索引,例如”性别”这种只有两三个值的字段
  • 字符字段只建前缀索引
  • 字符字段最好不要做主键
  • 不用外键,由程序保证约束
  • 尽量不用 UNIQUE,由程序保证约束
  • 使用多列索引时主意顺序和查询条件保持一致,同时删除不必要的单列索引

查询SQL

  • 可通过开启慢查询日志来找出较慢的SQL
  • 不做列运算: SELECT id WHERE age + 1 = 10,任何对列的操作都将导致表扫描,它包括数据库教程函数、计算表达式等等,查询时要尽可能将操作移至等号右边
  • sql语句尽可能简单:一条sql只能在一个cpu运算;大语句拆小语句,减少锁时间;一条大sql可以堵死整个库
  • 不用 SELECT *
  • OR改写成 INOR的效率是n级别, IN的效率是log(n)级别,in的个数建议控制在200以内
  • 不用函数和触发器,在应用程序实现
  • 避免 %xxx式查询
  • 少用 JOIN
  • 使用同类型进行比较,比如用 '123''123'比, 123123
  • 尽量避免在 WHERE子句中使用 != 或 <> 操作符,否则将引擎放弃使用索引而进行全表扫描
  • 对于连续数值,使用 BETWEEN不用 INSELECT id FROM t WHERE num BETWEEN 1 AND 5
  • 列表数据不要拿全表,要使用 LIMIT来分页,每页数量也不要太大

引擎

目前广泛使用的是MyISAM和InnoDB两种引擎:

MyISAM

MyISAM引擎是MySQL 5.1及之前版本的默认引擎,它的特点是:

  • 不支持行锁,读取时对需要读到的所有表加锁,写入时则对表加排它锁
  • 不支持事务
  • 不支持外键
  • 不支持崩溃后的安全恢复
  • 在表有读取查询的同时,支持往表中插入新纪录
  • 支持 BLOBTEXT的前500个字符索引,支持全文索引
  • 支持延迟更新索引,极大提升写入性能
  • 对于不会进行修改的表,支持压缩表,极大减少磁盘空间占用
InnoDB

InnoDB在MySQL 5.5后成为默认索引,它的特点是:

  • 支持行锁,采用MVCC来支持高并发
  • 支持事务
  • 支持外键
  • 支持崩溃后的安全恢复
  • 不支持全文索引

总体来讲,MyISAM适合 SELECT密集型的表,而InnoDB适合 INSERTUPDATE密集型的表

系统调优参数

可以使用下面几个工具来做基准测试:

  • sysbench:一个模块化,跨平台以及多线程的性能测试工具
  • iibench-mysql:基于 Java 的 MySQL/Percona/MariaDB 索引进行插入性能测试工具
  • tpcc-mysql:Percona开发的TPC-C测试工具

具体的调优参数内容较多,具体可参考官方文档,这里介绍一些比较重要的参数:

  • back_log:back_log值指出在MySQL暂时停止回答新请求之前的短时间内多少个请求可以被存在堆栈中。也就是说,如果MySql的连接数据达到max_connections时,新来的请求将会被存在堆栈中,以等待某一连接释放资源,该堆栈的数量即back_log,如果等待连接的数量超过back_log,将不被授予连接资源。可以从默认的50升至500
  • wait_timeout:数据库连接闲置时间,闲置连接会占用内存资源。可以从默认的8小时减到半小时
  • max_user_connection: 最大连接数,默认为0无上限,最好设一个合理上限
  • thread_concurrency:并发线程数,设为CPU核数的两倍
  • skip_name_resolve:禁止对外部连接进行DNS解析,消除DNS解析时间,但需要所有远程主机用IP访问
  • key_buffer_size:索引块的缓存大小,增加会提升索引处理速度,对MyISAM表性能影响最大。对于内存4G左右,可设为256M或384M,通过查询 show status like 'key_read%',保证 key_reads / key_read_requests在0.1%以下最好
  • innodb_buffer_pool_size:缓存数据块和索引块,对InnoDB表性能影响最大。通过查询 show status like 'Innodb_buffer_pool_read%',保证 (Innodb_buffer_pool_read_requests – Innodb_buffer_pool_reads) / Innodb_buffer_pool_read_requests越高越好
  • innodb_additional_mem_pool_size:InnoDB存储引擎用来存放数据字典信息以及一些内部数据结构的内存空间大小,当数据库对象非常多的时候,适当调整该参数的大小以确保所有数据都能存放在内存中提高访问效率,当过小的时候,MySQL会记录Warning信息到数据库的错误日志中,这时就需要该调整这个参数大小
  • innodb_log_buffer_size:InnoDB存储引擎的事务日志所使用的缓冲区,一般来说不建议超过32MB
  • query_cache_size:缓存MySQL中的ResultSet,也就是一条SQL语句执行的结果集,所以仅仅只能针对select语句。当某个表的数据有任何任何变化,都会导致所有引用了该表的select语句在Query Cache中的缓存数据失效。所以,当我们的数据变化非常频繁的情况下,使用Query Cache可能会得不偿失。根据命中率 (Qcache_hits/(Qcache_hits+Qcache_inserts)*100))进行调整,一般不建议太大,256MB可能已经差不多了,大型的配置型静态数据可适当调大.
    可以通过命令 show status like 'Qcache_%'查看目前系统Query catch使用大小
  • read_buffer_size:MySql读入缓冲区大小。对表进行顺序扫描的请求将分配一个读入缓冲区,MySql会为它分配一段内存缓冲区。如果对表的顺序扫描请求非常频繁,可以通过增加该变量值以及内存缓冲区大小提高其性能
  • sort_buffer_size:MySql执行排序使用的缓冲大小。如果想要增加 ORDER BY的速度,首先看是否可以让MySQL使用索引而不是额外的排序阶段。如果不能,可以尝试增加sort_buffer_size变量的大小
  • read_rnd_buffer_size:MySql的随机读缓冲区大小。当按任意顺序读取行时(例如,按照排序顺序),将分配一个随机读缓存区。进行排序查询时,MySql会首先扫描一遍该缓冲,以避免磁盘搜索,提高查询速度,如果需要排序大量数据,可适当调高该值。但MySql会为每个客户连接发放该缓冲空间,所以应尽量适当设置该值,以避免内存开销过大。
  • record_buffer:每个进行一个顺序扫描的线程为其扫描的每张表分配这个大小的一个缓冲区。如果你做很多顺序扫描,可能想要增加该值
  • thread_cache_size:保存当前没有与连接关联但是准备为后面新的连接服务的线程,可以快速响应连接的线程请求而无需创建新的
  • table_cache:类似于thread_cache_size,但用来缓存表文件,对InnoDB效果不大,主要用于MyISAM

升级硬件

Scale up,这个不多说了,根据MySQL是CPU密集型还是I/O密集型,通过提升CPU和内存、使用SSD,都能显著提升MySQL性能

读写分离

也是目前常用的优化,从库读主库写,一般不要采用双主或多主引入很多复杂性,尽量采用文中的其他方案来提高性能。同时目前很多拆分的解决方案同时也兼顾考虑了读写分离

缓存

缓存可以发生在这些层次:

  • MySQL内部:在 系统调优参数介绍了相关设置
  • 数据访问层:比如MyBatis针对SQL语句做缓存,而Hibernate可以精确到单个记录,这里缓存的对象主要是持久化对象 Persistence Object
  • 应用服务层:这里可以通过编程手段对缓存做到更精准的控制和更多的实现策略,这里缓存的对象是数据传输对象 Data Transfer Object
  • Web层:针对web页面做缓存
  • 浏览器客户端:用户端的缓存

可以根据实际情况在一个层次或多个层次结合加入缓存。这里重点介绍下服务层的缓存实现,目前主要有两种方式:

  • 直写式(Write Through):在数据写入数据库后,同时更新缓存,维持数据库与缓存的一致性。这也是当前大多数应用缓存框架如Spring Cache的工作方式。这种实现非常简单,同步好,但效率一般。
  • 回写式(Write Back):当有数据要写入数据库时,只会更新缓存,然后异步批量的将缓存数据同步到数据库上。这种实现比较复杂,需要较多的应用逻辑,同时可能会产生数据库与缓存的不同步,但效率非常高。

表分区

MySQL在5.1版引入的分区是一种简单的水平拆分,用户需要在建表的时候加上分区参数,对应用是透明的无需修改代码

对用户来说,分区表是一个独立的逻辑表,但是底层由多个物理子表组成,实现分区的代码实际上是通过对一组底层表的对象封装,但对SQL层来说是一个完全封装底层的黑盒子。MySQL实现分区的方式也意味着索引也是按照分区的子表定义,没有全局索引

用户的SQL语句是需要针对分区表做优化,SQL条件中要带上分区条件的列,从而使查询定位到少量的分区上,否则就会扫描全部分区,可以通过 EXPLAIN PARTITIONS来查看某条SQL语句会落在那些分区上,从而进行SQL优化,如下图5条记录落在两个分区上:

mysql> explain partitions select count(1) from user_partition where id in (1,2,3,4,5);
+----+-------------+----------------+------------+-------+---------------+---------+---------+------+------+--------------------------+
| id | select_type | table          | partitions | type  | possible_keys | key     | key_len | ref  | rows | Extra                    |
+----+-------------+----------------+------------+-------+---------------+---------+---------+------+------+--------------------------+
|  1 | SIMPLE      | user_partition | p1,p4      | range | PRIMARY       | PRIMARY | 8       | NULL |    5 | Using where; Using index |
+----+-------------+----------------+------------+-------+---------------+---------+---------+------+------+--------------------------+
1 row in set (0.00 sec)

分区的好处是:

  • 可以让单表存储更多的数据
  • 分区表的数据更容易维护,可以通过清楚整个分区批量删除大量数据,也可以增加新的分区来支持新插入的数据。另外,还可以对一个独立分区进行优化、检查、修复等操作
  • 部分查询能够从查询条件确定只落在少数分区上,速度会很快
  • 分区表的数据还可以分布在不同的物理设备上,从而搞笑利用多个硬件设备
  • 可以使用分区表赖避免某些特殊瓶颈,例如InnoDB单个索引的互斥访问、ext3文件系统的inode锁竞争
  • 可以备份和恢复单个分区

分区的限制和缺点:

  • 一个表最多只能有1024个分区
  • 如果分区字段中有主键或者唯一索引的列,那么所有主键列和唯一索引列都必须包含进来
  • 分区表无法使用外键约束
  • NULL值会使分区过滤无效
  • 所有分区必须使用相同的存储引擎

分区的类型:

  • RANGE分区:基于属于一个给定连续区间的列值,把多行分配给分区
  • LIST分区:类似于按RANGE分区,区别在于LIST分区是基于列值匹配一个离散值集合中的某个值来进行选择
  • HASH分区:基于用户定义的表达式的返回值来进行选择的分区,该表达式使用将要插入到表中的这些行的列值进行计算。这个函数可以包含MySQL中有效的、产生非负整数值的任何表达式
  • KEY分区:类似于按HASH分区,区别在于KEY分区只支持计算一列或多列,且MySQL服务器提供其自身的哈希函数。必须有一列或多列包含整数值

分区适合的场景有:

  • 最适合的场景数据的时间序列性比较强,则可以按时间来分区,如下所示:

CREATE TABLE members (
    firstname VARCHAR(25) NOT NULL,
    lastname VARCHAR(25) NOT NULL,
    username VARCHAR(16) NOT NULL,
    email VARCHAR(35),
    joined DATE NOT NULL
)
PARTITION BY RANGE( YEAR(joined) ) (
    PARTITION p0 VALUES LESS THAN (1960),
    PARTITION p1 VALUES LESS THAN (1970),
    PARTITION p2 VALUES LESS THAN (1980),
    PARTITION p3 VALUES LESS THAN (1990),
    PARTITION p4 VALUES LESS THAN MAXVALUE
);

查询时加上时间范围条件效率会非常高,同时对于不需要的历史数据能很容的批量删除。

  • 如果数据有明显的热点,而且除了这部分数据,其他数据很少被访问到,那么可以将热点数据单独放在一个分区,让这个分区的数据能够有机会都缓存在内存中,查询时只访问一个很小的分区表,能够有效使用索引和缓存

另外MySQL有一种早期的简单的分区实现 – 合并表(merge table),限制较多且缺乏优化,不建议使用,应该用新的分区机制来替代

垂直拆分

垂直分库是根据数据库里面的数据表的相关性进行拆分,比如:一个数据库里面既存在用户数据,又存在订单数据,那么垂直拆分可以把用户数据放到用户库、把订单数据放到订单库。垂直分表是对数据表进行垂直拆分的一种方式,常见的是把一个多字段的大表按常用字段和非常用字段进行拆分,每个表里面的数据记录数一般情况下是相同的,只是字段不一样,使用主键关联

比如原始的用户表是:

垂直拆分后是:

垂直拆分的优点是:

  • 可以使得行数据变小,一个数据块(Block)就能存放更多的数据,在查询时就会减少I/O次数(每次查询时读取的Block 就少)
  • 可以达到最大化利用Cache的目的,具体在垂直拆分的时候可以将不常变的字段放一起,将经常改变的放一起
  • 数据维护简单

缺点是:

  • 主键出现冗余,需要管理冗余列
  • 会引起表连接JOIN操作(增加CPU开销)可以通过在业务服务器上进行join来减少数据库压力
  • 依然存在单表数据量过大的问题(需要水平拆分)
  • 事务处理复杂

水平拆分

概述

水平拆分是通过某种策略将数据分片来存储,分库内分表和分库两部分,每片数据会分散到不同的MySQL表或库,达到分布式的效果,能够支持非常大的数据量。前面的表分区本质上也是一种特殊的库内分表

库内分表,仅仅是单纯的解决了单一表数据过大的问题,由于没有把表的数据分布到不同的机器上,因此对于减轻MySQL服务器的压力来说,并没有太大的作用,大家还是竞争同一个物理机上的IO、CPU、网络,这个就要通过分库来解决

前面垂直拆分的用户表如果进行水平拆分,结果是:

实际情况中往往会是垂直拆分和水平拆分的结合,即将 Users_A_MUsers_N_Z再拆成 UsersUserExtras,这样一共四张表

水平拆分的优点是:

  • 不存在单库大数据和高并发的性能瓶颈
  • 应用端改造较少
  • 提高了系统的稳定性和负载能力

缺点是:

  • 分片事务一致性难以解决
  • 跨节点Join性能差,逻辑复杂
  • 数据多次扩展难度跟维护量极大

分片原则

  • 能不分就不分,参考 单表优化
  • 分片数量尽量少,分片尽量均匀分布在多个数据结点上,因为一个查询SQL跨分片越多,则总体性能越差,虽然要好于所有数据在一个分片的结果,只在必要的时候进行扩容,增加分片数量
  • 分片规则需要慎重选择做好提前规划,分片规则的选择,需要考虑数据的增长模式,数据的访问模式,分片关联性问题,以及分片扩容问题,最近的分片策略为范围分片,枚举分片,一致性Hash分片,这几种分片都有利于扩容
  • 尽量不要在一个事务中的SQL跨越多个分片,分布式事务一直是个不好处理的问题
  • 查询条件尽量优化,尽量避免Select * 的方式,大量数据结果集下,会消耗大量带宽和CPU资源,查询尽量避免返回大量结果集,并且尽量为频繁使用的查询语句建立索引。
  • 通过数据冗余和表分区赖降低跨库Join的可能

这里特别强调一下分片规则的选择问题,如果某个表的数据有明显的时间特征,比如订单、交易记录等,则他们通常比较合适用时间范围分片,因为具有时效性的数据,我们往往关注其近期的数据,查询条件中往往带有时间字段进行过滤,比较好的方案是,当前活跃的数据,采用跨度比较短的时间段进行分片,而历史性的数据,则采用比较长的跨度存储。

总体上来说,分片的选择是取决于最频繁的查询SQL的条件,因为不带任何Where语句的查询SQL,会遍历所有的分片,性能相对最差,因此这种SQL越多,对系统的影响越大,所以我们要尽量避免这种SQL的产生。

解决方案

由于水平拆分牵涉的逻辑比较复杂,当前也有了不少比较成熟的解决方案。这些方案分为两大类:客户端架构和代理架构。

客户端架构

通过修改数据访问层,如JDBC、Data Source、MyBatis,通过配置来管理多个数据源,直连数据库,并在模块内完成数据的分片整合,一般以Jar包的方式呈现

这是一个客户端架构的例子:

可以看到分片的实现是和应用服务器在一起的,通过修改Spring JDBC层来实现

客户端架构的优点是:

  • 应用直连数据库,降低外围系统依赖所带来的宕机风险
  • 集成成本低,无需额外运维的组件

缺点是:

  • 限于只能在数据库访问层上做文章,扩展性一般,对于比较复杂的系统可能会力不从心
  • 将分片逻辑的压力放在应用服务器上,造成额外风险
代理架构

通过独立的中间件来统一管理所有数据源和数据分片整合,后端数据库集群对前端应用程序透明,需要独立部署和运维代理组件

这是一个代理架构的例子:

代理组件为了分流和防止单点,一般以集群形式存在,同时可能需要Zookeeper之类的服务组件来管理

代理架构的优点是:

  • 能够处理非常复杂的需求,不受数据库访问层原来实现的限制,扩展性强
  • 对于应用服务器透明且没有增加任何额外负载

缺点是:

  • 需部署和运维独立的代理中间件,成本高
  • 应用需经过代理来连接数据库,网络上多了一跳,性能有损失且有额外风险
各方案比较
出品方架构模型支持数据库分库分表读写分离外部依赖是否开源实现语言支持语言最后更新Github星数
MySQL FabricMySQL官方代理架构MySQLpython无限制4个月前35
Cobar阿里巴巴代理架构MySQLJava无限制两年前1287
Cobar Client阿里巴巴客户端架构MySQLJavaJava三年前344
TDDL淘宝客户端架构无限制Diamond只开源部分JavaJava未知519
Atlas奇虎360代理架构MySQLC无限制10个月前1941
Heisenberg百度熊照代理架构MySQLJava无限制2个月前197
TribeDB个人代理架构MySQLNodeJS无限制3个月前126
ShardingJDBC当当客户端架构MySQLJavaJava当天1144
Shark个人客户端架构MySQLJavaJava两天前84
KingShard个人代理架构MySQLGolang无限制两天前1836
OneProxy平民软件代理架构MySQL未知无限制未知未知
MyCat社区代理架构MySQLJava无限制两天前1270
VitessYoutube代理架构MySQLGolang无限制当天3636
Mixer个人代理架构MySQLGolang无限制9个月前472
JetPantsTumblr客户端架构MySQLRubyRuby10个月前957
HibernateShardHibernate客户端架构无限制JavaJava4年前57
MybatisShardMakerSoft客户端架构无限制JavaJava11个月前119
GizzardTwitter代理架构无限制Java无限制3年前2087

如此多的方案,如何进行选择?可以按以下思路来考虑:

  1. 确定是使用代理架构还是客户端架构。中小型规模或是比较简单的场景倾向于选择客户端架构,复杂场景或大规模系统倾向选择代理架构
  2. 具体功能是否满足,比如需要跨节点 ORDER BY,那么支持该功能的优先考虑
  3. 不考虑一年内没有更新的产品,说明开发停滞,甚至无人维护和技术支持
  4. 最好按大公司->社区->小公司->个人这样的出品方顺序来选择
  5. 选择口碑较好的,比如github星数、使用者数量质量和使用者反馈
  6. 开源的优先,往往项目有特殊需求可能需要改动源代码

按照上述思路,推荐以下选择:

  • 客户端架构:ShardingJDBC
  • 代理架构:MyCat或者Atlas

兼容MySQL且可水平扩展的数据库

目前也有一些开源数据库兼容MySQL协议,如:

但其工业品质和MySQL尚有差距,且需要较大的运维投入,如果想将原始的MySQL迁移到可水平扩展的新数据库中,可以考虑一些云数据库:

  • 阿里云PetaData
  • 阿里云OceanBase
  • 腾讯云DCDB

NoSQL

在MySQL上做Sharding是一种戴着镣铐的跳舞,事实上很多大表本身对MySQL这种RDBMS的需求并不大,并不要求ACID,可以考虑将这些表迁移到NoSQL,彻底解决水平扩展问题,例如:

  • 日志类、监控类、统计类数据
  • 非结构化或弱结构化数据
  • 对事务要求不强,且无太多关联操作的数据

 

 

MySQL 大表优化方案,首发于 文章 - 伯乐在线

JVM 调优 —— GC 长时间停顿问题及解决方法

$
0
0

零. 简介

垃圾收集器长时间停顿,表现在 Web 页面上可能是页面响应码 500 之类的服务器错误问题,如果是个支付过程可能会导致支付失败,将造成公司的直接经济损失,程序员要尽量避免或者说减少此类情况发生。

一. 并发模式失败(concurrent mode failure)

并发模式失败日志:

2016-02-21T13:53:07.974+0800: 171467.254: [GC [1 CMS-initial-mark: 1436199K(1560576K)] 1512927K(2504320K), 0.0618140 secs] [Times: user=0.06 sys=0.00, real=0.07 secs]  
2016-02-21T13:53:08.036+0800: 171467.316: [CMS-concurrent-mark-start]  
2016-02-21T13:53:08.377+0800: 171467.657: [CMS-concurrent-mark: 0.340/0.340 secs] [Times: user=2.11 sys=0.15, real=0.34 secs]  
2016-02-21T13:53:08.377+0800: 171467.657: [CMS-concurrent-preclean-start]  
2016-02-21T13:53:08.385+0800: 171467.665: [CMS-concurrent-preclean: 0.008/0.008 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]  
2016-02-21T13:53:08.385+0800: 171467.665: [CMS-concurrent-abortable-preclean-start]  
{Heap before GC invocations=88667 (full 45):  
 par new generation   total 943744K, used 914399K [0x0000000757000000, 0x0000000797000000, 0x0000000797000000)  
  eden space 838912K, 100% used [0x0000000757000000, 0x000000078a340000, 0x000000078a340000)  
  from space 104832K,  72% used [0x000000078a340000, 0x000000078ecf7d98, 0x00000007909a0000)  
  to   space 104832K,   0% used [0x00000007909a0000, 0x00000007909a0000, 0x0000000797000000)  
 concurrent mark-sweep generation total 1560576K, used 1436199K [0x0000000797000000, 0x00000007f6400000, 0x00000007f6400000)  
 concurrent-mark-sweep perm gen total 159744K, used 78413K [0x00000007f6400000, 0x0000000800000000, 0x0000000800000000)  
2016-02-21T13:53:09.535+0800: 171468.815: [GC2016-02-21T13:53:09.535+0800: 171468.815: [ParNew  
Desired survivor size 53673984 bytes, new threshold 6 (max 6)  
- age   1:    7100568 bytes,    7100568 total  
- age   2:    4676456 bytes,   11777024 total  
- age   3:    8773736 bytes,   20550760 total  
- age   4:    7709744 bytes,   28260504 total  
- age   5:   10891960 bytes,   39152464 total  
- age   6:   11735032 bytes,   50887496 total  
: 914399K->75616K(943744K), 0.0414860 secs] 2350599K->1517225K(2504320K), 0.0417710 secs] [Times: user=0.32 sys=0.05, real=0.05 secs]  
Heap after GC invocations=88668 (full 45):  
 par new generation   total 943744K, used 75616K [0x0000000757000000, 0x0000000797000000, 0x0000000797000000)  
  eden space 838912K,   0% used [0x0000000757000000, 0x0000000757000000, 0x000000078a340000)  
  from space 104832K,  72% used [0x00000007909a0000, 0x0000000795378128, 0x0000000797000000)  
  to   space 104832K,   0% used [0x000000078a340000, 0x000000078a340000, 0x00000007909a0000)  
 concurrent mark-sweep generation total 1560576K, used 1441609K [0x0000000797000000, 0x00000007f6400000, 0x00000007f6400000)  
 concurrent-mark-sweep perm gen total 159744K, used 78413K [0x00000007f6400000, 0x0000000800000000, 0x0000000800000000)  
}  
2016-02-21T13:53:10.202+0800: 171469.482: [CMS-concurrent-abortable-preclean: 1.772/1.817 secs] [Times: user=4.94 sys=0.06, real=1.81 secs]  
2016-02-21T13:53:10.204+0800: 171469.483: [GC[YG occupancy: 497152 K (943744 K)]2016-02-21T13:53:10.204+0800: 171469.483: [Rescan (parallel) , 1.3691900 secs]2016-02-21T13:53:11.573+0800: 171470.853: [weak refs processing, 0.1009300 secs]2016-02-21T13:53:11.674+0800: 1714  
70.954: [class unloading, 0.0153470 secs]2016-02-21T13:53:11.689+0800: 171470.969: [scrub symbol table, 0.0110770 secs]2016-02-21T13:53:11.700+0800: 171470.980: [scrub string table, 0.0016360 secs] [1 CMS-remark: 1441609K(1560576K)] 1938761K(2504320K), 1.5079530 secs] [Ti  
mes: user=13.01 sys=0.08, real=1.51 secs]  
2016-02-21T13:53:11.712+0800: 171470.992: [CMS-concurrent-sweep-start]  
{Heap before GC invocations=88668 (full 45):  
 par new generation   total 943744K, used 914528K [0x0000000757000000, 0x0000000797000000, 0x0000000797000000)  
  eden space 838912K, 100% used [0x0000000757000000, 0x000000078a340000, 0x000000078a340000)  
  from space 104832K,  72% used [0x00000007909a0000, 0x0000000795378128, 0x0000000797000000)  
  to   space 104832K,   0% used [0x000000078a340000, 0x000000078a340000, 0x00000007909a0000)  
 concurrent mark-sweep generation total 1560576K, used 1441606K [0x0000000797000000, 0x00000007f6400000, 0x00000007f6400000)  
 concurrent-mark-sweep perm gen total 159744K, used 78413K [0x00000007f6400000, 0x0000000800000000, 0x0000000800000000)  
2016-02-21T13:53:11.775+0800: 171471.055: [GC2016-02-21T13:53:11.775+0800: 171471.055: [ParNew (promotion failed)  
Desired survivor size 53673984 bytes, new threshold 6 (max 6)  
- age   1:    4030872 bytes,    4030872 total  
- age   2:    5959704 bytes,    9990576 total  
- age   3:    4628680 bytes,   14619256 total  
- age   4:    8773080 bytes,   23392336 total  
- age   5:    7707144 bytes,   31099480 total  
- age   6:   10890224 bytes,   41989704 total  
: 914528K->907344K(943744K), 1.0312010 secs]2016-02-21T13:53:12.807+0800: 171472.086: [CMS2016-02-21T13:53:14.455+0800: 171473.735: [CMS-concurrent-sweep: 1.684/2.743 secs] [Times: user=3.69 sys=0.36, real=2.74 secs]  
 (concurrent mode failure): 1451903K->475795K(1560576K), 3.9644230 secs] 2356134K->475795K(2504320K), [CMS Perm : 78413K->78413K(159744K)], 4.9959570 secs] [Times: user=5.65 sys=0.34, real=5.00 secs]  
Heap after GC invocations=88669 (full 46):  
 par new generation   total 943744K, used 0K [0x0000000757000000, 0x0000000797000000, 0x0000000797000000)  
  eden space 838912K,   0% used [0x0000000757000000, 0x0000000757000000, 0x000000078a340000)  
  from space 104832K,   0% used [0x000000078a340000, 0x000000078a340000, 0x00000007909a0000)  
  to   space 104832K,   0% used [0x00000007909a0000, 0x00000007909a0000, 0x0000000797000000)  
 concurrent mark-sweep generation total 1560576K, used 475795K [0x0000000797000000, 0x00000007f6400000, 0x00000007f6400000)  
 concurrent-mark-sweep perm gen total 159744K, used 78413K [0x00000007f6400000, 0x0000000800000000, 0x0000000800000000)  
}
两个原因:
  • 在 CMS 启动过程中,新生代提升速度过快,老年代收集速度赶不上新生代提升速度
  • 在 CMS 启动过程中,老年代碎片化严重,无法容纳新生代提升上来的大对象
发送这种情况,应用线程将会全部停止(相当于网站这段时间无法响应用户请求),进行压缩式垃圾收集(回退到 Serial Old 算法)
解决办法:
  • 新生代提升过快问题:(1)如果频率太快的话,说明空间不足,首先可以尝试调大新生代空间和晋升阈值。(2)如果内存有限,可以设置 CMS 垃圾收集在老年代占比达到多少时启动来减少问题发生频率(越早启动问题发生频率越低,但是会降低吞吐量,具体得多调整几次找到平衡点),参数如下:如果没有第二个参数,会随着 JVM 动态调节 CMS 启动时间
-XX:CMSInitiatingOccupancyFraction=68 (默认是 68)
-XX:+UseCMSInitiatingOccupancyOnly
  • 老年代碎片严重问题:(1)如果频率太快或者 Full GC 后空间释放不多的话,说明空间不足,首先可以尝试调大老年代空间(2)如果内存不足,可以设置进行 n 次 CMS 后进行一次压缩式 Full GC,参数如下:
-XX:+UseCMSCompactAtFullCollection:允许在 Full GC 时,启用压缩式 GC
-XX:CMSFullGCBeforeCompaction=n     在进行 n 次,CMS 后,进行一次压缩的 Full GC,用以减少 CMS 产生的碎片

二. 提升失败(promotion failed)

在 Minor GC 过程中,Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion)。 这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。  再进一步, 如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)。
提升失败日志:
2016-01-07T18:54:26.948+0800: 18782.967: [GC2016-04-07T18:54:26.948+0800: 18782.967: [ParNew (promotion failed)  
Desired survivor size 117833728 bytes, new threshold 10 (max 10)  
- age   1:    6141680 bytes,    6141680 total  
- age   2:    6337936 bytes,   12479616 total  
- age   3:     549120 bytes,   13028736 total  
- age   4:      87768 bytes,   13116504 total  
- age   5:     221384 bytes,   13337888 total  
- age   6:     934168 bytes,   14272056 total  
- age   7:     146072 bytes,   14418128 total  
- age   8:     626064 bytes,   15044192 total  
- age   9:     398000 bytes,   15442192 total  
- age  10:     429616 bytes,   15871808 total  
: 1969227K->1929200K(2071808K), 0.7452140 secs]2016-01-07T18:54:27.693+0800: 18783.713: [CMS: 1394703K->632845K(2097152K), 4.0993640 secs] 3301676K->632845K(4168960K), [CMS Perm : 77485K->77473K(159744K)], 4.8450240 secs] [Times: user=5.18 sys=0.56, real=4.84 secs]  
Heap after GC invocations=5847 (full 7):  
 par new generation   total 2071808K, used 0K [0x00000006e9c00000, 0x0000000776400000, 0x0000000776400000)  
  eden space 1841664K,   0% used [0x00000006e9c00000, 0x00000006e9c00000, 0x000000075a280000)  
  from space 230144K,   0% used [0x0000000768340000, 0x0000000768340000, 0x0000000776400000)  
  to   space 230144K,   0% used [0x000000075a280000, 0x000000075a280000, 0x0000000768340000)  
 concurrent mark-sweep generation total 2097152K, used 632845K [0x0000000776400000, 0x00000007f6400000, 0x00000007f6400000)  
 concurrent-mark-sweep perm gen total 159744K, used 77473K [0x00000007f6400000, 0x0000000800000000, 0x0000000800000000)  
}
提升失败原因:Minor GC 时发现 Survivor 空间放不下,而老年代的空闲也不够
  • 新生代提升太快
  • 老年代碎片太多,放不下大对象提升(表现为老年代还有很多空间但是,出现了 promotion failed)
解决方法:
       两条和上面 concurrent mode failure 一样
       另一条,是因为 Survivor Unused 不足,那么可以尝试调大 Survivor 来尝试下

三. 在 GC 的时候其他系统活动影响

有些时候系统活动诸如内存换入换出(vmstat)、网络活动(netstat)、I/O (iostat)在 GC 过程中发生会使 GC 时间变长。
前提是你的服务器上是有 SWAP 区域(用 top、 vmstat 等命令可以看出)用于内存的换入换出,那么操作系统可能会将 JVM 中不活跃的内存页换到 SWAP 区域用以释放内存给线程使用(这也透露出内存开始不够用了)。内存换入换出是一个开销巨大的磁盘操作,比内存访问慢好几个数量级。
看一段 GC 日志:耗时 29.47 秒
{Heap before GC invocations=132 (full 0):  
par new generation total 2696384K, used 2696384K [0xfffffffc20010000, 0xfffffffce0010000, 0xfffffffce0010000)  
eden space 2247040K, 100% used [0xfffffffc20010000, 0xfffffffca9270000, 0xfffffffca9270000)  
from space 449344K, 100% used [0xfffffffca9270000, 0xfffffffcc4940000, 0xfffffffcc4940000)  
to space 449344K, 0% used [0xfffffffcc4940000, 0xfffffffcc4940000, 0xfffffffce0010000)  
concurrent mark-sweep generation total 9437184K, used 1860619K [0xfffffffce0010000, 0xffffffff20010000, 0xffffffff20010000)  
concurrent-mark-sweep perm gen total 1310720K, used 511451K [0xffffffff20010000, 0xffffffff70010000, 0xffffffff70010000)  
2013-07-17T03:58:06.601-0700: 51522.120: [GC Before GC: : 2696384K->449344K(2696384K), 29.4779282 secs] 4557003K->2326821K(12133568K) ,29.4795222 secs] [Times: user=915.56 sys=6.35, real=29.48 secs]
再看看此时的 vmstat 命令中 si、so 列的数值,如果数值大说明换入换出严重,这是内存不足的表现。
解决方法:减少线程,这样可以降低内存换入换出;增加内存;如果是 JVM 内存设置过大导致线程所用内存不足,则适当调低 -Xmx 和 -Xms。

四. JVM 的 bug

这种原因就根据自己使用的 JDK 版本去查一下,如果是 JVM 的 bug,升级到解决的版本一般就能解决。

五. 总结

       长时间停顿问题的排查及解决首先需要一定的信息和方法论:
  • 详细的 GC 日志
  • 借助 Linux 平台下的 iostat、vmstat、netstat、mpstat 等命令监控系统情况
  • 使用 GCHisto 这个 GC 图形用户界面工具,可以统计出 Minor GC 及 Full GC 频率及时长分布,可参考: http://blog.csdn.net/wenniuwuren/article/details/50760259
  • 查看 GC 日志中是否出现了上述的典型内存异常问题(promotion failed, concurrent mode failure),整体来说把上述两个典型内存异常情况控制在可接受的发生频率即可,对 CMS 碎片问题来说杜绝以上问题似乎不太可能,只能靠 G1 来解决了
  • 是不是 JVM 本身的 bug 导致的
  • 如果程序没问题,参数调了几次还是不能解决,可能说明流量太大,需要加机器把压力分散到更多 JVM 上

六. 参考资料

         《Java 性能优化权威指南》

相关文章


zookeeper 大量连接断开重连原因排查

$
0
0

问题现象

最后发现线上的zookeeper的日志zookeeper.out 文件居然有6G,后来设置下日志为滚动输出,参考:

http://blog.csdn.net/hengyunabc/article/details/19006911

但是改了之后,发现一天的日志量就是100多M,滚动日志一天就被冲掉了,这个不科学。

再仔细查看下日志里的内容,发现有很多连接建立好,马上又断开:

2014-11-24 15:38:33,348 [myid:3] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@1001] - Closed socket connection for client /10.0.0.3:47772 (no session established for client)  
2014-11-24 15:38:33,682 [myid:3] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxnFactory@197] - Accepted socket connection from /10.0.0.3:32119  
2014-11-24 15:38:33,682 [myid:3] - WARN  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@349] - caught end of stream exception  
EndOfStreamException: Unable to read additional data from client sessionid 0x0, likely client has closed socket  
        at org.apache.zookeeper.server.NIOServerCnxn.doIO(NIOServerCnxn.java:220)  
        at org.apache.zookeeper.server.NIOServerCnxnFactory.run(NIOServerCnxnFactory.java:208)  
        at java.lang.Thread.run(Thread.java:745)  
2014-11-24 15:38:33,682 [myid:3] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@1001] - Closed socket connection for client /10.0.0.0:32119 (no session established for client)

从日志输出的时间来看,秒连秒断,非常诡异。

排查问题

用netstat查看网络连接状态

到client的服务器上查看连接的状态:

netstat -antp | grep 2181

发现有很多TIME_WAIT状态的连接:

tcp        0      0 10.0.0.3:44269         10.0.1.77:2181         TIME_WAIT   -                     
tcp        0      0 10.0.0.3:43646         10.0.1.77:2181         TIME_WAIT   -                     
tcp        0      0 10.0.0.3:44184         10.0.1.77:2181         TIME_WAIT   -                     
tcp        0      0 10.0.0.3:44026         10.0.1.77:2181         TIME_WAIT   -                     
tcp        0      0 10.0.0.3:43766         10.0.1.77:2181         TIME_WAIT   -

但是TIME_WAIT状态的连接是看不到进程号的。搜索研究了下netstat的参数,发现没有办法输出TIME_WAIT状态的连接的pid,只好尝试其它的办法。

再用 jstack -l pid 来查看进程的线程栈,也没有发现什么异常的东东。查看到有几个zookeeper连接的线程,但也是正常状态。

再检查了机器的IO,CPU,内存,也没有异常的情况。

没找到什么有用的信息,只好再研究下netstat的参数:
发现用 netstat -ae 输出了一些信息:

tcp        0      0 10.0.0.3:41772     10.0.1.77:eforward     TIME_WAIT   root       0            
tcp        0      0 10.0.0.3:41412     10.0.1.77:eforward     TIME_WAIT   root       0            
tcp        0      0 10.0.0.3:24226     10.0.1.77:2181         TIME_WAIT   root       0            
tcp        0      0 10.0.0.3:24623     10.0.1.77:2181         TIME_WAIT   root       0

发现user是root。于是以为是非Java应用,在不断地连接zookeeper。于是停止java程序,发现没有TIME_WAIT连接了。
但是确认是Java应用的问题,于是再重启Java应用,但没有再发现TIME_WAIT情况。很诡异。

问题不能重现了,相当的蛋疼。忽然想到线上的应用也许也有这个问题,于是到线下zookeeper服务器上查看了下,果然发现有同样的问题。

用tcpdump抓包和wireshark分析

先用tcpdump来查看下具体的网络连接,发现的确是连接连上再断开。于是先保存成cap文件,再用wireshark来分析:

tcpdump -vv host 192.168.66.27 and port 2181 -w 2181.cap

但是也没有发现什么有用信息,的确是TCP连接连上,再FIN,ACK连接断开。

查看应用日志,发现Tomcat webcontext没有正常启动

没办法了,有两种考虑,一个是用strace,二是用btrace。但是btrace好久没用过了,不太想再去看例子文档。

还好,去下btrace之后,先去看了下应用的日志,发现应用报了一些ClassLoader的错误:

Nov 24, 2014 7:32:43 PM org.apache.catalina.loader.WebappClassLoader loadClass  
INFO: Illegal access: this web application instance has been stopped already.  Could not load org.apache.zookeeper.ClientCnxnSocketNIO.  The eventual following stack trace is caused by an err  
or thrown for debugging purposes as well as to attempt to terminate the thread which caused the illegal access, and has no functional impact.  
java.lang.IllegalStateException  
        at org.apache.catalina.loader.WebappClassLoader.loadClass(WebappClassLoader.java:1564)  
        at org.apache.catalina.loader.WebappClassLoader.loadClass(WebappClassLoader.java:1523)  
        at ch.qos.logback.classic.spi.PackagingDataCalculator.loadClass(PackagingDataCalculator.java:198)  
        at ch.qos.logback.classic.spi.PackagingDataCalculator.bestEffortLoadClass(PackagingDataCalculator.java:226)  
        at ch.qos.logback.classic.spi.PackagingDataCalculator.computeBySTEP(PackagingDataCalculator.java:132)  
        at ch.qos.logback.classic.spi.PackagingDataCalculator.populateUncommonFrames(PackagingDataCalculator.java:107)  
        at ch.qos.logback.classic.spi.PackagingDataCalculator.populateFrames(PackagingDataCalculator.java:99)

因为有经验了,马上知道这个Tomcat因为其它原因webcontext实始化失败退出,然后后面的一些线程继续跑时,会抛出ClassLoader,或者Class not found的异常。

于是猜想到原因了:

Tomcat webcontext初始化失败,zookeeper的重连线程自动不断重连。

但是为什么重启Tomcat之后,没有重现TIME_WAIT的情况?

再折腾了下,发现只有当zookeeper重启后,应用才会出现大量的TIME_WAIT连接。报的是下面这个异常:

2014-11-24 19:42:44,399 [Thread-3-SendThread(192.168.90.147:4181)] WARN  org.apache.zookeeper.ClientCnxn - Session 0x149c21809731325 for server 192.168.90.147/192.168.90.147:4181, unexpected error, closing socket connection and attempting reconnect  
java.lang.NoClassDefFoundError: org/apache/zookeeper/proto/SetWatches  
        at org.apache.zookeeper.ClientCnxn$SendThread.primeConnection(ClientCnxn.java:867) ~[zookeeper-3.4.5.jar:3.4.5-1392090]  
        at org.apache.zookeeper.ClientCnxnSocketNIO.doTransport(ClientCnxnSocketNIO.java:352) ~[zookeeper-3.4.5.jar:3.4.5-1392090]

这个异常的原因,是某些zookeeper的类没有加载到。

最终原因分析

梳理下整个流程:

  1. Tomcat启动,初始化webcontext;
  2. 初始化spring, spring初始某些些bean,这些bean包括了zookeeper的连接相关的bean;
  3. 这时zkClient(独立线程)已经连接上服务器了,但是classloader没有加载到org/apache/zookeeper/proto/SetWatches类;
  4. spring初始化失败,导致Tomcat webcontext初始化也失败,应用在挂起状态,但zkClient线程还是正常的;
  5. zookeeper服务器重启,zkClient开始重连,连接上zookeeper服务器;
  6. zkClient触发watch的一些代码,ClassLoader尝试加载org/apache/zookeeper/proto/SetWatches类,但是发现找不到类,于是抛出异常;
  7. zkClient捕获到异常,认为重连失败,close掉connection,休眠几秒之后,再次重连;

于是出现了zkClient反复重试连接zookeeper服务器,而且都是秒连秒断的情况。

总结:

这次排查花了不少时间,有个原因是开始没有去查看应用的日志,以为应用的是正常的,而且zookeeper.out的输出日志很多,也有一段时间了。

还有线上的应用比较坑爹,活动已经过期很久了,但是程序还是线上跑,也没有人管是否出问题了。

所以,主要精力放在各种网络连接状态的获取上。对去查看应用日志比较排斥。

还有一个原因是,问题比较诡异,有点难重现,当发现可以重现时,基本已经发现问题所在了。

排查问题还是要耐心收集信息,再分析判断。

相关文章

关于Java并发编程的总结和思考

$
0
0

为什么需要并发

并发其实是一种解耦合的策略,它帮助我们把做什么(目标)和什么时候做(时机)分开。这样做可以明显改进应用程序的吞吐量(获得更多的CPU调度时间)和结构(程序有多个部分在协同工作)。做过Java Web开发的人都知道,Java Web中的Servlet程序在Servlet容器的支持下采用单实例多线程的工作模式,Servlet容器为你处理了并发问题。

误解和正解

最常见的对并发编程的误解有以下这些:

-并发总能改进性能(并发在CPU有很多空闲时间时能明显改进程序的性能,但当线程数量较多的时候,线程间频繁的调度切换反而会让系统的性能下降)
-编写并发程序无需修改原有的设计(目的与时机的解耦往往会对系统结构产生巨大的影响)
-在使用Web或EJB容器时不用关注并发问题(只有了解了容器在做什么,才能更好的使用容器)

下面的这些说法才是对并发客观的认识:

-编写并发程序会在代码上增加额外的开销
-正确的并发是非常复杂的,即使对于很简单的问题
-并发中的缺陷因为不易重现也不容易被发现
-并发往往需要对设计策略从根本上进行修改

并发编程的原则和技巧

单一职责原则

分离并发相关代码和其他代码(并发相关代码有自己的开发、修改和调优生命周期)。

限制数据作用域

两个线程修改共享对象的同一字段时可能会相互干扰,导致不可预期的行为,解决方案之一是构造临界区,但是必须限制临界区的数量。

使用数据副本

数据副本是避免共享数据的好方法,复制出来的对象只是以只读的方式对待。Java 5的java.util.concurrent包中增加一个名为CopyOnWriteArrayList的类,它是List接口的子类型,所以你可以认为它是ArrayList的线程安全的版本,它使用了写时复制的方式创建数据副本进行操作来避免对共享数据并发访问而引发的问题。

线程应尽可能独立

让线程存在于自己的世界中,不与其他线程共享数据。有过Java Web开发经验的人都知道,Servlet就是以单实例多线程的方式工作,和每个请求相关的数据都是通过Servlet子类的service方法(或者是doGet或doPost方法)的参数传入的。只要Servlet中的代码只使用局部变量,Servlet就不会导致同步问题。springMVC的控制器也是这么做的,从请求中获得的对象都是以方法的参数传入而不是作为类的成员,很明显Struts 2的做法就正好相反,因此Struts 2中作为控制器的Action类都是每个请求对应一个实例。

Java 5以前的并发编程

Java的线程模型建立在抢占式线程调度的基础上,也就是说:

  • 所有线程可以很容易的共享同一进程中的对象。
  • 能够引用这些对象的任何线程都可以修改这些对象。
  • 为了保护数据,对象可以被锁住。

Java基于线程和锁的并发过于底层,而且使用锁很多时候都是很万恶的,因为它相当于让所有的并发都变成了排队等待。
在Java 5以前,可以用synchronized关键字来实现锁的功能,它可以用在代码块和方法上,表示在执行整个代码块或方法之前线程必须取得合适的锁。对于类的非静态方法(成员方法)而言,这意味这要取得对象实例的锁,对于类的静态方法(类方法)而言,要取得类的Class对象的锁,对于同步代码块,程序员可以指定要取得的是那个对象的锁。
不管是同步代码块还是同步方法,每次只有一个线程可以进入,如果其他线程试图进入(不管是同一同步块还是不同的同步块),JVM会将它们挂起(放入到等锁池中)。这种结构在并发理论中称为临界区(critical section)。这里我们可以对Java中用synchronized实现同步和锁的功能做一个总结:

  • 只能锁定对象,不能锁定基本数据类型
  • 被锁定的对象数组中的单个对象不会被锁定
  • 同步方法可以视为包含整个方法的synchronized(this) { … }代码块
  • 静态同步方法会锁定它的Class对象
  • 内部类的同步是独立于外部类的
  • synchronized修饰符并不是方法签名的组成部分,所以不能出现在接口的方法声明中
  • 非同步的方法不关心锁的状态,它们在同步方法运行时仍然可以得以运行
  • synchronized实现的锁是可重入的锁。

在JVM内部,为了提高效率,同时运行的每个线程都会有它正在处理的数据的缓存副本,当我们使用synchronzied进行同步的时候,真正被同步的是在不同线程中表示被锁定对象的内存块(副本数据会保持和主内存的同步,现在知道为什么要用同步这个词汇了吧),简单的说就是在同步块或同步方法执行完后,对被锁定的对象做的任何修改要在释放锁之前写回到主内存中;在进入同步块得到锁之后,被锁定对象的数据是从主内存中读出来的,持有锁的线程的数据副本一定和主内存中的数据视图是同步的 。
在Java最初的版本中,就有一个叫volatile的关键字,它是一种简单的同步的处理机制,因为被volatile修饰的变量遵循以下规则:

  • 变量的值在使用之前总会从主内存中再读取出来。
  • 对变量值的修改总会在完成之后写回到主内存中。

使用volatile关键字可以在多线程环境下预防编译器不正确的优化假设(编译器可能会将在一个线程中值不会发生改变的变量优化成常量),但只有修改时不依赖当前状态(读取时的值)的变量才应该声明为volatile变量。
不变模式也是并发编程时可以考虑的一种设计。让对象的状态是不变的,如果希望修改对象的状态,就会创建对象的副本并将改变写入副本而不改变原来的对象,这样就不会出现状态不一致的情况,因此不变对象是线程安全的。Java中我们使用频率极高的String类就采用了这样的设计。如果对不变模式不熟悉,可以阅读阎宏博士的《Java与模式》一书的第34章。说到这里你可能也体会到final关键字的重要意义了。

Java 5的并发编程

不管今后的Java向着何种方向发展或者灭亡,Java 5绝对是Java发展史中一个极其重要的版本,这个版本提供的各种语言特性我们不在这里讨论(有兴趣的可以阅读我的另一篇文章《Java的第20年:从Java版本演进看编程技术的发展》),但是我们必须要感谢Doug Lea在Java 5中提供了他里程碑式的杰作java.util.concurrent包,它的出现让Java的并发编程有了更多的选择和更好的工作方式。Doug Lea的杰作主要包括以下内容:

  • 更好的线程安全的容器
  • 线程池和相关的工具类
  • 可选的非阻塞解决方案
  • 显示的锁和信号量机制

下面我们对这些东西进行一一解读。

原子类

Java 5中的java.util.concurrent包下面有一个atomic子包,其中有几个以Atomic打头的类,例如AtomicInteger和AtomicLong。它们利用了现代处理器的特性,可以用非阻塞的方式完成原子操作,代码如下所示:

/**
 ID序列生成器
*/
public class IdGenerator {
    private final AtomicLong sequenceNumber = new AtomicLong(0);

    public long next() {
        return sequenceNumber.getAndIncrement(); 
    }
}

显示锁

基于synchronized关键字的锁机制有以下问题:

  • 锁只有一种类型,而且对所有同步操作都是一样的作用
  • 锁只能在代码块或方法开始的地方获得,在结束的地方释放
  • 线程要么得到锁,要么阻塞,没有其他的可能性

Java 5对锁机制进行了 重构,提供了显示的锁,这样可以在以下几个方面提升锁机制:

  • 可以添加不同类型的锁,例如读取锁和写入锁
  • 可以在一个方法中加锁,在另一个方法中解锁
  • 可以使用tryLock方式尝试获得锁,如果得不到锁可以等待、回退或者干点别的事情,当然也可以在超时之后放弃操作

显示的锁都实现了java.util.concurrent.Lock接口,主要有两个实现类:

  • ReentrantLock – 比synchronized稍微灵活一些的重入锁
  • ReentrantReadWriteLock – 在读操作很多写操作很少时性能更好的一种重入锁

对于如何使用显示锁,可以参考我的Java面试系列文章 《Java面试题集51-70》中第60题的代码。只有一点需要提醒,解锁的方法unlock的调用最好能够在finally块中,因为这里是释放外部资源最好的地方,当然也是释放锁的最佳位置,因为不管正常异常可能都要释放掉锁来给其他线程以运行的机会。

CountDownLatch

CountDownLatch是一种简单的同步模式,它让一个线程可以等待一个或多个线程完成它们的工作从而避免对临界资源并发访问所引发的各种问题。下面借用别人的一段代码(我对它做了一些重构)来演示CountDownLatch是如何工作的。

import java.util.concurrent.CountDownLatch;

/**
 * 工人类
 * @author 骆昊
 *
 */
class Worker {
    private String name;        // 名字
    private long workDuration;  // 工作持续时间

    /**
     * 构造器
     */
    public Worker(String name, long workDuration) {
        this.name = name;
        this.workDuration = workDuration;
    }

    /**
     * 完成工作
     */
    public void doWork() {
        System.out.println(name + " begins to work...");
        try {
            Thread.sleep(workDuration); // 用休眠模拟工作执行的时间
        } catch(InterruptedException ex) {
            ex.printStackTrace();
        }
        System.out.println(name + " has finished the job...");
    }
}

/**
 * 测试线程
 * @author 骆昊
 *
 */
class WorkerTestThread implements Runnable {
    private Worker worker;
    private CountDownLatch cdLatch;

    public WorkerTestThread(Worker worker, CountDownLatch cdLatch) {
        this.worker = worker;
        this.cdLatch = cdLatch;
    }

    @Override
    public void run() {
        worker.doWork();        // 让工人开始工作
        cdLatch.countDown();    // 工作完成后倒计时次数减1
    }
}

class CountDownLatchTest {

    private static final int MAX_WORK_DURATION = 5000;  // 最大工作时间
    private static final int MIN_WORK_DURATION = 1000;  // 最小工作时间

    // 产生随机的工作时间
    private static long getRandomWorkDuration(long min, long max) {
        return (long) (Math.random() * (max - min) + min);
    }

    public static void main(String[] args) {
        CountDownLatch latch = new CountDownLatch(2);   // 创建倒计时闩并指定倒计时次数为2
        Worker w1 = new Worker("骆昊", getRandomWorkDuration(MIN_WORK_DURATION, MAX_WORK_DURATION));
        Worker w2 = new Worker("王大锤", getRandomWorkDuration(MIN_WORK_DURATION, MAX_WORK_DURATION));

        new Thread(new WorkerTestThread(w1, latch)).start();
        new Thread(new WorkerTestThread(w2, latch)).start();

        try {
            latch.await();  // 等待倒计时闩减到0
            System.out.println("All jobs have been finished!");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

ConcurrentHashMap

ConcurrentHashMap是HashMap在并发环境下的版本,大家可能要问,既然已经可以通过Collections.synchronizedMap获得线程安全的映射型容器,为什么还需要ConcurrentHashMap呢?因为通过Collections工具类获得的线程安全的HashMap会在读写数据时对整个容器对象上锁,这样其他使用该容器的线程无论如何也无法再获得该对象的锁,也就意味着要一直等待前一个获得锁的线程离开同步代码块之后才有机会执行。实际上,HashMap是通过哈希函数来确定存放键值对的桶(桶是为了解决哈希冲突而引入的),修改HashMap时并不需要将整个容器锁住,只需要锁住即将修改的“桶”就可以了。HashMap的数据结构如下图所示。
这里写图片描述

此外,ConcurrentHashMap还提供了原子操作的方法,如下所示:

  • putIfAbsent:如果还没有对应的键值对映射,就将其添加到HashMap中。
  • remove:如果键存在而且值与当前状态相等(equals比较结果为true),则用原子方式移除该键值对映射
  • replace:替换掉映射中元素的原子操作

CopyOnWriteArrayList

CopyOnWriteArrayList是ArrayList在并发环境下的替代品。CopyOnWriteArrayList通过增加写时复制语义来避免并发访问引起的问题,也就是说任何修改操作都会在底层创建一个列表的副本,也就意味着之前已有的迭代器不会碰到意料之外的修改。这种方式对于不要严格读写同步的场景非常有用,因为它提供了更好的性能。记住,要尽量减少锁的使用,因为那势必带来性能的下降(对数据库中数据的并发访问不也是如此吗?如果可以的话就应该放弃悲观锁而使用乐观锁),CopyOnWriteArrayList很明显也是通过牺牲空间获得了时间(在计算机的世界里,时间和空间通常是不可调和的矛盾,可以牺牲空间来提升效率获得时间,当然也可以通过牺牲时间来减少对空间的使用)。
这里写图片描述

可以通过下面两段代码的运行状况来验证一下CopyOnWriteArrayList是不是线程安全的容器。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class AddThread implements Runnable {
    private List<Double> list;

    public AddThread(List<Double> list) {
        this.list = list;
    }

    @Override
    public void run() {
        for(int i = 0; i < 10000; ++i) {
            list.add(Math.random());
        }
    }
}

public class Test05 {
    private static final int THREAD_POOL_SIZE = 2;

    public static void main(String[] args) {
        List<Double> list = new ArrayList<>();
        ExecutorService es = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
        es.execute(new AddThread(list));
        es.execute(new AddThread(list));
        es.shutdown();
    }
}

上面的代码会在运行时产生ArrayIndexOutOfBoundsException,试一试将上面代码25行的ArrayList换成CopyOnWriteArrayList再重新运行。

List<Double> list = new CopyOnWriteArrayList<>();

Queue

队列是一个无处不在的美妙概念,它提供了一种简单又可靠的方式将资源分发给处理单元(也可以说是将工作单元分配给待处理的资源,这取决于你看待问题的方式)。实现中的并发编程模型很多都依赖队列来实现,因为它可以在线程之间传递工作单元。
Java 5中的BlockingQueue就是一个在并发环境下非常好用的工具,在调用put方法向队列中插入元素时,如果队列已满,它会让插入元素的线程等待队列腾出空间;在调用take方法从队列中取元素时,如果队列为空,取出元素的线程就会阻塞。
这里写图片描述
可以用BlockingQueue来实现生产者-消费者并发模型(下一节中有介绍),当然在Java 5以前也可以通过wait和notify来实现线程调度,比较一下两种代码就知道基于已有的并发工具类来重构并发代码到底好在哪里了。

  • 基于wait和notify的实现
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * 公共常量
 * @author 骆昊
 *
 */
class Constants {
    public static final int MAX_BUFFER_SIZE = 10;
    public static final int NUM_OF_PRODUCER = 2;
    public static final int NUM_OF_CONSUMER = 3;
}

/**
 * 工作任务
 * @author 骆昊
 *
 */
class Task {
    private String id;  // 任务的编号

    public Task() {
        id = UUID.randomUUID().toString();
    }

    @Override
    public String toString() {
        return "Task[" + id + "]";
    }
}

/**
 * 消费者
 * @author 骆昊
 *
 */
class Consumer implements Runnable {
    private List<Task> buffer;

    public Consumer(List<Task> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while(true) {
            synchronized(buffer) {
                while(buffer.isEmpty()) {
                    try {
                        buffer.wait();
                    } catch(InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                Task task = buffer.remove(0);
                buffer.notifyAll();
                System.out.println("Consumer[" + Thread.currentThread().getName() + "] got " + task);
            }
        }
    }
}

/**
 * 生产者
 * @author 骆昊
 *
 */
class Producer implements Runnable {
    private List<Task> buffer;

    public Producer(List<Task> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while(true) {
            synchronized (buffer) {
                while(buffer.size() >= Constants.MAX_BUFFER_SIZE) {
                    try {
                        buffer.wait();
                    } catch(InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                Task task = new Task();
                buffer.add(task);
                buffer.notifyAll();
                System.out.println("Producer[" + Thread.currentThread().getName() + "] put " + task);
            }
        }
    }

}

public class Test06 {

    public static void main(String[] args) {
        List<Task> buffer = new ArrayList<>(Constants.MAX_BUFFER_SIZE);
        ExecutorService es = Executors.newFixedThreadPool(Constants.NUM_OF_CONSUMER + Constants.NUM_OF_PRODUCER);
        for(int i = 1; i <= Constants.NUM_OF_PRODUCER; ++i) {
            es.execute(new Producer(buffer));
        }
        for(int i = 1; i <= Constants.NUM_OF_CONSUMER; ++i) {
            es.execute(new Consumer(buffer));
        }
    }
}
  • 基于BlockingQueue的实现
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;

/**
 * 公共常量
 * @author 骆昊
 *
 */
class Constants {
    public static final int MAX_BUFFER_SIZE = 10;
    public static final int NUM_OF_PRODUCER = 2;
    public static final int NUM_OF_CONSUMER = 3;
}

/**
 * 工作任务
 * @author 骆昊
 *
 */
class Task {
    private String id;  // 任务的编号

    public Task() {
        id = UUID.randomUUID().toString();
    }

    @Override
    public String toString() {
        return "Task[" + id + "]";
    }
}

/**
 * 消费者
 * @author 骆昊
 *
 */
class Consumer implements Runnable {
    private BlockingQueue<Task> buffer;

    public Consumer(BlockingQueue<Task> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while(true) {
            try {
                Task task = buffer.take();
                System.out.println("Consumer[" + Thread.currentThread().getName() + "] got " + task);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

/**
 * 生产者
 * @author 骆昊
 *
 */
class Producer implements Runnable {
    private BlockingQueue<Task> buffer;

    public Producer(BlockingQueue<Task> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while(true) {
            try {
                Task task = new Task();
                buffer.put(task);
                System.out.println("Producer[" + Thread.currentThread().getName() + "] put " + task);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }

}

public class Test07 {

    public static void main(String[] args) {
        BlockingQueue<Task> buffer = new LinkedBlockingQueue<>(Constants.MAX_BUFFER_SIZE);
        ExecutorService es = Executors.newFixedThreadPool(Constants.NUM_OF_CONSUMER + Constants.NUM_OF_PRODUCER);
        for(int i = 1; i <= Constants.NUM_OF_PRODUCER; ++i) {
            es.execute(new Producer(buffer));
        }
        for(int i = 1; i <= Constants.NUM_OF_CONSUMER; ++i) {
            es.execute(new Consumer(buffer));
        }
    }
}

使用BlockingQueue后代码优雅了很多。

并发模型

在继续下面的探讨之前,我们还是重温一下几个概念:

概念解释
临界资源并发环境中有着固定数量的资源
互斥对资源的访问是排他式的
饥饿一个或一组线程长时间或永远无法取得进展
死锁两个或多个线程相互等待对方结束
活锁想要执行的线程总是发现其他的线程正在执行以至于长时间或永远无法执行

重温了这几个概念后,我们可以探讨一下下面的几种并发模型。

生产者-消费者

一个或多个生产者创建某些工作并将其置于缓冲区或队列中,一个或多个消费者会从队列中获得这些工作并完成之。这里的缓冲区或队列是临界资源。当缓冲区或队列放满的时候,生产这会被阻塞;而缓冲区或队列为空的时候,消费者会被阻塞。生产者和消费者的调度是通过二者相互交换信号完成的。

读者-写者

当存在一个主要为读者提供信息的共享资源,它偶尔会被写者更新,但是需要考虑系统的吞吐量,又要防止饥饿和陈旧资源得不到更新的问题。在这种并发模型中,如何平衡读者和写者是最困难的,当然这个问题至今还是一个被热议的问题,恐怕必须根据具体的场景来提供合适的解决方案而没有那种放之四海而皆准的方法(不像我在国内的科研文献中看到的那样)。

哲学家进餐

1965年,荷兰计算机科学家图灵奖得主Edsger Wybe Dijkstra提出并解决了一个他称之为哲学家进餐的同步问题。这个问题可以简单地描述如下:五个哲学家围坐在一张圆桌周围,每个哲学家面前都有一盘通心粉。由于通心粉很滑,所以需要两把叉子才能夹住。相邻两个盘子之间放有一把叉子如下图所示。哲学家的生活中有两种交替活动时段:即吃饭和思考。当一个哲学家觉得饿了时,他就试图分两次去取其左边和右边的叉子,每次拿一把,但不分次序。如果成功地得到了两把叉子,就开始吃饭,吃完后放下叉子继续思考。
把上面问题中的哲学家换成线程,把叉子换成竞争的临界资源,上面的问题就是线程竞争资源的问题。如果没有经过精心的设计,系统就会出现死锁、活锁、吞吐量下降等问题。
这里写图片描述
下面是用信号量原语来解决哲学家进餐问题的代码,使用了Java 5并发工具包中的Semaphore类(代码不够漂亮但是已经足以说明问题了)。

//import java.util.concurrent.ExecutorService;
//import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

/**
 * 存放线程共享信号量的上下问
 * @author 骆昊
 *
 */
class AppContext {
    public static final int NUM_OF_FORKS = 5;   // 叉子数量(资源)
    public static final int NUM_OF_PHILO = 5;   // 哲学家数量(线程)

    public static Semaphore[] forks;    // 叉子的信号量
    public static Semaphore counter;    // 哲学家的信号量

    static {
        forks = new Semaphore[NUM_OF_FORKS];

        for (int i = 0, len = forks.length; i < len; ++i) {
            forks[i] = new Semaphore(1);    // 每个叉子的信号量为1
        }

        counter = new Semaphore(NUM_OF_PHILO - 1);  // 如果有N个哲学家,最多只允许N-1人同时取叉子
    }

    /**
     * 取得叉子
     * @param index 第几个哲学家
     * @param leftFirst 是否先取得左边的叉子
     * @throws InterruptedException
     */
    public static void putOnFork(int index, boolean leftFirst) throws InterruptedException {
        if(leftFirst) {
            forks[index].acquire();
            forks[(index + 1) % NUM_OF_PHILO].acquire();
        }
        else {
            forks[(index + 1) % NUM_OF_PHILO].acquire();
            forks[index].acquire();
        }
    }

    /**
     * 放回叉子
     * @param index 第几个哲学家
     * @param leftFirst 是否先放回左边的叉子
     * @throws InterruptedException
     */
    public static void putDownFork(int index, boolean leftFirst) throws InterruptedException {
        if(leftFirst) {
            forks[index].release();
            forks[(index + 1) % NUM_OF_PHILO].release();
        }
        else {
            forks[(index + 1) % NUM_OF_PHILO].release();
            forks[index].release();
        }
    }
}

/**
 * 哲学家
 * @author 骆昊
 *
 */
class Philosopher implements Runnable {
    private int index;      // 编号
    private String name;    // 名字

    public Philosopher(int index, String name) {
        this.index = index;
        this.name = name;
    }

    @Override
    public void run() {
        while(true) {
            try {
                AppContext.counter.acquire();
                boolean leftFirst = index % 2 == 0;
                AppContext.putOnFork(index, leftFirst);
                System.out.println(name + "正在吃意大利面(通心粉)...");   // 取到两个叉子就可以进食
                AppContext.putDownFork(index, leftFirst);
                AppContext.counter.release();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Test04 {

    public static void main(String[] args) {
        String[] names = { "骆昊", "王大锤", "张三丰", "杨过", "李莫愁" };   // 5位哲学家的名字
//      ExecutorService es = Executors.newFixedThreadPool(AppContext.NUM_OF_PHILO); // 创建固定大小的线程池
//      for(int i = 0, len = names.length; i < len; ++i) {
//          es.execute(new Philosopher(i, names[i]));   // 启动线程
//      }
//      es.shutdown();
        for(int i = 0, len = names.length; i < len; ++i) {
            new Thread(new Philosopher(i, names[i])).start();
        }
    }

}

现实中的并发问题基本上都是这三种模型或者是这三种模型的变体。

测试并发代码

对并发代码的测试也是非常棘手的事情,棘手到无需说明大家也很清楚的程度,所以这里我们只是探讨一下如何解决这个棘手的问题。我们建议大家编写一些能够发现问题的测试并经常性的在不同的配置和不同的负载下运行这些测试。不要忽略掉任何一次失败的测试,线程代码中的缺陷可能在上万次测试中仅仅出现一次。具体来说有这么几个注意事项:

  • 不要将系统的失效归结于偶发事件,就像拉不出屎的时候不能怪地球没有引力。
  • 先让非并发代码工作起来,不要试图同时找到并发和非并发代码中的缺陷。
  • 编写可以在不同配置环境下运行的线程代码。
  • 编写容易调整的线程代码,这样可以调整线程使性能达到最优。
  • 让线程的数量多于CPU或CPU核心的数量,这样CPU调度切换过程中潜在的问题才会暴露出来。
  • 让并发代码在不同的平台上运行。
  • 通过自动化或者硬编码的方式向并发代码中加入一些辅助测试的代码。

Java 7的并发编程

Java 7中引入了TransferQueue,它比BlockingQueue多了一个叫transfer的方法,如果接收线程处于等待状态,该操作可以马上将任务交给它,否则就会阻塞直至取走该任务的线程出现。可以用TransferQueue代替BlockingQueue,因为它可以获得更好的性能。
刚才忘记了一件事情,Java 5中还引入了Callable接口、Future接口和FutureTask接口,通过他们也可以构建并发应用程序,代码如下所示。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Test07 {
    private static final int POOL_SIZE = 10;

    static class CalcThread implements Callable<Double> {
        private List<Double> dataList = new ArrayList<>();

        public CalcThread() {
            for(int i = 0; i < 10000; ++i) {
                dataList.add(Math.random());
            }
        }

        @Override
        public Double call() throws Exception {
            double total = 0;
            for(Double d : dataList) {
                total += d;
            }
            return total / dataList.size();
        }

    }

    public static void main(String[] args) {
        List<Future<Double>> fList = new ArrayList<>();
        ExecutorService es = Executors.newFixedThreadPool(POOL_SIZE);
        for(int i = 0; i < POOL_SIZE; ++i) {
            fList.add(es.submit(new CalcThread()));
        }

        for(Future<Double> f : fList) {
            try {
                System.out.println(f.get());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        es.shutdown();
    }
}

Callable接口也是一个单方法接口,显然这是一个回调方法,类似于函数式编程中的回调函数,在Java 8 以前,Java中还不能使用Lambda表达式来简化这种函数式编程。和Runnable接口不同的是Callable接口的回调方法call方法会返回一个对象,这个对象可以用将来时的方式在线程执行结束的时候获得信息。上面代码中的call方法就是将计算出的10000个0到1之间的随机小数的平均值返回,我们通过一个Future接口的对象得到了这个返回值。目前最新的Java版本中,Callable接口和Runnable接口都被打上了@FunctionalInterface的注解,也就是说它可以用函数式编程的方式(Lambda表达式)创建接口对象。
下面是Future接口的主要方法:

  • get():获取结果。如果结果还没有准备好,get方法会阻塞直到取得结果;当然也可以通过参数设置阻塞超时时间。
  • cancel():在运算结束前取消。
  • isDone():可以用来判断运算是否结束。

Java 7中还提供了分支/合并(fork/join)框架,它可以实现线程池中任务的自动调度,并且这种调度对用户来说是透明的。为了达到这种效果,必须按照用户指定的方式对任务进行分解,然后再将分解出的小型任务的执行结果合并成原来任务的执行结果。这显然是运用了分治法(divide-and-conquer)的思想。下面的代码使用了分支/合并框架来计算1到10000的和,当然对于如此简单的任务根本不需要分支/合并框架,因为分支和合并本身也会带来一定的开销,但是这里我们只是探索一下在代码中如何使用分支/合并框架,让我们的代码能够充分利用现代多核CPU的强大运算能力。

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.RecursiveTask;

class Calculator extends RecursiveTask<Integer> {
    private static final long serialVersionUID = 7333472779649130114L;

    private static final int THRESHOLD = 10;
    private int start;
    private int end;

    public Calculator(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    public Integer compute() {
        int sum = 0;
        if ((end - start) < THRESHOLD) {    // 当问题分解到可求解程度时直接计算结果
            for (int i = start; i <= end; i++) {
                sum += i;
            }
        } else {
            int middle = (start + end) >>> 1;
            // 将任务一分为二
            Calculator left = new Calculator(start, middle);
            Calculator right = new Calculator(middle + 1, end);
            left.fork();
            right.fork();
            // 注意:由于此处是递归式的任务分解,也就意味着接下来会二分为四,四分为八...

            sum = left.join() + right.join();   // 合并两个子任务的结果
        }
        return sum;
    }

}

public class Test08 {

    public static void main(String[] args) throws Exception {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        Future<Integer> result = forkJoinPool.submit(new Calculator(1, 10000));
        System.out.println(result.get());
    }
}

伴随着Java 7的到来,Java中默认的数组排序算法已经不再是经典的快速排序(双枢轴快速排序)了,新的排序算法叫TimSort,它是归并排序和插入排序的混合体,TimSort可以通过分支合并框架充分利用现代处理器的多核特性,从而获得更好的性能(更短的排序时间)。

参考文献

  1. Benjamin J. Evans, etc, The Well-Grounded Java Developer. Jul 21, 2012
  2. Robert Martin, Clean Code. Aug 11, 2008.
  3. Doug Lea, Concurrent Programming in Java: Design Principles and Patterns. 1999

相关文章

RESTful 架构风格下的 4 大常见安全问题

$
0
0

伴随着RESTful架构风格的大量应用微服务架构的流行,一些本来难以察觉到的安全问题也逐渐开始显现出来。在我经历过的各种采用RESTful微服务架构风格的应用中,某些安全问题几乎在每个应用中都会出现。然而它们并非是什么高深的技术难题,只不过是借着微服务的流行而显得越发突出,这些都可以通过一些安全实践来避免。本文将一些典型的问题列举出来,希望能引起开发团队的注意,帮助他们绕过这些安全问题的“坑”。

1. 遗漏了对资源从属关系的检查

一个典型的RESTful的URL会用资源名加上资源的ID编号来标识其唯一性,就像这样: /users/,例如: /users/100

一般而言用户只能查看自己的用户信息,而不允许查看其它用户的信息。在这种情况下,攻击者很可能会尝试把这个URL里面的USER ID从100修改为其他数值,以期望应用返回指定用户的信息。不过由于这个安全风险太显而易见,绝大多数应用都会对当前请求者的身份进行校验,看其是否是编号为100的用户,校验成功才返回URL中指定的用户信息,否则会拒绝当前请求。

对于URL中只出现一个资源的情况,绝大多数应用都已经做了安全防御,然而重灾区出现在URL中包含多个资源的时候。

以用户查看订单的RESTful URL为例: /users/100/orders/280010,应用只检查了当前请求发起者是否是编号为100的用户,以及编号为280010的订单是否存在,有很大的概率没有检查URL中的订单和用户之间的从属关系。其结果是,攻击者可以通过修改URL中的订单编号,从而遍历系统中的所有订单信息,甚至对不属于他/她的订单发起操作,例如取消订单。

上面的例子中只有两个资源,如果URL中资源数量继续增加,这种从属关系校验缺失的情况只会更加普遍。

解决这一问题的方法极其简单,只要发现URL里面出现了两个或者两个以上的资源,就像下面这样:

/ResourceA//ResourceB//ResourceC/

在对资源进行操作之前,就得先检查这些资源之间的从属关系,以确保当前请求具有相关的访问、操作权限。

2. HTTP响应中缺失必要的 Security Headers

HTTP中有一些和安全相关的Header,通过对它们的合理使用,可以使得应用在具备更高的安全性的同时,并不会显著增大开发者的工作负担,有着“低成本高收益”的效果。不过绝大多数情况下,这些Header是默认关闭的,因此很多应用中也就缺失了这些Security Headers。一些典型的Security Headers如下:

X-Frame-Options
为了防止应用遭受点击劫持攻击,可以使用 X-Frame-Options: DENY明确告知浏览器,不要把当前HTTP响应中的内容在HTML Frame中显示出来。

X-Content-Type-Options
在浏览器收到HTTP响应内容时,它会尝试按照自己的规则去推断响应内容的类型,并根据推断结果执行后续操作,而这可能造成安全问题。例如,一个包含恶意JavaScript代码的HTTP响应内容,虽然其 Content-Typeimage/png,但是浏览器推断出这是一段脚本并且会执行它。

X-Content-Type-Options就是专门用来解决这个问题的Header。通过将其设置为 X-Content-Type-Options: nosniff,浏览器将不再自作主张的推断HTTP响应内容的类型,而是严格按照响应中 Content-Type所指定的类型来解析响应内容。

X-XSS-Protection
避免应用出现跨站脚本漏洞(Cross-Site Scripting,简称XSS)的最佳办法是对输出数据进行正确的编码,不过除此之外,现如今的浏览器也自带了防御XSS的能力。

要开启浏览器的防XSS功能,只需要在HTTP响应中加上这个Header: X-XSS-Protection: 1; mode=block。其中,数字 1代表开启浏览器的XSS防御功能, mode=block是告诉浏览器,如果发现有XSS攻击,则直接屏蔽掉当前即将渲染的内容。

Strict-Transport-Security
使用TLS可以保护数据在传输过程中的安全,而在HTTP响应中添加上 Strict-Transport-Security这个Header,可以告知浏览器直接发起HTTPS请求,而不再像往常那样,先发送明文的HTTP请求,得到服务器跳转指令后再发送后续的HTTPS请求。并且,一旦浏览器接收到这个Header,那么当它发现数据传输通道不安全的时候,它会直接拒绝进行任何的数据传输,不再允许用户继续通过不安全的传输通道传输数据,以避免信息泄露。

3. 不经意间泄露的业务信息

会说话的ID
资源ID是RESTful URL中很重要的一个组成部分,大多数情况下这类资源ID都是用数字来表示的。这在不经意间泄露了业务信息,而这些信息可能正是竞争对手希望得到的数据。

以查看用户信息的RESTful URL为例: /users/100。由于用户ID是一个按序递增的数字,因此攻击者既可以通过ID知道目前应用中的用户规模,也可以分别在月初和月末的时候注册一个用户,并对比两个用户的ID即可知道当前这个月有多少新增用户。同理,如果订单号也是按序自增的数字,攻击者可以了解到一定时间范围内的订单量。

这类ID并不会给应用造成任何技术上的威胁,只是通过ID泄露出来的信息对于你的业务而言可能非常敏感。解决办法是不使用按序递增的数字作为ID,而是使用具有随机性、唯一性、不可预测性的值作为ID,最常见的做法就是使用UUID。

返回多余的数据
前后端分离的情况下,两者之间通常以JSON作为数据传输的主体。有时候可能是为了方便前端代码处理,也可能是疏忽大意,总之后端API返回的JSON数据中包含了远远超出前端代码需要的数据,因此造成数据泄露。

例如,前端代码本意是请求订单信息,但是后端API返回的订单JSON数据中还包含了很多“有意思”的数据。

{"id": 280010, "orderItems": [...], "user": {"id": 100, "password": "91B4E3E45B2465A4823BB5C03FF81B65"
    },
    ...
}

上面这个例子里,订单数据中包含了用户信息,最为关键的是连用户的密码字段也被包含在内。

解决办法显而易见,在给前端返回数据之前,将这些敏感的、前端并不需要的数据过滤掉。技术上实现起来易如反掌,但是真正难的地方在于让整个应用都严格的按照这样的方式来处理JSON数据,确保没有任何遗漏之处。

4. API缺乏速率限制的保护

先看一个例子。用户注册时发送短信验证码的API,由于没有做速率限制,使得攻击者可以用一段脚本不断的请求服务器发送短信验证码,导致在短时间内耗尽短信发送配额,或者造成短信网关拥挤等等后果。

受伤的不仅仅是发送短信的API,其他一些比较敏感的API如果缺乏请求速率限制的保护,同样也会遭遇安全问题。例如用户登录的API缺乏速率限制的话,攻击者可以利用其进行用户名密码暴力破解,再例如某些大量消耗服务器资源的API如果缺乏速率限制,攻击者可以利用其发起拒绝式攻击。

解决这类安全问题的原则就是对API请求的速率进行适当的限制。具体的做法有很多,最典型的例子就是使用图片验证码,其他的做法还有利用Redis的Expire特性对请求速率进行统计判断,甚至借助运维的力量(例如网络防火墙)来共同进行防御等等。

总结

开发出一个具备足够安全性的应用不是件容易的事情,本文中提到的只是RESTful架构风格下,众多安全问题中比较典型的一部分而已。之所以会有这些问题,其本质原因在于应用开发过程中,开发团队的注意力集中在业务功能的实现上,应用安全性相关的需求没有得到足够的明确和重视。

如果你不想被这些安全问题所困扰,建议通过在应用开发过程中引入威胁建模、在用户故事卡中设立安全验收标准、进行安全代码审查等一系列安全实践,尽可能从源头上规避这些问题。

RESTful 架构风格下的 4 大常见安全问题,首发于 文章 - 伯乐在线

JVM初探——使用堆外内存减少Full GC

$
0
0

问题: 大部分主流互联网企业线上Server JVM选用了CMS收集器(如Taobao、LinkedIn、Vdian), 虽然CMS可与用户线程并发GC以降低STW时间, 但它也并非十分完美, 尤其是当出现 Concurrent Mode Failure由并行GC转入串行时, 将导致非常长时间的 Stop The World(详细可参考 JVM初探- 内存分配、GC原理与垃圾收集器).

解决: 由 GCIH可以联想到:  将长期存活的对象(如Local Cache)移入堆外内存(off-heap, 又名 直接内存/direct-memory), 从而减少CMS管理的对象数量, 以降低Full GC的次数和频率, 达到提高系统响应速度的目的.


引入

这个idea最初来源于TaobaoJVM对OpenJDK定制开发的GCIH部分(详见 撒迦的分享- JVM定制改进@淘宝), 其中GCIH就是将CMS Old Heap区的一部分划分出来, 这部分内存虽然还在堆内, 但已不被GC所管理. 将长生命周期Java对象放在Java堆外, GC不能管理GCIH内Java对象(GC Invisible Heap):

(图片来源: JVM@Taobao PPT)

  • 这样做有两方面的好处:
    1. 减少GC管理内存:
      由于GCIH会从Old区 “切出”一块, 因此导致GC管理区域变小, 可以明显降低GC工作量, 提高GC效率, 降低Full GC STW时间(且由于这部分内存仍属于堆, 因此其访问方式/速度不变-  不必付出序列化/反序列化的开销).
    2. GCIH内容进程间共享:
      由于这部分区域不再是JVM运行时数据的一部分, 因此GCIH内的对象可供对个JVM实例所共享(如一台Server跑多个MR-Job可共享同一份Cache数据), 这样一台Server也就可以跑更多的VM实例.

(实际测试数据/图示可下载 撒迦分享 PPT).

但是大部分的互联公司不能像阿里这样可以有专门的工程师针对自己的业务特点定制JVM, 因此我们只能”眼馋”GCIH带来的性能提升却无法”享用”. 但通用的JVM开放了接口可直接向操作系统申请堆外内存( ByteBuffer or  Unsafe), 而这部分内存也是GC所顾及不到的, 因此我们可用JVM堆外内存来模拟GCIH的功能(但相比GCIH不足的是需要付出serialize/deserialize的开销).


JVM堆外内存

JVM初探 -JVM内存模型一文中介绍的 Java运行时数据区域中是找不到堆外内存区域的:

因为它并不是JVM运行时数据区的一部分, 也不是Java虚拟机规范中定义的内存区域, 这部分内存区域直接被操作系统管理.
在JDK 1.4以前, 对这部分内存访问没有光明正大的做法: 只能通过反射拿到 Unsafe类, 然后调用 allocateMemory()/freeMemory()来申请/释放这块内存. 1.4开始新加入了NIO, 它引入了一种基于Channel与Buffer的I/O方式, 可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作,  ByteBuffer提供了如下常用方法来跟堆外内存打交道:

API描述
static ByteBuffer allocateDirect(int capacity)Allocates a new direct byte buffer.
ByteBuffer put(byte b)Relative put method (optional operation).
ByteBuffer put(byte[] src)Relative bulk put method (optional operation).
ByteBuffer putXxx(Xxx value)Relative put method for writing a Char/Double/Float/Int/Long/Short value (optional operation).
ByteBuffer get(byte[] dst)Relative bulk get method.
Xxx getXxx()Relative get method for reading a Char/Double/Float/Int/Long/Short value.
XxxBuffer asXxxBuffer()Creates a view of this byte buffer as a Char/Double/Float/Int/Long/Short buffer.
ByteBuffer asReadOnlyBuffer()Creates a new, read-only byte buffer that shares this buffer’s content.
boolean isDirect()Tells whether or not this byte buffer is direct.
ByteBuffer duplicate()Creates a new byte buffer that shares this buffer’s content.

下面我们就用通用的JDK API来使用堆外内存来实现一个 local cache.


示例1.: 使用JDK API实现堆外Cache

注: 主要逻辑都集中在方法 invoke()内, 而 AbstractAppInvoker是一个自定义的性能测试框架, 在后面会有详细的介绍.

/**
 * @author jifang
 * @since 2016/12/31 下午6:05.
 */
public class DirectByteBufferApp extends AbstractAppInvoker {

    @Test
    @Override
    public void invoke(Object... param) {
        Map<String, FeedDO> map = createInHeapMap(SIZE);

        // move in off-heap
        byte[] bytes = serializer.serialize(map);
        ByteBuffer buffer = ByteBuffer.allocateDirect(bytes.length);
        buffer.put(bytes);
        buffer.flip();

        // for gc
        map = null;
        bytes = null;
        System.out.println("write down");
        // move out from off-heap
        byte[] offHeapBytes = new byte[buffer.limit()];
        buffer.get(offHeapBytes);
        Map<String, FeedDO> deserMap = serializer.deserialize(offHeapBytes);
        for (int i = 0; i < SIZE; ++i) {
            String key = "key-" + i;
            FeedDO feedDO = deserMap.get(key);
            checkValid(feedDO);

            if (i % 10000 == 0) {
                System.out.println("read " + i);
            }
        }

        free(buffer);
    }

    private Map<String, FeedDO> createInHeapMap(int size) {
        long createTime = System.currentTimeMillis();

        Map<String, FeedDO> map = new ConcurrentHashMap<>(size);
        for (int i = 0; i < size; ++i) {
            String key = "key-" + i;
            FeedDO value = createFeed(i, key, createTime);
            map.put(key, value);
        }

        return map;
    }
}

由JDK提供的堆外内存访问API只能申请到一个类似一维数组的 ByteBuffer, JDK并未提供基于堆外内存的实用数据结构实现(如堆外的 MapSet), 因此想要实现Cache的功能只能在 write()时先将数据 put()到一个堆内的 HashMap, 然后再将整个 Map序列化后 MoveInDirectMemory, 取缓存则反之. 由于需要在堆内申请 HashMap, 因此可能会导致多次Full GC. 这种方式虽然可以使用堆外内存, 但性能不高、无法发挥堆外内存的优势.
幸运的是开源界的前辈开发了诸如 EhcacheMapDBChronicle Map等一系列优秀的堆外内存框架, 使我们可以在使用简洁API访问堆外内存的同时又不损耗额外的性能.

其中又以Ehcache最为强大, 其提供了in-heap、off-heap、on-disk、cluster四级缓存, 且Ehcache企业级产品( BigMemory Max /  BigMemoryGo)实现的BigMemory也是Java堆外内存领域的先驱.


示例2: MapDB API实现堆外Cache

public class MapDBApp extends AbstractAppInvoker {

    private static HTreeMap<String, FeedDO> mapDBCache;

    static {
        mapDBCache = DBMaker.hashMapSegmentedMemoryDirect()
                .expireMaxSize(SIZE)
                .make();
    }

    @Test
    @Override
    public void invoke(Object... param) {

        for (int i = 0; i < SIZE; ++i) {
            String key = "key-" + i;
            FeedDO feed = createFeed(i, key, System.currentTimeMillis());

            mapDBCache.put(key, feed);
        }

        System.out.println("write down");
        for (int i = 0; i < SIZE; ++i) {
            String key = "key-" + i;
            FeedDO feedDO = mapDBCache.get(key);
            checkValid(feedDO);

            if (i % 10000 == 0) {
                System.out.println("read " + i);
            }
        }
    }
}

结果 & 分析

  • DirectByteBufferApp
 S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT
0.00   0.00   5.22  78.57  59.85     19    2.902    13    7.251   10.153
  • the last one jstat of  MapDBApp
 S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT
0.00   0.03   8.02   0.38  44.46    171    0.238     0    0.000    0.238

运行 DirectByteBufferApp.invoke()会发现有看到很多Full GC的产生, 这是因为HashMap需要一个很大的连续数组, Old区很快就会被占满, 因此也就导致频繁Full GC的产生.
而运行 MapDBApp.invoke()可以看到有一个 DirectMemory持续增长的过程, 但FullGC却一次都没有了.


实验: 使用堆外内存减少Full GC

实验环境

  • java -version
java version "1.7.0_79"
Java(TM) SE Runtime Environment (build 1.7.0_79-b15)
Java HotSpot(TM) 64-Bit Server VM (build 24.79-b02, mixed mode)
  • VM Options
-Xmx512M
-XX:MaxDirectMemorySize=512M
-XX:+PrintGC
-XX:+UseConcMarkSweepGC
-XX:+CMSClassUnloadingEnabled
-XX:CMSInitiatingOccupancyFraction=80
-XX:+UseCMSInitiatingOccupancyOnly
  • 实验数据
    170W条动态(FeedDO).

实验代码

第1组: in-heap、affect by GC、no serialize

  • ConcurrentHashMapApp
public class ConcurrentHashMapApp extends AbstractAppInvoker {

    private static final Map<String, FeedDO> cache = new ConcurrentHashMap<>();

    @Test
    @Override
    public void invoke(Object... param) {

        // write
        for (int i = 0; i < SIZE; ++i) {
            String key = String.format("key_%s", i);
            FeedDO feedDO = createFeed(i, key, System.currentTimeMillis());
            cache.put(key, feedDO);
        }

        System.out.println("write down");
        // read
        for (int i = 0; i < SIZE; ++i) {
            String key = String.format("key_%s", i);
            FeedDO feedDO = cache.get(key);
            checkValid(feedDO);

            if (i % 10000 == 0) {
                System.out.println("read " + i);
            }
        }
    }
}

GuavaCacheApp类似, 详细代码可参考完整项目.


第2组: off-heap、not affect by GC、need serialize

  • EhcacheApp
public class EhcacheApp extends AbstractAppInvoker {

    private static Cache<String, FeedDO> cache;

    static {
        ResourcePools resourcePools = ResourcePoolsBuilder.newResourcePoolsBuilder()
                .heap(1000, EntryUnit.ENTRIES)
                .offheap(480, MemoryUnit.MB)
                .build();

        CacheConfiguration<String, FeedDO> configuration = CacheConfigurationBuilder
                .newCacheConfigurationBuilder(String.class, FeedDO.class, resourcePools)
                .build();

        cache = CacheManagerBuilder.newCacheManagerBuilder()
                .withCache("cacher", configuration)
                .build(true)
                .getCache("cacher", String.class, FeedDO.class);

    }

    @Test
    @Override
    public void invoke(Object... param) {
        for (int i = 0; i < SIZE; ++i) {
            String key = String.format("key_%s", i);
            FeedDO feedDO = createFeed(i, key, System.currentTimeMillis());
            cache.put(key, feedDO);
        }

        System.out.println("write down");
        // read
        for (int i = 0; i < SIZE; ++i) {
            String key = String.format("key_%s", i);
            Object o = cache.get(key);
            checkValid(o);

            if (i % 10000 == 0) {
                System.out.println("read " + i);
            }
        }
    }
}

MapDBApp与前同.


第3组: off-process、not affect by GC、serialize、affect by process communication

  • LocalRedisApp
public class LocalRedisApp extends AbstractAppInvoker {

    private static final Jedis cache = new Jedis("localhost", 6379);

    private static final IObjectSerializer serializer = new Hessian2Serializer();

    @Test
    @Override
    public void invoke(Object... param) {
        // write
        for (int i = 0; i < SIZE; ++i) {
            String key = String.format("key_%s", i);
            FeedDO feedDO = createFeed(i, key, System.currentTimeMillis());

            byte[] value = serializer.serialize(feedDO);
            cache.set(key.getBytes(), value);

            if (i % 10000 == 0) {
                System.out.println("write " + i);
            }
        }

        System.out.println("write down");
        // read
        for (int i = 0; i < SIZE; ++i) {
            String key = String.format("key_%s", i);
            byte[] value = cache.get(key.getBytes());
            FeedDO feedDO = serializer.deserialize(value);
            checkValid(feedDO);

            if (i % 10000 == 0) {
                System.out.println("read " + i);
            }
        }
    }
}

RemoteRedisApp类似, 详细代码可参考下面完整项目.


实验结果

*ConcurrentMapGuava
TTC32166ms/32s47520ms/47s
Minor C/T31/1.52229/1.312
Full C/T24/23.21236/41.751
MapDBEhcache
TTC40272ms/40s30814ms/31s
Minor C/T511/0.557297/0.430
Full C/T0/0.0000/0.000
LocalRedisNetworkRedis
TTC176382ms/176s1h+
Minor C/T421/0.415-
Full C/T0/0.000-

备注:
- TTC: Total Time Cost 总共耗时
- C/T: Count/Time 次数/耗时(seconds)


结果分析

对比前面几组数据, 可以有如下总结:

  • 将长生命周期的大对象(如cache)移出heap可大幅度降低Full GC次数与耗时;
  • 使用off-heap存储对象需要付出serialize/deserialize成本;
  • 将cache放入分布式缓存需要付出进程间通信/网络通信的成本(UNIX Domain/TCP IP)

附:
off-heap的Ehcache能够跑出比in-heap的HashMap/Guava更好的成绩确实是我始料未及的O(∩_∩)O~, 但确实这些数据和堆内存的搭配导致in-heap的Full GC太多了, 当heap堆开大之后就肯定不是这个结果了. 因此在使用堆外内存降低Full GC前, 可以先考虑是否可以将heap开的更大.


附: 性能测试框架

在main函数启动时, 扫描 com.vdian.se.apps包下的所有继承了 AbstractAppInvoker的类, 然后使用 Javassist为每个类生成一个代理对象: 当 invoke()方法执行时首先检查他是否标注了 @Test注解(在此, 我们借用junit定义好了的注解), 并在执行的前后记录方法执行耗时, 并最终对比每个实现类耗时统计.

  • 依赖
<dependency><groupId>org.apache.commons</groupId><artifactId>commons-proxy</artifactId><version>${commons.proxy.version}</version></dependency><dependency><groupId>org.javassist</groupId><artifactId>javassist</artifactId><version>${javassist.version}</version></dependency><dependency><groupId>com.caucho</groupId><artifactId>hessian</artifactId><version>${hessian.version}</version></dependency><dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>${guava.version}</version></dependency><dependency><groupId>junit</groupId><artifactId>junit</artifactId><version>${junit.version}</version></dependency>

启动类:  OffHeapStarter

/**
 * @author jifang
 * @since 2017/1/1 上午10:47.
 */
public class OffHeapStarter {

    private static final Map<String, Long> STATISTICS_MAP = new HashMap<>();

    public static void main(String[] args) throws IOException, IllegalAccessException, InstantiationException {
        Set<Class<?>> classes = PackageScanUtil.scanPackage("com.vdian.se.apps");
        for (Class<?> clazz : classes) {
            AbstractAppInvoker invoker = createProxyInvoker(clazz.newInstance());
            invoker.invoke();

            //System.gc();
        }

        System.out.println("********************* statistics **********************");
        for (Map.Entry<String, Long> entry : STATISTICS_MAP.entrySet()) {
            System.out.println("method [" + entry.getKey() + "] total cost [" + entry.getValue() + "]ms");
        }
    }

    private static AbstractAppInvoker createProxyInvoker(Object invoker) {
        ProxyFactory factory = new JavassistProxyFactory();
        Class<?> superclass = invoker.getClass().getSuperclass();
        Object proxy = factory
                .createInterceptorProxy(invoker, new ProfileInterceptor(), new Class[]{superclass});
        return (AbstractAppInvoker) proxy;
    }

    private static class ProfileInterceptor implements Interceptor {

        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            Class<?> clazz = invocation.getProxy().getClass();
            Method method = clazz.getMethod(invocation.getMethod().getName(), Object[].class);

            Object result = null;
            if (method.isAnnotationPresent(Test.class)
                    && method.getName().equals("invoke")) {

                String methodName = String.format("%s.%s", clazz.getSimpleName(), method.getName());
                System.out.println("method [" + methodName + "] start invoke");

                long start = System.currentTimeMillis();
                result = invocation.proceed();
                long cost = System.currentTimeMillis() - start;

                System.out.println("method [" + methodName + "] total cost [" + cost + "]ms");

                STATISTICS_MAP.put(methodName, cost);
            }

            return result;
        }
    }
}
  • 包扫描工具: PackageScanUtil
public class PackageScanUtil {

    private static final String CLASS_SUFFIX = ".class";

    private static final String FILE_PROTOCOL = "file";

    public static Set<Class<?>> scanPackage(String packageName) throws IOException {

        Set<Class<?>> classes = new HashSet<>();
        String packageDir = packageName.replace('.', '/');
        Enumeration<URL> packageResources = Thread.currentThread().getContextClassLoader().getResources(packageDir);
        while (packageResources.hasMoreElements()) {
            URL packageResource = packageResources.nextElement();

            String protocol = packageResource.getProtocol();
            // 只扫描项目内class
            if (FILE_PROTOCOL.equals(protocol)) {
                String packageDirPath = URLDecoder.decode(packageResource.getPath(), "UTF-8");
                scanProjectPackage(packageName, packageDirPath, classes);
            }
        }

        return classes;
    }

    private static void scanProjectPackage(String packageName, String packageDirPath, Set<Class<?>> classes) {

        File packageDirFile = new File(packageDirPath);
        if (packageDirFile.exists() && packageDirFile.isDirectory()) {

            File[] subFiles = packageDirFile.listFiles(new FileFilter() {
                @Override
                public boolean accept(File pathname) {
                    return pathname.isDirectory() || pathname.getName().endsWith(CLASS_SUFFIX);
                }
            });

            for (File subFile : subFiles) {
                if (!subFile.isDirectory()) {
                    String className = trimClassSuffix(subFile.getName());
                    String classNameWithPackage = packageName + "." + className;

                    Class<?> clazz = null;
                    try {
                        clazz = Class.forName(classNameWithPackage);
                    } catch (ClassNotFoundException e) {
                        // ignore
                    }
                    assert clazz != null;

                    Class<?> superclass = clazz.getSuperclass();
                    if (superclass == AbstractAppInvoker.class) {
                        classes.add(clazz);
                    }
                }
            }
        }
    }

    // trim .class suffix
    private static String trimClassSuffix(String classNameWithSuffix) {
        int endIndex = classNameWithSuffix.length() - CLASS_SUFFIX.length();
        return classNameWithSuffix.substring(0, endIndex);
    }
}

注: 在此仅扫描 项目目录下的 单层目录的class文件, 功能更强大的包扫描工具可参考spring源代码或 Touch源代码中的 PackageScanUtil.


AppInvoker基类:  AbstractAppInvoker

提供通用测试参数 & 工具函数.

public abstract class AbstractAppInvoker {

    protected static final int SIZE = 170_0000;

    protected static final IObjectSerializer serializer = new Hessian2Serializer();

    protected static FeedDO createFeed(long id, String userId, long createTime) {

        return new FeedDO(id, userId, (int) id, userId + "_" + id, createTime);
    }

    protected static void free(ByteBuffer byteBuffer) {
        if (byteBuffer.isDirect()) {
            ((DirectBuffer) byteBuffer).cleaner().clean();
        }
    }

    protected static void checkValid(Object obj) {
        if (obj == null) {
            throw new RuntimeException("cache invalid");
        }
    }

    protected static void sleep(int time, String beforeMsg) {
        if (!Strings.isNullOrEmpty(beforeMsg)) {
            System.out.println(beforeMsg);
        }

        try {
            Thread.sleep(time);
        } catch (InterruptedException ignored) {
            // no op
        }
    }

    /**
     * 供子类继承 & 外界调用
     *
     * @param param
     */
    public abstract void invoke(Object... param);
}

序列化/反序列化接口与实现

public interface IObjectSerializer {<T> byte[] serialize(T obj);<T> T deserialize(byte[] bytes);
}
public class Hessian2Serializer implements IObjectSerializer {

    private static final Logger LOGGER = LoggerFactory.getLogger(Hessian2Serializer.class);

    @Override
    public <T> byte[] serialize(T obj) {
        if (obj != null) {
            try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {

                Hessian2Output out = new Hessian2Output(os);
                out.writeObject(obj);
                out.close();
                return os.toByteArray();

            } catch (IOException e) {
                LOGGER.error("Hessian serialize error ", e);
                throw new CacherException(e);
            }
        }
        return null;
    }

    @SuppressWarnings("unchecked")
    @Override
    public <T> T deserialize(byte[] bytes) {
        if (bytes != null) {
            try (ByteArrayInputStream is = new ByteArrayInputStream(bytes)) {

                Hessian2Input in = new Hessian2Input(is);
                T obj = (T) in.readObject();
                in.close();

                return obj;

            } catch (IOException e) {
                LOGGER.error("Hessian deserialize error ", e);
                throw new CacherException(e);
            }
        }
        return null;
    }
}

完整项目地址:  https://github.com/feiqing/off-heap-tester.git.


GC统计工具

#!/bin/bash

pid=`jps | grep $1 | awk '{print $1}'`
jstat -gcutil ${pid} 400 10000
  • 使用
    sh jstat-uti.sh ${u-main-class}

附加: 为什么在实验中in-heap cache的Minor GC那么少?
现在我还不能给出一个确切地分析答案, 有的同学说是因为CMS Full GC会连带一次Minor GC, 而用 jstat会直接计入Full GC, 但查看详细的GC日志也并未发现什么端倪. 希望有了解的同学可以在下面评论区可以给我留言, 再次先感谢了O(∩_∩)O~.


相关文章

Apache Beam:一个开源的统一的分布式数据处理编程库

$
0
0

Apache Beam是一个开源的数据处理编程库,由Google共享给Apache的项目,前不久刚刚成为Apache TLP项目。它提供了一个高级的、统一的编程模型,允许我们通过构建Pipeline的方式实现批量、流数据处理,并且构建好的Pipeline能够运行在底层不同的执行引擎上。刚刚接触该开源项目时,我的第一感觉就是:在编程API的设计上,数据集及其操作的抽象有点类似Apache Crunch(MapReduce Pipeline编程库)项目;而在支持统一数据处理模型上,能够让人想到Apache Flink项目。如果深入了解Apache Beam,你会发现未来Apache Beam很可能成为数据处理领域唯一一个能够将不同的数据应用统一起来的编程库。

Apache Beam架构概览

Apache Beam目前最新版本为0.5.0-SNAPSHOT,最新的Release版本为0.4.0,很多特性还在开发中。在网上找到一个由Andrew Psaltis在2016年6月份演讲的《Apache Beam: The Case for Unifying Streaming API’s》,引用了其中一个Apache Beam的架构图,如下图所示:
apache-beam-architecture
上图中,我们可以看到,Apache Beam核心的主要有两层:

  • Pipeline构建层

在Pipeline构建层,针对不同的编程语言,构建一组用于定义Pipeline相关抽象,提供编程API,这一层被称为Beam SDKs。最终的用户(具有不同编程语言技能的人员)可以基于这些抽象的Beam SDK来构建数据处理Pipeline。

  • Runner适配层

Runner适配层,主要是用来对接底层的计算引擎,用来执行上层用户开发好的Pipeline程序。

我们先根据官网文档,了解一下Apache Beam的Roadmap。首先,下面的三个特性,或者说是Apache Beam的目标:

  • 统一(UNIFIED)

基于单一的编程模型,能够实现批处理(Batch processing)、流处理(Streaming Processing),通常的做法是把待处理的数据集(Dataset)统一,一般会把有界(Bound)数据集作为无界(Unbound)数据集的一种特殊情况来看待,比如Apache Flink便是按照这种方式处理,在差异化的API层之上构建一个统一的API层。

  • 可移植(PORTABLE)

在多个不同的计算环境下,都能够执行已经定义好的数据处理Pipeline。也就是说,对数据集处理的定义(即构建的Data Pipeline),与最终所要Deploy的执行环境完全无关。这对实现数据处理的企业是非常友好的,当下数据处理新技术不断涌现,企业数据处理平台也为了能够与时俱进并提高处理效率,当然希望在底层计算平台升级的过程中无需重写上层已定义的Data Pipeline。
目前,Apache Beam项目开发整体来看还处在初期,初步决定底层执行环境支持主流的计算平台:Apache Apex、Apache Flink、Apache Spark、Google Cloud Dataflow。实际上,Apache Beam的这种统一编程模型,可以支持任意的计算引擎,通过Data Pipeline层与执行引擎层之间开发一个类似Driver的连接器即可实现。

  • 可扩展(EXTENSIBLE)

实现任意可以共享的Beam SDK、IO connector、Transform库。

基本概念

在使用Apache Beam构建数据处理程序,首先需要使用Beam SDK中的类创建一个Driver程序,在Driver程序中创建一个满足我们数据处理需求的Pipeline,Pipeline中包括输入(Inputs)、转换(Transformations)、输出(Outputs)三个核心的组件。然后,根据我们选择的Beam SDK来确定底层使用Pipeline Runner(执行引擎,或计算引擎),将我们定义好的Pipeline运行在Pipeline Runner上。
Apache Beam SDKs提供一组抽象,用来简化大规模分布式数据处理。同一个Beam抽象,能够同时适应批量处理、流处理两种数据源。下面,我们了解一下Apache Beam的一些关键抽象:

  • Pipeline

一个Pipeline是对一个数据处理任务抽象,它包含了我们在对给定数据集处理的全部逻辑,主要包括从数据源读取数据(可能从多个数据源读取)、在给定的数据集上执行Transform操作(中间可能是一个DAG图,通过多个Transform连接,而Transform的输出和输出都可能是一个数据集)、将Transform的数据结果写入到指定对的存储系统中。

  • PCollection

一个PCollection是对分布式数据集的抽象,他可以是输入数据集、中间结果数据集、输出数据集。每一个由PCollection表征的数据集作为输入时,都会存在一个或多个Transform作用在其上(对数据集进行处理的逻辑)。

  • Transform

一个Transform表示数据处理过程中一个步骤(Step),对应于Pipeline中一个操作,每一个Transform会以一个或多个PCollection作为输入,经过处理后输出一个或多个PCollection。

  • Source and Sink

Apache Beam提供了Source和Sink的API,用来表示读取和写入数据。Source表示从一个外部的数据源读入数据到Pipeline,而Sink表示经过Pipeline处理后将数据写入到外部存储系统

  • PipelineRunner

PipelineRunner是实际用来处理Pipeline逻辑的底层组件,它能够将用户构建的Pipeline翻译成底层计算引擎能够处理的Job,并执行Pipeline的处理逻辑。

API设计

Apache Beam还在开发之中,后续对应的API设计可能会有所变化,不过从当前版本来看,基于对数据处理领域对象的抽象,API的设计风格大量使用泛型来定义,具有很高的抽象级别。下面我们分别对感兴趣的的设计来详细说明。

  • Source

Source表示数据输入的抽象,在API定义上分成两大类:一类是面向数据批处理的,称为BoundedSource,它能够从输入的数据集读取有限的数据记录,知道数据具有有限性的特点,从而能够对输入数据进行切分,分成一定大小的分片,进而实现数据的并行处理;另一类是面向数据流处理的,称为UnboundedSource,它所表示的数据是连续不断地进行输入,从而能够实现支持流式数据所特有的一些操作,如Checkpointing、Watermarks等。
Source对应的类设计,如下类图所示:
Source
目前,Apache Beam支持BoundedSource的数据源主要有:HDFS、MongoDB、Elasticsearch、File等,支持UnboundedSource的数据源主要有:Kinesis、Pubsub、Socker等。未来,任何具有Bounded或Unbounded两类特性的数据源都可以在Apache Beam的抽象基础上实现对应的Source。

  • Sink

Sink表示任何经过Pipeline中一个或多个PTransform处理过的PCollection,最终会输出到特定的存储中。与Source对应,其实Sink主要也是具有两种类型:一种是直接写入特定存储的Bounded类型,如文件系统;另一种是写入具有Unbounded特性的存储或系统中,如Flink。在API设计上,Sink的类图如下所示:
Sink
可见,基于Sink的抽象,可以实现任意可以写入的存储系统。

  • PipelineRunner

下面,我们来看一下PipelineRunner的类设计以及目前开发中的PipelineRunner,如下图所示:
PipelineRunner
目前,PipelineRunner有DirectRunner、DataflowRunner、SparkRunner、ApexRunner、FlinkRunner,待这些主流的PipelineRunner稳定以后,如果有其他新的计算引擎框架出现,可以在PipelineRunner这一层进行扩展实现。
这些PipelineRunner中,DirectRunner是最简单的PipelineRunner,它非常有用,比如我们实现了一个从HDFS读取数据,但是需要在Spark集群上运行的ETL程序,使用DirectRunner可以在本地非常容易地调试ETL程序,调试到程序的数据处理逻辑没有问题了,再最终在实际的生产环境Spark集群上运行。如果特定的PipelineRunner所对应的计算引擎没有很好的支撑调试功能,使用DirectRunner是非常方便的。

  • PCollection

PCollection是对分布式数据集的抽象,主要用作输入、输出、中间结果集。其中,在Apache Beam中对数据及其数据集的抽象有几类,我们画到一张类图上,如下图所示:
PCollection
PCollection是对数据集的抽象,包括输入输出,而基于Window的数据处理有对应的Window相关的抽象,还有一类就是TupleTag,针对具有CoGroup操作的情况下用来标记对应数据中的Tuple数据,具体如何使用可以后面我们实现的Join的例子。

  • PTransform

一个Pipeline是由一个或多个PTransform构建而成的DAG图,其中每一个PTransform都具有输入和输出,所以PTransform是Apache Beam中非常核心的组件,我按照PTransform的做了一下分类,如下类图所示:
PTransform
通过上图可以看出,PTransform针对不同输入或输出的数据的特征,实现了一个算子(Operator)的集合,而Apache Beam除了期望实现一些通用的PTransform实现来供数据处理的开发人员开箱即用,同时也在API的抽象级别上做的非常Open,如果你想实现自己的PTransform来处理指定数据集,只需要自定义即可。而且,随着社区的活跃及其在实际应用场景中推广和使用,会很快构建一个庞大的PTransform实现库,任何有数据处理需求的开发人员都可以共享这些组件。

  • Combine

这里,单独把Combine这类合并数据集的实现拿出来,它的抽象很有趣,主要面向globally 和per-key这两类抽象,实现了一个非常丰富的PTransform算子库,对应的类图如下所示:
Combine
通过上图可以看出,作用在一个数据集上具有Combine特征的基本操作:Max、Min、Top、Mean、Sum、Count等等。

  • Window

Window是用来处理某一个Micro batch的数据记录可以进行Merge这种场景的需求,通常用在Streaming处理的情况下。Apache Beam也提供了对Window的抽象,其中对于某一个Window下的数据的处理,是通过WindowFn接口来定义的,与该接口相关的处理类,如下类图所示:
Window

编程实战

首先说明一下,为了简单起见,我直接在代码中显式配置指定PipelineRunner,示例代码片段如下所示:

         PipelineOptions options = PipelineOptionsFactory.create();
         options.setRunner(DirectRunner.class);

如果要部署到服务器上,可以通过命令行的方式指定PipelineRunner,比如要在Spark集群上运行,类似如下所示命令行:

spark-submit --class org.shirdrn.beam.examples.MinimalWordCountBasedSparkRunner 2017-01-18 --master spark://myserver:7077 target/my-beam-apps-0.0.1-SNAPSHOT-shaded.jar --runner=SparkRunner

下面,我们从几个典型的例子来看(基于Apache Beam软件包的examples有所改动),Apache Beam如何构建Pipeline并运行在指定的PipelineRunner上:

  • WordCount(Count/Source/Sink)

我们根据Apache Beam的MinimalWordCount示例代码开始,看如何构建一个Pipeline,并最终执行它。 MinimalWordCount的实现,代码如下所示:

package org.shirdrn.beam.examples;

import org.apache.beam.runners.direct.DirectRunner;
import org.apache.beam.sdk.Pipeline;
import org.apache.beam.sdk.io.TextIO;
import org.apache.beam.sdk.options.PipelineOptions;
import org.apache.beam.sdk.options.PipelineOptionsFactory;
import org.apache.beam.sdk.transforms.Count;
import org.apache.beam.sdk.transforms.DoFn;
import org.apache.beam.sdk.transforms.MapElements;
import org.apache.beam.sdk.transforms.ParDo;
import org.apache.beam.sdk.transforms.SimpleFunction;
import org.apache.beam.sdk.values.KV;

public class MinimalWordCount {

    @SuppressWarnings("serial")
    public static void main(String[] args) {

        PipelineOptions options = PipelineOptionsFactory.create();
        options.setRunner(DirectRunner.class); // 显式指定PipelineRunner:DirectRunner(Local模式)

        Pipeline pipeline = Pipeline.create(options);

        pipeline.apply(TextIO.Read.from("/tmp/dataset/apache_beam.txt")) // 读取本地文件,构建第一个PTransform
                .apply("ExtractWords", ParDo.of(new DoFn<String, String>() { // 对文件中每一行进行处理(实际上Split)

                    @ProcessElement
                    public void processElement(ProcessContext c) {
                        for (String word : c.element().split("[\\s:\\,\\.\\-]+")) {
                            if (!word.isEmpty()) {
                                c.output(word);
                            }
                        }
                    }

                }))
                .apply(Count.<String> perElement()) // 统计每一个Word的Count
                .apply("ConcatResultKVs", MapElements.via( // 拼接最后的格式化输出(Key为Word,Value为Count)
                        new SimpleFunction<KV<String, Long>, String>() {

                    @Override
                    public String apply(KV<String, Long> input) {
                        return input.getKey() + ": " + input.getValue();
                    }

                }))
                .apply(TextIO.Write.to("wordcount")); // 输出结果

        pipeline.run().waitUntilFinish();
    }
}

Pipeline的具体含义,可以看上面代码的注释信息。下面,我们考虑以HDFS数据源作为Source,如何构建第一个PTransform,代码片段如下所示:

        PCollection<KV<LongWritable, Text>> resultCollection = pipeline.apply(HDFSFileSource.readFrom("hdfs://myserver:8020/data/ds/beam.txt",
                TextInputFormat.class, LongWritable.class, Text.class))

可以看到,返回的是具有键值分别为LongWritable、Text类型的KV对象集合,后续处理和上面处理逻辑类似。如果使用Maven构建Project,需要加上如下依赖(这里beam.version的值可以为最新Release版本0.4.0):

<dependency><groupId>org.apache.beam</groupId><artifactId>beam-sdks-java-io-hdfs</artifactId><version>${beam.version}</version></dependency>
  • 去重(Distinct)

去重也是对数据集比较常见的操作,使用Apache Beam来实现,示例代码如下所示:

package org.shirdrn.beam.examples;

import org.apache.beam.runners.direct.DirectRunner;
import org.apache.beam.sdk.Pipeline;
import org.apache.beam.sdk.io.TextIO;
import org.apache.beam.sdk.options.PipelineOptions;
import org.apache.beam.sdk.options.PipelineOptionsFactory;
import org.apache.beam.sdk.transforms.Distinct;

public class DistinctExample {

    public static void main(String[] args) throws Exception {

         PipelineOptions options = PipelineOptionsFactory.create();
         options.setRunner(DirectRunner.class); // 显式指定PipelineRunner:DirectRunner(Local模式)

         Pipeline pipeline = Pipeline.create(options);
         pipeline.apply(TextIO.Read.from("/tmp/dataset/MY_ID_FILE.txt"))
             .apply(Distinct.<String> create()) // 创建一个处理String类型的PTransform:Distinct
             .apply(TextIO.Write.to("deduped.txt")); // 输出结果
         pipeline.run().waitUntilFinish();
    }
}
  • 分组(GroupByKey)

对数据进行分组操作也非常普遍,我们拿一个最基础的PTransform实现GroupByKey来实现一个例子,代码如下所示:

package org.shirdrn.beam.examples;

import org.apache.beam.runners.direct.DirectRunner;
import org.apache.beam.runners.direct.repackaged.com.google.common.base.Joiner;
import org.apache.beam.sdk.Pipeline;
import org.apache.beam.sdk.io.TextIO;
import org.apache.beam.sdk.options.PipelineOptions;
import org.apache.beam.sdk.options.PipelineOptionsFactory;
import org.apache.beam.sdk.transforms.DoFn;
import org.apache.beam.sdk.transforms.GroupByKey;
import org.apache.beam.sdk.transforms.MapElements;
import org.apache.beam.sdk.transforms.ParDo;
import org.apache.beam.sdk.transforms.SimpleFunction;
import org.apache.beam.sdk.values.KV;

public class GroupByKeyExample {

    @SuppressWarnings("serial")
    public static void main(String[] args) {

        PipelineOptions options = PipelineOptionsFactory.create();
        options.setRunner(DirectRunner.class); // 显式指定PipelineRunner:DirectRunner(Local模式)

        Pipeline pipeline = Pipeline.create(options);

        pipeline.apply(TextIO.Read.from("/tmp/dataset/MY_INFO_FILE.txt"))
            .apply("ExtractFields", ParDo.of(new DoFn<String, KV<String, String>>() {

                @ProcessElement
                public void processElement(ProcessContext c) {
                    // file format example: 35451605324179    3G    CMCC
                    String[] values = c.element().split("\t");
                    if(values.length == 3) {
                        c.output(KV.of(values[1], values[0]));
                    }
                }
            }))
            .apply("GroupByKey", GroupByKey.<String, String>create()) // 创建一个GroupByKey实例的PTransform
            .apply("ConcatResults", MapElements.via(
                    new SimpleFunction<KV<String, Iterable<String>>, String>() {

                        @Override
                        public String apply(KV<String, Iterable<String>> input) {
                            return new StringBuffer()
                                    .append(input.getKey()).append("\t")
                                    .append(Joiner.on(",").join(input.getValue()))
                                    .toString();
                        }


            }))
            .apply(TextIO.Write.to("grouppedResults"));

        pipeline.run().waitUntilFinish();

    }
}

使用DirectRunner运行,输出文件名称类似于grouppedResults-00000-of-00002、grouppedResults-00001-of-00002等等。

  • 连接(Join)

最后,我们通过实现一个Join的例子,其中,用户的基本信息包含ID和名称,对应文件格式如下所示:

35451605324179    Jack
35236905298306    Jim
35236905519469    John
35237005022314    Linda

另一个文件是用户使用手机的部分信息,文件格式如下所示:

35451605324179    3G    中国移动
35236905298306    2G    中国电信
35236905519469    4G    中国移动

我们希望通过Join操作后,能够知道用户使用的什么网络(用户名+网络),使用Apache Beam实现,具体实现代码如下所示:

package org.shirdrn.beam.examples;

import org.apache.beam.runners.direct.DirectRunner;
import org.apache.beam.sdk.Pipeline;
import org.apache.beam.sdk.io.TextIO;
import org.apache.beam.sdk.options.PipelineOptions;
import org.apache.beam.sdk.options.PipelineOptionsFactory;
import org.apache.beam.sdk.transforms.DoFn;
import org.apache.beam.sdk.transforms.MapElements;
import org.apache.beam.sdk.transforms.ParDo;
import org.apache.beam.sdk.transforms.SimpleFunction;
import org.apache.beam.sdk.transforms.join.CoGbkResult;
import org.apache.beam.sdk.transforms.join.CoGroupByKey;
import org.apache.beam.sdk.transforms.join.KeyedPCollectionTuple;
import org.apache.beam.sdk.values.KV;
import org.apache.beam.sdk.values.PCollection;
import org.apache.beam.sdk.values.TupleTag;

public class JoinExample {

    @SuppressWarnings("serial")
    public static void main(String[] args) {

        PipelineOptions options = PipelineOptionsFactory.create();
        options.setRunner(DirectRunner.class);  // 显式指定PipelineRunner:DirectRunner(Local模式)

        Pipeline pipeline = Pipeline.create(options);

        // create ID info collection
        final PCollection<KV<String, String>> idInfoCollection = pipeline
                .apply(TextIO.Read.from("/tmp/dataset/MY_ID_INFO_FILE.txt"))
                .apply("CreateUserIdInfoPairs", MapElements.via(
                        new SimpleFunction<String, KV<String, String>>() {

                    @Override
                    public KV<String, String> apply(String input) {
                        // line format example: 35451605324179    Jack
                        String[] values = input.split("\t");
                        return KV.of(values[0], values[1]);
                    }

                }));

        // create operation collection
        final PCollection<KV<String, String>> opCollection = pipeline
                .apply(TextIO.Read.from("/tmp/dataset/MY_ID_OP_INFO_FILE.txt"))
                .apply("CreateIdOperationPairs", MapElements.via(
                        new SimpleFunction<String, KV<String, String>>() {

                    @Override
                    public KV<String, String> apply(String input) {
                        // line format example: 35237005342309    3G    CMCC
                        String[] values = input.split("\t");
                        return KV.of(values[0], values[1]);
                    }

                }));

        final TupleTag<String> idInfoTag = new TupleTag<String>();
        final TupleTag<String> opInfoTag = new TupleTag<String>();

        final PCollection<KV<String, CoGbkResult>> coGrouppedCollection = KeyedPCollectionTuple
                .of(idInfoTag, idInfoCollection)
                .and(opInfoTag, opCollection)
                .apply(CoGroupByKey.<String>create());

        final PCollection<KV<String, String>> finalResultCollection = coGrouppedCollection
                .apply("", ParDo.of(new DoFn<KV<String, CoGbkResult>, KV<String, String>>() {

                @ProcessElement
                public void processElement(ProcessContext c) {
                    KV<String, CoGbkResult> e = c.element();
                    String id = e.getKey();
                    String name = e.getValue().getOnly(idInfoTag);
                    for (String eventInfo : c.element().getValue().getAll(opInfoTag)) {
                      // Generate a string that combines information from both collection values
                      c.output(KV.of(id, "\t" + name + "\t" + eventInfo));
                    }
                }
        }));

        PCollection<String> formattedResults = finalResultCollection
                .apply("Format", ParDo.of(new DoFn<KV<String, String>, String>() {
                  @ProcessElement
                  public void processElement(ProcessContext c) {
                    c.output(c.element().getKey() + "\t" + c.element().getValue());
                  }
                }));

         formattedResults.apply(TextIO.Write.to("joinedResults"));
         pipeline.run().waitUntilFinish();

    }
}

参考内容

JVM上最快的Bloom filter实现

$
0
0

英文原始出处:  Bloom filter for Scala, the fastest for JVM

本文介绍的是我用Scala实现的Bloom filter。 源代码在 github上。依照 性能测试结果,它是JVM上的 最快的Bloom filter实现。零分配(Zero-allocation)和高度优化的代码。 无内存限制,所以没有包含元素的数量限制和可控的误报率(false positive rate)。
扩展:可插拔的Hash算法,任意的元素类型。
没错,它使用 sun.misc.unsafe

1 介绍

“A Bloom filter is a space-efficient probabilistic data structure that is used to test whether an element is a member of a set. False positive matches are possible, but false negatives are not. In other words, a query returns either “possibly in set” or “definitely not in set”. Elements can be added to the set, but not removed,” says  Wikipedia.

Bloom filter 是由 Howard Bloom 在 1970 年提出的二进制向量数据结构,它具有很好的空间和时间效率,被用来检测一个元素是不是集合中的一个成员。如果检测结果为是,该元素不一定在集合中;但如果检测结果为否,该元素一定不在集合中。因此Bloom filter具有100%的召回率。这样每个检测请求返回有“在集合内(可能错报)”和“不在集合内(绝对不在集合内)”两种情况,可见 Bloom filter 是牺牲了正确率和时间以节省空间。 引自  百度百科

简而言之,Bloom filter是:

  • 优化内存占用, 当整个集合太大而不能全部放到内存中。Optimization for memory. It comes into play when you cannot put whole set into memory.
  • 解决成员存在性的问题。它可以回答下面的问题:一个元素属于一个集合还是不属于?
  • 概率(有损)数据结构。它可以返回一个元素有多大的概率属于一个集合

后面这篇文章介绍的Bloom filter很详尽 -  “What are Bloom filters, and why are they useful?” by  @Max Pagels。我没必要再献丑了,如果你还不熟悉Bloom filter不妨看一看。

2 为何再造轮子?

因为性能或者内存限制的原因,已有的Bloom filter并不能满足我们的需求,或者你发现你可以做的更好。坦率的说,都不是。只不过有时候你厌倦了而已。(作者吐槽,可忽略之)

主要的原因是性能。当开发高性能和低延迟的系统的时候,你可不想被外部的库所拖累,甚至分配了很多的内存。你的注意力应该集中在业务逻辑上,依赖的库应该尽可能的有效。

另一个原因还是内存限制。所有的实现都会因为JVM数组的大小的限制而受限制。JVM中,数字使用整数integer做索引,所以数组的最大长度也就是整数的最大值 2147483647。如果我们创建一个元素类型为long的数组存储比特位bit的值,那么最多我们可以存储64 bit * 2147483647 = 137438953408 bits,大概需要15 GB左右的内存。你可以放入大约10000000000左右的元素到误报率为0.1%的Bloom filter。这对于大部分软件来说足够了,但是当你处理大数据,比如URL,图标广告,实时竞价请求或者是事件流的时候,100亿的数据只是一个起步量。当然你可以有一些变通的办法:部署多个Bloom filter,将它们分布到多个节点,或者设计你的软件适应这些限制,但这些办法并不总是有效,可能花费较高护着不满足你的架构。

让我们看看当前已有的一些Bllom filter的实现。

2.1 Google guava

Guava是Google开发的一个高质量的核心库,它包含集合、基本数据、并发、I/O、Cache等模块。 它也包含一个 Bloom filter实现。Guava是我的初始选择,它经受考验、也很快,但是……

令人咂舌的是,它会额外分配内存。我使用Google的 Allocation Instrumenter监控所有的分配allocation。下面的分配监控显示了检查包含100字符的字符串是否存在于一个Bloom filter中:

I just allocated the object [B@39420d59 of type byte whose size is 40 It's an array of size 23
I just allocated the object java.nio.HeapByteBuffer[pos=0 lim=23 cap=23] of type java/nio/HeapByteBuffer whose size is 48
I just allocated the object com.google.common.hash.Murmur3_128HashFunction$Murmur3_128Hasher@5dd227b7 of type com/google/common/hash/Murmur3_128HashFunction$Murmur3_128Hasher whose size is 48
I just allocated the object [B@3d3b852e of type byte whose size is 24 It's an array of size 1
I just allocated the object [B@14ba7f15 of type byte whose size is 24 It's an array of size 1
I just allocated the object sun.nio.cs.UTF_8$Encoder@55cb3b7 of type sun/nio/cs/UTF_8$Encoder whose size is 56
I just allocated the object [B@497fd334 of type byte whose size is 320 It's an array of size 300
I just allocated the object [B@280c3dc0 of type byte whose size is 312 It's an array of size 296
I just allocated the object java.nio.HeapByteBuffer[pos=0 lim=296 cap=296] of type java/nio/HeapByteBuffer whose size is 48
I just allocated the object [B@6f89ad03 of type byte whose size is 32 It's an array of size 16
I just allocated the object java.nio.HeapByteBuffer[pos=0 lim=16 cap=16] of type java/nio/HeapByteBuffer whose size is 48
I just allocated the object 36db757cdd5ae408ef61dca2406d0d35 of type com/google/common/hash/HashCode$BytesHashCode whose size is 16

一共1016个字节。想象一下,我们计算一个短字符串的hash值,检查它相应的bit位设置已经设置,它需要分配大于1Kb的数据。太多了。那你可能会说内存占用已经很小了,好吧,当你做一个单独的微性能测试的时候,影响不是很大,但是在产品级的环境中,它会变得更糟:它会影响GC,导致分配变慢,触发GC,导致更高的延迟等。

不管怎样,review一下代码会很有趣,有时候你会发现一些复活节彩蛋在里面,比如下面的例子:

这些注释行来自Naughty by Nature说唱组合的歌曲“O.P.P.”,在上世纪90年代早期很流行。这段代码的开发者可能那时是四五十岁的人(偏题了)。

2.2 Twitter Algebird

Algebird “为Scala提供的抽象代数库,这些代码主要是用于建立聚合系统(通过Scalding或Storm)。 它是函数式functional,不可变
immutable, monadic,但是非常非常非常慢,并且仅仅支持字符串作为元素类型。字符串是万能的数据格式,你可硬用它存任何值 :) 。

它使用人人皆爱的MurmurHash3算法,它是最好的通用的hash算法。它计算出128-bit的 hash值,分割成4个32-bit的数字。然后它为每个32-bit的数字设置相应的位,而不是整个的hash值。这是相当有争议的设计,我进行了粗略的测试,测试表明Teitter Bloom filter有超过 10% 的误报率。

更深一步,有趣的是Twitter Bloom filter 底层使用  EWAHCompressedBitmap,它是一个压缩的可替代BitSet的实现。它专门为内存占用而优化,适合稀疏数据的场景。比如,如果你的位数从1000000开始,EWAH可以优化set而不会为前面的0位分配内存。集合的操作如交集、并集和差也更快。但是随机访问却很慢。 而且hash的目标就是有一个均匀分布的hash值,越均匀越好。这两点就排除了使用压缩bitset的好处。我做了一点点测试来检查整个的内存分配,结果显示Twitter Bloom filter比我的实现还要分配更多的内存。 同样,在我看来,Twitter的实现也是相当有争议。

内存检查的结果很长我就不贴了。为包含100个字符的字符串的检查要分配 1808字节,我哭!

同样,它是函数式functional, 不可变immutable, 使用持久化数据结构, monad, 但这些不足以让我们使用它。 大话说在前, 它的读性能要比我的实现慢10倍,写要慢100倍。

2.3 ScalaNLP’s Breeze

Breeze is a generic, clean and powerful Scala numerical processing library… Breeze is a part of ScalaNLP project, a scientific computing platform for Scala

Breeze的介绍看起来很有吸引力,如清爽的新风,但是,有一个 花招在它的实现里。它直接使用对象的hash值。 “WTF,我钟爱的MurmurHash3哪去了”,你可能会问。MurmurHash3仅仅用来计算最终的对象的hash值,没错,它可以和任意类型一起工作,但是你不会知道你的大数据集的细微差别(编者按:较难理解,需要配合代码一起理解。 英文原意为:It’s used only for “finalizing” the object’s hash. Yeah, it works with any type out-of-the-box but if you don’t know that little nuance you are done with large datasets.)

测试中它会分配544字节,看看代码你会发现通用的Scala的问题:

for {
  i <- 0 to numHashFunctions
} yield {
  val h = hash1 + i * hash2
  val nextHash = if (h < 0) ~h else h
  nextHash % numBuckets
}

看起来很简洁:for语句,延迟计算,漂亮的DSL。但是当它编译成Java代码的时候就不那么好看了,它会分配很多对象: intWrapper(), RichInt, Range.Inclusive, VectorBuilder/Vector, boxing/unboxing 等等:

return (IndexedSeq)RichInt$.MODULE$.to$extension0(Predef$.MODULE$.intWrapper(0), numHashFunctions()).map(new Serializable(hash1, hash2) {
    public final int apply(int i)
    {
        return apply$mcII$sp(i);
    }
    public int apply$mcII$sp(int i)
    {
        int h = hash1$1 + i * hash2$1;
        int nextHash = h >= 0 ? h : ~h;
        return nextHash % $outer.numBuckets();
    }
    public final volatile Object apply(Object v1)
    {
        return BoxesRunTime.boxToInteger(apply(BoxesRunTime.unboxToInt(v1)));
    }
    public static final long serialVersionUID = 0L;
    private final BloomFilter $outer;
    private final int hash1$1;
    private final int hash2$1;
    public
    {
        if(BloomFilter.this == null)
        {
            throw null;
        } else
        {
            this.$outer = BloomFilter.this;
            this.hash1$1 = hash1$1;
            this.hash2$1 = hash2$1;
            super();
            return;
        }
    }
}
, IndexedSeq$.MODULE$.canBuildFrom());

震撼吗?我想你被震惊了。接下来看看我的实现。

3 我是如何实现的?

一句话,我重新实现了Bloom filter的数据结构。源代码在 github上,可以通过 maven repository引用:

libraryDependencies += "com.github.alexandrnikitin" %% "bloom-filter" % "0.3.1"

下面是使用的例子:

import bloomfilter.mutable.BloomFilter
val expectedElements = 1000
val falsePositiveRate = 0.1
val bf = BloomFilter[String](expectedElements, falsePositiveRate)
bf.add("some string")
bf.mightContain("some string")
bf.dispose()

3.1 Unsafe

一个重要的设计就是底层使用 sun.misc.unsafe包。使用它分配一块内存来保存bit,所以你需要主动dispose Bloom filter 实例和不受管的内存释放。而且我的实现还使用  usafe做了一些花招以避免内存分配,比如直接访问字符串内部的char数组。

3.2 type class模式

我的实现是可扩展的,你可以为任意类型使用任意的hash算法。它通过 type class模式实现。如果你不熟悉它,你可以阅读 @Daniel Westheide的文章  “The Neophyte’s Guide to Scala”

基本上,你所需的就是实现 CanGenerateHashFrom[From] trait,就像这样:

trait CanGenerateHashFrom[From] {
  def generateHash(from: From): Long
}

不幸的是,它是 invariant不变类型。我想实现为逆变类型contravariant但是Scala编译器不能正确的解决contravariant implicits,将来在 Dotty编译器中会支持。

缺省地提供了一个 MurmurHash3的通用实现。我使用Scala实现了它,比Guava、Algebird、Cassandra的实现更快(希望我没有犯错)。为 LongStringArray[Byte]提供可开箱即用的库。作为一个福利,为无限唯一性(unlimited uniqueness)提供了128bit的版本。

3.3 零分配Zero-allocation

我的Bloom filter实现没有分配任何对象,代码被高度优化。我计划写一篇独立的文章来描述这些优化,敬请关注。通过一系列的 unsafe技巧来实现的。下面是为String类型实现的  CanGenerateHashFrom trait:

implicit object CanGenerateHashFromString extends CanGenerateHashFrom[String] {
  import scala.concurrent.util.Unsafe.{instance => unsafe}
  private val valueOffset = unsafe.objectFieldOffset(classOf[String].getDeclaredField("value"))
  override def generateHash(from: String): Long = {
    val value = unsafe.getObject(from, valueOffset).asInstanceOf[Array[Char]]
    MurmurHash3Generic.murmurhash3_x64_64(value, 0, from.length, 0)
  }
}

使用 unsafe.objectFieldOffset()方法获取String类型的value字段,它是字符串底层的char数组。然后使用 unsafe.getObject()方法访问字符数组,用来计算hash值。

不幸的是,128-bit的实现会分配一个对象。我在 (Long, Long) tuple和  ThreadLocal的字段选择上很犹豫,对于整体的性能,没有影响,有什么意见吗?在我的有生之年我希望能看到 JVM的值类型@Gil TeneObjectLayout尝试实现它。

限制

你可能已经注意到了,当前实现有一些限制。 CanGenerateHashFrom[From] trait是不可变的invariant,它不允许回退到对象的 hashCode()方法。你需要为你的类型实现它的hash算法。但我相信,为了性能这也是值得的。

并不是所有的JVM都支持,因为底层使用了“unsafe” 包,而且这也没有退路(fallback )的实现。

sun.misc.Unsafe至少从2004年Java1.4开始就存在于Java中了。在Java9中,为了提高JVM的可维护性,Unsafe和许多其他的东西一起都被作为内部使用类隐藏起来了。但是究竟是什么取代Unsafe不得而知。 摘自:  http://www.importnew.com/14511.html

可以在Java中用它吗?

可以,但是代码不会和Scala一样漂亮,当然你已经习惯了这一切。Java中没有implicit,而且Java编译器也不会帮你调用它。在Java中使用它很丑但是能工作:

import bloomfilter.CanGenerateHashFrom;
import bloomfilter.mutable.BloomFilter;
long expectedElements = 10000000;
double falsePositiveRate = 0.1;
BloomFilter<byte[]> bf = BloomFilter.apply(
        expectedElements,
        falsePositiveRate,
        CanGenerateHashFrom.CanGenerateHashFromByteArray$.MODULE$);
byte[] element = new byte[100];
bf.add(element);
bf.mightContain(element);
bf.dispose();

4 性能benchmark

我们都喜欢性能基准数据,对不?令人兴奋的数字在空中游荡,是那么的迷人。如果你准备写性能基准的测试,请使用 JMH。 它是Oracle的性能工程师  @Aleksey Shipilev创建的一个微性能基准库: “for building, running, and analyzing nano/micro/milli/macro benchmarks written in Java and other languages targeting the JVM.”,  @Konrad Malawski写了一个 SBT的插件

下面是一个 String类型的基准测试,其它类型的测试结果和此类似:

[info] Benchmark                                              (length)   Mode  Cnt          Score         Error  Units
[info] alternatives.algebird.StringItemBenchmark.algebirdGet      1024  thrpt   20    1181080.172 ▒    9867.840  ops/s
[info] alternatives.algebird.StringItemBenchmark.algebirdPut      1024  thrpt   20     157158.453 ▒     844.623  ops/s
[info] alternatives.breeze.StringItemBenchmark.breezeGet          1024  thrpt   20    5113222.168 ▒   47005.466  ops/s
[info] alternatives.breeze.StringItemBenchmark.breezePut          1024  thrpt   20    4482377.337 ▒   19971.209  ops/s
[info] alternatives.guava.StringItemBenchmark.guavaGet            1024  thrpt   20    5712237.339 ▒  115453.495  ops/s
[info] alternatives.guava.StringItemBenchmark.guavaPut            1024  thrpt   20    5621712.282 ▒  307133.297  ops/s

// My Bloom filter
[info] bloomfilter.mutable.StringItemBenchmark.myGet              1024  thrpt   20   11483828.730 ▒  342980.166  ops/s
[info] bloomfilter.mutable.StringItemBenchmark.myPut              1024  thrpt   20   11634399.272 ▒   45645.105  ops/s
[info] bloomfilter.mutable._128bit.StringItemBenchmark.myGet      1024  thrpt   20   11119086.965 ▒   43696.519  ops/s
[info] bloomfilter.mutable._128bit.StringItemBenchmark.myPut      1024  thrpt   20   11303765.075 ▒   52581.059  ops/s

我的实现大致要比Goole Guava的实现快2倍, 比Twitter Algebird快10 ~ 80倍,其它的benchmark你可以在 github上的“benchmarks’模块找到。

警告:这是在独立环境中的综合测试。通常吞吐率和延迟的差别要比产品环境中要大,因为它会对GC有压力,导致分配很慢,更高的延迟,触发GC等。

5 用在哪里?

高性能和低延迟系统。

大数据和机器学习系统,有巨量唯一的数据。

5.1 什么时候不用它?

如果你当前的解决方案已满足需求,大部分软件都不需要这么快。

你只信任那些大公司如Google、Twitter出品的已被证明的、经受考验的库。

你想要开箱即用的库。

6 下一步

欢迎你的意见和建议。下一步我会实现一个稳定的 (Stable) Bloom filter 数据结构,因为目前没有好的实现。我计划研究一下  Cuckoo filer 数据结构。对此有何经验吗?

相关文章

从Gitlab误删除数据库想到的

$
0
0

昨天,Gitlab.com发生了一个大事,某同学误删了数据库,这个事看似是个低级错误,不过,因为Gitlab把整个过程的细节都全部暴露出来了,所以,可以看到很多东西,而对于类似这样的事情,我自己以前也干过,而在最近的两公司中我也见过(Amazon中见过一次,阿里中见过至少四次),正好通过这个事来说说一下自己的一些感想和观点吧。 我先放个观点:你觉得有备份系统就不会丢数据了吗?

事件回顾

整个事件的回顾Gitlab.com在第一时间就放到了 Google Doc上,事后,又发了 一篇Blog来说明这个事,在这里,我简单的回顾一下这个事件的过程。

首先,一个叫YP的同学在给gitlab的线上数据库做一些负载均衡的工作,在做这个工作时的时候突发了一个情况,Gitlab被DDoS攻击,数据库的使用飙高,在block完攻击者的IP后,发现有个staging的数据库(db2.staging)已经落后生产库4GB的数据,于是YP同学在Fix这个staging库的同步问题的时候,发现db2.staging有各种问题都和主库无法同步,在这个时候,YP同学已经工作的很晚了,在尝试过多个方法后,发现db2.staging都hang在那里,无法同步,于是他想把db2.staging的数据库删除了,这样全新启动一个新的复制,结果呢,删除数据库的命令错误的敲在了生产环境上(db1.cluster),结果导致整个生产数据库被误删除。( 陈皓注:这个失败基本上就是 “工作时间过长” + “在多数终端窗口中切换中迷失掉了”

在恢复的过程中,他们发现只有db1.staging的数据库可以用于恢复,而其它的5种备份机制都不可用,第一个是数据库的同步,没有同步webhook,第二个是对硬盘的快照,没有对数据库做,第三个是用pg_dump的备份,发现版本不对(用9.2的版本去dump 9.6的数据)导致没有dump出数据,第四个S3的备份,完全没有备份上,第五个是相关的备份流程是问题百出的,只有几个粗糙的人肉的脚本和糟糕的文档,也就是说,不但是是人肉的,而且还是完全不可执行的。( 陈皓注:就算是这些备份机制都work,其实也有问题,因为这些备份大多数基本上都是24小时干一次,所以,要从这些备份恢复也一定是是要丢数据的了,只有第一个数据库同步才会实时一些

最终,gitlab从db1.staging上把6个小时前的数据copy回来,结果发现速度非常的慢,备份结点只有60Mbits/S,拷了很长时间( 陈皓注:为什么不把db1.staging给直接变成生产机?因为那台机器的性能很差)。数据现在的恢复了,不过,因为恢复的数据是6小时前的,所以,有如下的数据丢失掉了:

  • 粗略估计,有4613 的项目, 74 forks,  和 350 imports 丢失了;但是,因为Git仓库还在,所以,可以从Git仓库反向推导数据库中的数据,但是,项目中的issues等就完全丢失了。
  • 大约有±4979 提交记录丢失了(陈皓注:估计也可以用git仓库中反向恢复)。
  • 可能有 707  用户丢失了,这个数据来自Kibana的日志。
  • 在1月31日17:20 后的Webhooks 丢失了。

因为Gitlab把整个事件的细节公开了出来,所以,也得到了很多外部的帮助,2nd Quadrant的CTO –  Simon Riggs 在他的blog上也发布文章 Dataloss at Gitlab 给了一些非常不错的建议:

  • 关于PostgreSQL 9.6的数据同步hang住的问题,可能有一些Bug,正在fix中。
  • PostgreSQL有4GB的同步滞后是正常的,这不是什么问题。
  • 正常的停止从结点,会让主结点自动释放WALSender的链接数,所以,不应该重新配置主结点的 max_wal_senders 参数。但是,停止从结点时,主结点的复数连接数不会很快的被释放,而新启动的从结点又会消耗更多的链接数。他认为,Gitlab配置的32个链接数太高了,通常来说,2到4个就足够了。
  • 另外,之前gitlab配置的max_connections=8000太高了,现在降到2000个是合理的。
  • pg_basebackup 会先在主结点上建一个checkpoint,然后再开始同步,这个过程大约需要4分钟。
  • 手动的删除数据库目录是非常危险的操作,这个事应该交给程序来做。推荐使用刚release 的 repmgr
  • 恢复备份也是非常重要的,所以,也应该用相应的程序来做。推荐使用 barman (其支持S3)
  • 测试备份和恢复是一个很重要的过程。

看这个样子,估计也有一定的原因是——Gitlab的同学对PostgreSQL不是很熟悉。

随后,Gitlab在其网站上也开了一系列的issues,其issues列表在这里 Write post-mortem (这个列表可能还会在不断更新中)

从上面的这个列表中,我们可以看到一些改进措施了。挺好的,不过我觉得还不是很够。

相关的思考

因为类似这样的事,我以前也干过(误删除过数据库,在多个终端窗口中迷失掉了自己所操作的机器……),而且我在amazon里也见过一次,在阿里内至少见过四次以上(在阿里人肉运维的误操作的事故是我见过最多的),但是我无法在这里公开分享,私下可以分享。在这里,我只想从非技术和技术两个方面分享一下我的经验和认识。

技术方面

人肉运维

一直以来,我都觉得直接到生产线上敲命令是一种非常不好的习惯。我认为, 一个公司的运维能力的强弱和你上线上环境敲命令是有关的,你越是喜欢上线敲命令你的运维能力就越弱,越是通过自动化来处理问题,你的运维能力就越强。理由如下:

其一,如果说对代码的改动都是一次发布的话,那么,对生产环境的任何改动(包括硬件、操作系统、网络、软件配置……),也都算是一次发布。那么这样的发布就应该走发布系统和发布流程,要被很好的测试、上线和回滚计划。关键是,走发布过程是可以被记录、追踪和回溯的,而在线上敲命令是完全无法追踪的。没人知道你敲了什么命令。

其二,真正良性的运维能力是——人管代码,代码管机器,而不是人管机器。你敲了什么命令没人知道,但是你写个工具做变更线上系统,这个工具干了什么事,看看工具的源码就知道了。

另外、有人说,以后不要用rm了,要用mv,还有人说,以后干这样的事时,一个人干,另一个人在旁边看,还有人说,要有一个checklist的强制流程做线上的变更,还有人说要增加一个权限系统。我觉得,这些虽然可以work,但是依然不好,再由如下:

其一、如果要解决一个事情需要加更多的人来做的事,那这事就做成劳动密集型了。今天我们的科技就是在努力消除人力成本,而不是在增加人力成本。而做为一个技术人员,解决问题的最好方式是努力使用技术手段,而不是使用更多的人肉手段。 人类区别于动物的差别就是会发明和使用现代化的工具,而不是使用更多的人力。另外, 这不仅仅因为是,人都是会有这样或那样的问题(疲惫、情绪化、急燥、冲动……),而机器是单一无脑不知疲惫的,更是因为,机器干活的效率和速度是比人肉高出N多倍的

其二、增加一个权限系统或是别的一个watch dog的系统完全是在开倒车,权限系统中的权限谁来维护和审批?不仅仅是因为多出来的系统需要多出来的维护,关键是这个事就没有把问题解决在root上。除了为社会解决就业问题,别无好处,故障依然会发生,有权限的人一样会误操作。对于Gitlab这个问题,正如2nd Quadrant的CTO建议的那样,你需要的是一个自动化的备份和恢复的工具,而不是一个权限系统。

其三、像使用mv而不rm,搞一个checklist和一个更重的流程,更糟糕。这里的逻辑很简单,因为,1)这些规则需要人去学习和记忆,本质上来说,你本来就不相信人,所以你搞出了一些规则和流程,而这些规则和流程的执行,又依赖于人,换汤不换药,2)另外, 写在纸面上的东西都是不可执行的,可以执行的就是只有程序,所以,为什么不把checklist和流程写成代码呢?(你可能会说程序也会犯错,是的,程序的错误是consistent,而人的错误是inconsistent)

最关键的是, 数据丢失有各种各样的情况,不单单只是人员的误操作,比如,掉电、磁盘损坏、中病毒等等,在这些情况下,你设计的那些想流程、规则、人肉检查、权限系统、checklist等等统统都不管用了,这个时候,你觉得应该怎么做呢?是的,你会发现,你不得不用更好的技术去设计出一个高可用的系统!别无它法。

关于备份

一个系统是需要做数据备份的,但是,你会发现, Gitlab这个事中,就算所有的备份都可用,也不可避免地会有数据的丢失,或是也会有很多问题。理由如下:

1)备份通常来说都是周期性的,所以,如果你的数据丢失了,从你最近的备份恢复数据里,从备份时间到故障时间的数据都丢失了。

2)备份的数据会有版本不兼容的问题。比如,在你上次备份数据到故障期间,你对数据的scheme做了一次改动,或是你对数据做了一些调整,那么,你备份的数据就会和你线上的程序出现不兼容的情况。

3)有一些公司或是银行有灾备的数据中心,但是灾备的数据中心没有一天live过。等真正灾难来临需要live的时候,你就会发现,各种问题让你live不起来。你可以读一读几年前的这篇报道好好感受一下《 以史为鉴 宁夏银行7月系统瘫痪最新解析

所以,在灾难来临的时候,你会发现你所设计精良的“备份系统”或是“灾备系统”就算是平时可以工作,但也会导致数据丢失,而且可能长期不用的备份系统很难恢复(比如应用、工具、数据的版本不兼容等问题)。

我之前写过一篇《 分布式系统的事务处理》,你还记得下面这张图吗?看看 Data Loss 那一行的,在Backups, Master/Slave 和 Master/Master的架构下,都是会丢的。

所以说, 如果你要让你的备份系统随时都可以用,那么你就要让它随时都Live着,而随时都Live着的多结点系统,基本上就是一个分布式的高可用的系统。因为 ,数据丢失的原因有很多种,比如掉电、磁盘损坏、中病毒等等,而那些流程、规则、人肉检查、权限系统、checklist等等都只是让人不要误操作,都不管用,这个时候,你不得不用更好的技术去设计出一个高可用的系统!别无它法。(重要的事,得再说一篇)

另外,你可以参看我的另一篇《 关于高可用系统》,这篇文章中以MySQL为例,数据库的replication也只能达到 两个9。

AWS 的 S3 的的高可用是4个加11个9的持久性(所谓11个9的持久性durability,AWS是这样定义的,如果你存了1万个对象,那么丢一个的时间是1000万年 ),这意味着,不仅仅只是硬盘坏,机器掉电,整个机房挂了,其保证可以承受有两个设施的数据丢失,数据还是可用的。试想,如果你把数据的可用性通过技术做到了这个份上,那么,你还怕被人误删一个结点上的数据吗?

非技术方面

故障反思

一般说来,故障都需要反思,在Amazon,S2以上的故障都需要写COE(Correction of Errors),其中一节就是需要Ask 5 Whys,我发现在Gitlab的故障回顾的blog中第一段中也有说要在今天写个Ask 5 Whys。关于Ask 5 Whys,其实并不是亚马逊的玩法,这还是算一个业内常用的玩法,也就是说不断的为自己为为什么,直到找到问题的概本原因,这会逼着所有的当事人去学习和深究很多东西。在Wikipedia上有相关的词条 5 Whys,其中罗列了14条规则:

  1. 你需要找到正确的团队来完成这个故障反思。
  2. 使用纸或白板而不是电脑。
  3. 写下整个问题的过程,确保每个人都能看懂。
  4. 区别原因和症状。
  5. 特别注意因果关系。
  6. 说明Root Cause以及相关的证据。
  7. 5个为什么的答案需要是精确的。
  8. 寻找问题根源的频,而不是直接跳到结论。
  9. 要基础客观的事实、数据和知识。
  10. 评估过程而不是人。
  11. 千万不要把“人为失误”或是“工作不注意”当成问题的根源。
  12. 培养信任和真诚的气氛和文化。
  13. 不断的问“为什么”直到问题的根源被找到。这样可以保证同一个坑不会掉进去两次。
  14. 当你给出“为什么”的答案时,你应该从用户的角度来回答。

工程师文化

上述的这些观点,其实,我在我的以住的博客中都讲过很多遍了,你可以参看《 什么是工程师文化?》以及《 开发团队的效率》。其实,说白了就是这么一个事—— 如果你是一个技术公司,你就会更多的相信技术而不是管理。相信技术会用技术来解决问题,相信管理,那就只会有制度、流程和价值观来解决问题

这个道理很简单, 数据丢失有各种各样的情况,不单单只是人员的误操作,比如,掉电、磁盘损坏、中病毒等等,在这些情况下,你设计的那些流程、规则、人肉检查、权限系统、checklist等等统统都不管用,这个时候,你觉得应该怎么做呢?是的,你会发现,你不得不用更好的技术去设计出一个高可用的系统!别无它法。(重要的事得说三遍)

事件公开

很多公司基本上都是这样的套路,首先是极力掩盖,如果掩盖不了了就开始撒谎,撒不了谎了,就“文过饰非”、“避重就轻”、“转移视线”。然而,面对危机的最佳方法就是——“多一些真诚,少一些套路”, 所谓的“多一些真诚”的最佳实践就是——“透明公开所有的信息”,Gitlab此次的这个事给大家树立了非常好的榜样。AWS也会把自己所有的故障和细节都批露出来。

事情本来就做错了,而公开所有的细节,会让大众少很多猜测的空间,有利于抵制流言和黑公关,同时,还会赢得大众的理解和支持。看看Gitlab这次还去YouTube上直播整个修复过程,是件很了不起的事,大家可以到他们的blog上看看,对于这样的透明和公开,一片好评。

(全文完)


关注CoolShell微信公众账号可以在手机端搜索文章

(转载本站文章请注明作者和出处 酷 壳 – CoolShell,请勿用于任何商业用途)

——=== 访问 酷壳404页面寻找遗失儿童。 ===——

分布式系统中唯一 ID 的生成方法

$
0
0

本文主要介绍在一个分布式系统中, 怎么样生成全局唯一的 ID

一, 问题描述

在分布式系统存在多个 Shard 的场景中, 同时在各个 Shard 插入数据时, 怎么给这些数据生成全局的 unique ID?

在单机系统中 (例如一个 MySQL 实例), unique ID 的生成是非常简单的, 直接利用 MySQL 自带的自增 ID 功能就可以实现.

但在一个存在多个 Shards 的分布式系统 (例如多个 MySQL 实例组成一个集群, 在这个集群中插入数据), 这个问题会变得复杂, 所生成的全局的 unique ID 要满足以下需求:

  1. 保证生成的 ID 全局唯一
  2. 今后数据在多个 Shards 之间迁移不会受到 ID 生成方式的限制
  3. 生成的 ID 中最好能带上时间信息, 例如 ID 的前 k 位是 Timestamp, 这样能够直接通过对 ID 的前 k 位的排序来对数据按时间排序
  4. 生成的 ID 最好不大于 64 bits
  5. 生成 ID 的速度有要求. 例如, 在一个高吞吐量的场景中, 需要每秒生成几万个 ID (Twitter 最新的峰值到达了 143,199 Tweets/s, 也就是 10万+/秒)
  6. 整个服务最好没有单点

如果没有上面这些限制, 问题会相对简单, 例如:

  1. 直接利用 UUID.randomUUID() 接口来生成 unique ID (http://www.ietf.org/rfc/rfc4122.txt). 但这个方案生成的 ID 有 128 bits, 另外, 生成的 ID 中也没有带 Timestamp
  2. 利用一个中心服务器来统一生成 unique ID. 但这种方案可能存在单点问题; 另外, 要支持高吞吐率的系统, 这个方案还要做很多改进工作 (例如, 每次从中心服务器批量获取一批 IDs, 提升 ID 产生的吞吐率)
  3. Flickr 的做法 (http://code.flickr.net/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/). 但他这个方案 ID 中没有带 Timestamp, 生成的 ID 不能按时间排序

在要满足前面 6 点要求的场景中, 怎么来生成全局 unique ID 呢?

Twitter 的 Snowflake 是一种比较好的做法. 下面主要介绍 Twitter Snowflake, 以及它的变种

二, Twitter Snowflake

https://github.com/twitter/snowflake

Snowflake 生成的 unique ID 的组成 (由高位到低位):

  • 41 bits: Timestamp (毫秒级)
  • 10 bits: 节点 ID (datacenter ID 5 bits + worker ID 5 bits)
  • 12 bits: sequence number

一共 63 bits (最高位是 0)

unique ID 生成过程:

  • 10 bits 的机器号, 在 ID 分配 Worker 启动的时候, 从一个 Zookeeper 集群获取 (保证所有的 Worker 不会有重复的机器号)
  • 41 bits 的 Timestamp: 每次要生成一个新 ID 的时候, 都会获取一下当前的 Timestamp, 然后分两种情况生成 sequence number:
  • 如果当前的 Timestamp 和前一个已生成 ID 的 Timestamp 相同 (在同一毫秒中), 就用前一个 ID 的 sequence number + 1 作为新的 sequence number (12 bits); 如果本毫秒内的所有 ID 用完, 等到下一毫秒继续 (这个等待过程中, 不能分配出新的 ID)
  • 如果当前的 Timestamp 比前一个 ID 的 Timestamp 大, 随机生成一个初始 sequence number (12 bits) 作为本毫秒内的第一个 sequence number

整个过程中, 只是在 Worker 启动的时候会对外部有依赖 (需要从 Zookeeper 获取 Worker 号), 之后就可以独立工作了, 做到了去中心化.

异常情况讨论:

  • 在获取当前 Timestamp 时, 如果获取到的时间戳比前一个已生成 ID 的 Timestamp 还要小怎么办? Snowflake 的做法是继续获取当前机器的时间, 直到获取到更大的 Timestamp 才能继续工作 (在这个等待过程中, 不能分配出新的 ID)

从这个异常情况可以看出, 如果 Snowflake 所运行的那些机器时钟有大的偏差时, 整个 Snowflake 系统不能正常工作 (偏差得越多, 分配新 ID 时等待的时间越久)

从 Snowflake 的官方文档 (https://github.com/twitter/snowflake/#system-clock-dependency) 中也可以看到, 它明确要求 “You should use NTP to keep your system clock accurate”. 而且最好把 NTP 配置成不会向后调整的模式. 也就是说, NTP 纠正时间时, 不会向后回拨机器时钟.

三, Snowflake 的其他变种

Snowflake 有一些变种, 各个应用结合自己的实际场景对 Snowflake 做了一些改动. 这里主要介绍 3 种.

1. Boundary flake

http://boundary.com/blog/2012/01/12/flake-a-decentralized-k-ordered-unique-id-generator-in-erlang/

变化:

  • ID 长度扩展到 128 bits:
  • 最高 64 bits 时间戳;
  • 然后是 48 bits 的 Worker 号 (和 Mac 地址一样长);
  • 最后是 16 bits 的 Seq Number
  • 由于它用 48 bits 作为 Worker ID, 和 Mac 地址的长度一样, 这样启动时不需要和 Zookeeper 通讯获取 Worker ID. 做到了完全的去中心化
  • 基于 Erlang

它这样做的目的是用更多的 bits 实现更小的冲突概率, 这样就支持更多的 Worker 同时工作. 同时, 每毫秒能分配出更多的 ID

2. Simpleflake

http://engineering.custommade.com/simpleflake-distributed-id-generation-for-the-lazy/

Simpleflake 的思路是取消 Worker 号, 保留 41 bits 的 Timestamp, 同时把 sequence number 扩展到 22 bits;

Simpleflake 的特点:

  • sequence number 完全靠随机产生 (这样也导致了生成的 ID 可能出现重复)
  • 没有 Worker 号, 也就不需要和 Zookeeper 通讯, 实现了完全去中心化
  • Timestamp 保持和 Snowflake 一致, 今后可以无缝升级到 Snowflake

Simpleflake 的问题就是 sequence number 完全随机生成, 会导致生成的 ID 重复的可能. 这个生成 ID 重复的概率随着每秒生成的 ID 数的增长而增长.

所以, Simpleflake 的限制就是每秒生成的 ID 不能太多 (最好小于 100次/秒, 如果大于 100次/秒的场景, Simpleflake 就不适用了, 建议切换回 Snowflake).

3. instagram 的做法

先简单介绍一下 instagram 的分布式存储方案:

  • 先把每个 Table 划分为多个逻辑分片 (logic Shard), 逻辑分片的数量可以很大, 例如 2000 个逻辑分片
  • 然后制定一个规则, 规定每个逻辑分片被存储到哪个数据库实例上面; 数据库实例不需要很多. 例如, 对有 2 个 PostgreSQL 实例的系统 (instagram 使用 PostgreSQL); 可以使用奇数逻辑分片存放到第一个数据库实例, 偶数逻辑分片存放到第二个数据库实例的规则
  • 每个 Table 指定一个字段作为分片字段 (例如, 对用户表, 可以指定 uid 作为分片字段)
  • 插入一个新的数据时, 先根据分片字段的值, 决定数据被分配到哪个逻辑分片 (logic Shard)
  • 然后再根据 logic Shard 和 PostgreSQL 实例的对应关系, 确定这条数据应该被存放到哪台 PostgreSQL 实例上

instagram unique ID 的组成:

  • 41 bits: Timestamp (毫秒)
  • 13 bits: 每个 logic Shard 的代号 (最大支持 8 x 1024 个 logic Shards)
  • 10 bits: sequence number; 每个 Shard 每毫秒最多可以生成 1024 个 ID

生成 unique ID 时, 41 bits 的 Timestamp 和 Snowflake 类似, 这里就不细说了.

主要介绍一下 13 bits 的 logic Shard 代号 和 10 bits 的 sequence number 怎么生成.

logic Shard 代号:

  • 假设插入一条新的用户记录, 插入时, 根据 uid 来判断这条记录应该被插入到哪个 logic Shard 中.
  • 假设当前要插入的记录会被插入到第 1341 号 logic Shard 中 (假设当前的这个 Table 一共有 2000 个 logic Shard)
  • 新生成 ID 的 13 bits 段要填的就是 1341 这个数字

sequence number 利用 PostgreSQL 每个 Table 上的 auto-increment sequence 来生成:

  • 如果当前表上已经有 5000 条记录, 那么这个表的下一个 auto-increment sequence 就是 5001 (直接调用 PL/PGSQL 提供的方法可以获取到)
  • 然后把 这个 5001 对 1024 取模就得到了 10 bits 的 sequence number

instagram 这个方案的优势在于:

  • 利用 logic Shard 号来替换 Snowflake 使用的 Worker 号, 就不需要到中心节点获取 Worker 号了. 做到了完全去中心化
  • 另外一个附带的好处就是, 可以通过 ID 直接知道这条记录被存放在哪个 logic Shard 上

同时, 今后做数据迁移的时候, 也是按 logic Shard 为单位做数据迁移的, 所以这种做法也不会影响到今后的数据迁移

分布式系统中唯一 ID 的生成方法,首发于 文章 - 伯乐在线

每天自动备份MySQL数据库的shell脚本

$
0
0

经常备份数据库是一个好习惯,虽然数据库损坏或数据丢失的概率很低,但一旦发生这种事情,后悔是没用的。一般网站或应用的后台都有备份数据库的功能按钮,但需要去手工执行。我们需要一种安全的,每天自动备份的方法。下面的这个shell脚本就是能让你通过过设定Crontab来每天备份MySQL数据库的方法。

#!/bin/bash
# 数据库认证
 user=""
 password=""
 host=""
 db_name=""
# 其它
 backup_path="/path/to/your/home/_backup/mysql"
 date=$(date +"%d-%b-%Y")
# 设置导出文件的缺省权限
 umask 177
# Dump数据库到SQL文件
 mysqldump --user=$user --password=$password --host=$host $db_name > $backup_path/$db_name-$date.sql

通过上面的脚本,我们可以每天导出一份sql备份文件,文件的名称按当日日期生成。日积月累,这样的文件会生成很多,有必要定时删除一些老旧的备份的文件,下面的这行命令就是做这个任务的,你可以把它加在上面的脚本后面。

# 删除30天之前的就备份文件
 find $backup_path/* -mtime +30 -exec rm {} \;

我在使用上面的脚本时曾经遇到过一个问题,Crontab定时执行脚本导出没有报错,但导出的是空的SQL文件,但登录到控制台手工执行这个脚本是备份成功的。后来发现是Crontab执行脚本是缺少系统环境信息,找不到 mysqldump,改正的方法是使用 mysqldump全路径就行了。而之所以没有报错信息,是因为 mysqldump把错误信息输出到了 stderr。在命令的后面末尾接 “2>&1” 这样一个信息重定向命令就可以看到错误信息了:

mysqldump -ujoe -ppassword > /tmp/somefile 2>&1

Linux下正确删除海量文件的姿势

$
0
0

这里说的“海量”并不是指体积大,而是指数量,比如一个目录下有数百万个小文件。

最近在优化服务器时发现postfix下的maildrop目录和clientmqueue目录下发现有大量的文件,进入这些目录里使用ls命令是愚蠢的做法,而直接执行 rm *,没有任何反应,文件数量也没有减少,也就是说,在海量文件目录里直接使用rm命令进行删除是无效的。

那么正确的方法是什么呢?有两种方法可选:
第一种:

find /path/to/directory -type f -exec rm {} \;

第二种:

ls -1 /path/to/directory | xargs -I{} rm {}

上面这两种方法可以成功的删除海量文件,速度也很快。但还有一种更好的方法,比如要删除上面提到的clientmqueue目录,里面全部是一个一个的邮件,用下面的方法:

service sendmail stop
cd /var/spool
mv clientmqueue clientmqueue-todelete
mkdir clientmqueue
chown --reference=clientmqueue-todelete clientmqueue
chmod --reference=clientmqueue-todelete clientmqueue
service sendmail start
rm -rf clientmqueue-todelete

上面的方法是将目录重命名,然后使用了 --reference引用参数来重建目录,然后删除重命名的目录。直接删除目录的方法速度是十分的快。也可以留着备份不删。更安全。

防范 CSRF 跨站请求伪造

$
0
0

CSRF(Cross-site request forgery,中文为 跨站请求伪造)是一种利用网站可信用户的权限去执行未授权的命令的一种恶意攻击。通过 伪装可信用户的请求来利用信任该用户的网站,这种攻击方式虽然不是很流行,但是却难以防范,其危害也不比其他安全漏洞小。

本文将简要介绍CSRF产生的原因以及利用方式,然后对如何避免这种攻击方式提供一些可供参考的方案,希望广大程序猿们都能够对这种攻击方式有所了解,避免自己开发的应用被别人利用。

CSRF也称作 one-click attack或者 session riding,其简写有时候也会使用 XSRF

什么是CSRF?

简单点说,CSRF攻击就是 攻击者利用受害者的身份,以受害者的名义发送恶意请求。与XSS(Cross-site scripting,跨站脚本攻击)不同的是,XSS的目的是获取用户的身份信息,攻击者窃取到的是用户的身份(session/cookie),而CSRF则是利用用户当前的身份去做一些未经过授权的操作。

CSRF攻击最早在2001年被发现,由于它的请求是从用户的IP地址发起的,因此在服务器上的web日志中可能无法检测到是否受到了CSRF攻击,正是由于它的这种隐蔽性,很长时间以来都没有被公开的报告出来,直到2007年才真正的被人们所重视。

CSRF有哪些危害

CSRF可以盗用受害者的身份,完成受害者在web浏览器有权限进行的任何操作,想想吧,能做的事情太多了。

  • 以你的名义发送诈骗邮件,消息
  • 用你的账号购买商品
  • 用你的名义完成虚拟货币转账
  • 泄露个人隐私

产生原理以及利用方式

要完成一个CSRF攻击,必须具备以下几个条件:

  • 受害者已经登录到了目标网站(你的网站)并且没有退出
  • 受害者有意或者无意的访问了攻击者发布的页面或者链接地址

(图片来自网络,出处不明,百度来的)

整个步骤大致是这个样子的:

  1. 用户小明在你的网站A上面登录了,A返回了一个session ID(使用cookie存储)
  2. 小明的浏览器保持着在A网站的登录状态,事实上几乎所有的网站都是这样做的,一般至少是用户关闭浏览器之前用户的会话是不会结束的
  3. 攻击者小强给小明发送了一个链接地址,小明打开了这个地址,查看了网页的内容
  4. 小明在打开这个地址的时候,这个页面已经自动的对网站A发送了一个请求,这时候因为A网站没有退出,因此只要请求的地址是A的就会携带A的cookie信息,也就是使用A与小明之间的会话
  5. 这时候A网站肯定是不知道这个请求其实是小强伪造的网页上发送的,而是误以为小明就是要这样操作,这样小强就可以随意的更改小明在A上的信息,以小明的身份在A网站上进行操作

利用方式

利用CSRF攻击,主要包含两种方式,一种是基于GET请求方式的利用,另一种是基于POST请求方式的利用。

GET请求利用

使用GET请求方式的利用是最简单的一种利用方式,其隐患的来源主要是由于在开发系统的时候没有按照HTTP动词的正确使用方式来使用造成的。 对于GET请求来说,它所发起的请求应该是只读的,不允许对网站的任何内容进行修改

但是事实上并不是如此,很多网站在开发的时候,研发人员错误的认为GET/POST的使用区别仅仅是在于发送请求的数据是在Body中还是在请求地址中,以及请求内容的大小不同。对于一些危险的操作比如删除文章,用户授权等允许使用GET方式发送请求,在请求参数中加上文章或者用户的ID,这样就造成了只要请求地址被调用,数据就会产生修改。

现在假设攻击者(用户ID=121)想将自己的身份添加为网站的管理员,他在网站A上面发了一个帖子,里面包含一张图片,其地址为 http://a.com/user/grant_super_user/121

<img src="http://a.com/user/grant_super_user/121"/>

设想管理员看到这个帖子的时候,这个图片肯定会自动加载显示的。于是在管理员不知情的情况下,一个赋予用户管理员权限的操作已经悄悄的以他的身份执行了。这时候攻击者121就获取到了网站的管理员权限。

POST请求利用

相对于GET方式的利用,POST方式的利用更加复杂一些,难度也大了一些。攻击者需要伪造一个能够自动提交的表单来发送POST请求。

//

只要想办法实现用户访问的时候自动提交表单就可以了。

如何防范

防范原理

防范CSRF攻击,其实本质就是要求网站 能够识别出哪些请求是非正常用户主动发起的。这就要求我们 在请求中嵌入一些额外的授权数据,让网站服务器能够区分出这些未授权的请求,比如说在请求参数中添加一个字段,这个字段的值从登录用户的Cookie或者页面中获取的(这个字段的值必须对每个用户来说是随机的,不能有规律可循)。攻击者伪造请求的时候是无法获取页面中与登录用户有关的一个随机值或者用户当前cookie中的内容的,因此就可以避免这种攻击。

防范技术

Synchronizer token pattern

令牌同步模式(Synchronizer token pattern,简称STP)是在用户请求的页面中的所有表单中嵌入一个token,在服务端验证这个token的技术。token可以是任意的内容,但是一定要保证无法被攻击者猜测到或者查询到。攻击者在请求中无法使用正确的token,因此可以判断出未授权的请求。

Cookie-to-Header Token

对于使用Js作为主要交互技术的网站,将CSRF的token写入到cookie中

Set-Cookie: CSRF-token=i8XNjC4b8KVok4uw5RftR38Wgp2BFwql; expires=Thu, 23-Jul-2015 10:25:33 GMT; Max-Age=31449600; Path=/

然后使用javascript读取token的值,在发送http请求的时候将其作为请求的header

X-CSRF-Token: i8XNjC4b8KVok4uw5RftR38Wgp2BFwql

最后服务器验证请求头中的token是否合法。

验证码

使用验证码可以杜绝CSRF攻击,但是这种方式要求每个请求都输入一个验证码,显然没有哪个网站愿意使用这种粗暴的方式,用户体验太差,用户会疯掉的。

简单实现STP

首先在index.php中,创建一个表单,在表单中,我们将session中存储的token放入到隐藏域,这样,表单提交的时候token会随表单一起提交

<?php
$token = sha1(uniqid(rand(), true));
$_SESSION['token'] = $token;
?><form action="buy.php" method="post"><input type="hidden" name="token" value="<?=$token; ?>" />
    ... 表单内容
</form>

在服务端校验请求参数的 buy.php中,对表单提交过来的token与session中存储的token进行比对,如果一致说明token是有效的

<code><?php if ($_POST['token'] != $_SESSION['token']) {
    // TOKEN无效
    throw new \Exception('Token无效,请求为伪造请求');
}
// TOKEN有效,表单内容处理
</code?></code>

对于攻击者来说,在伪造请求的时候是无法获取到用户页面中的这个 token值的,因此就可以识别出其创建的伪造请求。

解析Laravel框架中的VerifyCSRFToken中间件

在Laravel框架中,使用了 VerifyCSRFToken这个中间件来防范CSRF攻击。

在页面的表单中使用 {{ CSRF_field() }}来生成token,该函数会在表单中添加一个名为 _token的隐藏域,该隐藏域的值为Laravel生成的token,Laravel使用随机生成的40个字符作为防范CSRF攻击的token。

$this->put('_token', Str::random(40));

如果请求是ajax异步请求,可以在 meta标签中添加token

<meta name="CSRF-token" content="{{ CSRF_token() }}"/>

使用 jquery作为前端的框架时候,可以通过以下配置将该值添加到所有的异步请求头中

$.ajaxSetup({
    headers: {'X-CSRF-TOKEN': $('meta[name="CSRF-token"]').attr('content')
    }
});

在启用session的时候,Laravel会生成一个名为 _token的值存储到session中。而使用前面两种方式在页面中加入的token就是使用的这一个值。在用户请求到来时, VerifyCSRFToken中间件会对符合条件的请求进行CSRF检查

if (
  $this->isReading($request) ||
  $this->runningUnitTests() ||
  $this->shouldPassThrough($request) ||
  $this->tokensMatch($request)
) {
  return $this->addCookieToResponse($request, $next($request));
}

throw new TokenMismatchException;

if语句中有四个条件,只要任何一个条件结果为 true则任何该请求是合法的,否则就会抛出 TokenMismatchException异常,告诉用户请求不合法,存在CSRF攻击。

第一个条件 $this->isReading($request)用来检查请求是否会对数据产生修改

protected function isReading($request)
{
    return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS']);
}

这里判断了请求方式,如果是 HEADGETOPTIONS这三种请求方式则直接放行。你可能会感到疑惑,为什么GET请求也要放行呢?这是因为Laravel认为这三个请求都是请求查询数据的, 如果一个请求是使用GET方式,那无论请求多少次,无论请求参数如何,都不应该最数据做任何修改

第二个条件顾名思义是对单元测试进行放行,第三个是为开发者提供了一个可以对某些请求添加例外的功能,最后一个 $this->tokensMatch($request)则是真正起作用的一个,它是Laravel防范CSRF攻击的关键

$sessionToken = $request->session()->token();
$token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN');

if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
  $token = $this->encrypter->decrypt($header);
}

if (! is_string($sessionToken) || ! is_string($token)) {
  return false;
}

return hash_equals($sessionToken, $token);

Laravel会从请求中读取 _token参数的的值,这个值就是在前面表单中添加的 CSRF_field()函数生成的。如果请求是异步的,那么会读取 X-CSRF-TOKEN请求头,从请求头中读取token的值。

最后使用 hash_equals函数验证请求参数中提供的token值和session中存储的token值是否一致,如果一致则说明请求是合法的。

你可能注意到,这个检查过程中也会读取一个名为 X-XSRF-TOKEN的请求头,这个值是为了提供对一些javascript框架的支持(比如Angular),它们会自动的对异步请求中添加该请求头,而该值是从Cookie中的 XSRF-TOKEN中读取的,因此在每个请求结束的时候,Laravel会发送给客户端一个名为 XSRF-TOKEN的Cookie值

$response->headers->setCookie(
    new Cookie('XSRF-TOKEN', $request->session()->token(), time() + 60 * $config['lifetime'],
        $config['path'], $config['domain'], $config['secure'], false
    )
);

写在最后

本文只是对CSRF做了一个简单的介绍,主要是侧重于CSRF是什么以及如何应对CSRF攻击。有一个事实是我们无法回避的: 没有绝对安全的系统,你有一千种防御对策,攻击者就有一千零一种攻击方式,但不管如何,我们都要尽最大的努力去将攻击者拦截在门外。如果希望深入了解如何发起一个CSRF攻击,可以参考一下这篇文章 从零开始学CSRF。

作为一名web方向的研发人员,无论你是从事业务逻辑开发还是做单纯的技术研究,了解一些安全方面的知识都是很有必要的,多关注一些安全方向的动态,了解常见的攻击方式以及应对策略,必将在你成长为一名大牛的路上为你“推波助澜”。

参考

防范 CSRF 跨站请求伪造,首发于 文章 - 伯乐在线

http request乱码的真相

$
0
0

首先,从浏览器端看下有多少种情况:

1.在浏览器的地址栏,或者搜索框里输入地址:http://www.test.com/衣服/search?keyword=T恤

2.在一个指定了编码的网页中,提交一个form,如:

<html>  <head>  <meta charset="gbk">  </head>  <body>  <p>你好</p>  <form id="productSearchForm" action="http://127.0.0.1:8080/test.html" method="post">  <input name="keyword" class="keyword" value="T恤" maxlength="30">  <button type="submit">搜索</button>  </form>  </body>  </html>

当然还有,各种细分的选项,如get/post,form里是否指定了编码。

3. ajax请求里的编码。

我们从流程上来看,一个http request要经过哪些东东的处理:

1.浏览器/JavaScript

2.web server,以tomcat/jetty为例

3.filter/servlet ,以Java为例

4.web 框架,以spring mvc为例。

对于在浏览器的地址栏支持输入的地址,各种浏览器是如何处理的,可以参考这个:

http://www.ruanyifeng.com/blog/2010/02/url_encoding.html

也可以自己简单的测试,在Linux下执行:

nc -l 8080

接着在浏览器里直接访问 http://localhost:8080/衣服/search?keyword=T恤,

然后在就可以看到nc的输出结果了。当然,浏览器的debug工具也可以很方法地看到编码的结果,不过用nc,就不用自己跑一个web服务器了,非常方便。

另外那个keyword=T恤,也是有意选择的,这样可以很方便地看到编码的结果。恤的gbk编码是两个byte,utf-8编码是3个byte,也很容易区别到底是什么编码。

简单地总结下对于浏览器地址栏里直接访问:http://www.test.com/衣服/search?keyword=T恤  的编码情况:

对于chrome,“衣服”和“T恤”都是utf-8编码;

对于IE8,“衣服”和“T恤”都是gbk编码。

这里实际上有两个概念,一个是URI的编码,一个是query string(即?后面的字符串)的编码。

http request里的Content-Type设置:

http request是可以指定request的编码信息的,如:

Content-Type: application/x-www-form-urlencoded ; charset=UTF-8
但实际上,浏览器都没有这样提供这样的说明……

form提交里的编码设置:

form可以这样子设置编码:

<form accept-charset="UTF-8" enctype="application/x-www-form-urlencoded;charset=UTF-8"

但是实际上浏览器却不一定会这么做……

比如,把页页编码设置为gbk,再把form编码设置为utf-8。

简单测试,IE8仍然把form编码为gbk,chrome虽然编码为utf-8,但却没有在request里指明。。

当然,还有一个小技巧可以强行使用某种编码,那就是我们先自己转换好编码,如:

<form id="productSearchForm" action="http://127.0.0.1:8080/%A3%A4"

不过,这样意义不大。

web server是如何处理http request的编码的?

只讨论tomcat和jetty。

Tomcat对于URI的编码,有两个参数可以配置:

URIEncoding:这个可以强制指定用什么编码处理URI,默认是ISO-8859-1;

useBodyEncodingForURI:这个是一个兼容性比较好的选项,如果在request指定了编码,则采用request里指定的编码。因此,设置了这个选项为true之后,在java代码里就可以调用request.setCharacterEncoding来设置编码了。

参考: http://wiki.apache.org/tomcat/FAQ/CharacterEncoding#Q2

Jetty只提供了对query string的编码的指定方式,没有提供对URI编码的设置。因此,对于http:www.test.com/衣服/abcd/ 这样的URI,jetty总是把“/衣服/abcd/”当做是utf-8编码。

参考: http://wiki.eclipse.org/Jetty/Howto/International_Characters#International_characters_in_URLs

Spring mvc是如何处理编码的:

spring mvc里提供了一个Filter:

<filter>  <filter-name>encodingFilter</filter-name>  <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>  <init-param>  <param-name>encoding</param-name>  <param-value>UTF-8</param-value>  </init-param>  <init-param>  <param-name>forceEncoding</param-name>  <param-value>true</param-value>  </init-param>  </filter>

到源代码里看一下,可以发现,其实里面只是设置了request的encoding:

if (this.encoding != null && (this.forceEncoding || request.getCharacterEncoding() == null)) {  
    request.setCharacterEncoding(this.encoding);  
    if (this.forceEncoding) {  
        response.setCharacterEncoding(this.encoding);  
    }  
}

但是这个对request URI的编码实际上是不起效的。

再看下源代码里是通过j2ee里的request的API来得到的:

RequestParamMethodArgumentResolver类里:

protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest webRequest) throws Exception {  

if (arg == null) {  
String[] paramValues = webRequest.getParameterValues(name);  
if (paramValues != null) {  
arg = paramValues.length == 1 ? paramValues[0] : paramValues;  
}  
}

最终实际上调用的是底层web server的request实现类,如tomcat的是org.apache.catalina.connector.RequestFacade,而web server到底是怎么处理请求的编码的,参照上一小节。

http request 编码自动识别

这个比较少用到,只有搜索引擎需要识别这种情况。因为搜索引擎需要处理在地址栏里直接输入的字符串的编码。

我测试了google, 百度,淘宝的搜索引擎,都能自动识别编码。但是其它的一些非搜索引擎的应用,都不能自动识别编码。

当然,程序员通常只保证在自家的网页上,点击的产生的http request能正确地被编码,被识别。

那么,假定我们现在要做一个搜索类的功能,而且要能自动识别编码,要怎么处理?

以tomcat为例,首先要配置URIEncoding为ISO-8859-1,这样保证信息不丢失。

接着,写一个filter,从request里拿到uri,再进行编码识别,转换。编码识别的库参考:

https://code.google.com/p/juniversalchardet/

还有另外一个思路,写一个nginx的插件,先在nginx层识别,转换好编码。当然原理都是一样的。

其它的一些东东:

中文域名的的编码:

这东东应该没多少人用吧。不过在jetty的网页里看到了一些有用的信息:

http://wiki.eclipse.org/Jetty/Howto/International_Characters#International_characters_in_domain_names

浏览器实际上会用一个叫Punycode的编码,把域名转换成ascii-only的域名,再发起请求。

我测试了下,在chrome里输入:导航.中国

实际是转到下面这个域名去了:http://xn--fet810g.xn--fiqs8s

C/C++里的编码:

我们在源文件test.c里写上:

printf(“%s”, “中文”);

那么它在源文件test.c里是什么编码?在编绎出来的test.out/test.exe里是什么编码?运行时输出到屏幕(shell/cmd)上又是什么编码?

其实Python也有这种蛋疼的情况……

QQ在User-Agent里的信息:

用IE8测试时,很神奇地发现在request里发现了QQDownload的字样,真是相当的令人无语。。

User-Agent: Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; QQDownload 751)

总结:

想要实现 www.test.com/王小明/文章  这种url是很困难的,因为你不但要应对各种浏览器的编码,还要处理各种web服务器的不同情况。

出现乱码时,首先区分request传过来的是什么编码,然后response返回的是什么编码,再逐一排查。

编码问题可以说是程序员无法回避的问题,我相信即使是很有经验的程序员,也会被坑。没有办法,现实世界就是这么坑爹,只能寻根溯源,一一排查了。

对于程序员通常,只要保证下面几点就没有问题了:

  • 指定网页的编码;
  • 配置web server对uri使用request里配置的编码;
  • 在ajax请求里先encodeURI();
  • 在web server端对request设置utf-8编码,对于response设置utf-8编码。

相关文章

Viewing all 330 articles
Browse latest View live


<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>