"Believe it or not, the bigger problem isn't scaling, it's getting to the point where you have to scale. Without the first problem you won't have the second."
—— 《Getting Real - The smarter, faster, easier way to build a successful web application》, Chapter 04, "Scale Later"
请原谅我再一次把这句话作为题头,这句话在本系列开篇《踏上架构之路(一)——一个初创公司的产品架构思路》一文中已经提到过,但是它对我的影响实在是太大,也许它并不是100%正确,但是到目前为止,我仍然认为它适用于绝大多数的情况。它直接决定了我是怎么规划自己的产品架构的,尤其是后台服务的架构。这篇我就来讲讲从零开始到现在差不多10个月的时间里,我们这个初创产品的后台技术架构变化历程(事实上核心演变过程大概是6个月)。
首先再简要介绍一下整个项目的业务需求 —— 这是一个典型的B2B2C的平台服务系统:
整个平台逻辑上分为BBC三大块:第一个B,是商务合作伙伴方,第二个B就是平台本身,第三个C是终端用户。可以参考京东的模式。而从系统的业务核心分为内容服务和交易服务两大类。
Step 1:从一无所有到雏形 (第0天到第2个月)
这是起步阶段,一切以快速原型开发为目的,所以开发环境下技术架构很简单:
没什么好说的,一台Web服务器,一台数据库服务器。所有业务都是在这台Web服务器上完成。
Step 2:开始分库(第3个月)
已经提到过到这个阶段的时候,系统的核心业务流程已经基本走通,这个时候重点解决的问题是前期为了开发的简单而建立的单库系统将会带来诸多的问题。针对两大核心业务:内容和交易,必须要将数据库分离。即方便将来做读写分离,也方便基于数据不同的特征进行不同的优化。在《我是怎样做B2B2C交易流程》的文章中,有关于这个部分的细节。
所以这个阶段,数据库一分为2:
Step 3:云部署,区分环境(第3个月)
分库之后, 这个阶段大部分业务模块的代码原型已经基本成型,这个时候开始考虑将开发环境向生产测试环境转移。生产测试环境必须要提供外网链接能力,所以必须在云服务器上部署。部署到云服务器之前,考虑到不同的测试应用场景,我一共规划了4个不同的环境,分别对应4个不同的版本:
- Dev:开发环境。在公司内部服务器搭建。是最简单的开发验证环境,相当于上述的Step 2结构。
- Beta:开发版外测环境,云服务器。是最新开发版的云端部署映射,主要用于C端用户外测。
- Pre Release:稳定版外测环境,云服务器。是基于Beta之上的稳定测试版,主要用于B端用户和平台端测试,同时可以领先于移动端的进度提前获取B端用户的最新内容数据。
- Release:正式发布版,云服务器。是最终的生产环境,一部分内容数据从Pre Release环境里导出,另一部分则开始接收用户最新正式上传数据。
可以看到,Pre Release环境下的数据,是需要选择性的向Beta和Release环境导出的,并且由于有4个环境,数据库数据的迁移不可避免,并且这是一件非常繁琐并谨慎的操作。一开始阶段我们也非常头疼这件事情,后来终于有招入一个开发专门投入精力处理这方面的事情,将绝大多数的迁移工作固化成脚本,给整个迁移过程节省了大量的精力和时间,错误率也大大降低。这里需要指出,为了尽可能降低迁移的失败率,MySQL数据库表的设计和修改需要尽可能的谨慎,对于新的需求和功能,尽可能的做增加,避免删除字段操作,尤其是删除列字段;对于修改列属性的需求,尽可能的使用兼容Type,并提前做好预留字段。如果做不到,需要针对性的手工开发迁移脚本,做copy和对应的映射修改,否则Migration是一件非常坑人的事情。这部分工作,如果深入并铺开研究的话,其实是一个相当大的工程,有能力的公司和团队,应当在这个方面提前做足够的框架设计。
我们选择的是阿里云,对应于服务器和数据库,阿里云有ECS和RDS。与此同时,处于Beta测试和业务的需要,开始需要反向代理将不同的域名导入到后台不同的服务器,于是很自然的增加了Nginx服务器。于是现在的结构就是这样:
Step 4:文件服务器分离 (第4个月)
系统的主要内容数据为图片和文本。尤其是图片,因为业务的原因,90%以上的内容数据为高清图片,所以随着测试数据和内容数据的增大,文件服务的分离不可避免。考虑到成本和图片压缩的原因,云图片服务是不二之选。目前国内著名的主流云存储服务提供商都有图片处理服务,最著名的应该是七牛和阿里两家。网上有很多关于七牛和阿里的云存储方案的对比文章,这里有一篇示例:《云存储:阿里和七牛的比较》。基本上来说,我个人的体验是,如果是单纯的多媒体存储服务,阿里和七牛功能上没有太大区别,但是七牛的API相对而言比较简洁方便。另外有一点,七牛每个月有定额的免费容量,阿里则没有,所有的存储容量都按照时间收费(或者包月包年)。但是如果考虑到业务服务器需要频繁调用这些资源动态生成内容,而你的服务器正好又是部署在阿里云上,那么阿里的云存储OSS就有天生的优势了。最终我们项目的文件访问架构正好是这两种情况的结合:B端的内容使用阿里的OSS,C端的内容使用七牛。系统将B端提供的初始内容全部存储在阿里进行管理,B端的内容流量通过后台服务器输出;内容审核系统经过筛选检查之后将有效内容传送到七牛存储,C端系统的所有内容请求将分离至七牛处理,后台服务器不再承担任何终端用户的请求流量。通过这种双云端存储的分离,将并发访问量很低的B端请求放在后台系统内部,由阿里内部网络消化,即方便了各个服务部门之间的数据传输,又能够很好的利用阿里的图片处理服务给B端用户提供高质量内容管理服务;而把终端用户可能产生的高并发访问需求全部导流至七牛云服务,充分利用七牛CDN的优势。这里唯一需要重视和处理的部分,就是B端阿里云和C端七牛云之间的内容同步问题,我们单独写了一套发布模块来解决这个问题,在此不细表。也就是说,现在的系统架构是这样:
其中Nginx在这反向代理的作用非常明显,C端内容请求走cimg.domain.com子域名,B端内容请求走bimg.domain.com子域名,所有API请求走www.domain.com/* 主域名路径。
到目前为止,这套分离的双存储架构在我们的项目里运行的非常良好。
Step 5:缓存,动静分离 (第5个月)
小到手机客户端,大到大型分布式系统,缓存无处不在。但是什么时候设置缓存,如何设置缓存,每个系统都不同。一般而言,只要系统有大规模的明确的静态数据请求,那么都是可以设置缓存来提高系统的响应速度的;其次,对于一个典型的系统而言,缓存是多级的:应用层缓存,数据层缓存,网络层缓存。对于Web服务器而言,网络层缓存是可以最先考虑的,因为绝大多数请求的内容都是重复的静态数据,如CSS,Html,JS文件,而在网络层使用静态文件缓存一般而言是非常直接简单的操作,比如Nginx就自带了功能强大的静态文件缓存功能;其次,数据层缓存是要重点考虑的,因为非运算密集型的应用,绝大多数情况下系统性能瓶颈都是在数据库上,尤其是当数据库数据读写比例非常大的场景,因为并发读写数据库都需要同步,不管是在应用层加锁还是在数据层加锁,还是使用其他的锁同步措施,都会对读数据造成阻塞,所以数据库缓存是提高高并发读性能最直接也是最方便的方案(当然读写分离是另一种常规措施,这个后面也会提到)。而应用层缓存则是根据不同的业务不同的系统有针对性设计的,一般内嵌在具体业务逻辑里。
对于我们的系统而言,首先考虑的是Nginx的静态文件缓存,把所有的静态页面相关的CSS/JS/html文件全部从Web服务器上分离出来到Nginx上,提供单独的路由路径:www.domain.com/static/*; 其次把体积较大的JS文件/主页图片/图标文件等移到七牛云上,让服务器彻底解放出来只做类似渲染Web页面,响应API请求任务上。
其次,增加数据库缓存,将C端请求中内容相对固定的API放入缓存中,只有当请求内容变化时才请求数据库返回新数据。在我们的系统中,和B端相关的商品和资讯内容相对而言是固定不变的(或者变化极其缓慢的),对于匿名用户而言,这部分是几乎不会变化完全可以独立出来做缓存的;而和登录用户相关的数据一般是变化频繁的且没有共同性的,这部分不做缓存,直接请求数据库。在这里,如何设计API缓存的键值是一件挺有意思的事情,键值太复杂会造成匹配开销太大,键值太短会形成太多冲突。但是在系统初期,访问压力不大的情况下,可以优先考虑减少冲突而牺牲一定的键值匹配复杂度。我们的数据库缓存是使用阿里的OCS服务(现在改名叫云数据库 Memcache 版-AliCloudDB for Memcache),它的优势是自动进行负载均衡和伸缩,可用性也比较强。我们可能是阿里的OCS最早的一批实验客户之一,我们的后台是python的,在早期使用OCS的时候猜测其python客户端接口是建立在bmemcached的版本上的(不知道现在是不是这样)。OCS有个最大的问题(其实也是Memcache的问题)就是不能够遍历当前所有的有效key,这在实际的测试和开发过程中是带来了一定的不便的。但是那个时候还没有Redis版本,现在阿里同时推出了云数据库Redis版,如果对缓存有高要求的环境,应该直接使用云缓存的Redis版本。另外需要注意的是,OCS使用懒删除机制,所有失效的key会延迟到次日凌晨2点左右统一做删除,而在此之前,系统内的缓存key数量不会变化。
于是系统现在的样子是这样:
Step 6:分布式任务和队列(第5个月)
稍具规模的系统,都会有大量的非实时响应服务:比如离线统计,消息推送,延时状态更新等等。这些服务一般来说响应时间比较长,要么是因为运算量较大,要么是因为第三方服务接入网络延迟等等,总之它们不适合直接在业务请求中实时响应给用户,否则会造成非常长的等待时间,甚至连接被强制中断。在这个阶段中,我们的项目需要接入大量的第三方服务,比如短信推送,快递查询,还有一些自身业务要求的离线更新计算。这些任务封装后都交给离线服务器在后台独立处理。考虑到可扩展性,应用服务器是集群,离线任务服务器也可能是集群,那么这里实际上存在两个问题:分布式任务和进程间通信。我们的解决方案是使用Celery处理分布式任务,用RabbitMQ负责通信队列实现。于是现在系统有了新的扩展,具备分布式任务处理能力:
Step 7:主从数据库 (第6个月之后)
这个阶段整个系统的业务部分已经基本完成了。现在是开始搭建辅助系统的时候,在第一篇《踏上架构之路(一)——一个初创公司产品的系统架构思路》中已经提到过,一个完整的平台系统,除了主业务服务之外,还有管理监控,财务客服,统计报表等等诸多外围服务。对于我们而言,统计监控和财务系统是比较大的挑战。首先统计监控对性能的要求比较高,我们需要在API层级插入统计代码,但是又不能显著影响API的响应速度。这就需要完全基于缓存上做统计功能,我在第一篇中已经详细提到过这个问题,为了能够自己实现统计功能,新建了Redis缓存服务器来给整个系统提供内部逻辑缓存服务,而阿里的OCS主要用来提供内容数据缓存。后来这个Redis服务在我们的竞价系统中也起到了关键的作用,这在文章《架构和产品的制衡——说说竞价拍卖那点事》中有提到。
而对于财务客服等系统,我在《我是怎样做B2B2C交易流程的》一文中提到,由于交易类数据非常敏感,处于保护的目的,我不希望这些辅助系统和主业务系统同时混在一起对数据库进行操作。
出于以上考虑,我们对数据库进行主从备份。外围系统都主要操作从库,而主业务系统负责处理主库。并且财务系统和客服系统则在主从库上进行读写分离,所有只读操作都在从库中进行,当财务需要对交易数据进行更新时(比如更新分账,退款审核,汇款审核操等等操作),才将直接操作主数据库。统计系统顺其自然的使用从库进行数据存储。不对主库进行任何干扰。这样,当系统业务压力增大时,虽然监控统计的压力也会增大,但是他们会分离在两个独立的数据库中进行。
于是系统最终扩展成主从库模式,增加Redis缓存(队列)支持:
Step 8:API优化
到此为止,上述一直讨论的都是整个系统的体系架构。那么细化到具体的接口设计上,有没有结构上的调整呢?答案是肯定的,但是这个多半情况涉及业务细节,在此暂不表述。不过有时间的话我会再写一篇聊一聊项目后期,为了提高系统缓存的效率和改善移动客户端代码的结构,我们对系统的主要API接口做的重构和优化。这些优化设计并不是万能的,也并一定就是绝对正确的,只是在特定的阶段为了特定的目的做的改动。“存在即合理”,这个世界上任何一种设计都有自己存在理由和价值,只不过是不是适用具体场合罢了。
但是在这写上Step 8的标题的目的是想说,虽然本文主要描述的是技术架构上的变化(其实更多偏重的是部署体系架构),但其实系统架构的含义很广,不同的层次具有不同的意义。系统层面有本文描述的技术架构,业务层面有模块设计的架构,代码层面有API的架构等等。但是它们都反应了一个事实:任何项目的成熟,都不开各个层面架构的不断更新和完善,有时候甚至是彻底的重构。但是,在恰当的阶段做最恰当的设计,用最小的资源去完成最需要完成的任务,是我做架构设计的核心宗旨。
结语:
开篇的那句引用,其实在它之前还有另一句话,我想拿来做结语:
“In the beginning, make building a solid core product your priority instead of obsessing over scalability and server farms.
Create a great app and then worry about what to do once it's wildly successful.
Otherwise you may waste energy, time, and money fixating on something that never even happens.” —— 《Getting Real - The smarter, faster, easier way to build a successful web application》, Chapter 04, "Scale Later"
子曰:“君子不器”。架构是器,产品是心。真正需要打磨的,是心。
希望此篇能够帮助到你,也希望大家批评指正。
2016年3月,完稿于南京