综述

2011年李彦宏在百度联盟峰会上就提到过互联网的读图时代已经到来[1],图片服务早已成为一个互联网应用中占比很大的部分,对图片的处理能力也相应地变成企业和开发者的一项基本技能。需要处理海量图片的典型应用有:
1. 图片类应用,如百度相册。
2. 导购类应用,如Guang.com。
3. 电商类应用,如淘宝。
4. 云存储服务,如七牛云存储。
除此之外几乎所有的网站都需要考虑自己图片处理的解决方案,以免在流量变大之后显得手足无措。
本文将从作者自己设计完成的图片服务程序zimg的设计思路出发,探讨高性能图片服务器的特点、难点和应对办法。

主要问题

要想处理好图片,需要面对的三个主要问题是:大流量,高并发,海量存储。下面将逐一进行讨论。

大流量

除了那些拥有自己数据中心的大型企业,中小型企业都需要考虑到流量问题,因为流量就是成本,图片相对于文本来说流量增加了一个数量级,省下的每一个字节都是白花花的银子。我曾经在一篇博客[2]里看到,作者在业务逻辑中引入PHP的imagick模块进行压缩,短短几行代码就做到了每个月为公司节省2万人民币的效果,可见凡是涉及到图片的互联网应用,都应该统筹规划,降低流量节约开支。

高并发

高并发的问题在用户量较低时几乎不会出现,但是一旦用户攀升,或者遇到热点事件,比如淘宝的双十一,或者网站被人上传了一张爆炸性的新闻图片,短时间内将会涌入大量的浏览请求,如果架构设计得不好,又没有紧急应对方案,很可能导致大量的等待、更多的页面刷新和更多请求的死循环。总的来说,就是要把图片服务的性能做得足够好。

海量存储

在2012年的介绍Facebook图片存储的文章[3]里提到,当时Facebook用户上传图片15亿张,总容量超过了1.5PB,这样的数量级是一般企业无法承受的。虽然我们很难做出一个可以跟Facebook比肩的应用,但是从架构设计的角度来说,良好的拓展方案还是要有的。我们需要提前设计出最合适的海量图片数据存储方案和操作方便的拓容方案,以应对将来不断增长的业务需求。

以上三个问题,其实也是相互制约和钳制的,比如要想降低流量,就需要大量的计算,导致请求处理时间延长,系统单位时间内的处理能力下降;再比如为了存储更多的图片,必然要在查找上消耗资源,同样也会降低处理能力。所以,图片服务虽然看起来业务简单,实际做起来也不是一件小事。

设计方案

zimg是作者针对图片处理服务器而设计开发的开源程序,它拥有很高的性能,也满足了应用在图片方面最基本的处理需求,下面将从架构设计、代码逻辑和性能测试等方面进行介绍。

总体思路

想要在展现图片这件事情上有最好的表现,首先需要从整体业务中将图片服务部分分离出来。使用单独的域名和建立独立的图片服务器有很多好处,比如:
1. CDN分流。如果你有注意的话,热门网站的图片地址都有特殊的域名,比如微博的是ww1.sinaimg.cn,人人的是fmn.xnpic.com等等,域名不同可以在CDN解析的层面就做到非常明显的优化效果。
2. 浏览器并发连接数限制。一般来说,浏览器加载HTML资源时会建立很多的连接,并行地下载资源。不同的浏览器对同一主机的并发连接数限制是不同的,比如IE8是10个,Firefox是30个。如果把图片服务器独立出来,就不会占用掉对主站连接数的名额,一定程度上提升了网站的性能。
3. 浏览器缓存。现在的浏览器都具有缓存功能,但是由于cookie的存在,大部分浏览器不会缓存带有cookie的请求,导致的结果是大量的图片请求无法命中,只能重新下载。独立域名的图片服务器,可以很大程度上缓解此问题。

图片服务器被独立出来之后,会面临两个选择,主流的方案是前端采用Nginx,中间是PHP或者自己开发的模块,后端是物理存储;比较特别一些的,比如Facebook,他们把图片的请求处理和存储合并成一体,叫做haystack,这样做的好处是,haystack只会处理与图片相关的请求,剥离了普通http服务器繁杂的功能,更加轻量高效,同时也使部署和运维难度降低。
zimg采用的是与Facebook相似的策略,将图片处理的大权收归自己所有,绝大部分事情都由自己处理,除非特别必要,最小程度地引入第三方模块。
注:zimg的1.0版本,设计面向图片量在TB级别的中小型服务,物理存储暂时不支持分布式集群,分布式功能将在2.0版本中完成。

架构设计

为了极致的性能表现,zimg全部采用C语言开发,总体上分为三个层次,前端http处理层,中间图片处理层和后端的存储层。下图为zimg架构设计图:
总体架构

http处理层引入基于libevent的libevhtp库,libevhtp是一款专门处理基本http请求的库,它太适合zimg的业务场景了,在性能和功能之间找到了很好的平衡点。图片处理层采用imagemagick库,imagemagick是现在公认功能最强,性能最好的图片处理函数库。存储层采用memcached缓存加直接读写硬盘的方案,更加深入的优化将在后续进行,比如引入TFS[4]等。为了避免数据库带来的性能瓶颈,zimg不引入结构化数据库,图片的查找全部采用哈希来解决。
事实上图片服务器的设计,是一个在I/O与CPU运算之间的博弈过程,最好的策略当然是继续拆:CPU敏感的http和图片处理层部署于运算能力更强的机器上,内存敏感的cache层部署于内存更大的机器上,I/O敏感的物理存储层则放在配备SSD的机器上,但并不是所有人都能负担得起这么奢侈的配置。zimg折中成本和业务需求,目前只需要部署在一台服务器上。由于不同服务器硬件不同,I/O和CPU运算速度差异很大,很难一棒子定死。zimg所选择的思路是,尽量减少I/O,将压力放在CPU上,事实证明这样的思路基本没错,在硬盘性能很差的机器上效果更加明显;即使以后SSD全面普及,CPU的运算能力也会相应提升,总体来说zimg的方案也不会太失衡。

代码层面

虽然zimg在二进制实体上没有分模块,上面已经提到了原因,现阶段面向中小型的服务,单机部署即可,但是代码上是分离的,下面介绍主要部分的功能和实现,更详细的内容可以从github上拉下来研究。热烈欢迎大家fork和contribute。

main.c是程序的入口,主要功能是处理启动参数,部分参数功能如下:

-p [port] 监听端口号,默认4869
-t [thread_num] 线程数,默认4,请调整为具体服务器的CPU核心数
-k [max_keepalive_num] 最高保持连接数,默认1,不启用长连接,0为启用
-l 启用log,会带来很大的性能损失,自行斟酌是否开启
-M [memcached_ip] 启用缓存的连接IP
-m [memcached_port] 启用缓存的连接端口
-b [backlog_num] 每个线程的最大连接数,默认1024,酌情设置

zhttpd.c是解析http请求的部分,分为GET和POST两大部分,GET请求会根据请求的URL参数去寻找图片并转给图片处理层处理,最后将结果返回给用户;POST接收上传请求然后将图片存入计算好的路径中。
为了实现zimg的总体设计愿景,zhttpd承担了很大部分的工作,也有一些关键点,下面捡重点的说一下:

在zimg中图片的唯一Key值就是该图片的MD5,这样既可以隐藏路径,又能减少前端(指zimg前面的部分,可能是你的应用服务器)和zimg本身的存储压力,是避免引入结构化存储部分的关键,所以所有GET请求都是基于MD5拼接而成的。
大家设想一下,假如你的网站某个地方需要展示一张图片,这个图片原图的大小是1000*1000,但是你想要展示的地方只有300*300,你会怎么做呢?一般还是依靠CSS来进行控制,但是这样的话就会造成很多流量的浪费。为此,zimg提供了图片裁剪功能,你所需要做的就是在图片URL后面加上w=300&h=300(width和height)即可。
另一个情景是图片灰白化,比如某天遇到重大自然灾害,想要网站所有图片变成灰白的,那么只需在图片URL后面再加上g=1(gray)即可。
当然,依托于imagemagick所提供的完善的图片处理函数,zimg将在后续版本中逐步增加功能,比如加水印等。

在图片上传部分,其实能玩的花样很少,但是编写代码所消耗的时间最多。现在我们再假设一种情景,如果我们的图片服务器前端采用Nginx,上传功能用PHP实现,需要写的代码很少,但是性能如何呢,答案是很差。首先PHP接收到Nginx传过来的请求后,会根据http协议(RFC1867)分离出其中的二进制文件,存储在一个临时目录里,等我们在PHP代码里使用$_FILES["upfile"][tmp_name]获取到文件后计算MD5再存储到指定目录,在这个过程中有一次读文件一次写文件是多余的,其实最好的情况是我们拿到http请求中的二进制文件(最好在内存里),直接计算MD5然后存储。
于是我去阅读了PHP的源代码,自己实现了POST文件的解析,让http层直接和存储层连在了一起,提高了上传图片的性能。关于RFC1867的内容和PHP是如何处理的,感兴趣的读者可以去搜索了解下,这里推荐@Laruence的文章《PHP文件上传源码分析(RFC1867) 》
除了POST请求这个例子,zimg代码中有多处都体现了这种“减少磁盘I/O,尽量在内存中读写”和“避免内存复制”的思想,一点点的积累,最终将会带来优秀的表现。

zimg.c是调用imagemagick处理图片的部分,这里先解释一下在zimg中图片存储路径的规划方案。
上文曾经提到,现阶段zimg服务于存储量在TB级别的单机图片服务器,所以存储路径采用2级子目录的方案。由于Linux同目录下的子目录数最好不要超过2000个,再加上MD5的值本身就是32位十六进制数,zimg就采取了一种非常取巧的方式:根据MD5的前六位进行哈希,1-3位转换为十六进制数后除以4,范围正好落在1024以内,以这个数作为第一级子目录;4-6位同样处理,作为第二级子目录;二级子目录下是以MD5命名的文件夹,每个MD5文件夹内存储图片的原图和其他根据需要存储的版本,假设一个图片平均占用空间200KB,一台zimg服务器支持的总容量就可以计算出来了:

1024 * 1024 * 1024 * 200KB = 200TB

这样的数量应该已经算很大了,在200TB的范围内可以采用加硬盘的方式来拓容,当然如果有更大的需求,请期待zimg后续版本的分布式集群存储支持。
除了路径规划,zimg另一大功能就是压缩图片。从用户角度来说,zimg返回来的图片只要看起来跟原图差不多就行了,如果确实需要原图,也可以通过将所有参数置空的方式来获得。基于这样的条件,zimg.c对于所有转换的图片都进行了压缩,压缩之后肉眼几乎无法分辨,但是体积将减少67.05%。具体的处理方式为:

图片裁剪时使用LanczosFilter滤镜;
以75%的压缩率进行压缩;
去除图片的Exif信息;
转换为JPEG格式。

经过这样的处理之后可以很大程度的减少流量,实现设计目标。

zcache.c是引入memcached缓存的部分,引入缓存是很重要的,尤其是图片量级上升之后。在zimg中缓存被作为一个很重要的功能,几乎所有zimg.c中的查找部分都会先去检查缓存是否存在。比如:
我想要a(代表某MD5)图片裁剪为100*100之后再灰白化的版本,那么过程是先去找a&w=100&h=100&g=1的缓存是否存在,不存在的话去找这个文件是否存在(这个请求所对应的文件名为 a/100*100pg),还不存在就去找这个分辨率的彩色图缓存是否存在,若依然不存在就去找彩色图文件是否存在(对应的文件名为 a/100*100p),若还是没有,那就去查询原图的缓,原图缓存依然未命中的话,只能打开原图文件了,然后开始裁剪,灰白化,然后返回给用户并存入缓存中。
可以看出,上面过程中如果某个环节命中缓存,就会相应地减少I/O或图片处理的运算次数。众所周知内存和硬盘的读写速度差距是巨大的,那么这样的设计对于热点图片抗压将会十分重要。

除了上述核心代码以外就是一些支持性的代码了,比如log部分,md5计算部分,util部分等。

性能测试

为了横向对比zimg的性能,我用PHP写了一个功能一模一样的后端,仅用时一下午,这充分证明了“PHP是世界上最好的语言”,也同时说明了用C语言来进行开发是多么的辛苦,不过,我喜欢性能测试结果出来之后的那份成就感,这样的付出我觉得是值得的。

测试方案

采用Apache自带的测试程序ab对指定请求进行测试,在特定并发数100的情况下进行10w个请求的测试,结果依据该并发下每秒处理请求数来定性,对比的方案是未启用缓存的zimg,启用缓存的zimg和Nginx+PHP,其中zimg端口为4868,Nginx端口为80。

测试命令分别为:

ab2 -c 100 -n 100000 http://127.0.0.1:4869/5f189d8ec57f5a5a0d3dcba47fa797e2  
ab2 -c 100 -n 100000 http://127.0.0.1:80/zimg.php?md5=5f189d8ec57f5a5a0d3dcba47fa797e2  
ab2 -c 100 -n 100000 http://127.0.0.1:4869/5f189d8ec57f5a5a0d3dcba47fa797e2?w=100&h=100&g=1  
ab2 -c 100 -n 100000 http://127.0.0.1:80/zimg.php?md5=5f189d8ec57f5a5a0d3dcba47fa797e2&w=100&h=100&g=1

注:以下测试数据单位皆为rps(request per second)。

测试环境

操作系统:openSUSE 12.3
CPU:Intel Xeon E3-1230 V2
内存:8GB DDR3 1333MHz
硬盘:西部数据 1TB 7200转

软件版本

zimg:1.0.0
Nginx:1.2.9
PHP:5.3.17

测试结果

测试项目 zimg zimg+memcached Nginx+PHP
静态图片 2857.80 4995.95 426.56
动态裁剪图片 2799.34 4658.35 58.61

总的来说测试结果符合预期,纯C写成并且专门为图片而做了大量优化的zimg表现远远优于采用PHP的方案,性能有6-79倍的提升。

高压测试

在测试过程中由于php-fpm的性能瓶颈,导致并发压力根本压不上去,为了充分展现zimg面对超高并发的抗压能力,我又做了另一项对比测试,即单纯的echo测试。测试方法是在逐渐升高的并发压力下完成20w个echo请求,记录每种并发压力下的处理能力。硬件环境不变,这次所要对比的是业界以性能著称的Nginx,Nginx和zimg都是接收echo请求后返回简单的“It works!”页面,不做任何复杂的业务。

测试命令分别为:

ab2 -c 5000 -n 200000 http://127.0.0.1:4869/  
ab2 -c 5000 -n 200000 http://127.0.0.1:80/  

测试结果如下:

Concurrency zimg Nginx
100 32765.35 33412.12
300 32991.86 32063.05
500 31364.29 30599.07
1000 28936.67 28163.63
2000 27939.02 25124.51
3000 28168.56 22053.22
4000 28463.45 21464.88
5000 27947.37 13536.93
6000 27533.83 14430.21
7000 27502.03 14623.62
8000 26505.07 13389.28
9000 27124.89 13650.01
10000 27446.23 10901.13
11000 26335.22 10585.73
12000 27068.68 10461.54
13000 26798.55 8530.11
14000 26741.93 7628.09
15000 26556.54 9832.16
16000 26815.70 8018.44
17000 27811.33 7951.21
18000 25722.97 6246.00
19000 26730.02 8134.93
20000 27678.67 6106.95

这是一份有趣的数据,其实测试过程中,Nginx在并发1000开始已经出现了部分失败,在并发9000以后就无法完成20w个请求,通过不断降低请求数才勉强完成了测试。而强大的zimg毫无压力地完成了20000并发以内的所有测试,没有一个失败返回。为了直观地显示测试结果请参考下图:

测试结果

由于去掉了不需要的复杂功能,zimg在http处理层面要远比Nginx轻量,同时测试数据也说明了它的高并发抗压能力。能有这样的成绩则完全要归功于libevhtp项目,它比libevent自带的http库要优秀得多。在我设计zimg的早期版本时,选用了libevent自带的evhttp库,然后采用线程池的方式来实现多线程处理,结果发现在高压力之下问题频出,最后无奈放弃。该版本封存在github上的zimg_workqueue分支中,也算是一个纪念吧。

最后

图片服务器的设计方案多种多样,zimg也只是提供了其中的一种思路而已,它才刚刚诞生,以后还有很长的路要走,共同学习,共同进步。
在孤独而漫长的开发过程中,经常会遇到思维枯竭毫无头绪的时候,感谢好基友@Xscape给予大量建设性指导性意见;还有@喀啦喀拉在存储路径规划问题上提供的思路。
那么就用这样一句经典而充满力量的话作为结尾吧。

We stand on the shoulders of giants.

[1]:http://tech.qq.com/a/20110412/000168.htm 百度联盟峰会
[2]:http://hi.baidu.com/bjmayor/item/bdb066ddd08263f8cb0c39cd 此本的转载
[3]:http://www.biaodianfu.com/facebook-efficient-storage-of-billions-of-photos.html Facebook图片存储架构学习
[4]:https://github.com/alibaba/tfs TFS