图片服务系统是各种针对C端系统常见的子系统,它的特点是存储规模大请求频度高,且单张图片的读请求远远高于写请求。后续几篇文章我们将从图片服务系统的需求分析开始,一起来讨论如何进行这类系统的技术选型、概要设计和详细设计,以及在这个过程中需要关注的技术难点。
虽然由于写作计划的变化,图片服务系统中所涉及的分布式文件系统原理、非关系型数据库原理都还没有讲到,但这些知识点也并不是组成整个图片服务的所有关键点,并且后续的文章中我们会尽快补上对这些知识点的介绍。图片服务系统的讲述会分为四篇文章,前两篇文章我们主要分析图片服务的需求、架构选型和技术方案,后两篇文章进行性能优化和详细设计部分的讲解。由于不能讲解全部的详细设计,所以文章会在最后将整个示例工程的源代码放置在网络上,供有兴趣的读者进行下载。
首先我们给出使用这个图片服务的场景,以便后续内容中根据这个需求场景进行初步架构设计、详细架构设计、技术选型,以及服务中各个功能模块的设计等工作。这是一个中等规模的针对C端的电商服务站点,日平均PV量在百万级别(在国内属于中等规模的站点,例如从Alexa查询到大众点评的日均PV大致为180万、美团日均PV大致为70万、JD日均PV为7500万)。电子商务类型的站点,其特点是一张页面上会出现多张图片,而且基本上不会使用原图而是大量使用缩略图;另外,多个用户经常同时访问同一个页面,所以不但需要使用缓存,而且缓存一般都有多级,否则会产生大量重复的物理I/O请求;最后,这样的电商平台图片服务都属于顶级业务服务,图片规模基本都是以TB计算,小一些规模的也有几个TB,规模更大的可能达到PB级别。
产品团队针对图片服务的基本功能要求是:需要支持单张或者多张图片上传,这样可以为站点运营人员/商户增强编辑体验,提高工作效率。另外需要在一些特定场景支持水印功能和图片特效功能,但这是优先级较低的功能,而图片的动态缩放则是必备功能,这是因为在站点的各种页面上都基本上不会使用原图而是使用各种等比缩小或中心截取的缩率图片,另外一个原因是一张图片会在各种客户端设备上显示,最常见的是IOS、Android和浏览器,而这些终端设备对图片的像素要求都是不一样的。产品团队针对图片服务的另一个基本要求是支持图片访问统计和报表,这个功能倒不是给终端用户使用的,而是给站点的运营团队准备的。通过访问统计和用户上传图片的报表统计,运营团队可以针对这些基础数据掌握每月的图片热点,而且这些数据可能还会为后期的数据分析所使用。
接着在与需求团队的沟通过程中,技术团队还推导了一些重要的非功能性需求,例如该图片服务属于整个站点的一个顶层子系统,所以需要保证7 * 24小时不间断运行,换句话说至少需要99.999%的稳定性;另外,图片服务的设计需要满足现有图片数据的割接,最好能够实现原有系统特别是存在在磁盘上的图片的无缝割接;最后,整个系统至少需要30%的冗余存储空间,保证技术团队有足够的时间和空间进行后续的技术改造。。。
那么图片处理系统要关注的主要问题是什么呢?首先是高并发下的图片处理性能问题。图片处理是一项计算密集型操作,例如目前流行的图片缩放算法就有临近值法、双线性插值法、多项卷积法等,图片锐化算法多为拉普拉斯锐化法,图片增强算法可以采用中值滤波法、直方图均衡法或者幂变换法。这些算法都对CPU计算资源和内存资源有较高的要求,特别是高并发请求背景下。而JAVA语言相对于C/C++语言在各种算法执行效率上又有先天的劣势,所以如果不能在性能上进行弥补就需要找到其它在高并发请求背景下加快图片处理效率的办法。其次是图片存储的问题,和存储稳定性安全性的问题。产生这个问题的原因已经在上文中进行了说明,这里就不再进行追溯了。接下来技术团队根据以上这些需求点和技术点,首先圈定了一个图片服务的概要架构设计:
外层组件表示那些可以和其它服务系统共同使用的组件,以及那些可以从第三方花钱购买的不需要技术团队关注的组件,上图中给出的示例包括:LVS、DNS/智能DNS、CDN等组件。接下来我们对上图中的各个服务层/服务模块进行选型分析。
分布式文件系统的选型是整个图片服务的重点,因为它提供了所有图片物理文件的存储基础。在分布式文件系统大量应用于生产环境前,解决此类问题一般使用网络块存储方案,例如IBM Storwize V5000 单柜提供24个磁盘位,单柜支持最大72TB存储容量。但这个方案的缺点是单位容量价格较高,另外虽然这各方案也可以较容易扩容,但是扩容费用也是极高的,基于单位建设成本的考虑目前互联网公司都倾向于使用分布式文件系统甚至现成的云存储服务。如果采用分布式文件系统,那可以用的选型就太多了。这里我们先列举几种常用的分布式文件系统方案,然后针对图片文件的特性,再来对这些方案进行排除。这里我们可选择的分布式文件系统包括:HDFS、TFS、MFS、FastdFD和Ceph。
那么图片服务系统涉及的文件有些什么样的特点呢?首先这些文件相对于视频文件、语音文件而言都不会太大,举个例子来说,虽然目前主流手机相机照相后产生的一张JPEG图片分辨率大致在2500 * 3200像素左右,大小在1MB到3MB之间,甚至更高端的手机照相后产生的JPEG图片文件大小可以达到5MB左右 。但是考虑到网络流量和客户端速度体验等问题,上传到服务器的单张图片大小往往会控制在1MB左右(当然也有特殊的产品需求会要求上传完整的原始图片)。
另外这些图片的特点是读请求远远高于写请求,对大多数图片而言一旦上传到了服务器就不会发生更改操作了,甚至有的图片系统都不会提供对某张图片的修改功能。可是图片的读请求却会很多而且带有一定周期性,什么意思呢?例如一张商品图片,当这个商品刚刚上架或者处于优惠期时,这张图片的读请求会处于一个高峰,而平时这张图片的读请求次数会处于一个平均水平,当这张图片对应的商品下架后其访问量就会很低了,但图片系统并不能想当然的删除这张图片,这是因为可能后端的运营团队后续进行商品管理和统计时,还会用到这张图片。
还有一个特点是,虽然图片系统中单张图片的大小不会过大,但是整个图片系统的文件数量规模却会非常庞大了。就像上文说到的:较小的文件规模也会有几个TB,规模更大图片系统甚至可能达到PB级别。最后,图片系统都应该随时留有足够的冗余存储空间,以便应付至少几个月内系统的扩容要求。
通过以上的分析,大致可以得到图片系统中对于文件存储的部分的要求:高稳定性工作的工作特性,文件系统不能出现系统崩溃的情况——可以出现单节点故障,但是不能整系统崩溃。这个要求决定了文件系统不能只有单个节点,而是多个节点协同工作——分布式文件系统是符合这样选型要求的。另外,文件系统的数据写性能可以不必太优秀,但是数据读性能必须要好,这样才能适应上文描述的读密集型业务。最后,文件系统的扩容应该比较方便,以便减少运维难度。在这个图片服务的示例中,我们将使用Ceph作为文件系统的选型。关于Ceph文件系统的介绍,可以参考其官网的介绍http://www.ceph.com/,在后续的文章中本专题还会专门介绍Ceph文件系统的原理和使用。
多级缓存方案在图片服务中是必然的存在,这是由于图片服务的访问特性决定的。一般来说我们会设计三级——四级缓存,它们分别是客户端缓存,网络层缓存,路由层缓存和服务层缓存。如下图所示:
上图展示了一个四级缓存方案,其中基于Nginx的路由层缓存可有可无(后文会讨论原因),但是另外三级缓存在大多数高压力负载的图片服务中都是必须存在的,无非是采用哪种具体技术组件而已。除了在上文中提到的减少大量重复的物理I/O请求的目的外,在图片服务中使用多级缓存方案还有以下这些原因:
增加客户端访问速度:这是多级缓存方案中的客户端缓存和网络层缓存的首要目的,从访问效果来说客户端浏览器如果开启了缓存功能,当请求一张图片时,将从服务端上得到图片信息(CDN相对于浏览器来说就相当于服务器了),HTTP的返回代码就是200;当第二次请求同一张图片时,浏览器会自行判断在缓存区中的图片是否过期,就是判断HTTP协议中的max-age/Expires属性,如果没有过期就直接到浏览器缓存中取得原始图片(如果发现过期就请求服务器,不过这时服务器发现资源没有改变,就会给客户端一个HTTP 304的代码,提示浏览器可以继续使用本地缓存)。CDN的作用是解决服务端到客户端的最后一公里问题,当客户端向CDN请求图片时,CDN会到“离客户端最近的”服务节点去的图片信息,如果那个服务节点上没有相应的图片,CDN才会到真实的图片服务上提取图片并备后续使用。
拦截/缓解单个图片服务节点上的请求压力:在真实服务之前的路由层缓存,主要起到的就是这个作用。以Nginx上的proxy_cache为例,我们一般基于它使用内存 + 物理磁盘的方式缓存多种没有过期的静态文件资源,包括图片文件。
分散真实图片服务节点上的请求压力:路由层上除了缓存功能外,还起到分散请求压力的作用。无论技术团队在路由层使用的是Nginx、Haproxy或者是Spring Cloud——Zuul,要达到的一个目的就是对下层多个图片服务节点做请求负载。由于路由层的定位问题,所以一些图片服务系统中即使路由层没有提供缓存功能,但也一定会提供负载功能用来分散请求压力。
总的来说多级服务缓存以最终以缩短客户端响应时间,减少图片服务器上真实的物理I/O操作压力为最终目的,经过多级缓存逐级削减向下传递的请求规模达到这样的目的。最终传递到最后一级业务层时,可能只剩下10%——30%的请求数量需要做真实的I/O操作(这还要看各级缓存的选型和详细参数设置)。而作为最后一级缓存的选型,除了读写速度上的要求外还有存储规模上的要求,所以Redis是一个比较理想的选择——利用Redis原生的Cluster技术,既可以兼顾访问速度、稳定性还可以获得很灵活的容量扩充方式。关于Redis Cluster的介绍可参考我另一篇文章《架构设计:系统存储(18)——Redis集群方案:高性能》
对缓存模块的设计和选型非常考量架构师对系统的驾驭能力。举个例子,为了保证数据A在靠近客户端的缓存模块失效时,数据A的访问压力不会直接传导到最后端的I/O请求上,就要保证数据A在下一层缓存上在同一时间不会失效。基于这样的考虑架构师可能需要配合调整每一层的缓存过期时间,否则就可能出现数据A在最上层缓存失效的同时,所有层级的缓存都已经失效,数据A不得不直接在真实服务器的物理磁盘上重新读取,并重建每一级缓存的情况。但问题是,如果数据A的更新时间设置得过长,且每一级缓存的有效时长依次增大,那么在数据A真正发生变化后,这个变化可能需要很长时间才会体现在客户端上。所以并不是缓存层级越多约好,也不是缓存上的数据过期时间越长约好。
在图片服务系统中,路由层有两个作用:缓存和负载均衡。这两个作用的介绍已经在3-2小节中已经给出了一个概要说明,这里就不再进行赘述了。基于对上文中需求的考虑, 技术团队主要考虑在两种技术组件中进行选择,它们是Nginx和Spring Cloud——Zuul。本小节内容中我们主要对这两种技术对系统功能的契合度进行分析——它们在路由层分别使用时所基于的路由层功能定位完全不一样!Zuul是Spring Cloud服务治理框架(也称为微服务治理框架)的一个重要组件,它可以单独使用也可以和Spring Cloud中的其它组件集成使用。下表给出了Zuul和Nginx对于图片服务需求各个方面的契合度:
组件 | Nginx | Zuul | 目前图片服务中的要求 |
---|---|---|---|
缓存能力 | Nginx带有Proxy Cache模块,可以通过配置非常方便的进行数据缓存 | 需要自行实现 | 为了分散自上而下的数据请求压力,图片服务系统的代理层需要对数据进行缓存。后文还详细介绍这部分的考虑细节 |
反向代理能力 | 反向代理是Nginx的主要功能,配置灵活且性能优异 | 有反向代理功能,虽然配置灵活性没有Nginx好,但是通过编程可以很方便的进行扩展 | 没有特别要求 |
路由能力 | 支持基于正则表达式的路由功能配置 | 自带路由配置功能,支持通配符形式的路由配置。还可以通过Zuul中的filters,扩展符合自身业务需求的路由规则 | 没有特别要求 |
负载均衡能力 | 自带非常强大的均衡功能,支持基于正则表达式的负载均衡配置,支持多种负载均衡规则 | 通过listOfServers关键字,可以配置负载均衡功能,但由于Zuul在Spring Cloud的定位问题负载均衡功能没有Nginx那么强大 | 需要较灵活的负载均衡配置能力,对负载均衡功能的性能也有一定要求 |
流量控制能力 | 没有原生支持 | 没有现成支持,但通过Zuul的filters规则可以通过编程非常方便的实现 | 没有特别要求 |
安全控制能力 | 可以实现简单的安全控制功能,例如设置客户端黑白名单 | 安全控制是Zuul的主要职责之一,通过Zuul的filters规则可以通过编程非常方便的实现,另外还可以直接集成Spring Cloud的安全控制组件Spring Cloud Security 来完成复杂的安全控制 | 没有非常复杂的要求,后期可能需要对图片盗链问题进行控制 |
监控和日志能力 | 有日志功能,包括访问日志、拒绝日志、异常日志在内的多种日志,可以选择开启也可以选择关闭 | 自带Log4j日志,通过结合Flume等数据汇聚组件可能非常方便的进行日志收集 | 目前没有特别要求,不过后续版本中可能会要求 |
编程扩展能力 | 支持技术人员使用C/C++语言开发第三方Module,并在Nginx编译安装时一并安装。但实际情况是,大家都只会使用一些现成的Nginx Module | 本来就是Spring Cloud微服务治理框架的一个组件,支持使用JVM系列语言进行开发,您可以通过Zuul为代理层加入任何您想要的功能——如果不讨论软件解耦的科学性。 | 没有特别要求 |
(表完)
从上表我们可以看出,Zuul和Nginx的功能虽然有一定的重合度,但是侧重点却是不一样的:Nginx倾向于配置,Zuul倾向于在特定规则下(filters责任链)自行编程实现,究其根本原因是两者的架构思路和在整体架构上存在的位置不一样。我们最终选择Nginx的原因实际上还是基于我们对路由层功能的定位:图片服务是单一的业务功能,并不存在再进行下层服务路由/代理的必要,所以没有必要使用Zuul提供的灵活路由规则支持。另外图片服务在至少能够预期的发展规划中并不存在很强的权限控制要求,即使有权限控制要求也是对图片盗链情况的控制,而图片盗链问题通过Nginx就可以很好的解决。最后,我们对路由层的功能定位主要就是缓存和负载均衡,而Nginx提供的负载均衡配置相对于Zuul提供的负载均衡更为灵活——这是因为Spring Cloud框架是一个服务治理框架,Zuul可以直接把请求转向Spring Cloud Eureka服务注册中心,而且Spring Cloud框架内部的负载均衡可以依靠Spring Cloud Ribbon完成。
这里要特别讨论一下路由层的缓存控制问题。如果使用Nginx,那么可以使用它自带的Proxy-Cache功能建立运行的缓存空间;如果使用Zuul则需要自己通过代码实现一个缓存功能:
很明显基于Nginx的方案更方便,因为是现成的;使用Zuul的方案需要自行开发缓存功能,所以会有额外开发工作量(而且不小,看实现到什么程度),却更能契合功能要求。那么实际情况是什么呢?实际情况是我们还需要考虑对缓存状态的控制力度:
使用Nginx的Proxy-Cache虽然方便,但是它是独立工作的,只能按照配置好的方式载入载出数据资源。也就是说,当图片文件真正发生变化时我们无法通过Nginx提供的原生API接口,清除Nginx上相应的缓存数据。幸运的是,Nginx提供了一个可选模块proxy_cache_purge,通过HTTP请求的方式清空缓存,但这种主动清理方式在高并发情况下的性能并没有太多可靠性和性能方面的资料可查。这些还是其次,最重要的情况是我们无法在图片A变化时,准确知晓上层若干台设定了Nginx_Cache的Nginx中哪些Nginx节点需要刷新缓存。所以最后得出的路由层结论是:Nginx存在的主要作用是负载均衡,可以为它配置Proxy-Cache模块,但是不能设置过长的有效时长,可以设置成10分钟但绝对不能设置成10小时。这样才能保证下层图片数据发生变化时,客户端被延迟通知的时间更短。
Nginx和Zuul 设计之初就针对两种不相同的业务领域,其功能定位和要解决的业务问题也是不一样的。之所以在路由层的技术选型中,比较这两种组件的差异,除了因为两者在功能上有一定的重合度外,更重要的原因是:功能/业务要求决定技术选型,而不是反过来进行思考。
基于以上所描述的各种技术关键点的选型,最终我们可以将概要的架构设计进行细化了。如下图所示:
上图中,最外层面向客户端访问加速的智能DNS路由、CDN加速服务通过购买获得(目前市面上还有很多免费的服务可以使用),首先这些组件与图片服务的核心设计基本上没有什么关联,其首要目标是加快客户端的服务响应速度,另外这样做还可以有效减少运维团队的工作量。至于LVS组件,可以和其它独立工作的子系统共享使用。
我们最终采用Nginx作为图片服务的路由层,使用Nginx提供的负载均衡配置将数据请求压力分散到下层的多个服务节点上。使用Nginx原生的Proxy Cache作为处客户端缓存、CDN加速以外的第三级缓存,但不能将过期时间设置的太长(几分钟最合适)。如果图片发生的变更,也不能主动由业务节点基于proxy_cache_purge主动向Nginx通知删除缓存内容,因为根本不知道哪些Nginx节点需要被通知。类似图片文件访问频度这样的统计工作也在这路由层完成,只不过它不是由路由层本身来处理,而是使用类似Flume这样的日志收集组件对Nginx的access.log文件数据进行收集后,送至专门的日志分析系统完成。
为了提高业务层的开发过程这次演示的开发工程将基于Spring Boot进行构建和代码编写,为了让图片文件的处理操作更加灵活,我们将基于责任链模式构建生产线形式的图片处理过程。我们还将在业务层使用Redis Cluster构造最后一级缓存,这级缓存的过期时间是各层缓存中最长的,存储规模也是各层缓存中最大的。一旦图片文件被更新后,业务层服务将直接使用Redis的原生JAVA API删除缓存中对应的数据信息。
最后,持久层的分布式文件系统我们选用Ceph。实际上这一层可用的选型是最多的,只要把握两个规则就行:相同的内存空间中可以存放更多的元数据,存储小文件时浪费的空间更少。您还可以选用TFS、FastDFS,又或者直接使用网络块存储方案——光交换机 + 磁盘柜。
=============================================
关于持久化存储的数据库技术要注意一点,实际上它并不是图片服务的必要组件。例如,我们在进行设计时可以将图片访问的URL地址直接对应图片文件在服务器上的存储地址,并按照一定的规则将图片文件重命名成一个系统中唯一的文件名,最后再删除Redis和Nginx Proxy Cache中可能存在的历史文件数据。这样就算没有数据库技术,也可以保证图片服务正常工作。
但是在上文描述的图片服务需求中,产品团队还明确要求需要对用户上传的规律、活跃度等状态信息进行统计,需要对图片的物理磁盘读操作频度进行统计分析,所以一些结构化的数据还是需要做持久化存储的。就拿图片的每日访问情况来说,当我们为通过Flmue收集了Nginx的访问日志,并送入到一个独立的日志分析系统中进行处理后,类似单个用户每一天对图片数据的访问数量、每张图片在每一天的访问数量这样的分析结果还是要存入到数据库中以备后续的统计分析——无论是选用关系型数据库MySQL、SQL Server或者非关系型数据库Mongodb、Apache Cassandra。
也就是说,实际上要实现完整的图片服务系统的话还是离不开使用数据库技术的,但是这个基本上属于一个边缘选型,完全可以根据您所在公司某种默认规定的数据库技术作为依据。
关于Nginx Proxy Cache
Nginx的Proxy Cache缓存采用内存索引 + 物理磁盘存储的工作方式,所以为了进一步提高Proxy Cache的工作性能,在为Proxy Cache指定工作目录时,最好指定到一个独立挂载点上,并且这个挂载点的底层物理介质最好为SSD 固态磁盘 + RIAD 5磁盘阵列。另外在后文我们介绍Proxy Cache配置时,还会讨论Proxy Cache的一些注意细节。
关于Image_Filter模块
有的读者可能会问,什么我们不直接基于Nginx提供的第三方模块Image_Filter作为图片处理的基础呢?这个模块也可以实现图片的缩放、裁剪、翻转、特效等操作。是的,如果您的系统对图片处理的需求不高,完全可以使用Image_Filter来动态处理图片请求。Image_Filter使用C/C++语言完成,在完成单张图片同样的特效要求的前提下,处理性能也比使用JAVA原生的Image I/O API要高。但Image_Filter可以提供的图片效果也是有限,例如Image_Filter提供的特效方面只有透明度、锐化、旋转、变更图片质量等操作,但如果系统中有诸如效果增强、背景虚化等这样的图片特效要求,那还是只能有开发人员自行编程解决;Image_Filter虽然可以为图片加水印效果,但是要求水印图片背景必须透明(有Alpha通道,后文会讲到);最后,这个图片服务系统是一个对本专题和其他几个专题所讲解的架构知识的综合应用,当然最好介绍一下自己编程做做图片处理的相关知识。
在图片处理系统的首个版本中,我们计划先提供诸如图片等比例缩放、图片中心点裁剪、图片白化、图片文字水印等基本功能,但是为了保证软件设计部分能够在后续版本方便进行功能扩展,我们需要找到一种符合功能特点的行为模式,作为基本的设计模式。
首先,我们在首期提供的这些功能并不能要求使用者(客户端)按照某种操作顺序执行,而应该由使用者自行确定操作顺序。什么意思呢?我们不能规定使用者必须先缩放图片才能为图片添加水印,也不能规定要进行图片白化,就不能进行图片裁剪。而应该让使用者像使用PS软件一样——可以首先进行图片裁剪,然后再进行白化,最后再添加水印;也可以先向图片增加文字水印,然后再进行图片等比例缩放操作。我们可以用以下的一张概要图表示这里文字描述的内容:
从缓存中取得图片这一步的注意事项我们将在下一节中进行介绍,这里我们先看读取原始图片后的处理过程。从上图我们可以看到读取完成后的图片各种处理组合,有一点类似于生产线的概念,每一个图片处理器接收到上一个处理器的产品后,再按照自己的处理逻辑进行处理,最后输出到下一个处理器,这种表象性的处理特点符合一个典型的责任链模式所适应的处理场景。在责任链模式里,若干实际的处理过程被串成一个链式结构,数据在上一个处理器中被处理后传递到下一个处理器,每个处理器都按照自己的业务逻辑规则处理数据。如果责任链中某个处理器处理失败可以通过返回null或者抛出异常等方式通知整个责任链停止处理。
我们在Http Servlet中使用的Filter就是一种责任链模式,在Netty中的ChannelHandler也是基于责任链模式进行构造的。另外,责任链模式还有很多变种/结构类似的设计模式,例如命令模式和装饰器模式。在本示例的图片服务系统中,我们可以参照客户端传递的参数,来构造相应的执行顺序。例如:当客户端传递zoom|0.8->cut|400|640参数时,表示先按照原始图片的80%缩小整张图片,再按照宽400像素高640像素以图片中心为基点进行图片裁剪;当客户单传递mark|aHR0cDovL2Jsb2cuY3Nkbi5uZXQveWlud2Vuamll->cut|800|640参数时,表示先在图片上加文字水印,文字信息为编码前的值,然后在按照宽800像素高640像素以图片中心为基点进行图片裁剪。
上篇文章中我们介绍到,图片服务系统将使用Redis Clusters作为图片数据远离客户端的最后一层缓存系统,那么存储什么样的数据,以及选用Redis支持的哪种数据结构进行存储就需要思考清楚。
在“存储什么样的数据”方面,初步来看有两种选择,一种是存储原始图片数据另一种是存储经过各种图片处理器经过处理后的用户最终需要的图片数据。显然后者在客户体验性、需求契合度和存储效率上更为合适,而如果存储原始图片不但存储单张图片需要的缓存容量更大,更关键的是这样的原始图片大多数情况下客户端并不需要。最后虽然缓存原始图片可以降低减轻物理磁盘的I/O压力,但并不能减轻图片服务器上CPU的计算压力,这是因为图片服务器在从缓存系统中取出原始图片数据后,大多数情况下都会再根据客户端的要求进行各种图片特效运算,而这一部分操作非常消耗CPU资源。
那么根据以上的分析,我们可以在“存储什么样的数据”方面形成讨论结果了。那“存成哪种数据结构”方面又是怎样一个思考呢?最直观的判断是,既然图片服务器向Redis Clusters中读取的是经过各种特效处理后的图片效果,而一张原始图片根据不同的特效组合处理后,得到的效果也不一样。所以应该使用Redis中的简单K-V结构进行存储,其中的Key应该是原始图片的路径 + 客户端给定的特效处理参数,而Value则应该是经过处理后的图片bytes数据。
但实际情况真是这样吗?实际情况是以上的内容描述并没有考虑太多性能方面的细节,这里我们至少还需要讨论一个重要性能点:数据文件的大小。虽然一个128 * 128 像素大小24KB的文件数据,相对于物理介质上的存储来说算是一个小文件,但是它在单个Redis上的存储却属于一个大文件——我们一般在Redis上存储的缓存数据也不过是1KB(例如一个经过序列化的用户信息)。而很多技术资料也表明当单个Redis Value的大小大于10KB时,Redis对于这个Value的读写性能会大大降低,甚至还给出了具体的数据写操作的性能测试结果。另外Redis Cluster保证性能的一个办法是在客户端将Key做一次CRC16运算,并根据计算结果将不同的Key送入不同的Redis Cluster Master节点,这样多个Redis Cluster Client就可以在同一时间完成多个Key的操作(Redis Server节点本身是单线程的,其性能完全依靠epoll、自身实现的事件分离器和全内存态数据存储来保证)。Java版本的Jedis Client,其CRC16算法工具类的类名是redis.clients.util.JedisClusterCRC16。
根据以上的分析,在存储一个24KB的图片文件时,我们不能直接将这个文件使用一个K-V结构存储到Redis Cluster的某一个节点上,而是应该将这个较大的数据文件分成若干byte数据段并对应不同的Key,Key的命名的原则是能够通过CRC16算法,计算出不同的Slot目标结果。并且还应该将这个图片的size进行缓存,以便读取时使用。什么叫做让CRC16算法呈现不同结果呢?请看如下测试代码:
public static void main(String[] args) throws Exception {
......
// 以下模拟某个图片在Redis Cluster存储的3个分段
System.out.println(JedisClusterCRC16.getCRC16("/path/imagename.png|1"));
System.out.println(JedisClusterCRC16.getCRC16("/path/imagename.png|2"));
System.out.println(JedisClusterCRC16.getCRC16("/path/imagename.png|3"));
......
}
// 以下是可能的计算结果
6450
10577
14704
以上计算结果当Redis Clusters中master的节点个数大于12时,图片的3个分段就会被存储到两个master节点中(6450 slot和10577 slot在一个节点上,14704在另一个节点上)。在这个图片服务系统的示例中,我们将固定5KB为一个文件分片,也就是说以上的举例的24KB图片文件数据会有5个文件分段。
当读取缓存文件时,客户端会首先去Redis Cluster上读取缓存图片的大小,以便重新确定文件有几个分片,然后再到Redis Cluster中读取每个数据分片。注意,虽然每个图片数据分片都设置了同样的过期时间,但由于每个节点的实际工作状态不同,所以还是可能出现某些分片数据读取失败,这个时候如果任何一个分片读取失败就认为整个读取过程失败。如果出现这样的情况,图片系统就会到最下层的分布式文件系统上读取数据。
另外在前文中我们介绍过的Redis单节点的性能配置注意事项,都需要应用到Redis Clusters的配置中,例如停止Redis节点的主动快照和AOF记录功能,调高操作系统最大文件副本数量,调整redis的backlog参数项等等(详细讨论请参见《架构设计:系统存储(15)——Redis基本概念和安装使用》、《架构设计:系统存储(16)——Redis事件订阅和持久化存储》)。最后,由于我们预计会使用Redis最基本的K-V结构存储数据,所以配置信息中关于“紧凑型数据结构”的配置项就不需要多做调整了。
在后续的文章中,我们将对图片服务工程中重要的代码片段进行演示,例如多个Jedis客户端同时配合去不同的Redis Cluster Slot中读取数据,并相互等待全部完成后进行byte的合并;再例如使用责任链模式按照外部请求方的要求自由组合各种图片处理器对图片进行流水线形式的处理,等等。但为了读者对整个工程有一个全面的了解以便提出自己的改进建议,我们将在CSDN的下载区上传整个工程。
这个版本的图片服务工程将基于Spring Boot进行构建。Spring Boot由Pivotal团队提供,它是基于Spring Core 4.X版本构建的一套组件库,它的既定目标是大幅度减少Spring工程在初始化搭建时的配置工作量。举个例子来说,我们在使用Spring(3.X版本尤为突出)时,会产生大量的xml配置信息,我们至少需要在配置信息中设定ScanBase的包路径、设定若干ApplicationListener、配置数据库数据源、配置各种对象池和连接池。此外如果没有部署持续化集成服务,我们还需要自行管理多套配置信息,以便将工程应用到不同的部署环境。
使用Spring Boot后,最直观的现象就是以前工程中的所有配置所需的Spring XML文件都消失了,Spring Boot会按照“约定优于配置”的原则,自动对工程进行扫描并提炼出需要在工程启动是加载的Bean、ApplicationListener、启动线程、预处理数据等等资源。为了方便读者阅读源代码下图给出了整个工程结构,后续的文章还会给出工程中重要的代码片段:
您还可以直接基于Spring Boot为图片服务系统集成一套服务治理框架Spring Cloud,但经过这个实例之前的文章内容讨论,您会发现这样做涉嫌过度设计——除非您的团队已经搭建了Spring Cloud基础应用环境,而图片服务只是作为一个服务提供节点注册到现有顶层系统中。
针对大多数TO C端的互联网系统,图片服务系统虽然属于一种顶级子系统(但也不一定,例如某些LBS系统就不怎么依赖于图片),但它必须依附于顶层系统的全局规划。而且图片服务系统对外暴露的服务接口太少,按照目前的功能规划,它对外暴露的服务接口就只包括三个:单一图片上传、批量上传和图片显示/下载。将为最顶层全局规划服务的Spring Cloud Eureka Server(服务发现/注册)放置到图片服务系统中既没有必要也不合理。
在之前的文章中,我们还从技术功能的角度讨论了为什么图片服务的路由层采用Nginx而不是Spring Cloud Zuul的原因。这里我们再从系统结构层面再进行以下补充:Spring Cloud Zuul着眼于系统服务和系统服务间的调用,其作用在于系统服务间的服务治理,而非单一系统内部的调用;另外,Spring Cloud Zuul最好配合Spring Cloud的其它组件进行使用,例如Spring Cloud Security、Spring Cloud Netfix、Spring Cloud Eureka,而在单一系统内部单独使用Spring Cloud Zuul不能发挥它的效用。
最后,基于Spring Boot构建图片服务工程,也是为了后续的工程部署过程能够灵活选用运行环境和集成环境。例如Spring Boot Web比起传统的Spring Web工程更能简便的在微服务组件上运行,如在Docker微服务容器上运行;再例如,如果您所在公司以后决定向Spring Cloud服务治理架构做技术转型,那么图片服务也可以方便的进行升级,直接将自己提供的服务向Spring Cloud Eureka注册即可供其它的子系统服务使用。
以上技术特性都是图片服务的第一个版本需要实现的,但在后续为了完善图片服务功能可以为图片服务增加一些新的技术特性。
上文中我们介绍技术选型时提到,将选用一款分布式文件系统作为对原始图片文件进行持久存储的技术选型,并且使用高性能的SSD固态磁盘 + 磁盘阵列作物分布式文件系统的物理层支持。这样一来系统I/O的吞吐量,特别是读操作的吞吐量比起简单的本地块存储方案会得到很高的提升。那么我们还有没有其它方法继续提高持久化存储层的数据读性能呢?我们首先来分析一下图片系统中数据读取和缓存的一些特点:
面向C端的系统有一些共同的特点,就拿图片服务来说吧,一个商品页面上会有很多图片,一般来说它们都不是原始图片(就是存放在图片上1MB到2MB的原图),都是按照一定的要求被缩放、被加印甚至被旋转的;它们使用的特效模式也基本上是不变的,例如A图片在界面上被以0.8的比例缩放,那么同一页面上的B图片肯定就是以0.7的比例被缩放;最后,由于它们会在物理磁盘上被同时读取,所以它们在各级缓存中存在的时间基本上也是一致的,当某张图片过期时其它图片也会过期;
那么我们可以使用预读的概念,将这些有读取关系图片从文件系统上一次性读取出来,这样对文件系统的读操作效率优于一次只读取一个文件的操作效率。预读的概念我们在本专题介绍块存储的文章中介绍过一次(读者可以参考《架构设计:系统存储(1)——块存储方案(1)》)。预读技术基于局部性原理,这是说计算机上某些相关部分的资源,都会存在于一个集中的区域,CPU寄存器、内存地址、磁盘数据等等,当某个资源X被处理时,和它临近的若干资源也即将被处理。这个概念可以被运用到图片处理上:如果某张页面上的图片A被读取时,这张图片上的其它图片也将同时被读取。
由于我们的图片系统并没有集成持久化数据库技术,所以无法记录某一个文件和哪些文件存在读取联系。而且即使能够记录这些原始文件的上传关系,也不能作为文件预读的依据——因为客户端请求图片信息时并不是请求原始图片,而是请求经过特效处理后的图片,也就是说图片C经过特效处理后的图片C1,和图片D经过特效处理后的图片D1才存在读取关联。
这样的图片读取关系显然只能通过对图片读取请求的持续分析才能得出,而这个分析源头可以基于Nginx层access.log日志,而分析的手段可以基于类似Hadoop MapReduce这样的离线/延迟分析手段。分析过程也很好理解,即按照10毫秒为单位以某一个访问路径为参照(带特效参数的),对后续又再次出现了这个访问路径的毫秒范围内的所有访问路径取交集,交集运算次数越多,得到的图片读取关系就越准确:
这样系统就可以得出,当图片A经过特效X处理后,紧接着最有可能会读取的其它文件和需要加载的特效,这样依赖图片系统就可以对后续的图片进行预读并在完成特效处理存储到缓存系统中。这个图片关联关系的分析工作计算量比较大,以上只是计算的某一个文件的关联情况,试想一下所有的图片都要进行类似的分析过程,然后还要过滤出重复的分析数据,所以只有依靠大数据分析手段完成。
我们好像一直没有讨论过图片的删除问题,实际上并不是所有的图片系统都需要图片删除功能,甚至有些系统还会特别说明所有的原始图片都要进行永久保存。但如果图片系统的存储容量确实有限,并且团队暂时没有太多资金进行存储扩容,那么删除一些不再使用的图片就是一个节约存储容量的好办法。但关键问题是,怎样判断图片不再使用呢?
最直观的思路是,按照图片上传时间向后推导3至6个月的时间到一个固定的时间点,如果超过这个固定时间点就将这张图片删除。但这样做的话图片系统并不能确定这些图片在后续的时间不会被请求者访问,例如一些畅销商品甚至会保持1年以上的销售热度。还有一种删除思路,是由客户端自行进行删除操作,例如当一个商品下架时同时删除商品图片。但这样做也有问题,因为后续运营团队可能还会在进行后期销售总结是访问这个商品的快照信息,这时也会同时查看这个商品的图片。
那么怎样删除才是较合理的呢?首先是删除时机的问题,显然给定一个固定的时间长度作为删除依据是不满足要求的,时间长度的选择应该是动态的:利用数据处理工具分析出当前某张图片最后一次访问时间,如果当前时间离该图片最后一次访问时间大于规定的阈值(例如3个月、6个月等值),就启动删除过程。另外从删除策略上来说,一张图片的删除不能不留余地的直接删除原始图片本身,一张原始图片的大小在1MB——2MB左右(可能还会更大,这完全取决于系统提供的图片上传功能中对图片大小的限制问题),而一张经过特效处理后的图片大小在100KB——300KB左右(可能还会更小,这完全看特效处理的情况),所以这里可以采用渐进式删除的方式。当然如果发现这样原始图片存在多种特效处理规则,并且经过这些规则处理后的图片大小总和已经大于原始图片的大小了,则可以跳过渐进式删除过程:
通过删除原始图片替换保留特效结果文件,可以有效防止原始图片删除后用户零星访问的空窗期。待到一个更长的,再无任何图片访问请求的时间期后,最终将图片所有的存储痕迹全部抹去,这时如果用户再进行访问就会出现图片已过期的提示。通过渐进式删除过程,一般可以在删除的第一阶段腾出20%——30%左右的存储容量,而且不会对用户后续的零星访问造成任何影响(但不在允许用户设定新的特效了),最后在保证用户有90%以上的几率没有再次访问该图片的可能后,在对图片进行正式删除。渐进式删除不适合所有的图片服务,本文还是建议在存储容量充足、集群服务性能足够的情况下对原始图片进行永久保存(至少3——5年)。
===================
(下文我们将对图片工程中重要的代码片段进行讲解)
关于持久化存储的数据库技术要注意一点,实际上它并不是图片服务的必要组件。例如,我们在进行设计时可以将图片访问的URL地址直接对应图片文件在服务器上的存储地址,并按照一定的规则将图片文件重命名成一个系统中唯一的文件名,最后再删除Redis和Nginx Proxy Cache中可能存在的历史文件数据。这样就算没有数据库技术,也可以保证图片服务正常工作。
但是在上文描述的图片服务需求中,产品团队还明确要求需要对用户上传的规律、活跃度等状态信息进行统计,需要对图片的物理磁盘读操作频度进行统计分析,所以一些结构化的数据还是需要做持久化存储的。就拿图片的每日访问情况来说,当我们为通过Flmue收集了Nginx的访问日志,并送入到一个独立的日志分析系统中进行处理后,类似单个用户每一天对图片数据的访问数量、每张图片在每一天的访问数量这样的分析结果还是要存入到数据库中以备后续的统计分析——无论是选用关系型数据库MySQL、SQL Server或者非关系型数据库Mongodb、Apache Cassandra。
也就是说,实际上要实现完整的图片服务系统的话还是离不开使用数据库技术的,但是这个基本上属于一个边缘选型,完全可以根据您所在公司某种默认规定的数据库技术作为依据。
关于Nginx Proxy Cache
Nginx的Proxy Cache缓存采用内存索引 + 物理磁盘存储的工作方式,所以为了进一步提高Proxy Cache的工作性能,在为Proxy Cache指定工作目录时,最好指定到一个独立挂载点上,并且这个挂载点的底层物理介质最好为SSD 固态磁盘 + RIAD 5磁盘阵列。另外在后文我们介绍Proxy Cache配置时,还会讨论Proxy Cache的一些注意细节。
关于Image_Filter模块
有的读者可能会问,什么我们不直接基于Nginx提供的第三方模块Image_Filter作为图片处理的基础呢?这个模块也可以实现图片的缩放、裁剪、翻转、特效等操作。是的,如果您的系统对图片处理的需求不高,完全可以使用Image_Filter来动态处理图片请求。Image_Filter使用C/C++语言完成,在完成单张图片同样的特效要求的前提下,处理性能也比使用JAVA原生的Image I/O API要高。但Image_Filter可以提供的图片效果也是有限,例如Image_Filter提供的特效方面只有透明度、锐化、旋转、变更图片质量等操作,但如果系统中有诸如效果增强、背景虚化等这样的图片特效要求,那还是只能有开发人员自行编程解决;Image_Filter虽然可以为图片加水印效果,但是要求水印图片背景必须透明(有Alpha通道,后文会讲到);最后,这个图片服务系统是一个对本专题和其他几个专题所讲解的架构知识的综合应用,当然最好介绍一下自己编程做做图片处理的相关知识。
在图片处理系统的首个版本中,我们计划先提供诸如图片等比例缩放、图片中心点裁剪、图片白化、图片文字水印等基本功能,但是为了保证软件设计部分能够在后续版本方便进行功能扩展,我们需要找到一种符合功能特点的行为模式,作为基本的设计模式。
首先,我们在首期提供的这些功能并不能要求使用者(客户端)按照某种操作顺序执行,而应该由使用者自行确定操作顺序。什么意思呢?我们不能规定使用者必须先缩放图片才能为图片添加水印,也不能规定要进行图片白化,就不能进行图片裁剪。而应该让使用者像使用PS软件一样——可以首先进行图片裁剪,然后再进行白化,最后再添加水印;也可以先向图片增加文字水印,然后再进行图片等比例缩放操作。我们可以用以下的一张概要图表示这里文字描述的内容:
从缓存中取得图片这一步的注意事项我们将在下一节中进行介绍,这里我们先看读取原始图片后的处理过程。从上图我们可以看到读取完成后的图片各种处理组合,有一点类似于生产线的概念,每一个图片处理器接收到上一个处理器的产品后,再按照自己的处理逻辑进行处理,最后输出到下一个处理器,这种表象性的处理特点符合一个典型的责任链模式所适应的处理场景。在责任链模式里,若干实际的处理过程被串成一个链式结构,数据在上一个处理器中被处理后传递到下一个处理器,每个处理器都按照自己的业务逻辑规则处理数据。如果责任链中某个处理器处理失败可以通过返回null或者抛出异常等方式通知整个责任链停止处理。
我们在Http Servlet中使用的Filter就是一种责任链模式,在Netty中的ChannelHandler也是基于责任链模式进行构造的。另外,责任链模式还有很多变种/结构类似的设计模式,例如命令模式和装饰器模式。在本示例的图片服务系统中,我们可以参照客户端传递的参数,来构造相应的执行顺序。例如:当客户端传递zoom|0.8->cut|400|640参数时,表示先按照原始图片的80%缩小整张图片,再按照宽400像素高640像素以图片中心为基点进行图片裁剪;当客户单传递mark|aHR0cDovL2Jsb2cuY3Nkbi5uZXQveWlud2Vuamll->cut|800|640参数时,表示先在图片上加文字水印,文字信息为编码前的值,然后在按照宽800像素高640像素以图片中心为基点进行图片裁剪。
上篇文章中我们介绍到,图片服务系统将使用Redis Clusters作为图片数据远离客户端的最后一层缓存系统,那么存储什么样的数据,以及选用Redis支持的哪种数据结构进行存储就需要思考清楚。
在“存储什么样的数据”方面,初步来看有两种选择,一种是存储原始图片数据另一种是存储经过各种图片处理器经过处理后的用户最终需要的图片数据。显然后者在客户体验性、需求契合度和存储效率上更为合适,而如果存储原始图片不但存储单张图片需要的缓存容量更大,更关键的是这样的原始图片大多数情况下客户端并不需要。最后虽然缓存原始图片可以降低减轻物理磁盘的I/O压力,但并不能减轻图片服务器上CPU的计算压力,这是因为图片服务器在从缓存系统中取出原始图片数据后,大多数情况下都会再根据客户端的要求进行各种图片特效运算,而这一部分操作非常消耗CPU资源。
那么根据以上的分析,我们可以在“存储什么样的数据”方面形成讨论结果了。那“存成哪种数据结构”方面又是怎样一个思考呢?最直观的判断是,既然图片服务器向Redis Clusters中读取的是经过各种特效处理后的图片效果,而一张原始图片根据不同的特效组合处理后,得到的效果也不一样。所以应该使用Redis中的简单K-V结构进行存储,其中的Key应该是原始图片的路径 + 客户端给定的特效处理参数,而Value则应该是经过处理后的图片bytes数据。
但实际情况真是这样吗?实际情况是以上的内容描述并没有考虑太多性能方面的细节,这里我们至少还需要讨论一个重要性能点:数据文件的大小。虽然一个128 * 128 像素大小24KB的文件数据,相对于物理介质上的存储来说算是一个小文件,但是它在单个Redis上的存储却属于一个大文件——我们一般在Redis上存储的缓存数据也不过是1KB(例如一个经过序列化的用户信息)。而很多技术资料也表明当单个Redis Value的大小大于10KB时,Redis对于这个Value的读写性能会大大降低,甚至还给出了具体的数据写操作的性能测试结果。另外Redis Cluster保证性能的一个办法是在客户端将Key做一次CRC16运算,并根据计算结果将不同的Key送入不同的Redis Cluster Master节点,这样多个Redis Cluster Client就可以在同一时间完成多个Key的操作(Redis Server节点本身是单线程的,其性能完全依靠epoll、自身实现的事件分离器和全内存态数据存储来保证)。Java版本的Jedis Client,其CRC16算法工具类的类名是redis.clients.util.JedisClusterCRC16。
根据以上的分析,在存储一个24KB的图片文件时,我们不能直接将这个文件使用一个K-V结构存储到Redis Cluster的某一个节点上,而是应该将这个较大的数据文件分成若干byte数据段并对应不同的Key,Key的命名的原则是能够通过CRC16算法,计算出不同的Slot目标结果。并且还应该将这个图片的size进行缓存,以便读取时使用。什么叫做让CRC16算法呈现不同结果呢?请看如下测试代码:
public static void main(String[] args) throws Exception {
......
// 以下模拟某个图片在Redis Cluster存储的3个分段
System.out.println(JedisClusterCRC16.getCRC16("/path/imagename.png|1"));
System.out.println(JedisClusterCRC16.getCRC16("/path/imagename.png|2"));
System.out.println(JedisClusterCRC16.getCRC16("/path/imagename.png|3"));
......
}
// 以下是可能的计算结果
6450
10577
14704
以上计算结果当Redis Clusters中master的节点个数大于12时,图片的3个分段就会被存储到两个master节点中(6450 slot和10577 slot在一个节点上,14704在另一个节点上)。在这个图片服务系统的示例中,我们将固定5KB为一个文件分片,也就是说以上的举例的24KB图片文件数据会有5个文件分段。
当读取缓存文件时,客户端会首先去Redis Cluster上读取缓存图片的大小,以便重新确定文件有几个分片,然后再到Redis Cluster中读取每个数据分片。注意,虽然每个图片数据分片都设置了同样的过期时间,但由于每个节点的实际工作状态不同,所以还是可能出现某些分片数据读取失败,这个时候如果任何一个分片读取失败就认为整个读取过程失败。如果出现这样的情况,图片系统就会到最下层的分布式文件系统上读取数据。
另外在前文中我们介绍过的Redis单节点的性能配置注意事项,都需要应用到Redis Clusters的配置中,例如停止Redis节点的主动快照和AOF记录功能,调高操作系统最大文件副本数量,调整redis的backlog参数项等等(详细讨论请参见《架构设计:系统存储(15)——Redis基本概念和安装使用》、《架构设计:系统存储(16)——Redis事件订阅和持久化存储》)。最后,由于我们预计会使用Redis最基本的K-V结构存储数据,所以配置信息中关于“紧凑型数据结构”的配置项就不需要多做调整了。
在后续的文章中,我们将对图片服务工程中重要的代码片段进行演示,例如多个Jedis客户端同时配合去不同的Redis Cluster Slot中读取数据,并相互等待全部完成后进行byte的合并;再例如使用责任链模式按照外部请求方的要求自由组合各种图片处理器对图片进行流水线形式的处理,等等。但为了读者对整个工程有一个全面的了解以便提出自己的改进建议,我们将在CSDN的下载区上传整个工程。
这个版本的图片服务工程将基于Spring Boot进行构建。Spring Boot由Pivotal团队提供,它是基于Spring Core 4.X版本构建的一套组件库,它的既定目标是大幅度减少Spring工程在初始化搭建时的配置工作量。举个例子来说,我们在使用Spring(3.X版本尤为突出)时,会产生大量的xml配置信息,我们至少需要在配置信息中设定ScanBase的包路径、设定若干ApplicationListener、配置数据库数据源、配置各种对象池和连接池。此外如果没有部署持续化集成服务,我们还需要自行管理多套配置信息,以便将工程应用到不同的部署环境。
使用Spring Boot后,最直观的现象就是以前工程中的所有配置所需的Spring XML文件都消失了,Spring Boot会按照“约定优于配置”的原则,自动对工程进行扫描并提炼出需要在工程启动是加载的Bean、ApplicationListener、启动线程、预处理数据等等资源。为了方便读者阅读源代码下图给出了整个工程结构,后续的文章还会给出工程中重要的代码片段:
您还可以直接基于Spring Boot为图片服务系统集成一套服务治理框架Spring Cloud,但经过这个实例之前的文章内容讨论,您会发现这样做涉嫌过度设计——除非您的团队已经搭建了Spring Cloud基础应用环境,而图片服务只是作为一个服务提供节点注册到现有顶层系统中。
针对大多数TO C端的互联网系统,图片服务系统虽然属于一种顶级子系统(但也不一定,例如某些LBS系统就不怎么依赖于图片),但它必须依附于顶层系统的全局规划。而且图片服务系统对外暴露的服务接口太少,按照目前的功能规划,它对外暴露的服务接口就只包括三个:单一图片上传、批量上传和图片显示/下载。将为最顶层全局规划服务的Spring Cloud Eureka Server(服务发现/注册)放置到图片服务系统中既没有必要也不合理。
在之前的文章中,我们还从技术功能的角度讨论了为什么图片服务的路由层采用Nginx而不是Spring Cloud Zuul的原因。这里我们再从系统结构层面再进行以下补充:Spring Cloud Zuul着眼于系统服务和系统服务间的调用,其作用在于系统服务间的服务治理,而非单一系统内部的调用;另外,Spring Cloud Zuul最好配合Spring Cloud的其它组件进行使用,例如Spring Cloud Security、Spring Cloud Netfix、Spring Cloud Eureka,而在单一系统内部单独使用Spring Cloud Zuul不能发挥它的效用。
最后,基于Spring Boot构建图片服务工程,也是为了后续的工程部署过程能够灵活选用运行环境和集成环境。例如Spring Boot Web比起传统的Spring Web工程更能简便的在微服务组件上运行,如在Docker微服务容器上运行;再例如,如果您所在公司以后决定向Spring Cloud服务治理架构做技术转型,那么图片服务也可以方便的进行升级,直接将自己提供的服务向Spring Cloud Eureka注册即可供其它的子系统服务使用。
以上技术特性都是图片服务的第一个版本需要实现的,但在后续为了完善图片服务功能可以为图片服务增加一些新的技术特性。
上文中我们介绍技术选型时提到,将选用一款分布式文件系统作为对原始图片文件进行持久存储的技术选型,并且使用高性能的SSD固态磁盘 + 磁盘阵列作物分布式文件系统的物理层支持。这样一来系统I/O的吞吐量,特别是读操作的吞吐量比起简单的本地块存储方案会得到很高的提升。那么我们还有没有其它方法继续提高持久化存储层的数据读性能呢?我们首先来分析一下图片系统中数据读取和缓存的一些特点:
面向C端的系统有一些共同的特点,就拿图片服务来说吧,一个商品页面上会有很多图片,一般来说它们都不是原始图片(就是存放在图片上1MB到2MB的原图),都是按照一定的要求被缩放、被加印甚至被旋转的;它们使用的特效模式也基本上是不变的,例如A图片在界面上被以0.8的比例缩放,那么同一页面上的B图片肯定就是以0.7的比例被缩放;最后,由于它们会在物理磁盘上被同时读取,所以它们在各级缓存中存在的时间基本上也是一致的,当某张图片过期时其它图片也会过期;
那么我们可以使用预读的概念,将这些有读取关系图片从文件系统上一次性读取出来,这样对文件系统的读操作效率优于一次只读取一个文件的操作效率。预读的概念我们在本专题介绍块存储的文章中介绍过一次(读者可以参考《架构设计:系统存储(1)——块存储方案(1)》)。预读技术基于局部性原理,这是说计算机上某些相关部分的资源,都会存在于一个集中的区域,CPU寄存器、内存地址、磁盘数据等等,当某个资源X被处理时,和它临近的若干资源也即将被处理。这个概念可以被运用到图片处理上:如果某张页面上的图片A被读取时,这张图片上的其它图片也将同时被读取。
由于我们的图片系统并没有集成持久化数据库技术,所以无法记录某一个文件和哪些文件存在读取联系。而且即使能够记录这些原始文件的上传关系,也不能作为文件预读的依据——因为客户端请求图片信息时并不是请求原始图片,而是请求经过特效处理后的图片,也就是说图片C经过特效处理后的图片C1,和图片D经过特效处理后的图片D1才存在读取关联。
这样的图片读取关系显然只能通过对图片读取请求的持续分析才能得出,而这个分析源头可以基于Nginx层access.log日志,而分析的手段可以基于类似Hadoop MapReduce这样的离线/延迟分析手段。分析过程也很好理解,即按照10毫秒为单位以某一个访问路径为参照(带特效参数的),对后续又再次出现了这个访问路径的毫秒范围内的所有访问路径取交集,交集运算次数越多,得到的图片读取关系就越准确:
这样系统就可以得出,当图片A经过特效X处理后,紧接着最有可能会读取的其它文件和需要加载的特效,这样依赖图片系统就可以对后续的图片进行预读并在完成特效处理存储到缓存系统中。这个图片关联关系的分析工作计算量比较大,以上只是计算的某一个文件的关联情况,试想一下所有的图片都要进行类似的分析过程,然后还要过滤出重复的分析数据,所以只有依靠大数据分析手段完成。
我们好像一直没有讨论过图片的删除问题,实际上并不是所有的图片系统都需要图片删除功能,甚至有些系统还会特别说明所有的原始图片都要进行永久保存。但如果图片系统的存储容量确实有限,并且团队暂时没有太多资金进行存储扩容,那么删除一些不再使用的图片就是一个节约存储容量的好办法。但关键问题是,怎样判断图片不再使用呢?
最直观的思路是,按照图片上传时间向后推导3至6个月的时间到一个固定的时间点,如果超过这个固定时间点就将这张图片删除。但这样做的话图片系统并不能确定这些图片在后续的时间不会被请求者访问,例如一些畅销商品甚至会保持1年以上的销售热度。还有一种删除思路,是由客户端自行进行删除操作,例如当一个商品下架时同时删除商品图片。但这样做也有问题,因为后续运营团队可能还会在进行后期销售总结是访问这个商品的快照信息,这时也会同时查看这个商品的图片。
那么怎样删除才是较合理的呢?首先是删除时机的问题,显然给定一个固定的时间长度作为删除依据是不满足要求的,时间长度的选择应该是动态的:利用数据处理工具分析出当前某张图片最后一次访问时间,如果当前时间离该图片最后一次访问时间大于规定的阈值(例如3个月、6个月等值),就启动删除过程。另外从删除策略上来说,一张图片的删除不能不留余地的直接删除原始图片本身,一张原始图片的大小在1MB——2MB左右(可能还会更大,这完全取决于系统提供的图片上传功能中对图片大小的限制问题),而一张经过特效处理后的图片大小在100KB——300KB左右(可能还会更小,这完全看特效处理的情况),所以这里可以采用渐进式删除的方式。当然如果发现这样原始图片存在多种特效处理规则,并且经过这些规则处理后的图片大小总和已经大于原始图片的大小了,则可以跳过渐进式删除过程:
通过删除原始图片替换保留特效结果文件,可以有效防止原始图片删除后用户零星访问的空窗期。待到一个更长的,再无任何图片访问请求的时间期后,最终将图片所有的存储痕迹全部抹去,这时如果用户再进行访问就会出现图片已过期的提示。通过渐进式删除过程,一般可以在删除的第一阶段腾出20%——30%左右的存储容量,而且不会对用户后续的零星访问造成任何影响(但不在允许用户设定新的特效了),最后在保证用户有90%以上的几率没有再次访问该图片的可能后,在对图片进行正式删除。渐进式删除不适合所有的图片服务,本文还是建议在存储容量充足、集群服务性能足够的情况下对原始图片进行永久保存(至少3——5年)。
===================
(下文我们将对图片工程中重要的代码片段进行讲解)