2023-07-28
原文作者:说好不能打脸 原文地址:https://yinwj.blog.csdn.net/article/details/54016015

3-5、其它技术选型说明

3-5-1、关于关系型数据库

关于持久化存储的数据库技术要注意一点,实际上它并不是图片服务的必要组件。例如,我们在进行设计时可以将图片访问的URL地址直接对应图片文件在服务器上的存储地址,并按照一定的规则将图片文件重命名成一个系统中唯一的文件名,最后再删除Redis和Nginx Proxy Cache中可能存在的历史文件数据。这样就算没有数据库技术,也可以保证图片服务正常工作。

但是在上文描述的图片服务需求中,产品团队还明确要求需要对用户上传的规律、活跃度等状态信息进行统计,需要对图片的物理磁盘读操作频度进行统计分析,所以一些结构化的数据还是需要做持久化存储的。就拿图片的每日访问情况来说,当我们为通过Flmue收集了Nginx的访问日志,并送入到一个独立的日志分析系统中进行处理后,类似单个用户每一天对图片数据的访问数量、每张图片在每一天的访问数量这样的分析结果还是要存入到数据库中以备后续的统计分析——无论是选用关系型数据库MySQL、SQL Server或者非关系型数据库Mongodb、Apache Cassandra。

202307282258266861.png

也就是说,实际上要实现完整的图片服务系统的话还是离不开使用数据库技术的,但是这个基本上属于一个边缘选型,完全可以根据您所在公司某种默认规定的数据库技术作为依据。

3-5-2、关于Nginx的补充

  • 关于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通道,后文会讲到);最后,这个图片服务系统是一个对本专题和其他几个专题所讲解的架构知识的综合应用,当然最好介绍一下自己编程做做图片处理的相关知识。

4、关键技术点考量

4-1、图片处理中的责任链模式

在图片处理系统的首个版本中,我们计划先提供诸如图片等比例缩放、图片中心点裁剪、图片白化、图片文字水印等基本功能,但是为了保证软件设计部分能够在后续版本方便进行功能扩展,我们需要找到一种符合功能特点的行为模式,作为基本的设计模式。

首先,我们在首期提供的这些功能并不能要求使用者(客户端)按照某种操作顺序执行,而应该由使用者自行确定操作顺序。什么意思呢?我们不能规定使用者必须先缩放图片才能为图片添加水印,也不能规定要进行图片白化,就不能进行图片裁剪。而应该让使用者像使用PS软件一样——可以首先进行图片裁剪,然后再进行白化,最后再添加水印;也可以先向图片增加文字水印,然后再进行图片等比例缩放操作。我们可以用以下的一张概要图表示这里文字描述的内容:

202307282258274812.png

从缓存中取得图片这一步的注意事项我们将在下一节中进行介绍,这里我们先看读取原始图片后的处理过程。从上图我们可以看到读取完成后的图片各种处理组合,有一点类似于生产线的概念,每一个图片处理器接收到上一个处理器的产品后,再按照自己的处理逻辑进行处理,最后输出到下一个处理器,这种表象性的处理特点符合一个典型的责任链模式所适应的处理场景。在责任链模式里,若干实际的处理过程被串成一个链式结构,数据在上一个处理器中被处理后传递到下一个处理器,每个处理器都按照自己的业务逻辑规则处理数据。如果责任链中某个处理器处理失败可以通过返回null或者抛出异常等方式通知整个责任链停止处理。

202307282258281363.png

我们在Http Servlet中使用的Filter就是一种责任链模式,在Netty中的ChannelHandler也是基于责任链模式进行构造的。另外,责任链模式还有很多变种/结构类似的设计模式,例如命令模式和装饰器模式。在本示例的图片服务系统中,我们可以参照客户端传递的参数,来构造相应的执行顺序。例如:当客户端传递zoom|0.8->cut|400|640参数时,表示先按照原始图片的80%缩小整张图片,再按照宽400像素高640像素以图片中心为基点进行图片裁剪;当客户单传递mark|aHR0cDovL2Jsb2cuY3Nkbi5uZXQveWlud2Vuamll->cut|800|640参数时,表示先在图片上加文字水印,文字信息为编码前的值,然后在按照宽800像素高640像素以图片中心为基点进行图片裁剪。

4-2、Redis中的数据结构

上篇文章中我们介绍到,图片服务系统将使用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个文件分段。

202307282258288694.png

当读取缓存文件时,客户端会首先去Redis Cluster上读取缓存图片的大小,以便重新确定文件有几个分片,然后再到Redis Cluster中读取每个数据分片。注意,虽然每个图片数据分片都设置了同样的过期时间,但由于每个节点的实际工作状态不同,所以还是可能出现某些分片数据读取失败,这个时候如果任何一个分片读取失败就认为整个读取过程失败。如果出现这样的情况,图片系统就会到最下层的分布式文件系统上读取数据。

另外在前文中我们介绍过的Redis单节点的性能配置注意事项,都需要应用到Redis Clusters的配置中,例如停止Redis节点的主动快照和AOF记录功能,调高操作系统最大文件副本数量,调整redis的backlog参数项等等(详细讨论请参见《架构设计:系统存储(15)——Redis基本概念和安装使用》、《架构设计:系统存储(16)——Redis事件订阅和持久化存储》)。最后,由于我们预计会使用Redis最基本的K-V结构存储数据,所以配置信息中关于“紧凑型数据结构”的配置项就不需要多做调整了。

4-3、使用Spring Boot

在后续的文章中,我们将对图片服务工程中重要的代码片段进行演示,例如多个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、启动线程、预处理数据等等资源。为了方便读者阅读源代码下图给出了整个工程结构,后续的文章还会给出工程中重要的代码片段:

202307282258294505.png

您还可以直接基于Spring Boot为图片服务系统集成一套服务治理框架Spring Cloud,但经过这个实例之前的文章内容讨论,您会发现这样做涉嫌过度设计——除非您的团队已经搭建了Spring Cloud基础应用环境,而图片服务只是作为一个服务提供节点注册到现有顶层系统中。

202307282258302766.png

  • 针对大多数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注册即可供其它的子系统服务使用。

4-4、其它技术特性

以上技术特性都是图片服务的第一个版本需要实现的,但在后续为了完善图片服务功能可以为图片服务增加一些新的技术特性。

4-4-1、预读

上文中我们介绍技术选型时提到,将选用一款分布式文件系统作为对原始图片文件进行持久存储的技术选型,并且使用高性能的SSD固态磁盘 + 磁盘阵列作物分布式文件系统的物理层支持。这样一来系统I/O的吞吐量,特别是读操作的吞吐量比起简单的本地块存储方案会得到很高的提升。那么我们还有没有其它方法继续提高持久化存储层的数据读性能呢?我们首先来分析一下图片系统中数据读取和缓存的一些特点:

面向C端的系统有一些共同的特点,就拿图片服务来说吧,一个商品页面上会有很多图片,一般来说它们都不是原始图片(就是存放在图片上1MB到2MB的原图),都是按照一定的要求被缩放、被加印甚至被旋转的;它们使用的特效模式也基本上是不变的,例如A图片在界面上被以0.8的比例缩放,那么同一页面上的B图片肯定就是以0.7的比例被缩放;最后,由于它们会在物理磁盘上被同时读取,所以它们在各级缓存中存在的时间基本上也是一致的,当某张图片过期时其它图片也会过期;

那么我们可以使用预读的概念,将这些有读取关系图片从文件系统上一次性读取出来,这样对文件系统的读操作效率优于一次只读取一个文件的操作效率。预读的概念我们在本专题介绍块存储的文章中介绍过一次(读者可以参考《架构设计:系统存储(1)——块存储方案(1)》)。预读技术基于局部性原理,这是说计算机上某些相关部分的资源,都会存在于一个集中的区域,CPU寄存器、内存地址、磁盘数据等等,当某个资源X被处理时,和它临近的若干资源也即将被处理。这个概念可以被运用到图片处理上:如果某张页面上的图片A被读取时,这张图片上的其它图片也将同时被读取。

202307282258309727.png

由于我们的图片系统并没有集成持久化数据库技术,所以无法记录某一个文件和哪些文件存在读取联系。而且即使能够记录这些原始文件的上传关系,也不能作为文件预读的依据——因为客户端请求图片信息时并不是请求原始图片,而是请求经过特效处理后的图片,也就是说图片C经过特效处理后的图片C1,和图片D经过特效处理后的图片D1才存在读取关联。

这样的图片读取关系显然只能通过对图片读取请求的持续分析才能得出,而这个分析源头可以基于Nginx层access.log日志,而分析的手段可以基于类似Hadoop MapReduce这样的离线/延迟分析手段。分析过程也很好理解,即按照10毫秒为单位以某一个访问路径为参照(带特效参数的),对后续又再次出现了这个访问路径的毫秒范围内的所有访问路径取交集,交集运算次数越多,得到的图片读取关系就越准确:

202307282258317028.png

这样系统就可以得出,当图片A经过特效X处理后,紧接着最有可能会读取的其它文件和需要加载的特效,这样依赖图片系统就可以对后续的图片进行预读并在完成特效处理存储到缓存系统中。这个图片关联关系的分析工作计算量比较大,以上只是计算的某一个文件的关联情况,试想一下所有的图片都要进行类似的分析过程,然后还要过滤出重复的分析数据,所以只有依靠大数据分析手段完成。

4-4-2、图片删除

我们好像一直没有讨论过图片的删除问题,实际上 并不是所有的图片系统都需要图片删除功能,甚至有些系统还会特别说明所有的原始图片都要进行永久保存 。但如果图片系统的存储容量确实有限,并且团队暂时没有太多资金进行存储扩容,那么删除一些不再使用的图片就是一个节约存储容量的好办法。但关键问题是,怎样判断图片不再使用呢?

最直观的思路是,按照图片上传时间向后推导3至6个月的时间到一个固定的时间点,如果超过这个固定时间点就将这张图片删除。但这样做的话图片系统并不能确定这些图片在后续的时间不会被请求者访问,例如一些畅销商品甚至会保持1年以上的销售热度。还有一种删除思路,是由客户端自行进行删除操作,例如当一个商品下架时同时删除商品图片。但这样做也有问题,因为后续运营团队可能还会在进行后期销售总结是访问这个商品的快照信息,这时也会同时查看这个商品的图片。

那么怎样删除才是较合理的呢?首先是删除时机的问题,显然给定一个固定的时间长度作为删除依据是不满足要求的,时间长度的选择应该是动态的:利用数据处理工具分析出当前某张图片最后一次访问时间,如果当前时间离该图片最后一次访问时间大于规定的阈值(例如3个月、6个月等值),就启动删除过程。另外从删除策略上来说,一张图片的删除不能不留余地的直接删除原始图片本身,一张原始图片的大小在1MB——2MB左右(可能还会更大,这完全取决于系统提供的图片上传功能中对图片大小的限制问题),而一张经过特效处理后的图片大小在100KB——300KB左右(可能还会更小,这完全看特效处理的情况),所以这里可以采用渐进式删除的方式。当然如果发现这样原始图片存在多种特效处理规则,并且经过这些规则处理后的图片大小总和已经大于原始图片的大小了,则可以跳过渐进式删除过程:

202307282258323949.png

通过删除原始图片替换保留特效结果文件,可以有效防止原始图片删除后用户零星访问的空窗期。待到一个更长的,再无任何图片访问请求的时间期后,最终将图片所有的存储痕迹全部抹去,这时如果用户再进行访问就会出现图片已过期的提示。通过渐进式删除过程,一般可以在删除的第一阶段腾出20%——30%左右的存储容量,而且不会对用户后续的零星访问造成任何影响(但不在允许用户设定新的特效了),最后在保证用户有90%以上的几率没有再次访问该图片的可能后,在对图片进行正式删除。渐进式删除不适合所有的图片服务,本文还是建议在存储容量充足、集群服务性能足够的情况下对原始图片进行永久保存(至少3——5年)。

===================
(下文我们将对图片工程中重要的代码片段进行讲解)

阅读全文